проект

This commit is contained in:
gorbenko.pavel
2026-07-29 16:18:57 +05:00
parent ab5793b1a9
commit 535741a031
7 changed files with 313 additions and 5 deletions
@@ -0,0 +1,200 @@
import * as React from 'react';
import styles from './SearchableSelect.module.scss';
import { Icon } from './Icon';
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 = 'Выберите значение',
searchPlaceholder = 'Поиск...',
disabled,
allowClear,
clearLabel = 'Не выбрано',
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 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]);
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}>
{loading && <div className={styles.empty}>Поиск...</div>}
{!loading && error && <div className={styles.errorText}>{error}</div>}
{!loading && !error && items.length === 0 && (
<div className={styles.empty}>Ничего не найдено</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 className={styles.loadMoreRow}>
<button
type="button"
className={styles.loadMoreBtn}
disabled={loadingMore}
onClick={() => runSearch(query, page + 1, true)}
>
{loadingMore ? 'Загрузка...' : 'Показать ещё'}
</button>
</div>
)}
</div>
{allowClear && value && (
<div className={styles.clear}>
<button type="button" className={styles.item} onClick={() => pick(undefined)}>
{clearLabel}
</button>
</div>
)}
</div>
)}
</div>
);
}