import * as React from 'react'; import styles from './SearchableSelect.module.scss'; import { Icon } from './Icon'; import { texts } from '../../texts'; export interface IAsyncSearchResult { items: T[]; hasMore: boolean; } export interface IAsyncSearchableSelectProps { value: string; /** Название выбранного значения — показываем в триггере без похода на сервер. */ selectedLabel?: string; onChange: (value: string, item: T | undefined) => void; search: (query: string, page: number) => Promise>; getValue: (item: T) => string; getLabel: (item: T) => string; getSublabel?: (item: T) => string | undefined; placeholder?: string; searchPlaceholder?: string; disabled?: boolean; allowClear?: boolean; clearLabel?: string; /** Задержка перед запросом после ввода — не долбим портал на каждый символ. */ debounceMs?: number; } export function AsyncSearchableSelect({ value, selectedLabel, onChange, search, getValue, getLabel, getSublabel, placeholder = texts.select.placeholder, searchPlaceholder = texts.select.searchPlaceholder, disabled, allowClear, clearLabel = texts.select.notSelected, debounceMs = 350 }: IAsyncSearchableSelectProps): React.ReactElement { const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(''); const [items, setItems] = React.useState([]); const [page, setPage] = React.useState(1); const [hasMore, setHasMore] = React.useState(false); const [loading, setLoading] = React.useState(false); const [loadingMore, setLoadingMore] = React.useState(false); const [error, setError] = React.useState(); const rootRef = React.useRef(null); const searchRef = React.useRef(null); const listRef = React.useRef(null); const sentinelRef = React.useRef(null); // Растущий счётчик запросов: если ответ пришёл не от последнего запроса // (например, пользователь допечатал ещё символ) — просто игнорируем его. const requestIdRef = React.useRef(0); const runSearch = React.useCallback((q: string, targetPage: number, append: boolean) => { const requestId = ++requestIdRef.current; if (append) setLoadingMore(true); else setLoading(true); setError(undefined); search(q, targetPage) .then(result => { if (requestIdRef.current !== requestId) return; // устарел setItems(prev => (append ? [...prev, ...result.items] : result.items)); setHasMore(result.hasMore); setPage(targetPage); }) .catch((e: Error) => { if (requestIdRef.current !== requestId) return; setError(e.message); }) .finally(() => { if (requestIdRef.current !== requestId) return; setLoading(false); setLoadingMore(false); }); }, [search]); // Открытие — сразу показываем первую страницу (пустой запрос = «последние/все»), // без debounce. Эффект ниже (deps: [query]) на само открытие не реагирует — // toggle() всегда сбрасывает query в '', так что при чистом открытии/закрытии // без ввода текста он не перезапускается и второго запроса не улетает. React.useEffect(() => { if (!open) return; runSearch('', 1, false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); // Debounce поиска по вводу. React.useEffect(() => { if (!open) return; const handle = setTimeout(() => runSearch(query, 1, false), debounceMs); return () => clearTimeout(handle); // eslint-disable-next-line react-hooks/exhaustive-deps }, [query]); React.useEffect(() => { if (!open) return; const onDocumentMouseDown = (e: MouseEvent): void => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); }; const onKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Escape') setOpen(false); }; document.addEventListener('mousedown', onDocumentMouseDown); document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('mousedown', onDocumentMouseDown); document.removeEventListener('keydown', onKeyDown); }; }, [open]); React.useEffect(() => { if (open && searchRef.current) searchRef.current.focus(); }, [open]); // Бесконечная прокрутка: как только сентинел внизу списка попадает в // видимую область скролл-контейнера — подгружаем следующую страницу. // root указываем явно (сам .list, а не viewport) — иначе браузер считает // видимость относительно всей страницы, а не прокручиваемого дропдауна. React.useEffect(() => { if (!open || !hasMore || loading || loadingMore) return; const rootEl = listRef.current; const sentinelEl = sentinelRef.current; if (!rootEl || !sentinelEl) return; const observer = new IntersectionObserver( entries => { if (entries[0]?.isIntersecting) runSearch(query, page + 1, true); }, { root: rootEl, rootMargin: '64px' } ); observer.observe(sentinelEl); return () => observer.disconnect(); }, [open, hasMore, loading, loadingMore, page, query, runSearch]); const toggle = (): void => { if (disabled) return; setQuery(''); setOpen(!open); }; const pick = (item: T | undefined): void => { onChange(item ? getValue(item) : '', item); setOpen(false); }; return (
{open && (
setQuery(e.target.value)} />
{loading &&
{texts.select.searching}
} {!loading && error &&
{error}
} {!loading && !error && items.length === 0 && (
{texts.select.nothingFound}
)} {!loading && !error && items.map(item => { const itemValue = getValue(item); const sub = getSublabel ? getSublabel(item) : undefined; return ( ); })} {!loading && !error && hasMore && (
{loadingMore && 'Загрузка...'}
)}
{allowClear && value && (
)}
)}
); }