feat(builder): mobile-responsive editor chrome (Phase A)

Makes the Craft.js editor usable on phones (≤768px) without touching desktop
layout/behavior: a useIsMobile() hook gates a bottom tab bar + sheets (hosting
the existing Blocks/Pages/Layers/Assets/Styles panels unchanged) in place of
the side panels, a collapsed TopBar with a "..." overflow menu, 44px touch
targets, 16px inputs, dvh/safe-area-aware sizing, and small copy/overflow
fixes (empty-canvas hint, Templates modal tabs + close button).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 06:54:15 -07:00
parent 329a782052
commit 979331b12d
10 changed files with 852 additions and 64 deletions
+66
View File
@@ -0,0 +1,66 @@
import React, { useEffect } from 'react';
export interface BottomSheetProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
/**
* Mobile-only bottom sheet (Phase A). Slides up from the bottom of the
* viewport to host one of the existing side-panel components
* (BlocksPanel/PagesPanel/LayersPanel/AssetsPanel/GuidedStyles) unchanged,
* as the mobile replacement for the desktop fixed left/right panel columns.
* See `MobilePanelBar`, which owns which sheet (if any) is open.
*
* Deliberately NOT a generic replacement for `src/ui/Modal.tsx` (centered
* dialog chrome) -- this is anchored to the bottom, sized to ~65dvh, and
* has its own drag-handle affordance + internally scrollable body, all of
* which Modal doesn't need for its centered use cases.
*/
export const BottomSheet: React.FC<BottomSheetProps> = ({ open, onClose, title, children }) => {
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = prevOverflow;
};
}, [open, onClose]);
if (!open) return null;
return (
<div
className="mobile-sheet-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="mobile-sheet" role="dialog" aria-modal="true" aria-label={title}>
<div className="mobile-sheet-handle-row">
<span className="mobile-sheet-handle" aria-hidden="true" />
</div>
<div className="mobile-sheet-header">
<span className="mobile-sheet-title">{title}</span>
<button
type="button"
className="mobile-sheet-close"
onClick={onClose}
aria-label={`Close ${title}`}
>
<i className="fa fa-times" aria-hidden="true" />
</button>
</div>
<div className="mobile-sheet-body">{children}</div>
</div>
</div>
);
};
@@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { BottomSheet } from './BottomSheet';
import { BlocksPanel } from '../left/BlocksPanel';
import { PagesPanel } from '../left/PagesPanel';
import { LayersPanel } from '../left/LayersPanel';
import { AssetsPanel } from '../left/AssetsPanel';
import { GuidedStyles } from '../right/GuidedStyles';
type MobileTab = 'blocks' | 'pages' | 'layers' | 'assets' | 'styles';
const TABS: { id: MobileTab; label: string; icon: string }[] = [
{ id: 'blocks', label: 'Blocks', icon: 'fa-cube' },
{ id: 'pages', label: 'Pages', icon: 'fa-file-o' },
{ id: 'layers', label: 'Layers', icon: 'fa-sitemap' },
{ id: 'assets', label: 'Assets', icon: 'fa-image' },
{ id: 'styles', label: 'Styles', icon: 'fa-paint-brush' },
];
/**
* Mobile replacement for the desktop LeftPanel/RightPanel column layout
* (Phase A). A fixed bottom tab bar (Blocks/Pages/Layers/Assets/Styles)
* toggles a single `BottomSheet` at a time, hosting the SAME panel
* component the desktop side panels use -- unchanged, so behavior/state
* stays identical, only the chrome around it differs. Tapping the active
* tab again, tapping the backdrop, the sheet's close button, or Escape all
* close the open sheet.
*
* "Styles" hosts `GuidedStyles`, which already falls back to the
* site-design panel when nothing is selected and switches to the
* 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;
* this component only wires up the sheet, per the Phase A brief).
*/
export const MobilePanelBar: React.FC = () => {
const [activeTab, setActiveTab] = useState<MobileTab | null>(null);
const handleTabClick = (tab: MobileTab) => {
setActiveTab((current) => (current === tab ? null : tab));
};
const activeLabel = TABS.find((t) => t.id === activeTab)?.label ?? '';
return (
<>
<BottomSheet open={activeTab !== null} onClose={() => setActiveTab(null)} title={activeLabel}>
{activeTab === 'blocks' && <BlocksPanel />}
{activeTab === 'pages' && <PagesPanel />}
{activeTab === 'layers' && <LayersPanel />}
{activeTab === 'assets' && <AssetsPanel />}
{activeTab === 'styles' && <GuidedStyles />}
</BottomSheet>
<nav className="mobile-tab-bar" aria-label="Editor panels">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
className={`mobile-tab-btn${activeTab === tab.id ? ' active' : ''}`}
onClick={() => handleTabClick(tab.id)}
aria-pressed={activeTab === tab.id}
>
<i className={`fa ${tab.icon}`} aria-hidden="true" />
<span>{tab.label}</span>
</button>
))}
</nav>
</>
);
};
+8 -2
View File
@@ -253,8 +253,10 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
</div>
<button
onClick={onClose}
className="template-modal-close-btn"
style={closeButtonStyle}
title="Close"
aria-label="Close"
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text)'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent'; (e.currentTarget as HTMLElement).style.color = '#71717a'; }}
>
@@ -262,11 +264,15 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
</button>
</div>
{/* Filter Tabs */}
<div style={tabBarStyle}>
{/* Filter Tabs -- horizontally scrollable (tabBarStyle sets
overflowX: 'auto') so the row never wraps/overflows the modal at
narrow widths; template-modal-tab-btn below keeps each tab from
shrinking below a comfortable touch target on mobile. */}
<div className="template-modal-tabbar" style={tabBarStyle}>
{TABS.map((tab) => (
<button
key={tab.value}
className="template-modal-tab-btn"
onClick={() => setActiveTab(tab.value)}
style={{
...tabStyle,
+155 -54
View File
@@ -4,9 +4,11 @@ import { useEditorConfig } from '../../state/EditorConfigContext';
import { useWhpApi } from '../../hooks/useWhpApi';
import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { useIsMobile } from '../../hooks/useIsMobile';
import { DeviceMode } from '../../types';
import { TemplateModal } from './TemplateModal';
import { HeadCodeModal } from './HeadCodeModal';
import { TopBarOverflowMenu } from './TopBarOverflowMenu';
import { SitesmithButton } from '../sitesmith/SitesmithButton';
import { useSitesmithModal } from '../../state/SitesmithContext';
@@ -33,6 +35,8 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
const [isDraft, setIsDraft] = useState(false);
const [templateModalOpen, setTemplateModalOpen] = useState(false);
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
const [overflowOpen, setOverflowOpen] = useState(false);
const isMobile = useIsMobile();
const { open: openSitesmith } = useSitesmithModal();
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -122,6 +126,156 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
};
}, []);
// Extracted so both the desktop inline Preview button and the mobile
// overflow-menu "Preview" item can trigger the exact same flow.
const handlePreview = useCallback(() => {
try {
const serialized = query.serialize();
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
// Get header HTML
let headerHtml = '';
try {
if (headerPage.craftState) {
headerHtml = exportBodyHtml(headerPage.craftState).html;
}
} catch (e) { console.warn('Header export failed:', e); }
// Get page body HTML
const bodyResult = exportBodyHtml(serialized);
const bodyHtml = bodyResult.html;
// Get footer HTML
let footerHtml = '';
try {
if (footerPage.craftState) {
footerHtml = exportBodyHtml(footerPage.craftState).html;
}
} catch (e) { console.warn('Footer export failed:', e); }
// Compose full page: header + body + footer
const composedBody = headerHtml + bodyHtml + footerHtml;
const result = exportToHtml(serialized, {
title: whpConfig?.siteName || 'Preview',
includeFonts: true,
headCode: design.headCode,
});
// Replace the body in the full document with our composed version.
// Use a function replacer -- a plain string replacer treats
// `$&`/`$1`/`$$` sequences in user content as replacement
// patterns, silently corrupting the output.
let html = result.html;
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
if (bodyMatch) {
html = html.replace(bodyMatch[1], () => composedBody);
}
// Make proxy URLs absolute so they work from the blob: context
const origin = window.location.origin;
html = html.replace(/src="\/api\//g, `src="${origin}/api/`);
html = html.replace(/url\('\/api\//g, `url('${origin}/api/`);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
});
} catch (e) {
console.error('Preview failed:', e);
}
}, [query, headerPage, footerPage, whpConfig, design]);
// Phase A: at ≤768px the topbar collapses to only the essentials --
// back arrow, Undo/Redo, Save (+Publish) -- with Templates, Sitesmith,
// Preview, Head Code, the guides toggle, and the device switcher moved
// into the "⋯" overflow menu. This keeps desktop's JSX/behavior below
// completely untouched.
if (isMobile) {
return (
<nav className="topbar topbar-mobile">
<div className="topbar-left">
{isWHP && (
<a
href={whpConfig!.backUrl}
className="topbar-btn icon-only back-btn"
aria-label="Back to Panel"
data-tooltip="Back"
>
<i className="fa fa-arrow-left" />
</a>
)}
</div>
<div className="topbar-right">
<button
className="topbar-btn icon-only"
onClick={() => actions.history.undo()}
disabled={!canUndo}
aria-label="Undo"
data-tooltip="Undo"
>
<i className="fa fa-undo" />
</button>
<button
className="topbar-btn icon-only"
onClick={() => actions.history.redo()}
disabled={!canRedo}
aria-label="Redo"
data-tooltip="Redo"
>
<i className="fa fa-repeat" />
</button>
<span className="topbar-divider" />
<button
className="topbar-btn icon-only primary"
onClick={handleSave}
disabled={saveStatus === 'saving'}
aria-label="Save Draft"
data-tooltip="Save"
>
<i className={`fa ${saveStatus === 'saving' ? 'fa-spinner fa-spin' : 'fa-save'}`} />
</button>
{isWHP && (
<button
className="topbar-btn icon-only publish"
onClick={handlePublish}
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
aria-label="Publish to live site"
data-tooltip="Publish"
>
<i className={`fa ${publishStatus === 'publishing' ? 'fa-spinner fa-spin' : 'fa-globe'}`} />
</button>
)}
<button
type="button"
className={`topbar-btn icon-only overflow-toggle${overflowOpen ? ' active' : ''}`}
onClick={() => setOverflowOpen((v) => !v)}
aria-label="More options"
aria-expanded={overflowOpen}
aria-haspopup="menu"
data-tooltip="More"
>
<i className="fa fa-ellipsis-h" />
</button>
</div>
<TopBarOverflowMenu
open={overflowOpen}
onClose={() => setOverflowOpen(false)}
device={device}
onDeviceChange={onDeviceChange}
showGuides={showGuides}
onToggleGuides={onToggleGuides}
onOpenTemplates={() => setTemplateModalOpen(true)}
onOpenHeadCode={() => setHeadCodeModalOpen(true)}
onPreview={handlePreview}
sitesmithNode={<SitesmithButton onClick={() => openSitesmith()} />}
/>
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
</nav>
);
}
return (
<nav className="topbar">
<div className="topbar-left">
@@ -182,60 +336,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
>
<i className="fa fa-code" />
</button>
<button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={() => {
try {
const serialized = query.serialize();
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
// Get header HTML
let headerHtml = '';
try {
if (headerPage.craftState) {
headerHtml = exportBodyHtml(headerPage.craftState).html;
}
} catch (e) { console.warn('Header export failed:', e); }
// Get page body HTML
const bodyResult = exportBodyHtml(serialized);
const bodyHtml = bodyResult.html;
// Get footer HTML
let footerHtml = '';
try {
if (footerPage.craftState) {
footerHtml = exportBodyHtml(footerPage.craftState).html;
}
} catch (e) { console.warn('Footer export failed:', e); }
// Compose full page: header + body + footer
const composedBody = headerHtml + bodyHtml + footerHtml;
const result = exportToHtml(serialized, {
title: whpConfig?.siteName || 'Preview',
includeFonts: true,
headCode: design.headCode,
});
// Replace the body in the full document with our composed version.
// Use a function replacer -- a plain string replacer treats
// `$&`/`$1`/`$$` sequences in user content as replacement
// patterns, silently corrupting the output.
let html = result.html;
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
if (bodyMatch) {
html = html.replace(bodyMatch[1], () => composedBody);
}
// Make proxy URLs absolute so they work from the blob: context
const origin = window.location.origin;
html = html.replace(/src="\/api\//g, `src="${origin}/api/`);
html = html.replace(/url\('\/api\//g, `url('${origin}/api/`);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
});
} catch (e) {
console.error('Preview failed:', e);
}
}}>
<button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={handlePreview}>
<i className="fa fa-eye" />
</button>
@@ -0,0 +1,110 @@
import React, { useEffect, useRef } from 'react';
import { DeviceMode } from '../../types';
export interface TopBarOverflowMenuProps {
open: boolean;
onClose: () => void;
device: DeviceMode;
onDeviceChange: (device: DeviceMode) => void;
showGuides: boolean;
onToggleGuides: () => void;
onOpenTemplates: () => void;
onOpenHeadCode: () => void;
onPreview: () => void;
/** Rendered `<SitesmithButton onClick={...} />` -- passed in rather than
* re-implemented here so the mobile menu reuses the exact same
* lock/cap-aware button the desktop topbar shows. */
sitesmithNode: React.ReactNode;
}
/**
* Mobile topbar "⋯" overflow menu (Phase A). At ≤768px, TopBar collapses to
* just the back arrow, Undo/Redo, Save/Publish, and this menu -- everything
* else that's inline on desktop (Templates, Sitesmith, Preview, Head Code,
* the guides toggle, and the device-width switcher) moves in here so the
* collapsed bar never overlaps or pushes controls off-screen.
*/
export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
open,
onClose,
device,
onDeviceChange,
showGuides,
onToggleGuides,
onOpenTemplates,
onOpenHeadCode,
onPreview,
sitesmithNode,
}) => {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
};
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleEsc);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleEsc);
};
}, [open, onClose]);
if (!open) return null;
const runAndClose = (fn: () => void) => () => {
fn();
onClose();
};
return (
<div ref={menuRef} className="topbar-overflow-menu" role="menu">
<div className="topbar-overflow-section">
<span className="topbar-overflow-label">Preview width</span>
<div className="device-switcher">
{(['desktop', 'tablet', 'mobile'] as DeviceMode[]).map((d) => (
<button
key={d}
type="button"
className={`device-btn ${device === d ? 'active' : ''}`}
onClick={() => onDeviceChange(d)}
title={d.charAt(0).toUpperCase() + d.slice(1)}
aria-label={d.charAt(0).toUpperCase() + d.slice(1)}
aria-pressed={device === d}
>
<i className={`fa ${d === 'desktop' ? 'fa-desktop' : d === 'tablet' ? 'fa-tablet' : 'fa-mobile'}`} />
</button>
))}
</div>
</div>
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenTemplates)}>
<i className="fa fa-th-large" aria-hidden="true" /> Templates
</button>
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onPreview)}>
<i className="fa fa-eye" aria-hidden="true" /> Preview
</button>
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenHeadCode)}>
<i className="fa fa-code" aria-hidden="true" /> Head Code
</button>
<button
type="button"
className={`topbar-overflow-item${showGuides ? ' active' : ''}`}
role="menuitem"
onClick={runAndClose(onToggleGuides)}
aria-pressed={showGuides}
>
<i className={`fa ${showGuides ? 'fa-th' : 'fa-th-large'}`} aria-hidden="true" />
{' '}{showGuides ? 'Hide Guides' : 'Show Guides'}
</button>
<div className="topbar-overflow-sitesmith" onClick={onClose}>
{sitesmithNode}
</div>
</div>
);
};