- Force font-size:16px !important on Styles-sheet/topbar/Sitesmith inputs inside the mobile media query so inline 12px/14px styles stop triggering iOS zoom-on-focus. - Lift sheet-open + Templates/Head Code modal-open state out of private useState into a shared MobileChromeContext (EditorShell), so Phase B can open/close sheets from outside MobilePanelBar. - Add an explicit z-index layer scale, portal TemplateModal to document.body (was trapped under the tab bar inside .topbar's stacking context), align Sitesmith to the same --z-modal layer, and make opening a sheet close any open modal. Also fix modal backdrops swallowing tab bar taps (mirrors the sheet backdrop's existing tab-bar cutout). - Drop BottomSheet's incorrect aria-modal; mobile-aware AssetsPanel empty state copy. - Tests: useIsMobile (matchMedia mock incl. legacy fallback + cleanup), MobileChromeContext invariants (one sheet open, sheet closes modals), MobilePanelBar wiring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
4.6 KiB
TypeScript
154 lines
4.6 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 { useIsMobile } from './useIsMobile';
|
|
|
|
/**
|
|
* Mobile-A2 (review item 5): useIsMobile is the single source of truth every
|
|
* mobile-only branch (bottom tab bar, collapsed topbar, bottom sheets) reads
|
|
* from, so it needs direct coverage of: (a) it reflects matchMedia's
|
|
* `matches`, (b) the `change` listener is registered AND cleaned up on
|
|
* unmount (a leaked listener would keep re-rendering an unmounted tree /
|
|
* leak the component instance), and (c) the legacy Safari<14
|
|
* addListener/removeListener fallback is used when addEventListener isn't
|
|
* available.
|
|
*
|
|
* DOM-harness pattern mirrors PageContext.pure-updaters.test.tsx /
|
|
* useKeyboardShortcuts.test.tsx: bare createRoot + act, no @testing-library
|
|
* (not a project dependency).
|
|
*/
|
|
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
let originalMatchMedia: typeof window.matchMedia;
|
|
|
|
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 MediaQueryList. `mode` picks which listener API it
|
|
* exposes, so tests can force the legacy fallback path. */
|
|
function makeMql(matches: boolean, mode: 'modern' | 'legacy' = 'modern') {
|
|
const listeners: Array<() => void> = [];
|
|
const mql: any = {
|
|
matches,
|
|
media: '(max-width: 768px)',
|
|
};
|
|
if (mode === 'modern') {
|
|
mql.addEventListener = vi.fn((_type: string, fn: () => void) => listeners.push(fn));
|
|
mql.removeEventListener = vi.fn((_type: string, fn: () => void) => {
|
|
const i = listeners.indexOf(fn);
|
|
if (i !== -1) listeners.splice(i, 1);
|
|
});
|
|
} else {
|
|
mql.addListener = vi.fn((fn: () => void) => listeners.push(fn));
|
|
mql.removeListener = vi.fn((fn: () => void) => {
|
|
const i = listeners.indexOf(fn);
|
|
if (i !== -1) listeners.splice(i, 1);
|
|
});
|
|
}
|
|
return { mql, fire: () => listeners.forEach((fn) => fn()), listenerCount: () => listeners.length };
|
|
}
|
|
|
|
let probedValue: boolean | null = null;
|
|
const Probe: React.FC = () => {
|
|
probedValue = useIsMobile();
|
|
return <span data-value={String(probedValue)} />;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
originalMatchMedia = window.matchMedia;
|
|
probedValue = null;
|
|
});
|
|
|
|
afterEach(() => {
|
|
window.matchMedia = originalMatchMedia;
|
|
});
|
|
|
|
describe('useIsMobile', () => {
|
|
test('returns true when the mobile media query matches', () => {
|
|
const { mql } = makeMql(true);
|
|
window.matchMedia = vi.fn(() => mql) as any;
|
|
|
|
render(<Probe />);
|
|
expect(probedValue).toBe(true);
|
|
unmount();
|
|
});
|
|
|
|
test('returns false when the mobile media query does not match', () => {
|
|
const { mql } = makeMql(false);
|
|
window.matchMedia = vi.fn(() => mql) as any;
|
|
|
|
render(<Probe />);
|
|
expect(probedValue).toBe(false);
|
|
unmount();
|
|
});
|
|
|
|
test('registers the change listener via addEventListener and updates on change', () => {
|
|
const { mql, fire, listenerCount } = makeMql(false);
|
|
window.matchMedia = vi.fn(() => mql) as any;
|
|
|
|
render(<Probe />);
|
|
expect(probedValue).toBe(false);
|
|
expect(mql.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
|
expect(listenerCount()).toBe(1);
|
|
|
|
// Flip the query result and fire the registered 'change' listener --
|
|
// the hook must re-read mql.matches, not just toggle blindly.
|
|
mql.matches = true;
|
|
act(() => {
|
|
fire();
|
|
});
|
|
expect(probedValue).toBe(true);
|
|
|
|
unmount();
|
|
});
|
|
|
|
test('cleans up the addEventListener listener on unmount', () => {
|
|
const { mql, listenerCount } = makeMql(true);
|
|
window.matchMedia = vi.fn(() => mql) as any;
|
|
|
|
render(<Probe />);
|
|
expect(listenerCount()).toBe(1);
|
|
|
|
unmount();
|
|
|
|
expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
|
expect(listenerCount()).toBe(0);
|
|
});
|
|
|
|
test('falls back to legacy addListener/removeListener when addEventListener is unavailable', () => {
|
|
const { mql, fire, listenerCount } = makeMql(false, 'legacy');
|
|
window.matchMedia = vi.fn(() => mql) as any;
|
|
|
|
render(<Probe />);
|
|
expect(probedValue).toBe(false);
|
|
expect(mql.addListener).toHaveBeenCalledWith(expect.any(Function));
|
|
expect(listenerCount()).toBe(1);
|
|
|
|
mql.matches = true;
|
|
act(() => {
|
|
fire();
|
|
});
|
|
expect(probedValue).toBe(true);
|
|
|
|
unmount();
|
|
|
|
expect(mql.removeListener).toHaveBeenCalledWith(expect.any(Function));
|
|
expect(listenerCount()).toBe(0);
|
|
});
|
|
});
|