Skip to content

Commit 0485e12

Browse files
committed
feat: implement Visual Viewport mock with event handling and comprehensive tests
1 parent bcba45c commit 0485e12

File tree

10 files changed

+1199
-1
lines changed

10 files changed

+1199
-1
lines changed

README.md

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ A set of tools for emulating browser behavior in jsdom environment
2020
[Intersection Observer](#mock-intersectionobserver)
2121
[Resize Observer](#mock-resizeobserver)
2222
[Web Animations API](#mock-web-animations-api)
23-
[CSS Typed OM](#mock-css-typed-om)
23+
[CSS Typed OM](#mock-css-typed-om)
24+
[Visual Viewport](#mock-visual-viewport)
2425

2526
## Installation
2627

@@ -442,6 +443,75 @@ it('enforces type safety', () => {
442443

443444
Supports all CSS units (length, angle, time, frequency, resolution, flex, percentage), mathematical operations, and enforces type compatibility rules as defined in the [W3C specification](https://www.w3.org/TR/css-typed-om-1/).
444445

446+
## Mock Visual Viewport
447+
448+
Provides a mock implementation of the Visual Viewport API for testing components that depend on viewport properties like `width`, `height`, `scale`, and position. The mock follows the library's pattern of manual event triggering for deterministic testing.
449+
450+
```jsx
451+
import { mockVisualViewport } from 'jsdom-testing-mocks';
452+
453+
it('responds to viewport changes', () => {
454+
const viewport = mockVisualViewport({
455+
width: 375,
456+
height: 667,
457+
scale: 1,
458+
offsetLeft: 0,
459+
offsetTop: 0,
460+
pageLeft: 0,
461+
pageTop: 0,
462+
});
463+
464+
const resizeCallback = jest.fn();
465+
window.visualViewport?.addEventListener('resize', resizeCallback);
466+
467+
// Update viewport properties
468+
viewport.set({ width: 768, height: 1024 });
469+
470+
// Manually trigger events (no automatic dispatch)
471+
viewport.triggerResize();
472+
expect(resizeCallback).toHaveBeenCalledTimes(1);
473+
474+
// Test scroll events
475+
const scrollCallback = jest.fn();
476+
window.visualViewport?.addEventListener('scroll', scrollCallback);
477+
478+
viewport.set({ pageLeft: 100, pageTop: 200 });
479+
viewport.triggerScroll();
480+
expect(scrollCallback).toHaveBeenCalledTimes(1);
481+
482+
viewport.cleanup();
483+
});
484+
```
485+
486+
### Properties
487+
488+
The mock supports all Visual Viewport properties:
489+
- `width` / `height`: Viewport dimensions
490+
- `scale`: Zoom level (must be positive and finite)
491+
- `offsetLeft` / `offsetTop`: Visual viewport offset from layout viewport
492+
- `pageLeft` / `pageTop`: Visual viewport position relative to the page
493+
- `segments`: Returns empty array (experimental property)
494+
495+
### Events
496+
497+
- `resize`: Triggered manually via `triggerResize()`
498+
- `scroll`: Triggered manually via `triggerScroll()`
499+
- Event handlers: `onresize` and `onscroll` properties supported
500+
501+
### Manual Triggering
502+
503+
Unlike real browsers, events are not automatically dispatched when properties change. This ensures deterministic tests:
504+
505+
```jsx
506+
// Properties can be updated without triggering events
507+
viewport.set({ width: 500, height: 600 });
508+
expect(resizeCallback).not.toHaveBeenCalled();
509+
510+
// Events must be triggered manually
511+
viewport.triggerResize();
512+
expect(resizeCallback).toHaveBeenCalledTimes(1);
513+
```
514+
445515
## Current issues
446516
447517
- Needs more tests

TODO-visual-viewport.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Visual Viewport Mock Implementation Plan
2+
3+
## 1. API SURFACE GAPS & DESIGN DECISIONS
4+
5+
### A. `addEventListener` / `removeEventListener` signature
6+
- Accept the optional `options?: boolean | AddEventListenerOptions` parameter just as the DOM type does (ignore it internally).
7+
- Keeps TypeScript happy when user code passes `{once: true}`, `{passive:true}`, etc.
8+
9+
### B. `segments` property (experimental but present in spec & polyfill)
10+
- Minimal stub: define a **read-only** getter that always returns `[]`.
11+
- This lets feature-detection (`'segments' in visualViewport`) succeed without misleading tests.
12+
13+
### C. Event bubbling to `window` (low priority)
14+
- Real browsers bubble `scroll`/`resize` from `visualViewport` to the Window.
15+
- Decide whether to replicate or knowingly document the divergence.
16+
- If replicated: after running local listeners, call `window.dispatchEvent(clonedEvent)`.
17+
18+
### D. Input validation / edge cases
19+
- Clamp `scale` to `> 0` and disallow `NaN` to avoid accidental bad state.
20+
- Document that the helper does **no automatic geometry**; all numbers are accepted "as-is".
21+
22+
### E. Documentation
23+
- README section explaining:
24+
- `set()` mutates state only.
25+
- Call `triggerResize` / `triggerScroll` (or `dispatchEvent`) when you want observers to run.
26+
- `segments` not yet supported (returns empty array).
27+
- Examples mirroring IntersectionObserver mock pattern.
28+
29+
## 2. CODE-LEVEL TASKS
30+
31+
1. Edit `MockedVisualViewport.addEventListener` / `removeEventListener` to include unused `options` param.
32+
2. Add private field `#segments: readonly DOMRectReadOnly[] = []` and read-only getter `segments`.
33+
- Export a `setSegments(rects)` helper only if a test ever needs it; otherwise keep fixed.
34+
3. (Optional) Implement bubbling:
35+
```ts
36+
if (config.getConfig().bubbleVisualViewportEvents) {
37+
window.dispatchEvent(event);
38+
}
39+
```
40+
4. Guard against `NaN`, `Infinity`, negative `scale` in `update()`.
41+
5. Update `src/index.ts` re-exports if new helpers are added.
42+
43+
## 3. TEST SUITE ADDITIONS
44+
45+
### A. Signature acceptance
46+
- `addEventListener('resize', fn, {once:true})` does **not** throw.
47+
- Same for `removeEventListener`.
48+
49+
### B. Segments
50+
- `expect(window.visualViewport!.segments).toEqual([]);`
51+
- Property is read-only (assignment throws in strict mode).
52+
53+
### C. Bubbling (only if implemented)
54+
- Attach `window.addEventListener('resize', spy)`; call `visualViewport.triggerResize()`; spy called.
55+
56+
### D. Validation
57+
- `set({ scale: -1 })` clamps or throws as documented.
58+
- `set({ scale: NaN })` ignored or throws.
59+
60+
### E. No auto-dispatch guarantee
61+
- `set({ width:123 })` by itself does **not** invoke listeners; after `triggerResize()` it does.
62+
63+
### F. Options untouched cleanup
64+
- After `cleanup()` original `window.visualViewport` (undefined or native) is restored.
65+
66+
## 4. DELIVERABLE ORDER
67+
68+
1. **Code changes** (steps 1-4).
69+
2. **New tests** – place in `src/mocks/visual-viewport.*.test.ts`.
70+
3. **README update**.
71+
4. Run Jest/Vitest & `npm run build`.
72+
5. One-sentence commit message:
73+
`feat(mockVisualViewport): add segments stub, full listener signature & tests`
74+
75+
## Implementation Steps
76+
77+
### Step 1: Fix addEventListener/removeEventListener signatures
78+
- [x] Add `options?: boolean | AddEventListenerOptions` to `addEventListener`
79+
- [x] Add `options?: boolean | EventListenerOptions` to `removeEventListener`
80+
- [x] Test that these don't break existing functionality
81+
82+
### Step 2: Add segments property
83+
- [x] Add private field `#segments: readonly DOMRectReadOnly[] = []`
84+
- [x] Add read-only getter `segments`
85+
- [x] Test that it returns empty array and is read-only
86+
87+
### Step 3: Add input validation
88+
- [x] Add validation in `update()` method for scale values
89+
- [x] Test edge cases (NaN, negative scale, etc.)
90+
91+
### Step 4: Add comprehensive tests
92+
- [x] Test signature acceptance
93+
- [x] Test segments property
94+
- [x] Test validation
95+
- [x] Test no auto-dispatch guarantee
96+
- [x] Test cleanup
97+
98+
### Step 5: Update documentation
99+
- [x] Add README section for Visual Viewport mock
100+
- [x] Document the manual trigger pattern
101+
- [x] Add usage examples
102+
103+
## Implementation Status
104+
105+
All steps completed! ✅
106+
107+
The Visual Viewport mock implementation is now complete with:
108+
- ✅ Proper event listener signatures with options support
109+
- ✅ Segments property (read-only empty array)
110+
- ✅ Input validation for scale values
111+
- ✅ Comprehensive test coverage
112+
- ✅ Complete documentation with examples
113+
114+
## Summary
115+
116+
We have successfully implemented the Visual Viewport API mock for the `jsdom-testing-mocks` library, addressing issue #61. The implementation includes:
117+
118+
1. **Complete API Surface**: All Visual Viewport properties (`width`, `height`, `scale`, `offsetLeft`, `offsetTop`, `pageLeft`, `pageTop`, `segments`)
119+
2. **Event Support**: `resize` and `scroll` events with manual triggering pattern
120+
3. **Event Handlers**: `onresize` and `onscroll` properties
121+
4. **Input Validation**: Scale values must be positive and finite
122+
5. **Comprehensive Testing**: 15 test cases covering all functionality
123+
6. **Documentation**: Complete README section with usage examples
124+
125+
The mock follows the library's established pattern of manual event triggering for deterministic testing, ensuring that tests are predictable and reliable.

eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export default [
3737
// TS handles undefined types, turn off for TS files
3838
'no-undef': 'off',
3939
'@typescript-eslint/ban-ts-comment': 'off',
40+
'@typescript-eslint/no-explicit-any': 'error',
4041
},
4142
},
4243
{
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { render, act, screen } from '@testing-library/react';
2+
import { mockVisualViewport, configMocks } from '../../../../dist';
3+
import VisualViewportExample from './VisualViewportExample';
4+
5+
configMocks({ act });
6+
7+
describe('VisualViewportExample', () => {
8+
it('should display viewport information when VisualViewport API is available', () => {
9+
const visualViewport = mockVisualViewport({
10+
width: 375,
11+
height: 667,
12+
scale: 1,
13+
offsetLeft: 0,
14+
offsetTop: 0,
15+
pageLeft: 0,
16+
pageTop: 0,
17+
});
18+
19+
render(<VisualViewportExample />);
20+
21+
expect(screen.getByText('VisualViewport API')).toBeInTheDocument();
22+
expect(screen.getByText('Width: 375px')).toBeInTheDocument();
23+
expect(screen.getByText('Height: 667px')).toBeInTheDocument();
24+
expect(screen.getByText('Scale: 1')).toBeInTheDocument();
25+
26+
visualViewport.cleanup();
27+
});
28+
29+
it('should update viewport information when properties change', () => {
30+
const visualViewport = mockVisualViewport({
31+
width: 375,
32+
height: 667,
33+
scale: 1,
34+
offsetLeft: 0,
35+
offsetTop: 0,
36+
pageLeft: 0,
37+
pageTop: 0,
38+
});
39+
40+
render(<VisualViewportExample />);
41+
42+
expect(screen.getByText('Width: 375px')).toBeInTheDocument();
43+
expect(screen.getByText('Scale: 1')).toBeInTheDocument();
44+
45+
act(() => {
46+
visualViewport.set({
47+
width: 500,
48+
scale: 1.5,
49+
offsetLeft: 10,
50+
offsetTop: 20,
51+
});
52+
});
53+
54+
expect(screen.getByText('Width: 500px')).toBeInTheDocument();
55+
expect(screen.getByText('Scale: 1.5')).toBeInTheDocument();
56+
expect(screen.getByText('Offset Left: 10px')).toBeInTheDocument();
57+
expect(screen.getByText('Offset Top: 20px')).toBeInTheDocument();
58+
59+
visualViewport.cleanup();
60+
});
61+
62+
it('should increment resize counter when resize event is triggered', () => {
63+
const visualViewport = mockVisualViewport({
64+
width: 375,
65+
height: 667,
66+
scale: 1,
67+
offsetLeft: 0,
68+
offsetTop: 0,
69+
pageLeft: 0,
70+
pageTop: 0,
71+
});
72+
73+
render(<VisualViewportExample />);
74+
75+
expect(screen.getByText('Resize Events: 0')).toBeInTheDocument();
76+
77+
act(() => {
78+
visualViewport.triggerResize();
79+
});
80+
81+
expect(screen.getByText('Resize Events: 1')).toBeInTheDocument();
82+
83+
act(() => {
84+
visualViewport.triggerResize();
85+
});
86+
87+
expect(screen.getByText('Resize Events: 2')).toBeInTheDocument();
88+
89+
visualViewport.cleanup();
90+
});
91+
92+
it('should increment scroll counter when scroll event is triggered', () => {
93+
const visualViewport = mockVisualViewport({
94+
width: 375,
95+
height: 667,
96+
scale: 1,
97+
offsetLeft: 0,
98+
offsetTop: 0,
99+
pageLeft: 0,
100+
pageTop: 0,
101+
});
102+
103+
render(<VisualViewportExample />);
104+
105+
expect(screen.getByText('Scroll Events: 0')).toBeInTheDocument();
106+
107+
act(() => {
108+
visualViewport.triggerScroll();
109+
});
110+
111+
expect(screen.getByText('Scroll Events: 1')).toBeInTheDocument();
112+
113+
act(() => {
114+
visualViewport.triggerScroll();
115+
});
116+
117+
expect(screen.getByText('Scroll Events: 2')).toBeInTheDocument();
118+
119+
visualViewport.cleanup();
120+
});
121+
122+
it('should update viewport info when resize event is triggered', () => {
123+
const visualViewport = mockVisualViewport({
124+
width: 375,
125+
height: 667,
126+
scale: 1,
127+
offsetLeft: 0,
128+
offsetTop: 0,
129+
pageLeft: 0,
130+
pageTop: 0,
131+
});
132+
133+
render(<VisualViewportExample />);
134+
135+
expect(screen.getByText('Width: 375px')).toBeInTheDocument();
136+
137+
act(() => {
138+
visualViewport.set({ width: 500, height: 800 });
139+
visualViewport.triggerResize();
140+
});
141+
142+
expect(screen.getByText('Width: 500px')).toBeInTheDocument();
143+
expect(screen.getByText('Height: 800px')).toBeInTheDocument();
144+
145+
visualViewport.cleanup();
146+
});
147+
148+
it('should update viewport info when scroll event is triggered', () => {
149+
const visualViewport = mockVisualViewport({
150+
width: 375,
151+
height: 667,
152+
scale: 1,
153+
offsetLeft: 0,
154+
offsetTop: 0,
155+
pageLeft: 0,
156+
pageTop: 0,
157+
});
158+
159+
render(<VisualViewportExample />);
160+
161+
expect(screen.getByText('Page Left: 0px')).toBeInTheDocument();
162+
expect(screen.getByText('Page Top: 0px')).toBeInTheDocument();
163+
164+
act(() => {
165+
visualViewport.set({ pageLeft: 100, pageTop: 200 });
166+
visualViewport.triggerScroll();
167+
});
168+
169+
expect(screen.getByText('Page Left: 100px')).toBeInTheDocument();
170+
expect(screen.getByText('Page Top: 200px')).toBeInTheDocument();
171+
172+
visualViewport.cleanup();
173+
});
174+
});

0 commit comments

Comments
 (0)