Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View 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>
)
}