Merge PR #11: mobile fast-follows
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,146 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -195,7 +195,12 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
label: 'Select Parent',
|
label: 'Select Parent',
|
||||||
icon: 'level-up',
|
icon: 'level-up',
|
||||||
action: selectParent,
|
action: selectParent,
|
||||||
disabled: isRoot,
|
// Not just `isRoot`: the real dead-end is when nodeId's PARENT is
|
||||||
|
// ROOT (selecting a top-level section -> Select Parent would only
|
||||||
|
// select the page-wide, un-editable ROOT -- no outline, no toolbar).
|
||||||
|
// `canSelectParent` (useNodeActions) already encodes that and matches
|
||||||
|
// the mobile selection toolbar's identical guard.
|
||||||
|
disabled: !nodeActions.canSelectParent,
|
||||||
dividerAfter: true,
|
dividerAfter: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user