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