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 +184 -63
- package/dist/cjs/index.d.ts +84 -24
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +805 -290
- package/dist/es/index.d.mts +84 -24
- package/dist/es/index.d.mts.map +1 -0
- package/dist/es/index.mjs +806 -292
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -1,119 +1,156 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Domet
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
24
|
+
const ids = ['intro', 'features', 'api']
|
|
21
25
|
|
|
22
26
|
function Page() {
|
|
23
|
-
const {
|
|
27
|
+
const { active, register, link } = useDomet({
|
|
28
|
+
ids,
|
|
29
|
+
})
|
|
24
30
|
|
|
25
31
|
return (
|
|
26
32
|
<>
|
|
27
33
|
<nav>
|
|
28
|
-
{
|
|
29
|
-
<button key={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 {...
|
|
36
|
-
<section {...
|
|
37
|
-
<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
|
-
|
|
49
|
+
### API Reference
|
|
44
50
|
|
|
45
|
-
###
|
|
51
|
+
### Options
|
|
46
52
|
|
|
47
53
|
| Prop | Type | Default | Description |
|
|
48
54
|
|------|------|---------|-------------|
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
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
|
-
| `
|
|
79
|
-
| `
|
|
80
|
-
| `
|
|
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
|
-
| `
|
|
83
|
-
| `
|
|
84
|
-
| `
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
+
scrolling: boolean // True while actively scrolling
|
|
100
135
|
maxScroll: number // Maximum scroll value
|
|
101
136
|
viewportHeight: number // Viewport height in pixels
|
|
102
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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: `${
|
|
217
|
+
<div style={{ width: `${progress * 100}%` }} />
|
|
148
218
|
|
|
149
219
|
// Per-section animations
|
|
150
|
-
{
|
|
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
|
-
|
|
241
|
+
Track scroll within a specific container instead of the window:
|
|
242
|
+
|
|
243
|
+
```tsx showLineNumbers
|
|
158
244
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
159
|
-
|
|
245
|
+
|
|
246
|
+
const { active, register } = useDomet({
|
|
247
|
+
ids: ['s1', 's2'],
|
|
248
|
+
container: containerRef,
|
|
249
|
+
})
|
|
160
250
|
|
|
161
251
|
return (
|
|
162
|
-
<div ref={containerRef} style={{
|
|
163
|
-
{
|
|
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
|
-
###
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
304
|
+
### Support
|
|
184
305
|
|
|
185
306
|
For issues or feature requests, open an issue on [GitHub](https://github.com/blksmr/domet).
|
|
186
307
|
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
+
scrolling: boolean;
|
|
14
16
|
maxScroll: number;
|
|
15
17
|
viewportHeight: number;
|
|
16
|
-
|
|
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
|
-
|
|
23
|
-
|
|
25
|
+
inView: boolean;
|
|
26
|
+
active: boolean;
|
|
27
|
+
rect: DOMRect | null;
|
|
24
28
|
};
|
|
25
29
|
type ScrollBehavior = "smooth" | "instant" | "auto";
|
|
26
|
-
type
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
72
|
+
type RegisterProps = {
|
|
40
73
|
id: string;
|
|
41
74
|
ref: (el: HTMLElement | null) => void;
|
|
42
75
|
"data-domet": string;
|
|
43
76
|
};
|
|
44
|
-
type
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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,
|
|
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;;;;"}
|