import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; /** Селект дизайн-системы: разметкой и состоянием полностью управляет React. */ export function CustomSelect({ children, ...props }) { const rootRef = useRef(null); const selectRef = useRef(null); const triggerRef = useRef(null); const listId = useId(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const [invalid, setInvalid] = useState(false); const [snapshot, setSnapshot] = useState({ options: [], selectedIndex: -1, label: '' }); // Читаем итоговые option после commit: children могут содержать массивы и фрагменты. // Контролируемые value, disabled и подписи синхронизируются при каждом рендере. useLayoutEffect(() => { const select = selectRef.current; const next = { options: Array.from(select.options, option => ({ value: option.value, text: option.text, disabled: option.disabled || Boolean(option.closest('optgroup')?.disabled) })), selectedIndex: select.selectedIndex, label: Array.from(select.labels || [], label => { const caption = label.cloneNode(true); caption.querySelectorAll('select, .custom-select-wrapper').forEach(node => node.remove()); return caption.textContent.trim(); }).join(' ') }; setSnapshot(current => JSON.stringify(current) === JSON.stringify(next) ? current : next); if (select.validity.valid) setInvalid(false); }); const expanded = open && !props.disabled; useEffect(() => { if (!expanded) return undefined; function closeOutside(event) { if (!rootRef.current?.contains(event.target)) setOpen(false); } document.addEventListener('pointerdown', closeOutside); return () => document.removeEventListener('pointerdown', closeOutside); }, [expanded]); const selected = snapshot.options[snapshot.selectedIndex]; const searchable = snapshot.options.length > 6; function choose(index) { const select = selectRef.current; select.selectedIndex = index; select.dispatchEvent(new Event('change', { bubbles: true })); setOpen(false); triggerRef.current?.focus(); } function toggle() { setQuery(''); setOpen(current => !current); } function handleKeydown(event) { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); setOpen(false); triggerRef.current?.focus(); } else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { event.preventDefault(); if (!expanded) { setQuery(''); setOpen(true); return; } const options = Array.from(rootRef.current.querySelectorAll('[role="option"]:not(:disabled)')); const current = options.indexOf(document.activeElement); const index = event.key === 'Home' ? 0 : event.key === 'End' ? options.length - 1 : event.key === 'ArrowUp' ? (current < 0 ? options.length - 1 : Math.max(0, current - 1)) : Math.min(options.length - 1, current + 1); options[index]?.focus(); } } return (