234 lines
8.8 KiB
TypeScript
234 lines
8.8 KiB
TypeScript
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||
import { AlertCircle, Check, ChevronDown } from 'lucide-react'
|
||
|
||
export interface SelectOption {
|
||
value: string
|
||
label: string
|
||
disabled?: boolean
|
||
}
|
||
|
||
interface SelectProps {
|
||
value: string
|
||
onChange: (value: string) => void
|
||
options: SelectOption[]
|
||
placeholder?: string
|
||
disabled?: boolean
|
||
/** Показать ошибку (2px `--color-danger`, иконка `AlertCircle`) — текст ошибки рисует вызывающий код под полем. */
|
||
error?: boolean
|
||
id?: string
|
||
'aria-label'?: string
|
||
'aria-labelledby'?: string
|
||
}
|
||
|
||
/**
|
||
* Кастомный селект (DESIGN_SYSTEM.md §4.16) — заменяет нативный `<select>`.
|
||
* Паттерн listbox-combobox: триггер — геометрия инпута (§4.4), дроплист —
|
||
* паттерн `.menu` (design/mockups/lobby.html), пункт — `.menu a`. Полностью
|
||
* на var()-токенах — обе темы работают без отдельных правил (см. §4.16
|
||
* «Обе темы»).
|
||
*
|
||
* Клавиатура: Enter/Space/стрелки открывают список (фокус остаётся на
|
||
* триггере), стрелки двигают клавиатурную подсветку (не по кругу),
|
||
* Home/End — к границам, ввод буквы — type-ahead, Enter/Space на подсвеченном
|
||
* пункте — выбор и закрытие, Esc/клик вне/Tab — закрытие без изменений.
|
||
*/
|
||
export function Select({
|
||
value,
|
||
onChange,
|
||
options,
|
||
placeholder = 'Выберите…',
|
||
disabled = false,
|
||
error = false,
|
||
id,
|
||
...aria
|
||
}: SelectProps) {
|
||
const generatedId = useId()
|
||
const baseId = id ?? generatedId
|
||
const listboxId = `${baseId}-listbox`
|
||
|
||
const rootRef = useRef<HTMLDivElement>(null)
|
||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||
const optionRefs = useRef<Map<string, HTMLLIElement>>(new Map())
|
||
const typeaheadRef = useRef<{ text: string; timer: ReturnType<typeof window.setTimeout> | undefined }>({
|
||
text: '',
|
||
timer: undefined,
|
||
})
|
||
|
||
const enabledOptions = useMemo(() => options.filter((o) => !o.disabled), [options])
|
||
const selectedOption = options.find((o) => o.value === value) ?? null
|
||
|
||
// `null` — единственный сентинел «нет активного пункта»; пустая строка
|
||
// ('' — например, «По умолчанию» в SUMMARY_RECIPIENTS_OPTIONS) — легитимное
|
||
// значение и НЕ должна схлопываться в `null` через `||`-фоллбэки (был баг:
|
||
// ArrowDown ставил activeValue='', а `if (activeValue)`/`activeValue ? …`
|
||
// ниже трактовали это как «ничего не подсвечено», из-за чего Enter не
|
||
// выбирал пункт и aria-activedescendant не выставлялся). Поэтому везде ниже
|
||
// сравнение только с `null` (`!== null`/`=== null`), никогда `||`/truthiness.
|
||
const [open, setOpen] = useState(false)
|
||
const [activeValue, setActiveValue] = useState<string | null>(
|
||
enabledOptions.some((o) => o.value === value) ? value : null,
|
||
)
|
||
|
||
function closePanel() {
|
||
setOpen(false)
|
||
}
|
||
|
||
function openPanel() {
|
||
if (disabled) return
|
||
const current = enabledOptions.find((o) => o.value === value)
|
||
setActiveValue(current ? current.value : (enabledOptions[0]?.value ?? null))
|
||
setOpen(true)
|
||
}
|
||
|
||
// Закрытие по клику вне триггера/панели — тот же приём, что бургер-меню ShellTopbar.
|
||
useEffect(() => {
|
||
if (!open) return
|
||
function handlePointerDown(event: MouseEvent) {
|
||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) closePanel()
|
||
}
|
||
document.addEventListener('mousedown', handlePointerDown)
|
||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||
}, [open])
|
||
|
||
useEffect(() => {
|
||
if (open && activeValue !== null) {
|
||
optionRefs.current.get(activeValue)?.scrollIntoView({ block: 'nearest' })
|
||
}
|
||
}, [open, activeValue])
|
||
|
||
function moveActive(direction: 1 | -1) {
|
||
if (enabledOptions.length === 0) return
|
||
const currentIndex = enabledOptions.findIndex((o) => o.value === activeValue)
|
||
const nextIndex =
|
||
currentIndex === -1 ? 0 : Math.min(Math.max(currentIndex + direction, 0), enabledOptions.length - 1)
|
||
setActiveValue(enabledOptions[nextIndex].value)
|
||
}
|
||
|
||
function commitActive() {
|
||
if (activeValue !== null) onChange(activeValue)
|
||
closePanel()
|
||
triggerRef.current?.focus()
|
||
}
|
||
|
||
function selectOption(option: SelectOption) {
|
||
if (option.disabled) return
|
||
onChange(option.value)
|
||
closePanel()
|
||
triggerRef.current?.focus()
|
||
}
|
||
|
||
function handleTypeahead(char: string) {
|
||
const buf = typeaheadRef.current
|
||
window.clearTimeout(buf.timer)
|
||
buf.text += char.toLowerCase()
|
||
buf.timer = window.setTimeout(() => {
|
||
buf.text = ''
|
||
}, 600)
|
||
const currentIndex = enabledOptions.findIndex((o) => o.value === activeValue)
|
||
// Ищем от следующего после подсвеченного, по кругу — как в нативном select.
|
||
const ordered = [...enabledOptions.slice(currentIndex + 1), ...enabledOptions.slice(0, currentIndex + 1)]
|
||
const match = ordered.find((o) => o.label.toLowerCase().startsWith(buf.text))
|
||
if (match) setActiveValue(match.value)
|
||
}
|
||
|
||
function handleTriggerKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
|
||
if (disabled) return
|
||
|
||
if (!open) {
|
||
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||
event.preventDefault()
|
||
openPanel()
|
||
}
|
||
return
|
||
}
|
||
|
||
switch (event.key) {
|
||
case 'ArrowDown':
|
||
event.preventDefault()
|
||
moveActive(1)
|
||
break
|
||
case 'ArrowUp':
|
||
event.preventDefault()
|
||
moveActive(-1)
|
||
break
|
||
case 'Home':
|
||
event.preventDefault()
|
||
if (enabledOptions[0]) setActiveValue(enabledOptions[0].value)
|
||
break
|
||
case 'End':
|
||
event.preventDefault()
|
||
if (enabledOptions.length > 0) setActiveValue(enabledOptions[enabledOptions.length - 1].value)
|
||
break
|
||
case 'Enter':
|
||
case ' ':
|
||
event.preventDefault()
|
||
commitActive()
|
||
break
|
||
case 'Escape':
|
||
event.preventDefault()
|
||
closePanel()
|
||
break
|
||
case 'Tab':
|
||
closePanel()
|
||
break
|
||
default:
|
||
if (event.key.length === 1) handleTypeahead(event.key)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="vc-select" ref={rootRef}>
|
||
<button
|
||
type="button"
|
||
ref={triggerRef}
|
||
id={baseId}
|
||
className={`vc-select-trigger${error ? ' has-error' : ''}`}
|
||
role="combobox"
|
||
aria-haspopup="listbox"
|
||
aria-expanded={open}
|
||
aria-controls={listboxId}
|
||
aria-activedescendant={open && activeValue !== null ? `${listboxId}-opt-${activeValue}` : undefined}
|
||
aria-disabled={disabled || undefined}
|
||
disabled={disabled}
|
||
onClick={() => (open ? closePanel() : openPanel())}
|
||
onKeyDown={handleTriggerKeyDown}
|
||
{...aria}
|
||
>
|
||
{error && <AlertCircle className="vc-select-error-icon" style={{ width: 16, height: 16 }} aria-hidden="true" />}
|
||
<span className={selectedOption ? 'vc-select-value' : 'vc-select-placeholder'}>
|
||
{selectedOption ? selectedOption.label : placeholder}
|
||
</span>
|
||
<ChevronDown className={`vc-select-chevron${open ? ' is-open' : ''}`} style={{ width: 18, height: 18 }} aria-hidden="true" />
|
||
</button>
|
||
|
||
{open && (
|
||
<ul className="vc-select-panel" role="listbox" id={listboxId}>
|
||
{options.map((option) => {
|
||
const isSelected = option.value === value
|
||
const isActive = option.value === activeValue
|
||
return (
|
||
<li
|
||
key={option.value}
|
||
id={`${listboxId}-opt-${option.value}`}
|
||
ref={(el) => {
|
||
if (el) optionRefs.current.set(option.value, el)
|
||
else optionRefs.current.delete(option.value)
|
||
}}
|
||
role="option"
|
||
aria-selected={isSelected}
|
||
aria-disabled={option.disabled || undefined}
|
||
className={`vc-select-option${isActive ? ' is-active' : ''}${isSelected ? ' is-selected' : ''}${option.disabled ? ' is-disabled' : ''}`}
|
||
onMouseEnter={() => !option.disabled && setActiveValue(option.value)}
|
||
onClick={() => selectOption(option)}
|
||
>
|
||
<span>{option.label}</span>
|
||
{isSelected && <Check style={{ width: 16, height: 16 }} aria-hidden="true" />}
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|