Files
magistr/frontend/react/shared/ui/AsyncCombobox.jsx
2026-10-02 04:07:56 +03:00

198 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 restoringFocus = useRef(false);
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('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', 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();
restoreInputFocus();
}
function restoreInputFocus() {
restoringFocus.current = true;
inputRef.current?.focus();
restoringFocus.current = false;
}
function clear() {
onSelect(null);
setQuery('');
restoreInputFocus();
void search('');
}
function handleFocus() {
if (restoringFocus.current || open) return;
setQuery(selectedLabel);
void search(selectedLabel);
}
function handleInput(event) {
const value = event.target.value;
setQuery(value);
// Старый ответ не должен стать доступен во время задержки нового поиска.
requestNumber.current += 1;
setOpen(true);
setItems([]);
setLoading(true);
setError('');
window.clearTimeout(debounceTimer.current);
debounceTimer.current = window.setTimeout(() => {
void search(value);
}, SEARCH_DEBOUNCE_MS);
}
function handleInputKeydown(event) {
if (event.key === 'Escape') {
event.preventDefault();
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();
restoreInputFocus();
} 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}
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget)) close();
}}
>
<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>
);
}