@@ -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 />
0 commit comments