Files
cruciverb/frontend/src/components/ui/Select.tsx
T

133 lines
5.1 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(); triggerRef.current?.focus() }}
onKeyDown={e => onOptionKeyDown(e, i)}
type="button"
>
{o.label}
</button>
))}
</div>,
document.body,
)}
</div>
)
}