Mobile Phase A: responsive editor (bottom-sheet panels, collapsed topbar, touch ergonomics) #9

Merged
jknapp merged 2 commits from mobile-phase-a into main 2026-07-13 14:31:24 +00:00
14 changed files with 686 additions and 52 deletions
Showing only changes of commit 2a8a26687b - Show all commits
+29 -21
View File
@@ -9,6 +9,7 @@ import { ContextMenu } from '../panels/context-menu/ContextMenu';
import { useContextMenu } from '../hooks/useContextMenu'; import { useContextMenu } from '../hooks/useContextMenu';
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
import { MobileChromeProvider } from '../state/MobileChromeContext';
import { DeviceMode } from '../types'; import { DeviceMode } from '../types';
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides'; const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
@@ -75,28 +76,35 @@ export const EditorShell: React.FC = () => {
}, [query, showMenu]); }, [query, showMenu]);
return ( return (
<div className="editor-app"> // Mobile-A2: shared sheet/modal-open state (see MobileChromeContext) --
<TopBar // provided around the whole shell so TopBar's Templates/Head Code modal
device={device} // state and MobilePanelBar's sheet state live in one place. Desktop
onDeviceChange={setDevice} // doesn't render MobilePanelBar and TopBar's desktop branch behaves
showGuides={showGuides} // identically to before (same booleans, just sourced from context).
onToggleGuides={() => setShowGuides(!showGuides)} <MobileChromeProvider>
/> <div className="editor-app">
<div className="editor-container"> <TopBar
{!isMobile && <LeftPanel />} device={device}
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}> onDeviceChange={setDevice}
<Canvas device={device} showGuides={showGuides} /> showGuides={showGuides}
onToggleGuides={() => setShowGuides(!showGuides)}
/>
<div className="editor-container">
{!isMobile && <LeftPanel />}
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
<Canvas device={device} showGuides={showGuides} />
</div>
{!isMobile && <RightPanel />}
</div> </div>
{!isMobile && <RightPanel />} {isMobile && <MobilePanelBar />}
<ContextMenu
visible={menuState.visible}
x={menuState.x}
y={menuState.y}
nodeId={menuState.nodeId}
onClose={hideMenu}
/>
</div> </div>
{isMobile && <MobilePanelBar />} </MobileChromeProvider>
<ContextMenu
visible={menuState.visible}
x={menuState.x}
y={menuState.y}
nodeId={menuState.nodeId}
onClose={hideMenu}
/>
</div>
); );
}; };
+153
View File
@@ -0,0 +1,153 @@
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);
});
});
+5 -1
View File
@@ -2,12 +2,14 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useAssets } from '../../hooks/useAssets'; import { useAssets } from '../../hooks/useAssets';
import { clickableProps } from '../../utils/a11y'; import { clickableProps } from '../../utils/a11y';
import { copyToClipboard } from '../../utils/clipboard'; import { copyToClipboard } from '../../utils/clipboard';
import { useIsMobile } from '../../hooks/useIsMobile';
/** How long the "Delete?" confirm state stays armed before auto-resetting. */ /** How long the "Delete?" confirm state stays armed before auto-resetting. */
const DELETE_CONFIRM_TIMEOUT_MS = 4000; const DELETE_CONFIRM_TIMEOUT_MS = 4000;
export const AssetsPanel: React.FC = () => { export const AssetsPanel: React.FC = () => {
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets(); const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
const isMobile = useIsMobile();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [copiedUrl, setCopiedUrl] = useState<string | null>(null); const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
@@ -139,7 +141,9 @@ export const AssetsPanel: React.FC = () => {
{assets.length === 0 && ( {assets.length === 0 && (
<i className="fa fa-cloud-upload" aria-hidden style={{ fontSize: 28, opacity: 0.5 }} /> <i className="fa fa-cloud-upload" aria-hidden style={{ fontSize: 28, opacity: 0.5 }} />
)} )}
{assets.length === 0 ? 'Drag images here or click to upload' : 'Drop files here to upload'} {assets.length === 0
? (isMobile ? 'Tap to upload images' : 'Drag images here or click to upload')
: 'Drop files here to upload'}
</div> </div>
{/* Error message */} {/* Error message */}
+5 -1
View File
@@ -44,7 +44,11 @@ export const BottomSheet: React.FC<BottomSheetProps> = ({ open, onClose, title,
if (e.target === e.currentTarget) onClose(); if (e.target === e.currentTarget) onClose();
}} }}
> >
<div className="mobile-sheet" role="dialog" aria-modal="true" aria-label={title}> {/* Deliberately NOT aria-modal: the tab bar stays reachable/operable
while a sheet is open (tapping another tab swaps sheets directly),
so the rest of the screen must not be marked inert to assistive
tech the way a true modal dialog would be. */}
<div className="mobile-sheet" role="dialog" aria-label={title}>
<div className="mobile-sheet-handle-row"> <div className="mobile-sheet-handle-row">
<span className="mobile-sheet-handle" aria-hidden="true" /> <span className="mobile-sheet-handle" aria-hidden="true" />
</div> </div>
@@ -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();
});
});
+21 -16
View File
@@ -1,14 +1,13 @@
import React, { useState } from 'react'; import React from 'react';
import { BottomSheet } from './BottomSheet'; import { BottomSheet } from './BottomSheet';
import { BlocksPanel } from '../left/BlocksPanel'; import { BlocksPanel } from '../left/BlocksPanel';
import { PagesPanel } from '../left/PagesPanel'; import { PagesPanel } from '../left/PagesPanel';
import { LayersPanel } from '../left/LayersPanel'; import { LayersPanel } from '../left/LayersPanel';
import { AssetsPanel } from '../left/AssetsPanel'; import { AssetsPanel } from '../left/AssetsPanel';
import { GuidedStyles } from '../right/GuidedStyles'; import { GuidedStyles } from '../right/GuidedStyles';
import { useMobileChrome, MobileSheetTab } from '../../state/MobileChromeContext';
type MobileTab = 'blocks' | 'pages' | 'layers' | 'assets' | 'styles'; const TABS: { id: MobileSheetTab; label: string; icon: string }[] = [
const TABS: { id: MobileTab; label: string; icon: string }[] = [
{ id: 'blocks', label: 'Blocks', icon: 'fa-cube' }, { id: 'blocks', label: 'Blocks', icon: 'fa-cube' },
{ id: 'pages', label: 'Pages', icon: 'fa-file-o' }, { id: 'pages', label: 'Pages', icon: 'fa-file-o' },
{ id: 'layers', label: 'Layers', icon: 'fa-sitemap' }, { id: 'layers', label: 'Layers', icon: 'fa-sitemap' },
@@ -30,24 +29,30 @@ const TABS: { id: MobileTab; label: string; icon: string }[] = [
* per-type style panel once something is -- this is the natural place to * per-type style panel once something is -- this is the natural place to
* edit a tapped element's styling (Phase B deepens the tap-to-select flow; * edit a tapped element's styling (Phase B deepens the tap-to-select flow;
* this component only wires up the sheet, per the Phase A brief). * this component only wires up the sheet, per the Phase A brief).
*
* Sheet-open state itself lives in `MobileChromeContext` (Mobile-A2), not
* a private `useState`, so Phase B can open/close a sheet from elsewhere
* (e.g. a canvas selection toolbar). The toggle-to-close-on-repeat-tap
* behavior is local to this click handler -- `openSheet` always opens.
*/ */
export const MobilePanelBar: React.FC = () => { export const MobilePanelBar: React.FC = () => {
const [activeTab, setActiveTab] = useState<MobileTab | null>(null); const { activeSheet, openSheet, closeSheet } = useMobileChrome();
const handleTabClick = (tab: MobileTab) => { const handleTabClick = (tab: MobileSheetTab) => {
setActiveTab((current) => (current === tab ? null : tab)); if (activeSheet === tab) closeSheet();
else openSheet(tab);
}; };
const activeLabel = TABS.find((t) => t.id === activeTab)?.label ?? ''; const activeLabel = TABS.find((t) => t.id === activeSheet)?.label ?? '';
return ( return (
<> <>
<BottomSheet open={activeTab !== null} onClose={() => setActiveTab(null)} title={activeLabel}> <BottomSheet open={activeSheet !== null} onClose={closeSheet} title={activeLabel}>
{activeTab === 'blocks' && <BlocksPanel />} {activeSheet === 'blocks' && <BlocksPanel />}
{activeTab === 'pages' && <PagesPanel />} {activeSheet === 'pages' && <PagesPanel />}
{activeTab === 'layers' && <LayersPanel />} {activeSheet === 'layers' && <LayersPanel />}
{activeTab === 'assets' && <AssetsPanel />} {activeSheet === 'assets' && <AssetsPanel />}
{activeTab === 'styles' && <GuidedStyles />} {activeSheet === 'styles' && <GuidedStyles />}
</BottomSheet> </BottomSheet>
<nav className="mobile-tab-bar" aria-label="Editor panels"> <nav className="mobile-tab-bar" aria-label="Editor panels">
@@ -55,9 +60,9 @@ export const MobilePanelBar: React.FC = () => {
<button <button
key={tab.id} key={tab.id}
type="button" type="button"
className={`mobile-tab-btn${activeTab === tab.id ? ' active' : ''}`} className={`mobile-tab-btn${activeSheet === tab.id ? ' active' : ''}`}
onClick={() => handleTabClick(tab.id)} onClick={() => handleTabClick(tab.id)}
aria-pressed={activeTab === tab.id} aria-pressed={activeSheet === tab.id}
> >
<i className={`fa ${tab.icon}`} aria-hidden="true" /> <i className={`fa ${tab.icon}`} aria-hidden="true" />
<span>{tab.label}</span> <span>{tab.label}</span>
+1
View File
@@ -10,6 +10,7 @@ export const ChatInput: React.FC<Props> = ({ disabled, placeholder, onSend }) =>
<div style={{ display: 'flex', gap: 8, padding: '8px 0' }}> <div style={{ display: 'flex', gap: 8, padding: '8px 0' }}>
<textarea value={v} onChange={(e) => setV(e.target.value)} onKeyDown={onKey} rows={2} disabled={disabled} <textarea value={v} onChange={(e) => setV(e.target.value)} onKeyDown={onKey} rows={2} disabled={disabled}
placeholder={placeholder || 'Describe what you want...'} placeholder={placeholder || 'Describe what you want...'}
className="sitesmith-textarea"
style={{ style={{
flex: 1, background: disabled ? '#1f1f24' : '#0f0f17', color: '#e5e7eb', flex: 1, background: disabled ? '#1f1f24' : '#0f0f17', color: '#e5e7eb',
border: '1px solid #3f3f46', borderRadius: 6, padding: 10, fontSize: 14, resize: 'none', border: '1px solid #3f3f46', borderRadius: 6, padding: 10, fontSize: 14, resize: 'none',
@@ -98,7 +98,7 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
onClose={onClose} onClose={onClose}
closeOnEscape={false} closeOnEscape={false}
closeOnBackdropClick={false} closeOnBackdropClick={false}
backdropStyle={{ backgroundColor: 'rgba(0,0,0,0.8)', zIndex: 9000 }} backdropStyle={{ backgroundColor: 'rgba(0,0,0,0.8)' }}
backdropProps={{ role: 'dialog', 'aria-modal': true }} backdropProps={{ role: 'dialog', 'aria-modal': true }}
> >
<div style={panel}> <div style={panel}>
+11 -2
View File
@@ -1,4 +1,5 @@
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { usePages } from '../../state/PageContext'; import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext'; import { useSiteDesign } from '../../state/SiteDesignContext';
@@ -238,7 +239,14 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
} }
}, [confirmTemplate, applyDesign, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, wait, onClose]); }, [confirmTemplate, applyDesign, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, wait, onClose]);
return ( // Mobile-A2: portaled to `document.body` -- TopBar.tsx mounts this modal
// as a child of `.topbar`, which (as a flex item with its own z-index)
// forms its own stacking context. That trapped the modal's fixed-position
// backdrop underneath the mobile tab bar (z-index: var(--z-tabbar)) no
// matter how high the modal's own z-index was set. Portaling escapes that
// stacking context entirely so `--z-modal` (see editor.css) is evaluated
// at the document root, same as SitesmithModal.
return createPortal(
<Modal open={open} onClose={handleModalClose}> <Modal open={open} onClose={handleModalClose}>
<div style={modalStyle}> <div style={modalStyle}>
{/* Header */} {/* Header */}
@@ -381,7 +389,8 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
</div> </div>
)} )}
</div> </div>
</Modal> </Modal>,
document.body,
); );
}; };
+6 -4
View File
@@ -5,6 +5,7 @@ import { useWhpApi } from '../../hooks/useWhpApi';
import { usePages } from '../../state/PageContext'; import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext'; import { useSiteDesign } from '../../state/SiteDesignContext';
import { useIsMobile } from '../../hooks/useIsMobile'; import { useIsMobile } from '../../hooks/useIsMobile';
import { useMobileChrome } from '../../state/MobileChromeContext';
import { DeviceMode } from '../../types'; import { DeviceMode } from '../../types';
import { TemplateModal } from './TemplateModal'; import { TemplateModal } from './TemplateModal';
import { HeadCodeModal } from './HeadCodeModal'; import { HeadCodeModal } from './HeadCodeModal';
@@ -33,9 +34,10 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle'); const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle');
const [isDraft, setIsDraft] = useState(false); const [isDraft, setIsDraft] = useState(false);
const [templateModalOpen, setTemplateModalOpen] = useState(false); // Mobile-A2: lifted from private useState into MobileChromeContext so
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false); // opening a mobile sheet can close these modals (item 3) -- behavior is
const [overflowOpen, setOverflowOpen] = useState(false); // otherwise identical for both the desktop and mobile branches below.
const { templateModalOpen, setTemplateModalOpen, headCodeModalOpen, setHeadCodeModalOpen, overflowOpen, setOverflowOpen } = useMobileChrome();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { open: openSitesmith } = useSitesmithModal(); const { open: openSitesmith } = useSitesmithModal();
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -247,7 +249,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
<button <button
type="button" type="button"
className={`topbar-btn icon-only overflow-toggle${overflowOpen ? ' active' : ''}`} className={`topbar-btn icon-only overflow-toggle${overflowOpen ? ' active' : ''}`}
onClick={() => setOverflowOpen((v) => !v)} onClick={() => setOverflowOpen(!overflowOpen)}
aria-label="More options" aria-label="More options"
aria-expanded={overflowOpen} aria-expanded={overflowOpen}
aria-haspopup="menu" aria-haspopup="menu"
@@ -0,0 +1,152 @@
import { describe, test, expect } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { MobileChromeProvider, useMobileChrome, MobileSheetTab } from './MobileChromeContext';
import { SitesmithProvider, useSitesmithModal } from './SitesmithContext';
/**
* Mobile-A2 (review item 5): covers the two invariants item 3 depends on --
* (1) only one sheet is ever open ("tab-bar-always-wins", Phase A's existing
* behavior, now backed by the shared context instead of a private
* useState), and (2) opening a sheet closes any open full-screen modal
* (Templates / Head Code / Sitesmith), so a sheet is never shown stacked
* underneath one.
*
* DOM-harness pattern mirrors PageContext.pure-updaters.test.tsx: bare
* createRoot + act, a Consumer component that stashes the hook result on an
* outer variable so assertions can run outside of render.
*/
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();
}
let chrome: ReturnType<typeof useMobileChrome> | null = null;
let sitesmith: ReturnType<typeof useSitesmithModal> | null = null;
const Consumer: React.FC = () => {
chrome = useMobileChrome();
sitesmith = useSitesmithModal();
return null;
};
function renderProviders() {
render(
<SitesmithProvider>
<MobileChromeProvider>
<Consumer />
</MobileChromeProvider>
</SitesmithProvider>,
);
}
describe('MobileChromeContext', () => {
test('only one sheet is ever open at a time', () => {
renderProviders();
act(() => chrome!.openSheet('blocks'));
expect(chrome!.activeSheet).toBe('blocks');
// Opening a different sheet replaces the previous one -- never both.
act(() => chrome!.openSheet('styles'));
expect(chrome!.activeSheet).toBe('styles');
act(() => chrome!.closeSheet());
expect(chrome!.activeSheet).toBeNull();
unmount();
});
test('openSheet is idempotent-safe: opening the same tab twice stays open (caller owns toggle-to-close)', () => {
renderProviders();
act(() => chrome!.openSheet('pages'));
expect(chrome!.activeSheet).toBe('pages');
act(() => chrome!.openSheet('pages'));
expect(chrome!.activeSheet).toBe('pages');
unmount();
});
test('openSheet closes an open Templates modal', () => {
renderProviders();
act(() => chrome!.setTemplateModalOpen(true));
expect(chrome!.templateModalOpen).toBe(true);
act(() => chrome!.openSheet('blocks'));
expect(chrome!.templateModalOpen).toBe(false);
expect(chrome!.activeSheet).toBe('blocks');
unmount();
});
test('openSheet closes an open Head Code modal', () => {
renderProviders();
act(() => chrome!.setHeadCodeModalOpen(true));
expect(chrome!.headCodeModalOpen).toBe(true);
act(() => chrome!.openSheet('assets'));
expect(chrome!.headCodeModalOpen).toBe(false);
expect(chrome!.activeSheet).toBe('assets');
unmount();
});
test('openSheet closes an open Sitesmith modal', () => {
renderProviders();
act(() => sitesmith!.open());
expect(sitesmith!.isOpen).toBe(true);
act(() => chrome!.openSheet('layers'));
// sitesmith is a stale snapshot from before the re-render the `act`
// above triggered; re-read via the live ref after settling.
expect(sitesmith!.isOpen).toBe(false);
expect(chrome!.activeSheet).toBe('layers');
unmount();
});
test('closeSheet does not disturb modal state', () => {
renderProviders();
act(() => chrome!.openSheet('styles'));
act(() => chrome!.closeSheet());
expect(chrome!.activeSheet).toBeNull();
expect(chrome!.templateModalOpen).toBe(false);
expect(chrome!.headCodeModalOpen).toBe(false);
unmount();
});
test('sheet tabs are exactly the five mobile panels', () => {
const tabs: MobileSheetTab[] = ['blocks', 'pages', 'layers', 'assets', 'styles'];
renderProviders();
tabs.forEach((tab) => {
act(() => chrome!.openSheet(tab));
expect(chrome!.activeSheet).toBe(tab);
});
unmount();
});
});
+104
View File
@@ -0,0 +1,104 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { useSitesmithModal } from './SitesmithContext';
/** Mobile bottom-sheet tabs (Phase A: Blocks/Pages/Layers/Assets/Styles).
* Exported so Phase B can open a specific sheet from outside
* `MobilePanelBar` -- e.g. the selection toolbar's "Edit styles" action
* opening 'styles', or tap-to-add closing 'blocks' after inserting a
* block on the canvas. */
export type MobileSheetTab = 'blocks' | 'pages' | 'layers' | 'assets' | 'styles';
interface MobileChromeContextValue {
/** Which bottom sheet (if any) is open. Only one at a time -- this is a
* single value by construction, not a set, so "one sheet open" is an
* invariant of the type, not something callers have to maintain. */
activeSheet: MobileSheetTab | null;
/** Opens `tab`'s sheet (replacing whatever sheet, if any, was open).
* Per item 3 (no accidental overlay stacking), this also closes any
* open full-screen modal -- Templates, Head Code, Sitesmith -- so a
* sheet is never shown stacked underneath one. Toggle-to-close is the
* CALLER's job (see MobilePanelBar's tab click handler): this function
* always opens, so Phase B can call it unconditionally. */
openSheet: (tab: MobileSheetTab) => void;
/** Closes the open sheet, if any. No-op otherwise. */
closeSheet: () => void;
/** Mobile topbar "..." overflow menu open state -- lifted here too
* ("+ the overflow-menu open state if convenient" per the brief) so
* Phase B can close it alongside sheets/modals from one place. */
overflowOpen: boolean;
setOverflowOpen: (open: boolean) => void;
/** Templates / Head Code modal open state, lifted out of TopBar so
* `openSheet` can close them directly (see above). Behavior is
* otherwise identical to the private useState these replaced. */
templateModalOpen: boolean;
setTemplateModalOpen: (open: boolean) => void;
headCodeModalOpen: boolean;
setHeadCodeModalOpen: (open: boolean) => void;
}
const noop = () => {};
/** Default value mirrors the other state contexts in this codebase
* (EditorConfigContext, PageContext, SiteDesignContext): a harmless no-op
* stub rather than throwing, so a consumer rendered outside the provider
* (e.g. in a narrower test) degrades quietly instead of crashing. */
const MobileChromeContext = createContext<MobileChromeContextValue>({
activeSheet: null,
openSheet: noop,
closeSheet: noop,
overflowOpen: false,
setOverflowOpen: noop,
templateModalOpen: false,
setTemplateModalOpen: noop,
headCodeModalOpen: false,
setHeadCodeModalOpen: noop,
});
export const useMobileChrome = () => useContext(MobileChromeContext);
/**
* Shared mobile editor-chrome state (Phase A hardening / Phase B prep).
* Previously `activeTab`/sheet-open state lived as a private `useState`
* inside `MobilePanelBar`, which meant only a tap on the tab bar itself
* could open or close a sheet. Phase B needs to drive this from elsewhere
* (a canvas selection toolbar, tap-to-add, etc.), so it's lifted into this
* context, provided once in `EditorShell.tsx` around the whole mobile+
* desktop tree (harmless on desktop -- nothing there consumes it except
* `TopBar`'s Templates/Head Code modal state, which behaves identically to
* the private `useState` it replaced).
*/
export const MobileChromeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [activeSheet, setActiveSheet] = useState<MobileSheetTab | null>(null);
const [overflowOpen, setOverflowOpen] = useState(false);
const [templateModalOpen, setTemplateModalOpen] = useState(false);
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
const { close: closeSitesmith } = useSitesmithModal();
const openSheet = useCallback((tab: MobileSheetTab) => {
setActiveSheet(tab);
// Item 3: a sheet and a full-screen modal must never stack -- opening
// a sheet always wins over whatever modal was open.
setTemplateModalOpen(false);
setHeadCodeModalOpen(false);
closeSitesmith();
}, [closeSitesmith]);
const closeSheet = useCallback(() => setActiveSheet(null), []);
const value = useMemo<MobileChromeContextValue>(() => ({
activeSheet,
openSheet,
closeSheet,
overflowOpen,
setOverflowOpen,
templateModalOpen,
setTemplateModalOpen,
headCodeModalOpen,
setHeadCodeModalOpen,
}), [activeSheet, openSheet, closeSheet, overflowOpen, templateModalOpen, headCodeModalOpen]);
return <MobileChromeContext.Provider value={value}>{children}</MobileChromeContext.Provider>;
};
+48 -5
View File
@@ -34,6 +34,24 @@
/* Phase A (mobile): fixed bottom tab bar height, used to size /* Phase A (mobile): fixed bottom tab bar height, used to size
.editor-container and the safe-area padding math below. */ .editor-container and the safe-area padding math below. */
--mobile-tabbar-height: 58px; --mobile-tabbar-height: 58px;
/* Mobile-A2: explicit overlay layer scale (was ad-hoc numbers scattered
across the mobile block + Modal.tsx, causing accidental stacking --
e.g. the Templates modal getting trapped under the tab bar inside
.topbar's own stacking context). Higher = closer to the user.
--z-selection-toolbar is reserved for Phase B's selection toolbar. */
--z-sheet-backdrop: 400;
--z-tabbar: 500;
--z-selection-toolbar: 600;
--z-modal: 5000;
--z-overflow: 10000;
}
/* --------------------------------------------------------------------------
Modals (shared by Modal.tsx -- TemplateModal, HeadCodeModal, SitesmithModal)
-------------------------------------------------------------------------- */
.modal-backdrop {
z-index: var(--z-modal);
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
@@ -1459,6 +1477,23 @@ body {
border-color: var(--color-accent); border-color: var(--color-accent);
} }
/* --------------------------------------------------------------------
Full-screen modals (Modal.tsx -- TemplateModal/HeadCodeModal/
SitesmithModal share `.modal-backdrop`). Same tab-bar cutout as
`.mobile-sheet-backdrop` below, and for the same reason: without it,
the backdrop's default `inset: 0` sits ABOVE the tab bar (--z-modal >
--z-tabbar) and swallows every tap there, so "open Templates -> tap a
bottom tab" would silently just close Templates (a tap on the
backdrop) instead of ALSO opening the tapped sheet. `!important`
because Modal.tsx sets `bottom` via an inline style, which otherwise
beats this class rule. The modal's own content still sits above the
tab bar (it's a commit/cancel moment) -- only the dismiss-affordance
backdrop stops short, exactly like the sheet backdrop.
-------------------------------------------------------------------- */
.modal-backdrop {
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px)) !important;
}
/* -------------------------------------------------------------------- /* --------------------------------------------------------------------
"⋯" overflow menu (TopBarOverflowMenu.tsx) "⋯" overflow menu (TopBarOverflowMenu.tsx)
-------------------------------------------------------------------- */ -------------------------------------------------------------------- */
@@ -1466,7 +1501,7 @@ body {
position: fixed; position: fixed;
top: calc(var(--topbar-height) + env(safe-area-inset-top, 0px) + 6px); top: calc(var(--topbar-height) + env(safe-area-inset-top, 0px) + 6px);
right: calc(8px + env(safe-area-inset-right, 0px)); right: calc(8px + env(safe-area-inset-right, 0px));
z-index: 10000; z-index: var(--z-overflow);
min-width: 220px; min-width: 220px;
max-width: calc(100vw - 16px); max-width: calc(100vw - 16px);
max-height: calc(100dvh - var(--topbar-height) - 24px); max-height: calc(100dvh - var(--topbar-height) - 24px);
@@ -1557,7 +1592,7 @@ body {
directly, instead of requiring a close-then-reopen round trip. The directly, instead of requiring a close-then-reopen round trip. The
backdrop itself stops short of the tab bar (see its `bottom` offset backdrop itself stops short of the tab bar (see its `bottom` offset
below) so this is mostly belt-and-suspenders for the boundary. */ below) so this is mostly belt-and-suspenders for the boundary. */
z-index: 500; z-index: var(--z-tabbar);
display: flex; display: flex;
height: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px)); height: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px));
background: var(--color-bg-surface); background: var(--color-bg-surface);
@@ -1605,7 +1640,7 @@ body {
being swallowed by the overlay sitting on top of it. */ being swallowed by the overlay sitting on top of it. */
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px)); bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px));
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
z-index: 400; z-index: var(--z-sheet-backdrop);
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
} }
@@ -1679,6 +1714,13 @@ body {
Touch ergonomics inside sheets -- 16px inputs kill iOS's auto-zoom on Touch ergonomics inside sheets -- 16px inputs kill iOS's auto-zoom on
focus; touch-action speeds up tap response on chrome that doesn't need focus; touch-action speeds up tap response on chrome that doesn't need
native scroll-gesture handling. native scroll-gesture handling.
`!important` is required here: GuidedStyles/StylePanel inputs (see
`inputStyle` in `panels/right/styles/shared.tsx`) carry an INLINE
`font-size: 12px`, which otherwise beats this class rule on the
Styles sheet -- the most-used mobile surface -- and iOS zooms in on
focus. `.sitesmith-textarea` (ChatInput.tsx, inline font-size: 14)
has the same problem even though the Sitesmith modal isn't a sheet.
-------------------------------------------------------------------- */ -------------------------------------------------------------------- */
.mobile-sheet-body button, .mobile-sheet-body button,
.mobile-sheet-body .block-item { .mobile-sheet-body .block-item {
@@ -1693,8 +1735,9 @@ body {
.control-select, .control-select,
.topbar input, .topbar input,
.topbar textarea, .topbar textarea,
.topbar select { .topbar select,
font-size: 16px; .sitesmith-textarea {
font-size: 16px !important;
} }
.mobile-sheet-body .settings-tabs button { .mobile-sheet-body .settings-tabs button {
+5 -1
View File
@@ -31,7 +31,10 @@ const defaultBackdropStyle: React.CSSProperties = {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
zIndex: 10000, /* z-index comes from the `.modal-backdrop` class (--z-modal in the
shared layer scale, editor.css) rather than an inline value, so every
Modal consumer -- including ones that pass a custom `backdropStyle`
without a zIndex, like SitesmithModal -- lands on the same layer. */
}; };
/* ---------- Shared modal chrome ---------- /* ---------- Shared modal chrome ----------
@@ -77,6 +80,7 @@ export const Modal: React.FC<ModalProps> = ({
return ( return (
<div <div
className="modal-backdrop"
{...backdropProps} {...backdropProps}
style={backdropStyle ? { ...defaultBackdropStyle, ...backdropStyle } : defaultBackdropStyle} style={backdropStyle ? { ...defaultBackdropStyle, ...backdropStyle } : defaultBackdropStyle}
onClick={(e) => { onClick={(e) => {