fix(builder): mobile-A2 hardening -- 16px inputs, shared sheet/modal chrome, z-scale + portal

- 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>
This commit is contained in:
2026-07-13 07:30:12 -07:00
co-authored by Claude Opus 4.8
parent 979331b12d
commit 2a8a26687b
14 changed files with 686 additions and 52 deletions
@@ -0,0 +1,145 @@
import { describe, test, expect, vi } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { MobilePanelBar } from './MobilePanelBar';
import { MobileChromeProvider } from '../../state/MobileChromeContext';
/**
* Mobile-A2 (review item 5): the real BlocksPanel/PagesPanel/LayersPanel/
* AssetsPanel/GuidedStyles all pull in Craft.js's `useEditor` and other
* heavy dependencies not relevant here, so they're stubbed out -- this
* suite is only exercising MobilePanelBar's OWN wiring: tapping a tab opens
* that tab's sheet with only its panel rendered, tapping the active tab
* again closes it, and (mirroring MobileChromeContext.test.tsx) only one
* sheet's content is ever mounted at once.
*/
vi.mock('../left/BlocksPanel', () => ({ BlocksPanel: () => <div data-testid="panel-blocks" /> }));
vi.mock('../left/PagesPanel', () => ({ PagesPanel: () => <div data-testid="panel-pages" /> }));
vi.mock('../left/LayersPanel', () => ({ LayersPanel: () => <div data-testid="panel-layers" /> }));
vi.mock('../left/AssetsPanel', () => ({ AssetsPanel: () => <div data-testid="panel-assets" /> }));
vi.mock('../right/GuidedStyles', () => ({ GuidedStyles: () => <div data-testid="panel-styles" /> }));
let container: HTMLDivElement;
let root: Root;
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();
}
function tabButton(label: string): HTMLButtonElement {
const buttons = Array.from(container.querySelectorAll('.mobile-tab-btn')) as HTMLButtonElement[];
const btn = buttons.find((b) => b.textContent?.includes(label));
if (!btn) throw new Error(`No tab button found for "${label}"`);
return btn;
}
function openPanelIds(): string[] {
return Array.from(container.querySelectorAll('[data-testid^="panel-"]')).map(
(el) => (el as HTMLElement).dataset.testid!,
);
}
describe('MobilePanelBar', () => {
test('no sheet is open initially', () => {
render(
<MobileChromeProvider>
<MobilePanelBar />
</MobileChromeProvider>,
);
expect(container.querySelector('.mobile-sheet-backdrop')).toBeNull();
expect(openPanelIds()).toEqual([]);
unmount();
});
test('tapping a tab opens exactly that tab\'s panel', () => {
render(
<MobileChromeProvider>
<MobilePanelBar />
</MobileChromeProvider>,
);
act(() => {
tabButton('Blocks').click();
});
expect(openPanelIds()).toEqual(['panel-blocks']);
unmount();
});
test('switching tabs replaces the open sheet -- only one panel mounted at a time', () => {
render(
<MobileChromeProvider>
<MobilePanelBar />
</MobileChromeProvider>,
);
act(() => {
tabButton('Blocks').click();
});
expect(openPanelIds()).toEqual(['panel-blocks']);
act(() => {
tabButton('Styles').click();
});
expect(openPanelIds()).toEqual(['panel-styles']);
act(() => {
tabButton('Assets').click();
});
expect(openPanelIds()).toEqual(['panel-assets']);
unmount();
});
test('tapping the active tab again closes the sheet', () => {
render(
<MobileChromeProvider>
<MobilePanelBar />
</MobileChromeProvider>,
);
act(() => {
tabButton('Pages').click();
});
expect(openPanelIds()).toEqual(['panel-pages']);
expect(container.querySelector('.mobile-sheet-backdrop')).not.toBeNull();
act(() => {
tabButton('Pages').click();
});
expect(openPanelIds()).toEqual([]);
expect(container.querySelector('.mobile-sheet-backdrop')).toBeNull();
unmount();
});
test('active tab button carries aria-pressed=true only for the open sheet', () => {
render(
<MobileChromeProvider>
<MobilePanelBar />
</MobileChromeProvider>,
);
act(() => {
tabButton('Layers').click();
});
expect(tabButton('Layers').getAttribute('aria-pressed')).toBe('true');
expect(tabButton('Blocks').getAttribute('aria-pressed')).toBe('false');
unmount();
});
});