diff --git a/craft/index.html b/craft/index.html index a0bd55e..d57a881 100644 --- a/craft/index.html +++ b/craft/index.html @@ -2,7 +2,7 @@ - + Site Builder diff --git a/craft/src/editor/Canvas.tsx b/craft/src/editor/Canvas.tsx index ba3eab2..e50ec05 100644 --- a/craft/src/editor/Canvas.tsx +++ b/craft/src/editor/Canvas.tsx @@ -5,6 +5,7 @@ import { usePages } from '../state/PageContext'; import { DeviceMode } from '../types'; import { DEVICE_WIDTHS } from '../constants/presets'; import { exportBodyHtml } from '../utils/html-export'; +import { useIsMobile } from '../hooks/useIsMobile'; interface CanvasProps { device: DeviceMode; @@ -112,13 +113,18 @@ export const EmptyCanvasHint: React.FC = () => { isDragging: state.events.dragged.size > 0, }; }); + const isMobile = useIsMobile(); if (!isEmpty || isDragging) return null; return (
- Drag blocks from the left panel, or pick a Template to start. + + {isMobile + ? <>Tap Blocks below to add content, or pick a Template to start. + : 'Drag blocks from the left panel, or pick a Template to start.'} +
); }; diff --git a/craft/src/editor/EditorShell.tsx b/craft/src/editor/EditorShell.tsx index fb45fb6..59b2866 100644 --- a/craft/src/editor/EditorShell.tsx +++ b/craft/src/editor/EditorShell.tsx @@ -1,12 +1,15 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; import { useEditor } from '@craftjs/core'; import { TopBar } from '../panels/topbar/TopBar'; import { LeftPanel } from '../panels/left/LeftPanel'; import { RightPanel } from '../panels/right/RightPanel'; +import { MobilePanelBar } from '../panels/mobile/MobilePanelBar'; import { Canvas } from './Canvas'; import { ContextMenu } from '../panels/context-menu/ContextMenu'; import { useContextMenu } from '../hooks/useContextMenu'; import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; +import { useIsMobile } from '../hooks/useIsMobile'; +import { MobileChromeProvider } from '../state/MobileChromeContext'; import { DeviceMode } from '../types'; const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides'; @@ -21,7 +24,21 @@ function loadShowGuides(): boolean { } export const EditorShell: React.FC = () => { + const isMobile = useIsMobile(); const [device, setDevice] = useState('desktop'); + // Phase A: default the canvas to the "mobile" preview width the first + // time we detect a mobile viewport, so the frame fits without the user + // having to reach for the device switcher (now tucked in TopBar's + // overflow menu on mobile). Only fires once, and only if the device is + // still at its initial default -- it must not fight a device the user + // has already picked (e.g. from the overflow menu) on a later re-render. + const mobileDeviceAppliedRef = useRef(false); + useEffect(() => { + if (isMobile && !mobileDeviceAppliedRef.current) { + mobileDeviceAppliedRef.current = true; + setDevice((current) => (current === 'desktop' ? 'mobile' : current)); + } + }, [isMobile]); // Item 10: canvas dashed "guide" outlines toggle -- default ON, persisted // so the choice survives a reload. Lifted here (rather than owned by // TopBar or Canvas alone) because the toggle button lives in TopBar but @@ -59,27 +76,35 @@ export const EditorShell: React.FC = () => { }, [query, showMenu]); return ( -
- setShowGuides(!showGuides)} - /> -
- -
- + // Mobile-A2: shared sheet/modal-open state (see MobileChromeContext) -- + // provided around the whole shell so TopBar's Templates/Head Code modal + // state and MobilePanelBar's sheet state live in one place. Desktop + // doesn't render MobilePanelBar and TopBar's desktop branch behaves + // identically to before (same booleans, just sourced from context). + +
+ setShowGuides(!showGuides)} + /> +
+ {!isMobile && } +
+ +
+ {!isMobile && }
- + {isMobile && } +
- -
+ ); }; diff --git a/craft/src/hooks/useIsMobile.test.tsx b/craft/src/hooks/useIsMobile.test.tsx new file mode 100644 index 0000000..ad27519 --- /dev/null +++ b/craft/src/hooks/useIsMobile.test.tsx @@ -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 ; +}; + +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(); + 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(); + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/craft/src/hooks/useIsMobile.ts b/craft/src/hooks/useIsMobile.ts new file mode 100644 index 0000000..afaaa16 --- /dev/null +++ b/craft/src/hooks/useIsMobile.ts @@ -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(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; +} diff --git a/craft/src/panels/left/AssetsPanel.tsx b/craft/src/panels/left/AssetsPanel.tsx index 034b2ac..26572c9 100644 --- a/craft/src/panels/left/AssetsPanel.tsx +++ b/craft/src/panels/left/AssetsPanel.tsx @@ -2,12 +2,14 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; import { useAssets } from '../../hooks/useAssets'; import { clickableProps } from '../../utils/a11y'; import { copyToClipboard } from '../../utils/clipboard'; +import { useIsMobile } from '../../hooks/useIsMobile'; /** How long the "Delete?" confirm state stays armed before auto-resetting. */ const DELETE_CONFIRM_TIMEOUT_MS = 4000; export const AssetsPanel: React.FC = () => { const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets(); + const isMobile = useIsMobile(); const fileInputRef = useRef(null); const [isDragOver, setIsDragOver] = useState(false); const [copiedUrl, setCopiedUrl] = useState(null); @@ -139,7 +141,9 @@ export const AssetsPanel: React.FC = () => { {assets.length === 0 && ( )} - {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'}
{/* Error message */} diff --git a/craft/src/panels/mobile/BottomSheet.tsx b/craft/src/panels/mobile/BottomSheet.tsx new file mode 100644 index 0000000..aed4f59 --- /dev/null +++ b/craft/src/panels/mobile/BottomSheet.tsx @@ -0,0 +1,70 @@ +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 = ({ 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 ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > + {/* 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. */} +
+
+
+
+ {title} +