Mobile Phase A: responsive editor (bottom-sheet panels, collapsed topbar, touch ergonomics) #9
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>Site Builder</title>
|
<title>Site Builder</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { usePages } from '../state/PageContext';
|
|||||||
import { DeviceMode } from '../types';
|
import { DeviceMode } from '../types';
|
||||||
import { DEVICE_WIDTHS } from '../constants/presets';
|
import { DEVICE_WIDTHS } from '../constants/presets';
|
||||||
import { exportBodyHtml } from '../utils/html-export';
|
import { exportBodyHtml } from '../utils/html-export';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
|
||||||
interface CanvasProps {
|
interface CanvasProps {
|
||||||
device: DeviceMode;
|
device: DeviceMode;
|
||||||
@@ -112,13 +113,18 @@ export const EmptyCanvasHint: React.FC = () => {
|
|||||||
isDragging: state.events.dragged.size > 0,
|
isDragging: state.events.dragged.size > 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
if (!isEmpty || isDragging) return null;
|
if (!isEmpty || isDragging) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="empty-canvas-hint">
|
<div className="empty-canvas-hint">
|
||||||
<i className="fa fa-cubes" aria-hidden />
|
<i className="fa fa-cubes" aria-hidden />
|
||||||
<span>Drag blocks from the left panel, or pick a Template to start.</span>
|
<span>
|
||||||
|
{isMobile
|
||||||
|
? <>Tap <strong>Blocks</strong> below to add content, or pick a Template to start.</>
|
||||||
|
: 'Drag blocks from the left panel, or pick a Template to start.'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { TopBar } from '../panels/topbar/TopBar';
|
import { TopBar } from '../panels/topbar/TopBar';
|
||||||
import { LeftPanel } from '../panels/left/LeftPanel';
|
import { LeftPanel } from '../panels/left/LeftPanel';
|
||||||
import { RightPanel } from '../panels/right/RightPanel';
|
import { RightPanel } from '../panels/right/RightPanel';
|
||||||
|
import { MobilePanelBar } from '../panels/mobile/MobilePanelBar';
|
||||||
import { Canvas } from './Canvas';
|
import { Canvas } from './Canvas';
|
||||||
import { ContextMenu } from '../panels/context-menu/ContextMenu';
|
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 { 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';
|
||||||
@@ -21,7 +24,21 @@ function loadShowGuides(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const EditorShell: React.FC = () => {
|
export const EditorShell: React.FC = () => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [device, setDevice] = useState<DeviceMode>('desktop');
|
const [device, setDevice] = useState<DeviceMode>('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
|
// Item 10: canvas dashed "guide" outlines toggle -- default ON, persisted
|
||||||
// so the choice survives a reload. Lifted here (rather than owned by
|
// so the choice survives a reload. Lifted here (rather than owned by
|
||||||
// TopBar or Canvas alone) because the toggle button lives in TopBar but
|
// TopBar or Canvas alone) because the toggle button lives in TopBar but
|
||||||
@@ -59,27 +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
|
||||||
<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>
|
||||||
<RightPanel />
|
{isMobile && <MobilePanelBar />}
|
||||||
|
<ContextMenu
|
||||||
|
visible={menuState.visible}
|
||||||
|
x={menuState.x}
|
||||||
|
y={menuState.y}
|
||||||
|
nodeId={menuState.nodeId}
|
||||||
|
onClose={hideMenu}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ContextMenu
|
</MobileChromeProvider>
|
||||||
visible={menuState.visible}
|
|
||||||
x={menuState.x}
|
|
||||||
y={menuState.y}
|
|
||||||
nodeId={menuState.nodeId}
|
|
||||||
onClose={hideMenu}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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<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();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 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">
|
||||||
|
<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,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import React 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';
|
||||||
|
import { useMobileChrome, MobileSheetTab } from '../../state/MobileChromeContext';
|
||||||
|
|
||||||
|
const TABS: { id: MobileSheetTab; 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).
|
||||||
|
*
|
||||||
|
* 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 = () => {
|
||||||
|
const { activeSheet, openSheet, closeSheet } = useMobileChrome();
|
||||||
|
|
||||||
|
const handleTabClick = (tab: MobileSheetTab) => {
|
||||||
|
if (activeSheet === tab) closeSheet();
|
||||||
|
else openSheet(tab);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeLabel = TABS.find((t) => t.id === activeSheet)?.label ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BottomSheet open={activeSheet !== null} onClose={closeSheet} title={activeLabel}>
|
||||||
|
{activeSheet === 'blocks' && <BlocksPanel />}
|
||||||
|
{activeSheet === 'pages' && <PagesPanel />}
|
||||||
|
{activeSheet === 'layers' && <LayersPanel />}
|
||||||
|
{activeSheet === 'assets' && <AssetsPanel />}
|
||||||
|
{activeSheet === '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${activeSheet === tab.id ? ' active' : ''}`}
|
||||||
|
onClick={() => handleTabClick(tab.id)}
|
||||||
|
aria-pressed={activeSheet === tab.id}
|
||||||
|
>
|
||||||
|
<i className={`fa ${tab.icon}`} aria-hidden="true" />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
@@ -253,8 +261,10 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
className="template-modal-close-btn"
|
||||||
style={closeButtonStyle}
|
style={closeButtonStyle}
|
||||||
title="Close"
|
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)'; }}
|
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'; }}
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent'; (e.currentTarget as HTMLElement).style.color = '#71717a'; }}
|
||||||
>
|
>
|
||||||
@@ -262,11 +272,15 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Tabs */}
|
{/* Filter Tabs -- horizontally scrollable (tabBarStyle sets
|
||||||
<div style={tabBarStyle}>
|
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) => (
|
{TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
|
className="template-modal-tab-btn"
|
||||||
onClick={() => setActiveTab(tab.value)}
|
onClick={() => setActiveTab(tab.value)}
|
||||||
style={{
|
style={{
|
||||||
...tabStyle,
|
...tabStyle,
|
||||||
@@ -375,7 +389,8 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import { useEditorConfig } from '../../state/EditorConfigContext';
|
|||||||
import { useWhpApi } from '../../hooks/useWhpApi';
|
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 { 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';
|
||||||
|
import { TopBarOverflowMenu } from './TopBarOverflowMenu';
|
||||||
import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
||||||
import { useSitesmithModal } from '../../state/SitesmithContext';
|
import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||||
|
|
||||||
@@ -31,8 +34,11 @@ 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
|
||||||
|
// otherwise identical for both the desktop and mobile branches below.
|
||||||
|
const { templateModalOpen, setTemplateModalOpen, headCodeModalOpen, setHeadCodeModalOpen, overflowOpen, setOverflowOpen } = useMobileChrome();
|
||||||
|
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);
|
||||||
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -122,6 +128,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(!overflowOpen)}
|
||||||
|
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 (
|
return (
|
||||||
<nav className="topbar">
|
<nav className="topbar">
|
||||||
<div className="topbar-left">
|
<div className="topbar-left">
|
||||||
@@ -182,60 +338,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
>
|
>
|
||||||
<i className="fa fa-code" />
|
<i className="fa fa-code" />
|
||||||
</button>
|
</button>
|
||||||
<button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={() => {
|
<button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={handlePreview}>
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<i className="fa fa-eye" />
|
<i className="fa fa-eye" />
|
||||||
</button>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
|
};
|
||||||
+387
-3
@@ -31,6 +31,27 @@
|
|||||||
--radius-lg: 8px;
|
--radius-lg: 8px;
|
||||||
--transition-fast: 0.15s ease;
|
--transition-fast: 0.15s ease;
|
||||||
--transition-normal: 0.25s ease;
|
--transition-normal: 0.25s ease;
|
||||||
|
/* Phase A (mobile): fixed bottom tab bar height, used to size
|
||||||
|
.editor-container and the safe-area padding math below. */
|
||||||
|
--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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
@@ -1377,13 +1398,376 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
Phase A -- Mobile Editor Chrome
|
||||||
|
Gated entirely behind this breakpoint; desktop styles above are
|
||||||
|
untouched. See useIsMobile() (same 768px breakpoint) for the React-side
|
||||||
|
equivalent that swaps LeftPanel/RightPanel for MobilePanelBar, and
|
||||||
|
collapses TopBar.
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/* Side panels are not rendered on mobile (EditorShell swaps them for the
|
||||||
|
bottom tab bar) -- kept as a defensive fallback in case either is ever
|
||||||
|
mounted at this width. */
|
||||||
.panel-left,
|
.panel-left,
|
||||||
.panel-right {
|
.panel-right {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-btn span,
|
/* --------------------------------------------------------------------
|
||||||
.topbar-domain {
|
Viewport sizing -- dynamic viewport height accounts for mobile
|
||||||
display: none;
|
browsers' collapsing URL bar; the unconditional 100vh set earlier in
|
||||||
|
this file is the fallback for browsers without dvh support.
|
||||||
|
-------------------------------------------------------------------- */
|
||||||
|
.editor-app {
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
padding-top: env(safe-area-inset-top, 0px);
|
||||||
|
padding-left: env(safe-area-inset-left, 0px);
|
||||||
|
padding-right: env(safe-area-inset-right, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-container {
|
||||||
|
height: calc(100vh - var(--topbar-height) - var(--mobile-tabbar-height) - env(safe-area-inset-bottom, 0px));
|
||||||
|
height: calc(100dvh - var(--topbar-height) - var(--mobile-tabbar-height) - env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-width canvas, safe-area-aware bottom padding so content never
|
||||||
|
sits flush against the fixed bottom tab bar. */
|
||||||
|
.editor-canvas {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------
|
||||||
|
Topbar collapse (TopBar.tsx renders a distinct `.topbar-mobile` markup
|
||||||
|
branch -- this is purely the styling for it)
|
||||||
|
-------------------------------------------------------------------- */
|
||||||
|
.topbar-mobile {
|
||||||
|
padding: 0 8px;
|
||||||
|
padding-left: calc(8px + env(safe-area-inset-left, 0px));
|
||||||
|
padding-right: calc(8px + env(safe-area-inset-right, 0px));
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-mobile .topbar-left {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-mobile .topbar-right {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-mobile .topbar-btn.icon-only {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: center;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-mobile .topbar-divider {
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-btn.overflow-toggle.active {
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: var(--color-accent-subtle);
|
||||||
|
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)
|
||||||
|
-------------------------------------------------------------------- */
|
||||||
|
.topbar-overflow-menu {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--topbar-height) + env(safe-area-inset-top, 0px) + 6px);
|
||||||
|
right: calc(8px + env(safe-area-inset-right, 0px));
|
||||||
|
z-index: var(--z-overflow);
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: calc(100vw - 16px);
|
||||||
|
max-height: calc(100dvh - var(--topbar-height) - 24px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-overflow-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-overflow-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-overflow-section .device-switcher {
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-overflow-section .device-btn {
|
||||||
|
width: 44px;
|
||||||
|
height: 42px;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-overflow-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-overflow-item:hover,
|
||||||
|
.topbar-overflow-item:active {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-overflow-item.active {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-overflow-sitesmith {
|
||||||
|
padding: 6px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-overflow-sitesmith .sitesmith-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------
|
||||||
|
Bottom tab bar (MobilePanelBar.tsx)
|
||||||
|
-------------------------------------------------------------------- */
|
||||||
|
.mobile-tab-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
/* Above .mobile-sheet-backdrop (400) -- the tab bar must stay clickable
|
||||||
|
even while a sheet is open so tapping a different tab swaps sheets
|
||||||
|
directly, instead of requiring a close-then-reopen round trip. The
|
||||||
|
backdrop itself stops short of the tab bar (see its `bottom` offset
|
||||||
|
below) so this is mostly belt-and-suspenders for the boundary. */
|
||||||
|
z-index: var(--z-tabbar);
|
||||||
|
display: flex;
|
||||||
|
height: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px));
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
padding-left: env(safe-area-inset-left, 0px);
|
||||||
|
padding-right: env(safe-area-inset-right, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tab-btn i {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tab-btn.active {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------
|
||||||
|
Bottom sheet (BottomSheet.tsx)
|
||||||
|
-------------------------------------------------------------------- */
|
||||||
|
.mobile-sheet-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
/* Stop above the bottom tab bar (rather than a full-viewport `inset: 0`)
|
||||||
|
so the tab bar is never covered/blocked by the backdrop -- tapping a
|
||||||
|
different tab while a sheet is open swaps sheets directly instead of
|
||||||
|
being swallowed by the overlay sitting on top of it. */
|
||||||
|
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px));
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: var(--z-sheet-backdrop);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 65vh;
|
||||||
|
max-height: 65dvh;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border-top-left-radius: 16px;
|
||||||
|
border-top-right-radius: 16px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-bottom: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-handle-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 12px 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-close {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------
|
||||||
|
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
|
||||||
|
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 .block-item {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-body input,
|
||||||
|
.mobile-sheet-body select,
|
||||||
|
.mobile-sheet-body textarea,
|
||||||
|
.guided-input,
|
||||||
|
.control-input,
|
||||||
|
.control-select,
|
||||||
|
.topbar input,
|
||||||
|
.topbar textarea,
|
||||||
|
.topbar select,
|
||||||
|
.sitesmith-textarea {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-body .settings-tabs button {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-body .color-preset {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------
|
||||||
|
Templates modal: category tabs stay horizontally scrollable and never
|
||||||
|
shrink below a comfortable touch target; close button enlarged to the
|
||||||
|
44px minimum (overriding its 32px inline style).
|
||||||
|
-------------------------------------------------------------------- */
|
||||||
|
.template-modal-tabbar {
|
||||||
|
gap: 8px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-modal-tab-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 8px 16px !important;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-modal-close-btn {
|
||||||
|
width: 44px !important;
|
||||||
|
height: 44px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user