domet 1.1.3 → 1.1.5
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 +28 -4
- package/dist/cjs/index.d.ts +8 -2
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +226 -78
- package/dist/es/index.d.mts +8 -2
- package/dist/es/index.d.mts.map +1 -1
- package/dist/es/index.mjs +226 -78
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -58,7 +58,11 @@ function Page() {
|
|
|
58
58
|
| `tracking` | `TrackingOptions` | `undefined` | Tracking configuration (offset, threshold, hysteresis, throttle) |
|
|
59
59
|
| `scrolling` | `ScrollingOptions` | `undefined` | Default scroll behavior for link/scrollTo (behavior, offset, position, lockActive) |
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
`tracking.offset` and `scrolling.offset` serve different purposes:
|
|
62
|
+
- **`tracking.offset`**: Defines the trigger line position (where section detection happens). A value of `100` means the line sits 100px from the top of the viewport. Sections crossing this line are candidates for "active".
|
|
63
|
+
- **`scrolling.offset`**: Only affects programmatic scrolling (`link`/`scrollTo`). It shifts where the section lands after navigation. Has no effect on detection.
|
|
64
|
+
|
|
65
|
+
Tracking defaults are `threshold: 0.6`, `hysteresis: 150`, and `throttle: 10` (ms). `scrolling.behavior` defaults to `auto`, which resolves to `smooth` unless `prefers-reduced-motion` is enabled (then `instant`).
|
|
62
66
|
|
|
63
67
|
IDs are sanitized: non-strings, empty values, and duplicates are ignored.
|
|
64
68
|
|
|
@@ -87,7 +91,7 @@ Callbacks do not fire while `lockActive` is enabled during programmatic scroll.
|
|
|
87
91
|
| `sections` | `Record<string, SectionState>` | Per-section state indexed by ID |
|
|
88
92
|
| `register` | `(id: string) => RegisterProps` | Props to spread on section elements (includes id, ref, data-domet) |
|
|
89
93
|
| `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 |
|
|
94
|
+
| `navRef` | `(id: string, options?: NavRefOptions) => (el: HTMLElement \| null) => void` | Ref callback for nav items; auto-scrolls active item into view in scrollable nav containers |
|
|
91
95
|
| `scrollTo` | `(target: ScrollTarget, options?: ScrollToOptions) => void` | Programmatically scroll to a section or absolute scroll position |
|
|
92
96
|
|
|
93
97
|
### Types
|
|
@@ -122,6 +126,22 @@ type ScrollingOptions = {
|
|
|
122
126
|
|
|
123
127
|
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`).
|
|
124
128
|
|
|
129
|
+
### NavRefOptions
|
|
130
|
+
|
|
131
|
+
Options for customizing nav item auto-scrolling behavior.
|
|
132
|
+
|
|
133
|
+
```ts showLineNumbers
|
|
134
|
+
type NavRefOptions = {
|
|
135
|
+
behavior?: 'smooth' | 'instant' | 'auto'
|
|
136
|
+
offset?: number
|
|
137
|
+
position?: 'nearest' | 'center' | 'start' | 'end'
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
- `behavior`: Scroll animation (`'auto'` respects `prefers-reduced-motion`). Default: `'auto'`.
|
|
142
|
+
- `offset`: Pixel offset from container edge when scrolling nav items. Default: `0`.
|
|
143
|
+
- `position`: Alignment within the scrollable container. Default: `'nearest'`.
|
|
144
|
+
|
|
125
145
|
### ScrollState
|
|
126
146
|
|
|
127
147
|
Global scroll information updated on every scroll event.
|
|
@@ -259,7 +279,7 @@ return (
|
|
|
259
279
|
|
|
260
280
|
### Scrollable Navigation
|
|
261
281
|
|
|
262
|
-
Keep the active nav item visible in a scrollable navigation container:
|
|
282
|
+
Keep the active nav item visible in a scrollable navigation container. The `navRef` function accepts optional scroll options for smooth animations and consistent offset:
|
|
263
283
|
|
|
264
284
|
```tsx showLineNumbers
|
|
265
285
|
const { link, navRef } = useDomet({ ids })
|
|
@@ -267,7 +287,11 @@ const { link, navRef } = useDomet({ ids })
|
|
|
267
287
|
return (
|
|
268
288
|
<nav style={{ maxHeight: '200px', overflow: 'auto' }}>
|
|
269
289
|
{ids.map(id => (
|
|
270
|
-
<button
|
|
290
|
+
<button
|
|
291
|
+
key={id}
|
|
292
|
+
ref={navRef(id, { behavior: 'smooth', offset: 16 })}
|
|
293
|
+
{...link(id)}
|
|
294
|
+
>
|
|
271
295
|
{id}
|
|
272
296
|
</button>
|
|
273
297
|
))}
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -28,6 +28,12 @@ type SectionState = {
|
|
|
28
28
|
};
|
|
29
29
|
type ScrollBehavior = "smooth" | "instant" | "auto";
|
|
30
30
|
type ScrollToPosition = "top" | "center" | "bottom";
|
|
31
|
+
type NavRefPosition = "nearest" | "center" | "start" | "end";
|
|
32
|
+
type NavRefOptions = {
|
|
33
|
+
behavior?: ScrollBehavior;
|
|
34
|
+
offset?: number;
|
|
35
|
+
position?: NavRefPosition;
|
|
36
|
+
};
|
|
31
37
|
type ScrollTarget = string | {
|
|
32
38
|
id: string;
|
|
33
39
|
} | {
|
|
@@ -90,7 +96,7 @@ type UseDometReturn = {
|
|
|
90
96
|
scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void;
|
|
91
97
|
register: (id: string) => RegisterProps;
|
|
92
98
|
link: (id: string, options?: ScrollToOptions) => LinkProps;
|
|
93
|
-
navRef: (id: string) => (el: HTMLElement | null) => void;
|
|
99
|
+
navRef: (id: string, options?: NavRefOptions) => (el: HTMLElement | null) => void;
|
|
94
100
|
};
|
|
95
101
|
|
|
96
102
|
declare function useDomet(options: DometOptions): UseDometReturn;
|
|
@@ -119,5 +125,5 @@ declare const VALIDATION_LIMITS: {
|
|
|
119
125
|
};
|
|
120
126
|
|
|
121
127
|
export { VALIDATION_LIMITS, useDomet as default, useDomet };
|
|
122
|
-
export type { DometOptions, LinkProps, Offset, RegisterProps, ScrollBehavior, ScrollContainer, ScrollState, ScrollTarget, ScrollToOptions, ScrollToPosition, ScrollingOptions, SectionBounds, SectionState, TrackingOptions, UseDometReturn };
|
|
128
|
+
export type { DometOptions, LinkProps, NavRefOptions, NavRefPosition, Offset, RegisterProps, ScrollBehavior, ScrollContainer, ScrollState, ScrollTarget, ScrollToOptions, ScrollToPosition, ScrollingOptions, SectionBounds, SectionState, TrackingOptions, UseDometReturn };
|
|
123
129
|
//# sourceMappingURL=index.d.ts.map
|
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 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;;;;"}
|
|
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 NavRefPosition = \"nearest\" | \"center\" | \"start\" | \"end\";\n\nexport type NavRefOptions = {\n behavior?: ScrollBehavior;\n offset?: number;\n position?: NavRefPosition;\n};\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, options?: NavRefOptions) => (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\nexport type CachedSectionPosition = {\n id: string;\n baseTop: number;\n height: number;\n width: number;\n left: number;\n};\n","import {\n startTransition,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport type {\n CachedSectionPosition,\n DometOptions,\n LinkProps,\n NavRefOptions,\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 buildSectionCache,\n getSectionBoundsFromCache,\n calculateSectionScores,\n determineActiveSection,\n sanitizeOffset,\n sanitizeThreshold,\n sanitizeHysteresis,\n sanitizeThrottle,\n sanitizeIds,\n sanitizeSelector,\n useIsomorphicLayoutEffect,\n areIdInputsEqual,\n findScrollableParent,\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 navRefOptions = useRef<Record<string, NavRefOptions | undefined>>({});\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 currentSectionsInViewport = useRef<Set<string>>(new Set());\n const prevScrollStateRef = useRef<ScrollState | null>(null);\n const prevSectionsStateRef = useRef<Record<string, SectionState> | null>(null);\n const sectionCacheRef = useRef<CachedSectionPosition[]>([]);\n const cacheValidRef = useRef<boolean>(false);\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 cacheValidRef.current = false;\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 const observeTarget = containerElement ?? document.body;\n\n mutationObserverRef.current = new MutationObserver(handleMutation);\n mutationObserverRef.current.observe(observeTarget, {\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, containerElement]);\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 cacheValidRef.current = false;\n scheduleRecalculate();\n };\n\n refCallbacks.current[id] = callback;\n return callback;\n }, [scheduleRecalculate]);\n\n const navRef = useCallback((id: string, options?: NavRefOptions) => {\n navRefOptions.current[id] = options;\n\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 delete navRefOptions.current[id];\n }\n };\n\n navRefCallbacks.current[id] = callback;\n return callback;\n }, []);\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 useEffect(() => {\n if (!activeId) return;\n const navElement = navRefs.current[activeId];\n if (!navElement) return;\n\n const options = navRefOptions.current[activeId];\n const behavior = getResolvedBehavior(options?.behavior);\n const position = options?.position ?? \"nearest\";\n const offset = options?.offset ?? 0;\n\n if (offset === 0) {\n if (typeof navElement.scrollIntoView !== \"function\") return;\n navElement.scrollIntoView({\n block: position,\n behavior,\n });\n return;\n }\n\n const scrollableParent = findScrollableParent(navElement);\n if (!scrollableParent) return;\n\n const parentRect = scrollableParent.getBoundingClientRect();\n const elementRect = navElement.getBoundingClientRect();\n const currentScroll = scrollableParent.scrollTop;\n\n let targetScroll: number;\n const elementTop = elementRect.top - parentRect.top + currentScroll;\n const elementBottom = elementTop + elementRect.height;\n const visibleTop = currentScroll;\n const visibleBottom = currentScroll + parentRect.height;\n\n switch (position) {\n case \"start\":\n targetScroll = elementTop - offset;\n break;\n case \"end\":\n targetScroll = elementBottom - parentRect.height + offset;\n break;\n case \"center\":\n targetScroll = elementTop - (parentRect.height - elementRect.height) / 2;\n break;\n case \"nearest\":\n default: {\n const isAbove = elementTop < visibleTop + offset;\n const isBelow = elementBottom > visibleBottom - offset;\n if (!isAbove && !isBelow) return;\n targetScroll = isAbove\n ? elementTop - offset\n : elementBottom - parentRect.height + offset;\n break;\n }\n }\n\n scrollableParent.scrollTo({\n top: Math.max(0, targetScroll),\n behavior,\n });\n }, [activeId, getResolvedBehavior]);\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 if (currentSections.length === 0) return;\n\n if (!cacheValidRef.current || sectionCacheRef.current.length !== currentSections.length) {\n sectionCacheRef.current = buildSectionCache(currentSections, container);\n cacheValidRef.current = true;\n }\n\n const sectionBounds = getSectionBoundsFromCache(\n sectionCacheRef.current,\n scrollY,\n );\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 = currentSectionsInViewport.current;\n currentInViewport.clear();\n for (const s of scores) {\n if (s.inView) currentInViewport.add(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 const temp = prevSectionsInViewport.current;\n prevSectionsInViewport.current = currentSectionsInViewport.current;\n currentSectionsInViewport.current = temp;\n }\n\n const triggerLine = Math.round(\n effectiveOffset + scrollProgress * (viewportHeight - effectiveOffset)\n );\n\n const roundedY = Math.round(scrollY);\n const clampedProgress = Math.max(0, Math.min(1, scrollProgress));\n const roundedVelocity = Math.round(velocity);\n const roundedMaxScroll = Math.round(maxScroll);\n const roundedViewportHeight = Math.round(viewportHeight);\n const roundedTrackingOffset = Math.round(effectiveOffset);\n const currentScrolling = isScrollingRef.current;\n\n const prev = prevScrollStateRef.current;\n const scrollChanged = !prev ||\n prev.y !== roundedY ||\n prev.progress !== clampedProgress ||\n prev.direction !== scrollDirection ||\n prev.velocity !== roundedVelocity ||\n prev.scrolling !== currentScrolling ||\n prev.maxScroll !== roundedMaxScroll ||\n prev.viewportHeight !== roundedViewportHeight ||\n prev.trackingOffset !== roundedTrackingOffset ||\n prev.triggerLine !== triggerLine;\n\n if (scrollChanged) {\n const newScrollState: ScrollState = {\n y: roundedY,\n progress: clampedProgress,\n direction: scrollDirection,\n velocity: roundedVelocity,\n scrolling: currentScrolling,\n maxScroll: roundedMaxScroll,\n viewportHeight: roundedViewportHeight,\n trackingOffset: roundedTrackingOffset,\n triggerLine,\n };\n prevScrollStateRef.current = newScrollState;\n startTransition(() => {\n setScroll(newScrollState);\n });\n }\n\n const prevSections = prevSectionsStateRef.current;\n let sectionsChanged = !prevSections;\n\n if (!sectionsChanged && prevSections) {\n let countPrev = 0;\n for (const key in prevSections) {\n if (Object.prototype.hasOwnProperty.call(prevSections, key)) countPrev++;\n }\n if (countPrev !== scores.length) {\n sectionsChanged = true;\n } else {\n for (const s of scores) {\n const ps = prevSections[s.id];\n if (!ps) {\n sectionsChanged = true;\n break;\n }\n const roundedVisibility = Math.round(s.visibilityRatio * 100) / 100;\n const roundedProgress = Math.round(s.progress * 100) / 100;\n const isActive = s.id === (isProgrammatic ? currentActiveId : newActiveId);\n const roundedTop = Math.round(s.bounds.top);\n const roundedBottom = Math.round(s.bounds.bottom);\n const roundedHeight = Math.round(s.bounds.height);\n if (\n ps.visibility !== roundedVisibility ||\n ps.progress !== roundedProgress ||\n ps.inView !== s.inView ||\n ps.active !== isActive ||\n ps.bounds.top !== roundedTop ||\n ps.bounds.bottom !== roundedBottom ||\n ps.bounds.height !== roundedHeight\n ) {\n sectionsChanged = true;\n break;\n }\n }\n }\n }\n\n if (sectionsChanged) {\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 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 cacheValidRef.current = false;\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,cAAA;AACA,KAAA,aAAA;AACP,eAAA,cAAA;AACA;AACA,eAAA,cAAA;AACA;AACO,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,mCAAA,aAAA,UAAA,WAAA;AACA;;ACjGO,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;;;;"}
|