Первоначальная версия VidConf
This commit is contained in:
233
frontend/src/components/ui/Select.tsx
Normal file
233
frontend/src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user