diff --git a/craft/src/hooks/useVisualViewport.test.tsx b/craft/src/hooks/useVisualViewport.test.tsx new file mode 100644 index 0000000..f88ed23 --- /dev/null +++ b/craft/src/hooks/useVisualViewport.test.tsx @@ -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 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 ; +}; + +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(); + + 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(); + + // 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(); + + 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); + }); +}); diff --git a/craft/src/panels/context-menu/ContextMenu.tsx b/craft/src/panels/context-menu/ContextMenu.tsx index 1d31e0a..4be5807 100644 --- a/craft/src/panels/context-menu/ContextMenu.tsx +++ b/craft/src/panels/context-menu/ContextMenu.tsx @@ -195,7 +195,12 @@ export const ContextMenu: React.FC = ({ label: 'Select Parent', icon: 'level-up', 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, }, {