Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions docs/design/site-editor-ux.md
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,66 @@ The shell has **four regions** and their responsibilities are fixed:
Rationale: F10 (navigator depth), F11 (styles hierarchy), F13 (control dispersion) all stem from ambiguity about *which
region owns what*. Locking these roles means controls have a single predictable home.

#### 3.2.1 Editing-mode regions (block inserter, list view, pattern picker)

D1 (#368) ships the four-region shell above. As D2–D5 wire entity editing into the canvas, three additional
regions need a fixed home so the chrome doesn't drift section by section: the **block inserter**, the **list view**
(block outline), and the **pattern picker**. Decision (recorded after D1 prototype review):

- **Navigator stays visible.** When the user enters edit mode (selects an entity in the section list), the navigator
collapses to an **icon-only** rail (≈48px). It does not disappear. Section labels are revealed on hover / focus and
the active section keeps its accent. This preserves P5 ("navigator browses, canvas edits, inspector configures") and
the cross-section jump that motivates the persistent-shell IA in the first place — losing the navigator while
editing is one of the WP frictions (F11) we're explicitly designing out.
- **Inserter + pattern picker live in a secondary left rail.** A second column opens *to the right of* the navigator
when toggled from the top bar's left button. It hosts the block inserter and pattern picker (synced/unsynced tabs
from §3.6) as a tabbed panel. The panel is roughly the same width as the post-editor's inserter (≈350px). When
closed, the rail collapses entirely; the canvas reclaims the space.
- **List view rides in the inspector.** The right-rail inspector grows a third tab — **List view** — alongside Block
and Entity. This avoids competing with the inserter for left-rail space and keeps the inspector's charter intact:
it's the panel for *configuring what's on the canvas*, and the outline of the canvas is part of that.
- **Top bar adds three editing-mode toggles.** Inserter (left-rail), list-view (right-rail tab focus), and a future
zoom-out / preview affordance. Each toggle's `aria-pressed` reflects the panel's open state, mirroring the post
editor's M7 conventions.

```text
Edit mode (entity selected):
┌──────────────────────────────────────────────────────────────────────────┐
│ Top Bar: [☰] [+] [List] [Brand] [Entity title] [Save template] │
├────┬─────────────────┬─────────────────────────────┬─────────────────────┤
│ │ │ │ │
│ N │ Inserter / │ Canvas (iframe) │ Inspector │
│ a │ pattern │ │ ┌──────────────┐ │
│ v │ picker │ │ │ Block │ Doc │ │
│ ( │ (toggled, │ │ │ List view │ │
│ i │ optional) │ │ └──────────────┘ │
│ c │ │ │ │
│ ) │ │ │ │
└────┴─────────────────┴─────────────────────────────┴─────────────────────┘
```

```text
Browse mode (no entity selected):
┌──────────────────────────────────────────────────────────────────────────┐
│ Top Bar: [☰] [Brand] [Section name] │
├──────────────────────┬───────────────────────────────┬───────────────────┤
│ │ │ │
│ Navigator │ Canvas (entity list/grid) │ Inspector │
│ (full-width with │ │ (section-scoped) │
│ section list) │ │ │
│ │ │ │
└──────────────────────┴───────────────────────────────┴───────────────────┘
```

Rationale: this is the "Pattern B" hybrid considered after D1's shell landed. Pattern A (swap left rail entirely
with editing tools) loses the navigator at exactly the moment cross-section jumps are most useful. Pattern C
(floating overlays) makes the pattern picker too cramped for the synced/unsynced + filter UI in §3.6. Pattern B
preserves the four-region shell while giving editing tools dedicated space, at the cost of needing one extra
column on small viewports — addressed by the inserter rail being collapsible.

D2 implements the icon-only navigator collapse and the inserter rail. D3–D5 plug their per-section editor surfaces
into the same chrome. Every D-issue's PR must reference this section when it lands editing-mode UI.

### 3.3 Section IA — overview

Five sections, each with consistent region semantics:
Expand Down
48 changes: 48 additions & 0 deletions resources/js/visual-editor/editor/__tests__/top-bar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,54 @@ describe('TopBar', () => {
).toHaveTextContent('Network error');
});

it('honors per-host overrides for the inserter and inspector aria labels', () => {
const { rerender } = render(
<TopBar
{...defaultProps({
inserterToggleAriaLabel: {
open: 'Open navigator',
close: 'Close navigator',
},
inspectorToggleAriaLabel: {
open: 'Open settings',
close: 'Close settings',
},
})}
/>
);

expect(
screen.getByTestId('ap-visual-editor-top-bar-inserter')
).toHaveAttribute('aria-label', 'Open navigator');
expect(
screen.getByTestId('ap-visual-editor-top-bar-inspector')
).toHaveAttribute('aria-label', 'Open settings');

rerender(
<TopBar
{...defaultProps({
isInserterOpen: true,
isInspectorOpen: true,
inserterToggleAriaLabel: {
open: 'Open navigator',
close: 'Close navigator',
},
inspectorToggleAriaLabel: {
open: 'Open settings',
close: 'Close settings',
},
})}
/>
);

expect(
screen.getByTestId('ap-visual-editor-top-bar-inserter')
).toHaveAttribute('aria-label', 'Close navigator');
expect(
screen.getByTestId('ap-visual-editor-top-bar-inspector')
).toHaveAttribute('aria-label', 'Close settings');
});

