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
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
/** Mobile breakpoint for the editor chrome (Phase A). Keep in sync with the
* `@media (max-width: 768px)` block in `src/styles/editor.css` -- both the
* CSS and this hook must agree on where mobile chrome kicks in. */
export const MOBILE_BREAKPOINT_PX = 768;
const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT_PX}px)`;
function computeIsMobile(): boolean {
if (typeof window === 'undefined') return false;
if (typeof window.matchMedia === 'function') {
try {
return window.matchMedia(MOBILE_QUERY).matches;
} catch {
// Fall through to the width check below (e.g. some older/embedded
// WebViews expose a matchMedia that throws instead of omitting it).
}
}
return window.innerWidth <= MOBILE_BREAKPOINT_PX;
}
/**
* Reports whether the viewport is at or below the mobile editor breakpoint.
* Drives every mobile-only branch introduced in Phase A (bottom tab bar,
* collapsed topbar, bottom sheets) -- desktop rendering must stay identical
* above the breakpoint, so this is the single source of truth both React
* and (via the matching CSS media query) plain CSS use to agree on "mobile".
*
* Falls back to a `resize` listener + `window.innerWidth` when
* `matchMedia` isn't available (e.g. some test/JSDOM environments), so the
* hook degrades gracefully rather than throwing.
*/
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState<boolean>(computeIsMobile);
useEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window.matchMedia === 'function') {
let mql: MediaQueryList | null = null;
try {
mql = window.matchMedia(MOBILE_QUERY);
} catch {
mql = null;
}
if (mql) {
const handleChange = () => setIsMobile(mql!.matches);
handleChange();
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', handleChange);
return () => mql!.removeEventListener('change', handleChange);
}
// Safari < 14 only supports the deprecated addListener/removeListener pair.
const legacyMql = mql as MediaQueryList & {
addListener?: (listener: () => void) => void;
removeListener?: (listener: () => void) => void;
};
legacyMql.addListener?.(handleChange);
return () => legacyMql.removeListener?.(handleChange);
}
}
const handleResize = () => setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT_PX);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return isMobile;
}