domet 1.0.5 → 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,119 +1,156 @@
1
- # domet ・ /ˈdɔ.met/
1
+ # Domet
2
2
 
3
- 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.
3
+ ### Introduction
4
+
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.
4
6
 
5
7
  Lightweight under the hood: a tight scroll loop and hysteresis for stable, flicker-free section tracking.
6
8
 
7
9
  For the source code, check out the [GitHub](https://github.com/blksmr/domet).
8
10
 
9
- ## Installation Requires React 18 or higher
11
+ ### Installation
12
+ Install the package from your command line.
10
13
 
11
14
  ```bash
12
15
  npm install domet
13
16
  ```
14
17
 
15
- ## Quick Start
18
+ ### Usage
19
+ Basic example of how to use the hook.
16
20
 
17
- ```tsx
21
+ ```tsx showLineNumbers
18
22
  import { useDomet } from 'domet'
19
23
 
20
- const sections = ['intro', 'features', 'api']
24
+ const ids = ['intro', 'features', 'api']
21
25
 
22
26
  function Page() {
23
- const { activeId, sectionProps, navProps } = useDomet(sections)
27
+ const { active, register, link } = useDomet({
28
+ ids,
29
+ })
24
30
 
25
31
  return (
26
32
  <>
27
33
  <nav>
28
- {sections.map(id => (
29
- <button key={id} {...navProps(id)}>
34
+ {ids.map(id => (
35
+ <button key={id} {...link(id)}>
30
36
  {id}
31
37
  </button>
32
38
  ))}
33
39
  </nav>
34
40
 
35
- <section {...sectionProps('intro')}>...</section>
36
- <section {...sectionProps('features')}>...</section>
37
- <section {...sectionProps('api')}>...</section>
41
+ <section {...register('intro')}>...</section>
42
+ <section {...register('features')}>...</section>
43
+ <section {...register('api')}>...</section>
38
44
  </>
39
45
  )
40
46
  }
41
47
  ```
42
48
 
43
- ## API Reference
49
+ ### API Reference
44
50
 
45
- ### Arguments
51
+ ### Options
46
52
 
47
53
  | Prop | Type | Default | Description |
48
54
  |------|------|---------|-------------|
49
- | `sectionIds` | `string[]` | — | Array of section IDs to track |
50
- | `containerRef` | `RefObject<HTMLElement> \| null` | `null` | Scrollable container (defaults to window) |
51
- | `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) |
52
60
 
53
- ### 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`).
54
62
 
55
- | Prop | Type | Default | Description |
56
- |------|------|---------|-------------|
57
- | `offset` | `number` | `0` | Trigger offset from top in pixels |
58
- | `offsetRatio` | `number` | `0.08` | Viewport ratio for trigger line calculation |
59
- | `debounceMs` | `number` | `10` | Throttle delay in milliseconds |
60
- | `visibilityThreshold` | `number` | `0.6` | Minimum visibility ratio (0-1) for section to get priority |
61
- | `hysteresisMargin` | `number` | `150` | Score margin to prevent rapid section switching |
62
- | `behavior` | `'smooth' \| 'instant' \| 'auto'` | `'auto'` | Scroll behavior. 'auto' respects prefers-reduced-motion |
63
+ IDs are sanitized: non-strings, empty values, and duplicates are ignored.
63
64
 
64
65
  ### Callbacks
65
66
 
66
67
  | Prop | Type | Description |
67
68
  |------|------|-------------|
68
- | `onActiveChange` | `(id: string \| null, prevId: string \| null) => void` | Called when active section changes |
69
- | `onSectionEnter` | `(id: string) => void` | Called when a section enters the viewport |
70
- | `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 |
71
72
  | `onScrollStart` | `() => void` | Called when scrolling starts |
72
73
  | `onScrollEnd` | `() => void` | Called when scrolling stops |
73
74
 
75
+ Callbacks do not fire while `lockActive` is enabled during programmatic scroll. `onScrollEnd` fires after `100` ms of scroll inactivity.
76
+
74
77
  ### Return Value
75
78
 
76
79
  | Prop | Type | Description |
77
80
  |------|------|-------------|
78
- | `activeId` | `string \| null` | ID of the currently active section |
79
- | `activeIndex` | `number` | Index of the active section in sectionIds (-1 if none) |
80
- | `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 |
81
87
  | `sections` | `Record<string, SectionState>` | Per-section state indexed by ID |
82
- | `sectionProps` | `(id: string) => SectionProps` | Props to spread on section elements |
83
- | `navProps` | `(id: string) => NavProps` | Props to spread on nav items |
84
- | `registerRef` | `(id: string) => (el: HTMLElement \| null) => void` | Manual ref registration |
85
- | `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
86
110
 
87
- ## Types
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
+ ```
121
+
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.
88
123
 
89
124
  ### ScrollState
90
125
 
91
126
  Global scroll information updated on every scroll event.
92
127
 
93
- ```ts
128
+ ```ts showLineNumbers
94
129
  type ScrollState = {
95
130
  y: number // Current scroll position in pixels
96
131
  progress: number // Overall scroll progress (0-1)
97
132
  direction: 'up' | 'down' | null // Scroll direction
98
133
  velocity: number // Scroll speed
99
- isScrolling: boolean // True while actively scrolling
134
+ scrolling: boolean // True while actively scrolling
100
135
  maxScroll: number // Maximum scroll value
101
136
  viewportHeight: number // Viewport height in pixels
102
- offset: number // Effective trigger offset
137
+ trackingOffset: number // Effective tracking offset
138
+ triggerLine: number // Dynamic trigger line position in viewport
103
139
  }
104
140
  ```
105
141
 
106
142
  ### SectionState
107
143
 
108
- 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.
109
145
 
110
- ```ts
146
+ ```ts showLineNumbers
111
147
  type SectionState = {
112
148
  bounds: SectionBounds // Position and dimensions
113
149
  visibility: number // Visibility ratio (0-1)
114
150
  progress: number // Section scroll progress (0-1)
115
- isInViewport: boolean // True if any part is visible
116
- 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
117
154
  }
118
155
 
119
156
  type SectionBounds = {
@@ -123,16 +160,45 @@ type SectionBounds = {
123
160
  }
124
161
  ```
125
162
 
126
- ## Examples
163
+ ### ScrollTarget
164
+
165
+ Target input for programmatic scrolling.
166
+
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
127
190
 
128
191
  ### With Callbacks
129
192
 
130
- ```tsx
131
- const { activeId } = useDomet(sections, null, {
132
- onActiveChange: (id, prevId) => {
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) => {
133
199
  console.log(`Changed from ${prevId} to ${id}`)
134
200
  },
135
- onSectionEnter: (id) => {
201
+ onEnter: (id) => {
136
202
  console.log(`Entered: ${id}`)
137
203
  },
138
204
  })
@@ -140,47 +206,102 @@ const { activeId } = useDomet(sections, null, {
140
206
 
141
207
  ### Using Scroll State
142
208
 
143
- ```tsx
144
- 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
+ })
145
215
 
146
216
  // Global progress bar
147
- <div style={{ width: `${scroll.progress * 100}%` }} />
217
+ <div style={{ width: `${progress * 100}%` }} />
148
218
 
149
219
  // Per-section animations
150
- {sectionIds.map(id => (
220
+ {ids.map(id => (
151
221
  <div style={{ opacity: sections[id]?.visibility }} />
152
222
  ))}
153
223
  ```
154
224
 
225
+ ### Default Scrolling Options
226
+
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
+
155
239
  ### Custom Container
156
240
 
157
- ```tsx
241
+ Track scroll within a specific container instead of the window:
242
+
243
+ ```tsx showLineNumbers
158
244
  const containerRef = useRef<HTMLDivElement>(null)
159
- const { activeId } = useDomet(sections, containerRef)
245
+
246
+ const { active, register } = useDomet({
247
+ ids: ['s1', 's2'],
248
+ container: containerRef,
249
+ })
160
250
 
161
251
  return (
162
- <div ref={containerRef} style={{ overflow: 'auto', height: '100vh' }}>
163
- {/* sections */}
252
+ <div ref={containerRef} style={{ height: '100vh', overflow: 'auto' }}>
253
+ <section {...register('s1')}>Section 1</section>
254
+ <section {...register('s2')}>Section 2</section>
164
255
  </div>
165
256
  )
166
257
  ```
167
258
 
168
- ### 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`:
169
262
 
170
263
  ```tsx
171
- useDomet(sections, null, {
172
- visibilityThreshold: 0.8, // Require 80% visibility
173
- 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
+ },
174
293
  })
175
294
  ```
176
295
 
177
- ## Why domet?
296
+ ### Why domet?
178
297
 
179
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.
180
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
+
181
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.
182
303
 
183
- ## Support
304
+ ### Support
184
305
 
185
306
  For issues or feature requests, open an issue on [GitHub](https://github.com/blksmr/domet).
186
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;;;;"}