475 lines
15 KiB
TypeScript
475 lines
15 KiB
TypeScript
import * as React from 'react'
|
|
import * as ReactDOM from 'react-dom'
|
|
import { Check, ChevronDown } from 'lucide-react'
|
|
|
|
import { cn } from '@/lib/utils'
|
|
|
|
type NativeSelectProps = React.ComponentProps<'select'>
|
|
|
|
type SelectOption = {
|
|
value: string
|
|
label: React.ReactNode
|
|
disabled: boolean
|
|
}
|
|
|
|
type MenuPlacement = 'top' | 'bottom'
|
|
|
|
function normalizeValue(value: NativeSelectProps['value'] | NativeSelectProps['defaultValue']) {
|
|
if (Array.isArray(value)) {
|
|
return value[0] == null ? '' : String(value[0])
|
|
}
|
|
|
|
return value == null ? '' : String(value)
|
|
}
|
|
|
|
function extractOptions(children: React.ReactNode) {
|
|
const options: SelectOption[] = []
|
|
|
|
React.Children.forEach(children, (child) => {
|
|
if (!React.isValidElement(child) || child.type !== 'option') {
|
|
return
|
|
}
|
|
|
|
const props = child.props as React.OptionHTMLAttributes<HTMLOptionElement> & {
|
|
children?: React.ReactNode
|
|
}
|
|
|
|
options.push({
|
|
value: normalizeValue(props.value),
|
|
label: props.children,
|
|
disabled: Boolean(props.disabled),
|
|
})
|
|
})
|
|
|
|
return options
|
|
}
|
|
|
|
function getFirstEnabledIndex(options: SelectOption[]) {
|
|
return options.findIndex((option) => !option.disabled)
|
|
}
|
|
|
|
function getLastEnabledIndex(options: SelectOption[]) {
|
|
for (let index = options.length - 1; index >= 0; index -= 1) {
|
|
if (!options[index]?.disabled) {
|
|
return index
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
function getNextEnabledIndex(options: SelectOption[], currentIndex: number, direction: 1 | -1) {
|
|
if (options.length === 0) {
|
|
return -1
|
|
}
|
|
|
|
let index = currentIndex
|
|
|
|
for (let step = 0; step < options.length; step += 1) {
|
|
index = (index + direction + options.length) % options.length
|
|
if (!options[index]?.disabled) {
|
|
return index
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
|
|
(
|
|
{
|
|
children,
|
|
className,
|
|
defaultValue,
|
|
disabled = false,
|
|
id,
|
|
onBlur,
|
|
onClick,
|
|
onFocus,
|
|
onKeyDown,
|
|
value,
|
|
...props
|
|
},
|
|
forwardedRef,
|
|
) => {
|
|
const options = React.useMemo(() => extractOptions(children), [children])
|
|
const isControlled = value !== undefined
|
|
const initialValue = React.useMemo(() => {
|
|
if (defaultValue !== undefined) {
|
|
return normalizeValue(defaultValue)
|
|
}
|
|
|
|
return options[0]?.value ?? ''
|
|
}, [defaultValue, options])
|
|
|
|
const [internalValue, setInternalValue] = React.useState(initialValue)
|
|
const [open, setOpen] = React.useState(false)
|
|
const [highlightedIndex, setHighlightedIndex] = React.useState(-1)
|
|
const [menuPlacement, setMenuPlacement] = React.useState<MenuPlacement>('bottom')
|
|
const [menuStyle, setMenuStyle] = React.useState<React.CSSProperties | null>(null)
|
|
|
|
const wrapperRef = React.useRef<HTMLDivElement>(null)
|
|
const triggerRef = React.useRef<HTMLButtonElement>(null)
|
|
const nativeSelectRef = React.useRef<HTMLSelectElement>(null)
|
|
const menuRef = React.useRef<HTMLDivElement>(null)
|
|
const optionRefs = React.useRef<Array<HTMLButtonElement | null>>([])
|
|
const menuId = React.useId()
|
|
|
|
const currentValue = isControlled ? normalizeValue(value) : internalValue
|
|
const selectedIndex = options.findIndex((option) => option.value === currentValue)
|
|
const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : options[0] ?? null
|
|
|
|
React.useEffect(() => {
|
|
if (!isControlled && options.length > 0 && !options.some((option) => option.value === internalValue)) {
|
|
setInternalValue(options[0]?.value ?? '')
|
|
}
|
|
}, [internalValue, isControlled, options])
|
|
|
|
const updateMenuPosition = React.useCallback(() => {
|
|
const trigger = triggerRef.current
|
|
if (!trigger) {
|
|
return
|
|
}
|
|
|
|
const rect = trigger.getBoundingClientRect()
|
|
const viewportPadding = 12
|
|
const gutter = 6
|
|
const estimatedHeight = Math.min(Math.max(options.length, 1) * 44 + 18, 320)
|
|
const spaceBelow = window.innerHeight - rect.bottom - viewportPadding
|
|
const spaceAbove = rect.top - viewportPadding
|
|
const openToTop = spaceBelow < estimatedHeight && spaceAbove > spaceBelow
|
|
const maxHeight = Math.max(120, Math.min(openToTop ? spaceAbove : spaceBelow, 320))
|
|
const width = Math.min(rect.width, window.innerWidth - viewportPadding * 2)
|
|
const left = Math.min(Math.max(rect.left, viewportPadding), window.innerWidth - width - viewportPadding)
|
|
|
|
setMenuPlacement(openToTop ? 'top' : 'bottom')
|
|
setMenuStyle(
|
|
openToTop
|
|
? {
|
|
left,
|
|
width,
|
|
maxHeight,
|
|
bottom: window.innerHeight - rect.top + gutter,
|
|
}
|
|
: {
|
|
left,
|
|
width,
|
|
maxHeight,
|
|
top: rect.bottom + gutter,
|
|
},
|
|
)
|
|
}, [options.length])
|
|
|
|
const setOpenWithHighlight = React.useCallback(
|
|
(nextOpen: boolean, preferredIndex?: number) => {
|
|
if (disabled) {
|
|
return
|
|
}
|
|
|
|
if (nextOpen) {
|
|
const fallbackIndex =
|
|
preferredIndex ??
|
|
(selectedIndex >= 0 && !options[selectedIndex]?.disabled
|
|
? selectedIndex
|
|
: getFirstEnabledIndex(options))
|
|
|
|
setHighlightedIndex(fallbackIndex)
|
|
updateMenuPosition()
|
|
setOpen(true)
|
|
return
|
|
}
|
|
|
|
setOpen(false)
|
|
},
|
|
[disabled, options, selectedIndex, updateMenuPosition],
|
|
)
|
|
|
|
const commitValue = React.useCallback(
|
|
(nextIndex: number) => {
|
|
const option = options[nextIndex]
|
|
const nativeSelect = nativeSelectRef.current
|
|
|
|
if (!option || option.disabled) {
|
|
return
|
|
}
|
|
|
|
if (!isControlled) {
|
|
setInternalValue(option.value)
|
|
}
|
|
|
|
if (nativeSelect && currentValue !== option.value) {
|
|
nativeSelect.value = option.value
|
|
nativeSelect.dispatchEvent(new Event('change', { bubbles: true }))
|
|
}
|
|
|
|
setOpen(false)
|
|
window.requestAnimationFrame(() => {
|
|
triggerRef.current?.focus()
|
|
})
|
|
},
|
|
[currentValue, isControlled, options],
|
|
)
|
|
|
|
const handleKeyDown = React.useCallback(
|
|
(event: React.KeyboardEvent<HTMLButtonElement | HTMLDivElement>) => {
|
|
onKeyDown?.(event as unknown as React.KeyboardEvent<HTMLSelectElement>)
|
|
if (event.defaultPrevented || disabled) {
|
|
return
|
|
}
|
|
|
|
switch (event.key) {
|
|
case 'ArrowDown': {
|
|
event.preventDefault()
|
|
if (!open) {
|
|
const nextIndex =
|
|
selectedIndex >= 0
|
|
? getNextEnabledIndex(options, selectedIndex, 1)
|
|
: getFirstEnabledIndex(options)
|
|
setOpenWithHighlight(true, nextIndex >= 0 ? nextIndex : getFirstEnabledIndex(options))
|
|
return
|
|
}
|
|
|
|
setHighlightedIndex((current) => getNextEnabledIndex(options, current, 1))
|
|
return
|
|
}
|
|
case 'ArrowUp': {
|
|
event.preventDefault()
|
|
if (!open) {
|
|
const nextIndex =
|
|
selectedIndex >= 0
|
|
? getNextEnabledIndex(options, selectedIndex, -1)
|
|
: getLastEnabledIndex(options)
|
|
setOpenWithHighlight(true, nextIndex >= 0 ? nextIndex : getLastEnabledIndex(options))
|
|
return
|
|
}
|
|
|
|
setHighlightedIndex((current) => getNextEnabledIndex(options, current, -1))
|
|
return
|
|
}
|
|
case 'Home': {
|
|
event.preventDefault()
|
|
const firstIndex = getFirstEnabledIndex(options)
|
|
if (!open) {
|
|
setOpenWithHighlight(true, firstIndex)
|
|
return
|
|
}
|
|
setHighlightedIndex(firstIndex)
|
|
return
|
|
}
|
|
case 'End': {
|
|
event.preventDefault()
|
|
const lastIndex = getLastEnabledIndex(options)
|
|
if (!open) {
|
|
setOpenWithHighlight(true, lastIndex)
|
|
return
|
|
}
|
|
setHighlightedIndex(lastIndex)
|
|
return
|
|
}
|
|
case 'Enter':
|
|
case ' ': {
|
|
event.preventDefault()
|
|
if (!open) {
|
|
setOpenWithHighlight(true)
|
|
return
|
|
}
|
|
|
|
if (highlightedIndex >= 0) {
|
|
commitValue(highlightedIndex)
|
|
}
|
|
return
|
|
}
|
|
case 'Escape': {
|
|
if (!open) {
|
|
return
|
|
}
|
|
event.preventDefault()
|
|
setOpen(false)
|
|
return
|
|
}
|
|
case 'Tab': {
|
|
setOpen(false)
|
|
return
|
|
}
|
|
default:
|
|
return
|
|
}
|
|
},
|
|
[commitValue, disabled, highlightedIndex, onKeyDown, open, options, selectedIndex, setOpenWithHighlight],
|
|
)
|
|
|
|
React.useLayoutEffect(() => {
|
|
if (!open) {
|
|
return
|
|
}
|
|
|
|
updateMenuPosition()
|
|
}, [open, updateMenuPosition])
|
|
|
|
React.useEffect(() => {
|
|
if (!open) {
|
|
return
|
|
}
|
|
|
|
const handlePointerDown = (event: MouseEvent) => {
|
|
const target = event.target as Node
|
|
if (wrapperRef.current?.contains(target) || menuRef.current?.contains(target)) {
|
|
return
|
|
}
|
|
|
|
setOpen(false)
|
|
}
|
|
|
|
const handleWindowChange = () => updateMenuPosition()
|
|
|
|
document.addEventListener('mousedown', handlePointerDown)
|
|
window.addEventListener('resize', handleWindowChange)
|
|
window.addEventListener('scroll', handleWindowChange, true)
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', handlePointerDown)
|
|
window.removeEventListener('resize', handleWindowChange)
|
|
window.removeEventListener('scroll', handleWindowChange, true)
|
|
}
|
|
}, [open, updateMenuPosition])
|
|
|
|
React.useEffect(() => {
|
|
if (!open || highlightedIndex < 0) {
|
|
return
|
|
}
|
|
|
|
optionRefs.current[highlightedIndex]?.scrollIntoView({ block: 'nearest' })
|
|
}, [highlightedIndex, open])
|
|
|
|
const triggerClasses = cn(
|
|
'flex h-11 w-full items-center justify-between gap-3 rounded-xl border border-input bg-background/80 px-3 py-2 text-left text-sm text-foreground shadow-sm outline-none transition-[border-color,box-shadow,background-color,transform] focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50 data-[state=open]:border-primary/40 data-[state=open]:bg-card data-[state=open]:shadow-[0_18px_40px_rgb(15_23_42_/_0.14)]',
|
|
className,
|
|
)
|
|
|
|
const menu = open && menuStyle
|
|
? ReactDOM.createPortal(
|
|
<div
|
|
ref={menuRef}
|
|
aria-orientation="vertical"
|
|
className={cn(
|
|
'custom-select-popover fixed z-[80] overflow-hidden rounded-2xl border border-border/70 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_48px_rgb(15_23_42_/_0.18)] will-change-transform',
|
|
menuPlacement === 'top' ? 'origin-bottom' : 'origin-top',
|
|
)}
|
|
id={menuId}
|
|
onKeyDown={handleKeyDown}
|
|
role="listbox"
|
|
style={menuStyle}
|
|
tabIndex={-1}
|
|
>
|
|
<div className="max-h-full overflow-y-auto pr-0.5">
|
|
{options.map((option, index) => {
|
|
const selected = option.value === currentValue
|
|
const highlighted = index === highlightedIndex
|
|
|
|
return (
|
|
<button
|
|
key={`${option.value}-${index}`}
|
|
ref={(node) => {
|
|
optionRefs.current[index] = node
|
|
}}
|
|
aria-selected={selected}
|
|
className={cn(
|
|
'flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors',
|
|
option.disabled ? 'cursor-not-allowed opacity-45' : 'cursor-pointer',
|
|
selected
|
|
? 'bg-primary text-primary-foreground shadow-[0_12px_30px_rgb(37_99_235_/_0.22)]'
|
|
: highlighted
|
|
? 'bg-accent text-accent-foreground'
|
|
: 'text-foreground hover:bg-accent hover:text-accent-foreground',
|
|
)}
|
|
disabled={option.disabled}
|
|
onClick={() => commitValue(index)}
|
|
onMouseEnter={() => {
|
|
if (!option.disabled) {
|
|
setHighlightedIndex(index)
|
|
}
|
|
}}
|
|
role="option"
|
|
type="button"
|
|
>
|
|
<span className="truncate">{option.label}</span>
|
|
<Check className={cn('h-4 w-4 shrink-0', selected ? 'opacity-100' : 'opacity-0')} />
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
: null
|
|
|
|
return (
|
|
<div className="relative w-full" ref={wrapperRef}>
|
|
<select
|
|
{...props}
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute h-0 w-0 opacity-0"
|
|
defaultValue={defaultValue}
|
|
disabled={disabled}
|
|
id={id}
|
|
onBlur={onBlur}
|
|
onFocus={onFocus}
|
|
ref={(node) => {
|
|
nativeSelectRef.current = node
|
|
if (typeof forwardedRef === 'function') {
|
|
forwardedRef(node)
|
|
} else if (forwardedRef) {
|
|
forwardedRef.current = node
|
|
}
|
|
}}
|
|
tabIndex={-1}
|
|
value={isControlled ? currentValue : internalValue}
|
|
>
|
|
{children}
|
|
</select>
|
|
|
|
<button
|
|
aria-controls={open ? menuId : undefined}
|
|
aria-expanded={open}
|
|
aria-haspopup="listbox"
|
|
className={triggerClasses}
|
|
data-state={open ? 'open' : 'closed'}
|
|
disabled={disabled}
|
|
onBlur={(event) => {
|
|
onBlur?.(event as unknown as React.FocusEvent<HTMLSelectElement>)
|
|
}}
|
|
onClick={(event) => {
|
|
onClick?.(event as unknown as React.MouseEvent<HTMLSelectElement>)
|
|
}}
|
|
onPointerDown={(event) => {
|
|
if (event.button !== 0 || disabled) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
triggerRef.current?.focus()
|
|
setOpenWithHighlight(!open)
|
|
}}
|
|
onFocus={(event) => {
|
|
onFocus?.(event as unknown as React.FocusEvent<HTMLSelectElement>)
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
ref={triggerRef}
|
|
role="combobox"
|
|
type="button"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">{selectedOption?.label ?? '请选择'}</span>
|
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/70 text-muted-foreground transition-colors">
|
|
<ChevronDown className={cn('h-4 w-4 transition-transform duration-200', open && 'rotate-180')} />
|
|
</span>
|
|
</button>
|
|
|
|
{menu}
|
|
</div>
|
|
)
|
|
},
|
|
)
|
|
Select.displayName = 'Select'
|
|
|
|
export { Select }
|