editor: friendlier panels - collapsible rail, remembered state, no more overlap
Rail sections (Outline/Blocks/Nodes) collapse via their headers like Experiments already did, so long pages stay navigable. Rail open state and section toggles persist in localStorage across reloads (the QA preview flow reloads constantly). The assistant panel shifts left while the dev panel is open instead of stacking exactly underneath it, and the rail/dev/assistant widths cap at the viewport on small screens. Tests: rail-sections harness (collapse, reload persistence, shifted assistant); harness boots with a fresh profile unless keepStorage is passed; in-memory localStorage shim for the origin-less jsdom. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
1 parent
5786712078
commit
bb875a0956
10 files changed
+185
-20
No files matched your search
+8
-1
@@ -24,6 +24,7 @@ import type { DropTarget } from './page/dnd'
|
|||||||
import type { BlockEditorDef, BlockInfo, InlineSession } from './registry'
|
import type { BlockEditorDef, BlockInfo, InlineSession } from './registry'
|
||||||
import { createStore } from './store'
|
import { createStore } from './store'
|
||||||
import type { Store } from './store'
|
import type { Store } from './store'
|
||||||
|
import { readPersist, writePersist } from './persist'
|
||||||
|
|
||||||
export interface Block extends BlockInfo {
|
export interface Block extends BlockInfo {
|
||||||
depth: number
|
depth: number
|
||||||
@@ -123,7 +124,7 @@ export class Controller {
|
|||||||
canRedo: false,
|
canRedo: false,
|
||||||
busy: 0,
|
busy: 0,
|
||||||
toast: null,
|
toast: null,
|
||||||
rail: false,
|
rail: readPersist('cms-onpage.rail', false),
|
||||||
dev: false,
|
dev: false,
|
||||||
assistant: false,
|
assistant: false,
|
||||||
inline: null,
|
inline: null,
|
||||||
@@ -155,6 +156,12 @@ export class Controller {
|
|||||||
this.reloadImpl = opts.reload ?? (() => this.softReload())
|
this.reloadImpl = opts.reload ?? (() => this.softReload())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Left panel open state, remembered across reloads. */
|
||||||
|
setRail(open: boolean): void {
|
||||||
|
writePersist('cms-onpage.rail', open)
|
||||||
|
this.store.set({ rail: open })
|
||||||
|
}
|
||||||
|
|
||||||
// --- lifecycle ----------------------------------------------------------
|
// --- lifecycle ----------------------------------------------------------
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Remembering small UI preferences across reloads (rail open, section
|
||||||
|
// collapsed). Editors reload the page constantly to preview; losing the
|
||||||
|
// panel state every time is the papercut this removes. All access is
|
||||||
|
// guarded: private mode, sandboxed iframes and some embeds throw.
|
||||||
|
export function readPersist<T>(key: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (raw === null) return fallback
|
||||||
|
return (typeof fallback === 'boolean' ? raw === 'true' : JSON.parse(raw)) as T
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writePersist(key: string, value: unknown): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value))
|
||||||
|
} catch {
|
||||||
|
// preferences are best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ function ProposalCard({ p, status, onResolve }: { p: Proposal; status: ProposalS
|
|||||||
export function Assistant() {
|
export function Assistant() {
|
||||||
const ctrl = useCtrl()
|
const ctrl = useCtrl()
|
||||||
const open = useEditorState((s) => s.assistant)
|
const open = useEditorState((s) => s.assistant)
|
||||||
|
const devOpen = useEditorState((s) => s.dev)
|
||||||
const selected = useEditorState((s) => s.selected)
|
const selected = useEditorState((s) => s.selected)
|
||||||
useEditorState((s) => s.labelsVersion)
|
useEditorState((s) => s.labelsVersion)
|
||||||
const store = conversationsFor(ctrl)
|
const store = conversationsFor(ctrl)
|
||||||
@@ -140,7 +141,7 @@ export function Assistant() {
|
|||||||
const starters = scope.rootId ? BLOCK_STARTERS : PAGE_STARTERS
|
const starters = scope.rootId ? BLOCK_STARTERS : PAGE_STARTERS
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="cms-assistant" aria-label="AI assistant">
|
<aside className={`cms-assistant${devOpen ? ' cms-assistant--shifted' : ''}`} aria-label="AI assistant">
|
||||||
<header>
|
<header>
|
||||||
<span className="cms-assistant__title">
|
<span className="cms-assistant__title">
|
||||||
<Icon name="assistant" size={16} /> Assistant
|
<Icon name="assistant" size={16} /> Assistant
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ export function Dock() {
|
|||||||
ctrl.toast('error', (e as Error).message)
|
ctrl.toast('error', (e as Error).message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const panel = () => ctrl.store.set({ rail: !s.rail })
|
const panel = () => ctrl.setRail(!s.rail)
|
||||||
return (
|
return (
|
||||||
<nav className="cms-dock" aria-label="Page editor">
|
<nav className="cms-dock" aria-label="Page editor">
|
||||||
<button className={`cms-pill${s.mode === 'edit' ? ' is-on' : ''}`} onClick={() => ctrl.setMode(s.mode === 'edit' ? 'browse' : 'edit')} title="Toggle editing (links work in browse mode)">
|
<button className={`cms-pill${s.mode === 'edit' ? ' is-on' : ''}`} onClick={() => ctrl.setMode(s.mode === 'edit' ? 'browse' : 'edit')} title="Toggle editing (links work in browse mode)">
|
||||||
|
|||||||
+39
-12
@@ -5,7 +5,7 @@ import { allowedChildren, connectionsOf, labelKey, sortGroups, typeName } from '
|
|||||||
import type { DropTarget } from '../page/dnd'
|
import type { DropTarget } from '../page/dnd'
|
||||||
import { Icon, TypeIcon } from './Icon'
|
import { Icon, TypeIcon } from './Icon'
|
||||||
import { Summary } from './Summary'
|
import { Summary } from './Summary'
|
||||||
import { useCtrl, useEditorState } from './hooks'
|
import { useCtrl, useEditorState, usePersistentState } from './hooks'
|
||||||
import type { Block, Controller } from '../controller'
|
import type { Block, Controller } from '../controller'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,7 +39,7 @@ export function Rail() {
|
|||||||
<span className="cms-rail__title">
|
<span className="cms-rail__title">
|
||||||
<Icon name="outline" size={16} /> Page
|
<Icon name="outline" size={16} /> Page
|
||||||
</span>
|
</span>
|
||||||
<button className="cms-ico" onClick={() => ctrl.store.set({ rail: false })} aria-label="Close panel">
|
<button className="cms-ico" onClick={() => ctrl.setRail(false)} aria-label="Close panel">
|
||||||
<Icon name="close" />
|
<Icon name="close" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -108,16 +108,25 @@ function OutlineTree() {
|
|||||||
}, [drag])
|
}, [drag])
|
||||||
|
|
||||||
const roots = useMemo(() => blocks.filter((b) => !b.parentId), [blocks])
|
const roots = useMemo(() => blocks.filter((b) => !b.parentId), [blocks])
|
||||||
|
const [open, setOpen] = usePersistentState('cms-onpage.sec.outline', true)
|
||||||
if (!blocks.length) return null
|
if (!blocks.length) return null
|
||||||
return (
|
return (
|
||||||
<section aria-label="Outline">
|
<section aria-label="Outline">
|
||||||
<h4 className="cms-rail__sec">Outline</h4>
|
<h4 className="cms-rail__sec">
|
||||||
<ul className="cms-outline cms-outline--tree">
|
<button className="cms-sectoggle" aria-expanded={open} aria-label="Outline" onClick={() => setOpen((o) => !o)}>
|
||||||
{roots.map((b) => (
|
Outline ({blocks.length})
|
||||||
<OutlineNode key={b.id} block={b} level={0} collapsed={collapsed} setCollapsed={setCollapsed} over={over} setOver={setOver} dragging={dragging} selected={selected} />
|
</button>
|
||||||
))}
|
</h4>
|
||||||
</ul>
|
{open && (
|
||||||
{dragging && <OutlineGap parentId="" index={roots.length} over={over} setOver={setOver} />}
|
<>
|
||||||
|
<ul className="cms-outline cms-outline--tree">
|
||||||
|
{roots.map((b) => (
|
||||||
|
<OutlineNode key={b.id} block={b} level={0} collapsed={collapsed} setCollapsed={setCollapsed} over={over} setOver={setOver} dragging={dragging} selected={selected} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{dragging && <OutlineGap parentId="" index={roots.length} over={over} setOver={setOver} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -389,9 +398,16 @@ function Palette() {
|
|||||||
doc.addEventListener('pointerup', stop, true)
|
doc.addEventListener('pointerup', stop, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [open, setOpen] = usePersistentState('cms-onpage.sec.blocks', true)
|
||||||
return (
|
return (
|
||||||
<section aria-label="Blocks">
|
<section aria-label="Blocks">
|
||||||
<h4 className="cms-rail__sec">Blocks</h4>
|
<h4 className="cms-rail__sec">
|
||||||
|
<button className="cms-sectoggle" aria-expanded={open} aria-label="Blocks" onClick={() => setOpen((o) => !o)}>
|
||||||
|
Blocks
|
||||||
|
</button>
|
||||||
|
</h4>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
<div className="cms-pad">
|
<div className="cms-pad">
|
||||||
<input className="cms-input" placeholder="Search blocks…" aria-label="Search blocks" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input className="cms-input" placeholder="Search blocks…" aria-label="Search blocks" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
<p className="cms-muted cms-hint">Drag onto the page or the outline, or click to add to the selection.</p>
|
<p className="cms-muted cms-hint">Drag onto the page or the outline, or click to add to the selection.</p>
|
||||||
@@ -426,6 +442,8 @@ function Palette() {
|
|||||||
</details>
|
</details>
|
||||||
))}
|
))}
|
||||||
{types && !shown.length && <p className="cms-muted cms-pad">Nothing matches.</p>}
|
{types && !shown.length && <p className="cms-muted cms-pad">Nothing matches.</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -515,9 +533,16 @@ function NodeSearch() {
|
|||||||
doc.addEventListener('pointerup', stop, true)
|
doc.addEventListener('pointerup', stop, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [open, setOpen] = usePersistentState('cms-onpage.sec.nodes', true)
|
||||||
return (
|
return (
|
||||||
<section aria-label="Nodes">
|
<section aria-label="Nodes">
|
||||||
<h4 className="cms-rail__sec">Nodes</h4>
|
<h4 className="cms-rail__sec">
|
||||||
|
<button className="cms-sectoggle" aria-expanded={open} aria-label="Nodes" onClick={() => setOpen((o) => !o)}>
|
||||||
|
Nodes
|
||||||
|
</button>
|
||||||
|
</h4>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
<div className="cms-pad cms-nodefind">
|
<div className="cms-pad cms-nodefind">
|
||||||
<input className="cms-input" placeholder="Search existing nodes…" aria-label="Search nodes" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input className="cms-input" placeholder="Search existing nodes…" aria-label="Search nodes" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
<select className="cms-input" aria-label="Filter by type" value={type} onChange={(e) => setType(e.target.value)}>
|
<select className="cms-input" aria-label="Filter by type" value={type} onChange={(e) => setType(e.target.value)}>
|
||||||
@@ -560,6 +585,8 @@ function NodeSearch() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{!!results?.length && total > results.length && <p className="cms-muted cms-pad">Showing {results.length} of {total} — refine the search.</p>}
|
{!!results?.length && total > results.length && <p className="cms-muted cms-pad">Showing {results.length} of {total} — refine the search.</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -585,7 +612,7 @@ function ExperimentsPanel() {
|
|||||||
const [stats, setStats] = useState<Record<string, ExperimentAnalytics>>({})
|
const [stats, setStats] = useState<Record<string, ExperimentAnalytics>>({})
|
||||||
const [confirmOff, setConfirmOff] = useState<string | null>(null)
|
const [confirmOff, setConfirmOff] = useState<string | null>(null)
|
||||||
const [tick, setTick] = useState(0) // re-read override cookies after set/clear
|
const [tick, setTick] = useState(0) // re-read override cookies after set/clear
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = usePersistentState('cms-onpage.sec.experiments', false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|||||||
+14
-1
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useContext, useSyncExternalStore } from 'react'
|
import { createContext, useContext, useState, useSyncExternalStore } from 'react'
|
||||||
import type { Controller, EditorState } from '../controller'
|
import type { Controller, EditorState } from '../controller'
|
||||||
|
import { readPersist, writePersist } from '../persist'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import { blockEditors, fieldEditors } from '../registry'
|
import { blockEditors, fieldEditors } from '../registry'
|
||||||
|
|
||||||
@@ -10,6 +11,18 @@ export function useEditorState<S>(select: (s: EditorState) => S): S {
|
|||||||
return useStore(useCtrl().store, select)
|
return useStore(useCtrl().store, select)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Boolean UI state that survives reloads (rail sections, panel toggles). */
|
||||||
|
export function usePersistentState(key: string, initial: boolean): [boolean, (update: boolean | ((prev: boolean) => boolean)) => void] {
|
||||||
|
const [value, setValue] = useState(() => readPersist(key, initial))
|
||||||
|
const set = (update: boolean | ((prev: boolean) => boolean)) =>
|
||||||
|
setValue((prev) => {
|
||||||
|
const next = typeof update === 'function' ? update(prev) : update
|
||||||
|
writePersist(key, next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
return [value, set]
|
||||||
|
}
|
||||||
|
|
||||||
export function useRegistryVersion(): number {
|
export function useRegistryVersion(): number {
|
||||||
const subscribe = (f: () => void) => {
|
const subscribe = (f: () => void) => {
|
||||||
const a = blockEditors.subscribe(f)
|
const a = blockEditors.subscribe(f)
|
||||||
|
|||||||
+6
-3
@@ -67,7 +67,7 @@ button:disabled { opacity: .35; cursor: default; }
|
|||||||
.cms-outline__type { font-weight: 600; }
|
.cms-outline__type { font-weight: 600; }
|
||||||
.cms-outline__text { color: #8b8fa0; overflow: hidden; text-overflow: ellipsis; }
|
.cms-outline__text { color: #8b8fa0; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
.cms-dev { right: 12px; top: 12px; bottom: 70px; width: 420px; display: flex; flex-direction: column; overflow: hidden; }
|
.cms-dev { right: 12px; top: 12px; bottom: 70px; width: min(420px, calc(100vw - 24px)); display: flex; flex-direction: column; overflow: hidden; }
|
||||||
.cms-dev header { justify-content: flex-start; gap: 4px; }
|
.cms-dev header { justify-content: flex-start; gap: 4px; }
|
||||||
.cms-dev header button:not(.cms-ico) { background: none; border: 0; padding: 4px 10px; border-radius: 6px; text-transform: capitalize; }
|
.cms-dev header button:not(.cms-ico) { background: none; border: 0; padding: 4px 10px; border-radius: 6px; text-transform: capitalize; }
|
||||||
.cms-dev header button.is-on { background: #2b3350; }
|
.cms-dev header button.is-on { background: #2b3350; }
|
||||||
@@ -226,7 +226,7 @@ input.cms-map__key { padding-top: 6px; }
|
|||||||
.cms-attrchip:hover { background: #1f3a2e; }
|
.cms-attrchip:hover { background: #1f3a2e; }
|
||||||
|
|
||||||
/* left panel: outline + blocks + nodes, one scroll (no tabs) */
|
/* left panel: outline + blocks + nodes, one scroll (no tabs) */
|
||||||
.cms-rail { pointer-events: auto; position: fixed; z-index: 2147483001; left: 12px; top: 12px; bottom: 76px; width: 320px; display: flex; flex-direction: column; overflow: hidden; background: var(--cms-panel); border: 1px solid var(--cms-line); border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,.5); }
|
.cms-rail { pointer-events: auto; position: fixed; z-index: 2147483001; left: 12px; top: 12px; bottom: 76px; width: min(320px, calc(100vw - 24px)); display: flex; flex-direction: column; overflow: hidden; background: var(--cms-panel); border: 1px solid var(--cms-line); border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,.5); }
|
||||||
.cms-rail > header { display: flex; align-items: center; justify-content: space-between; padding: 6px 6px 6px 12px; border-bottom: 1px solid var(--cms-line); font-weight: 600; }
|
.cms-rail > header { display: flex; align-items: center; justify-content: space-between; padding: 6px 6px 6px 12px; border-bottom: 1px solid var(--cms-line); font-weight: 600; }
|
||||||
.cms-rail__title { display: inline-flex; align-items: center; gap: 8px; }
|
.cms-rail__title { display: inline-flex; align-items: center; gap: 8px; }
|
||||||
.cms-rail__body { overflow-y: auto; flex: 1; min-height: 0; padding-bottom: 12px; }
|
.cms-rail__body { overflow-y: auto; flex: 1; min-height: 0; padding-bottom: 12px; }
|
||||||
@@ -324,7 +324,10 @@ details[open] > summary > .cms-chev { transform: rotate(90deg); }
|
|||||||
.cms-ai__actions { display: flex; justify-content: flex-end; gap: 8px; }
|
.cms-ai__actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
|
||||||
/* AI assistant panel */
|
/* AI assistant panel */
|
||||||
.cms-assistant { right: 12px; top: 12px; bottom: 70px; width: 380px; display: flex; flex-direction: column; overflow: hidden; }
|
.cms-assistant { right: 12px; top: 12px; bottom: 70px; width: min(380px, calc(100vw - 24px)); display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
/* dev and assistant share the right edge: sit side by side instead of stacking */
|
||||||
|
.cms-assistant--shifted { right: 444px; }
|
||||||
|
@media (max-width: 900px) { .cms-assistant--shifted { right: 12px; } }
|
||||||
.cms-assistant > header { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px 6px 12px; border-bottom: 1px solid var(--cms-line, #2a2d3a); font-weight: 600; }
|
.cms-assistant > header { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px 6px 12px; border-bottom: 1px solid var(--cms-line, #2a2d3a); font-weight: 600; }
|
||||||
.cms-chat { flex: 1; overflow: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
|
.cms-chat { flex: 1; overflow: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
.cms-msg { max-width: 92%; padding: 6px 10px; border-radius: 10px; white-space: pre-wrap; word-break: break-word; background: #1d2029; }
|
.cms-msg { max-width: 92%; padding: 6px 10px; border-radius: 10px; white-space: pre-wrap; word-break: break-word; background: #1d2029; }
|
||||||
|
|||||||
+4
-1
@@ -20,7 +20,10 @@ export interface Harness {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `stale`: the DOM is the *published* page, and the store has moved on since (the base-page problem). */
|
/** `stale`: the DOM is the *published* page, and the store has moved on since (the base-page problem). */
|
||||||
export async function setup(opts: { token?: string; auth?: boolean; stale?: boolean; noPreviewApi?: boolean } = {}): Promise<Harness> {
|
export async function setup(opts: { token?: string; auth?: boolean; stale?: boolean; noPreviewApi?: boolean; keepStorage?: boolean } = {}): Promise<Harness> {
|
||||||
|
// Fresh profile by default: remembered panel state must not leak between tests.
|
||||||
|
// Pass keepStorage to simulate a reload with the same profile.
|
||||||
|
if (!opts.keepStorage) localStorage.clear()
|
||||||
const cms = createMockCms({ token: opts.token, auth: opts.auth })
|
const cms = createMockCms({ token: opts.token, auth: opts.auth })
|
||||||
document.head.innerHTML = ''
|
document.head.innerHTML = ''
|
||||||
window.open = () => null
|
window.open = () => null
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { act } from 'react'
|
||||||
|
import { setup } from './harness'
|
||||||
|
import type { Harness } from './harness'
|
||||||
|
|
||||||
|
let h: Harness
|
||||||
|
beforeEach(() => localStorage.clear())
|
||||||
|
afterEach(() => {
|
||||||
|
h?.cleanup()
|
||||||
|
delete (navigator as any).gpu
|
||||||
|
})
|
||||||
|
|
||||||
|
const q = (sel: string) => h.shadow.querySelector<HTMLElement>(sel)!
|
||||||
|
const qa = (sel: string) => [...h.shadow.querySelectorAll<HTMLElement>(sel)]
|
||||||
|
|
||||||
|
describe('rail sections', () => {
|
||||||
|
it('outline, blocks and nodes collapse via their headers', async () => {
|
||||||
|
h = await setup()
|
||||||
|
await h.settle()
|
||||||
|
await h.press('Panel')
|
||||||
|
await h.settle()
|
||||||
|
await h.settle()
|
||||||
|
expect(qa('.cms-outline').length).toBeGreaterThan(0)
|
||||||
|
// collapse outline: the tree leaves the DOM, palette stays
|
||||||
|
// (NB: the section itself shares the aria-label, so target the toggle button)
|
||||||
|
await act(async () => void q('button[aria-label="Outline"]').click())
|
||||||
|
await h.settle()
|
||||||
|
expect(qa('.cms-outline').length).toBe(0)
|
||||||
|
expect(q('[aria-label="Search blocks"]')).not.toBeNull()
|
||||||
|
// collapse blocks too
|
||||||
|
await act(async () => void q('button[aria-label="Blocks"]').click())
|
||||||
|
await h.settle()
|
||||||
|
expect(h.shadow.querySelector('[aria-label="Search blocks"]')).toBeNull()
|
||||||
|
// and back open
|
||||||
|
await act(async () => void q('button[aria-label="Blocks"]').click())
|
||||||
|
await h.settle()
|
||||||
|
expect(q('[aria-label="Search blocks"]')).not.toBeNull()
|
||||||
|
await act(async () => void q('button[aria-label="Outline"]').click())
|
||||||
|
await h.settle()
|
||||||
|
expect(qa('.cms-outline').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rail open state and collapsed sections survive a reload', async () => {
|
||||||
|
h = await setup()
|
||||||
|
await h.settle()
|
||||||
|
await h.press('Panel')
|
||||||
|
await h.settle()
|
||||||
|
await act(async () => void q('button[aria-label="Blocks"]').click()) // collapse blocks
|
||||||
|
await h.settle()
|
||||||
|
expect(localStorage.getItem('cms-onpage.rail')).toBe('true')
|
||||||
|
expect(localStorage.getItem('cms-onpage.sec.blocks')).toBe('false')
|
||||||
|
// reload with the same profile: rail reopens, blocks stays collapsed
|
||||||
|
h.cleanup()
|
||||||
|
h = await setup({ keepStorage: true })
|
||||||
|
await h.settle()
|
||||||
|
await h.settle()
|
||||||
|
expect(q('.cms-rail')).not.toBeNull()
|
||||||
|
expect(h.shadow.querySelector('[aria-label="Search blocks"]')).toBeNull()
|
||||||
|
expect(qa('.cms-outline').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('assistant sits beside the dev panel instead of stacking under it', async () => {
|
||||||
|
;(navigator as any).gpu = {} // aiSupported() checks for a WebGPU API
|
||||||
|
h = await setup()
|
||||||
|
await h.settle()
|
||||||
|
await act(async () => void h.editor.ctrl.store.set({ assistant: true, dev: true }))
|
||||||
|
await h.settle()
|
||||||
|
expect(q('.cms-dev')).not.toBeNull()
|
||||||
|
expect(q('.cms-assistant.cms-assistant--shifted')).not.toBeNull()
|
||||||
|
await act(async () => void h.editor.ctrl.store.set({ dev: false }))
|
||||||
|
await h.settle()
|
||||||
|
expect(h.shadow.querySelector('.cms-assistant--shifted')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,21 @@
|
|||||||
// jsdom has no layout: give the few APIs the editor touches harmless defaults.
|
// jsdom has no layout: give the few APIs the editor touches harmless defaults.
|
||||||
if (!('scrollIntoView' in Element.prototype)) (Element.prototype as any).scrollIntoView = () => {}
|
if (!('scrollIntoView' in Element.prototype)) (Element.prototype as any).scrollIntoView = () => {}
|
||||||
|
|
||||||
|
// This jsdom runs origin-less, so there is no localStorage: provide an
|
||||||
|
// in-memory one so UI persistence (rail/panel state) behaves like a browser.
|
||||||
|
if (typeof (globalThis as any).localStorage === 'undefined') {
|
||||||
|
const store = new Map<string, string>()
|
||||||
|
;(globalThis as any).localStorage = {
|
||||||
|
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
|
||||||
|
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||||
|
removeItem: (k: string) => void store.delete(k),
|
||||||
|
clear: () => store.clear(),
|
||||||
|
key: (i: number) => [...store.keys()][i] ?? null,
|
||||||
|
get length() {
|
||||||
|
return store.size
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
||||||
|
|
||||||
// Store updates arrive from fetch/timers outside act(); the harness settles them explicitly.
|
// Store updates arrive from fetch/timers outside act(); the harness settles them explicitly.
|
||||||
|
|||||||
Reference in new issue
Block a user