domet 1.0.6 → 1.1.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Domet
2
2
 
3
- # Introduction
3
+ ### Introduction
4
4
 
5
5
  Domet is a lightweight React hook built for scroll-driven interfaces. Use it for classic scroll-spy, but also for progress indicators, lazy section loading, or any UI that needs reliable section awareness.
6
6
 
@@ -8,116 +8,149 @@ Lightweight under the hood: a tight scroll loop and hysteresis for stable, flick
8
8
 
9
9
  For the source code, check out the [GitHub](https://github.com/blksmr/domet).
10
10
 
11
- ## Installation
11
+ ### Installation
12
12
  Install the package from your command line.
13
13
 
14
14
  ```bash
15
15
  npm install domet
16
16
  ```
17
17
 
18
- ## Usage
18
+ ### Usage
19
19
  Basic example of how to use the hook.
20
20
 
21
21
  ```tsx showLineNumbers
22
22
  import { useDomet } from 'domet'
23
23
 
24
- const sections = ['intro', 'features', 'api']
24
+ const ids = ['intro', 'features', 'api']
25
25
 
26
26
  function Page() {
27
- const { activeId, sectionProps, navProps } = useDomet(sections)
27
+ const { active, register, link } = useDomet({
28
+ ids,
29
+ })
28
30
 
29
31
  return (
30
32
  <>
31
33
  <nav>
32
- {sections.map(id => (
33
- <button key={id} {...navProps(id)}>
34
+ {ids.map(id => (
35
+ <button key={id} {...link(id)}>
34
36
  {id}
35
37
  </button>
36
38
  ))}
37
39
  </nav>
38
40
 
39
- <section {...sectionProps('intro')}>...</section>
40
- <section {...sectionProps('features')}>...</section>
41
- <section {...sectionProps('api')}>...</section>
41
+ <section {...register('intro')}>...</section>
42
+ <section {...register('features')}>...</section>
43
+ <section {...register('api')}>...</section>
42
44
  </>
43
45
  )
44
46
  }
45
47
  ```
46
48
 
47
- ## API Reference
49
+ ### API Reference
48
50
 
49
- ## Arguments
51
+ ### Options
50
52
 
51
53
  | Prop | Type | Default | Description |
52
54
  |------|------|---------|-------------|
53
- | `sectionIds` | `string[]` | — | Array of section IDs to track |
54
- | `containerRef` | `RefObject<HTMLElement> \| null` | `null` | Scrollable container (defaults to window) |
55
- | `options` | `DometOptions` | `{}` | Configuration options |
55
+ | `ids` | `string[]` | — | Array of section IDs to track (mutually exclusive with selector) |
56
+ | `selector` | `string` | | CSS selector to find sections (mutually exclusive with ids) |
57
+ | `container` | `RefObject<HTMLElement \| null>` | `undefined` | React ref to scrollable container (defaults to window) |
58
+ | `tracking` | `TrackingOptions` | `undefined` | Tracking configuration (offset, threshold, hysteresis, throttle) |
59
+ | `scrolling` | `ScrollingOptions` | `undefined` | Default scroll behavior for link/scrollTo (behavior, offset, position, lockActive) |
56
60
 
57
- ## Options
61
+ Note that `tracking.offset` affects tracking (active section + trigger line). `scrolling.offset` only shifts programmatic scroll targets and defaults to `0`. 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`).
58
62
 
59
- | Prop | Type | Default | Description |
60
- |------|------|---------|-------------|
61
- | `offset` | `number` | `0` | Trigger offset from top in pixels |
62
- | `offsetRatio` | `number` | `0.08` | Viewport ratio for trigger line calculation |
63
- | `debounceMs` | `number` | `10` | Throttle delay in milliseconds |
64
- | `visibilityThreshold` | `number` | `0.6` | Minimum visibility ratio (0-1) for section to get priority |
65
- | `hysteresisMargin` | `number` | `150` | Score margin to prevent rapid section switching |
66
- | `behavior` | `'smooth' \| 'instant' \| 'auto'` | `'auto'` | Scroll behavior. 'auto' respects prefers-reduced-motion |
63
+ IDs are sanitized: non-strings, empty values, and duplicates are ignored.
67
64
 
68
- ## Callbacks
65
+ ### Callbacks
69
66
 
70
67
  | Prop | Type | Description |
71
68
  |------|------|-------------|
72
- | `onActiveChange` | `(id: string \| null, prevId: string \| null) => void` | Called when active section changes |
73
- | `onSectionEnter` | `(id: string) => void` | Called when a section enters the viewport |
74
- | `onSectionLeave` | `(id: string) => void` | Called when a section leaves the viewport |
69
+ | `onActive` | `(id: string \| null, prevId: string \| null) => void` | Called when active section changes |
70
+ | `onEnter` | `(id: string) => void` | Called when a section enters the viewport |
71
+ | `onLeave` | `(id: string) => void` | Called when a section leaves the viewport |
75
72
  | `onScrollStart` | `() => void` | Called when scrolling starts |
76
73
  | `onScrollEnd` | `() => void` | Called when scrolling stops |
77
74
 
78
- ## Return Value
75
+ Callbacks do not fire while `lockActive` is enabled during programmatic scroll. `onScrollEnd` fires after `100` ms of scroll inactivity.
76
+
77
+ ### Return Value
79
78
 
80
79
  | Prop | Type | Description |
81
80
  |------|------|-------------|
82
- | `activeId` | `string \| null` | ID of the currently active section |
83
- | `activeIndex` | `number` | Index of the active section in sectionIds (-1 if none) |
84
- | `scroll` | `ScrollState` | Global scroll state |
81
+ | `active` | `string \| null` | ID of the currently active section |
82
+ | `index` | `number` | Index of the active section in ids (-1 if none) |
83
+ | `progress` | `number` | Overall scroll progress (0-1), shortcut for scroll.progress |
84
+ | `direction` | `'up' \| 'down' \| null` | Scroll direction, shortcut for scroll.direction |
85
+ | `ids` | `string[]` | Resolved section IDs (useful with CSS selector) |
86
+ | `scroll` | `ScrollState` | Full scroll state object |
85
87
  | `sections` | `Record<string, SectionState>` | Per-section state indexed by ID |
86
- | `sectionProps` | `(id: string) => SectionProps` | Props to spread on section elements |
87
- | `navProps` | `(id: string) => NavProps` | Props to spread on nav items |
88
- | `registerRef` | `(id: string) => (el: HTMLElement \| null) => void` | Manual ref registration |
89
- | `scrollToSection` | `(id: string) => void` | Programmatically scroll to a section |
88
+ | `register` | `(id: string) => RegisterProps` | Props to spread on section elements (includes id, ref, data-domet) |
89
+ | `link` | `(id: string, options?: ScrollToOptions) => LinkProps` | Nav props (onClick, aria-current, data-active) with optional scroll overrides |
90
+ | `scrollTo` | `(target: ScrollTarget, options?: ScrollToOptions) => void` | Programmatically scroll to a section or absolute scroll position |
91
+
92
+ ### Types
93
+
94
+ ### TrackingOptions
95
+
96
+ Options that control tracking behavior.
97
+
98
+ ```ts showLineNumbers
99
+ type TrackingOptions = {
100
+ offset?: number | `${number}%`
101
+ threshold?: number
102
+ hysteresis?: number
103
+ throttle?: number
104
+ }
105
+ ```
106
+
107
+ Defaults: `threshold: 0.6`, `hysteresis: 150`, `throttle: 10` (ms).
108
+
109
+ ### ScrollingOptions
110
+
111
+ Defaults for programmatic scrolling (link/scrollTo).
112
+
113
+ ```ts showLineNumbers
114
+ type ScrollingOptions = {
115
+ behavior?: 'smooth' | 'instant' | 'auto'
116
+ offset?: number | `${number}%`
117
+ position?: 'top' | 'center' | 'bottom'
118
+ lockActive?: boolean
119
+ }
120
+ ```
90
121
 
91
- ## Types
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.
92
123
 
93
- ## ScrollState
124
+ ### ScrollState
94
125
 
95
126
  Global scroll information updated on every scroll event.
96
127
 
97
- ```ts
128
+ ```ts showLineNumbers
98
129
  type ScrollState = {
99
130
  y: number // Current scroll position in pixels
100
131
  progress: number // Overall scroll progress (0-1)
101
132
  direction: 'up' | 'down' | null // Scroll direction
102
133
  velocity: number // Scroll speed
103
- isScrolling: boolean // True while actively scrolling
134
+ scrolling: boolean // True while actively scrolling
104
135
  maxScroll: number // Maximum scroll value
105
136
  viewportHeight: number // Viewport height in pixels
106
- offset: number // Effective trigger offset
137
+ trackingOffset: number // Effective tracking offset
138
+ triggerLine: number // Dynamic trigger line position in viewport
107
139
  }
108
140
  ```
109
141
 
110
- ## SectionState
142
+ ### SectionState
111
143
 
112
- Per-section state available for each tracked section.
144
+ Per-section state available for each tracked section. `visibility` and `progress` are rounded to 2 decimals.
113
145
 
114
- ```ts
146
+ ```ts showLineNumbers
115
147
  type SectionState = {
116
148
  bounds: SectionBounds // Position and dimensions
117
149
  visibility: number // Visibility ratio (0-1)
118
150
  progress: number // Section scroll progress (0-1)
119
- isInViewport: boolean // True if any part is visible
120
- isActive: boolean // True if this is the active section
151
+ inView: boolean // True if any part is visible
152
+ active: boolean // True if this is the active section
153
+ rect: DOMRect | null // Full bounding rect
121
154
  }
122
155
 
123
156
  type SectionBounds = {
@@ -127,64 +160,148 @@ type SectionBounds = {
127
160
  }
128
161
  ```
129
162
 
130
- ## Examples
163
+ ### ScrollTarget
131
164
 
132
- ## With Callbacks
165
+ Target input for programmatic scrolling.
133
166
 
134
- ```tsx
135
- const { activeId } = useDomet(sections, null, {
136
- onActiveChange: (id, prevId) => {
167
+ ```ts showLineNumbers
168
+ type ScrollTarget =
169
+ | string
170
+ | { id: string }
171
+ | { top: number } // Absolute scroll position in px (scrolling.offset is subtracted)
172
+ ```
173
+
174
+ ### ScrollToOptions
175
+
176
+ Options for programmatic scrolling. Use `scrolling` in the hook options for defaults, and pass overrides to `link` or `scrollTo`.
177
+
178
+ ```ts showLineNumbers
179
+ type ScrollToOptions = {
180
+ offset?: number | `${number}%` // Override scroll target offset (applies to id/top targets)
181
+ behavior?: 'smooth' | 'instant' | 'auto' // Override scroll behavior
182
+ position?: 'top' | 'center' | 'bottom' // Section alignment for ID targets only
183
+ lockActive?: boolean // Lock active section during programmatic scroll
184
+ }
185
+ ```
186
+
187
+ By default, `lockActive` is enabled for id targets and disabled for `{ top }`.
188
+
189
+ ### Examples
190
+
191
+ ### With Callbacks
192
+
193
+ React to section changes with callbacks for analytics, animations, or state updates:
194
+
195
+ ```tsx showLineNumbers
196
+ const { active } = useDomet({
197
+ ids: ['intro', 'features', 'api'],
198
+ onActive: (id, prevId) => {
137
199
  console.log(`Changed from ${prevId} to ${id}`)
138
200
  },
139
- onSectionEnter: (id) => {
201
+ onEnter: (id) => {
140
202
  console.log(`Entered: ${id}`)
141
203
  },
142
204
  })
143
205
  ```
144
206
 
145
- ## Using Scroll State
207
+ ### Using Scroll State
146
208
 
147
- ```tsx
148
- const { scroll, sections } = useDomet(sectionIds)
209
+ Build progress indicators and scroll-driven animations using the scroll state:
210
+
211
+ ```tsx showLineNumbers
212
+ const { progress, sections, ids } = useDomet({
213
+ ids: ['intro', 'features', 'api'],
214
+ })
149
215
 
150
216
  // Global progress bar
151
- <div style={{ width: `${scroll.progress * 100}%` }} />
217
+ <div style={{ width: `${progress * 100}%` }} />
152
218
 
153
219
  // Per-section animations
154
- {sectionIds.map(id => (
220
+ {ids.map(id => (
155
221
  <div style={{ opacity: sections[id]?.visibility }} />
156
222
  ))}
157
223
  ```
158
224
 
159
- ## Custom Container
225
+ ### Default Scrolling Options
160
226
 
161
- ```tsx
227
+ Define default scroll behavior for links and override per click:
228
+
229
+ ```tsx showLineNumbers
230
+ const { link } = useDomet({
231
+ ids: ['intro', 'details'],
232
+ scrolling: { position: 'top', behavior: 'smooth' },
233
+ })
234
+
235
+ <button {...link('intro')}>Intro</button>
236
+ <button {...link('details', { behavior: 'instant', offset: 100 })}>Details</button>
237
+ ```
238
+
239
+ ### Custom Container
240
+
241
+ Track scroll within a specific container instead of the window:
242
+
243
+ ```tsx showLineNumbers
162
244
  const containerRef = useRef<HTMLDivElement>(null)
163
- const { activeId } = useDomet(sections, containerRef)
245
+
246
+ const { active, register } = useDomet({
247
+ ids: ['s1', 's2'],
248
+ container: containerRef,
249
+ })
164
250
 
165
251
  return (
166
- <div ref={containerRef} style={{ overflow: 'auto', height: '100vh' }}>
167
- {/* sections */}
252
+ <div ref={containerRef} style={{ height: '100vh', overflow: 'auto' }}>
253
+ <section {...register('s1')}>Section 1</section>
254
+ <section {...register('s2')}>Section 2</section>
168
255
  </div>
169
256
  )
170
257
  ```
171
258
 
172
- ## Fine-tuning Behavior
259
+ ### Third-party Components
260
+
261
+ If a third-party component only accepts a `ref` prop (no spread), extract the ref from `register`:
173
262
 
174
263
  ```tsx
175
- useDomet(sections, null, {
176
- visibilityThreshold: 0.8, // Require 80% visibility
177
- hysteresisMargin: 200, // More resistance to switching
264
+ <ThirdPartyComponent ref={register('section-1').ref} />
265
+ ```
266
+
267
+ ### CSS Selector for Sections
268
+
269
+ Instead of passing an array of IDs, you can use the `selector` prop to automatically find sections:
270
+
271
+ ```tsx showLineNumbers
272
+ const { active, ids } = useDomet({
273
+ selector: '[data-section]', // CSS selector
274
+ })
275
+
276
+ // ids will contain IDs from:
277
+ // 1. element.id
278
+ // 2. data-domet attribute
279
+ // 3. fallback: section-0, section-1, etc.
280
+ ```
281
+
282
+ ### Fine-tuning Behavior
283
+
284
+ Adjust sensitivity and stability of section detection:
285
+
286
+ ```tsx showLineNumbers
287
+ useDomet({
288
+ ids: ['intro', 'features'],
289
+ tracking: {
290
+ threshold: 0.8, // Require 80% visibility
291
+ hysteresis: 200, // More resistance to switching
292
+ },
178
293
  })
179
294
  ```
180
295
 
181
- ## Why domet?
296
+ ### Why domet?
182
297
 
183
298
  This library was born from a real need at work. I wanted a scroll-spy solution that was powerful and completely headless, but above all, extremely lightweight. No bloated dependencies, no opinionated styling, just a hook that does one thing well.
184
299
 
300
+ Why a hook instead of a component wrapper? Because hooks give you full control. You decide the markup, the styling, and the behavior. If you want a `<ScrollSpy>` component, you can build one in minutes on top of `useDomet`. The hook stays minimal; you compose what you need.
301
+
185
302
  The name **domet** comes from Bosnian/Serbian/Croatian and means "reach" or "range" — the distance something can cover. Pronounced `/ˈdɔ.met/`: stress on the first syllable, open "o", and a hard "t" at the end.
186
303
 
187
- ## Support
304
+ ### Support
188
305
 
189
306
  For issues or feature requests, open an issue on [GitHub](https://github.com/blksmr/domet).
190
307
 
@@ -1,5 +1,7 @@
1
1
  import { RefObject } from 'react';
2
2
 
3
+ type ScrollContainer = RefObject<HTMLElement | null>;
4
+ type Offset = number | `${number}%`;
3
5
  type SectionBounds = {
4
6
  top: number;
5
7
  bottom: number;
@@ -10,53 +12,111 @@ type ScrollState = {
10
12
  progress: number;
11
13
  direction: "up" | "down" | null;
12
14
  velocity: number;
13
- isScrolling: boolean;
15
+ scrolling: boolean;
14
16
  maxScroll: number;
15
17
  viewportHeight: number;
16
- offset: number;
18
+ trackingOffset: number;
19
+ triggerLine: number;
17
20
  };
18
21
  type SectionState = {
19
22
  bounds: SectionBounds;
20
23
  visibility: number;
21
24
  progress: number;
22
- isInViewport: boolean;
23
- isActive: boolean;
25
+ inView: boolean;
26
+ active: boolean;
27
+ rect: DOMRect | null;
24
28
  };
25
29
  type ScrollBehavior = "smooth" | "instant" | "auto";
26
- type DometOptions = {
27
- offset?: number;
28
- offsetRatio?: number;
29
- debounceMs?: number;
30
- visibilityThreshold?: number;
31
- hysteresisMargin?: number;
30
+ type ScrollToPosition = "top" | "center" | "bottom";
31
+ type ScrollTarget = string | {
32
+ id: string;
33
+ } | {
34
+ top: number;
35
+ };
36
+ type ScrollToOptions = {
37
+ offset?: Offset;
32
38
  behavior?: ScrollBehavior;
33
- onActiveChange?: (id: string | null, prevId: string | null) => void;
34
- onSectionEnter?: (id: string) => void;
35
- onSectionLeave?: (id: string) => void;
39
+ position?: ScrollToPosition;
40
+ lockActive?: boolean;
41
+ };
42
+ type TrackingOptions = {
43
+ offset?: Offset;
44
+ threshold?: number;
45
+ hysteresis?: number;
46
+ throttle?: number;
47
+ };
48
+ type ScrollingOptions = ScrollToOptions;
49
+ type DometOptions = {
50
+ ids: string[];
51
+ selector?: never;
52
+ container?: ScrollContainer;
53
+ tracking?: TrackingOptions;
54
+ scrolling?: ScrollingOptions;
55
+ onActive?: (id: string | null, prevId: string | null) => void;
56
+ onEnter?: (id: string) => void;
57
+ onLeave?: (id: string) => void;
58
+ onScrollStart?: () => void;
59
+ onScrollEnd?: () => void;
60
+ } | {
61
+ ids?: never;
62
+ selector: string;
63
+ container?: ScrollContainer;
64
+ tracking?: TrackingOptions;
65
+ scrolling?: ScrollingOptions;
66
+ onActive?: (id: string | null, prevId: string | null) => void;
67
+ onEnter?: (id: string) => void;
68
+ onLeave?: (id: string) => void;
36
69
  onScrollStart?: () => void;
37
70
  onScrollEnd?: () => void;
38
71
  };
39
- type SectionProps = {
72
+ type RegisterProps = {
40
73
  id: string;
41
74
  ref: (el: HTMLElement | null) => void;
42
75
  "data-domet": string;
43
76
  };
44
- type NavProps = {
77
+ type LinkProps = {
45
78
  onClick: () => void;
46
79
  "aria-current": "page" | undefined;
47
80
  "data-active": boolean;
48
81
  };
49
82
  type UseDometReturn = {
50
- activeId: string | null;
51
- activeIndex: number;
83
+ active: string | null;
84
+ index: number;
85
+ progress: number;
86
+ direction: "up" | "down" | null;
52
87
  scroll: ScrollState;
53
88
  sections: Record<string, SectionState>;
54
- registerRef: (id: string) => (el: HTMLElement | null) => void;
55
- scrollToSection: (id: string) => void;
56
- sectionProps: (id: string) => SectionProps;
57
- navProps: (id: string) => NavProps;
89
+ ids: string[];
90
+ scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void;
91
+ register: (id: string) => RegisterProps;
92
+ link: (id: string, options?: ScrollToOptions) => LinkProps;
93
+ };
94
+
95
+ declare function useDomet(options: DometOptions): UseDometReturn;
96
+
97
+ declare const VALIDATION_LIMITS: {
98
+ readonly offset: {
99
+ readonly min: -10000;
100
+ readonly max: 10000;
101
+ };
102
+ readonly offsetPercent: {
103
+ readonly min: -500;
104
+ readonly max: 500;
105
+ };
106
+ readonly threshold: {
107
+ readonly min: 0;
108
+ readonly max: 1;
109
+ };
110
+ readonly hysteresis: {
111
+ readonly min: 0;
112
+ readonly max: 1000;
113
+ };
114
+ readonly throttle: {
115
+ readonly min: 0;
116
+ readonly max: 1000;
117
+ };
58
118
  };
59
- declare function useDomet(sectionIds: string[], containerRef?: RefObject<HTMLElement> | null, options?: DometOptions): UseDometReturn;
60
119
 
61
- export { useDomet as default, useDomet };
62
- export type { DometOptions, NavProps, ScrollBehavior, ScrollState, SectionBounds, SectionProps, SectionState, UseDometReturn };
120
+ export { VALIDATION_LIMITS, useDomet as default, useDomet };
121
+ export type { DometOptions, LinkProps, Offset, RegisterProps, ScrollBehavior, ScrollContainer, ScrollState, ScrollTarget, ScrollToOptions, ScrollToPosition, ScrollingOptions, SectionBounds, SectionState, TrackingOptions, UseDometReturn };
122
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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;;;;"}