domet 1.1.1 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -2
- package/dist/cjs/index.d.ts +1 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +70 -14
- package/dist/es/index.d.mts +1 -0
- package/dist/es/index.d.mts.map +1 -1
- package/dist/es/index.mjs +71 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,6 +87,7 @@ Callbacks do not fire while `lockActive` is enabled during programmatic scroll.
|
|
|
87
87
|
| `sections` | `Record<string, SectionState>` | Per-section state indexed by ID |
|
|
88
88
|
| `register` | `(id: string) => RegisterProps` | Props to spread on section elements (includes id, ref, data-domet) |
|
|
89
89
|
| `link` | `(id: string, options?: ScrollToOptions) => LinkProps` | Nav props (onClick, aria-current, data-active) with optional scroll overrides |
|
|
90
|
+
| `navRef` | `(id: string) => (el: HTMLElement \| null) => void` | Ref callback for nav items; auto-scrolls active item into view in scrollable nav containers |
|
|
90
91
|
| `scrollTo` | `(target: ScrollTarget, options?: ScrollToOptions) => void` | Programmatically scroll to a section or absolute scroll position |
|
|
91
92
|
|
|
92
93
|
### Types
|
|
@@ -119,7 +120,7 @@ type ScrollingOptions = {
|
|
|
119
120
|
}
|
|
120
121
|
```
|
|
121
122
|
|
|
122
|
-
If `position` is omitted for ID targets, Domet uses a dynamic alignment that keeps the trigger line within the section and prefers centering sections that fit in the viewport.
|
|
123
|
+
If `position` is omitted for ID targets, Domet uses a dynamic alignment that keeps the trigger line within the section and prefers centering sections that fit in the viewport. When `position: "center"` is set, sections that fit in the viewport are centered; sections taller than the viewport align to the top instead (respecting `scrolling.offset`).
|
|
123
124
|
|
|
124
125
|
### ScrollState
|
|
125
126
|
|
|
@@ -256,6 +257,24 @@ return (
|
|
|
256
257
|
)
|
|
257
258
|
```
|
|
258
259
|
|
|
260
|
+
### Scrollable Navigation
|
|
261
|
+
|
|
262
|
+
Keep the active nav item visible in a scrollable navigation container:
|
|
263
|
+
|
|
264
|
+
```tsx showLineNumbers
|
|
265
|
+
const { link, navRef } = useDomet({ ids })
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<nav style={{ maxHeight: '200px', overflow: 'auto' }}>
|
|
269
|
+
{ids.map(id => (
|
|
270
|
+
<button key={id} ref={navRef(id)} {...link(id)}>
|
|
271
|
+
{id}
|
|
272
|
+
</button>
|
|
273
|
+
))}
|
|
274
|
+
</nav>
|
|
275
|
+
)
|
|
276
|
+
```
|
|
277
|
+
|
|
259
278
|
### Third-party Components
|
|
260
279
|
|
|
261
280
|
If a third-party component only accepts a `ref` prop (no spread), extract the ref from `register`:
|
|
@@ -305,6 +324,6 @@ The name **domet** comes from Bosnian/Serbian/Croatian and means "reach" or "ran
|
|
|
305
324
|
|
|
306
325
|
For issues or feature requests, open an issue on [GitHub](https://github.com/blksmr/domet).
|
|
307
326
|
|
|
308
|
-
For LLMs, the full documentation is available at [/llms.txt](/llms.txt).
|
|
327
|
+
For LLMs, the full documentation is available at [/llms.txt](./website/public/llms.txt).
|
|
309
328
|
|
|
310
329
|
You can also reach out to me on [Twitter](https://x.com/blkasmir).
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -90,6 +90,7 @@ type UseDometReturn = {
|
|
|
90
90
|
scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void;
|
|
91
91
|
register: (id: string) => RegisterProps;
|
|
92
92
|
link: (id: string, options?: ScrollToOptions) => LinkProps;
|
|
93
|
+
navRef: (id: string) => (el: HTMLElement | null) => void;
|
|
93
94
|
};
|
|
94
95
|
|
|
95
96
|
declare function useDomet(options: DometOptions): UseDometReturn;
|
package/dist/cjs/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sources":["../../src/types.ts","../../src/useDomet/index.ts","../../src/utils/validation.ts"],"sourcesContent":["import type { RefObject } from \"react\";\n\nexport type ScrollContainer = RefObject<HTMLElement | null>;\n\nexport type Offset = number | `${number}%`;\n\nexport type SectionBounds = {\n top: number;\n bottom: number;\n height: number;\n};\n\nexport type ScrollState = {\n y: number;\n progress: number;\n direction: \"up\" | \"down\" | null;\n velocity: number;\n scrolling: boolean;\n maxScroll: number;\n viewportHeight: number;\n trackingOffset: number;\n triggerLine: number;\n};\n\nexport type SectionState = {\n bounds: SectionBounds;\n visibility: number;\n progress: number;\n inView: boolean;\n active: boolean;\n rect: DOMRect | null;\n};\n\nexport type ScrollBehavior = \"smooth\" | \"instant\" | \"auto\";\n\nexport type ScrollToPosition = \"top\" | \"center\" | \"bottom\";\n\nexport type ScrollTarget =\n | string\n | { id: string }\n | { top: number };\n\nexport type ScrollToOptions = {\n offset?: Offset;\n behavior?: ScrollBehavior;\n position?: ScrollToPosition;\n lockActive?: boolean;\n};\n\nexport type TrackingOptions = {\n offset?: Offset;\n threshold?: number;\n hysteresis?: number;\n throttle?: number;\n};\n\nexport type ScrollingOptions = ScrollToOptions;\n\nexport type DometOptions = {\n ids: string[];\n selector?: never;\n container?: ScrollContainer;\n tracking?: TrackingOptions;\n scrolling?: ScrollingOptions;\n onActive?: (id: string | null, prevId: string | null) => void;\n onEnter?: (id: string) => void;\n onLeave?: (id: string) => void;\n onScrollStart?: () => void;\n onScrollEnd?: () => void;\n} | {\n ids?: never;\n selector: string;\n container?: ScrollContainer;\n tracking?: TrackingOptions;\n scrolling?: ScrollingOptions;\n onActive?: (id: string | null, prevId: string | null) => void;\n onEnter?: (id: string) => void;\n onLeave?: (id: string) => void;\n onScrollStart?: () => void;\n onScrollEnd?: () => void;\n};\n\nexport type RegisterProps = {\n id: string;\n ref: (el: HTMLElement | null) => void;\n \"data-domet\": string;\n};\n\nexport type LinkProps = {\n onClick: () => void;\n \"aria-current\": \"page\" | undefined;\n \"data-active\": boolean;\n};\n\nexport type UseDometReturn = {\n active: string | null;\n index: number;\n progress: number;\n direction: \"up\" | \"down\" | null;\n scroll: ScrollState;\n sections: Record<string, SectionState>;\n ids: string[];\n scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void;\n register: (id: string) => RegisterProps;\n link: (id: string, options?: ScrollToOptions) => LinkProps;\n};\n\nexport type ResolvedSection = {\n id: string;\n element: HTMLElement;\n};\n\nexport type InternalSectionBounds = SectionBounds & { id: string };\n\nexport type SectionScore = {\n id: string;\n score: number;\n visibilityRatio: number;\n inView: boolean;\n bounds: InternalSectionBounds;\n progress: number;\n rect: DOMRect | null;\n};\n","import {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport type {\n DometOptions,\n LinkProps,\n RegisterProps,\n ResolvedSection,\n ScrollBehavior,\n ScrollState,\n ScrollTarget,\n ScrollToOptions,\n ScrollToPosition,\n SectionState,\n UseDometReturn,\n} from \"../types\";\n\nimport {\n DEFAULT_OFFSET,\n SCROLL_IDLE_MS,\n} from \"../constants\";\n\nimport {\n resolveContainer,\n resolveSectionsFromIds,\n resolveSectionsFromSelector,\n resolveOffset,\n getSectionBounds,\n calculateSectionScores,\n determineActiveSection,\n sanitizeOffset,\n sanitizeThreshold,\n sanitizeHysteresis,\n sanitizeThrottle,\n sanitizeIds,\n sanitizeSelector,\n useIsomorphicLayoutEffect,\n areIdInputsEqual,\n} from \"../utils\";\n\n\nexport function useDomet(options: DometOptions): UseDometReturn {\n const {\n container: containerInput,\n tracking,\n scrolling,\n onActive,\n onEnter,\n onLeave,\n onScrollStart,\n onScrollEnd,\n } = options;\n\n const trackingOffset = sanitizeOffset(tracking?.offset);\n const throttle = sanitizeThrottle(tracking?.throttle);\n const threshold = sanitizeThreshold(tracking?.threshold);\n const hysteresis = sanitizeHysteresis(tracking?.hysteresis);\n const scrollingDefaults = useMemo(() => {\n if (!scrolling) {\n return {\n behavior: \"auto\" as ScrollBehavior,\n offset: undefined,\n position: undefined,\n lockActive: undefined,\n };\n }\n\n return {\n behavior: scrolling.behavior ?? \"auto\",\n offset: scrolling.offset !== undefined\n ? sanitizeOffset(scrolling.offset)\n : undefined,\n position: scrolling.position,\n lockActive: scrolling.lockActive,\n };\n }, [scrolling]);\n\n const rawIds = \"ids\" in options ? options.ids : undefined;\n const rawSelector = \"selector\" in options ? options.selector : undefined;\n\n const idsCacheRef = useRef<{\n raw: unknown;\n sanitized: string[] | undefined;\n }>({ raw: undefined, sanitized: undefined });\n\n const idsArray = useMemo(() => {\n if (rawIds === undefined) {\n idsCacheRef.current = { raw: undefined, sanitized: undefined };\n return undefined;\n }\n\n if (areIdInputsEqual(rawIds, idsCacheRef.current.raw)) {\n idsCacheRef.current.raw = rawIds;\n return idsCacheRef.current.sanitized;\n }\n\n const sanitized = sanitizeIds(rawIds);\n idsCacheRef.current = { raw: rawIds, sanitized };\n return sanitized;\n }, [rawIds]);\n\n const selectorString = useMemo(() => {\n if (rawSelector === undefined) return undefined;\n return sanitizeSelector(rawSelector);\n }, [rawSelector]);\n const useSelector = selectorString !== undefined && selectorString !== \"\";\n\n const initialActiveId = idsArray && idsArray.length > 0 ? idsArray[0] : null;\n\n const [containerElement, setContainerElement] = useState<HTMLElement | null>(null);\n const [resolvedSections, setResolvedSections] = useState<ResolvedSection[]>([]);\n const [activeId, setActiveId] = useState<string | null>(initialActiveId);\n const [scroll, setScroll] = useState<ScrollState>({\n y: 0,\n progress: 0,\n direction: null,\n velocity: 0,\n scrolling: false,\n maxScroll: 0,\n viewportHeight: 0,\n trackingOffset: 0,\n triggerLine: 0,\n });\n const [sections, setSections] = useState<Record<string, SectionState>>({});\n\n const refs = useRef<Record<string, HTMLElement | null>>({});\n const refCallbacks = useRef<Record<string, (el: HTMLElement | null) => void>>({});\n const registerPropsCache = useRef<Record<string, RegisterProps>>({});\n const activeIdRef = useRef<string | null>(initialActiveId);\n const lastScrollY = useRef<number>(0);\n const lastScrollTime = useRef<number>(Date.now());\n const rafId = useRef<number | null>(null);\n const isThrottled = useRef<boolean>(false);\n const throttleTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);\n const hasPendingScroll = useRef<boolean>(false);\n const isProgrammaticScrolling = useRef<boolean>(false);\n const isScrollingRef = useRef<boolean>(false);\n const scrollIdleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const prevSectionsInViewport = useRef<Set<string>>(new Set());\n const recalculateRef = useRef<() => void>(() => {});\n const scheduleRecalculate = useCallback(() => {\n if (typeof window === \"undefined\") return;\n if (rafId.current) {\n cancelAnimationFrame(rafId.current);\n }\n rafId.current = requestAnimationFrame(() => {\n rafId.current = null;\n recalculateRef.current();\n });\n }, []);\n const scrollCleanupRef = useRef<(() => void) | null>(null);\n const mutationObserverRef = useRef<MutationObserver | null>(null);\n const mutationDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const optionsRef = useRef({ trackingOffset, scrolling: scrollingDefaults });\n const callbackRefs = useRef({\n onActive,\n onEnter,\n onLeave,\n onScrollStart,\n onScrollEnd,\n });\n\n useIsomorphicLayoutEffect(() => {\n optionsRef.current = { trackingOffset, scrolling: scrollingDefaults };\n }, [trackingOffset, scrollingDefaults]);\n\n useEffect(() => {\n scheduleRecalculate();\n }, [trackingOffset, scheduleRecalculate]);\n\n useIsomorphicLayoutEffect(() => {\n callbackRefs.current = {\n onActive,\n onEnter,\n onLeave,\n onScrollStart,\n onScrollEnd,\n };\n }, [onActive, onEnter, onLeave, onScrollStart, onScrollEnd]);\n\n const sectionIds = useMemo(() => {\n if (!useSelector && idsArray) return idsArray;\n return resolvedSections.map((s) => s.id);\n }, [useSelector, idsArray, resolvedSections]);\n\n const sectionIndexMap = useMemo(() => {\n const map = new Map<string, number>();\n for (let i = 0; i < sectionIds.length; i++) {\n map.set(sectionIds[i], i);\n }\n return map;\n }, [sectionIds]);\n\n const containerRefCurrent = containerInput?.current ?? null;\n\n useIsomorphicLayoutEffect(() => {\n const resolved = resolveContainer(containerInput);\n if (resolved !== containerElement) {\n setContainerElement(resolved);\n }\n }, [containerInput, containerRefCurrent]);\n\n const updateSectionsFromSelector = useCallback((selector: string) => {\n const resolved = resolveSectionsFromSelector(selector);\n setResolvedSections(resolved);\n if (resolved.length > 0) {\n const currentStillExists = resolved.some((s) => s.id === activeIdRef.current);\n if (!activeIdRef.current || !currentStillExists) {\n activeIdRef.current = resolved[0].id;\n setActiveId(resolved[0].id);\n }\n } else if (activeIdRef.current !== null) {\n activeIdRef.current = null;\n setActiveId(null);\n }\n }, []);\n\n useIsomorphicLayoutEffect(() => {\n if (useSelector && selectorString) {\n updateSectionsFromSelector(selectorString);\n }\n }, [selectorString, useSelector, updateSectionsFromSelector]);\n\n useEffect(() => {\n if (\n !useSelector ||\n !selectorString ||\n typeof window === \"undefined\" ||\n typeof MutationObserver === \"undefined\"\n ) {\n return;\n }\n\n const handleMutation = () => {\n if (mutationDebounceRef.current) {\n clearTimeout(mutationDebounceRef.current);\n }\n mutationDebounceRef.current = setTimeout(() => {\n updateSectionsFromSelector(selectorString);\n }, 50);\n };\n\n mutationObserverRef.current = new MutationObserver(handleMutation);\n mutationObserverRef.current.observe(document.body, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: [\"id\", \"data-domet\"],\n });\n\n return () => {\n if (mutationDebounceRef.current) {\n clearTimeout(mutationDebounceRef.current);\n mutationDebounceRef.current = null;\n }\n if (mutationObserverRef.current) {\n mutationObserverRef.current.disconnect();\n mutationObserverRef.current = null;\n }\n };\n }, [useSelector, selectorString, updateSectionsFromSelector]);\n\n useEffect(() => {\n if (!useSelector && idsArray) {\n const idsSet = new Set(idsArray);\n\n for (const id of Object.keys(refs.current)) {\n if (!idsSet.has(id)) {\n delete refs.current[id];\n }\n }\n\n for (const id of Object.keys(refCallbacks.current)) {\n if (!idsSet.has(id)) {\n delete refCallbacks.current[id];\n }\n }\n\n const currentActive = activeIdRef.current;\n const nextActive =\n currentActive && idsSet.has(currentActive)\n ? currentActive\n : (idsArray[0] ?? null);\n\n if (nextActive !== currentActive) {\n activeIdRef.current = nextActive;\n setActiveId(nextActive);\n }\n }\n }, [idsArray, useSelector]);\n\n const registerRef = useCallback((id: string) => {\n const existing = refCallbacks.current[id];\n if (existing) return existing;\n\n const callback = (el: HTMLElement | null) => {\n if (el) {\n refs.current[id] = el;\n } else {\n delete refs.current[id];\n }\n scheduleRecalculate();\n };\n\n refCallbacks.current[id] = callback;\n return callback;\n }, [scheduleRecalculate]);\n\n const getResolvedBehavior = useCallback((behaviorOverride?: ScrollBehavior): ScrollBehavior => {\n const b = behaviorOverride ?? optionsRef.current.scrolling.behavior;\n if (b === \"auto\") {\n if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") {\n return \"smooth\";\n }\n const prefersReducedMotion = window.matchMedia(\n \"(prefers-reduced-motion: reduce)\",\n ).matches;\n return prefersReducedMotion ? \"instant\" : \"smooth\";\n }\n return b;\n }, []);\n\n const getCurrentSections = useCallback((): ResolvedSection[] => {\n if (!useSelector && idsArray) {\n return resolveSectionsFromIds(idsArray, refs.current);\n }\n return resolvedSections;\n }, [useSelector, idsArray, resolvedSections]);\n\n const scrollTo = useCallback(\n (target: ScrollTarget, scrollOptions?: ScrollToOptions): void => {\n const resolvedTarget = typeof target === \"string\"\n ? { type: \"id\" as const, id: target }\n : \"id\" in target\n ? { type: \"id\" as const, id: target.id }\n : { type: \"top\" as const, top: target.top };\n\n const defaultScroll = optionsRef.current.scrolling;\n const lockActive = scrollOptions?.lockActive\n ?? defaultScroll.lockActive\n ?? resolvedTarget.type === \"id\";\n const container = containerElement;\n const scrollTarget = container || window;\n const viewportHeight = container ? container.clientHeight : window.innerHeight;\n const scrollHeight = container\n ? container.scrollHeight\n : document.documentElement.scrollHeight;\n const maxScroll = Math.max(0, scrollHeight - viewportHeight);\n const scrollBehavior = getResolvedBehavior(\n scrollOptions?.behavior ?? defaultScroll.behavior,\n );\n const offsetCandidate = scrollOptions?.offset\n ?? defaultScroll.offset;\n const offsetValue = sanitizeOffset(offsetCandidate);\n const effectiveOffset = resolveOffset(offsetValue, viewportHeight, DEFAULT_OFFSET);\n\n const stopProgrammaticScroll = () => {\n if (scrollCleanupRef.current) {\n scrollCleanupRef.current();\n scrollCleanupRef.current = null;\n }\n isProgrammaticScrolling.current = false;\n };\n\n if (!lockActive) {\n stopProgrammaticScroll();\n } else if (scrollCleanupRef.current) {\n scrollCleanupRef.current();\n }\n\n const setupLock = () => {\n const unlockScroll = () => {\n isProgrammaticScrolling.current = false;\n };\n\n let debounceTimer: ReturnType<typeof setTimeout> | null = null;\n let isUnlocked = false;\n\n const cleanup = () => {\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n debounceTimer = null;\n }\n scrollTarget.removeEventListener(\"scroll\", handleScrollActivity);\n if (\"onscrollend\" in scrollTarget) {\n scrollTarget.removeEventListener(\"scrollend\", handleScrollEnd);\n }\n scrollCleanupRef.current = null;\n };\n\n const doUnlock = () => {\n if (isUnlocked) return;\n isUnlocked = true;\n cleanup();\n unlockScroll();\n };\n\n const resetDebounce = () => {\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n }\n debounceTimer = setTimeout(doUnlock, SCROLL_IDLE_MS);\n };\n\n const handleScrollActivity = () => {\n resetDebounce();\n };\n\n const handleScrollEnd = () => {\n doUnlock();\n };\n\n scrollTarget.addEventListener(\"scroll\", handleScrollActivity, {\n passive: true,\n });\n\n if (\"onscrollend\" in scrollTarget) {\n scrollTarget.addEventListener(\"scrollend\", handleScrollEnd, {\n once: true,\n });\n }\n\n scrollCleanupRef.current = cleanup;\n\n return { doUnlock, resetDebounce };\n };\n\n const clampValue = (value: number, min: number, max: number): number =>\n Math.max(min, Math.min(max, value));\n\n let targetScroll: number | null = null;\n let activeTargetId: string | null = null;\n\n if (resolvedTarget.type === \"id\") {\n const id = resolvedTarget.id;\n if (!sectionIndexMap.has(id)) {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`[domet] scrollTo: id \"${id}\" not found`);\n }\n return;\n }\n\n const currentSections = getCurrentSections();\n const section = currentSections.find((s) => s.id === id);\n if (!section) {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`[domet] scrollTo: element for id \"${id}\" not yet mounted`);\n }\n return;\n }\n\n const elementRect = section.element.getBoundingClientRect();\n\n const position: ScrollToPosition | undefined =\n scrollOptions?.position ?? defaultScroll.position;\n\n const sectionTop = container\n ? elementRect.top - container.getBoundingClientRect().top + container.scrollTop\n : elementRect.top + window.scrollY;\n const sectionHeight = elementRect.height;\n\n const calculateTargetScroll = (): number => {\n if (maxScroll <= 0) return 0;\n\n const topTarget = sectionTop - effectiveOffset;\n const centerTarget = sectionTop - (viewportHeight - sectionHeight) / 2;\n const bottomTarget = sectionTop + sectionHeight - viewportHeight;\n\n if (position === \"top\") {\n return clampValue(topTarget, 0, maxScroll);\n }\n\n if (position === \"center\") {\n return clampValue(centerTarget, 0, maxScroll);\n }\n\n if (position === \"bottom\") {\n return clampValue(bottomTarget, 0, maxScroll);\n }\n\n const fits = sectionHeight <= viewportHeight;\n\n const dynamicRange = viewportHeight - effectiveOffset;\n const denominator = dynamicRange !== 0 ? 1 + dynamicRange / maxScroll : 1;\n\n const triggerMin = (sectionTop - effectiveOffset) / denominator;\n const triggerMax = (sectionTop + sectionHeight - effectiveOffset) / denominator;\n\n if (fits) {\n if (centerTarget >= triggerMin && centerTarget <= triggerMax) {\n return clampValue(centerTarget, 0, maxScroll);\n }\n\n if (centerTarget < triggerMin) {\n return clampValue(triggerMin, 0, maxScroll);\n }\n\n return clampValue(triggerMax, 0, maxScroll);\n }\n\n return clampValue(topTarget, 0, maxScroll);\n };\n\n targetScroll = calculateTargetScroll();\n activeTargetId = id;\n } else {\n const top = resolvedTarget.top;\n if (!Number.isFinite(top)) {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`[domet] scrollTo: top \"${top}\" is not a valid number`);\n }\n return;\n }\n targetScroll = clampValue(top - effectiveOffset, 0, maxScroll);\n }\n\n if (targetScroll === null) return;\n\n if (lockActive) {\n isProgrammaticScrolling.current = true;\n if (activeTargetId) {\n activeIdRef.current = activeTargetId;\n setActiveId(activeTargetId);\n }\n }\n\n const lockControls = lockActive ? setupLock() : null;\n\n if (container) {\n container.scrollTo({\n top: targetScroll,\n behavior: scrollBehavior,\n });\n } else {\n window.scrollTo({\n top: targetScroll,\n behavior: scrollBehavior,\n });\n }\n\n if (lockControls) {\n if (scrollBehavior === \"instant\") {\n lockControls.doUnlock();\n } else {\n lockControls.resetDebounce();\n }\n }\n },\n [sectionIndexMap, containerElement, getResolvedBehavior, getCurrentSections],\n );\n\n const register = useCallback(\n (id: string): RegisterProps => {\n const cached = registerPropsCache.current[id];\n if (cached) return cached;\n\n const props: RegisterProps = {\n id,\n ref: registerRef(id),\n \"data-domet\": id,\n };\n registerPropsCache.current[id] = props;\n return props;\n },\n [registerRef],\n );\n\n const link = useCallback(\n (id: string, options?: ScrollToOptions): LinkProps => ({\n onClick: () => scrollTo(id, options),\n \"aria-current\": activeId === id ? \"page\" : undefined,\n \"data-active\": activeId === id,\n }),\n [activeId, scrollTo],\n );\n\n const calculateActiveSection = useCallback(() => {\n const container = containerElement;\n const currentActiveId = activeIdRef.current;\n const now = Date.now();\n const scrollY = container ? container.scrollTop : window.scrollY;\n const viewportHeight = container ? container.clientHeight : window.innerHeight;\n const scrollHeight = container\n ? container.scrollHeight\n : document.documentElement.scrollHeight;\n const maxScroll = Math.max(1, scrollHeight - viewportHeight);\n const scrollProgress = Math.min(1, Math.max(0, scrollY / maxScroll));\n const scrollDirection: \"up\" | \"down\" | null =\n scrollY === lastScrollY.current\n ? null\n : scrollY > lastScrollY.current\n ? \"down\"\n : \"up\";\n const deltaTime = now - lastScrollTime.current;\n const deltaY = scrollY - lastScrollY.current;\n const velocity = deltaTime > 0 ? Math.abs(deltaY) / deltaTime : 0;\n\n lastScrollY.current = scrollY;\n lastScrollTime.current = now;\n\n const currentSections = getCurrentSections();\n const sectionBounds = getSectionBounds(currentSections, container);\n if (sectionBounds.length === 0) return;\n\n const effectiveOffset = resolveOffset(trackingOffset, viewportHeight, DEFAULT_OFFSET);\n\n const scores = calculateSectionScores(sectionBounds, currentSections, {\n scrollY,\n viewportHeight,\n scrollHeight,\n effectiveOffset,\n visibilityThreshold: threshold,\n scrollDirection,\n sectionIndexMap,\n });\n\n const isProgrammatic = isProgrammaticScrolling.current;\n\n const newActiveId = isProgrammatic\n ? currentActiveId\n : determineActiveSection(\n scores,\n sectionIds,\n currentActiveId,\n hysteresis,\n scrollY,\n viewportHeight,\n scrollHeight,\n );\n\n if (!isProgrammatic && newActiveId !== currentActiveId) {\n activeIdRef.current = newActiveId;\n setActiveId(newActiveId);\n callbackRefs.current.onActive?.(newActiveId, currentActiveId);\n }\n\n if (!isProgrammatic) {\n const currentInViewport = new Set(\n scores.filter((s) => s.inView).map((s) => s.id),\n );\n const prevInViewport = prevSectionsInViewport.current;\n\n for (const id of currentInViewport) {\n if (!prevInViewport.has(id)) {\n callbackRefs.current.onEnter?.(id);\n }\n }\n for (const id of prevInViewport) {\n if (!currentInViewport.has(id)) {\n callbackRefs.current.onLeave?.(id);\n }\n }\n prevSectionsInViewport.current = currentInViewport;\n }\n\n const triggerLine = Math.round(\n effectiveOffset + scrollProgress * (viewportHeight - effectiveOffset)\n );\n\n const newScrollState: ScrollState = {\n y: Math.round(scrollY),\n progress: Math.max(0, Math.min(1, scrollProgress)),\n direction: scrollDirection,\n velocity: Math.round(velocity),\n scrolling: isScrollingRef.current,\n maxScroll: Math.round(maxScroll),\n viewportHeight: Math.round(viewportHeight),\n trackingOffset: Math.round(effectiveOffset),\n triggerLine,\n };\n\n const newSections: Record<string, SectionState> = {};\n for (const s of scores) {\n newSections[s.id] = {\n bounds: {\n top: Math.round(s.bounds.top),\n bottom: Math.round(s.bounds.bottom),\n height: Math.round(s.bounds.height),\n },\n visibility: Math.round(s.visibilityRatio * 100) / 100,\n progress: Math.round(s.progress * 100) / 100,\n inView: s.inView,\n active: s.id === (isProgrammatic ? currentActiveId : newActiveId),\n rect: s.rect,\n };\n }\n\n setScroll(newScrollState);\n setSections(newSections);\n }, [\n sectionIds,\n sectionIndexMap,\n trackingOffset,\n threshold,\n hysteresis,\n containerElement,\n getCurrentSections,\n ]);\n\n recalculateRef.current = calculateActiveSection;\n\n useEffect(() => {\n const container = containerElement;\n const scrollTarget = container || window;\n\n const handleScrollEnd = (): void => {\n isScrollingRef.current = false;\n setScroll((prev) => ({ ...prev, scrolling: false }));\n callbackRefs.current.onScrollEnd?.();\n };\n\n const handleScroll = (): void => {\n if (!isScrollingRef.current) {\n isScrollingRef.current = true;\n setScroll((prev) => ({ ...prev, scrolling: true }));\n callbackRefs.current.onScrollStart?.();\n }\n\n if (scrollIdleTimeoutRef.current) {\n clearTimeout(scrollIdleTimeoutRef.current);\n }\n scrollIdleTimeoutRef.current = setTimeout(handleScrollEnd, SCROLL_IDLE_MS);\n\n if (isThrottled.current) {\n hasPendingScroll.current = true;\n return;\n }\n\n isThrottled.current = true;\n hasPendingScroll.current = false;\n\n if (throttleTimeoutId.current) {\n clearTimeout(throttleTimeoutId.current);\n }\n\n scheduleRecalculate();\n\n throttleTimeoutId.current = setTimeout(() => {\n isThrottled.current = false;\n throttleTimeoutId.current = null;\n\n if (hasPendingScroll.current) {\n hasPendingScroll.current = false;\n handleScroll();\n }\n }, throttle);\n };\n\n const handleResize = (): void => {\n if (useSelector && selectorString) {\n updateSectionsFromSelector(selectorString);\n }\n scheduleRecalculate();\n };\n\n const deferredRecalcId = setTimeout(() => {\n scheduleRecalculate();\n }, 0);\n\n scrollTarget.addEventListener(\"scroll\", handleScroll, { passive: true });\n window.addEventListener(\"resize\", handleResize, { passive: true });\n\n return () => {\n clearTimeout(deferredRecalcId);\n scrollTarget.removeEventListener(\"scroll\", handleScroll);\n window.removeEventListener(\"resize\", handleResize);\n if (rafId.current) {\n cancelAnimationFrame(rafId.current);\n rafId.current = null;\n }\n if (throttleTimeoutId.current) {\n clearTimeout(throttleTimeoutId.current);\n throttleTimeoutId.current = null;\n }\n if (scrollIdleTimeoutRef.current) {\n clearTimeout(scrollIdleTimeoutRef.current);\n scrollIdleTimeoutRef.current = null;\n }\n scrollCleanupRef.current?.();\n isThrottled.current = false;\n hasPendingScroll.current = false;\n isProgrammaticScrolling.current = false;\n isScrollingRef.current = false;\n };\n }, [throttle, containerElement, useSelector, selectorString, updateSectionsFromSelector, scheduleRecalculate]);\n\n const index = useMemo(() => {\n if (!activeId) return -1;\n return sectionIndexMap.get(activeId) ?? -1;\n }, [activeId, sectionIndexMap]);\n\n return {\n active: activeId,\n index,\n progress: scroll.progress,\n direction: scroll.direction,\n scroll,\n sections,\n ids: sectionIds,\n scrollTo,\n register,\n link,\n };\n}\n\nexport default useDomet;\n","import type { Offset } from \"../types\";\nimport {\n DEFAULT_OFFSET,\n DEFAULT_THRESHOLD,\n DEFAULT_HYSTERESIS,\n DEFAULT_THROTTLE,\n} from \"../constants\";\n\nconst PERCENT_REGEX = /^(-?\\d+(?:\\.\\d+)?)%$/;\n\nexport const VALIDATION_LIMITS = {\n offset: { min: -10000, max: 10000 },\n offsetPercent: { min: -500, max: 500 },\n threshold: { min: 0, max: 1 },\n hysteresis: { min: 0, max: 1000 },\n throttle: { min: 0, max: 1000 },\n} as const;\n\nfunction warn(message: string): void {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`[domet] ${message}`);\n }\n}\n\nfunction isFiniteNumber(value: unknown): value is number {\n return typeof value === \"number\" && Number.isFinite(value);\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.max(min, Math.min(max, value));\n}\n\nexport function sanitizeOffset(offset: Offset | undefined): Offset {\n if (offset === undefined) {\n return DEFAULT_OFFSET;\n }\n\n if (typeof offset === \"number\") {\n if (!isFiniteNumber(offset)) {\n warn(`Invalid offset value: ${offset}. Using default.`);\n return DEFAULT_OFFSET;\n }\n const { min, max } = VALIDATION_LIMITS.offset;\n if (offset < min || offset > max) {\n warn(`Offset ${offset} clamped to [${min}, ${max}].`);\n return clamp(offset, min, max);\n }\n return offset;\n }\n\n if (typeof offset === \"string\") {\n const trimmed = offset.trim();\n const match = PERCENT_REGEX.exec(trimmed);\n if (!match) {\n warn(`Invalid offset format: \"${offset}\". Using default.`);\n return DEFAULT_OFFSET;\n }\n const percent = parseFloat(match[1]);\n if (!isFiniteNumber(percent)) {\n warn(`Invalid percentage value in offset: \"${offset}\". Using default.`);\n return DEFAULT_OFFSET;\n }\n const { min, max } = VALIDATION_LIMITS.offsetPercent;\n if (percent < min || percent > max) {\n warn(`Offset percentage ${percent}% clamped to [${min}%, ${max}%].`);\n return `${clamp(percent, min, max)}%`;\n }\n return trimmed as `${number}%`;\n }\n\n warn(`Invalid offset type: ${typeof offset}. Using default.`);\n return DEFAULT_OFFSET;\n}\n\nexport function sanitizeThreshold(threshold: number | undefined): number {\n if (threshold === undefined) {\n return DEFAULT_THRESHOLD;\n }\n\n if (!isFiniteNumber(threshold)) {\n warn(`Invalid threshold value: ${threshold}. Using default.`);\n return DEFAULT_THRESHOLD;\n }\n\n const { min, max } = VALIDATION_LIMITS.threshold;\n if (threshold < min || threshold > max) {\n warn(`Threshold ${threshold} clamped to [${min}, ${max}].`);\n return clamp(threshold, min, max);\n }\n\n return threshold;\n}\n\nexport function sanitizeHysteresis(hysteresis: number | undefined): number {\n if (hysteresis === undefined) {\n return DEFAULT_HYSTERESIS;\n }\n\n if (!isFiniteNumber(hysteresis)) {\n warn(`Invalid hysteresis value: ${hysteresis}. Using default.`);\n return DEFAULT_HYSTERESIS;\n }\n\n const { min, max } = VALIDATION_LIMITS.hysteresis;\n if (hysteresis < min || hysteresis > max) {\n warn(`Hysteresis ${hysteresis} clamped to [${min}, ${max}].`);\n return clamp(hysteresis, min, max);\n }\n\n return hysteresis;\n}\n\nexport function sanitizeThrottle(throttle: number | undefined): number {\n if (throttle === undefined) {\n return DEFAULT_THROTTLE;\n }\n\n if (!isFiniteNumber(throttle)) {\n warn(`Invalid throttle value: ${throttle}. Using default.`);\n return DEFAULT_THROTTLE;\n }\n\n const { min, max } = VALIDATION_LIMITS.throttle;\n if (throttle < min || throttle > max) {\n warn(`Throttle ${throttle} clamped to [${min}, ${max}].`);\n return clamp(throttle, min, max);\n }\n\n return throttle;\n}\n\nexport function sanitizeIds(ids: string[] | undefined): string[] {\n if (!ids || !Array.isArray(ids)) {\n warn(\"Invalid ids: expected an array. Using empty array.\");\n return [];\n }\n\n const seen = new Set<string>();\n const sanitized: string[] = [];\n\n for (const id of ids) {\n if (typeof id !== \"string\") {\n warn(`Invalid id type: ${typeof id}. Skipping.`);\n continue;\n }\n\n const trimmed = id.trim();\n if (trimmed === \"\") {\n warn(\"Empty string id detected. Skipping.\");\n continue;\n }\n\n if (seen.has(trimmed)) {\n warn(`Duplicate id \"${trimmed}\" detected. Skipping.`);\n continue;\n }\n\n seen.add(trimmed);\n sanitized.push(trimmed);\n }\n\n return sanitized;\n}\n\nexport function sanitizeSelector(selector: string | undefined): string {\n if (selector === undefined) {\n return \"\";\n }\n\n if (typeof selector !== \"string\") {\n warn(`Invalid selector type: ${typeof selector}. Using empty string.`);\n return \"\";\n }\n\n const trimmed = selector.trim();\n if (trimmed === \"\") {\n warn(\"Empty selector provided.\");\n }\n\n return trimmed;\n}\n"],"names":[],"mappings":";;AACO,KAAA,eAAA,GAAA,SAAA,CAAA,WAAA;AACA,KAAA,MAAA;AACA,KAAA,aAAA;AACP;AACA;AACA;AACA;AACO,KAAA,WAAA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,KAAA,YAAA;AACP,YAAA,aAAA;AACA;AACA;AACA;AACA;AACA,UAAA,OAAA;AACA;AACO,KAAA,cAAA;AACA,KAAA,gBAAA;AACA,KAAA,YAAA;AACP;AACA;AACA;AACA;AACO,KAAA,eAAA;AACP,aAAA,MAAA;AACA,eAAA,cAAA;AACA,eAAA,gBAAA;AACA;AACA;AACO,KAAA,eAAA;AACP,aAAA,MAAA;AACA;AACA;AACA;AACA;AACO,KAAA,gBAAA,GAAA,eAAA;AACA,KAAA,YAAA;AACP;AACA;AACA,gBAAA,eAAA;AACA,eAAA,eAAA;AACA,gBAAA,gBAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,gBAAA,eAAA;AACA,eAAA,eAAA;AACA,gBAAA,gBAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,KAAA,aAAA;AACP;AACA,cAAA,WAAA;AACA;AACA;AACO,KAAA,SAAA;AACP;AACA;AACA;AACA;AACO,KAAA,cAAA;AACP;AACA;AACA;AACA;AACA,YAAA,WAAA;AACA,cAAA,MAAA,SAAA,YAAA;AACA;AACA,uBAAA,YAAA,YAAA,eAAA;AACA,8BAAA,aAAA;AACA,iCAAA,eAAA,KAAA,SAAA;AACA;;AC1FO,iBAAA,QAAA,UAAA,YAAA,GAAA,cAAA;;ACAA,cAAA,iBAAA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;;;"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sources":["../../src/types.ts","../../src/useDomet/index.ts","../../src/utils/validation.ts"],"sourcesContent":["import type { RefObject } from \"react\";\n\nexport type ScrollContainer = RefObject<HTMLElement | null>;\n\nexport type Offset = number | `${number}%`;\n\nexport type SectionBounds = {\n top: number;\n bottom: number;\n height: number;\n};\n\nexport type ScrollState = {\n y: number;\n progress: number;\n direction: \"up\" | \"down\" | null;\n velocity: number;\n scrolling: boolean;\n maxScroll: number;\n viewportHeight: number;\n trackingOffset: number;\n triggerLine: number;\n};\n\nexport type SectionState = {\n bounds: SectionBounds;\n visibility: number;\n progress: number;\n inView: boolean;\n active: boolean;\n rect: DOMRect | null;\n};\n\nexport type ScrollBehavior = \"smooth\" | \"instant\" | \"auto\";\n\nexport type ScrollToPosition = \"top\" | \"center\" | \"bottom\";\n\nexport type ScrollTarget =\n | string\n | { id: string }\n | { top: number };\n\nexport type ScrollToOptions = {\n offset?: Offset;\n behavior?: ScrollBehavior;\n position?: ScrollToPosition;\n lockActive?: boolean;\n};\n\nexport type TrackingOptions = {\n offset?: Offset;\n threshold?: number;\n hysteresis?: number;\n throttle?: number;\n};\n\nexport type ScrollingOptions = ScrollToOptions;\n\nexport type DometOptions = {\n ids: string[];\n selector?: never;\n container?: ScrollContainer;\n tracking?: TrackingOptions;\n scrolling?: ScrollingOptions;\n onActive?: (id: string | null, prevId: string | null) => void;\n onEnter?: (id: string) => void;\n onLeave?: (id: string) => void;\n onScrollStart?: () => void;\n onScrollEnd?: () => void;\n} | {\n ids?: never;\n selector: string;\n container?: ScrollContainer;\n tracking?: TrackingOptions;\n scrolling?: ScrollingOptions;\n onActive?: (id: string | null, prevId: string | null) => void;\n onEnter?: (id: string) => void;\n onLeave?: (id: string) => void;\n onScrollStart?: () => void;\n onScrollEnd?: () => void;\n};\n\nexport type RegisterProps = {\n id: string;\n ref: (el: HTMLElement | null) => void;\n \"data-domet\": string;\n};\n\nexport type LinkProps = {\n onClick: () => void;\n \"aria-current\": \"page\" | undefined;\n \"data-active\": boolean;\n};\n\nexport type UseDometReturn = {\n active: string | null;\n index: number;\n progress: number;\n direction: \"up\" | \"down\" | null;\n scroll: ScrollState;\n sections: Record<string, SectionState>;\n ids: string[];\n scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void;\n register: (id: string) => RegisterProps;\n link: (id: string, options?: ScrollToOptions) => LinkProps;\n navRef: (id: string) => (el: HTMLElement | null) => void;\n};\n\nexport type ResolvedSection = {\n id: string;\n element: HTMLElement;\n};\n\nexport type InternalSectionBounds = SectionBounds & { id: string; rect: DOMRect };\n\nexport type SectionScore = {\n id: string;\n score: number;\n visibilityRatio: number;\n inView: boolean;\n bounds: InternalSectionBounds;\n progress: number;\n rect: DOMRect | null;\n};\n","import {\n startTransition,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport type {\n DometOptions,\n LinkProps,\n RegisterProps,\n ResolvedSection,\n ScrollBehavior,\n ScrollState,\n ScrollTarget,\n ScrollToOptions,\n ScrollToPosition,\n SectionState,\n UseDometReturn,\n} from \"../types\";\n\nimport {\n DEFAULT_OFFSET,\n SCROLL_IDLE_MS,\n} from \"../constants\";\n\nimport {\n resolveContainer,\n resolveSectionsFromIds,\n resolveSectionsFromSelector,\n resolveOffset,\n getSectionBounds,\n calculateSectionScores,\n determineActiveSection,\n sanitizeOffset,\n sanitizeThreshold,\n sanitizeHysteresis,\n sanitizeThrottle,\n sanitizeIds,\n sanitizeSelector,\n useIsomorphicLayoutEffect,\n areIdInputsEqual,\n areScrollStatesEqual,\n areSectionsEqual,\n} from \"../utils\";\n\n\nexport function useDomet(options: DometOptions): UseDometReturn {\n const {\n container: containerInput,\n tracking,\n scrolling,\n onActive,\n onEnter,\n onLeave,\n onScrollStart,\n onScrollEnd,\n } = options;\n\n const trackingOffset = sanitizeOffset(tracking?.offset);\n const throttle = sanitizeThrottle(tracking?.throttle);\n const threshold = sanitizeThreshold(tracking?.threshold);\n const hysteresis = sanitizeHysteresis(tracking?.hysteresis);\n const scrollingDefaults = useMemo(() => {\n if (!scrolling) {\n return {\n behavior: \"auto\" as ScrollBehavior,\n offset: undefined,\n position: undefined,\n lockActive: undefined,\n };\n }\n\n return {\n behavior: scrolling.behavior ?? \"auto\",\n offset: scrolling.offset !== undefined\n ? sanitizeOffset(scrolling.offset)\n : undefined,\n position: scrolling.position,\n lockActive: scrolling.lockActive,\n };\n }, [scrolling]);\n\n const rawIds = \"ids\" in options ? options.ids : undefined;\n const rawSelector = \"selector\" in options ? options.selector : undefined;\n\n const idsCacheRef = useRef<{\n raw: unknown;\n sanitized: string[] | undefined;\n }>({ raw: undefined, sanitized: undefined });\n\n const idsArray = useMemo(() => {\n if (rawIds === undefined) {\n idsCacheRef.current = { raw: undefined, sanitized: undefined };\n return undefined;\n }\n\n if (areIdInputsEqual(rawIds, idsCacheRef.current.raw)) {\n idsCacheRef.current.raw = rawIds;\n return idsCacheRef.current.sanitized;\n }\n\n const sanitized = sanitizeIds(rawIds);\n idsCacheRef.current = { raw: rawIds, sanitized };\n return sanitized;\n }, [rawIds]);\n\n const selectorString = useMemo(() => {\n if (rawSelector === undefined) return undefined;\n return sanitizeSelector(rawSelector);\n }, [rawSelector]);\n const useSelector = selectorString !== undefined && selectorString !== \"\";\n\n const initialActiveId = idsArray && idsArray.length > 0 ? idsArray[0] : null;\n\n const [containerElement, setContainerElement] = useState<HTMLElement | null>(null);\n const [resolvedSections, setResolvedSections] = useState<ResolvedSection[]>([]);\n const [activeId, setActiveId] = useState<string | null>(initialActiveId);\n const [scroll, setScroll] = useState<ScrollState>({\n y: 0,\n progress: 0,\n direction: null,\n velocity: 0,\n scrolling: false,\n maxScroll: 0,\n viewportHeight: 0,\n trackingOffset: 0,\n triggerLine: 0,\n });\n const [sections, setSections] = useState<Record<string, SectionState>>({});\n\n const refs = useRef<Record<string, HTMLElement | null>>({});\n const refCallbacks = useRef<Record<string, (el: HTMLElement | null) => void>>({});\n const registerPropsCache = useRef<Record<string, RegisterProps>>({});\n const navRefs = useRef<Record<string, HTMLElement | null>>({});\n const navRefCallbacks = useRef<Record<string, (el: HTMLElement | null) => void>>({});\n const activeIdRef = useRef<string | null>(initialActiveId);\n const lastScrollY = useRef<number>(0);\n const lastScrollTime = useRef<number>(Date.now());\n const rafId = useRef<number | null>(null);\n const isThrottled = useRef<boolean>(false);\n const throttleTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);\n const hasPendingScroll = useRef<boolean>(false);\n const isProgrammaticScrolling = useRef<boolean>(false);\n const isScrollingRef = useRef<boolean>(false);\n const scrollIdleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const prevSectionsInViewport = useRef<Set<string>>(new Set());\n const prevScrollStateRef = useRef<ScrollState | null>(null);\n const prevSectionsStateRef = useRef<Record<string, SectionState> | null>(null);\n const recalculateRef = useRef<() => void>(() => {});\n const scheduleRecalculate = useCallback(() => {\n if (typeof window === \"undefined\") return;\n if (rafId.current) {\n cancelAnimationFrame(rafId.current);\n }\n rafId.current = requestAnimationFrame(() => {\n rafId.current = null;\n recalculateRef.current();\n });\n }, []);\n const scrollCleanupRef = useRef<(() => void) | null>(null);\n const mutationObserverRef = useRef<MutationObserver | null>(null);\n const mutationDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const optionsRef = useRef({ trackingOffset, scrolling: scrollingDefaults });\n const callbackRefs = useRef({\n onActive,\n onEnter,\n onLeave,\n onScrollStart,\n onScrollEnd,\n });\n\n useIsomorphicLayoutEffect(() => {\n optionsRef.current = { trackingOffset, scrolling: scrollingDefaults };\n }, [trackingOffset, scrollingDefaults]);\n\n useEffect(() => {\n scheduleRecalculate();\n }, [trackingOffset, scheduleRecalculate]);\n\n useIsomorphicLayoutEffect(() => {\n callbackRefs.current = {\n onActive,\n onEnter,\n onLeave,\n onScrollStart,\n onScrollEnd,\n };\n }, [onActive, onEnter, onLeave, onScrollStart, onScrollEnd]);\n\n const sectionIds = useMemo(() => {\n if (!useSelector && idsArray) return idsArray;\n return resolvedSections.map((s) => s.id);\n }, [useSelector, idsArray, resolvedSections]);\n\n const sectionIndexMap = useMemo(() => {\n const map = new Map<string, number>();\n for (let i = 0; i < sectionIds.length; i++) {\n map.set(sectionIds[i], i);\n }\n return map;\n }, [sectionIds]);\n\n const containerRefCurrent = containerInput?.current ?? null;\n\n useIsomorphicLayoutEffect(() => {\n const resolved = resolveContainer(containerInput);\n if (resolved !== containerElement) {\n setContainerElement(resolved);\n }\n }, [containerInput, containerRefCurrent]);\n\n const updateSectionsFromSelector = useCallback((selector: string) => {\n const resolved = resolveSectionsFromSelector(selector);\n setResolvedSections(resolved);\n if (resolved.length > 0) {\n const currentStillExists = resolved.some((s) => s.id === activeIdRef.current);\n if (!activeIdRef.current || !currentStillExists) {\n activeIdRef.current = resolved[0].id;\n setActiveId(resolved[0].id);\n }\n } else if (activeIdRef.current !== null) {\n activeIdRef.current = null;\n setActiveId(null);\n }\n }, []);\n\n useIsomorphicLayoutEffect(() => {\n if (useSelector && selectorString) {\n updateSectionsFromSelector(selectorString);\n }\n }, [selectorString, useSelector, updateSectionsFromSelector]);\n\n useEffect(() => {\n if (\n !useSelector ||\n !selectorString ||\n typeof window === \"undefined\" ||\n typeof MutationObserver === \"undefined\"\n ) {\n return;\n }\n\n const handleMutation = () => {\n if (mutationDebounceRef.current) {\n clearTimeout(mutationDebounceRef.current);\n }\n mutationDebounceRef.current = setTimeout(() => {\n updateSectionsFromSelector(selectorString);\n }, 50);\n };\n\n mutationObserverRef.current = new MutationObserver(handleMutation);\n mutationObserverRef.current.observe(document.body, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: [\"id\", \"data-domet\"],\n });\n\n return () => {\n if (mutationDebounceRef.current) {\n clearTimeout(mutationDebounceRef.current);\n mutationDebounceRef.current = null;\n }\n if (mutationObserverRef.current) {\n mutationObserverRef.current.disconnect();\n mutationObserverRef.current = null;\n }\n };\n }, [useSelector, selectorString, updateSectionsFromSelector]);\n\n useEffect(() => {\n if (!useSelector && idsArray) {\n const idsSet = new Set(idsArray);\n\n for (const id of Object.keys(refs.current)) {\n if (!idsSet.has(id)) {\n delete refs.current[id];\n }\n }\n\n for (const id of Object.keys(refCallbacks.current)) {\n if (!idsSet.has(id)) {\n delete refCallbacks.current[id];\n }\n }\n\n const currentActive = activeIdRef.current;\n const nextActive =\n currentActive && idsSet.has(currentActive)\n ? currentActive\n : (idsArray[0] ?? null);\n\n if (nextActive !== currentActive) {\n activeIdRef.current = nextActive;\n setActiveId(nextActive);\n }\n }\n }, [idsArray, useSelector]);\n\n const registerRef = useCallback((id: string) => {\n const existing = refCallbacks.current[id];\n if (existing) return existing;\n\n const callback = (el: HTMLElement | null) => {\n if (el) {\n refs.current[id] = el;\n } else {\n delete refs.current[id];\n }\n scheduleRecalculate();\n };\n\n refCallbacks.current[id] = callback;\n return callback;\n }, [scheduleRecalculate]);\n\n const navRef = useCallback((id: string) => {\n const existing = navRefCallbacks.current[id];\n if (existing) return existing;\n\n const callback = (el: HTMLElement | null) => {\n if (el) {\n navRefs.current[id] = el;\n } else {\n delete navRefs.current[id];\n }\n };\n\n navRefCallbacks.current[id] = callback;\n return callback;\n }, []);\n\n useEffect(() => {\n if (!activeId) return;\n const navElement = navRefs.current[activeId];\n if (!navElement || typeof navElement.scrollIntoView !== \"function\") return;\n\n navElement.scrollIntoView({\n block: \"nearest\",\n behavior: \"instant\",\n });\n }, [activeId]);\n\n const getResolvedBehavior = useCallback((behaviorOverride?: ScrollBehavior): ScrollBehavior => {\n const b = behaviorOverride ?? optionsRef.current.scrolling.behavior;\n if (b === \"auto\") {\n if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") {\n return \"smooth\";\n }\n const prefersReducedMotion = window.matchMedia(\n \"(prefers-reduced-motion: reduce)\",\n ).matches;\n return prefersReducedMotion ? \"instant\" : \"smooth\";\n }\n return b;\n }, []);\n\n const getCurrentSections = useCallback((): ResolvedSection[] => {\n if (!useSelector && idsArray) {\n return resolveSectionsFromIds(idsArray, refs.current);\n }\n return resolvedSections;\n }, [useSelector, idsArray, resolvedSections]);\n\n const scrollTo = useCallback(\n (target: ScrollTarget, scrollOptions?: ScrollToOptions): void => {\n const resolvedTarget = typeof target === \"string\"\n ? { type: \"id\" as const, id: target }\n : \"id\" in target\n ? { type: \"id\" as const, id: target.id }\n : { type: \"top\" as const, top: target.top };\n\n const defaultScroll = optionsRef.current.scrolling;\n const lockActive = scrollOptions?.lockActive\n ?? defaultScroll.lockActive\n ?? resolvedTarget.type === \"id\";\n const container = containerElement;\n const scrollTarget = container || window;\n const viewportHeight = container ? container.clientHeight : window.innerHeight;\n const scrollHeight = container\n ? container.scrollHeight\n : document.documentElement.scrollHeight;\n const maxScroll = Math.max(0, scrollHeight - viewportHeight);\n const scrollBehavior = getResolvedBehavior(\n scrollOptions?.behavior ?? defaultScroll.behavior,\n );\n const offsetCandidate = scrollOptions?.offset\n ?? defaultScroll.offset;\n const offsetValue = sanitizeOffset(offsetCandidate);\n const effectiveOffset = resolveOffset(offsetValue, viewportHeight, DEFAULT_OFFSET);\n\n const stopProgrammaticScroll = () => {\n if (scrollCleanupRef.current) {\n scrollCleanupRef.current();\n scrollCleanupRef.current = null;\n }\n isProgrammaticScrolling.current = false;\n };\n\n if (!lockActive) {\n stopProgrammaticScroll();\n } else if (scrollCleanupRef.current) {\n scrollCleanupRef.current();\n }\n\n const setupLock = () => {\n const unlockScroll = () => {\n isProgrammaticScrolling.current = false;\n };\n\n let debounceTimer: ReturnType<typeof setTimeout> | null = null;\n let isUnlocked = false;\n\n const cleanup = () => {\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n debounceTimer = null;\n }\n scrollTarget.removeEventListener(\"scroll\", handleScrollActivity);\n if (\"onscrollend\" in scrollTarget) {\n scrollTarget.removeEventListener(\"scrollend\", handleScrollEnd);\n }\n scrollCleanupRef.current = null;\n };\n\n const doUnlock = () => {\n if (isUnlocked) return;\n isUnlocked = true;\n cleanup();\n unlockScroll();\n };\n\n const resetDebounce = () => {\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n }\n debounceTimer = setTimeout(doUnlock, SCROLL_IDLE_MS);\n };\n\n const handleScrollActivity = () => {\n resetDebounce();\n };\n\n const handleScrollEnd = () => {\n doUnlock();\n };\n\n scrollTarget.addEventListener(\"scroll\", handleScrollActivity, {\n passive: true,\n });\n\n if (\"onscrollend\" in scrollTarget) {\n scrollTarget.addEventListener(\"scrollend\", handleScrollEnd, {\n once: true,\n });\n }\n\n scrollCleanupRef.current = cleanup;\n\n return { doUnlock, resetDebounce };\n };\n\n const clampValue = (value: number, min: number, max: number): number =>\n Math.max(min, Math.min(max, value));\n\n let targetScroll: number | null = null;\n let activeTargetId: string | null = null;\n\n if (resolvedTarget.type === \"id\") {\n const id = resolvedTarget.id;\n if (!sectionIndexMap.has(id)) {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`[domet] scrollTo: id \"${id}\" not found`);\n }\n return;\n }\n\n const currentSections = getCurrentSections();\n const section = currentSections.find((s) => s.id === id);\n if (!section) {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`[domet] scrollTo: element for id \"${id}\" not yet mounted`);\n }\n return;\n }\n\n const elementRect = section.element.getBoundingClientRect();\n\n const position: ScrollToPosition | undefined =\n scrollOptions?.position ?? defaultScroll.position;\n\n const sectionTop = container\n ? elementRect.top - container.getBoundingClientRect().top + container.scrollTop\n : elementRect.top + window.scrollY;\n const sectionHeight = elementRect.height;\n\n const calculateTargetScroll = (): number => {\n if (maxScroll <= 0) return 0;\n\n const topTarget = sectionTop - effectiveOffset;\n const centerTarget = sectionTop - (viewportHeight - sectionHeight) / 2;\n const bottomTarget = sectionTop + sectionHeight - viewportHeight;\n\n if (position === \"top\") {\n return clampValue(topTarget, 0, maxScroll);\n }\n\n if (position === \"center\") {\n const fits = sectionHeight <= viewportHeight;\n if (fits) {\n return clampValue(centerTarget, 0, maxScroll);\n }\n return clampValue(topTarget, 0, maxScroll);\n }\n\n if (position === \"bottom\") {\n return clampValue(bottomTarget, 0, maxScroll);\n }\n\n const fits = sectionHeight <= viewportHeight;\n\n const dynamicRange = viewportHeight - effectiveOffset;\n const denominator = dynamicRange !== 0 ? 1 + dynamicRange / maxScroll : 1;\n\n const triggerMin = (sectionTop - effectiveOffset) / denominator;\n const triggerMax = (sectionTop + sectionHeight - effectiveOffset) / denominator;\n\n if (fits) {\n if (centerTarget >= triggerMin && centerTarget <= triggerMax) {\n return clampValue(centerTarget, 0, maxScroll);\n }\n\n if (centerTarget < triggerMin) {\n return clampValue(triggerMin, 0, maxScroll);\n }\n\n return clampValue(triggerMax, 0, maxScroll);\n }\n\n return clampValue(topTarget, 0, maxScroll);\n };\n\n targetScroll = calculateTargetScroll();\n activeTargetId = id;\n } else {\n const top = resolvedTarget.top;\n if (!Number.isFinite(top)) {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`[domet] scrollTo: top \"${top}\" is not a valid number`);\n }\n return;\n }\n targetScroll = clampValue(top - effectiveOffset, 0, maxScroll);\n }\n\n if (targetScroll === null) return;\n\n if (lockActive) {\n isProgrammaticScrolling.current = true;\n if (activeTargetId) {\n activeIdRef.current = activeTargetId;\n setActiveId(activeTargetId);\n }\n }\n\n const lockControls = lockActive ? setupLock() : null;\n\n if (container) {\n container.scrollTo({\n top: targetScroll,\n behavior: scrollBehavior,\n });\n } else {\n window.scrollTo({\n top: targetScroll,\n behavior: scrollBehavior,\n });\n }\n\n if (lockControls) {\n if (scrollBehavior === \"instant\") {\n lockControls.doUnlock();\n } else {\n lockControls.resetDebounce();\n }\n }\n },\n [sectionIndexMap, containerElement, getResolvedBehavior, getCurrentSections],\n );\n\n const register = useCallback(\n (id: string): RegisterProps => {\n const cached = registerPropsCache.current[id];\n if (cached) return cached;\n\n const props: RegisterProps = {\n id,\n ref: registerRef(id),\n \"data-domet\": id,\n };\n registerPropsCache.current[id] = props;\n return props;\n },\n [registerRef],\n );\n\n const link = useCallback(\n (id: string, options?: ScrollToOptions): LinkProps => ({\n onClick: () => scrollTo(id, options),\n \"aria-current\": activeId === id ? \"page\" : undefined,\n \"data-active\": activeId === id,\n }),\n [activeId, scrollTo],\n );\n\n const calculateActiveSection = useCallback(() => {\n const container = containerElement;\n const currentActiveId = activeIdRef.current;\n const now = Date.now();\n const scrollY = container ? container.scrollTop : window.scrollY;\n const viewportHeight = container ? container.clientHeight : window.innerHeight;\n const scrollHeight = container\n ? container.scrollHeight\n : document.documentElement.scrollHeight;\n const maxScroll = Math.max(1, scrollHeight - viewportHeight);\n const scrollProgress = Math.min(1, Math.max(0, scrollY / maxScroll));\n const scrollDirection: \"up\" | \"down\" | null =\n scrollY === lastScrollY.current\n ? null\n : scrollY > lastScrollY.current\n ? \"down\"\n : \"up\";\n const deltaTime = now - lastScrollTime.current;\n const deltaY = scrollY - lastScrollY.current;\n const velocity = deltaTime > 0 ? Math.abs(deltaY) / deltaTime : 0;\n\n lastScrollY.current = scrollY;\n lastScrollTime.current = now;\n\n const currentSections = getCurrentSections();\n const sectionBounds = getSectionBounds(currentSections, container);\n if (sectionBounds.length === 0) return;\n\n const effectiveOffset = resolveOffset(trackingOffset, viewportHeight, DEFAULT_OFFSET);\n\n const scores = calculateSectionScores(sectionBounds, currentSections, {\n scrollY,\n viewportHeight,\n scrollHeight,\n effectiveOffset,\n visibilityThreshold: threshold,\n scrollDirection,\n sectionIndexMap,\n });\n\n const isProgrammatic = isProgrammaticScrolling.current;\n\n const newActiveId = isProgrammatic\n ? currentActiveId\n : determineActiveSection(\n scores,\n sectionIds,\n currentActiveId,\n hysteresis,\n scrollY,\n viewportHeight,\n scrollHeight,\n );\n\n if (!isProgrammatic && newActiveId !== currentActiveId) {\n activeIdRef.current = newActiveId;\n setActiveId(newActiveId);\n callbackRefs.current.onActive?.(newActiveId, currentActiveId);\n }\n\n if (!isProgrammatic) {\n const currentInViewport = new Set(\n scores.filter((s) => s.inView).map((s) => s.id),\n );\n const prevInViewport = prevSectionsInViewport.current;\n\n for (const id of currentInViewport) {\n if (!prevInViewport.has(id)) {\n callbackRefs.current.onEnter?.(id);\n }\n }\n for (const id of prevInViewport) {\n if (!currentInViewport.has(id)) {\n callbackRefs.current.onLeave?.(id);\n }\n }\n prevSectionsInViewport.current = currentInViewport;\n }\n\n const triggerLine = Math.round(\n effectiveOffset + scrollProgress * (viewportHeight - effectiveOffset)\n );\n\n const newScrollState: ScrollState = {\n y: Math.round(scrollY),\n progress: Math.max(0, Math.min(1, scrollProgress)),\n direction: scrollDirection,\n velocity: Math.round(velocity),\n scrolling: isScrollingRef.current,\n maxScroll: Math.round(maxScroll),\n viewportHeight: Math.round(viewportHeight),\n trackingOffset: Math.round(effectiveOffset),\n triggerLine,\n };\n\n const newSections: Record<string, SectionState> = {};\n for (const s of scores) {\n newSections[s.id] = {\n bounds: {\n top: Math.round(s.bounds.top),\n bottom: Math.round(s.bounds.bottom),\n height: Math.round(s.bounds.height),\n },\n visibility: Math.round(s.visibilityRatio * 100) / 100,\n progress: Math.round(s.progress * 100) / 100,\n inView: s.inView,\n active: s.id === (isProgrammatic ? currentActiveId : newActiveId),\n rect: s.rect,\n };\n }\n\n if (!prevScrollStateRef.current || !areScrollStatesEqual(prevScrollStateRef.current, newScrollState)) {\n prevScrollStateRef.current = newScrollState;\n startTransition(() => {\n setScroll(newScrollState);\n });\n }\n\n if (!prevSectionsStateRef.current || !areSectionsEqual(prevSectionsStateRef.current, newSections)) {\n prevSectionsStateRef.current = newSections;\n startTransition(() => {\n setSections(newSections);\n });\n }\n }, [\n sectionIds,\n sectionIndexMap,\n trackingOffset,\n threshold,\n hysteresis,\n containerElement,\n getCurrentSections,\n ]);\n\n recalculateRef.current = calculateActiveSection;\n\n useEffect(() => {\n const container = containerElement;\n const scrollTarget = container || window;\n\n const handleScrollEnd = (): void => {\n isScrollingRef.current = false;\n setScroll((prev) => ({ ...prev, scrolling: false, direction: null }));\n callbackRefs.current.onScrollEnd?.();\n };\n\n const handleScroll = (): void => {\n if (!isScrollingRef.current) {\n isScrollingRef.current = true;\n setScroll((prev) => ({ ...prev, scrolling: true }));\n callbackRefs.current.onScrollStart?.();\n }\n\n if (scrollIdleTimeoutRef.current) {\n clearTimeout(scrollIdleTimeoutRef.current);\n }\n scrollIdleTimeoutRef.current = setTimeout(handleScrollEnd, SCROLL_IDLE_MS);\n\n if (isThrottled.current) {\n hasPendingScroll.current = true;\n return;\n }\n\n isThrottled.current = true;\n hasPendingScroll.current = false;\n\n if (throttleTimeoutId.current) {\n clearTimeout(throttleTimeoutId.current);\n }\n\n scheduleRecalculate();\n\n throttleTimeoutId.current = setTimeout(() => {\n isThrottled.current = false;\n throttleTimeoutId.current = null;\n\n if (hasPendingScroll.current) {\n hasPendingScroll.current = false;\n handleScroll();\n }\n }, throttle);\n };\n\n const handleResize = (): void => {\n if (useSelector && selectorString) {\n updateSectionsFromSelector(selectorString);\n }\n scheduleRecalculate();\n };\n\n const deferredRecalcId = setTimeout(() => {\n scheduleRecalculate();\n }, 0);\n\n scrollTarget.addEventListener(\"scroll\", handleScroll, { passive: true });\n window.addEventListener(\"resize\", handleResize, { passive: true });\n\n return () => {\n clearTimeout(deferredRecalcId);\n scrollTarget.removeEventListener(\"scroll\", handleScroll);\n window.removeEventListener(\"resize\", handleResize);\n if (rafId.current) {\n cancelAnimationFrame(rafId.current);\n rafId.current = null;\n }\n if (throttleTimeoutId.current) {\n clearTimeout(throttleTimeoutId.current);\n throttleTimeoutId.current = null;\n }\n if (scrollIdleTimeoutRef.current) {\n clearTimeout(scrollIdleTimeoutRef.current);\n scrollIdleTimeoutRef.current = null;\n }\n scrollCleanupRef.current?.();\n isThrottled.current = false;\n hasPendingScroll.current = false;\n isProgrammaticScrolling.current = false;\n isScrollingRef.current = false;\n };\n }, [throttle, containerElement, useSelector, selectorString, updateSectionsFromSelector, scheduleRecalculate]);\n\n const index = useMemo(() => {\n if (!activeId) return -1;\n return sectionIndexMap.get(activeId) ?? -1;\n }, [activeId, sectionIndexMap]);\n\n return {\n active: activeId,\n index,\n progress: scroll.progress,\n direction: scroll.direction,\n scroll,\n sections,\n ids: sectionIds,\n scrollTo,\n register,\n link,\n navRef,\n };\n}\n\nexport default useDomet;\n","import type { Offset } from \"../types\";\nimport {\n DEFAULT_OFFSET,\n DEFAULT_THRESHOLD,\n DEFAULT_HYSTERESIS,\n DEFAULT_THROTTLE,\n} from \"../constants\";\n\nconst PERCENT_REGEX = /^(-?\\d+(?:\\.\\d+)?)%$/;\n\nexport const VALIDATION_LIMITS = {\n offset: { min: -10000, max: 10000 },\n offsetPercent: { min: -500, max: 500 },\n threshold: { min: 0, max: 1 },\n hysteresis: { min: 0, max: 1000 },\n throttle: { min: 0, max: 1000 },\n} as const;\n\nfunction warn(message: string): void {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`[domet] ${message}`);\n }\n}\n\nfunction isFiniteNumber(value: unknown): value is number {\n return typeof value === \"number\" && Number.isFinite(value);\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.max(min, Math.min(max, value));\n}\n\nexport function sanitizeOffset(offset: Offset | undefined): Offset {\n if (offset === undefined) {\n return DEFAULT_OFFSET;\n }\n\n if (typeof offset === \"number\") {\n if (!isFiniteNumber(offset)) {\n warn(`Invalid offset value: ${offset}. Using default.`);\n return DEFAULT_OFFSET;\n }\n const { min, max } = VALIDATION_LIMITS.offset;\n if (offset < min || offset > max) {\n warn(`Offset ${offset} clamped to [${min}, ${max}].`);\n return clamp(offset, min, max);\n }\n return offset;\n }\n\n if (typeof offset === \"string\") {\n const trimmed = offset.trim();\n const match = PERCENT_REGEX.exec(trimmed);\n if (!match) {\n warn(`Invalid offset format: \"${offset}\". Using default.`);\n return DEFAULT_OFFSET;\n }\n const percent = parseFloat(match[1]);\n if (!isFiniteNumber(percent)) {\n warn(`Invalid percentage value in offset: \"${offset}\". Using default.`);\n return DEFAULT_OFFSET;\n }\n const { min, max } = VALIDATION_LIMITS.offsetPercent;\n if (percent < min || percent > max) {\n warn(`Offset percentage ${percent}% clamped to [${min}%, ${max}%].`);\n return `${clamp(percent, min, max)}%`;\n }\n return trimmed as `${number}%`;\n }\n\n warn(`Invalid offset type: ${typeof offset}. Using default.`);\n return DEFAULT_OFFSET;\n}\n\nexport function sanitizeThreshold(threshold: number | undefined): number {\n if (threshold === undefined) {\n return DEFAULT_THRESHOLD;\n }\n\n if (!isFiniteNumber(threshold)) {\n warn(`Invalid threshold value: ${threshold}. Using default.`);\n return DEFAULT_THRESHOLD;\n }\n\n const { min, max } = VALIDATION_LIMITS.threshold;\n if (threshold < min || threshold > max) {\n warn(`Threshold ${threshold} clamped to [${min}, ${max}].`);\n return clamp(threshold, min, max);\n }\n\n return threshold;\n}\n\nexport function sanitizeHysteresis(hysteresis: number | undefined): number {\n if (hysteresis === undefined) {\n return DEFAULT_HYSTERESIS;\n }\n\n if (!isFiniteNumber(hysteresis)) {\n warn(`Invalid hysteresis value: ${hysteresis}. Using default.`);\n return DEFAULT_HYSTERESIS;\n }\n\n const { min, max } = VALIDATION_LIMITS.hysteresis;\n if (hysteresis < min || hysteresis > max) {\n warn(`Hysteresis ${hysteresis} clamped to [${min}, ${max}].`);\n return clamp(hysteresis, min, max);\n }\n\n return hysteresis;\n}\n\nexport function sanitizeThrottle(throttle: number | undefined): number {\n if (throttle === undefined) {\n return DEFAULT_THROTTLE;\n }\n\n if (!isFiniteNumber(throttle)) {\n warn(`Invalid throttle value: ${throttle}. Using default.`);\n return DEFAULT_THROTTLE;\n }\n\n const { min, max } = VALIDATION_LIMITS.throttle;\n if (throttle < min || throttle > max) {\n warn(`Throttle ${throttle} clamped to [${min}, ${max}].`);\n return clamp(throttle, min, max);\n }\n\n return throttle;\n}\n\nexport function sanitizeIds(ids: string[] | undefined): string[] {\n if (!ids || !Array.isArray(ids)) {\n warn(\"Invalid ids: expected an array. Using empty array.\");\n return [];\n }\n\n const seen = new Set<string>();\n const sanitized: string[] = [];\n\n for (const id of ids) {\n if (typeof id !== \"string\") {\n warn(`Invalid id type: ${typeof id}. Skipping.`);\n continue;\n }\n\n const trimmed = id.trim();\n if (trimmed === \"\") {\n warn(\"Empty string id detected. Skipping.\");\n continue;\n }\n\n if (seen.has(trimmed)) {\n warn(`Duplicate id \"${trimmed}\" detected. Skipping.`);\n continue;\n }\n\n seen.add(trimmed);\n sanitized.push(trimmed);\n }\n\n return sanitized;\n}\n\nexport function sanitizeSelector(selector: string | undefined): string {\n if (selector === undefined) {\n return \"\";\n }\n\n if (typeof selector !== \"string\") {\n warn(`Invalid selector type: ${typeof selector}. Using empty string.`);\n return \"\";\n }\n\n const trimmed = selector.trim();\n if (trimmed === \"\") {\n warn(\"Empty selector provided.\");\n }\n\n return trimmed;\n}\n"],"names":[],"mappings":";;AACO,KAAA,eAAA,GAAA,SAAA,CAAA,WAAA;AACA,KAAA,MAAA;AACA,KAAA,aAAA;AACP;AACA;AACA;AACA;AACO,KAAA,WAAA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,KAAA,YAAA;AACP,YAAA,aAAA;AACA;AACA;AACA;AACA;AACA,UAAA,OAAA;AACA;AACO,KAAA,cAAA;AACA,KAAA,gBAAA;AACA,KAAA,YAAA;AACP;AACA;AACA;AACA;AACO,KAAA,eAAA;AACP,aAAA,MAAA;AACA,eAAA,cAAA;AACA,eAAA,gBAAA;AACA;AACA;AACO,KAAA,eAAA;AACP,aAAA,MAAA;AACA;AACA;AACA;AACA;AACO,KAAA,gBAAA,GAAA,eAAA;AACA,KAAA,YAAA;AACP;AACA;AACA,gBAAA,eAAA;AACA,eAAA,eAAA;AACA,gBAAA,gBAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,gBAAA,eAAA;AACA,eAAA,eAAA;AACA,gBAAA,gBAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,KAAA,aAAA;AACP;AACA,cAAA,WAAA;AACA;AACA;AACO,KAAA,SAAA;AACP;AACA;AACA;AACA;AACO,KAAA,cAAA;AACP;AACA;AACA;AACA;AACA,YAAA,WAAA;AACA,cAAA,MAAA,SAAA,YAAA;AACA;AACA,uBAAA,YAAA,YAAA,eAAA;AACA,8BAAA,aAAA;AACA,iCAAA,eAAA,KAAA,SAAA;AACA,iCAAA,WAAA;AACA;;AC3FO,iBAAA,QAAA,UAAA,YAAA,GAAA,cAAA;;ACAA,cAAA,iBAAA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;;;"}
|
package/dist/cjs/index.js
CHANGED
|
@@ -221,11 +221,12 @@ function getSectionBounds(sections, container) {
|
|
|
221
221
|
id,
|
|
222
222
|
top: relativeTop,
|
|
223
223
|
bottom: relativeTop + rect.height,
|
|
224
|
-
height: rect.height
|
|
224
|
+
height: rect.height,
|
|
225
|
+
rect
|
|
225
226
|
};
|
|
226
227
|
});
|
|
227
228
|
}
|
|
228
|
-
function calculateSectionScores(sectionBounds,
|
|
229
|
+
function calculateSectionScores(sectionBounds, _sections, ctx) {
|
|
229
230
|
const { scrollY, viewportHeight, effectiveOffset, visibilityThreshold} = ctx;
|
|
230
231
|
const viewportTop = scrollY;
|
|
231
232
|
const viewportBottom = scrollY + viewportHeight;
|
|
@@ -233,10 +234,6 @@ function calculateSectionScores(sectionBounds, sections, ctx) {
|
|
|
233
234
|
const scrollProgress = Math.min(1, Math.max(0, scrollY / maxScroll));
|
|
234
235
|
const dynamicOffset = effectiveOffset + scrollProgress * (viewportHeight - effectiveOffset);
|
|
235
236
|
const triggerLine = scrollY + dynamicOffset;
|
|
236
|
-
const elementMap = new Map(sections.map((s)=>[
|
|
237
|
-
s.id,
|
|
238
|
-
s.element
|
|
239
|
-
]));
|
|
240
237
|
return sectionBounds.map((section)=>{
|
|
241
238
|
const visibleTop = Math.max(section.top, viewportTop);
|
|
242
239
|
const visibleBottom = Math.min(section.bottom, viewportBottom);
|
|
@@ -268,8 +265,6 @@ function calculateSectionScores(sectionBounds, sections, ctx) {
|
|
|
268
265
|
const proximityScore = Math.max(0, 1 - distanceFromTrigger / maxDistance) * 500;
|
|
269
266
|
score += proximityScore;
|
|
270
267
|
}
|
|
271
|
-
const element = elementMap.get(section.id);
|
|
272
|
-
const rect = element ? element.getBoundingClientRect() : null;
|
|
273
268
|
return {
|
|
274
269
|
id: section.id,
|
|
275
270
|
score,
|
|
@@ -277,7 +272,7 @@ function calculateSectionScores(sectionBounds, sections, ctx) {
|
|
|
277
272
|
inView: isInView,
|
|
278
273
|
bounds: section,
|
|
279
274
|
progress: sectionProgress,
|
|
280
|
-
rect
|
|
275
|
+
rect: section.rect
|
|
281
276
|
};
|
|
282
277
|
});
|
|
283
278
|
}
|
|
@@ -328,6 +323,23 @@ function areIdInputsEqual(a, b) {
|
|
|
328
323
|
}
|
|
329
324
|
return true;
|
|
330
325
|
}
|
|
326
|
+
function areScrollStatesEqual(a, b) {
|
|
327
|
+
return a.y === b.y && a.progress === b.progress && a.direction === b.direction && a.velocity === b.velocity && a.scrolling === b.scrolling && a.maxScroll === b.maxScroll && a.viewportHeight === b.viewportHeight && a.trackingOffset === b.trackingOffset && a.triggerLine === b.triggerLine;
|
|
328
|
+
}
|
|
329
|
+
function areSectionsEqual(a, b) {
|
|
330
|
+
const keysA = Object.keys(a);
|
|
331
|
+
const keysB = Object.keys(b);
|
|
332
|
+
if (keysA.length !== keysB.length) return false;
|
|
333
|
+
for (const key of keysA){
|
|
334
|
+
const sA = a[key];
|
|
335
|
+
const sB = b[key];
|
|
336
|
+
if (!sB) return false;
|
|
337
|
+
if (sA.visibility !== sB.visibility || sA.progress !== sB.progress || sA.inView !== sB.inView || sA.active !== sB.active || sA.bounds.top !== sB.bounds.top || sA.bounds.bottom !== sB.bounds.bottom || sA.bounds.height !== sB.bounds.height) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
331
343
|
|
|
332
344
|
function useDomet(options) {
|
|
333
345
|
const { container: containerInput, tracking, scrolling, onActive, onEnter, onLeave, onScrollStart, onScrollEnd } = options;
|
|
@@ -406,6 +418,8 @@ function useDomet(options) {
|
|
|
406
418
|
const refs = react.useRef({});
|
|
407
419
|
const refCallbacks = react.useRef({});
|
|
408
420
|
const registerPropsCache = react.useRef({});
|
|
421
|
+
const navRefs = react.useRef({});
|
|
422
|
+
const navRefCallbacks = react.useRef({});
|
|
409
423
|
const activeIdRef = react.useRef(initialActiveId);
|
|
410
424
|
const lastScrollY = react.useRef(0);
|
|
411
425
|
const lastScrollTime = react.useRef(Date.now());
|
|
@@ -417,6 +431,8 @@ function useDomet(options) {
|
|
|
417
431
|
const isScrollingRef = react.useRef(false);
|
|
418
432
|
const scrollIdleTimeoutRef = react.useRef(null);
|
|
419
433
|
const prevSectionsInViewport = react.useRef(new Set());
|
|
434
|
+
const prevScrollStateRef = react.useRef(null);
|
|
435
|
+
const prevSectionsStateRef = react.useRef(null);
|
|
420
436
|
const recalculateRef = react.useRef(()=>{});
|
|
421
437
|
const scheduleRecalculate = react.useCallback(()=>{
|
|
422
438
|
if (typeof window === "undefined") return;
|
|
@@ -599,6 +615,30 @@ function useDomet(options) {
|
|
|
599
615
|
}, [
|
|
600
616
|
scheduleRecalculate
|
|
601
617
|
]);
|
|
618
|
+
const navRef = react.useCallback((id)=>{
|
|
619
|
+
const existing = navRefCallbacks.current[id];
|
|
620
|
+
if (existing) return existing;
|
|
621
|
+
const callback = (el)=>{
|
|
622
|
+
if (el) {
|
|
623
|
+
navRefs.current[id] = el;
|
|
624
|
+
} else {
|
|
625
|
+
delete navRefs.current[id];
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
navRefCallbacks.current[id] = callback;
|
|
629
|
+
return callback;
|
|
630
|
+
}, []);
|
|
631
|
+
react.useEffect(()=>{
|
|
632
|
+
if (!activeId) return;
|
|
633
|
+
const navElement = navRefs.current[activeId];
|
|
634
|
+
if (!navElement || typeof navElement.scrollIntoView !== "function") return;
|
|
635
|
+
navElement.scrollIntoView({
|
|
636
|
+
block: "nearest",
|
|
637
|
+
behavior: "instant"
|
|
638
|
+
});
|
|
639
|
+
}, [
|
|
640
|
+
activeId
|
|
641
|
+
]);
|
|
602
642
|
const getResolvedBehavior = react.useCallback((behaviorOverride)=>{
|
|
603
643
|
const b = behaviorOverride ?? optionsRef.current.scrolling.behavior;
|
|
604
644
|
if (b === "auto") {
|
|
@@ -735,7 +775,11 @@ function useDomet(options) {
|
|
|
735
775
|
return clampValue(topTarget, 0, maxScroll);
|
|
736
776
|
}
|
|
737
777
|
if (position === "center") {
|
|
738
|
-
|
|
778
|
+
const fits = sectionHeight <= viewportHeight;
|
|
779
|
+
if (fits) {
|
|
780
|
+
return clampValue(centerTarget, 0, maxScroll);
|
|
781
|
+
}
|
|
782
|
+
return clampValue(topTarget, 0, maxScroll);
|
|
739
783
|
}
|
|
740
784
|
if (position === "bottom") {
|
|
741
785
|
return clampValue(bottomTarget, 0, maxScroll);
|
|
@@ -896,8 +940,18 @@ function useDomet(options) {
|
|
|
896
940
|
rect: s.rect
|
|
897
941
|
};
|
|
898
942
|
}
|
|
899
|
-
|
|
900
|
-
|
|
943
|
+
if (!prevScrollStateRef.current || !areScrollStatesEqual(prevScrollStateRef.current, newScrollState)) {
|
|
944
|
+
prevScrollStateRef.current = newScrollState;
|
|
945
|
+
react.startTransition(()=>{
|
|
946
|
+
setScroll(newScrollState);
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
if (!prevSectionsStateRef.current || !areSectionsEqual(prevSectionsStateRef.current, newSections)) {
|
|
950
|
+
prevSectionsStateRef.current = newSections;
|
|
951
|
+
react.startTransition(()=>{
|
|
952
|
+
setSections(newSections);
|
|
953
|
+
});
|
|
954
|
+
}
|
|
901
955
|
}, [
|
|
902
956
|
sectionIds,
|
|
903
957
|
sectionIndexMap,
|
|
@@ -915,7 +969,8 @@ function useDomet(options) {
|
|
|
915
969
|
isScrollingRef.current = false;
|
|
916
970
|
setScroll((prev)=>({
|
|
917
971
|
...prev,
|
|
918
|
-
scrolling: false
|
|
972
|
+
scrolling: false,
|
|
973
|
+
direction: null
|
|
919
974
|
}));
|
|
920
975
|
callbackRefs.current.onScrollEnd?.();
|
|
921
976
|
};
|
|
@@ -1013,7 +1068,8 @@ function useDomet(options) {
|
|
|
1013
1068
|
ids: sectionIds,
|
|
1014
1069
|
scrollTo,
|
|
1015
1070
|
register,
|
|
1016
|
-
link
|
|
1071
|
+
link,
|
|
1072
|
+
navRef
|
|
1017
1073
|
};
|
|
1018
1074
|
}
|
|
1019
1075
|
|
package/dist/es/index.d.mts
CHANGED
|
@@ -90,6 +90,7 @@ type UseDometReturn = {
|
|
|
90
90
|
scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void;
|
|
91
91
|
register: (id: string) => RegisterProps;
|
|
92
92
|
link: (id: string, options?: ScrollToOptions) => LinkProps;
|
|
93
|
+
navRef: (id: string) => (el: HTMLElement | null) => void;
|
|
93
94
|
};
|
|
94
95
|
|
|
95
96
|
declare function useDomet(options: DometOptions): UseDometReturn;
|
package/dist/es/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","sources":["../../src/types.ts","../../src/useDomet/index.ts","../../src/utils/validation.ts"],"sourcesContent":["import type { RefObject } from \"react\";\n\nexport type ScrollContainer = RefObject<HTMLElement | null>;\n\nexport type Offset = number | `${number}%`;\n\nexport type SectionBounds = {\n top: number;\n bottom: number;\n height: number;\n};\n\nexport type ScrollState = {\n y: number;\n progress: number;\n direction: \"up\" | \"down\" | null;\n velocity: number;\n scrolling: boolean;\n maxScroll: number;\n viewportHeight: number;\n trackingOffset: number;\n triggerLine: number;\n};\n\nexport type SectionState = {\n bounds: SectionBounds;\n visibility: number;\n progress: number;\n inView: boolean;\n active: boolean;\n rect: DOMRect | null;\n};\n\nexport type ScrollBehavior = \"smooth\" | \"instant\" | \"auto\";\n\nexport type ScrollToPosition = \"top\" | \"center\" | \"bottom\";\n\nexport type ScrollTarget =\n | string\n | { id: string }\n | { top: number };\n\nexport type ScrollToOptions = {\n offset?: Offset;\n behavior?: ScrollBehavior;\n position?: ScrollToPosition;\n lockActive?: boolean;\n};\n\nexport type TrackingOptions = {\n offset?: Offset;\n threshold?: number;\n hysteresis?: number;\n throttle?: number;\n};\n\nexport type ScrollingOptions = ScrollToOptions;\n\nexport type DometOptions = {\n ids: string[];\n selector?: never;\n container?: ScrollContainer;\n tracking?: TrackingOptions;\n scrolling?: ScrollingOptions;\n onActive?: (id: string | null, prevId: string | null) => void;\n onEnter?: (id: string) => void;\n onLeave?: (id: string) => void;\n onScrollStart?: () => void;\n onScrollEnd?: () => void;\n} | {\n ids?: never;\n selector: string;\n container?: ScrollContainer;\n tracking?: TrackingOptions;\n scrolling?: ScrollingOptions;\n onActive?: (id: string | null, prevId: string | null) => void;\n onEnter?: (id: string) => void;\n onLeave?: (id: string) => void;\n onScrollStart?: () => void;\n onScrollEnd?: () => void;\n};\n\nexport type RegisterProps = {\n id: string;\n ref: (el: HTMLElement | null) => void;\n \"data-domet\": string;\n};\n\nexport type LinkProps = {\n onClick: () => void;\n \"aria-current\": \"page\" | undefined;\n \"data-active\": boolean;\n};\n\nexport type UseDometReturn = {\n active: string | null;\n index: number;\n progress: number;\n direction: \"up\" | \"down\" | null;\n scroll: ScrollState;\n sections: Record<string, SectionState>;\n ids: string[];\n scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void;\n register: (id: string) => RegisterProps;\n link: (id: string, options?: ScrollToOptions) => LinkProps;\n};\n\nexport type ResolvedSection = {\n id: string;\n element: HTMLElement;\n};\n\nexport type InternalSectionBounds = SectionBounds & { id: string };\n\nexport type SectionScore = {\n id: string;\n score: number;\n visibilityRatio: number;\n inView: boolean;\n bounds: InternalSectionBounds;\n progress: number;\n rect: DOMRect | null;\n};\n","import {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport type {\n DometOptions,\n LinkProps,\n RegisterProps,\n ResolvedSection,\n ScrollBehavior,\n ScrollState,\n ScrollTarget,\n ScrollToOptions,\n ScrollToPosition,\n SectionState,\n UseDometReturn,\n} from \"../types\";\n\nimport {\n DEFAULT_OFFSET,\n SCROLL_IDLE_MS,\n} from \"../constants\";\n\nimport {\n resolveContainer,\n resolveSectionsFromIds,\n resolveSectionsFromSelector,\n resolveOffset,\n getSectionBounds,\n calculateSectionScores,\n determineActiveSection,\n sanitizeOffset,\n sanitizeThreshold,\n sanitizeHysteresis,\n sanitizeThrottle,\n sanitizeIds,\n sanitizeSelector,\n useIsomorphicLayoutEffect,\n areIdInputsEqual,\n} from \"../utils\";\n\n\nexport function useDomet(options: DometOptions): UseDometReturn {\n const {\n container: containerInput,\n tracking,\n scrolling,\n onActive,\n onEnter,\n onLeave,\n onScrollStart,\n onScrollEnd,\n } = options;\n\n const trackingOffset = sanitizeOffset(tracking?.offset);\n const throttle = sanitizeThrottle(tracking?.throttle);\n const threshold = sanitizeThreshold(tracking?.threshold);\n const hysteresis = sanitizeHysteresis(tracking?.hysteresis);\n const scrollingDefaults = useMemo(() => {\n if (!scrolling) {\n return {\n behavior: \"auto\" as ScrollBehavior,\n offset: undefined,\n position: undefined,\n lockActive: undefined,\n };\n }\n\n return {\n behavior: scrolling.behavior ?? \"auto\",\n offset: scrolling.offset !== undefined\n ? sanitizeOffset(scrolling.offset)\n : undefined,\n position: scrolling.position,\n lockActive: scrolling.lockActive,\n };\n }, [scrolling]);\n\n const rawIds = \"ids\" in options ? options.ids : undefined;\n const rawSelector = \"selector\" in options ? options.selector : undefined;\n\n const idsCacheRef = useRef<{\n raw: unknown;\n sanitized: string[] | undefined;\n }>({ raw: undefined, sanitized: undefined });\n\n const idsArray = useMemo(() => {\n if (rawIds === undefined) {\n idsCacheRef.current = { raw: undefined, sanitized: undefined };\n return undefined;\n }\n\n if (areIdInputsEqual(rawIds, idsCacheRef.current.raw)) {\n idsCacheRef.current.raw = rawIds;\n return idsCacheRef.current.sanitized;\n }\n\n const sanitized = sanitizeIds(rawIds);\n idsCacheRef.current = { raw: rawIds, sanitized };\n return sanitized;\n }, [rawIds]);\n\n const selectorString = useMemo(() => {\n if (rawSelector === undefined) return undefined;\n return sanitizeSelector(rawSelector);\n }, [rawSelector]);\n const useSelector = selectorString !== undefined && selectorString !== \"\";\n\n const initialActiveId = idsArray && idsArray.length > 0 ? idsArray[0] : null;\n\n const [containerElement, setContainerElement] = useState<HTMLElement | null>(null);\n const [resolvedSections, setResolvedSections] = useState<ResolvedSection[]>([]);\n const [activeId, setActiveId] = useState<string | null>(initialActiveId);\n const [scroll, setScroll] = useState<ScrollState>({\n y: 0,\n progress: 0,\n direction: null,\n velocity: 0,\n scrolling: false,\n maxScroll: 0,\n viewportHeight: 0,\n trackingOffset: 0,\n triggerLine: 0,\n });\n const [sections, setSections] = useState<Record<string, SectionState>>({});\n\n const refs = useRef<Record<string, HTMLElement | null>>({});\n const refCallbacks = useRef<Record<string, (el: HTMLElement | null) => void>>({});\n const registerPropsCache = useRef<Record<string, RegisterProps>>({});\n const activeIdRef = useRef<string | null>(initialActiveId);\n const lastScrollY = useRef<number>(0);\n const lastScrollTime = useRef<number>(Date.now());\n const rafId = useRef<number | null>(null);\n const isThrottled = useRef<boolean>(false);\n const throttleTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);\n const hasPendingScroll = useRef<boolean>(false);\n const isProgrammaticScrolling = useRef<boolean>(false);\n const isScrollingRef = useRef<boolean>(false);\n const scrollIdleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const prevSectionsInViewport = useRef<Set<string>>(new Set());\n const recalculateRef = useRef<() => void>(() => {});\n const scheduleRecalculate = useCallback(() => {\n if (typeof window === \"undefined\") return;\n if (rafId.current) {\n cancelAnimationFrame(rafId.current);\n }\n rafId.current = requestAnimationFrame(() => {\n rafId.current = null;\n recalculateRef.current();\n });\n }, []);\n const scrollCleanupRef = useRef<(() => void) | null>(null);\n const mutationObserverRef = useRef<MutationObserver | null>(null);\n const mutationDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const optionsRef = useRef({ trackingOffset, scrolling: scrollingDefaults });\n const callbackRefs = useRef({\n onActive,\n onEnter,\n onLeave,\n onScrollStart,\n onScrollEnd,\n });\n\n useIsomorphicLayoutEffect(() => {\n optionsRef.current = { trackingOffset, scrolling: scrollingDefaults };\n }, [trackingOffset, scrollingDefaults]);\n\n useEffect(() => {\n scheduleRecalculate();\n }, [trackingOffset, scheduleRecalculate]);\n\n useIsomorphicLayoutEffect(() => {\n callbackRefs.current = {\n onActive,\n onEnter,\n onLeave,\n onScrollStart,\n onScrollEnd,\n };\n }, [onActive, onEnter, onLeave, onScrollStart, onScrollEnd]);\n\n const sectionIds = useMemo(() => {\n if (!useSelector && idsArray) return idsArray;\n return resolvedSections.map((s) => s.id);\n }, [useSelector, idsArray, resolvedSections]);\n\n const sectionIndexMap = useMemo(() => {\n const map = new Map<string, number>();\n for (let i = 0; i < sectionIds.length; i++) {\n map.set(sectionIds[i], i);\n }\n return map;\n }, [sectionIds]);\n\n const containerRefCurrent = containerInput?.current ?? null;\n\n useIsomorphicLayoutEffect(() => {\n const resolved = resolveContainer(containerInput);\n if (resolved !== containerElement) {\n setContainerElement(resolved);\n }\n }, [containerInput, containerRefCurrent]);\n\n const updateSectionsFromSelector = useCallback((selector: string) => {\n const resolved = resolveSectionsFromSelector(selector);\n setResolvedSections(resolved);\n if (resolved.length > 0) {\n const currentStillExists = resolved.some((s) => s.id === activeIdRef.current);\n if (!activeIdRef.current || !currentStillExists) {\n activeIdRef.current = resolved[0].id;\n setActiveId(resolved[0].id);\n }\n } else if (activeIdRef.current !== null) {\n activeIdRef.current = null;\n setActiveId(null);\n }\n }, []);\n\n useIsomorphicLayoutEffect(() => {\n if (useSelector && selectorString) {\n updateSectionsFromSelector(selectorString);\n }\n }, [selectorString, useSelector, updateSectionsFromSelector]);\n\n useEffect(() => {\n if (\n !useSelector ||\n !selectorString ||\n typeof window === \"undefined\" ||\n typeof MutationObserver === \"undefined\"\n ) {\n return;\n }\n\n const handleMutation = () => {\n if (mutationDebounceRef.current) {\n clearTimeout(mutationDebounceRef.current);\n }\n mutationDebounceRef.current = setTimeout(() => {\n updateSectionsFromSelector(selectorString);\n }, 50);\n };\n\n mutationObserverRef.current = new MutationObserver(handleMutation);\n mutationObserverRef.current.observe(document.body, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: [\"id\", \"data-domet\"],\n });\n\n return () => {\n if (mutationDebounceRef.current) {\n clearTimeout(mutationDebounceRef.current);\n mutationDebounceRef.current = null;\n }\n if (mutationObserverRef.current) {\n mutationObserverRef.current.disconnect();\n mutationObserverRef.current = null;\n }\n };\n }, [useSelector, selectorString, updateSectionsFromSelector]);\n\n useEffect(() => {\n if (!useSelector && idsArray) {\n const idsSet = new Set(idsArray);\n\n for (const id of Object.keys(refs.current)) {\n if (!idsSet.has(id)) {\n delete refs.current[id];\n }\n }\n\n for (const id of Object.keys(refCallbacks.current)) {\n if (!idsSet.has(id)) {\n delete refCallbacks.current[id];\n }\n }\n\n const currentActive = activeIdRef.current;\n const nextActive =\n currentActive && idsSet.has(currentActive)\n ? currentActive\n : (idsArray[0] ?? null);\n\n if (nextActive !== currentActive) {\n activeIdRef.current = nextActive;\n setActiveId(nextActive);\n }\n }\n }, [idsArray, useSelector]);\n\n const registerRef = useCallback((id: string) => {\n const existing = refCallbacks.current[id];\n if (existing) return existing;\n\n const callback = (el: HTMLElement | null) => {\n if (el) {\n refs.current[id] = el;\n } else {\n delete refs.current[id];\n }\n scheduleRecalculate();\n };\n\n refCallbacks.current[id] = callback;\n return callback;\n }, [scheduleRecalculate]);\n\n const getResolvedBehavior = useCallback((behaviorOverride?: ScrollBehavior): ScrollBehavior => {\n const b = behaviorOverride ?? optionsRef.current.scrolling.behavior;\n if (b === \"auto\") {\n if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") {\n return \"smooth\";\n }\n const prefersReducedMotion = window.matchMedia(\n \"(prefers-reduced-motion: reduce)\",\n ).matches;\n return prefersReducedMotion ? \"instant\" : \"smooth\";\n }\n return b;\n }, []);\n\n const getCurrentSections = useCallback((): ResolvedSection[] => {\n if (!useSelector && idsArray) {\n return resolveSectionsFromIds(idsArray, refs.current);\n }\n return resolvedSections;\n }, [useSelector, idsArray, resolvedSections]);\n\n const scrollTo = useCallback(\n (target: ScrollTarget, scrollOptions?: ScrollToOptions): void => {\n const resolvedTarget = typeof target === \"string\"\n ? { type: \"id\" as const, id: target }\n : \"id\" in target\n ? { type: \"id\" as const, id: target.id }\n : { type: \"top\" as const, top: target.top };\n\n const defaultScroll = optionsRef.current.scrolling;\n const lockActive = scrollOptions?.lockActive\n ?? defaultScroll.lockActive\n ?? resolvedTarget.type === \"id\";\n const container = containerElement;\n const scrollTarget = container || window;\n const viewportHeight = container ? container.clientHeight : window.innerHeight;\n const scrollHeight = container\n ? container.scrollHeight\n : document.documentElement.scrollHeight;\n const maxScroll = Math.max(0, scrollHeight - viewportHeight);\n const scrollBehavior = getResolvedBehavior(\n scrollOptions?.behavior ?? defaultScroll.behavior,\n );\n const offsetCandidate = scrollOptions?.offset\n ?? defaultScroll.offset;\n const offsetValue = sanitizeOffset(offsetCandidate);\n const effectiveOffset = resolveOffset(offsetValue, viewportHeight, DEFAULT_OFFSET);\n\n const stopProgrammaticScroll = () => {\n if (scrollCleanupRef.current) {\n scrollCleanupRef.current();\n scrollCleanupRef.current = null;\n }\n isProgrammaticScrolling.current = false;\n };\n\n if (!lockActive) {\n stopProgrammaticScroll();\n } else if (scrollCleanupRef.current) {\n scrollCleanupRef.current();\n }\n\n const setupLock = () => {\n const unlockScroll = () => {\n isProgrammaticScrolling.current = false;\n };\n\n let debounceTimer: ReturnType<typeof setTimeout> | null = null;\n let isUnlocked = false;\n\n const cleanup = () => {\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n debounceTimer = null;\n }\n scrollTarget.removeEventListener(\"scroll\", handleScrollActivity);\n if (\"onscrollend\" in scrollTarget) {\n scrollTarget.removeEventListener(\"scrollend\", handleScrollEnd);\n }\n scrollCleanupRef.current = null;\n };\n\n const doUnlock = () => {\n if (isUnlocked) return;\n isUnlocked = true;\n cleanup();\n unlockScroll();\n };\n\n const resetDebounce = () => {\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n }\n debounceTimer = setTimeout(doUnlock, SCROLL_IDLE_MS);\n };\n\n const handleScrollActivity = () => {\n resetDebounce();\n };\n\n const handleScrollEnd = () => {\n doUnlock();\n };\n\n scrollTarget.addEventListener(\"scroll\", handleScrollActivity, {\n passive: true,\n });\n\n if (\"onscrollend\" in scrollTarget) {\n scrollTarget.addEventListener(\"scrollend\", handleScrollEnd, {\n once: true,\n });\n }\n\n scrollCleanupRef.current = cleanup;\n\n return { doUnlock, resetDebounce };\n };\n\n const clampValue = (value: number, min: number, max: number): number =>\n Math.max(min, Math.min(max, value));\n\n let targetScroll: number | null = null;\n let activeTargetId: string | null = null;\n\n if (resolvedTarget.type === \"id\") {\n const id = resolvedTarget.id;\n if (!sectionIndexMap.has(id)) {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`[domet] scrollTo: id \"${id}\" not found`);\n }\n return;\n }\n\n const currentSections = getCurrentSections();\n const section = currentSections.find((s) => s.id === id);\n if (!section) {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`[domet] scrollTo: element for id \"${id}\" not yet mounted`);\n }\n return;\n }\n\n const elementRect = section.element.getBoundingClientRect();\n\n const position: ScrollToPosition | undefined =\n scrollOptions?.position ?? defaultScroll.position;\n\n const sectionTop = container\n ? elementRect.top - container.getBoundingClientRect().top + container.scrollTop\n : elementRect.top + window.scrollY;\n const sectionHeight = elementRect.height;\n\n const calculateTargetScroll = (): number => {\n if (maxScroll <= 0) return 0;\n\n const topTarget = sectionTop - effectiveOffset;\n const centerTarget = sectionTop - (viewportHeight - sectionHeight) / 2;\n const bottomTarget = sectionTop + sectionHeight - viewportHeight;\n\n if (position === \"top\") {\n return clampValue(topTarget, 0, maxScroll);\n }\n\n if (position === \"center\") {\n return clampValue(centerTarget, 0, maxScroll);\n }\n\n if (position === \"bottom\") {\n return clampValue(bottomTarget, 0, maxScroll);\n }\n\n const fits = sectionHeight <= viewportHeight;\n\n const dynamicRange = viewportHeight - effectiveOffset;\n const denominator = dynamicRange !== 0 ? 1 + dynamicRange / maxScroll : 1;\n\n const triggerMin = (sectionTop - effectiveOffset) / denominator;\n const triggerMax = (sectionTop + sectionHeight - effectiveOffset) / denominator;\n\n if (fits) {\n if (centerTarget >= triggerMin && centerTarget <= triggerMax) {\n return clampValue(centerTarget, 0, maxScroll);\n }\n\n if (centerTarget < triggerMin) {\n return clampValue(triggerMin, 0, maxScroll);\n }\n\n return clampValue(triggerMax, 0, maxScroll);\n }\n\n return clampValue(topTarget, 0, maxScroll);\n };\n\n targetScroll = calculateTargetScroll();\n activeTargetId = id;\n } else {\n const top = resolvedTarget.top;\n if (!Number.isFinite(top)) {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`[domet] scrollTo: top \"${top}\" is not a valid number`);\n }\n return;\n }\n targetScroll = clampValue(top - effectiveOffset, 0, maxScroll);\n }\n\n if (targetScroll === null) return;\n\n if (lockActive) {\n isProgrammaticScrolling.current = true;\n if (activeTargetId) {\n activeIdRef.current = activeTargetId;\n setActiveId(activeTargetId);\n }\n }\n\n const lockControls = lockActive ? setupLock() : null;\n\n if (container) {\n container.scrollTo({\n top: targetScroll,\n behavior: scrollBehavior,\n });\n } else {\n window.scrollTo({\n top: targetScroll,\n behavior: scrollBehavior,\n });\n }\n\n if (lockControls) {\n if (scrollBehavior === \"instant\") {\n lockControls.doUnlock();\n } else {\n lockControls.resetDebounce();\n }\n }\n },\n [sectionIndexMap, containerElement, getResolvedBehavior, getCurrentSections],\n );\n\n const register = useCallback(\n (id: string): RegisterProps => {\n const cached = registerPropsCache.current[id];\n if (cached) return cached;\n\n const props: RegisterProps = {\n id,\n ref: registerRef(id),\n \"data-domet\": id,\n };\n registerPropsCache.current[id] = props;\n return props;\n },\n [registerRef],\n );\n\n const link = useCallback(\n (id: string, options?: ScrollToOptions): LinkProps => ({\n onClick: () => scrollTo(id, options),\n \"aria-current\": activeId === id ? \"page\" : undefined,\n \"data-active\": activeId === id,\n }),\n [activeId, scrollTo],\n );\n\n const calculateActiveSection = useCallback(() => {\n const container = containerElement;\n const currentActiveId = activeIdRef.current;\n const now = Date.now();\n const scrollY = container ? container.scrollTop : window.scrollY;\n const viewportHeight = container ? container.clientHeight : window.innerHeight;\n const scrollHeight = container\n ? container.scrollHeight\n : document.documentElement.scrollHeight;\n const maxScroll = Math.max(1, scrollHeight - viewportHeight);\n const scrollProgress = Math.min(1, Math.max(0, scrollY / maxScroll));\n const scrollDirection: \"up\" | \"down\" | null =\n scrollY === lastScrollY.current\n ? null\n : scrollY > lastScrollY.current\n ? \"down\"\n : \"up\";\n const deltaTime = now - lastScrollTime.current;\n const deltaY = scrollY - lastScrollY.current;\n const velocity = deltaTime > 0 ? Math.abs(deltaY) / deltaTime : 0;\n\n lastScrollY.current = scrollY;\n lastScrollTime.current = now;\n\n const currentSections = getCurrentSections();\n const sectionBounds = getSectionBounds(currentSections, container);\n if (sectionBounds.length === 0) return;\n\n const effectiveOffset = resolveOffset(trackingOffset, viewportHeight, DEFAULT_OFFSET);\n\n const scores = calculateSectionScores(sectionBounds, currentSections, {\n scrollY,\n viewportHeight,\n scrollHeight,\n effectiveOffset,\n visibilityThreshold: threshold,\n scrollDirection,\n sectionIndexMap,\n });\n\n const isProgrammatic = isProgrammaticScrolling.current;\n\n const newActiveId = isProgrammatic\n ? currentActiveId\n : determineActiveSection(\n scores,\n sectionIds,\n currentActiveId,\n hysteresis,\n scrollY,\n viewportHeight,\n scrollHeight,\n );\n\n if (!isProgrammatic && newActiveId !== currentActiveId) {\n activeIdRef.current = newActiveId;\n setActiveId(newActiveId);\n callbackRefs.current.onActive?.(newActiveId, currentActiveId);\n }\n\n if (!isProgrammatic) {\n const currentInViewport = new Set(\n scores.filter((s) => s.inView).map((s) => s.id),\n );\n const prevInViewport = prevSectionsInViewport.current;\n\n for (const id of currentInViewport) {\n if (!prevInViewport.has(id)) {\n callbackRefs.current.onEnter?.(id);\n }\n }\n for (const id of prevInViewport) {\n if (!currentInViewport.has(id)) {\n callbackRefs.current.onLeave?.(id);\n }\n }\n prevSectionsInViewport.current = currentInViewport;\n }\n\n const triggerLine = Math.round(\n effectiveOffset + scrollProgress * (viewportHeight - effectiveOffset)\n );\n\n const newScrollState: ScrollState = {\n y: Math.round(scrollY),\n progress: Math.max(0, Math.min(1, scrollProgress)),\n direction: scrollDirection,\n velocity: Math.round(velocity),\n scrolling: isScrollingRef.current,\n maxScroll: Math.round(maxScroll),\n viewportHeight: Math.round(viewportHeight),\n trackingOffset: Math.round(effectiveOffset),\n triggerLine,\n };\n\n const newSections: Record<string, SectionState> = {};\n for (const s of scores) {\n newSections[s.id] = {\n bounds: {\n top: Math.round(s.bounds.top),\n bottom: Math.round(s.bounds.bottom),\n height: Math.round(s.bounds.height),\n },\n visibility: Math.round(s.visibilityRatio * 100) / 100,\n progress: Math.round(s.progress * 100) / 100,\n inView: s.inView,\n active: s.id === (isProgrammatic ? currentActiveId : newActiveId),\n rect: s.rect,\n };\n }\n\n setScroll(newScrollState);\n setSections(newSections);\n }, [\n sectionIds,\n sectionIndexMap,\n trackingOffset,\n threshold,\n hysteresis,\n containerElement,\n getCurrentSections,\n ]);\n\n recalculateRef.current = calculateActiveSection;\n\n useEffect(() => {\n const container = containerElement;\n const scrollTarget = container || window;\n\n const handleScrollEnd = (): void => {\n isScrollingRef.current = false;\n setScroll((prev) => ({ ...prev, scrolling: false }));\n callbackRefs.current.onScrollEnd?.();\n };\n\n const handleScroll = (): void => {\n if (!isScrollingRef.current) {\n isScrollingRef.current = true;\n setScroll((prev) => ({ ...prev, scrolling: true }));\n callbackRefs.current.onScrollStart?.();\n }\n\n if (scrollIdleTimeoutRef.current) {\n clearTimeout(scrollIdleTimeoutRef.current);\n }\n scrollIdleTimeoutRef.current = setTimeout(handleScrollEnd, SCROLL_IDLE_MS);\n\n if (isThrottled.current) {\n hasPendingScroll.current = true;\n return;\n }\n\n isThrottled.current = true;\n hasPendingScroll.current = false;\n\n if (throttleTimeoutId.current) {\n clearTimeout(throttleTimeoutId.current);\n }\n\n scheduleRecalculate();\n\n throttleTimeoutId.current = setTimeout(() => {\n isThrottled.current = false;\n throttleTimeoutId.current = null;\n\n if (hasPendingScroll.current) {\n hasPendingScroll.current = false;\n handleScroll();\n }\n }, throttle);\n };\n\n const handleResize = (): void => {\n if (useSelector && selectorString) {\n updateSectionsFromSelector(selectorString);\n }\n scheduleRecalculate();\n };\n\n const deferredRecalcId = setTimeout(() => {\n scheduleRecalculate();\n }, 0);\n\n scrollTarget.addEventListener(\"scroll\", handleScroll, { passive: true });\n window.addEventListener(\"resize\", handleResize, { passive: true });\n\n return () => {\n clearTimeout(deferredRecalcId);\n scrollTarget.removeEventListener(\"scroll\", handleScroll);\n window.removeEventListener(\"resize\", handleResize);\n if (rafId.current) {\n cancelAnimationFrame(rafId.current);\n rafId.current = null;\n }\n if (throttleTimeoutId.current) {\n clearTimeout(throttleTimeoutId.current);\n throttleTimeoutId.current = null;\n }\n if (scrollIdleTimeoutRef.current) {\n clearTimeout(scrollIdleTimeoutRef.current);\n scrollIdleTimeoutRef.current = null;\n }\n scrollCleanupRef.current?.();\n isThrottled.current = false;\n hasPendingScroll.current = false;\n isProgrammaticScrolling.current = false;\n isScrollingRef.current = false;\n };\n }, [throttle, containerElement, useSelector, selectorString, updateSectionsFromSelector, scheduleRecalculate]);\n\n const index = useMemo(() => {\n if (!activeId) return -1;\n return sectionIndexMap.get(activeId) ?? -1;\n }, [activeId, sectionIndexMap]);\n\n return {\n active: activeId,\n index,\n progress: scroll.progress,\n direction: scroll.direction,\n scroll,\n sections,\n ids: sectionIds,\n scrollTo,\n register,\n link,\n };\n}\n\nexport default useDomet;\n","import type { Offset } from \"../types\";\nimport {\n DEFAULT_OFFSET,\n DEFAULT_THRESHOLD,\n DEFAULT_HYSTERESIS,\n DEFAULT_THROTTLE,\n} from \"../constants\";\n\nconst PERCENT_REGEX = /^(-?\\d+(?:\\.\\d+)?)%$/;\n\nexport const VALIDATION_LIMITS = {\n offset: { min: -10000, max: 10000 },\n offsetPercent: { min: -500, max: 500 },\n threshold: { min: 0, max: 1 },\n hysteresis: { min: 0, max: 1000 },\n throttle: { min: 0, max: 1000 },\n} as const;\n\nfunction warn(message: string): void {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`[domet] ${message}`);\n }\n}\n\nfunction isFiniteNumber(value: unknown): value is number {\n return typeof value === \"number\" && Number.isFinite(value);\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.max(min, Math.min(max, value));\n}\n\nexport function sanitizeOffset(offset: Offset | undefined): Offset {\n if (offset === undefined) {\n return DEFAULT_OFFSET;\n }\n\n if (typeof offset === \"number\") {\n if (!isFiniteNumber(offset)) {\n warn(`Invalid offset value: ${offset}. Using default.`);\n return DEFAULT_OFFSET;\n }\n const { min, max } = VALIDATION_LIMITS.offset;\n if (offset < min || offset > max) {\n warn(`Offset ${offset} clamped to [${min}, ${max}].`);\n return clamp(offset, min, max);\n }\n return offset;\n }\n\n if (typeof offset === \"string\") {\n const trimmed = offset.trim();\n const match = PERCENT_REGEX.exec(trimmed);\n if (!match) {\n warn(`Invalid offset format: \"${offset}\". Using default.`);\n return DEFAULT_OFFSET;\n }\n const percent = parseFloat(match[1]);\n if (!isFiniteNumber(percent)) {\n warn(`Invalid percentage value in offset: \"${offset}\". Using default.`);\n return DEFAULT_OFFSET;\n }\n const { min, max } = VALIDATION_LIMITS.offsetPercent;\n if (percent < min || percent > max) {\n warn(`Offset percentage ${percent}% clamped to [${min}%, ${max}%].`);\n return `${clamp(percent, min, max)}%`;\n }\n return trimmed as `${number}%`;\n }\n\n warn(`Invalid offset type: ${typeof offset}. Using default.`);\n return DEFAULT_OFFSET;\n}\n\nexport function sanitizeThreshold(threshold: number | undefined): number {\n if (threshold === undefined) {\n return DEFAULT_THRESHOLD;\n }\n\n if (!isFiniteNumber(threshold)) {\n warn(`Invalid threshold value: ${threshold}. Using default.`);\n return DEFAULT_THRESHOLD;\n }\n\n const { min, max } = VALIDATION_LIMITS.threshold;\n if (threshold < min || threshold > max) {\n warn(`Threshold ${threshold} clamped to [${min}, ${max}].`);\n return clamp(threshold, min, max);\n }\n\n return threshold;\n}\n\nexport function sanitizeHysteresis(hysteresis: number | undefined): number {\n if (hysteresis === undefined) {\n return DEFAULT_HYSTERESIS;\n }\n\n if (!isFiniteNumber(hysteresis)) {\n warn(`Invalid hysteresis value: ${hysteresis}. Using default.`);\n return DEFAULT_HYSTERESIS;\n }\n\n const { min, max } = VALIDATION_LIMITS.hysteresis;\n if (hysteresis < min || hysteresis > max) {\n warn(`Hysteresis ${hysteresis} clamped to [${min}, ${max}].`);\n return clamp(hysteresis, min, max);\n }\n\n return hysteresis;\n}\n\nexport function sanitizeThrottle(throttle: number | undefined): number {\n if (throttle === undefined) {\n return DEFAULT_THROTTLE;\n }\n\n if (!isFiniteNumber(throttle)) {\n warn(`Invalid throttle value: ${throttle}. Using default.`);\n return DEFAULT_THROTTLE;\n }\n\n const { min, max } = VALIDATION_LIMITS.throttle;\n if (throttle < min || throttle > max) {\n warn(`Throttle ${throttle} clamped to [${min}, ${max}].`);\n return clamp(throttle, min, max);\n }\n\n return throttle;\n}\n\nexport function sanitizeIds(ids: string[] | undefined): string[] {\n if (!ids || !Array.isArray(ids)) {\n warn(\"Invalid ids: expected an array. Using empty array.\");\n return [];\n }\n\n const seen = new Set<string>();\n const sanitized: string[] = [];\n\n for (const id of ids) {\n if (typeof id !== \"string\") {\n warn(`Invalid id type: ${typeof id}. Skipping.`);\n continue;\n }\n\n const trimmed = id.trim();\n if (trimmed === \"\") {\n warn(\"Empty string id detected. Skipping.\");\n continue;\n }\n\n if (seen.has(trimmed)) {\n warn(`Duplicate id \"${trimmed}\" detected. Skipping.`);\n continue;\n }\n\n seen.add(trimmed);\n sanitized.push(trimmed);\n }\n\n return sanitized;\n}\n\nexport function sanitizeSelector(selector: string | undefined): string {\n if (selector === undefined) {\n return \"\";\n }\n\n if (typeof selector !== \"string\") {\n warn(`Invalid selector type: ${typeof selector}. Using empty string.`);\n return \"\";\n }\n\n const trimmed = selector.trim();\n if (trimmed === \"\") {\n warn(\"Empty selector provided.\");\n }\n\n return trimmed;\n}\n"],"names":[],"mappings":";;AACO,KAAA,eAAA,GAAA,SAAA,CAAA,WAAA;AACA,KAAA,MAAA;AACA,KAAA,aAAA;AACP;AACA;AACA;AACA;AACO,KAAA,WAAA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,KAAA,YAAA;AACP,YAAA,aAAA;AACA;AACA;AACA;AACA;AACA,UAAA,OAAA;AACA;AACO,KAAA,cAAA;AACA,KAAA,gBAAA;AACA,KAAA,YAAA;AACP;AACA;AACA;AACA;AACO,KAAA,eAAA;AACP,aAAA,MAAA;AACA,eAAA,cAAA;AACA,eAAA,gBAAA;AACA;AACA;AACO,KAAA,eAAA;AACP,aAAA,MAAA;AACA;AACA;AACA;AACA;AACO,KAAA,gBAAA,GAAA,eAAA;AACA,KAAA,YAAA;AACP;AACA;AACA,gBAAA,eAAA;AACA,eAAA,eAAA;AACA,gBAAA,gBAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,gBAAA,eAAA;AACA,eAAA,eAAA;AACA,gBAAA,gBAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,KAAA,aAAA;AACP;AACA,cAAA,WAAA;AACA;AACA;AACO,KAAA,SAAA;AACP;AACA;AACA;AACA;AACO,KAAA,cAAA;AACP;AACA;AACA;AACA;AACA,YAAA,WAAA;AACA,cAAA,MAAA,SAAA,YAAA;AACA;AACA,uBAAA,YAAA,YAAA,eAAA;AACA,8BAAA,aAAA;AACA,iCAAA,eAAA,KAAA,SAAA;AACA;;AC1FO,iBAAA,QAAA,UAAA,YAAA,GAAA,cAAA;;ACAA,cAAA,iBAAA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;;;"}
|
|
1
|
+
{"version":3,"file":"index.d.mts","sources":["../../src/types.ts","../../src/useDomet/index.ts","../../src/utils/validation.ts"],"sourcesContent":["import type { RefObject } from \"react\";\n\nexport type ScrollContainer = RefObject<HTMLElement | null>;\n\nexport type Offset = number | `${number}%`;\n\nexport type SectionBounds = {\n top: number;\n bottom: number;\n height: number;\n};\n\nexport type ScrollState = {\n y: number;\n progress: number;\n direction: \"up\" | \"down\" | null;\n velocity: number;\n scrolling: boolean;\n maxScroll: number;\n viewportHeight: number;\n trackingOffset: number;\n triggerLine: number;\n};\n\nexport type SectionState = {\n bounds: SectionBounds;\n visibility: number;\n progress: number;\n inView: boolean;\n active: boolean;\n rect: DOMRect | null;\n};\n\nexport type ScrollBehavior = \"smooth\" | \"instant\" | \"auto\";\n\nexport type ScrollToPosition = \"top\" | \"center\" | \"bottom\";\n\nexport type ScrollTarget =\n | string\n | { id: string }\n | { top: number };\n\nexport type ScrollToOptions = {\n offset?: Offset;\n behavior?: ScrollBehavior;\n position?: ScrollToPosition;\n lockActive?: boolean;\n};\n\nexport type TrackingOptions = {\n offset?: Offset;\n threshold?: number;\n hysteresis?: number;\n throttle?: number;\n};\n\nexport type ScrollingOptions = ScrollToOptions;\n\nexport type DometOptions = {\n ids: string[];\n selector?: never;\n container?: ScrollContainer;\n tracking?: TrackingOptions;\n scrolling?: ScrollingOptions;\n onActive?: (id: string | null, prevId: string | null) => void;\n onEnter?: (id: string) => void;\n onLeave?: (id: string) => void;\n onScrollStart?: () => void;\n onScrollEnd?: () => void;\n} | {\n ids?: never;\n selector: string;\n container?: ScrollContainer;\n tracking?: TrackingOptions;\n scrolling?: ScrollingOptions;\n onActive?: (id: string | null, prevId: string | null) => void;\n onEnter?: (id: string) => void;\n onLeave?: (id: string) => void;\n onScrollStart?: () => void;\n onScrollEnd?: () => void;\n};\n\nexport type RegisterProps = {\n id: string;\n ref: (el: HTMLElement | null) => void;\n \"data-domet\": string;\n};\n\nexport type LinkProps = {\n onClick: () => void;\n \"aria-current\": \"page\" | undefined;\n \"data-active\": boolean;\n};\n\nexport type UseDometReturn = {\n active: string | null;\n index: number;\n progress: number;\n direction: \"up\" | \"down\" | null;\n scroll: ScrollState;\n sections: Record<string, SectionState>;\n ids: string[];\n scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void;\n register: (id: string) => RegisterProps;\n link: (id: string, options?: ScrollToOptions) => LinkProps;\n navRef: (id: string) => (el: HTMLElement | null) => void;\n};\n\nexport type ResolvedSection = {\n id: string;\n element: HTMLElement;\n};\n\nexport type InternalSectionBounds = SectionBounds & { id: string; rect: DOMRect };\n\nexport type SectionScore = {\n id: string;\n score: number;\n visibilityRatio: number;\n inView: boolean;\n bounds: InternalSectionBounds;\n progress: number;\n rect: DOMRect | null;\n};\n","import {\n startTransition,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport type {\n DometOptions,\n LinkProps,\n RegisterProps,\n ResolvedSection,\n ScrollBehavior,\n ScrollState,\n ScrollTarget,\n ScrollToOptions,\n ScrollToPosition,\n SectionState,\n UseDometReturn,\n} from \"../types\";\n\nimport {\n DEFAULT_OFFSET,\n SCROLL_IDLE_MS,\n} from \"../constants\";\n\nimport {\n resolveContainer,\n resolveSectionsFromIds,\n resolveSectionsFromSelector,\n resolveOffset,\n getSectionBounds,\n calculateSectionScores,\n determineActiveSection,\n sanitizeOffset,\n sanitizeThreshold,\n sanitizeHysteresis,\n sanitizeThrottle,\n sanitizeIds,\n sanitizeSelector,\n useIsomorphicLayoutEffect,\n areIdInputsEqual,\n areScrollStatesEqual,\n areSectionsEqual,\n} from \"../utils\";\n\n\nexport function useDomet(options: DometOptions): UseDometReturn {\n const {\n container: containerInput,\n tracking,\n scrolling,\n onActive,\n onEnter,\n onLeave,\n onScrollStart,\n onScrollEnd,\n } = options;\n\n const trackingOffset = sanitizeOffset(tracking?.offset);\n const throttle = sanitizeThrottle(tracking?.throttle);\n const threshold = sanitizeThreshold(tracking?.threshold);\n const hysteresis = sanitizeHysteresis(tracking?.hysteresis);\n const scrollingDefaults = useMemo(() => {\n if (!scrolling) {\n return {\n behavior: \"auto\" as ScrollBehavior,\n offset: undefined,\n position: undefined,\n lockActive: undefined,\n };\n }\n\n return {\n behavior: scrolling.behavior ?? \"auto\",\n offset: scrolling.offset !== undefined\n ? sanitizeOffset(scrolling.offset)\n : undefined,\n position: scrolling.position,\n lockActive: scrolling.lockActive,\n };\n }, [scrolling]);\n\n const rawIds = \"ids\" in options ? options.ids : undefined;\n const rawSelector = \"selector\" in options ? options.selector : undefined;\n\n const idsCacheRef = useRef<{\n raw: unknown;\n sanitized: string[] | undefined;\n }>({ raw: undefined, sanitized: undefined });\n\n const idsArray = useMemo(() => {\n if (rawIds === undefined) {\n idsCacheRef.current = { raw: undefined, sanitized: undefined };\n return undefined;\n }\n\n if (areIdInputsEqual(rawIds, idsCacheRef.current.raw)) {\n idsCacheRef.current.raw = rawIds;\n return idsCacheRef.current.sanitized;\n }\n\n const sanitized = sanitizeIds(rawIds);\n idsCacheRef.current = { raw: rawIds, sanitized };\n return sanitized;\n }, [rawIds]);\n\n const selectorString = useMemo(() => {\n if (rawSelector === undefined) return undefined;\n return sanitizeSelector(rawSelector);\n }, [rawSelector]);\n const useSelector = selectorString !== undefined && selectorString !== \"\";\n\n const initialActiveId = idsArray && idsArray.length > 0 ? idsArray[0] : null;\n\n const [containerElement, setContainerElement] = useState<HTMLElement | null>(null);\n const [resolvedSections, setResolvedSections] = useState<ResolvedSection[]>([]);\n const [activeId, setActiveId] = useState<string | null>(initialActiveId);\n const [scroll, setScroll] = useState<ScrollState>({\n y: 0,\n progress: 0,\n direction: null,\n velocity: 0,\n scrolling: false,\n maxScroll: 0,\n viewportHeight: 0,\n trackingOffset: 0,\n triggerLine: 0,\n });\n const [sections, setSections] = useState<Record<string, SectionState>>({});\n\n const refs = useRef<Record<string, HTMLElement | null>>({});\n const refCallbacks = useRef<Record<string, (el: HTMLElement | null) => void>>({});\n const registerPropsCache = useRef<Record<string, RegisterProps>>({});\n const navRefs = useRef<Record<string, HTMLElement | null>>({});\n const navRefCallbacks = useRef<Record<string, (el: HTMLElement | null) => void>>({});\n const activeIdRef = useRef<string | null>(initialActiveId);\n const lastScrollY = useRef<number>(0);\n const lastScrollTime = useRef<number>(Date.now());\n const rafId = useRef<number | null>(null);\n const isThrottled = useRef<boolean>(false);\n const throttleTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);\n const hasPendingScroll = useRef<boolean>(false);\n const isProgrammaticScrolling = useRef<boolean>(false);\n const isScrollingRef = useRef<boolean>(false);\n const scrollIdleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const prevSectionsInViewport = useRef<Set<string>>(new Set());\n const prevScrollStateRef = useRef<ScrollState | null>(null);\n const prevSectionsStateRef = useRef<Record<string, SectionState> | null>(null);\n const recalculateRef = useRef<() => void>(() => {});\n const scheduleRecalculate = useCallback(() => {\n if (typeof window === \"undefined\") return;\n if (rafId.current) {\n cancelAnimationFrame(rafId.current);\n }\n rafId.current = requestAnimationFrame(() => {\n rafId.current = null;\n recalculateRef.current();\n });\n }, []);\n const scrollCleanupRef = useRef<(() => void) | null>(null);\n const mutationObserverRef = useRef<MutationObserver | null>(null);\n const mutationDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const optionsRef = useRef({ trackingOffset, scrolling: scrollingDefaults });\n const callbackRefs = useRef({\n onActive,\n onEnter,\n onLeave,\n onScrollStart,\n onScrollEnd,\n });\n\n useIsomorphicLayoutEffect(() => {\n optionsRef.current = { trackingOffset, scrolling: scrollingDefaults };\n }, [trackingOffset, scrollingDefaults]);\n\n useEffect(() => {\n scheduleRecalculate();\n }, [trackingOffset, scheduleRecalculate]);\n\n useIsomorphicLayoutEffect(() => {\n callbackRefs.current = {\n onActive,\n onEnter,\n onLeave,\n onScrollStart,\n onScrollEnd,\n };\n }, [onActive, onEnter, onLeave, onScrollStart, onScrollEnd]);\n\n const sectionIds = useMemo(() => {\n if (!useSelector && idsArray) return idsArray;\n return resolvedSections.map((s) => s.id);\n }, [useSelector, idsArray, resolvedSections]);\n\n const sectionIndexMap = useMemo(() => {\n const map = new Map<string, number>();\n for (let i = 0; i < sectionIds.length; i++) {\n map.set(sectionIds[i], i);\n }\n return map;\n }, [sectionIds]);\n\n const containerRefCurrent = containerInput?.current ?? null;\n\n useIsomorphicLayoutEffect(() => {\n const resolved = resolveContainer(containerInput);\n if (resolved !== containerElement) {\n setContainerElement(resolved);\n }\n }, [containerInput, containerRefCurrent]);\n\n const updateSectionsFromSelector = useCallback((selector: string) => {\n const resolved = resolveSectionsFromSelector(selector);\n setResolvedSections(resolved);\n if (resolved.length > 0) {\n const currentStillExists = resolved.some((s) => s.id === activeIdRef.current);\n if (!activeIdRef.current || !currentStillExists) {\n activeIdRef.current = resolved[0].id;\n setActiveId(resolved[0].id);\n }\n } else if (activeIdRef.current !== null) {\n activeIdRef.current = null;\n setActiveId(null);\n }\n }, []);\n\n useIsomorphicLayoutEffect(() => {\n if (useSelector && selectorString) {\n updateSectionsFromSelector(selectorString);\n }\n }, [selectorString, useSelector, updateSectionsFromSelector]);\n\n useEffect(() => {\n if (\n !useSelector ||\n !selectorString ||\n typeof window === \"undefined\" ||\n typeof MutationObserver === \"undefined\"\n ) {\n return;\n }\n\n const handleMutation = () => {\n if (mutationDebounceRef.current) {\n clearTimeout(mutationDebounceRef.current);\n }\n mutationDebounceRef.current = setTimeout(() => {\n updateSectionsFromSelector(selectorString);\n }, 50);\n };\n\n mutationObserverRef.current = new MutationObserver(handleMutation);\n mutationObserverRef.current.observe(document.body, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: [\"id\", \"data-domet\"],\n });\n\n return () => {\n if (mutationDebounceRef.current) {\n clearTimeout(mutationDebounceRef.current);\n mutationDebounceRef.current = null;\n }\n if (mutationObserverRef.current) {\n mutationObserverRef.current.disconnect();\n mutationObserverRef.current = null;\n }\n };\n }, [useSelector, selectorString, updateSectionsFromSelector]);\n\n useEffect(() => {\n if (!useSelector && idsArray) {\n const idsSet = new Set(idsArray);\n\n for (const id of Object.keys(refs.current)) {\n if (!idsSet.has(id)) {\n delete refs.current[id];\n }\n }\n\n for (const id of Object.keys(refCallbacks.current)) {\n if (!idsSet.has(id)) {\n delete refCallbacks.current[id];\n }\n }\n\n const currentActive = activeIdRef.current;\n const nextActive =\n currentActive && idsSet.has(currentActive)\n ? currentActive\n : (idsArray[0] ?? null);\n\n if (nextActive !== currentActive) {\n activeIdRef.current = nextActive;\n setActiveId(nextActive);\n }\n }\n }, [idsArray, useSelector]);\n\n const registerRef = useCallback((id: string) => {\n const existing = refCallbacks.current[id];\n if (existing) return existing;\n\n const callback = (el: HTMLElement | null) => {\n if (el) {\n refs.current[id] = el;\n } else {\n delete refs.current[id];\n }\n scheduleRecalculate();\n };\n\n refCallbacks.current[id] = callback;\n return callback;\n }, [scheduleRecalculate]);\n\n const navRef = useCallback((id: string) => {\n const existing = navRefCallbacks.current[id];\n if (existing) return existing;\n\n const callback = (el: HTMLElement | null) => {\n if (el) {\n navRefs.current[id] = el;\n } else {\n delete navRefs.current[id];\n }\n };\n\n navRefCallbacks.current[id] = callback;\n return callback;\n }, []);\n\n useEffect(() => {\n if (!activeId) return;\n const navElement = navRefs.current[activeId];\n if (!navElement || typeof navElement.scrollIntoView !== \"function\") return;\n\n navElement.scrollIntoView({\n block: \"nearest\",\n behavior: \"instant\",\n });\n }, [activeId]);\n\n const getResolvedBehavior = useCallback((behaviorOverride?: ScrollBehavior): ScrollBehavior => {\n const b = behaviorOverride ?? optionsRef.current.scrolling.behavior;\n if (b === \"auto\") {\n if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") {\n return \"smooth\";\n }\n const prefersReducedMotion = window.matchMedia(\n \"(prefers-reduced-motion: reduce)\",\n ).matches;\n return prefersReducedMotion ? \"instant\" : \"smooth\";\n }\n return b;\n }, []);\n\n const getCurrentSections = useCallback((): ResolvedSection[] => {\n if (!useSelector && idsArray) {\n return resolveSectionsFromIds(idsArray, refs.current);\n }\n return resolvedSections;\n }, [useSelector, idsArray, resolvedSections]);\n\n const scrollTo = useCallback(\n (target: ScrollTarget, scrollOptions?: ScrollToOptions): void => {\n const resolvedTarget = typeof target === \"string\"\n ? { type: \"id\" as const, id: target }\n : \"id\" in target\n ? { type: \"id\" as const, id: target.id }\n : { type: \"top\" as const, top: target.top };\n\n const defaultScroll = optionsRef.current.scrolling;\n const lockActive = scrollOptions?.lockActive\n ?? defaultScroll.lockActive\n ?? resolvedTarget.type === \"id\";\n const container = containerElement;\n const scrollTarget = container || window;\n const viewportHeight = container ? container.clientHeight : window.innerHeight;\n const scrollHeight = container\n ? container.scrollHeight\n : document.documentElement.scrollHeight;\n const maxScroll = Math.max(0, scrollHeight - viewportHeight);\n const scrollBehavior = getResolvedBehavior(\n scrollOptions?.behavior ?? defaultScroll.behavior,\n );\n const offsetCandidate = scrollOptions?.offset\n ?? defaultScroll.offset;\n const offsetValue = sanitizeOffset(offsetCandidate);\n const effectiveOffset = resolveOffset(offsetValue, viewportHeight, DEFAULT_OFFSET);\n\n const stopProgrammaticScroll = () => {\n if (scrollCleanupRef.current) {\n scrollCleanupRef.current();\n scrollCleanupRef.current = null;\n }\n isProgrammaticScrolling.current = false;\n };\n\n if (!lockActive) {\n stopProgrammaticScroll();\n } else if (scrollCleanupRef.current) {\n scrollCleanupRef.current();\n }\n\n const setupLock = () => {\n const unlockScroll = () => {\n isProgrammaticScrolling.current = false;\n };\n\n let debounceTimer: ReturnType<typeof setTimeout> | null = null;\n let isUnlocked = false;\n\n const cleanup = () => {\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n debounceTimer = null;\n }\n scrollTarget.removeEventListener(\"scroll\", handleScrollActivity);\n if (\"onscrollend\" in scrollTarget) {\n scrollTarget.removeEventListener(\"scrollend\", handleScrollEnd);\n }\n scrollCleanupRef.current = null;\n };\n\n const doUnlock = () => {\n if (isUnlocked) return;\n isUnlocked = true;\n cleanup();\n unlockScroll();\n };\n\n const resetDebounce = () => {\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n }\n debounceTimer = setTimeout(doUnlock, SCROLL_IDLE_MS);\n };\n\n const handleScrollActivity = () => {\n resetDebounce();\n };\n\n const handleScrollEnd = () => {\n doUnlock();\n };\n\n scrollTarget.addEventListener(\"scroll\", handleScrollActivity, {\n passive: true,\n });\n\n if (\"onscrollend\" in scrollTarget) {\n scrollTarget.addEventListener(\"scrollend\", handleScrollEnd, {\n once: true,\n });\n }\n\n scrollCleanupRef.current = cleanup;\n\n return { doUnlock, resetDebounce };\n };\n\n const clampValue = (value: number, min: number, max: number): number =>\n Math.max(min, Math.min(max, value));\n\n let targetScroll: number | null = null;\n let activeTargetId: string | null = null;\n\n if (resolvedTarget.type === \"id\") {\n const id = resolvedTarget.id;\n if (!sectionIndexMap.has(id)) {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`[domet] scrollTo: id \"${id}\" not found`);\n }\n return;\n }\n\n const currentSections = getCurrentSections();\n const section = currentSections.find((s) => s.id === id);\n if (!section) {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`[domet] scrollTo: element for id \"${id}\" not yet mounted`);\n }\n return;\n }\n\n const elementRect = section.element.getBoundingClientRect();\n\n const position: ScrollToPosition | undefined =\n scrollOptions?.position ?? defaultScroll.position;\n\n const sectionTop = container\n ? elementRect.top - container.getBoundingClientRect().top + container.scrollTop\n : elementRect.top + window.scrollY;\n const sectionHeight = elementRect.height;\n\n const calculateTargetScroll = (): number => {\n if (maxScroll <= 0) return 0;\n\n const topTarget = sectionTop - effectiveOffset;\n const centerTarget = sectionTop - (viewportHeight - sectionHeight) / 2;\n const bottomTarget = sectionTop + sectionHeight - viewportHeight;\n\n if (position === \"top\") {\n return clampValue(topTarget, 0, maxScroll);\n }\n\n if (position === \"center\") {\n const fits = sectionHeight <= viewportHeight;\n if (fits) {\n return clampValue(centerTarget, 0, maxScroll);\n }\n return clampValue(topTarget, 0, maxScroll);\n }\n\n if (position === \"bottom\") {\n return clampValue(bottomTarget, 0, maxScroll);\n }\n\n const fits = sectionHeight <= viewportHeight;\n\n const dynamicRange = viewportHeight - effectiveOffset;\n const denominator = dynamicRange !== 0 ? 1 + dynamicRange / maxScroll : 1;\n\n const triggerMin = (sectionTop - effectiveOffset) / denominator;\n const triggerMax = (sectionTop + sectionHeight - effectiveOffset) / denominator;\n\n if (fits) {\n if (centerTarget >= triggerMin && centerTarget <= triggerMax) {\n return clampValue(centerTarget, 0, maxScroll);\n }\n\n if (centerTarget < triggerMin) {\n return clampValue(triggerMin, 0, maxScroll);\n }\n\n return clampValue(triggerMax, 0, maxScroll);\n }\n\n return clampValue(topTarget, 0, maxScroll);\n };\n\n targetScroll = calculateTargetScroll();\n activeTargetId = id;\n } else {\n const top = resolvedTarget.top;\n if (!Number.isFinite(top)) {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`[domet] scrollTo: top \"${top}\" is not a valid number`);\n }\n return;\n }\n targetScroll = clampValue(top - effectiveOffset, 0, maxScroll);\n }\n\n if (targetScroll === null) return;\n\n if (lockActive) {\n isProgrammaticScrolling.current = true;\n if (activeTargetId) {\n activeIdRef.current = activeTargetId;\n setActiveId(activeTargetId);\n }\n }\n\n const lockControls = lockActive ? setupLock() : null;\n\n if (container) {\n container.scrollTo({\n top: targetScroll,\n behavior: scrollBehavior,\n });\n } else {\n window.scrollTo({\n top: targetScroll,\n behavior: scrollBehavior,\n });\n }\n\n if (lockControls) {\n if (scrollBehavior === \"instant\") {\n lockControls.doUnlock();\n } else {\n lockControls.resetDebounce();\n }\n }\n },\n [sectionIndexMap, containerElement, getResolvedBehavior, getCurrentSections],\n );\n\n const register = useCallback(\n (id: string): RegisterProps => {\n const cached = registerPropsCache.current[id];\n if (cached) return cached;\n\n const props: RegisterProps = {\n id,\n ref: registerRef(id),\n \"data-domet\": id,\n };\n registerPropsCache.current[id] = props;\n return props;\n },\n [registerRef],\n );\n\n const link = useCallback(\n (id: string, options?: ScrollToOptions): LinkProps => ({\n onClick: () => scrollTo(id, options),\n \"aria-current\": activeId === id ? \"page\" : undefined,\n \"data-active\": activeId === id,\n }),\n [activeId, scrollTo],\n );\n\n const calculateActiveSection = useCallback(() => {\n const container = containerElement;\n const currentActiveId = activeIdRef.current;\n const now = Date.now();\n const scrollY = container ? container.scrollTop : window.scrollY;\n const viewportHeight = container ? container.clientHeight : window.innerHeight;\n const scrollHeight = container\n ? container.scrollHeight\n : document.documentElement.scrollHeight;\n const maxScroll = Math.max(1, scrollHeight - viewportHeight);\n const scrollProgress = Math.min(1, Math.max(0, scrollY / maxScroll));\n const scrollDirection: \"up\" | \"down\" | null =\n scrollY === lastScrollY.current\n ? null\n : scrollY > lastScrollY.current\n ? \"down\"\n : \"up\";\n const deltaTime = now - lastScrollTime.current;\n const deltaY = scrollY - lastScrollY.current;\n const velocity = deltaTime > 0 ? Math.abs(deltaY) / deltaTime : 0;\n\n lastScrollY.current = scrollY;\n lastScrollTime.current = now;\n\n const currentSections = getCurrentSections();\n const sectionBounds = getSectionBounds(currentSections, container);\n if (sectionBounds.length === 0) return;\n\n const effectiveOffset = resolveOffset(trackingOffset, viewportHeight, DEFAULT_OFFSET);\n\n const scores = calculateSectionScores(sectionBounds, currentSections, {\n scrollY,\n viewportHeight,\n scrollHeight,\n effectiveOffset,\n visibilityThreshold: threshold,\n scrollDirection,\n sectionIndexMap,\n });\n\n const isProgrammatic = isProgrammaticScrolling.current;\n\n const newActiveId = isProgrammatic\n ? currentActiveId\n : determineActiveSection(\n scores,\n sectionIds,\n currentActiveId,\n hysteresis,\n scrollY,\n viewportHeight,\n scrollHeight,\n );\n\n if (!isProgrammatic && newActiveId !== currentActiveId) {\n activeIdRef.current = newActiveId;\n setActiveId(newActiveId);\n callbackRefs.current.onActive?.(newActiveId, currentActiveId);\n }\n\n if (!isProgrammatic) {\n const currentInViewport = new Set(\n scores.filter((s) => s.inView).map((s) => s.id),\n );\n const prevInViewport = prevSectionsInViewport.current;\n\n for (const id of currentInViewport) {\n if (!prevInViewport.has(id)) {\n callbackRefs.current.onEnter?.(id);\n }\n }\n for (const id of prevInViewport) {\n if (!currentInViewport.has(id)) {\n callbackRefs.current.onLeave?.(id);\n }\n }\n prevSectionsInViewport.current = currentInViewport;\n }\n\n const triggerLine = Math.round(\n effectiveOffset + scrollProgress * (viewportHeight - effectiveOffset)\n );\n\n const newScrollState: ScrollState = {\n y: Math.round(scrollY),\n progress: Math.max(0, Math.min(1, scrollProgress)),\n direction: scrollDirection,\n velocity: Math.round(velocity),\n scrolling: isScrollingRef.current,\n maxScroll: Math.round(maxScroll),\n viewportHeight: Math.round(viewportHeight),\n trackingOffset: Math.round(effectiveOffset),\n triggerLine,\n };\n\n const newSections: Record<string, SectionState> = {};\n for (const s of scores) {\n newSections[s.id] = {\n bounds: {\n top: Math.round(s.bounds.top),\n bottom: Math.round(s.bounds.bottom),\n height: Math.round(s.bounds.height),\n },\n visibility: Math.round(s.visibilityRatio * 100) / 100,\n progress: Math.round(s.progress * 100) / 100,\n inView: s.inView,\n active: s.id === (isProgrammatic ? currentActiveId : newActiveId),\n rect: s.rect,\n };\n }\n\n if (!prevScrollStateRef.current || !areScrollStatesEqual(prevScrollStateRef.current, newScrollState)) {\n prevScrollStateRef.current = newScrollState;\n startTransition(() => {\n setScroll(newScrollState);\n });\n }\n\n if (!prevSectionsStateRef.current || !areSectionsEqual(prevSectionsStateRef.current, newSections)) {\n prevSectionsStateRef.current = newSections;\n startTransition(() => {\n setSections(newSections);\n });\n }\n }, [\n sectionIds,\n sectionIndexMap,\n trackingOffset,\n threshold,\n hysteresis,\n containerElement,\n getCurrentSections,\n ]);\n\n recalculateRef.current = calculateActiveSection;\n\n useEffect(() => {\n const container = containerElement;\n const scrollTarget = container || window;\n\n const handleScrollEnd = (): void => {\n isScrollingRef.current = false;\n setScroll((prev) => ({ ...prev, scrolling: false, direction: null }));\n callbackRefs.current.onScrollEnd?.();\n };\n\n const handleScroll = (): void => {\n if (!isScrollingRef.current) {\n isScrollingRef.current = true;\n setScroll((prev) => ({ ...prev, scrolling: true }));\n callbackRefs.current.onScrollStart?.();\n }\n\n if (scrollIdleTimeoutRef.current) {\n clearTimeout(scrollIdleTimeoutRef.current);\n }\n scrollIdleTimeoutRef.current = setTimeout(handleScrollEnd, SCROLL_IDLE_MS);\n\n if (isThrottled.current) {\n hasPendingScroll.current = true;\n return;\n }\n\n isThrottled.current = true;\n hasPendingScroll.current = false;\n\n if (throttleTimeoutId.current) {\n clearTimeout(throttleTimeoutId.current);\n }\n\n scheduleRecalculate();\n\n throttleTimeoutId.current = setTimeout(() => {\n isThrottled.current = false;\n throttleTimeoutId.current = null;\n\n if (hasPendingScroll.current) {\n hasPendingScroll.current = false;\n handleScroll();\n }\n }, throttle);\n };\n\n const handleResize = (): void => {\n if (useSelector && selectorString) {\n updateSectionsFromSelector(selectorString);\n }\n scheduleRecalculate();\n };\n\n const deferredRecalcId = setTimeout(() => {\n scheduleRecalculate();\n }, 0);\n\n scrollTarget.addEventListener(\"scroll\", handleScroll, { passive: true });\n window.addEventListener(\"resize\", handleResize, { passive: true });\n\n return () => {\n clearTimeout(deferredRecalcId);\n scrollTarget.removeEventListener(\"scroll\", handleScroll);\n window.removeEventListener(\"resize\", handleResize);\n if (rafId.current) {\n cancelAnimationFrame(rafId.current);\n rafId.current = null;\n }\n if (throttleTimeoutId.current) {\n clearTimeout(throttleTimeoutId.current);\n throttleTimeoutId.current = null;\n }\n if (scrollIdleTimeoutRef.current) {\n clearTimeout(scrollIdleTimeoutRef.current);\n scrollIdleTimeoutRef.current = null;\n }\n scrollCleanupRef.current?.();\n isThrottled.current = false;\n hasPendingScroll.current = false;\n isProgrammaticScrolling.current = false;\n isScrollingRef.current = false;\n };\n }, [throttle, containerElement, useSelector, selectorString, updateSectionsFromSelector, scheduleRecalculate]);\n\n const index = useMemo(() => {\n if (!activeId) return -1;\n return sectionIndexMap.get(activeId) ?? -1;\n }, [activeId, sectionIndexMap]);\n\n return {\n active: activeId,\n index,\n progress: scroll.progress,\n direction: scroll.direction,\n scroll,\n sections,\n ids: sectionIds,\n scrollTo,\n register,\n link,\n navRef,\n };\n}\n\nexport default useDomet;\n","import type { Offset } from \"../types\";\nimport {\n DEFAULT_OFFSET,\n DEFAULT_THRESHOLD,\n DEFAULT_HYSTERESIS,\n DEFAULT_THROTTLE,\n} from \"../constants\";\n\nconst PERCENT_REGEX = /^(-?\\d+(?:\\.\\d+)?)%$/;\n\nexport const VALIDATION_LIMITS = {\n offset: { min: -10000, max: 10000 },\n offsetPercent: { min: -500, max: 500 },\n threshold: { min: 0, max: 1 },\n hysteresis: { min: 0, max: 1000 },\n throttle: { min: 0, max: 1000 },\n} as const;\n\nfunction warn(message: string): void {\n if (process.env.NODE_ENV !== \"production\") {\n console.warn(`[domet] ${message}`);\n }\n}\n\nfunction isFiniteNumber(value: unknown): value is number {\n return typeof value === \"number\" && Number.isFinite(value);\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.max(min, Math.min(max, value));\n}\n\nexport function sanitizeOffset(offset: Offset | undefined): Offset {\n if (offset === undefined) {\n return DEFAULT_OFFSET;\n }\n\n if (typeof offset === \"number\") {\n if (!isFiniteNumber(offset)) {\n warn(`Invalid offset value: ${offset}. Using default.`);\n return DEFAULT_OFFSET;\n }\n const { min, max } = VALIDATION_LIMITS.offset;\n if (offset < min || offset > max) {\n warn(`Offset ${offset} clamped to [${min}, ${max}].`);\n return clamp(offset, min, max);\n }\n return offset;\n }\n\n if (typeof offset === \"string\") {\n const trimmed = offset.trim();\n const match = PERCENT_REGEX.exec(trimmed);\n if (!match) {\n warn(`Invalid offset format: \"${offset}\". Using default.`);\n return DEFAULT_OFFSET;\n }\n const percent = parseFloat(match[1]);\n if (!isFiniteNumber(percent)) {\n warn(`Invalid percentage value in offset: \"${offset}\". Using default.`);\n return DEFAULT_OFFSET;\n }\n const { min, max } = VALIDATION_LIMITS.offsetPercent;\n if (percent < min || percent > max) {\n warn(`Offset percentage ${percent}% clamped to [${min}%, ${max}%].`);\n return `${clamp(percent, min, max)}%`;\n }\n return trimmed as `${number}%`;\n }\n\n warn(`Invalid offset type: ${typeof offset}. Using default.`);\n return DEFAULT_OFFSET;\n}\n\nexport function sanitizeThreshold(threshold: number | undefined): number {\n if (threshold === undefined) {\n return DEFAULT_THRESHOLD;\n }\n\n if (!isFiniteNumber(threshold)) {\n warn(`Invalid threshold value: ${threshold}. Using default.`);\n return DEFAULT_THRESHOLD;\n }\n\n const { min, max } = VALIDATION_LIMITS.threshold;\n if (threshold < min || threshold > max) {\n warn(`Threshold ${threshold} clamped to [${min}, ${max}].`);\n return clamp(threshold, min, max);\n }\n\n return threshold;\n}\n\nexport function sanitizeHysteresis(hysteresis: number | undefined): number {\n if (hysteresis === undefined) {\n return DEFAULT_HYSTERESIS;\n }\n\n if (!isFiniteNumber(hysteresis)) {\n warn(`Invalid hysteresis value: ${hysteresis}. Using default.`);\n return DEFAULT_HYSTERESIS;\n }\n\n const { min, max } = VALIDATION_LIMITS.hysteresis;\n if (hysteresis < min || hysteresis > max) {\n warn(`Hysteresis ${hysteresis} clamped to [${min}, ${max}].`);\n return clamp(hysteresis, min, max);\n }\n\n return hysteresis;\n}\n\nexport function sanitizeThrottle(throttle: number | undefined): number {\n if (throttle === undefined) {\n return DEFAULT_THROTTLE;\n }\n\n if (!isFiniteNumber(throttle)) {\n warn(`Invalid throttle value: ${throttle}. Using default.`);\n return DEFAULT_THROTTLE;\n }\n\n const { min, max } = VALIDATION_LIMITS.throttle;\n if (throttle < min || throttle > max) {\n warn(`Throttle ${throttle} clamped to [${min}, ${max}].`);\n return clamp(throttle, min, max);\n }\n\n return throttle;\n}\n\nexport function sanitizeIds(ids: string[] | undefined): string[] {\n if (!ids || !Array.isArray(ids)) {\n warn(\"Invalid ids: expected an array. Using empty array.\");\n return [];\n }\n\n const seen = new Set<string>();\n const sanitized: string[] = [];\n\n for (const id of ids) {\n if (typeof id !== \"string\") {\n warn(`Invalid id type: ${typeof id}. Skipping.`);\n continue;\n }\n\n const trimmed = id.trim();\n if (trimmed === \"\") {\n warn(\"Empty string id detected. Skipping.\");\n continue;\n }\n\n if (seen.has(trimmed)) {\n warn(`Duplicate id \"${trimmed}\" detected. Skipping.`);\n continue;\n }\n\n seen.add(trimmed);\n sanitized.push(trimmed);\n }\n\n return sanitized;\n}\n\nexport function sanitizeSelector(selector: string | undefined): string {\n if (selector === undefined) {\n return \"\";\n }\n\n if (typeof selector !== \"string\") {\n warn(`Invalid selector type: ${typeof selector}. Using empty string.`);\n return \"\";\n }\n\n const trimmed = selector.trim();\n if (trimmed === \"\") {\n warn(\"Empty selector provided.\");\n }\n\n return trimmed;\n}\n"],"names":[],"mappings":";;AACO,KAAA,eAAA,GAAA,SAAA,CAAA,WAAA;AACA,KAAA,MAAA;AACA,KAAA,aAAA;AACP;AACA;AACA;AACA;AACO,KAAA,WAAA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,KAAA,YAAA;AACP,YAAA,aAAA;AACA;AACA;AACA;AACA;AACA,UAAA,OAAA;AACA;AACO,KAAA,cAAA;AACA,KAAA,gBAAA;AACA,KAAA,YAAA;AACP;AACA;AACA;AACA;AACO,KAAA,eAAA;AACP,aAAA,MAAA;AACA,eAAA,cAAA;AACA,eAAA,gBAAA;AACA;AACA;AACO,KAAA,eAAA;AACP,aAAA,MAAA;AACA;AACA;AACA;AACA;AACO,KAAA,gBAAA,GAAA,eAAA;AACA,KAAA,YAAA;AACP;AACA;AACA,gBAAA,eAAA;AACA,eAAA,eAAA;AACA,gBAAA,gBAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,gBAAA,eAAA;AACA,eAAA,eAAA;AACA,gBAAA,gBAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,KAAA,aAAA;AACP;AACA,cAAA,WAAA;AACA;AACA;AACO,KAAA,SAAA;AACP;AACA;AACA;AACA;AACO,KAAA,cAAA;AACP;AACA;AACA;AACA;AACA,YAAA,WAAA;AACA,cAAA,MAAA,SAAA,YAAA;AACA;AACA,uBAAA,YAAA,YAAA,eAAA;AACA,8BAAA,aAAA;AACA,iCAAA,eAAA,KAAA,SAAA;AACA,iCAAA,WAAA;AACA;;AC3FO,iBAAA,QAAA,UAAA,YAAA,GAAA,cAAA;;ACAA,cAAA,iBAAA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;;;"}
|
package/dist/es/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { useLayoutEffect, useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
|
2
|
+
import { useLayoutEffect, useEffect, useMemo, useRef, useState, useCallback, startTransition } from 'react';
|
|
3
3
|
|
|
4
4
|
const DEFAULT_THRESHOLD = 0.6;
|
|
5
5
|
const DEFAULT_HYSTERESIS = 150;
|
|
@@ -219,11 +219,12 @@ function getSectionBounds(sections, container) {
|
|
|
219
219
|
id,
|
|
220
220
|
top: relativeTop,
|
|
221
221
|
bottom: relativeTop + rect.height,
|
|
222
|
-
height: rect.height
|
|
222
|
+
height: rect.height,
|
|
223
|
+
rect
|
|
223
224
|
};
|
|
224
225
|
});
|
|
225
226
|
}
|
|
226
|
-
function calculateSectionScores(sectionBounds,
|
|
227
|
+
function calculateSectionScores(sectionBounds, _sections, ctx) {
|
|
227
228
|
const { scrollY, viewportHeight, effectiveOffset, visibilityThreshold} = ctx;
|
|
228
229
|
const viewportTop = scrollY;
|
|
229
230
|
const viewportBottom = scrollY + viewportHeight;
|
|
@@ -231,10 +232,6 @@ function calculateSectionScores(sectionBounds, sections, ctx) {
|
|
|
231
232
|
const scrollProgress = Math.min(1, Math.max(0, scrollY / maxScroll));
|
|
232
233
|
const dynamicOffset = effectiveOffset + scrollProgress * (viewportHeight - effectiveOffset);
|
|
233
234
|
const triggerLine = scrollY + dynamicOffset;
|
|
234
|
-
const elementMap = new Map(sections.map((s)=>[
|
|
235
|
-
s.id,
|
|
236
|
-
s.element
|
|
237
|
-
]));
|
|
238
235
|
return sectionBounds.map((section)=>{
|
|
239
236
|
const visibleTop = Math.max(section.top, viewportTop);
|
|
240
237
|
const visibleBottom = Math.min(section.bottom, viewportBottom);
|
|
@@ -266,8 +263,6 @@ function calculateSectionScores(sectionBounds, sections, ctx) {
|
|
|
266
263
|
const proximityScore = Math.max(0, 1 - distanceFromTrigger / maxDistance) * 500;
|
|
267
264
|
score += proximityScore;
|
|
268
265
|
}
|
|
269
|
-
const element = elementMap.get(section.id);
|
|
270
|
-
const rect = element ? element.getBoundingClientRect() : null;
|
|
271
266
|
return {
|
|
272
267
|
id: section.id,
|
|
273
268
|
score,
|
|
@@ -275,7 +270,7 @@ function calculateSectionScores(sectionBounds, sections, ctx) {
|
|
|
275
270
|
inView: isInView,
|
|
276
271
|
bounds: section,
|
|
277
272
|
progress: sectionProgress,
|
|
278
|
-
rect
|
|
273
|
+
rect: section.rect
|
|
279
274
|
};
|
|
280
275
|
});
|
|
281
276
|
}
|
|
@@ -326,6 +321,23 @@ function areIdInputsEqual(a, b) {
|
|
|
326
321
|
}
|
|
327
322
|
return true;
|
|
328
323
|
}
|
|
324
|
+
function areScrollStatesEqual(a, b) {
|
|
325
|
+
return a.y === b.y && a.progress === b.progress && a.direction === b.direction && a.velocity === b.velocity && a.scrolling === b.scrolling && a.maxScroll === b.maxScroll && a.viewportHeight === b.viewportHeight && a.trackingOffset === b.trackingOffset && a.triggerLine === b.triggerLine;
|
|
326
|
+
}
|
|
327
|
+
function areSectionsEqual(a, b) {
|
|
328
|
+
const keysA = Object.keys(a);
|
|
329
|
+
const keysB = Object.keys(b);
|
|
330
|
+
if (keysA.length !== keysB.length) return false;
|
|
331
|
+
for (const key of keysA){
|
|
332
|
+
const sA = a[key];
|
|
333
|
+
const sB = b[key];
|
|
334
|
+
if (!sB) return false;
|
|
335
|
+
if (sA.visibility !== sB.visibility || sA.progress !== sB.progress || sA.inView !== sB.inView || sA.active !== sB.active || sA.bounds.top !== sB.bounds.top || sA.bounds.bottom !== sB.bounds.bottom || sA.bounds.height !== sB.bounds.height) {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
329
341
|
|
|
330
342
|
function useDomet(options) {
|
|
331
343
|
const { container: containerInput, tracking, scrolling, onActive, onEnter, onLeave, onScrollStart, onScrollEnd } = options;
|
|
@@ -404,6 +416,8 @@ function useDomet(options) {
|
|
|
404
416
|
const refs = useRef({});
|
|
405
417
|
const refCallbacks = useRef({});
|
|
406
418
|
const registerPropsCache = useRef({});
|
|
419
|
+
const navRefs = useRef({});
|
|
420
|
+
const navRefCallbacks = useRef({});
|
|
407
421
|
const activeIdRef = useRef(initialActiveId);
|
|
408
422
|
const lastScrollY = useRef(0);
|
|
409
423
|
const lastScrollTime = useRef(Date.now());
|
|
@@ -415,6 +429,8 @@ function useDomet(options) {
|
|
|
415
429
|
const isScrollingRef = useRef(false);
|
|
416
430
|
const scrollIdleTimeoutRef = useRef(null);
|
|
417
431
|
const prevSectionsInViewport = useRef(new Set());
|
|
432
|
+
const prevScrollStateRef = useRef(null);
|
|
433
|
+
const prevSectionsStateRef = useRef(null);
|
|
418
434
|
const recalculateRef = useRef(()=>{});
|
|
419
435
|
const scheduleRecalculate = useCallback(()=>{
|
|
420
436
|
if (typeof window === "undefined") return;
|
|
@@ -597,6 +613,30 @@ function useDomet(options) {
|
|
|
597
613
|
}, [
|
|
598
614
|
scheduleRecalculate
|
|
599
615
|
]);
|
|
616
|
+
const navRef = useCallback((id)=>{
|
|
617
|
+
const existing = navRefCallbacks.current[id];
|
|
618
|
+
if (existing) return existing;
|
|
619
|
+
const callback = (el)=>{
|
|
620
|
+
if (el) {
|
|
621
|
+
navRefs.current[id] = el;
|
|
622
|
+
} else {
|
|
623
|
+
delete navRefs.current[id];
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
navRefCallbacks.current[id] = callback;
|
|
627
|
+
return callback;
|
|
628
|
+
}, []);
|
|
629
|
+
useEffect(()=>{
|
|
630
|
+
if (!activeId) return;
|
|
631
|
+
const navElement = navRefs.current[activeId];
|
|
632
|
+
if (!navElement || typeof navElement.scrollIntoView !== "function") return;
|
|
633
|
+
navElement.scrollIntoView({
|
|
634
|
+
block: "nearest",
|
|
635
|
+
behavior: "instant"
|
|
636
|
+
});
|
|
637
|
+
}, [
|
|
638
|
+
activeId
|
|
639
|
+
]);
|
|
600
640
|
const getResolvedBehavior = useCallback((behaviorOverride)=>{
|
|
601
641
|
const b = behaviorOverride ?? optionsRef.current.scrolling.behavior;
|
|
602
642
|
if (b === "auto") {
|
|
@@ -733,7 +773,11 @@ function useDomet(options) {
|
|
|
733
773
|
return clampValue(topTarget, 0, maxScroll);
|
|
734
774
|
}
|
|
735
775
|
if (position === "center") {
|
|
736
|
-
|
|
776
|
+
const fits = sectionHeight <= viewportHeight;
|
|
777
|
+
if (fits) {
|
|
778
|
+
return clampValue(centerTarget, 0, maxScroll);
|
|
779
|
+
}
|
|
780
|
+
return clampValue(topTarget, 0, maxScroll);
|
|
737
781
|
}
|
|
738
782
|
if (position === "bottom") {
|
|
739
783
|
return clampValue(bottomTarget, 0, maxScroll);
|
|
@@ -894,8 +938,18 @@ function useDomet(options) {
|
|
|
894
938
|
rect: s.rect
|
|
895
939
|
};
|
|
896
940
|
}
|
|
897
|
-
|
|
898
|
-
|
|
941
|
+
if (!prevScrollStateRef.current || !areScrollStatesEqual(prevScrollStateRef.current, newScrollState)) {
|
|
942
|
+
prevScrollStateRef.current = newScrollState;
|
|
943
|
+
startTransition(()=>{
|
|
944
|
+
setScroll(newScrollState);
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
if (!prevSectionsStateRef.current || !areSectionsEqual(prevSectionsStateRef.current, newSections)) {
|
|
948
|
+
prevSectionsStateRef.current = newSections;
|
|
949
|
+
startTransition(()=>{
|
|
950
|
+
setSections(newSections);
|
|
951
|
+
});
|
|
952
|
+
}
|
|
899
953
|
}, [
|
|
900
954
|
sectionIds,
|
|
901
955
|
sectionIndexMap,
|
|
@@ -913,7 +967,8 @@ function useDomet(options) {
|
|
|
913
967
|
isScrollingRef.current = false;
|
|
914
968
|
setScroll((prev)=>({
|
|
915
969
|
...prev,
|
|
916
|
-
scrolling: false
|
|
970
|
+
scrolling: false,
|
|
971
|
+
direction: null
|
|
917
972
|
}));
|
|
918
973
|
callbackRefs.current.onScrollEnd?.();
|
|
919
974
|
};
|
|
@@ -1011,7 +1066,8 @@ function useDomet(options) {
|
|
|
1011
1066
|
ids: sectionIds,
|
|
1012
1067
|
scrollTo,
|
|
1013
1068
|
register,
|
|
1014
|
-
link
|
|
1069
|
+
link,
|
|
1070
|
+
navRef
|
|
1015
1071
|
};
|
|
1016
1072
|
}
|
|
1017
1073
|
|