Files
vidconf/frontend/src/components/ui/Select.tsx

234 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}