react-sway 0.1.1 → 0.2.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.
@@ -0,0 +1,508 @@
1
+ /**
2
+ * Behavioral and regression tests for ReactSway.
3
+ */
4
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ import { ReactSway } from '../index';
8
+
9
+ const mockDisconnect = vi.fn();
10
+ const mockObserve = vi.fn();
11
+ const mockUnobserve = vi.fn();
12
+
13
+ let mockResizeCallback: (() => void) | null = null;
14
+ const mockResizeDisconnect = vi.fn();
15
+ const mockResizeObserve = vi.fn();
16
+
17
+ beforeEach(() => {
18
+ const MockIntersectionObserver = vi.fn(function (this: IntersectionObserver) {
19
+ this.disconnect = mockDisconnect;
20
+ this.observe = mockObserve;
21
+ this.root = null;
22
+ this.rootMargin = '';
23
+ this.takeRecords = vi.fn(() => []);
24
+ this.thresholds = [];
25
+ this.unobserve = mockUnobserve;
26
+ });
27
+ vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
28
+
29
+ const MockResizeObserver = vi.fn(function (this: ResizeObserver, callback: ResizeObserverCallback) {
30
+ mockResizeCallback = callback as unknown as () => void;
31
+ this.disconnect = mockResizeDisconnect;
32
+ this.observe = mockResizeObserve;
33
+ this.unobserve = vi.fn();
34
+ });
35
+ vi.stubGlobal('ResizeObserver', MockResizeObserver);
36
+ });
37
+
38
+ afterEach(() => {
39
+ cleanup();
40
+ mockResizeCallback = null;
41
+ vi.restoreAllMocks();
42
+ });
43
+
44
+ describe('ReactSway', () => {
45
+ describe('rendering', () => {
46
+ it('renders children content with duplicates', () => {
47
+ render(
48
+ <ReactSway>
49
+ <div data-testid="child">Hello</div>
50
+ </ReactSway>
51
+ );
52
+
53
+ const children = screen.getAllByTestId('child');
54
+ expect(children).toHaveLength(3);
55
+ });
56
+
57
+ it('renders container with correct class name', () => {
58
+ const { container } = render(
59
+ <ReactSway>
60
+ <div>Content</div>
61
+ </ReactSway>
62
+ );
63
+
64
+ const swayContainer = container.querySelector('.react-sway-container');
65
+ expect(swayContainer).toBeInTheDocument();
66
+ });
67
+
68
+ it('renders duplicate groups with accessibility attributes', () => {
69
+ const { container } = render(
70
+ <ReactSway>
71
+ <div>Content</div>
72
+ </ReactSway>
73
+ );
74
+
75
+ const duplicates = container.querySelectorAll('[data-duplicate="true"]');
76
+ expect(duplicates).toHaveLength(2);
77
+
78
+ duplicates.forEach((duplicate) => {
79
+ expect(duplicate.getAttribute('aria-hidden')).toBe('true');
80
+ expect(duplicate.getAttribute('role')).toBe('presentation');
81
+ expect(duplicate.tagName.toLowerCase()).toBe('aside');
82
+ });
83
+ });
84
+
85
+ it('renders original content group as div', () => {
86
+ const { container } = render(
87
+ <ReactSway>
88
+ <div>Content</div>
89
+ </ReactSway>
90
+ );
91
+
92
+ const original = container.querySelector('.content-group.original');
93
+ expect(original).toBeInTheDocument();
94
+ expect(original?.tagName.toLowerCase()).toBe('div');
95
+ });
96
+
97
+ it('applies translate3d transform', () => {
98
+ const { container } = render(
99
+ <ReactSway>
100
+ <div>Content</div>
101
+ </ReactSway>
102
+ );
103
+
104
+ const swayContainer = container.querySelector('.react-sway-container') as HTMLElement;
105
+ expect(swayContainer.style.transform).toContain('translate3d');
106
+ });
107
+ });
108
+
109
+ describe('draggable prop', () => {
110
+ it('applies grab cursor when draggable (default)', () => {
111
+ const { container } = render(
112
+ <ReactSway>
113
+ <div>Content</div>
114
+ </ReactSway>
115
+ );
116
+
117
+ const swayContainer = container.querySelector('.react-sway-container') as HTMLElement;
118
+ expect(swayContainer.style.cursor).toBe('grab');
119
+ });
120
+
121
+ it('applies default cursor when draggable is false', () => {
122
+ const { container } = render(
123
+ <ReactSway draggable={false}>
124
+ <div>Content</div>
125
+ </ReactSway>
126
+ );
127
+
128
+ const swayContainer = container.querySelector('.react-sway-container') as HTMLElement;
129
+ expect(swayContainer.style.cursor).toBe('default');
130
+ });
131
+ });
132
+
133
+ describe('keyboard prop', () => {
134
+ it('is focusable when keyboard is enabled (default)', () => {
135
+ const { container } = render(
136
+ <ReactSway>
137
+ <div>Content</div>
138
+ </ReactSway>
139
+ );
140
+
141
+ const swayContainer = container.querySelector('.react-sway-container') as HTMLElement;
142
+ expect(swayContainer.getAttribute('tabindex')).toBe('0');
143
+ });
144
+
145
+ it('is not focusable when keyboard is disabled', () => {
146
+ const { container } = render(
147
+ <ReactSway keyboard={false}>
148
+ <div>Content</div>
149
+ </ReactSway>
150
+ );
151
+
152
+ const swayContainer = container.querySelector('.react-sway-container') as HTMLElement;
153
+ expect(swayContainer.getAttribute('tabindex')).toBeNull();
154
+ });
155
+ });
156
+
157
+ describe('callbacks', () => {
158
+ it('does not react to keyboard events when container is not focused', () => {
159
+ const onPause = vi.fn();
160
+ render(
161
+ <ReactSway onPause={onPause}>
162
+ <div>Content</div>
163
+ </ReactSway>
164
+ );
165
+
166
+ fireEvent.keyDown(document, { key: ' ' });
167
+ expect(onPause).not.toHaveBeenCalled();
168
+ });
169
+
170
+ it('fires onPause when space key pauses', () => {
171
+ const onPause = vi.fn();
172
+ const { container } = render(
173
+ <ReactSway onPause={onPause}>
174
+ <div>Content</div>
175
+ </ReactSway>
176
+ );
177
+
178
+ const swayContainer = container.querySelector('.react-sway-container') as HTMLElement;
179
+ swayContainer.focus();
180
+ fireEvent.keyDown(swayContainer, { key: ' ' });
181
+ expect(onPause).toHaveBeenCalledOnce();
182
+ });
183
+
184
+ it('fires onResume when space key unpauses', () => {
185
+ const onResume = vi.fn();
186
+ const { container } = render(
187
+ <ReactSway onResume={onResume}>
188
+ <div>Content</div>
189
+ </ReactSway>
190
+ );
191
+
192
+ const swayContainer = container.querySelector('.react-sway-container') as HTMLElement;
193
+ swayContainer.focus();
194
+
195
+ // First press pauses, second unpauses
196
+ fireEvent.keyDown(swayContainer, { key: ' ' });
197
+ fireEvent.keyDown(swayContainer, { key: ' ' });
198
+ expect(onResume).toHaveBeenCalledOnce();
199
+ });
200
+
201
+ it('does not fire onPause when pauseOnInteraction is false', () => {
202
+ const onPause = vi.fn();
203
+ const { container } = render(
204
+ <ReactSway onPause={onPause} pauseOnInteraction={false}>
205
+ <div>Content</div>
206
+ </ReactSway>
207
+ );
208
+
209
+ const swayContainer = container.querySelector('.react-sway-container') as HTMLElement;
210
+ swayContainer.focus();
211
+ fireEvent.keyDown(swayContainer, { key: 'ArrowDown' });
212
+ expect(onPause).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it('does not resume auto-scroll if parent disables autoScroll during resume delay', () => {
216
+ vi.useFakeTimers();
217
+
218
+ const onResume = vi.fn();
219
+ const { container, rerender } = render(
220
+ <ReactSway onResume={onResume} resumeDelay={100}>
221
+ <div>Content</div>
222
+ </ReactSway>
223
+ );
224
+
225
+ const swayContainer = container.querySelector('.react-sway-container') as HTMLElement;
226
+ fireEvent.wheel(swayContainer, { deltaY: 120 });
227
+
228
+ rerender(
229
+ <ReactSway autoScroll={false} onResume={onResume} resumeDelay={100}>
230
+ <div>Content</div>
231
+ </ReactSway>
232
+ );
233
+
234
+ vi.advanceTimersByTime(150);
235
+ expect(onResume).not.toHaveBeenCalled();
236
+
237
+ vi.useRealTimers();
238
+ });
239
+ });
240
+
241
+ describe('IntersectionObserver', () => {
242
+ it('sets up observer for content items', () => {
243
+ render(
244
+ <ReactSway>
245
+ <div className="content-item">Item</div>
246
+ </ReactSway>
247
+ );
248
+
249
+ expect(IntersectionObserver).toHaveBeenCalled();
250
+ });
251
+
252
+ it('handles missing IntersectionObserver gracefully', () => {
253
+ vi.stubGlobal('IntersectionObserver', undefined);
254
+
255
+ expect(() => {
256
+ render(
257
+ <ReactSway>
258
+ <div>Content</div>
259
+ </ReactSway>
260
+ );
261
+ }).not.toThrow();
262
+ });
263
+
264
+ it('does not set up observer when lazy is false', () => {
265
+ const observerSpy = vi.fn();
266
+ vi.stubGlobal('IntersectionObserver', observerSpy);
267
+
268
+ render(
269
+ <ReactSway lazy={false}>
270
+ <div className="content-item">Item</div>
271
+ </ReactSway>
272
+ );
273
+
274
+ expect(observerSpy).not.toHaveBeenCalled();
275
+ });
276
+ });
277
+
278
+ describe('wheel events', () => {
279
+ it('applies wheel delta to velocity (fires onPause)', () => {
280
+ const onPause = vi.fn();
281
+ const { container } = render(
282
+ <ReactSway onPause={onPause}>
283
+ <div>Content</div>
284
+ </ReactSway>
285
+ );
286
+
287
+ const swayContainer = container.querySelector('.react-sway-container') as HTMLElement;
288
+ fireEvent.wheel(swayContainer, { deltaY: 100 });
289
+ expect(onPause).toHaveBeenCalledOnce();
290
+ });
291
+
292
+ it('caps velocity at MAX_VELOCITY', () => {
293
+ const onPause = vi.fn();
294
+ const { container } = render(
295
+ <ReactSway onPause={onPause}>
296
+ <div>Content</div>
297
+ </ReactSway>
298
+ );
299
+
300
+ const swayContainer = container.querySelector('.react-sway-container') as HTMLElement;
301
+
302
+ // Fire many large wheel events to exceed MAX_VELOCITY (150)
303
+ for (let i = 0; i < 20; i++) {
304
+ fireEvent.wheel(swayContainer, { deltaY: 1000 });
305
+ }
306
+
307
+ // If velocity were uncapped, it would be 20 * 1000 * 0.3 = 6000
308
+ // With cap at 150, onPause is still called but velocity is bounded
309
+ expect(onPause).toHaveBeenCalled();
310
+ });
311
+
312
+ it('does not respond to wheel when wheelEnabled is false', () => {
313
+ const onPause = vi.fn();
314
+ const { container } = render(
315
+ <ReactSway onPause={onPause} wheelEnabled={false}>
316
+ <div>Content</div>
317
+ </ReactSway>
318
+ );
319
+
320
+ const swayContainer = container.querySelector('.react-sway-container') as HTMLElement;
321
+ fireEvent.wheel(swayContainer, { deltaY: 100 });
322
+ expect(onPause).not.toHaveBeenCalled();
323
+ });
324
+ });
325
+
326
+ describe('touch interactions', () => {
327
+ it('rejects multi-touch gestures on start', () => {
328
+ const onPause = vi.fn();
329
+ const { container } = render(
330
+ <ReactSway onPause={onPause}>
331
+ <div>Content</div>
332
+ </ReactSway>
333
+ );
334
+
335
+ const swayContainer = container.querySelector('.react-sway-container') as HTMLElement;
336
+
337
+ // jsdom lacks the Touch constructor, so create a minimal synthetic event
338
+ const touchStartEvent = new Event('touchstart', { bubbles: true }) as Event & { touches: { length: number } };
339
+ Object.defineProperty(touchStartEvent, 'touches', {
340
+ value: { length: 2 },
341
+ });
342
+ swayContainer.dispatchEvent(touchStartEvent);
343
+
344
+ // onPause should not fire because multi-touch is rejected
345
+ expect(onPause).not.toHaveBeenCalled();
346
+ });
347
+ });
348
+
349
+ describe('ResizeObserver', () => {
350
+ it('sets up observer on mount', () => {
351
+ render(
352
+ <ReactSway>
353
+ <div>Content</div>
354
+ </ReactSway>
355
+ );
356
+
357
+ expect(mockResizeObserve).toHaveBeenCalled();
358
+ });
359
+
360
+ it('debounces rapid resize events', () => {
361
+ vi.useFakeTimers();
362
+
363
+ render(
364
+ <ReactSway>
365
+ <div>Content</div>
366
+ </ReactSway>
367
+ );
368
+
369
+ // Fire the ResizeObserver callback multiple times rapidly
370
+ if (mockResizeCallback) {
371
+ for (let i = 0; i < 5; i++) {
372
+ mockResizeCallback();
373
+ }
374
+ }
375
+
376
+ // Before debounce delay, nothing should have recalculated yet
377
+ // After debounce delay (150ms), recalculation fires once
378
+ vi.advanceTimersByTime(200);
379
+
380
+ // Verify observer was set up (the debounce is internal)
381
+ expect(mockResizeObserve).toHaveBeenCalled();
382
+
383
+ vi.useRealTimers();
384
+ });
385
+
386
+ it('handles missing ResizeObserver gracefully', () => {
387
+ vi.stubGlobal('ResizeObserver', undefined);
388
+
389
+ expect(() => {
390
+ render(
391
+ <ReactSway>
392
+ <div>Content</div>
393
+ </ReactSway>
394
+ );
395
+ }).not.toThrow();
396
+ });
397
+ });
398
+
399
+ describe('visibility change', () => {
400
+ it('does not throw when visibility changes', () => {
401
+ render(
402
+ <ReactSway>
403
+ <div>Content</div>
404
+ </ReactSway>
405
+ );
406
+
407
+ expect(() => {
408
+ Object.defineProperty(document, 'hidden', { value: true, writable: true });
409
+ fireEvent(document, new Event('visibilitychange'));
410
+
411
+ Object.defineProperty(document, 'hidden', { value: false, writable: true });
412
+ fireEvent(document, new Event('visibilitychange'));
413
+ }).not.toThrow();
414
+ });
415
+ });
416
+
417
+ describe('direction prop', () => {
418
+ it('accepts direction="down"', () => {
419
+ expect(() => {
420
+ render(
421
+ <ReactSway direction="down">
422
+ <div>Content</div>
423
+ </ReactSway>
424
+ );
425
+ }).not.toThrow();
426
+ });
427
+
428
+ it('accepts direction="up" (default)', () => {
429
+ expect(() => {
430
+ render(
431
+ <ReactSway direction="up">
432
+ <div>Content</div>
433
+ </ReactSway>
434
+ );
435
+ }).not.toThrow();
436
+ });
437
+
438
+ it('can switch direction via rerender', () => {
439
+ const { rerender } = render(
440
+ <ReactSway direction="up">
441
+ <div>Content</div>
442
+ </ReactSway>
443
+ );
444
+
445
+ expect(() => {
446
+ rerender(
447
+ <ReactSway direction="down">
448
+ <div>Content</div>
449
+ </ReactSway>
450
+ );
451
+ }).not.toThrow();
452
+ });
453
+ });
454
+
455
+ describe('prefers-reduced-motion', () => {
456
+ it('renders without error when reduced motion is preferred', () => {
457
+ // Override matchMedia to return reduced motion
458
+ vi.stubGlobal('matchMedia', vi.fn().mockImplementation((query: string) => ({
459
+ addEventListener: vi.fn(),
460
+ addListener: vi.fn(),
461
+ dispatchEvent: vi.fn(),
462
+ matches: query === '(prefers-reduced-motion: reduce)',
463
+ media: query,
464
+ onchange: null,
465
+ removeEventListener: vi.fn(),
466
+ removeListener: vi.fn(),
467
+ })));
468
+
469
+ expect(() => {
470
+ render(
471
+ <ReactSway>
472
+ <div>Content</div>
473
+ </ReactSway>
474
+ );
475
+ }).not.toThrow();
476
+ });
477
+
478
+ it('responds to dynamic media query changes', () => {
479
+ let changeHandler: ((e: MediaQueryListEvent) => void) | null = null;
480
+
481
+ vi.stubGlobal('matchMedia', vi.fn().mockImplementation((query: string) => ({
482
+ addEventListener: vi.fn((_event: string, handler: (e: MediaQueryListEvent) => void) => {
483
+ changeHandler = handler;
484
+ }),
485
+ addListener: vi.fn(),
486
+ dispatchEvent: vi.fn(),
487
+ matches: false,
488
+ media: query,
489
+ onchange: null,
490
+ removeEventListener: vi.fn(),
491
+ removeListener: vi.fn(),
492
+ })));
493
+
494
+ render(
495
+ <ReactSway>
496
+ <div>Content</div>
497
+ </ReactSway>
498
+ );
499
+
500
+ // Simulate media query change to reduced motion
501
+ expect(() => {
502
+ if (changeHandler) {
503
+ changeHandler({ matches: true } as MediaQueryListEvent);
504
+ }
505
+ }).not.toThrow();
506
+ });
507
+ });
508
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Shared testing-library setup for Vitest.
3
+ */
4
+ import '@testing-library/jest-dom';
5
+ import { vi } from 'vitest';
6
+
7
+ // jsdom does not implement matchMedia — provide a default mock
8
+ Object.defineProperty(window, 'matchMedia', {
9
+ writable: true,
10
+ value: vi.fn().mockImplementation((query: string) => ({
11
+ addEventListener: vi.fn(),
12
+ addListener: vi.fn(),
13
+ dispatchEvent: vi.fn(),
14
+ matches: false,
15
+ media: query,
16
+ onchange: null,
17
+ removeEventListener: vi.fn(),
18
+ removeListener: vi.fn(),
19
+ })),
20
+ });
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
- import ReactSway from './ReactSway';
2
-
3
- export { ReactSway };
1
+ /**
2
+ * Public package entrypoint for ReactSway exports.
3
+ */
4
+ export { default as ReactSway } from './ReactSway';
5
+ export type { ReactSwayProps } from './ReactSway';