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.
Files changed (51) hide show
  1. package/README.md +195 -29
  2. package/dist/__mocks__/IntersectionObserver.d.ts +25 -0
  3. package/dist/__mocks__/IntersectionObserver.js +116 -0
  4. package/dist/__mocks__/svelte-runes.d.ts +25 -0
  5. package/dist/__mocks__/svelte-runes.js +117 -0
  6. package/dist/__test-helpers__/dom.d.ts +118 -0
  7. package/dist/__test-helpers__/dom.js +305 -0
  8. package/dist/animate.d.ts +4 -0
  9. package/dist/animate.js +152 -0
  10. package/dist/animate.test.js +370 -0
  11. package/dist/animations.comprehensive.test.d.ts +1 -0
  12. package/dist/animations.comprehensive.test.js +432 -0
  13. package/dist/animations.css +21 -12
  14. package/dist/animations.d.ts +12 -9
  15. package/dist/animations.js +31 -6
  16. package/dist/animations.test.js +23 -41
  17. package/dist/dom-utils.d.ts +40 -0
  18. package/dist/dom-utils.js +111 -0
  19. package/dist/dom-utils.test.d.ts +1 -0
  20. package/dist/dom-utils.test.js +220 -0
  21. package/dist/index.d.ts +6 -6
  22. package/dist/index.js +17 -4
  23. package/dist/observer-utils.d.ts +40 -0
  24. package/dist/observer-utils.js +50 -0
  25. package/dist/robustness.test.d.ts +1 -0
  26. package/dist/robustness.test.js +317 -0
  27. package/dist/runeScroller.d.ts +25 -0
  28. package/dist/runeScroller.integration.test.d.ts +1 -0
  29. package/dist/runeScroller.integration.test.js +419 -0
  30. package/dist/runeScroller.js +183 -0
  31. package/dist/runeScroller.test.d.ts +1 -0
  32. package/dist/runeScroller.test.js +375 -0
  33. package/dist/types.d.ts +104 -24
  34. package/dist/types.js +58 -0
  35. package/dist/useIntersection.svelte.d.ts +7 -12
  36. package/dist/useIntersection.svelte.js +75 -54
  37. package/dist/useIntersection.test.d.ts +1 -0
  38. package/dist/useIntersection.test.js +98 -0
  39. package/package.json +19 -18
  40. package/dist/BaseAnimated.svelte +0 -48
  41. package/dist/BaseAnimated.svelte.d.ts +0 -16
  42. package/dist/RuneScroller.svelte +0 -37
  43. package/dist/RuneScroller.svelte.d.ts +0 -16
  44. package/dist/animate.svelte.d.ts +0 -14
  45. package/dist/animate.svelte.js +0 -79
  46. package/dist/dom-utils.svelte.d.ts +0 -22
  47. package/dist/dom-utils.svelte.js +0 -46
  48. package/dist/runeScroller.svelte.d.ts +0 -24
  49. package/dist/runeScroller.svelte.js +0 -79
  50. package/dist/scroll-animate.test.js +0 -57
  51. /package/dist/{scroll-animate.test.d.ts → animate.test.d.ts} +0 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * DOM Test Helpers
