This commit is contained in:
Zuev
2026-10-02 04:07:56 +03:00
parent 97bf3cc50a
commit fc5801d683
42 changed files with 1201 additions and 305 deletions

View File

@@ -0,0 +1,171 @@
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 (
<div
className={`react-custom-select${expanded ? ' select-layer-active' : ''}`}
ref={rootRef}
onKeyDown={handleKeydown}
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
}}
>
<select
{...props}
ref={selectRef}
hidden
aria-hidden="true"
tabIndex={-1}
data-native-select="true"
onInvalid={event => {
props.onInvalid?.(event);
event.preventDefault();
setInvalid(true);
triggerRef.current?.focus();
}}
>
{children}
</select>
<div className={`custom-select-wrapper${expanded ? ' open' : ''}${props.disabled ? ' disabled' : ''}`}>
<button
type="button"
className={`custom-select-trigger${!selected?.value ? ' placeholder-active' : ''}`}
ref={triggerRef}
disabled={props.disabled}
role="combobox"
aria-expanded={expanded}
aria-controls={listId}
aria-haspopup="listbox"
aria-required={props.required || undefined}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? `${listId}-error` : undefined}
aria-label={props['aria-label'] || snapshot.label || 'Выбор значения'}
onClick={toggle}
>
<span className="custom-select-text">{selected?.text || '—'}</span>
<span className="custom-select-icon" aria-hidden="true">
<svg width="12" height="8" viewBox="0 0 12 8" fill="none">
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</button>
{expanded && (
<div className="custom-select-menu">
{searchable && (
<input
className="custom-select-search-input"
type="search"
placeholder="Поиск..."
aria-label="Поиск по списку"
value={query}
onChange={event => setQuery(event.target.value)}
/>
)}
<ul className="react-select-options" role="listbox" id={listId}>
{snapshot.options.filter(option => option.text.toLocaleLowerCase('ru').includes(query.toLocaleLowerCase('ru'))).length === 0 && (
<li className="custom-select-item disabled">Ничего не найдено</li>
)}
{snapshot.options.map((option, index) => (
option.text.toLocaleLowerCase('ru').includes(query.toLocaleLowerCase('ru')) && (
<li key={index} role="presentation">
<button
type="button"
className={`custom-select-item${index === snapshot.selectedIndex ? ' selected' : ''}${option.disabled ? ' disabled' : ''}`}
role="option"
aria-selected={index === snapshot.selectedIndex}
disabled={option.disabled}
onClick={() => choose(index)}
>
{option.text}
</button>
</li>
)
))}
</ul>
</div>
)}
</div>
{invalid && <span id={`${listId}-error`} className="form-alert error" role="alert">Выберите значение</span>}
</div>
);
}