133 lines
5.0 KiB
TypeScript
133 lines
5.0 KiB
TypeScript
import { useState, useRef, useEffect, useCallback, useId } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { IconChevronDownFilled } from '@tabler/icons-react'
|
|
|
|
interface Option {
|
|
value: string | number
|
|
label: string
|
|
}
|
|
|
|
interface Props {
|
|
value: string | number
|
|
options: Option[]
|
|
onChange: (value: string | number) => void
|
|
className?: string
|
|
style?: React.CSSProperties
|
|
}
|
|
|
|
export default function Select({ value, options, onChange, className, style }: Props) {
|
|
const [open, setOpen] = useState(false)
|
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
const dropRef = useRef<HTMLDivElement>(null)
|
|
const optionRefs = useRef<(HTMLButtonElement | null)[]>([])
|
|
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
|
const listboxId = useId()
|
|
|
|
const selected = options.find(o => String(o.value) === String(value))
|
|
const selectedIndex = options.findIndex(o => String(o.value) === String(value))
|
|
|
|
const close = useCallback(() => setOpen(false), [])
|
|
|
|
const updatePos = useCallback(() => {
|
|
if (!triggerRef.current) return
|
|
const rect = triggerRef.current.getBoundingClientRect()
|
|
setPos({ top: rect.bottom, left: rect.left, width: rect.width })
|
|
}, [])
|
|
|
|
// move real focus to the option matching the current value (or the first)
|
|
useEffect(() => {
|
|
if (!open) return
|
|
updatePos()
|
|
const target = optionRefs.current[selectedIndex >= 0 ? selectedIndex : 0]
|
|
target?.focus()
|
|
}, [open, selectedIndex, updatePos])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
function onDown(e: MouseEvent) {
|
|
const target = e.target
|
|
if (target instanceof Node && dropRef.current && !dropRef.current.contains(target) &&
|
|
triggerRef.current && !triggerRef.current.contains(target)) {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
window.addEventListener('scroll', updatePos, true)
|
|
document.addEventListener('mousedown', onDown)
|
|
return () => {
|
|
window.removeEventListener('scroll', updatePos, true)
|
|
document.removeEventListener('mousedown', onDown)
|
|
}
|
|
}, [open, updatePos])
|
|
|
|
const moveFocus = useCallback((delta: number) => {
|
|
const refs = optionRefs.current
|
|
const current = refs.findIndex(el => el === document.activeElement)
|
|
const next = Math.min(refs.length - 1, Math.max(0, (current >= 0 ? current : 0) + delta))
|
|
refs[next]?.focus()
|
|
}, [])
|
|
|
|
// trigger-level keys: arrows cycle values like a native select while closed
|
|
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
const delta = e.key === 'ArrowDown' ? 1 : -1
|
|
const next = Math.min(options.length - 1, Math.max(0, selectedIndex + delta))
|
|
if (next !== selectedIndex && options[next]) onChange(options[next].value)
|
|
return
|
|
}
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
setOpen(!open)
|
|
return
|
|
}
|
|
if (e.key === 'Escape') {
|
|
close()
|
|
}
|
|
}
|
|
|
|
const onOptionKeyDown = (e: React.KeyboardEvent, index: number) => {
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); moveFocus(1); return }
|
|
if (e.key === 'ArrowUp') { e.preventDefault(); moveFocus(-1); return }
|
|
if (e.key === 'Home') { e.preventDefault(); optionRefs.current[0]?.focus(); return }
|
|
if (e.key === 'End') { e.preventDefault(); optionRefs.current[options.length - 1]?.focus(); return }
|
|
if (e.key === 'Escape') { e.preventDefault(); close(); triggerRef.current?.focus(); return }
|
|
if (e.key === 'Tab') { close(); return }
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
const o = options[index]
|
|
if (o) { onChange(o.value); close(); triggerRef.current?.focus() }
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className={`cv-select ${className || ''}`} style={style}>
|
|
<button className="cv-select-trigger" ref={triggerRef} onClick={() => setOpen(!open)} type="button"
|
|
aria-haspopup="listbox" aria-expanded={open} aria-controls={open ? listboxId : undefined}
|
|
onKeyDown={onTriggerKeyDown}>
|
|
<span>{selected?.label || String(value)}</span>
|
|
<IconChevronDownFilled size={10} style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} />
|
|
</button>
|
|
{open && createPortal(
|
|
<div className="cv-select-dropdown" ref={dropRef} id={listboxId} role="listbox"
|
|
style={{ top: pos.top, left: pos.left, minWidth: pos.width }}>
|
|
{options.map((o, i) => (
|
|
<button
|
|
key={String(o.value)}
|
|
ref={el => { optionRefs.current[i] = el }}
|
|
role="option"
|
|
aria-selected={String(o.value) === String(value)}
|
|
tabIndex={i === (selectedIndex >= 0 ? selectedIndex : 0) ? 0 : -1}
|
|
className={`cv-select-option ${String(o.value) === String(value) ? 'cv-select-option-active' : ''}`}
|
|
onClick={() => { onChange(o.value); close() }}
|
|
onKeyDown={e => onOptionKeyDown(e, i)}
|
|
type="button"
|
|
>
|
|
{o.label}
|
|
</button>
|
|
))}
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
)
|
|
} |