бесконечный скролл

This commit is contained in:
gorbenko.pavel
2026-07-29 16:24:05 +05:00
parent 535741a031
commit bfa0777739
2 changed files with 33 additions and 28 deletions
@@ -51,6 +51,8 @@ export function AsyncSearchableSelect<T>({
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);
@@ -118,6 +120,27 @@ export function AsyncSearchableSelect<T>({
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('');
@@ -152,7 +175,7 @@ export function AsyncSearchableSelect<T>({
value={query}
onChange={e => setQuery(e.target.value)}
/>
<div className={styles.list}>
<div className={styles.list} ref={listRef}>
{loading && <div className={styles.empty}>Поиск...</div>}
{!loading && error && <div className={styles.errorText}>{error}</div>}
{!loading && !error && items.length === 0 && (
@@ -174,15 +197,8 @@ export function AsyncSearchableSelect<T>({
);
})}
{!loading && !error && hasMore && (
<div className={styles.loadMoreRow}>
<button
type="button"
className={styles.loadMoreBtn}
disabled={loadingMore}
onClick={() => runSearch(query, page + 1, true)}
>
{loadingMore ? 'Загрузка...' : 'Показать ещё'}
</button>
<div ref={sentinelRef} className={styles.sentinel}>
{loadingMore && 'Загрузка...'}
</div>
)}
</div>
@@ -115,25 +115,14 @@
text-align: center;
}
.loadMoreRow {
padding: 4px 12px 2px;
.sentinel {
height: 30px;
flex-shrink: 0;
}
.loadMoreBtn {
width: 100%;
padding: 8px 12px;
border-radius: $r-sm;
border: 1px dashed $gray-300;
background: transparent;
color: $navy-700;
font-family: $font-sans;
font-size: 12.5px;
cursor: pointer;
transition: .15s;
&:hover:not(:disabled) { border-color: $gold-500; color: $gold-600; background: $gold-50; }
&:disabled { opacity: .5; cursor: not-allowed; }
display: flex;
align-items: center;
justify-content: center;
color: $gray-400;
font-size: 11.5px;
}
.clear {