feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
This commit is contained in:
175
frontend/react/shared/ui/AsyncCombobox.jsx
Normal file
175
frontend/react/shared/ui/AsyncCombobox.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user