Skip to content

Commit 926a436

Browse files
committed
fix(search): fix duplicate clear icon, add infinite scroll, and prevent background scroll leak
1 parent 291a140 commit 926a436

2 files changed

Lines changed: 202 additions & 11 deletions

File tree

src/components/blog/Search.tsx

Lines changed: 154 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -173,15 +173,52 @@ export function Search() {
173173
return () => document.removeEventListener('keydown', down);
174174
}, [isExpanded, searchValue]);
175175

176-
// Lock body scroll on mobile when expanded
176+
// Complete scroll locking (desktop + mobile) to prevent background page scroll
177177
useEffect(() => {
178-
if (isExpanded && typeof window !== 'undefined' && window.innerWidth < 768) {
179-
const originalOverflow = document.body.style.overflow;
180-
document.body.style.overflow = 'hidden';
181-
return () => {
182-
document.body.style.overflow = originalOverflow;
183-
};
178+
if (!isExpanded || typeof window === 'undefined') return;
179+
180+
const docEl = document.documentElement;
181+
const body = document.body;
182+
const scrollbarWidth = window.innerWidth - docEl.clientWidth;
183+
184+
const prevDocOverflow = docEl.style.overflow;
185+
const prevBodyOverflow = body.style.overflow;
186+
const prevBodyPaddingRight = body.style.paddingRight;
187+
188+
docEl.style.overflow = 'hidden';
189+
body.style.overflow = 'hidden';
190+
if (scrollbarWidth > 0) {
191+
body.style.paddingRight = `${scrollbarWidth}px`;
184192
}
193+
194+
// Prevent mouse wheel outside results list from scrolling the background
195+
const handleWheel = (e: WheelEvent) => {
196+
const scrollArea = pagefindContainerRef.current?.querySelector('.pagefind-ui__results-area');
197+
if (!scrollArea || !scrollArea.contains(e.target as Node)) {
198+
e.preventDefault();
199+
}
200+
};
201+
202+
// Prevent touch drag outside results list from pulling the background on mobile
203+
const handleTouchMove = (e: TouchEvent) => {
204+
const scrollArea = pagefindContainerRef.current?.querySelector('.pagefind-ui__results-area');
205+
if (!scrollArea || !scrollArea.contains(e.target as Node)) {
206+
if (e.cancelable) {
207+
e.preventDefault();
208+
}
209+
}
210+
};
211+
212+
window.addEventListener('wheel', handleWheel, { passive: false });
213+
window.addEventListener('touchmove', handleTouchMove, { passive: false });
214+
215+
return () => {
216+
docEl.style.overflow = prevDocOverflow;
217+
body.style.overflow = prevBodyOverflow;
218+
body.style.paddingRight = prevBodyPaddingRight;
219+
window.removeEventListener('wheel', handleWheel);
220+
window.removeEventListener('touchmove', handleTouchMove);
221+
};
185222
}, [isExpanded]);
186223

187224
// 支持 ?q= 深链(结构化数据 SearchAction / 外部直达搜索时自动聚焦)
@@ -329,6 +366,112 @@ export function Search() {
329366
return () => container.removeEventListener('keydown', handleResultsKeyDown);
330367
}, []);
331368