3
+ * Utilities for creating test elements, verifying animations, and measuring performance
4
+ */
5
+ /**
6
+ * Create a test element with default styles
7
+ * @param {Object} options - Configuration options
8
+ * @param {string} options.width - Width (default: '100px')
9
+ * @param {string} options.height - Height (default: '100px')
10
+ * @param {string} options.id - Element ID
11
+ * @param {string} options.className - Additional CSS classes
12
+ * @param {Document} options.document - Document to use (for testing)
13
+ * @returns {HTMLElement}
14
+ */
15
+ export function createTestElement(options?: {
16
+ width: string;
17
+ height: string;
18
+ id: string;
19
+ className: string;
20
+ document: Document;
21
+ }): HTMLElement;
22
+ /**
23
+ * Create multiple test elements
24
+ * @param {number} count - Number of elements to create
25
+ * @param {Object} options - Element options (passed to createTestElement)
26
+ * @returns {HTMLElement[]}
27
+ */
28
+ export function createTestElements(count: number, options?: Object): HTMLElement[];
29
+ /**
30
+ * Get sentinel element from animated element's wrapper
31
+ * @param {HTMLElement} element - The animated element
32
+ * @returns {HTMLElement|null}
33
+ */
34
+ export function getSentinel(element: HTMLElement): HTMLElement | null;
35
+ /**
36
+ * Check if element has animation applied
37
+ * @param {HTMLElement} element - Element to check
38
+ * @returns {boolean}
39
+ */
40
+ export function hasAnimation(element: HTMLElement): boolean;
41
+ /**
42
+ * Check if animation is active (is-visible)
43
+ * @param {HTMLElement} element - Element to check
44
+ * @returns {boolean}
45
+ */
46
+ export function isAnimating(element: HTMLElement): boolean;
47
+ /**
48
+ * Get animation type from element
49
+ * @param {HTMLElement} element - Element to check
50
+ * @returns {string|null}
51
+ */
52
+ export function getAnimationType(element: HTMLElement): string | null;
53
+ /**
54
+ * Get CSS variable value from element
55
+ * @param {HTMLElement} element - Element to check
56
+ * @param {string} variableName - Variable name (with or without --)
57
+ * @returns {string|null}
58
+ */
59
+ export function getCSSVariable(element: HTMLElement, variableName: string): string | null;
60
+ /**
61
+ * Create a mock ResizeObserver
62
+ * @param {Function} callback - Callback function
63
+ * @returns {Object}
64
+ */
65
+ export function createMockResizeObserver(callback: Function): Object;
66
+ /**
67
+ * Setup a test environment with mocked DOM APIs
68
+ * @param {Object} options - Configuration
69
+ * @returns {Object} - Setup context with window, document, cleanup
70
+ */
71
+ export function setupTestDOM(options?: Object): Object;
72
+ /**
73
+ * Measure animation performance
74
+ * @param {Function} fn - Function to measure
75
+ * @returns {Object} - Performance metrics
76
+ */
77
+ export function measurePerformance(fn: Function): Object;
78
+ /**
79
+ * Create spacer element for pagination
80
+ * @param {number} height - Height in pixels
81
+ * @param {Document} doc - Document reference
82
+ * @returns {HTMLElement}
83
+ */
84
+ export function createSpacer(height?: number, doc?: Document): HTMLElement;
85
+ /**
86
+ * Add element to DOM
87
+ * @param {HTMLElement} element - Element to add
88
+ * @param {HTMLElement} parent - Parent element (default: body)
89
+ */
90
+ export function appendElement(element: HTMLElement, parent?: HTMLElement): void;
91
+ /**
92
+ * Remove element from DOM
93
+ * @param {HTMLElement} element - Element to remove
94
+ */
95
+ export function removeElement(element: HTMLElement): void;
96
+ /**
97
+ * Clone element for comparison
98
+ * @param {HTMLElement} element - Element to clone
99
+ * @returns {Object} - Element state snapshot
100
+ */
101
+ export function snapshotElement(element: HTMLElement): Object;
102
+ declare namespace _default {
103
+ export { createTestElement };
104
+ export { createTestElements };
105
+ export { getSentinel };
106
+ export { hasAnimation };
107
+ export { isAnimating };
108
+ export { getAnimationType };
109
+ export { getCSSVariable };
110
+ export { createMockResizeObserver };
111
+ export { setupTestDOM };
112
+ export { measurePerformance };
113
+ export { createSpacer };
114
+ export { appendElement };
115
+ export { removeElement };
116
+ export { snapshotElement };
117
+ }
118
+ export default _default;
@@ -0,0 +1,305 @@
1
+ /**
2
+ * DOM Test Helpers
3
+ * Utilities for creating test elements, verifying animations, and measuring performance
4
+ */
5
+
6
+ /**
7
+ * Create a test element with default styles
8
+ * @param {Object} options - Configuration options
9
+ * @param {string} options.width - Width (default: '100px')
10
+ * @param {string} options.height - Height (default: '100px')
11
+ * @param {string} options.id - Element ID
12
+ * @param {string} options.className - Additional CSS classes
13
+ * @param {Document} options.document - Document to use (for testing)
14
+ * @returns {HTMLElement}
15
+ */
16
+ export function createTestElement(options = {}) {
17
+ const {
18
+ width = '100px',
19
+ height = '100px',
20
+ id = null,
21
+ className = '',
22
+ document: doc = typeof document !== 'undefined' ? document : global.document
23
+ } = options;
24
+
25
+ const element = doc.createElement('div');
26
+ element.style.cssText = `
27
+ width: ${width};
28
+ height: ${height};
29
+ background: #ccc;
30
+ position: relative;
31
+ `;
32
+
33
+ if (id) element.id = id;
34
+ if (className) element.className = className;
35
+
36
+ element.textContent = `Test ${id || 'Element'}`;
37
+
38
+ // Mock getBoundingClientRect
39
+ if (!element.getBoundingClientRect) {
40
+ element.getBoundingClientRect = () => ({
41
+ width: parseInt(width),
42
+ height: parseInt(height),
43
+ top: 0,
44
+ left: 0,
45
+ bottom: parseInt(height),
46
+ right: parseInt(width),
47
+ x: 0,
48
+ y: 0,
49
+ toJSON: () => ({})
50
+ });
51
+ }
52
+
53
+ // Mock offsetHeight
54
+ Object.defineProperty(element, 'offsetHeight', {
55
+ configurable: true,
56
+ value: parseInt(height)
57
+ });
58
+
59
+ return element;
60
+ }
61
+
62
+ /**
63
+ * Create multiple test elements
64
+ * @param {number} count - Number of elements to create
65
+ * @param {Object} options - Element options (passed to createTestElement)
66
+ * @returns {HTMLElement[]}
67
+ */
68
+ export function createTestElements(count, options = {}) {
69
+ const elements = [];
70
+ for (let i = 0; i < count; i++) {
71
+ elements.push(
72
+ createTestElement({
73
+ ...options,
74
+ id: options.id ? `${options.id}-${i}` : `element-${i}`
75
+ })
76
+ );
77
+ }
78
+ return elements;
79
+ }
80
+
81
+ /**
82
+ * Get sentinel element from animated element's wrapper
83
+ * @param {HTMLElement} element - The animated element
84
+ * @returns {HTMLElement|null}
85
+ */
86
+ export function getSentinel(element) {
87
+ const wrapper = element.parentElement;
88
+ if (!wrapper) return null;
89
+
90
+ // Sentinel is typically the last child or has data-sentinel-id attribute
91
+ const sentinel = wrapper.querySelector('[data-sentinel-id]');
92
+ if (sentinel) return sentinel;
93
+
94
+ // Fallback: find child that is not the element
95
+ for (let i = 0; i < wrapper.children.length; i++) {
96
+ if (wrapper.children[i] !== element) {
97
+ return wrapper.children[i];
98
+ }
99
+ }
100
+
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * Check if element has animation applied
106
+ * @param {HTMLElement} element - Element to check
107
+ * @returns {boolean}
108
+ */
109
+ export function hasAnimation(element) {
110
+ return (
111
+ element.classList.contains('scroll-animate') &&
112
+ element.hasAttribute('data-animation')
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Check if animation is active (is-visible)
118
+ * @param {HTMLElement} element - Element to check
119
+ * @returns {boolean}
120
+ */
121
+ export function isAnimating(element) {
122
+ return element.classList.contains('is-visible');
123
+ }
124
+
125
+ /**
126
+ * Get animation type from element
127
+ * @param {HTMLElement} element - Element to check
128
+ * @returns {string|null}
129
+ */
130
+ export function getAnimationType(element) {
131
+ return element.getAttribute('data-animation');
132
+ }
133
+
134
+ /**
135
+ * Get CSS variable value from element
136
+ * @param {HTMLElement} element - Element to check
137
+ * @param {string} variableName - Variable name (with or without --)
138
+ * @returns {string|null}
139
+ */
140
+ export function getCSSVariable(element, variableName) {
141
+ const name = variableName.startsWith('--') ? variableName : `--${variableName}`;
142
+ return element.style.getPropertyValue(name);
143
+ }
144
+
145
+ /**
146
+ * Create a mock ResizeObserver
147
+ * @param {Function} callback - Callback function
148
+ * @returns {Object}
149
+ */
150
+ export function createMockResizeObserver(callback) {
151
+ const observedElements = new Set();
152
+
153
+ return {
154
+ observe(element) {
155
+ observedElements.add(element);
156
+ },
157
+
158
+ unobserve(element) {
159
+ observedElements.delete(element);
160
+ },
161
+
162
+ disconnect() {
163
+ observedElements.clear();
164
+ },
165
+
166
+ // Testing API: trigger resize
167
+ trigger(element) {
168
+ if (observedElements.has(element)) {
169
+ callback([
170
+ {
171
+ target: element,
172
+ contentRect: element.getBoundingClientRect()
173
+ }
174
+ ]);
175
+ }
176
+ },
177
+
178
+ triggerAll() {
179
+ observedElements.forEach((element) => {
180
+ this.trigger(element);
181
+ });
182
+ }
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Setup a test environment with mocked DOM APIs
188
+ * @param {Object} options - Configuration
189
+ * @returns {Object} - Setup context with window, document, cleanup
190
+ */
191
+ export function setupTestDOM(options = {}) {
192
+ const { width = 1024, height = 768 } = options;
193
+
194
+ // Already have a global document in test environment (happy-dom)
195
+ const doc = typeof document !== 'undefined' ? document : global.document;
196
+ const win = typeof window !== 'undefined' ? window : global.window;
197
+
198
+ if (doc && doc.body) {
199
+ doc.body.style.cssText = `width: ${width}px; height: ${height}px; margin: 0; padding: 0;`;
200
+ }
201
+
202
+ return {
203
+ window: win,
204
+ document: doc,
205
+ body: doc?.body,
206
+ cleanup() {
207
+ // Clean up any test elements
208
+ if (doc && doc.body) {
209
+ doc.body.innerHTML = '';
210
+ }
211
+ }
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Measure animation performance
217
+ * @param {Function} fn - Function to measure
218
+ * @returns {Object} - Performance metrics
219
+ */
220
+ export function measurePerformance(fn) {
221
+ const start = Date.now();
222
+ const startMemory = process.memoryUsage().heapUsed;
223
+
224
+ const result = fn();
225
+
226
+ const end = Date.now();
227
+ const endMemory = process.memoryUsage().heapUsed;
228
+
229
+ return {
230
+ duration: end - start,
231
+ memory: endMemory - startMemory,
232
+ result
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Create spacer element for pagination
238
+ * @param {number} height - Height in pixels
239
+ * @param {Document} doc - Document reference
240
+ * @returns {HTMLElement}
241
+ */
242
+ export function createSpacer(height = 500, doc = global.document) {
243
+ const spacer = doc.createElement('div');
244
+ spacer.style.cssText = `height: ${height}px; background: #f0f0f0;`;
245
+ spacer.textContent = `Spacer (${height}px)`;
246
+ return spacer;
247
+ }
248
+
249
+ /**
250
+ * Add element to DOM
251
+ * @param {HTMLElement} element - Element to add
252
+ * @param {HTMLElement} parent - Parent element (default: body)
253
+ */
254
+ export function appendElement(element, parent = global.document?.body) {
255
+ if (parent) {
256
+ parent.appendChild(element);
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Remove element from DOM
262
+ * @param {HTMLElement} element - Element to remove
263
+ */
264
+ export function removeElement(element) {
265
+ if (element?.parentElement) {
266
+ element.remove();
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Clone element for comparison
272
+ * @param {HTMLElement} element - Element to clone
273
+ * @returns {Object} - Element state snapshot
274
+ */
275
+ export function snapshotElement(element) {
276
+ return {
277
+ classList: Array.from(element.classList),
278
+ attributes: {
279
+ 'data-animation': element.getAttribute('data-animation'),
280
+ 'data-sentinel-id': element.getAttribute('data-sentinel-id')
281
+ },
282
+ styles: {
283
+ '--duration': element.style.getPropertyValue('--duration'),
284
+ '--delay': element.style.getPropertyValue('--delay')
285
+ },
286
+ isVisible: element.classList.contains('is-visible')
287
+ };
288
+ }
289
+
290
+ export default {
291
+ createTestElement,
292
+ createTestElements,
293
+ getSentinel,
294
+ hasAnimation,
295
+ isAnimating,
296
+ getAnimationType,
297
+ getCSSVariable,
298
+ createMockResizeObserver,
299
+ setupTestDOM,
300
+ measurePerformance,
301
+ createSpacer,
302
+ appendElement,
303
+ removeElement,
304
+ snapshotElement
305
+ };
@@ -0,0 +1,4 @@
1
+ export function animate(node: HTMLElement, options?: import("./types.js").AnimateOptions): {
2
+ update: (newOptions: import("./types.js").AnimateOptions) => void;
3
+ destroy: () => void;
4
+ };
@@ -0,0 +1,152 @@
1
+ import { calculateRootMargin, ANIMATION_TYPES } from './animations.js';
2
+ import { setCSSVariables, setupAnimationElement, checkAndWarnIfCSSNotLoaded } from './dom-utils.js';
3
+ import { createManagedObserver, disconnectObserver } from './observer-utils.js';
4
+
5
+ /**
6
+ * Svelte action for scroll animations
7
+ * Triggers animation once when element enters viewport
8
+ *
9
+ * @param {HTMLElement} node - The element to animate
10
+ * @param {import('./types.js').AnimateOptions} [options={}] - Animation configuration
11
+ * @returns {{ update: (newOptions: import('./types.js').AnimateOptions) => void, destroy: () => void }}
12
+ *
13
+ * @example
14
+ * ```svelte
15
+ * <div use:animate={{ animation: 'fade-up', duration: 1000 }}>
16
+ * Content
17
+ * </div>
18
+ * ```
19
+ */
20
+ export const animate = (node, options = {}) => {
21
+ // SSR Guard: Return no-op action when running on server
22
+ if (typeof window === 'undefined') {
23
+ return {
24
+ update: () => {},
25
+ destroy: () => {}
26
+ };
27
+ }
28
+
29
+ // Warn if CSS is not loaded (first time only)
30
+ if (typeof document !== 'undefined') {
31
+ checkAndWarnIfCSSNotLoaded();
32
+ }
33
+
34
+ let {
35
+ animation = 'fade-in',
36
+ duration = 2500,
37
+ delay = 0,
38
+ offset,
39
+ threshold = 0,
40
+ rootMargin,
41
+ onVisible
42
+ } = options;
43
+
44
+ // Validate animation type
45
+ if (animation && !ANIMATION_TYPES.includes(animation)) {
46
+ console.warn(
47
+ `[rune-scroller] Invalid animation "${animation}". Using "fade-in" instead. ` +
48
+ `Valid options: ${ANIMATION_TYPES.join(', ')}`
49
+ );
50
+ animation = 'fade-in';
51
+ }
52
+
53
+ // Calculate rootMargin from offset (0-100%)
54
+ let finalRootMargin = calculateRootMargin(offset, rootMargin);
55
+
56
+ // Setup animation with utilities
57
+ setupAnimationElement(node, animation);
58
+ setCSSVariables(node, duration, delay);
59
+
60
+ // Track if animation has been triggered
61
+ let animated = false;
62
+ const state = { isConnected: true };
63
+
64
+ // Create IntersectionObserver for one-time animation
65
+ const { observer } = createManagedObserver(
66
+ node,
67
+ (entries) => {
68
+ entries.forEach((entry) => {
69
+ // Trigger animation once when element enters viewport
70
+ if (entry.isIntersecting && !animated) {
71
+ node.classList.add('is-visible');
72
+ // Call onVisible callback if provided
73
+ onVisible?.(node);
74
+ animated = true;
75
+ // Stop observing after animation triggers
76
+ disconnectObserver(observer, state);
77
+ }
78
+ });
79
+ },
80
+ {
81
+ threshold,
82
+ rootMargin: finalRootMargin
83
+ }
84
+ );
85
+
86
+ return {
87
+ update(newOptions) {
88
+ const {
89
+ duration: newDuration,
90
+ delay: newDelay,
91
+ animation: newAnimation,
92
+ offset: newOffset,
93
+ threshold: newThreshold,
94
+ rootMargin: newRootMargin
95
+ } = newOptions;
96
+
97
+ // Update CSS properties
98
+ if (newDuration !== undefined) {
99
+ duration = newDuration;
100
+ setCSSVariables(node, duration, newDelay ?? delay);
101
+ }
102
+ if (newDelay !== undefined && newDelay !== delay) {
103
+ delay = newDelay;
104
+ setCSSVariables(node, duration, delay);
105
+ }
106
+ if (newAnimation && newAnimation !== animation) {
107
+ // Validate animation type
108
+ if (!ANIMATION_TYPES.includes(newAnimation)) {
109
+ console.warn(
110
+ `[rune-scroller] Invalid animation "${newAnimation}". Keeping "${animation}". ` +
111
+ `Valid options: ${ANIMATION_TYPES.join(', ')}`
112
+ );
113
+ } else {
114
+ animation = newAnimation;
115
+ node.setAttribute('data-animation', newAnimation);
116
+ }
117
+ }
118
+
119
+ // Recreate observer if threshold or rootMargin changed
120
+ if (newThreshold !== undefined || newOffset !== undefined || newRootMargin !== undefined) {
121
+ disconnectObserver(observer, state);
122
+ threshold = newThreshold ?? threshold;
123
+ offset = newOffset ?? offset;
124
+ rootMargin = newRootMargin ?? rootMargin;
125
+ finalRootMargin = calculateRootMargin(offset, rootMargin);
126
+
127
+ if (!animated) {
128
+ const newObserver = new IntersectionObserver(
129
+ (entries) => {
130
+ entries.forEach((entry) => {
131
+ if (entry.isIntersecting && !animated) {
132
+ node.classList.add('is-visible');
133
+ // Call onVisible callback if provided
134
+ onVisible?.(node);
135
+ animated = true;
136
+ disconnectObserver(newObserver, state);
137
+ }
138
+ });
139
+ },
140
+ { threshold, rootMargin: finalRootMargin }
141
+ );
142
+ newObserver.observe(node);
143
+ state.isConnected = true;
144
+ }
145
+ }
146
+ },
147
+
148
+ destroy() {
149
+ disconnectObserver(observer, state);
150
+ }
151
+ };
152
+ };