Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md). - 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой - Удалены legacy-views и переходный слой LegacyTabView (45 файлов) - Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки - CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf) - Фиксы: lazy-импорт именованных экспортов (краш после авторизации), растяжение #admin-root/#settings-root на всю ширину, возврат subjects/lessonTypes из справочников (краш просмотра расписаний), отказ от заведомо широкого поиска расписания без выбранной цели, центрирование карточки логина - Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину - Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
176 lines
6.2 KiB
JavaScript
176 lines
6.2 KiB
JavaScript
import { useEffect, useId, useRef, useState } from 'react';
|
||
|
||
const SEARCH_DEBOUNCE_MS = 320;
|
||
|
||
export function AsyncCombobox({
|
||
allowClear = true,
|
||
ariaLabel = 'Поисковый список',
|
||
emptyLabel = 'Ничего не найдено',
|
||
load,
|
||
onSelect,
|
||
optionDescription = () => '',
|
||
optionLabel = item => item.name,
|
||
optionValue = item => item.id,
|
||
placeholder = 'Начните вводить название',
|
||
selectedLabel = '',
|
||
selectedValue = ''
|
||
}) {
|
||
const rootRef = useRef(null);
|
||
const inputRef = useRef(null);
|
||
const debounceTimer = useRef(null);
|
||
const requestNumber = useRef(0);
|
||
const [open, setOpen] = useState(false);
|
||
const [query, setQuery] = useState('');
|
||
const [items, setItems] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const listId = useId();
|
||
|
||
useEffect(() => () => {
|
||
window.clearTimeout(debounceTimer.current);
|
||
requestNumber.current += 1;
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!open) return undefined;
|
||
const handlePointerDown = event => {
|
||
if (rootRef.current && !rootRef.current.contains(event.target)) {
|
||
close();
|
||
}
|
||
};
|
||
document.addEventListener('mousedown', handlePointerDown);
|
||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||
});
|
||
|
||
async function search(nextQuery) {
|
||
const currentRequest = requestNumber.current + 1;
|
||
requestNumber.current = currentRequest;
|
||
setOpen(true);
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
const result = await load(String(nextQuery || '').trim(), selectedValue || '');
|
||
if (requestNumber.current !== currentRequest) return;
|
||
setItems(Array.isArray(result) ? result : []);
|
||
setLoading(false);
|
||
} catch {
|
||
if (requestNumber.current !== currentRequest) return;
|
||
setError('Не удалось выполнить поиск');
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
function close() {
|
||
window.clearTimeout(debounceTimer.current);
|
||
requestNumber.current += 1;
|
||
setOpen(false);
|
||
setQuery('');
|
||
setItems([]);
|
||
setError('');
|
||
setLoading(false);
|
||
}
|
||
|
||
function choose(item) {
|
||
onSelect(item);
|
||
close();
|
||
}
|
||
|
||
function clear() {
|
||
onSelect(null);
|
||
setQuery('');
|
||
inputRef.current?.focus();
|
||
void search('');
|
||
}
|
||
|
||
function handleFocus() {
|
||
setQuery(selectedLabel);
|
||
void search(selectedLabel);
|
||
}
|
||
|
||
function handleInput(event) {
|
||
const value = event.target.value;
|
||
setQuery(value);
|
||
window.clearTimeout(debounceTimer.current);
|
||
debounceTimer.current = window.setTimeout(() => {
|
||
void search(value);
|
||
}, SEARCH_DEBOUNCE_MS);
|
||
}
|
||
|
||
function handleInputKeydown(event) {
|
||
if (event.key === 'Escape') {
|
||
close();
|
||
} else if (event.key === 'ArrowDown') {
|
||
event.preventDefault();
|
||
rootRef.current?.querySelector('[role="option"]')?.focus();
|
||
}
|
||
}
|
||
|
||
function handleMenuKeydown(event) {
|
||
const options = Array.from(rootRef.current?.querySelectorAll('[role="option"]') || []);
|
||
const current = options.indexOf(document.activeElement);
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault();
|
||
close();
|
||
inputRef.current?.focus();
|
||
} else if (event.key === 'ArrowDown' && current >= 0) {
|
||
event.preventDefault();
|
||
options[Math.min(current + 1, options.length - 1)]?.focus();
|
||
} else if (event.key === 'ArrowUp' && current >= 0) {
|
||
event.preventDefault();
|
||
(current === 0 ? inputRef.current : options[current - 1])?.focus();
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className={`async-combobox${open ? ' is-open' : ''}`} ref={rootRef}>
|
||
<div className="async-combobox-control">
|
||
<input
|
||
type="search"
|
||
role="combobox"
|
||
aria-autocomplete="list"
|
||
aria-expanded={open}
|
||
aria-controls={listId}
|
||
aria-label={ariaLabel}
|
||
autoComplete="off"
|
||
placeholder={placeholder}
|
||
value={open ? query : selectedLabel}
|
||
onChange={handleInput}
|
||
onFocus={handleFocus}
|
||
onKeyDown={handleInputKeydown}
|
||
ref={inputRef}
|
||
/>
|
||
{allowClear && (
|
||
<button type="button" className="async-combobox-clear" aria-label="Очистить выбор" onClick={clear}>×</button>
|
||
)}
|
||
</div>
|
||
<div className="async-combobox-menu" role="listbox" id={listId} hidden={!open} onKeyDown={handleMenuKeydown}>
|
||
{open && (
|
||
loading ? (
|
||
<div className="async-combobox-state"><span className="state-spinner" aria-hidden="true" />Поиск...</div>
|
||
) : error ? (
|
||
<div className="async-combobox-state is-error">{error}</div>
|
||
) : items.length === 0 ? (
|
||
<div className="async-combobox-state">{emptyLabel}</div>
|
||
) : items.map(item => {
|
||
const value = String(optionValue(item));
|
||
const description = optionDescription(item);
|
||
return (
|
||
<button
|
||
key={value}
|
||
type="button"
|
||
role="option"
|
||
className="async-combobox-option"
|
||
aria-selected={String(selectedValue) === value}
|
||
onClick={() => choose(item)}
|
||
>
|
||
<strong>{optionLabel(item)}</strong>
|
||
{description ? <small>{description}</small> : null}
|
||
</button>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|