369+
// Auto-load more results on scroll (Infinite Scroll)
370+
useEffect(() => {
371+
if (!isExpanded || searchStatus !== 'ready') return;
372+
const container = pagefindContainerRef.current;
373+
if (!container) return;
374+
375+
let isAutoLoading = false;
376+
let observer: IntersectionObserver | null = null;
377+
let currentObservedBtn: HTMLButtonElement | null = null;
378+
379+
const triggerAutoLoad = (btn: HTMLButtonElement) => {
380+
if (isAutoLoading) return;
381+
isAutoLoading = true;
382+
btn.click();
383+
setTimeout(() => {
384+
isAutoLoading = false;
385+
}, 300);
386+
};
387+
388+
const attachAutoLoad = () => {
389+
const scrollArea = container.querySelector<HTMLElement>('.pagefind-ui__results-area');
390+
const loadMoreBtn = container.querySelector<HTMLButtonElement>('.pagefind-ui__button');
391+
392+
if (!loadMoreBtn) {
393+
if (observer && currentObservedBtn) {
394+
observer.unobserve(currentObservedBtn);
395+
currentObservedBtn = null;
396+
}
397+
return;
398+
}
399+
400+
if (loadMoreBtn === currentObservedBtn && observer) {
401+
return;
402+
}
403+
404+
if (observer) {
405+
observer.disconnect();
406+
}
407+
408+
currentObservedBtn = loadMoreBtn;
409+
observer = new IntersectionObserver(
410+
(entries) => {
411+
for (const entry of entries) {
412+
if (entry.isIntersecting && !isAutoLoading) {
413+
triggerAutoLoad(loadMoreBtn);
414+
}
415+
}
416+
},
417+
{
418+
root: scrollArea || null,
419+
rootMargin: '200px',
420+
}
421+
);
422+
423+
observer.observe(loadMoreBtn);
424+
};
425+
426+
const handleScroll = (e: Event) => {
427+
const scrollArea = e.currentTarget as HTMLElement;
428+
if (!scrollArea || isAutoLoading) return;
429+
const loadMoreBtn = container.querySelector<HTMLButtonElement>('.pagefind-ui__button');
430+
if (!loadMoreBtn) return;
431+
432+
if (scrollArea.scrollTop + scrollArea.clientHeight >= scrollArea.scrollHeight - 200) {
433+
triggerAutoLoad(loadMoreBtn);
434+
}
435+
};
436+
437+
const mutationObserver = new MutationObserver(() => {
438+
attachAutoLoad();
439+
const scrollArea = container.querySelector<HTMLElement>('.pagefind-ui__results-area');
440+
if (
441+
scrollArea &&
442+
!(scrollArea as HTMLElement & { __infiniteScrollAttached?: boolean })
443+
.__infiniteScrollAttached
444+
) {
445+
scrollArea.addEventListener('scroll', handleScroll, { passive: true });
446+
(
447+
scrollArea as HTMLElement & { __infiniteScrollAttached?: boolean }
448+
).__infiniteScrollAttached = true;
449+
}
450+
});
451+
452+
mutationObserver.observe(container, { childList: true, subtree: true });
453+
454+
attachAutoLoad();
455+
const initialScrollArea = container.querySelector<HTMLElement>('.pagefind-ui__results-area');
456+
if (initialScrollArea) {
457+
initialScrollArea.addEventListener('scroll', handleScroll, { passive: true });
458+
(
459+
initialScrollArea as HTMLElement & { __infiniteScrollAttached?: boolean }
460+
).__infiniteScrollAttached = true;
461+
}
462+
463+
return () => {
464+
mutationObserver.disconnect();
465+
if (observer) observer.disconnect();
466+
const area = container.querySelector<HTMLElement>('.pagefind-ui__results-area');
467+
if (area) {
468+
area.removeEventListener('scroll', handleScroll);
469+
delete (area as HTMLElement & { __infiniteScrollAttached?: boolean })
470+
.__infiniteScrollAttached;
471+
}
472+
};
473+
}, [isExpanded, searchStatus, searchValue]);
474+
332475
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
333476
setSearchValue(e.target.value);
334477
};
@@ -362,8 +505,9 @@ export function Search() {
362505
{/* Mobile Backdrop */}
363506
{isExpanded && (
364507
<div
365-
className="fixed inset-0 z-[55] bg-background/80 backdrop-blur-sm md:hidden"
508+
className="fixed inset-0 z-[55] bg-background/80 backdrop-blur-sm md:hidden touch-none select-none"
366509
onClick={() => closeSearch(false)}
510+
onTouchMove={(e) => e.preventDefault()}
367511
aria-hidden="true"
368512
/>
369513
)}
@@ -452,7 +596,7 @@ export function Search() {
452596
{isExpanded && (
453597
<div
454598
className={cn(
455-
'fixed left-2 right-2 top-12 z-[60] mt-1 rounded-xl border border-border bg-background shadow-xl max-h-[calc(100vh-4rem)] overflow-hidden md:absolute md:top-full md:left-0 md:right-0 md:mt-2 md:rounded-lg md:shadow-lg md:max-h-[70vh]',
599+
'fixed left-2 right-2 top-12 z-[60] mt-1 rounded-xl border border-border bg-background shadow-xl overflow-hidden overscroll-contain md:absolute md:top-full md:left-0 md:right-0 md:mt-2 md:rounded-lg md:shadow-lg',
456600
hasSearchQuery && 'min-h-[12rem]'
457601
)}
458602
role="dialog"
@@ -496,7 +640,7 @@ export function Search() {
496640
ref={pagefindContainerRef}
497641
id="pagefind-results"
498642
className={cn(
499-
'max-h-[calc(100vh-5rem)] overflow-y-auto p-2 md:max-h-[70vh]',
643+
'w-full p-2 overscroll-contain',
500644
searchStatus === 'ready' && hasSearchQuery ? 'block' : 'hidden'
501645
)}
502646
/>

src/styles/global.css

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,16 +263,25 @@
263263
margin: 0 !important;
264264
}
265265

266+
#pagefind-results-panel {
267+
overscroll-behavior: contain;
268+
}
269+
266270
/* Style pagefind results in dropdown */
267271
#pagefind-results .pagefind-ui {
268272
width: 100%;
269273
}
270274

275+
#pagefind-results {
276+
overscroll-behavior: contain;
277+
}
278+
271279
#pagefind-results .pagefind-ui__drawer {
272280
display: flex;
273281
flex-direction: column;
274282
gap: 0;
275283
width: 100%;
284+
overscroll-behavior: contain;
276285
}
277286