it('renders a preview link when a URL is provided', () => {
render(
<TopBar
Expand Down
33 changes: 27 additions & 6 deletions resources/js/visual-editor/editor/top-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ export interface TopBarProps {
* actions between the preview link and the more-options menu.
*/
extraActions?: ReactNode;
/**
* Override the aria-label / accessible name of the left toggle button.
* The post editor uses this slot for the block inserter; the site
* editor (D1 · #368) reuses the same button for the navigator panel
* and supplies the matching label here. Defaults preserve the
* post-editor wording when omitted.
*/
inserterToggleAriaLabel?: { open: string; close: string };
/**
* Override the aria-label / accessible name of the right (inspector)
* toggle button. The label varies between the post editor (the
* inspector panel) and the site editor (the section inspector).
*/
inspectorToggleAriaLabel?: { open: string; close: string };
}

function saveStatusLabel(
Expand Down Expand Up @@ -145,8 +159,19 @@ export function TopBar(props: TopBarProps): JSX.Element {
onPasteStyles,
onShowKeyboardShortcuts,
extraActions,
inserterToggleAriaLabel,
inspectorToggleAriaLabel,
} = props;

const inserterOpenLabel =
inserterToggleAriaLabel?.open ?? __('Open block inserter', TEXT_DOMAIN);
const inserterCloseLabel =
inserterToggleAriaLabel?.close ?? __('Close block inserter', TEXT_DOMAIN);
const inspectorOpenLabel =
inspectorToggleAriaLabel?.open ?? __('Open inspector', TEXT_DOMAIN);
const inspectorCloseLabel =
inspectorToggleAriaLabel?.close ?? __('Close inspector', TEXT_DOMAIN);

const menuId = useId();
const menuTriggerRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -321,9 +346,7 @@ export function TopBar(props: TopBarProps): JSX.Element {
type="button"
className="ap-visual-editor-top-bar__icon-button"
aria-label={
isInserterOpen
? __('Close block inserter', TEXT_DOMAIN)
: __('Open block inserter', TEXT_DOMAIN)
isInserterOpen ? inserterCloseLabel : inserterOpenLabel
}
aria-expanded={isInserterOpen}
aria-pressed={isInserterOpen}
Expand Down Expand Up @@ -413,9 +436,7 @@ export function TopBar(props: TopBarProps): JSX.Element {
type="button"
className="ap-visual-editor-top-bar__icon-button"
aria-label={
isInspectorOpen
? __('Close inspector', TEXT_DOMAIN)
: __('Open inspector', TEXT_DOMAIN)
isInspectorOpen ? inspectorCloseLabel : inspectorOpenLabel
}
aria-expanded={isInspectorOpen}
aria-pressed={isInspectorOpen}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';

// Stub `BlockCanvas` and `BlockEditorProvider` from `@wordpress/block-editor`.
// The real `BlockCanvas` mounts an iframe and pulls from a Gutenberg data
// store, neither of which jsdom + the package's coreData shim produce
// reliably. Stubbing keeps the test focused on the wrapper's behaviour:
// - render the canvas region;
// - render the empty state when `hasEntity={false}`;
// - swap to `children` when an entity is supplied.
vi.mock('@wordpress/block-editor', () => ({
BlockCanvas: ({ children }: { children: ReactNode }): JSX.Element => (
<div data-testid="ap-stub-block-canvas">{children}</div>
),
BlockEditorProvider: ({ children }: { children: ReactNode }): JSX.Element => (
<div data-testid="ap-stub-block-editor-provider">{children}</div>
),
}));

vi.mock('@wordpress/components', () => {
const SlotFillProvider = ({ children }: { children: ReactNode }): JSX.Element => (
<div data-testid="ap-stub-slotfill-provider">{children}</div>
);

function PopoverSlot(): null {
return null;
}

const Popover = Object.assign(() => null, { Slot: PopoverSlot });

return { SlotFillProvider, Popover };
});

import { CanvasFrame } from '../canvas-frame';

describe('CanvasFrame', () => {
it('renders the canvas wrapper and the empty state when no entity is selected', () => {
render(<CanvasFrame sectionLabel="Editing: Templates" />);

const canvas = screen.getByTestId('ap-site-editor-canvas');

expect(canvas).toBeInTheDocument();
expect(canvas).toHaveAttribute('data-has-entity', 'false');

const emptyState = screen.getByTestId('ap-site-editor-canvas-empty');

expect(emptyState).toHaveTextContent('Editing: Templates');
expect(emptyState).toHaveTextContent(
'Select an entity from the navigator to start editing.'
);
});

it('mounts the BlockCanvas and provider chain', () => {
render(<CanvasFrame sectionLabel="Editing: Styles" />);

expect(
screen.getByTestId('ap-stub-block-editor-provider')
).toBeInTheDocument();
expect(
screen.getByTestId('ap-stub-block-canvas')
).toBeInTheDocument();
});

it('renders supplied children inside the canvas when hasEntity is true', () => {
render(
<CanvasFrame sectionLabel="Editing: Patterns" hasEntity>
<div data-testid="ap-test-entity">My pattern</div>
</CanvasFrame>
);

expect(screen.getByTestId('ap-test-entity')).toBeInTheDocument();
expect(
screen.queryByTestId('ap-site-editor-canvas-empty')
).not.toBeInTheDocument();
expect(screen.getByTestId('ap-site-editor-canvas')).toHaveAttribute(
'data-has-entity',
'true'
);
});
});
Loading