147 lines
5.2 KiB
TypeScript
147 lines
5.2 KiB
TypeScript
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||
|
|
import React from 'react';
|
||
|
|
import { createRoot, Root } from 'react-dom/client';
|
||
|
|
import { act } from 'react-dom/test-utils';
|
||
|
|
import { useVisualViewportInsets } from './useVisualViewport';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Fast-follow coverage for `useVisualViewportInsets` (BottomSheet's
|
||
|
|
* keyboard-avoidance source of truth). Mirrors the bare createRoot + act
|
||
|
|
* harness `useIsMobile.test.tsx` uses (no @testing-library/react in this
|
||
|
|
* repo). Covers:
|
||
|
|
* - the no-`visualViewport` fallback (older WebViews) returns
|
||
|
|
* `{ height: window.innerHeight, keyboardInset: 0 }` AND registers no
|
||
|
|
* listener (there's nothing to listen to).
|
||
|
|
* - the keyboard-open math: `keyboardInset = innerHeight - (vv.height +
|
||
|
|
* vv.offsetTop)`, recomputed when the mocked vv fires `resize`.
|
||
|
|
* - both `resize` and `scroll` listeners are added on mount and the SAME
|
||
|
|
* handler is removed for both on unmount (a leaked listener would keep
|
||
|
|
* reacting after the owning component -- e.g. BottomSheet -- is gone).
|
||
|
|
*/
|
||
|
|
|
||
|
|
let container: HTMLDivElement;
|
||
|
|
let root: Root;
|
||
|
|
let originalInnerHeight: number;
|
||
|
|
let originalVisualViewport: VisualViewport | null;
|
||
|
|
|
||
|
|
function render(ui: React.ReactElement) {
|
||
|
|
container = document.createElement('div');
|
||
|
|
document.body.appendChild(container);
|
||
|
|
act(() => {
|
||
|
|
root = createRoot(container);
|
||
|
|
root.render(ui);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function unmount() {
|
||
|
|
act(() => {
|
||
|
|
root.unmount();
|
||
|
|
});
|
||
|
|
container.remove();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** A minimal fake `visualViewport`: just enough surface for the hook
|
||
|
|
* (`height`, `offsetTop`, `addEventListener`/`removeEventListener`). */
|
||
|
|
function makeVisualViewport(height: number, offsetTop: number) {
|
||
|
|
const listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
|
||
|
|
const vv: any = {
|
||
|
|
height,
|
||
|
|
offsetTop,
|
||
|
|
addEventListener: vi.fn((type: string, fn: () => void) => {
|
||
|
|
listeners[type].push(fn);
|
||
|
|
}),
|
||
|
|
removeEventListener: vi.fn((type: string, fn: () => void) => {
|
||
|
|
const i = listeners[type].indexOf(fn);
|
||
|
|
if (i !== -1) listeners[type].splice(i, 1);
|
||
|
|
}),
|
||
|
|
};
|
||
|
|
return {
|
||
|
|
vv,
|
||
|
|
fire: (type: 'resize' | 'scroll') => listeners[type].forEach((fn) => fn()),
|
||
|
|
listenerCount: (type: 'resize' | 'scroll') => listeners[type].length,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
let probed: { height: number; keyboardInset: number } | null = null;
|
||
|
|
const Probe: React.FC = () => {
|
||
|
|
probed = useVisualViewportInsets();
|
||
|
|
return <span data-height={probed.height} data-inset={probed.keyboardInset} />;
|
||
|
|
};
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
originalInnerHeight = window.innerHeight;
|
||
|
|
originalVisualViewport = window.visualViewport;
|
||
|
|
probed = null;
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
Object.defineProperty(window, 'innerHeight', {
|
||
|
|
value: originalInnerHeight,
|
||
|
|
configurable: true,
|
||
|
|
writable: true,
|
||
|
|
});
|
||
|
|
Object.defineProperty(window, 'visualViewport', {
|
||
|
|
value: originalVisualViewport,
|
||
|
|
configurable: true,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('useVisualViewportInsets', () => {
|
||
|
|
test('falls back to innerHeight/0 and registers no listener when visualViewport is undefined', () => {
|
||
|
|
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
|
||
|
|
Object.defineProperty(window, 'visualViewport', { value: undefined, configurable: true });
|
||
|
|
|
||
|
|
render(<Probe />);
|
||
|
|
|
||
|
|
expect(probed).toEqual({ height: 800, keyboardInset: 0 });
|
||
|
|
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('computes keyboardInset from visualViewport and recomputes on resize', () => {
|
||
|
|
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
|
||
|
|
const { vv, fire } = makeVisualViewport(800, 0);
|
||
|
|
Object.defineProperty(window, 'visualViewport', { value: vv, configurable: true });
|
||
|
|
|
||
|
|
render(<Probe />);
|
||
|
|
|
||
|
|
// No keyboard open: vv fills the layout viewport exactly.
|
||
|
|
expect(probed).toEqual({ height: 800, keyboardInset: 0 });
|
||
|
|
|
||
|
|
// Simulate the on-screen keyboard opening: the visual viewport shrinks.
|
||
|
|
vv.height = 500;
|
||
|
|
vv.offsetTop = 0;
|
||
|
|
act(() => {
|
||
|
|
fire('resize');
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(probed).toEqual({ height: 500, keyboardInset: 300 });
|
||
|
|
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('registers resize and scroll listeners on mount and removes both (same handler) on unmount', () => {
|
||
|
|
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
|
||
|
|
const { vv, listenerCount } = makeVisualViewport(800, 0);
|
||
|
|
Object.defineProperty(window, 'visualViewport', { value: vv, configurable: true });
|
||
|
|
|
||
|
|
render(<Probe />);
|
||
|
|
|
||
|
|
expect(vv.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
|
||
|
|
expect(vv.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
|
||
|
|
expect(listenerCount('resize')).toBe(1);
|
||
|
|
expect(listenerCount('scroll')).toBe(1);
|
||
|
|
|
||
|
|
const resizeHandler = vv.addEventListener.mock.calls.find((c: any[]) => c[0] === 'resize')[1];
|
||
|
|
const scrollHandler = vv.addEventListener.mock.calls.find((c: any[]) => c[0] === 'scroll')[1];
|
||
|
|
expect(resizeHandler).toBe(scrollHandler);
|
||
|
|
|
||
|
|
unmount();
|
||
|
|
|
||
|
|
expect(vv.removeEventListener).toHaveBeenCalledWith('resize', resizeHandler);
|
||
|
|
expect(vv.removeEventListener).toHaveBeenCalledWith('scroll', scrollHandler);
|
||
|
|
expect(listenerCount('resize')).toBe(0);
|
||
|
|
expect(listenerCount('scroll')).toBe(0);
|
||
|
|
});
|
||
|
|
});
|