278287
#pagefind-results .pagefind-ui__drawer > *:not(.pagefind-ui__results-area) {
@@ -284,8 +293,16 @@
284293
min-width: 0;
285294
flex: none;
286295
margin-top: 0;
287-
max-height: min(60vh, 28rem);
296+
max-height: min(65vh, 32rem);
288297
overflow-y: auto;
298+
overscroll-behavior: contain;
299+
-webkit-overflow-scrolling: touch;
300+
}
301+
302+
@media (max-width: 767px) {
303+
#pagefind-results .pagefind-ui__results-area {
304+
max-height: calc(100vh - 8.5rem);
305+
}
289306
}
290307

291308
#pagefind-results .pagefind-ui__results {
@@ -350,6 +367,36 @@
350367
padding: 0.05rem 0.25rem;
351368
}
352369

370+
/* Pagefind "Load more results" button & infinite-scroll styling */
371+
#pagefind-results .pagefind-ui__button {
372+
width: 100%;
373+
padding: 0.625rem 1rem;
374+
margin: 0.5rem 0;
375+
border-radius: 0.5rem;
376+
border: 1px dashed var(--border);
377+
background: color-mix(in srgb, var(--muted) 40%, transparent);
378+
color: var(--muted-foreground);
379+
font-size: 0.8125rem;
380+
font-weight: 500;
381+
cursor: pointer;
382+
text-align: center;
383+
transition: all 0.2s ease;
384+
display: flex;
385+
align-items: center;
386+
justify-content: center;
387+
gap: 0.5rem;
388+
}
389+
390+
#pagefind-results .pagefind-ui__button:hover {
391+
background: var(--accent);
392+
color: var(--foreground);
393+
}
394+
395+
#pagefind-results .pagefind-ui__button:focus-visible {
396+
outline: 2px solid var(--ring);
397+
outline-offset: 2px;
398+
}
399+
353400
/* Subtle scrollbar for search results */
354401
#pagefind-results .pagefind-ui__results-area::-webkit-scrollbar {
355402
width: 6px;

0 commit comments

Comments
 (0)