218 lines
8.1 KiB
TypeScript
218 lines
8.1 KiB
TypeScript
import * as React from 'react';
|
|
import styles from './SearchableSelect.module.scss';
|
|
import { Icon } from './Icon';
|
|
import { texts } from '../../texts';
|
|
|
|
export interface IAsyncSearchResult<T> {
|
|
items: T[];
|
|
hasMore: boolean;
|
|
}
|
|
|
|
export interface IAsyncSearchableSelectProps<T> {
|
|
value: string;
|
|
/** Название выбранного значения — показываем в триггере без похода на сервер. */
|
|
selectedLabel?: string;
|
|
onChange: (value: string, item: T | undefined) => void;
|
|
search: (query: string, page: number) => Promise<IAsyncSearchResult<T>>;
|
|
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<T>({
|
|
value,
|
|
selectedLabel,
|
|
onChange,
|
|
search,
|
|
getValue,
|
|
getLabel,
|
|
getSublabel,
|
|
placeholder = texts.select.placeholder,
|
|
searchPlaceholder = texts.select.searchPlaceholder,
|
|
disabled,
|
|
allowClear,
|
|
clearLabel = texts.select.notSelected,
|
|
debounceMs = 350
|
|
}: IAsyncSearchableSelectProps<T>): React.ReactElement {
|
|
const [open, setOpen] = React.useState(false);
|
|
const [query, setQuery] = React.useState('');
|
|
const [items, setItems] = React.useState<T[]>([]);
|
|
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<string | undefined>();
|
|
|
|
const rootRef = React.useRef<HTMLDivElement>(null);
|
|
const searchRef = React.useRef<HTMLInputElement>(null);
|
|
const listRef = React.useRef<HTMLDivElement>(null);
|
|
const sentinelRef = React.useRef<HTMLDivElement>(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 (
|
|
<div className={styles.root} ref={rootRef}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.trigger} ${open ? styles.open : ''}`}
|
|
onClick={toggle}
|
|
disabled={disabled}
|
|
>
|
|
<span className={`${styles.value} ${value ? '' : styles.placeholder}`}>
|
|
{value ? (selectedLabel ?? value) : placeholder}
|
|
</span>
|
|
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
|
</button>
|
|
|
|
{open && (
|
|
<div className={styles.menu}>
|
|
<input
|
|
ref={searchRef}
|
|
className={styles.search}
|
|
placeholder={searchPlaceholder}
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
/>
|
|
<div className={styles.list} ref={listRef}>
|
|
{loading && <div className={styles.empty}>{texts.select.searching}</div>}
|
|
{!loading && error && <div className={styles.errorText}>{error}</div>}
|
|
{!loading && !error && items.length === 0 && (
|
|
<div className={styles.empty}>{texts.select.nothingFound}</div>
|
|
)}
|
|
{!loading && !error && items.map(item => {
|
|
const itemValue = getValue(item);
|
|
const sub = getSublabel ? getSublabel(item) : undefined;
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={itemValue}
|
|
className={`${styles.item} ${itemValue === value ? styles.selected : ''}`}
|
|
onClick={() => pick(item)}
|
|
>
|
|
{getLabel(item)}
|
|
{sub && <span className={styles.itemSub}>{sub}</span>}
|
|
</button>
|
|
);
|
|
})}
|
|
{!loading && !error && hasMore && (
|
|
<div ref={sentinelRef} className={styles.sentinel}>
|
|
{loadingMore && 'Загрузка...'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{allowClear && value && (
|
|
<div className={styles.clear}>
|
|
<button type="button" className={styles.item} onClick={() => pick(undefined)}>
|
|
{clearLabel}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|