7 changed files with 143 additions and 4 deletions
@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
|
||||
import { elementIsNearViewport } from '@/hooks/useNearViewport' |
||||
import { describe, expect, it } from 'vitest' |
||||
|
||||
describe('elementIsNearViewport', () => { |
||||
it('returns true when element rect overlaps expanded viewport', () => { |
||||
const el = { |
||||
getBoundingClientRect: () => ({ |
||||
top: 10, |
||||
bottom: 50, |
||||
left: 0, |
||||
right: 100 |
||||
}) |
||||
} as HTMLElement |
||||
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true }) |
||||
Object.defineProperty(window, 'innerWidth', { value: 1200, configurable: true }) |
||||
expect(elementIsNearViewport(el, 100)).toBe(true) |
||||
}) |
||||
|
||||
it('returns false when element is far below the fold', () => { |
||||
const el = { |
||||
getBoundingClientRect: () => ({ |
||||
top: 2000, |
||||
bottom: 2100, |
||||
left: 0, |
||||
right: 100 |
||||
}) |
||||
} as HTMLElement |
||||
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true }) |
||||
Object.defineProperty(window, 'innerWidth', { value: 1200, configurable: true }) |
||||
expect(elementIsNearViewport(el, 50)).toBe(false) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useLayoutEffect, useState, type RefObject } from 'react' |
||||
|
||||
/** Pixels beyond the viewport edge to treat as visible (prefetch before scroll lands). */ |
||||
export const NEAR_VIEWPORT_MARGIN_PX = 320 |
||||
|
||||
export function elementIsNearViewport(el: HTMLElement, marginPx: number): boolean { |
||||
const rect = el.getBoundingClientRect() |
||||
const vh = window.innerHeight |
||||
const vw = window.innerWidth |
||||
return ( |
||||
rect.bottom >= -marginPx && |
||||
rect.top <= vh + marginPx && |
||||
rect.right >= -marginPx && |
||||
rect.left <= vw + marginPx |
||||
) |
||||
} |
||||
|
||||
/** |
||||
* True when `ref` points at an element intersecting the viewport (with margin). |
||||
* When `enabled` is false, returns true immediately (no deferral). |
||||
*/ |
||||
export function useNearViewport( |
||||
ref: RefObject<HTMLElement | null>, |
||||
options?: { enabled?: boolean; marginPx?: number } |
||||
): boolean { |
||||
const enabled = options?.enabled !== false |
||||
const marginPx = options?.marginPx ?? NEAR_VIEWPORT_MARGIN_PX |
||||
const [isNear, setIsNear] = useState(() => !enabled) |
||||
|
||||
useLayoutEffect(() => { |
||||
if (!enabled) { |
||||
setIsNear(true) |
||||
return |
||||
} |
||||
const el = ref.current |
||||
if (!el) { |
||||
setIsNear(false) |
||||
return |
||||
} |
||||
if (elementIsNearViewport(el, marginPx)) { |
||||
setIsNear(true) |
||||
return |
||||
} |
||||
setIsNear(false) |
||||
}, [enabled, marginPx, ref]) |
||||
|
||||
useEffect(() => { |
||||
if (!enabled || isNear) return |
||||
if (typeof IntersectionObserver === 'undefined') { |
||||
setIsNear(true) |
||||
return |
||||
} |
||||
let io: IntersectionObserver | null = null |
||||
let raf = 0 |
||||
const attach = () => { |
||||
const el = ref.current |
||||
if (!el || io) return |
||||
io = new IntersectionObserver( |
||||
(entries) => { |
||||
if (entries.some((e) => e.isIntersecting)) { |
||||
setIsNear(true) |
||||
} |
||||
}, |
||||
{ root: null, rootMargin: `${marginPx}px`, threshold: 0.01 } |
||||
) |
||||
io.observe(el) |
||||
} |
||||
raf = requestAnimationFrame(attach) |
||||
return () => { |
||||
cancelAnimationFrame(raf) |
||||
io?.disconnect() |
||||
} |
||||
}, [enabled, isNear, marginPx, ref]) |
||||
|
||||
return isNear |
||||
} |
||||
Loading…
Reference in new issue