rune-scroller 0.1.11 → 2.0.0
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 +195 -29
- package/dist/__mocks__/IntersectionObserver.d.ts +25 -0
- package/dist/__mocks__/IntersectionObserver.js +116 -0
- package/dist/__mocks__/svelte-runes.d.ts +25 -0
- package/dist/__mocks__/svelte-runes.js +117 -0
- package/dist/__test-helpers__/dom.d.ts +118 -0
- package/dist/__test-helpers__/dom.js +305 -0
- package/dist/animate.d.ts +4 -0
- package/dist/animate.js +152 -0
- package/dist/animate.test.js +370 -0
- package/dist/animations.comprehensive.test.d.ts +1 -0
- package/dist/animations.comprehensive.test.js +432 -0
- package/dist/animations.css +21 -12
- package/dist/animations.d.ts +12 -9
- package/dist/animations.js +31 -6
- package/dist/animations.test.js +23 -41
- package/dist/dom-utils.d.ts +40 -0
- package/dist/dom-utils.js +111 -0
- package/dist/dom-utils.test.d.ts +1 -0
- package/dist/dom-utils.test.js +220 -0
- package/dist/index.d.ts +6 -6
- package/dist/index.js +17 -4
- package/dist/observer-utils.d.ts +40 -0
- package/dist/observer-utils.js +50 -0
- package/dist/robustness.test.d.ts +1 -0
- package/dist/robustness.test.js +317 -0
- package/dist/runeScroller.d.ts +25 -0
- package/dist/runeScroller.integration.test.d.ts +1 -0
- package/dist/runeScroller.integration.test.js +419 -0
- package/dist/runeScroller.js +183 -0
- package/dist/runeScroller.test.d.ts +1 -0
- package/dist/runeScroller.test.js +375 -0
- package/dist/types.d.ts +104 -24
- package/dist/types.js +58 -0
- package/dist/useIntersection.svelte.d.ts +7 -12
- package/dist/useIntersection.svelte.js +75 -54
- package/dist/useIntersection.test.d.ts +1 -0
- package/dist/useIntersection.test.js +98 -0
- package/package.json +19 -18
- package/dist/BaseAnimated.svelte +0 -48
- package/dist/BaseAnimated.svelte.d.ts +0 -16
- package/dist/RuneScroller.svelte +0 -37
- package/dist/RuneScroller.svelte.d.ts +0 -16
- package/dist/animate.svelte.d.ts +0 -14
- package/dist/animate.svelte.js +0 -79
- package/dist/dom-utils.svelte.d.ts +0 -22
- package/dist/dom-utils.svelte.js +0 -46
- package/dist/runeScroller.svelte.d.ts +0 -24
- package/dist/runeScroller.svelte.js +0 -79
- package/dist/scroll-animate.test.js +0 -57
- /package/dist/{scroll-animate.test.d.ts → animate.test.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
# ⚡ Rune Scroller
|
|
1
|
+
# ⚡ Rune Scroller - Full Reference
|
|
2
|
+
|
|
3
|
+
**📚 Complete API Reference** — Detailed documentation for all features and options.
|
|
4
|
+
|
|
5
|
+
**Quick start?** See [README.md](../README.md) for a simpler introduction.
|
|
6
|
+
|
|
7
|
+
**Development?** See [CLAUDE.md](../CLAUDE.md) for the developer guide.
|
|
8
|
+
|
|
9
|
+
---
|
|
2
10
|
|
|
3
11
|
<div align="center">
|
|
4
12
|
<img src="./logo.png" alt="Rune Scroller Logo" width="200" />
|
|
@@ -13,13 +21,28 @@
|
|
|
13
21
|
|
|
14
22
|
## ✨ Features
|
|
15
23
|
|
|
16
|
-
- **~
|
|
24
|
+
- **~3.4KB gzipped** (11.5KB uncompressed) - Minimal overhead
|
|
17
25
|
- **Zero dependencies** - Pure Svelte 5 + IntersectionObserver
|
|
18
26
|
- **14 animations** - Fade, Zoom, Flip, Slide, Bounce variants
|
|
19
|
-
- **TypeScript** -
|
|
27
|
+
- **Full TypeScript support** - Type definitions generated from JSDoc
|
|
20
28
|
- **SSR-ready** - SvelteKit compatible
|
|
21
29
|
- **GPU-accelerated** - Pure CSS transforms
|
|
22
30
|
- **Accessible** - Respects `prefers-reduced-motion`
|
|
31
|
+
- **v2.0.0 New** - `onVisible` callback, ResizeObserver support, animation validation, sentinel customization
|
|
32
|
+
- **✨ Latest** - `useIntersection` migrated to Svelte 5 `$effect` rune for better lifecycle management
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 📊 Performance & Quality
|
|
37
|
+
|
|
38
|
+
**Recent Optimization (2026-01-06):**
|
|
39
|
+
- ✅ **278/278 tests passing** (100%)
|
|
40
|
+
- ✅ **Bundle size:** 10.5KiB gzipped (stable, no regression)
|
|
41
|
+
- ✅ **Type safety:** 0 errors (JSDoc + TypeScript)
|
|
42
|
+
- ✅ **Memory leaks:** 0 detected
|
|
43
|
+
- ✅ **Svelte 5 aligned:** Full runes support
|
|
44
|
+
|
|
45
|
+
See [`MIGRATION_METRICS.md`](../MIGRATION_METRICS.md) for detailed performance benchmarks.
|
|
23
46
|
|
|
24
47
|
---
|
|
25
48
|
|
|
@@ -37,11 +60,37 @@ yarn add rune-scroller
|
|
|
37
60
|
|
|
38
61
|
## 🚀 Quick Start
|
|
39
62
|
|
|
63
|
+
### Step 1: Import CSS (required)
|
|
64
|
+
|
|
65
|
+
**⚠️ Important:** You must import the CSS file once in your app.
|
|
66
|
+
|
|
67
|
+
**Option A - In your root layout (recommended for SvelteKit):**
|
|
68
|
+
|
|
69
|
+
```svelte
|
|
70
|
+
<!-- src/routes/+layout.svelte -->
|
|
71
|
+
<script>
|
|
72
|
+
import 'rune-scroller/animations.css';
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<slot />
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Option B - In each component that uses animations:**
|
|
79
|
+
|
|
40
80
|
```svelte
|
|
41
81
|
<script>
|
|
42
82
|
import runeScroller from 'rune-scroller';
|
|
43
83
|
import 'rune-scroller/animations.css';
|
|
44
84
|
</script>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Step 2: Use the animations
|
|
88
|
+
|
|
89
|
+
```svelte
|
|
90
|
+
<script>
|
|
91
|
+
import runeScroller from 'rune-scroller';
|
|
92
|
+
// CSS already imported in layout or above
|
|
93
|
+
</script>
|
|
45
94
|
|
|
46
95
|
<!-- Simple animation -->
|
|
47
96
|
<div use:runeScroller={{ animation: 'fade-in' }}>
|
|
@@ -65,17 +114,17 @@ yarn add rune-scroller
|
|
|
65
114
|
|
|
66
115
|
### Fade (5)
|
|
67
116
|
- `fade-in` - Simple opacity fade
|
|
68
|
-
- `fade-in-up` - Fade + move up
|
|
69
|
-
- `fade-in-down` - Fade + move down
|
|
70
|
-
- `fade-in-left` - Fade + move from right
|
|
71
|
-
- `fade-in-right` - Fade + move from left
|
|
117
|
+
- `fade-in-up` - Fade + move up 300px
|
|
118
|
+
- `fade-in-down` - Fade + move down 300px
|
|
119
|
+
- `fade-in-left` - Fade + move from right 300px
|
|
120
|
+
- `fade-in-right` - Fade + move from left 300px
|
|
72
121
|
|
|
73
122
|
### Zoom (5)
|
|
74
|
-
- `zoom-in` - Scale from 0.
|
|
75
|
-
- `zoom-out` - Scale from
|
|
76
|
-
- `zoom-in-up` - Zoom + move up
|
|
77
|
-
- `zoom-in-left` - Zoom + move from right
|
|
78
|
-
- `zoom-in-right` - Zoom + move from left
|
|
123
|
+
- `zoom-in` - Scale from 0.3 to 1
|
|
124
|
+
- `zoom-out` - Scale from 2 to 1
|
|
125
|
+
- `zoom-in-up` - Zoom (0.5→1) + move up 300px
|
|
126
|
+
- `zoom-in-left` - Zoom (0.5→1) + move from right 300px
|
|
127
|
+
- `zoom-in-right` - Zoom (0.5→1) + move from left 300px
|
|
79
128
|
|
|
80
129
|
### Others (4)
|
|
81
130
|
- `flip` - 3D flip on Y-axis
|
|
@@ -90,12 +139,27 @@ yarn add rune-scroller
|
|
|
90
139
|
```typescript
|
|
91
140
|
interface RuneScrollerOptions {
|
|
92
141
|
animation?: AnimationType; // Animation name (default: 'fade-in')
|
|
93
|
-
duration?: number; // Duration in ms (default:
|
|
142
|
+
duration?: number; // Duration in ms (default: 800)
|
|
94
143
|
repeat?: boolean; // Repeat on scroll (default: false)
|
|
95
|
-
debug?: boolean; // Show sentinel
|
|
144
|
+
debug?: boolean; // Show sentinel as visible line (default: false)
|
|
145
|
+
offset?: number; // Sentinel offset in px (default: 0, negative = above)
|
|
146
|
+
onVisible?: (element: HTMLElement) => void; // Callback when animation triggers (v2.0.0+)
|
|
147
|
+
sentinelColor?: string; // Sentinel debug color, e.g. '#ff6b6b' (v2.0.0+)
|
|
148
|
+
sentinelId?: string; // Custom ID for sentinel identification (v2.0.0+)
|
|
96
149
|
}
|
|
97
150
|
```
|
|
98
151
|
|
|
152
|
+
### Option Details
|
|
153
|
+
|
|
154
|
+
- **`animation`** - Type of animation to play. Choose from 14 built-in animations listed above. Invalid animations automatically fallback to 'fade-in' with a console warning.
|
|
155
|
+
- **`duration`** - How long the animation lasts in milliseconds (default: 800ms).
|
|
156
|
+
- **`repeat`** - If `true`, animation plays every time sentinel enters viewport. If `false`, plays only once.
|
|
157
|
+
- **`debug`** - If `true`, displays the sentinel element as a visible line below your element. Useful for seeing exactly when animations trigger. Default color is cyan (#00e0ff), customize with `sentinelColor`.
|
|
158
|
+
- **`offset`** - Offset of the sentinel in pixels. Positive values move sentinel down (delays animation), negative values move it up (triggers earlier). Useful for large elements where you want animation to trigger before the entire element is visible.
|
|
159
|
+
- **`onVisible`** *(v2.0.0+)* - Callback function triggered when the animation becomes visible. Receives the animated element as parameter. Useful for analytics, lazy loading, or triggering custom effects.
|
|
160
|
+
- **`sentinelColor`** *(v2.0.0+)* - Customize the debug sentinel color (e.g., '#ff6b6b' for red). Only visible when `debug: true`. Useful for distinguishing multiple sentinels on the same page.
|
|
161
|
+
- **`sentinelId`** *(v2.0.0+)* - Set a custom ID for the sentinel element. If not provided, an auto-ID is generated (`sentinel-1`, `sentinel-2`, etc.). Useful for identifying sentinels in DevTools and tracking which element owns which sentinel.
|
|
162
|
+
|
|
99
163
|
### Examples
|
|
100
164
|
|
|
101
165
|
```svelte
|
|
@@ -114,9 +178,74 @@ interface RuneScrollerOptions {
|
|
|
114
178
|
Repeats every time you scroll
|
|
115
179
|
</div>
|
|
116
180
|
|
|
117
|
-
<!-- Debug mode
|
|
181
|
+
<!-- Debug mode - shows cyan line marking sentinel position -->
|
|
118
182
|
<div use:runeScroller={{ animation: 'fade-in', debug: true }}>
|
|
119
|
-
|
|
183
|
+
The cyan line below this shows when animation will trigger
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<!-- Multiple options -->
|
|
187
|
+
<div use:runeScroller={{
|
|
188
|
+
animation: 'fade-in-up',
|
|
189
|
+
duration: 1200,
|
|
190
|
+
repeat: true,
|
|
191
|
+
debug: true
|
|
192
|
+
}}>
|
|
193
|
+
Full featured example
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<!-- Large element - trigger animation earlier with negative offset -->
|
|
197
|
+
<div use:runeScroller={{
|
|
198
|
+
animation: 'fade-in-up',
|
|
199
|
+
offset: -200 // Trigger 200px before element bottom
|
|
200
|
+
}}>
|
|
201
|
+
Large content that needs early triggering
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<!-- Delay animation by moving sentinel down -->
|
|
205
|
+
<div use:runeScroller={{
|
|
206
|
+
animation: 'zoom-in',
|
|
207
|
+
offset: 300 // Trigger 300px after element bottom
|
|
208
|
+
}}>
|
|
209
|
+
Content with delayed animation
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<!-- v2.0.0: onVisible callback for analytics tracking -->
|
|
213
|
+
<div use:runeScroller={{
|
|
214
|
+
animation: 'fade-in-up',
|
|
215
|
+
onVisible: (el) => {
|
|
216
|
+
console.log('Animation visible!', el);
|
|
217
|
+
// Track analytics, load images, trigger API calls, etc.
|
|
218
|
+
window.gtag?.('event', 'animation_visible', { element: el.id });
|
|
219
|
+
}
|
|
220
|
+
}}>
|
|
221
|
+
Tracked animation
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<!-- v2.0.0: Custom sentinel color for debugging -->
|
|
225
|
+
<div use:runeScroller={{
|
|
226
|
+
animation: 'fade-in',
|
|
227
|
+
debug: true,
|
|
228
|
+
sentinelColor: '#ff6b6b' // Red instead of default cyan
|
|
229
|
+
}}>
|
|
230
|
+
Red debug sentinel
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<!-- v2.0.0: Custom sentinel ID for identification -->
|
|
234
|
+
<div use:runeScroller={{
|
|
235
|
+
animation: 'zoom-in',
|
|
236
|
+
sentinelId: 'hero-zoom',
|
|
237
|
+
debug: true
|
|
238
|
+
}}>
|
|
239
|
+
Identified sentinel (shows "hero-zoom" in debug mode)
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<!-- v2.0.0: Auto-ID (sentinel-1, sentinel-2, etc) -->
|
|
243
|
+
<div use:runeScroller={{
|
|
244
|
+
animation: 'fade-in-up',
|
|
245
|
+
debug: true
|
|
246
|
+
// sentinelId omitted → auto generates "sentinel-1", "sentinel-2", etc
|
|
247
|
+
}}>
|
|
248
|
+
Auto-identified sentinel
|
|
120
249
|
</div>
|
|
121
250
|
```
|
|
122
251
|
|
|
@@ -139,19 +268,19 @@ For advanced use cases, use `animate` for fine-grained IntersectionObserver cont
|
|
|
139
268
|
duration: 1000,
|
|
140
269
|
delay: 200,
|
|
141
270
|
threshold: 0.5,
|
|
142
|
-
offset: 20
|
|
143
|
-
once: true
|
|
271
|
+
offset: 20
|
|
144
272
|
}}>
|
|
145
273
|
Advanced control
|
|
146
274
|
</div>
|
|
147
275
|
```
|
|
148
276
|
|
|
149
277
|
**Options:**
|
|
150
|
-
- `threshold` - Intersection ratio to trigger (0-1)
|
|
278
|
+
- `threshold` - Intersection ratio to trigger (0-1, default: 0)
|
|
151
279
|
- `offset` - Viewport offset percentage (0-100)
|
|
152
280
|
- `rootMargin` - Custom IntersectionObserver margin
|
|
153
|
-
- `delay` - Animation delay in ms
|
|
154
|
-
|
|
281
|
+
- `delay` - Animation delay in ms (default: 0)
|
|
282
|
+
|
|
283
|
+
**Note:** `animate` triggers the animation **once** when the element enters the viewport (it's one-time by default, unlike `runeScroller` with `repeat: true`)
|
|
155
284
|
|
|
156
285
|
### Using Composables
|
|
157
286
|
|
|
@@ -184,24 +313,42 @@ Rune Scroller uses **sentinel-based triggering**:
|
|
|
184
313
|
3. This ensures precise timing regardless of element size
|
|
185
314
|
4. Uses native IntersectionObserver for performance
|
|
186
315
|
5. Pure CSS animations (GPU-accelerated)
|
|
316
|
+
6. *(v2.0.0)* Sentinel automatically repositions on element resize via ResizeObserver
|
|
187
317
|
|
|
188
318
|
**Why sentinels?**
|
|
189
319
|
- Accurate timing across all screen sizes
|
|
190
320
|
- No complex offset calculations
|
|
191
321
|
- Handles staggered animations naturally
|
|
322
|
+
- Sentinel stays fixed while element animates (no observer confusion with transforms)
|
|
323
|
+
|
|
324
|
+
**Automatic ResizeObserver** *(v2.0.0+)*
|
|
325
|
+
- Sentinel repositions automatically when element resizes
|
|
326
|
+
- Works with responsive layouts and dynamic content
|
|
327
|
+
- No configuration needed—it just works
|
|
192
328
|
|
|
193
329
|
---
|
|
194
330
|
|
|
195
331
|
## 🌐 SSR Compatibility
|
|
196
332
|
|
|
197
|
-
Works seamlessly with SvelteKit:
|
|
333
|
+
Works seamlessly with SvelteKit. Import CSS in your root layout:
|
|
198
334
|
|
|
199
335
|
```svelte
|
|
336
|
+
<!-- src/routes/+layout.svelte -->
|
|
200
337
|
<script>
|
|
201
|
-
import runeScroller from 'rune-scroller';
|
|
202
338
|
import 'rune-scroller/animations.css';
|
|
203
339
|
</script>
|
|
204
340
|
|
|
341
|
+
<slot />
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Then use animations anywhere in your app:
|
|
345
|
+
|
|
346
|
+
```svelte
|
|
347
|
+
<!-- src/routes/+page.svelte -->
|
|
348
|
+
<script>
|
|
349
|
+
import runeScroller from 'rune-scroller';
|
|
350
|
+
</script>
|
|
351
|
+
|
|
205
352
|
<!-- No special handling needed -->
|
|
206
353
|
<div use:runeScroller={{ animation: 'fade-in-up' }}>
|
|
207
354
|
Works in SvelteKit SSR!
|
|
@@ -233,6 +380,19 @@ Users who prefer reduced motion will see content without animations.
|
|
|
233
380
|
|
|
234
381
|
## 📚 API Reference
|
|
235
382
|
|
|
383
|
+
### Public API
|
|
384
|
+
|
|
385
|
+
Rune Scroller exports **2 action-based APIs** (no components):
|
|
386
|
+
|
|
387
|
+
1. **`runeScroller`** (default) - Recommended, sentinel-based, simple
|
|
388
|
+
2. **`animate`** (advanced) - Direct observation, fine-grained control
|
|
389
|
+
|
|
390
|
+
**Why actions instead of components?**
|
|
391
|
+
- Actions are lightweight directives
|
|
392
|
+
- No DOM wrapper overhead
|
|
393
|
+
- Better performance
|
|
394
|
+
- More flexible
|
|
395
|
+
|
|
236
396
|
### Main Export
|
|
237
397
|
|
|
238
398
|
```typescript
|
|
@@ -270,16 +430,23 @@ interface RuneScrollerOptions {
|
|
|
270
430
|
duration?: number;
|
|
271
431
|
repeat?: boolean;
|
|
272
432
|
debug?: boolean;
|
|
433
|
+
offset?: number;
|
|
434
|
+
onVisible?: (element: HTMLElement) => void; // v2.0.0+
|
|
435
|
+
sentinelColor?: string; // v2.0.0+
|
|
436
|
+
sentinelId?: string; // v2.0.0+
|
|
273
437
|
}
|
|
274
438
|
|
|
275
439
|
interface AnimateOptions {
|
|
276
440
|
animation?: AnimationType;
|
|
277
|
-
duration?: number;
|
|
441
|
+
duration?: number; // default: 2500
|
|
278
442
|
delay?: number;
|
|
279
443
|
threshold?: number;
|
|
280
444
|
rootMargin?: string;
|
|
281
445
|
offset?: number;
|
|
282
446
|
once?: boolean;
|
|
447
|
+
onVisible?: (element: HTMLElement) => void; // v2.0.0+
|
|
448
|
+
sentinelColor?: string; // v2.0.0+
|
|
449
|
+
sentinelId?: string; // v2.0.0+
|
|
283
450
|
}
|
|
284
451
|
```
|
|
285
452
|
|
|
@@ -333,7 +500,6 @@ interface AnimateOptions {
|
|
|
333
500
|
|
|
334
501
|
- **npm Package**: [rune-scroller](https://www.npmjs.com/package/rune-scroller)
|
|
335
502
|
- **GitHub**: [lelabdev/rune-scroller](https://github.com/lelabdev/rune-scroller)
|
|
336
|
-
- **Documentation**: [CLAUDE.md](./CLAUDE.md)
|
|
337
503
|
- **Changelog**: [CHANGELOG.md](./CHANGELOG.md)
|
|
338
504
|
|
|
339
505
|
---
|
|
@@ -350,10 +516,10 @@ Contributions welcome! Please open an issue or PR on GitHub.
|
|
|
350
516
|
|
|
351
517
|
```bash
|
|
352
518
|
# Development
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
519
|
+
bun install
|
|
520
|
+
bun run dev
|
|
521
|
+
bun test
|
|
522
|
+
bun run build
|
|
357
523
|
```
|
|
358
524
|
|
|
359
525
|
---
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export class IntersectionObserverMock {
|
|
2
|
+
constructor(callback: any, options?: {});
|
|
3
|
+
id: number;
|
|
4
|
+
callback: any;
|
|
5
|
+
options: {};
|
|
6
|
+
observedElements: Set<any>;
|
|
7
|
+
isConnected: boolean;
|
|
8
|
+
observe(element: any): void;
|
|
9
|
+
unobserve(element: any): void;
|
|
10
|
+
disconnect(): void;
|
|
11
|
+
trigger(element: any, isIntersecting: any): void;
|
|
12
|
+
}
|
|
13
|
+
export namespace mockIntersectionObserver {
|
|
14
|
+
let observers: Map<any, any>;
|
|
15
|
+
let lastObserver: null;
|
|
16
|
+
function install(): void;
|
|
17
|
+
function uninstall(): void;
|
|
18
|
+
function reset(): void;
|
|
19
|
+
function getLastCreated(): any;
|
|
20
|
+
function getAll(): any[];
|
|
21
|
+
function trigger(element: any, isIntersecting: any): void;
|
|
22
|
+
function triggerAll(isIntersecting: any): void;
|
|
23
|
+
function getObserverFor(element: any): any;
|
|
24
|
+
}
|
|
25
|
+
export default IntersectionObserverMock;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock IntersectionObserver for testing
|
|
3
|
+
* Allows manual triggering of intersection events for animation testing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const observers = new Map();
|
|
7
|
+
let observerId = 0;
|
|
8
|
+
|
|
9
|
+
export class IntersectionObserverMock {
|
|
10
|
+
constructor(callback, options = {}) {
|
|
11
|
+
this.id = observerId++;
|
|
12
|
+
this.callback = callback;
|
|
13
|
+
this.options = options;
|
|
14
|
+
this.observedElements = new Set();
|
|
15
|
+
this.isConnected = true;
|
|
16
|
+
|
|
17
|
+
observers.set(this.id, this);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
observe(element) {
|
|
21
|
+
if (!this.isConnected) return;
|
|
22
|
+
this.observedElements.add(element);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
unobserve(element) {
|
|
26
|
+
this.observedElements.delete(element);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
disconnect() {
|
|
30
|
+
this.isConnected = false;
|
|
31
|
+
this.observedElements.clear();
|
|
32
|
+
observers.delete(this.id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Testing API: Manually trigger intersection
|
|
36
|
+
trigger(element, isIntersecting) {
|
|
37
|
+
if (!this.observedElements.has(element)) return;
|
|
38
|
+
|
|
39
|
+
const entry = {
|
|
40
|
+
target: element,
|
|
41
|
+
isIntersecting,
|
|
42
|
+
intersectionRatio: isIntersecting ? 1 : 0,
|
|
43
|
+
boundingClientRect: element.getBoundingClientRect?.() || {},
|
|
44
|
+
intersectionRect: isIntersecting ? { top: 0, height: 100 } : {},
|
|
45
|
+
rootBounds: { top: 0, height: window.innerHeight || 768 },
|
|
46
|
+
time: Date.now(),
|
|
47
|
+
toJSON: () => ({})
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
this.callback([entry], this);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Global API
|
|
55
|
+
export const mockIntersectionObserver = {
|
|
56
|
+
observers: new Map(),
|
|
57
|
+
lastObserver: null,
|
|
58
|
+
|
|
59
|
+
install() {
|
|
60
|
+
global.IntersectionObserver = IntersectionObserverMock;
|
|
61
|
+
this.reset();
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
uninstall() {
|
|
65
|
+
if (typeof global !== 'undefined') {
|
|
66
|
+
delete global.IntersectionObserver;
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
reset() {
|
|
71
|
+
observers.forEach((obs) => obs.disconnect());
|
|
72
|
+
observers.clear();
|
|
73
|
+
observerId = 0;
|
|
74
|
+
this.lastObserver = null;
|
|
75
|
+
this.observers.clear();
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
// Get the last created observer
|
|
79
|
+
getLastCreated() {
|
|
80
|
+
if (observers.size === 0) return null;
|
|
81
|
+
const ids = Array.from(observers.keys());
|
|
82
|
+
return observers.get(ids[ids.length - 1]);
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// Get all observers
|
|
86
|
+
getAll() {
|
|
87
|
+
return Array.from(observers.values());
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
// Trigger intersection on an element
|
|
91
|
+
trigger(element, isIntersecting) {
|
|
92
|
+
const matchingObservers = Array.from(observers.values()).filter((obs) =>
|
|
93
|
+
obs.observedElements.has(element)
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
matchingObservers.forEach((obs) => obs.trigger(element, isIntersecting));
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// Trigger intersection on all observed elements
|
|
100
|
+
triggerAll(isIntersecting) {
|
|
101
|
+
observers.forEach((obs) => {
|
|
102
|
+
obs.observedElements.forEach((element) => {
|
|
103
|
+
obs.trigger(element, isIntersecting);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// Get observer for specific element
|
|
109
|
+
getObserverFor(element) {
|
|
110
|
+
return Array.from(observers.values()).find((obs) =>
|
|
111
|
+
obs.observedElements.has(element)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export default IntersectionObserverMock;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export namespace mockSvelteRunes {
|
|
2
|
+
export { effects };
|
|
3
|
+
export { effectCleanups };
|
|
4
|
+
export function install(): void;
|
|
5
|
+
export function uninstall(): void;
|
|
6
|
+
export function reset(): void;
|
|
7
|
+
export function getEffects(): any[];
|
|
8
|
+
export function runCleanups(): void;
|
|
9
|
+
export function createState(initialValue: any): ReactiveValue;
|
|
10
|
+
}
|
|
11
|
+
export default mockSvelteRunes;
|
|
12
|
+
declare let effects: any[];
|
|
13
|
+
declare let effectCleanups: any[];
|
|
14
|
+
/**
|
|
15
|
+
* Mock Svelte 5 Runes for testing useIntersection composables
|
|
16
|
+
* Allows testing reactive state and effects without Svelte runtime
|
|
17
|
+
*/
|
|
18
|
+
declare class ReactiveValue {
|
|
19
|
+
constructor(initialValue: any);
|
|
20
|
+
_value: any;
|
|
21
|
+
_subscribers: Set<any>;
|
|
22
|
+
set value(newValue: any);
|
|
23
|
+
get value(): any;
|
|
24
|
+
subscribe(fn: any): () => boolean;
|
|
25
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock Svelte 5 Runes for testing useIntersection composables
|
|
3
|
+
* Allows testing reactive state and effects without Svelte runtime
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class ReactiveValue {
|
|
7
|
+
constructor(initialValue) {
|
|
8
|
+
this._value = initialValue;
|
|
9
|
+
this._subscribers = new Set();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
get value() {
|
|
13
|
+
return this._value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
set value(newValue) {
|
|
17
|
+
if (this._value !== newValue) {
|
|
18
|
+
this._value = newValue;
|
|
19
|
+
this._subscribers.forEach((fn) => fn(newValue));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
subscribe(fn) {
|
|
24
|
+
this._subscribers.add(fn);
|
|
25
|
+
return () => this._subscribers.delete(fn);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Global state management for mocks
|
|
30
|
+
let effects = [];
|
|
31
|
+
let effectCleanups = [];
|
|
32
|
+
|
|
33
|
+
export const mockSvelteRunes = {
|
|
34
|
+
effects,
|
|
35
|
+
effectCleanups,
|
|
36
|
+
|
|
37
|
+
install() {
|
|
38
|
+
// Mock $state
|
|
39
|
+
global.$state = (initialValue) => {
|
|
40
|
+
return new ReactiveValue(initialValue);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Mock $effect
|
|
44
|
+
global.$effect = (fn) => {
|
|
45
|
+
const cleanup = fn();
|
|
46
|
+
effects.push(fn);
|
|
47
|
+
effectCleanups.push(cleanup);
|
|
48
|
+
return cleanup;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Mock $effect.pre
|
|
52
|
+
global.$effect.pre = (fn) => {
|
|
53
|
+
const cleanup = fn();
|
|
54
|
+
effects.push(fn);
|
|
55
|
+
effectCleanups.push(cleanup);
|
|
56
|
+
return cleanup;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Mock $derived
|
|
60
|
+
global.$derived = (expression) => {
|
|
61
|
+
return expression;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Mock $derived.by
|
|
65
|
+
global.$derived.by = (fn) => {
|
|
66
|
+
return fn();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
this.reset();
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
uninstall() {
|
|
73
|
+
if (typeof global !== 'undefined') {
|
|
74
|
+
delete global.$state;
|
|
75
|
+
delete global.$effect;
|
|
76
|
+
delete global.$derived;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
reset() {
|
|
81
|
+
// Run all cleanups
|
|
82
|
+
effectCleanups.forEach((cleanup) => {
|
|
83
|
+
if (typeof cleanup === 'function') {
|
|
84
|
+
try {
|
|
85
|
+
cleanup();
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// Silently ignore cleanup errors
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
effects.length = 0;
|
|
93
|
+
effectCleanups.length = 0;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
getEffects() {
|
|
97
|
+
return effects;
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
runCleanups() {
|
|
101
|
+
effectCleanups.forEach((cleanup) => {
|
|
102
|
+
if (typeof cleanup === 'function') {
|
|
103
|
+
try {
|
|
104
|
+
cleanup();
|
|
105
|
+
} catch (e) {
|
|
106
|
+
// Silently ignore cleanup errors
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
createState(initialValue) {
|
|
113
|
+
return new ReactiveValue(initialValue);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export default mockSvelteRunes;
|