Define semantic page archetypes

This commit is contained in:
2026-08-19 14:26:25 +02:00
parent c042244da8
commit 41db78c201
18 changed files with 315 additions and 87 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ domain modules own their compositions.
| Full-canvas workspace | Navigation/content and list/detail canvases that own pane geometry and scrolling | Navigation and split variants, primary-pane width, pane-owned or contained scrolling, responsive stacking or navigation collapse, pane labels, and contextual-help identity are centralized without encoding domain navigation | `WorkspaceLayout.tsx`, `workspace-layout.test.tsx`, Core Settings, Access administration, Docs, Organizations, Campaign, Templates, Approvals, and `check-shared-webui-layouts.py`; the raw-workspace exception baseline is empty | | Full-canvas workspace | Navigation/content and list/detail canvases that own pane geometry and scrolling | Navigation and split variants, primary-pane width, pane-owned or contained scrolling, responsive stacking or navigation collapse, pane labels, and contextual-help identity are centralized without encoding domain navigation | `WorkspaceLayout.tsx`, `workspace-layout.test.tsx`, Core Settings, Access administration, Docs, Organizations, Campaign, Templates, Approvals, and `check-shared-webui-layouts.py`; the raw-workspace exception baseline is empty |
| Full-height module frame | Outer module landmark and viewport/container sizing | `WorkspaceFrame` centralizes surface, overflow, box sizing, accessible naming, help identity, and application-viewport height so modules do not copy the `100vh - shell` frame | `WorkspaceFrame.tsx`, `layout-primitives.test.tsx`, Dataflow, Workflow, Datasources, Distribution Lists, Notifications, Tasks, Scheduling, Forms, Portal, Projects, Records, and Reporting | | Full-height module frame | Outer module landmark and viewport/container sizing | `WorkspaceFrame` centralizes surface, overflow, box sizing, accessible naming, help identity, and application-viewport height so modules do not copy the `100vh - shell` frame | `WorkspaceFrame.tsx`, `layout-primitives.test.tsx`, Dataflow, Workflow, Datasources, Distribution Lists, Notifications, Tasks, Scheduling, Forms, Portal, Projects, Records, and Reporting |
| Responsive action toolbar | Domain-neutral action and filter grouping for pages, workspaces, editors, and overlays | Density, surface, grouping, flexible space, accessible naming, toolbar help identity, and responsive wrapping are centralized while modules retain action wording, authority, and consequence | `ActionToolbar.tsx`, `layout-primitives.test.tsx`, WYSIWYG, Calendar, Files, Forms, Templates, and the product-wide primitive check | | Responsive action toolbar | Domain-neutral action and filter grouping for pages, workspaces, editors, and overlays | Density, surface, grouping, flexible space, accessible naming, toolbar help identity, and responsive wrapping are centralized while modules retain action wording, authority, and consequence | `ActionToolbar.tsx`, `layout-primitives.test.tsx`, WYSIWYG, Calendar, Files, Forms, Templates, and the product-wide primitive check |
| Semantic page action bar | Named collection, detail, and editor action placement composed over the responsive toolbar | Reload stays leading; collection Create, editor Save, and detail consequences occupy stable trailing slots; read-only pages do not invent Save; modules retain wording, permissions, blockers, and effects | `PageActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, `layout-primitives.test.tsx`, Payments, and the product-wide primitive check | | Semantic page archetypes and action bar | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry and composed over the responsive toolbar | Refreshable pages require leading Reload; editor persistence owns clean/dirty/saving feedback, guarded Discard and far-right Save; destructive actions occupy an explicit separated slot; read-only pages do not invent Save | `PageLayout.tsx`, `PageActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed product page, and `check-shared-webui-layouts.py` |
| Catalogue and state composition | Search/filter bars, selectable navigation lists, count badges, and empty/blocked/error panels | Width, surface, wrap, selection geometry, title/description truncation, numeric emphasis, state sizing, tone and action placement are centralized; modules retain query behavior, object state and consequences | `FilterBar.tsx`, `SelectionList.tsx`, `CountBadge.tsx`, `StatePanel.tsx`, `layout-primitives.test.tsx`, and list/detail modules across Cases, Committee, Dataflow, Forms, Notifications, Portal, Postbox, Projects, Records, Reporting, Tasks, Templates, and Workflow | | Catalogue and state composition | Search/filter bars, selectable navigation lists, count badges, and empty/blocked/error panels | Width, surface, wrap, selection geometry, title/description truncation, numeric emphasis, state sizing, tone and action placement are centralized; modules retain query behavior, object state and consequences | `FilterBar.tsx`, `SelectionList.tsx`, `CountBadge.tsx`, `StatePanel.tsx`, `layout-primitives.test.tsx`, and list/detail modules across Cases, Committee, Dataflow, Forms, Notifications, Portal, Postbox, Projects, Records, Reporting, Tasks, Templates, and Workflow |
| Content and form grids | Equal-column content, field, and native-form geometry | Explicit 14 columns, standard gaps, item spans, alignment, and named narrow/workspace/standard/wide collapse points replace generic and module-prefixed copies; unequal domain tracks remain local | `ContentGrid.tsx`, `layout-primitives.test.tsx`, Core dashboard/settings/mail, Calendar dialogs, Forms editor, Datasources, Postbox, Campaign, administration surfaces, and the product-wide primitive check | | Content and form grids | Equal-column content, field, and native-form geometry | Explicit 14 columns, standard gaps, item spans, alignment, and named narrow/workspace/standard/wide collapse points replace generic and module-prefixed copies; unequal domain tracks remain local | `ContentGrid.tsx`, `layout-primitives.test.tsx`, Core dashboard/settings/mail, Calendar dialogs, Forms editor, Datasources, Postbox, Campaign, administration surfaces, and the product-wide primitive check |
| Content sections | Repeated editor/detail section surfaces | Border, surface, compact/default density, stacked flow and block rhythm are centralized without encoding section contents | `ContentSection.tsx`, `layout-primitives.test.tsx`, Datasources, Distribution Lists, Templates, Dataflow, and Workflow | | Content sections | Repeated editor/detail section surfaces | Border, surface, compact/default density, stacked flow and block rhythm are centralized without encoding section contents | `ContentSection.tsx`, `layout-primitives.test.tsx`, Datasources, Distribution Lists, Templates, Dataflow, and Workflow |
+54 -15
View File
@@ -7,6 +7,8 @@ wording, authorization, consequences, and data state.
## Required Page Frame ## Required Page Frame
- Use `PageLayout` for every headed standalone, workspace, or embedded page. - Use `PageLayout` for every headed standalone, workspace, or embedded page.
- Declare exactly one semantic `archetype`; do not infer page intent from the
`mode`, which controls geometry and scroll ownership only.
- Use `WorkspaceFrame` for a full-height module surface and - Use `WorkspaceFrame` for a full-height module surface and
`WorkspaceLayout` only where navigation/content or list/detail panes are `WorkspaceLayout` only where navigation/content or list/detail panes are
genuinely part of the interaction. genuinely part of the interaction.
@@ -16,7 +18,22 @@ wording, authorization, consequences, and data state.
- Do not reproduce shared page padding, heading, toolbar, form-grid, section, - Do not reproduce shared page padding, heading, toolbar, form-grid, section,
table, dialog, or breakpoint CSS in a module. table, dialog, or breakpoint CSS in a module.
## Page Action Archetypes ## Semantic Page Archetypes
| Archetype | Use when |
| --- | --- |
| `overview` | The page summarizes health, metrics, or several peer areas without owning one primary collection or draft. |
| `collection` | The primary object is a searchable/listable collection and Create, when available, applies to that collection. |
| `detail` | The page primarily presents one record, report, or immutable projection. |
| `editor` | The page owns one explicit draft with Save and Discard behavior. |
| `workspace` | The page coordinates several panes, stages, or task-local operations that cannot honestly be reduced to one record or draft. |
The archetype remains stable for the current interaction. A page may switch
from `overview` to `editor` when the user explicitly enters configuration
mode. It must not call a page an editor merely because a dialog or an inline
filter is editable.
## Page Action Rules
Pass one `PageActionBar` to the `PageLayout` `actions` slot. The variant makes Pass one `PageActionBar` to the `PageLayout` `actions` slot. The variant makes
the page's intent inspectable and preserves the same keyboard and visual order the page's intent inspectable and preserves the same keyboard and visual order
@@ -24,17 +41,36 @@ across modules.
| Page kind | Leading group | Trailing group | | Page kind | Leading group | Trailing group |
| --- | --- | --- | | --- | --- | --- |
| Collection | Reload, then collection context such as export | Help, then Create at the far right | | Overview | Reload when refreshable, then context | Help, then ordinary primary actions |
| Detail | Reload, then object context | Help, ordinary primary actions, then consequential actions | | Collection | Reload when refreshable, then collection context such as export | Help, then Create at the far right |
| Editor | Reload, then editor context such as preview | Help, Discard, then Save at the far right | | Detail | Reload when refreshable, then object context | Help, ordinary primary actions, then a separated destructive group |
| Editor | Reload only when refresh is a distinct safe operation, then context | Dirty state, Help, ordinary primary actions, separated destructive actions, Discard, then Save at the far right |
| Workspace | Reload when the coordinated projection can become stale, then task context | Help, ordinary primary actions, then a separated destructive group |
Reload means re-fetch or re-evaluate the current surface. It remains present Reload means re-fetch or re-evaluate the current surface. A page declaring
when the page can become stale. Create is a collection-wide action and is not `refreshable` must provide it, and a non-refreshable page must not use Reload as
duplicated in a persistent side panel. Save is present only where the page owns a synonym for Cancel, Reset, or Discard. Reload never silently destroys a dirty
an editable draft; a read-only detail page must not display a disabled or inert draft. Create is a collection-wide action and is not duplicated in a
Save merely to fill the slot. persistent side panel. Save is present only where the page owns an editable
draft; a read-only detail page must not display a disabled or inert Save merely
to fill the slot.
`PageActionBar` controls placement only. Actions continue to use central Editor bars always keep Discard and Save visible. They expose `clean`, `dirty`,
and `saving` status through a live status label. In the clean or saving state,
the central component disables both persistence actions and supplies the
standard explanation. A module may add a more specific validation, policy, or
permission blocker while the draft is dirty. The editor must register its
draft with `useUnsavedDraftGuard` (or a shared hook that uses the same
registration contract), so browser unload, route navigation, section changes,
and the explicit Discard path cannot silently lose work.
Destructive page actions use `destructiveActions`; never put a danger action in
`contextActions` or the ordinary primary group. Core renders a persistent
visual and semantic boundary before this group. In an editor it precedes the
Discard/Save pair, keeping Save in the final keyboard and visual position.
`PageActionBar` controls non-editor placement and owns the standard editor
persistence buttons. Other actions continue to use central
`Button`, `IconButton`, or `TableActionGroup` components. When an action is `Button`, `IconButton`, or `TableActionGroup` components. When an action is
visible but unavailable because of permission, target, policy, state, or visible but unavailable because of permission, target, policy, state, or
validation, keep it in its stable slot and supply `disabledReason`. Do not validation, keep it in its stable slot and supply `disabledReason`. Do not
@@ -65,8 +101,11 @@ silently hide a normally applicable action.
## Review Evidence ## Review Evidence
Every new or changed page should have structural evidence for its page frame, Every new or changed page must have structural evidence for its page frame,
semantic action archetype and slot order, shared component usage, stable semantic archetype and slot order, refresh declaration, shared component
disabled actions, and module-owned help identity. Keyboard and narrow-layout usage, stable disabled actions, dirty guard, destructive boundary, and
checks must confirm that all commands remain reachable in DOM order and that module-owned help identity. Type checks enforce conditional Reload and editor
the trailing group stays visually trailing after wrapping. persistence props. Product checks reject undeclared archetypes and ad-hoc
headed action fragments. Browser conformance confirms keyboard order, live
dirty-state changes, accessibility, destructive separation, narrow wrapping,
and screenshot geometry.
+2 -1
View File
@@ -57,7 +57,8 @@ contestability, responsibility, and traceability at the point of action.
| UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs | | UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs |
| UX-032 | `F1` resolves help from the focused field or action, then its dialog/section/page and registered route. Focused contexts retain the page fallback; Docs applies audience and permission filtering and falls back to visible module documentation. | Accepted | Core shell, Docs, and all module WebUIs | | UX-032 | `F1` resolves help from the focused field or action, then its dialog/section/page and registered route. Focused contexts retain the page fallback; Docs applies audience and permission filtering and falls back to visible module documentation. | Accepted | Core shell, Docs, and all module WebUIs |
| UX-033 | Global search is the left-most titlebar command, immediately before language selection. Its icon, `F3`, and `Ctrl`/`Cmd`+`K` all open the same permission-aware search overlay; the titlebar does not reserve a persistent query field. | Accepted | Core shell and Search WebUI | | UX-033 | Global search is the left-most titlebar command, immediately before language selection. Its icon, `F3`, and `Ctrl`/`Cmd`+`K` all open the same permission-aware search overlay; the titlebar does not reserve a persistent query field. | Accepted | Core shell and Search WebUI |
| UX-034 | Headed pages use the semantic `PageActionBar` slots instead of arranging primary page actions ad hoc. Collections place Reload in the leading group and Create at the far right; details place Reload first and contextual, primary, then consequential actions in stable groups; editors place Reload first and Discard immediately before the far-right Save. Read-only pages do not invent a meaningless Save action, and unavailable actions retain shared actionable blocker explanations. | Accepted | Core and all module WebUIs | | UX-034 | Every headed `PageLayout` declares one of `overview`, `collection`, `detail`, `editor`, or `workspace` independently from its standalone/workspace/embedded geometry. Its actions use the matching semantic `PageActionBar`: a refreshable page must provide Reload in the leading slot; collections keep Create far right; read-only pages do not invent Save. | Accepted | Core and all module WebUIs |
| UX-035 | Editor action bars expose clean, dirty, and saving state; always retain Discard immediately before the far-right Save; centrally disable both while clean or saving; and participate in the unsaved-change navigation guard. Danger actions occupy the explicit separated destructive group after ordinary actions and before editor persistence. | Accepted | Core and all module WebUIs |
## Confirmed Implementation Decisions ## Confirmed Implementation Decisions
+9 -9
View File
@@ -33,29 +33,29 @@ import type { AuthInfo } from "../src/types";
export default function ConformanceApp() { export default function ConformanceApp() {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editorDirty, setEditorDirty] = useState(true);
return ( return (
<main className="conformance-root" data-conformance-id="shared-ui-lab"> <main className="conformance-root" data-conformance-id="shared-ui-lab">
<PageLayout <PageLayout
archetype="overview"
mode="embedded" mode="embedded"
title="Zentrale GovOPlaN-Oberflächen" title="Zentrale GovOPlaN-Oberflächen"
description="Diese Prüfseite rendert reale gemeinsame Komponenten mit langen deutschen Beschriftungen, Zuständen und responsiven Zusammensetzungen." description="Diese Prüfseite rendert reale gemeinsame Komponenten mit langen deutschen Beschriftungen, Zuständen und responsiven Zusammensetzungen."
actions={<Button variant="primary" data-testid="open-dialog" onClick={() => setDialogOpen(true)}>Prüfdialog öffnen</Button>} actions={<PageActionBar variant="overview" primaryActions={<Button variant="primary" data-testid="open-dialog" onClick={() => setDialogOpen(true)}>Prüfdialog öffnen</Button>} />}
> >
<section className="conformance-section" aria-labelledby="actions-heading"> <section className="conformance-section" aria-labelledby="actions-heading">
<h2 id="actions-heading">Aktionen, Filter und Status</h2> <h2 id="actions-heading">Aktionen, Filter und Status</h2>
<PageActionBar <PageActionBar
variant="editor" variant="editor"
refreshable
dirty={editorDirty}
label="Bearbeitungsaktionen" label="Bearbeitungsaktionen"
reloadAction={<Button variant="ghost">Neu laden</Button>} reloadAction={<Button variant="ghost">Neu laden</Button>}
contextActions={( contextActions={<Button>Vorschau öffnen</Button>}
<> destructiveActions={<Button variant="danger" disabledReason="Nur die federführende Stelle darf diesen Vorgang endgültig löschen.">Löschen</Button>}
<Button>Vorschau öffnen</Button> discardAction={{ label: "Verwerfen", onClick: () => setEditorDirty(false) }}
<Button variant="danger" disabledReason="Nur die federführende Stelle darf diesen Vorgang endgültig löschen.">Löschen</Button> saveAction={{ label: "Änderungen speichern", onClick: () => setEditorDirty(false) }}
</>
)}
discardAction={<Button variant="ghost">Verwerfen</Button>}
saveAction={<Button variant="primary">Änderungen speichern</Button>}
/> />
<FilterBar surface="control" aria-label="Vorgangsliste filtern"> <FilterBar surface="control" aria-label="Vorgangsliste filtern">
<label>Suche<input type="search" placeholder="Aktenzeichen oder verantwortliche Stelle" /></label> <label>Suche<input type="search" placeholder="Aktenzeichen oder verantwortliche Stelle" /></label>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 214 KiB

@@ -25,6 +25,20 @@ test("shared components remain accessible and keyboard operable", async ({ page
await expectNoAccessibilityViolations(page); await expectNoAccessibilityViolations(page);
const editorActions = page.getByRole("toolbar", { name: "Bearbeitungsaktionen" }); const editorActions = page.getByRole("toolbar", { name: "Bearbeitungsaktionen" });
await expect(page.locator("[data-page-archetype='overview']")).toBeVisible();
await expect(editorActions).toHaveAttribute("data-page-refreshable", "true");
await expect(editorActions).toHaveAttribute("data-page-dirty", "true");
await expect(editorActions.getByRole("status")).toHaveText("Unsaved changes");
await expect(editorActions.locator("[data-page-action-separation='destructive']")).toHaveCount(1);
await expect(editorActions.locator("[data-page-action-separation='destructive']")).toHaveCSS("border-left-width", "2px");
const actionSlots = await editorActions.locator("[data-page-action-slot]").evaluateAll((elements) => elements.map((element) => element.getAttribute("data-page-action-slot")));
expect(actionSlots).toEqual([
"reload",
"context",
"destructive",
"discard",
"save"
]);
await expect(editorActions.getByRole("button")).toHaveText([ await expect(editorActions.getByRole("button")).toHaveText([
"Neu laden", "Neu laden",
"Vorschau öffnen", "Vorschau öffnen",
@@ -42,6 +56,11 @@ test("shared components remain accessible and keyboard operable", async ({ page
await expect(editorActions.getByRole("button", { name: "Verwerfen" })).toBeFocused(); await expect(editorActions.getByRole("button", { name: "Verwerfen" })).toBeFocused();
await page.keyboard.press("Tab"); await page.keyboard.press("Tab");
await expect(editorActions.getByRole("button", { name: "Änderungen speichern" })).toBeFocused(); await expect(editorActions.getByRole("button", { name: "Änderungen speichern" })).toBeFocused();
await page.keyboard.press("Enter");
await expect(editorActions).toHaveAttribute("data-page-dirty", "false");
await expect(editorActions.getByRole("status")).toHaveText("Saved");
await expect(editorActions.getByRole("button", { name: "Verwerfen" })).toBeDisabled();
await expect(editorActions.getByRole("button", { name: "Änderungen speichern" })).toBeDisabled();
const opener = page.getByTestId("open-dialog"); const opener = page.getByTestId("open-dialog");
await opener.focus(); await opener.focus();
@@ -92,5 +111,8 @@ test("narrow layout preserves task order without horizontal overflow", async ({
expect(overflowing).toEqual([]); expect(overflowing).toEqual([]);
await expect(page.locator("[data-page-action-archetype='editor'] [data-page-action-group='trailing']")) await expect(page.locator("[data-page-action-archetype='editor'] [data-page-action-group='trailing']"))
.toHaveCSS("justify-content", "flex-end"); .toHaveCSS("justify-content", "flex-end");
const saveRightEdge = await page.locator("[data-page-action-archetype='editor'] [data-page-action-slot='save']").evaluate((element) => element.getBoundingClientRect().right);
const actionBarRightEdge = await page.locator("[data-page-action-archetype='editor']").evaluate((element) => element.getBoundingClientRect().right);
expect(Math.abs(actionBarRightEdge - saveRightEdge)).toBeLessThanOrEqual(1);
await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-light-narrow.png", { animations: "disabled", maxDiffPixelRatio: 0.005 }); await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-light-narrow.png", { animations: "disabled", maxDiffPixelRatio: 0.005 });
}); });
+10 -2
View File
@@ -28,8 +28,8 @@ const layoutStyles = read("src/styles/layout.css");
const authGateStyles = read("src/styles/auth-gate.css"); const authGateStyles = read("src/styles/auth-gate.css");
assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation"); assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation");
assert.match(settings, /There are no unsaved profile changes\./, "profile save explains its clean state"); assert.match(settings, /archetype=\{editorSection \? "editor" : "workspace"\}/, "settings declare editor intent only for draft-owning sections");
assert.match(settings, /There are no unsaved interface changes\./, "preference save explains its clean state"); assert.match(settings, /<PageActionBar[\s\S]*variant="editor"[\s\S]*dirty=\{editorDirty\}[\s\S]*discardAction=[\s\S]*saveAction=/, "settings use central dirty persistence actions");
assert.match(retention, /<ActionBlockerHint/, "retention renders the shared actionable blocker"); assert.match(retention, /<ActionBlockerHint/, "retention renders the shared actionable blocker");
assert.match(retention, /contextId: "privacy\.retention"/, "retention exposes stable admin documentation"); assert.match(retention, /contextId: "privacy\.retention"/, "retention exposes stable admin documentation");
@@ -52,6 +52,8 @@ assert.match(credentials, /disabledReason: writeDisabledReason/, "credential row
assert.doesNotMatch(credentials, /<textarea/, "credentials use typed controls rather than a primary JSON editor"); assert.doesNotMatch(credentials, /<textarea/, "credentials use typed controls rather than a primary JSON editor");
assert.match(pageLayout, /`page-layout-\$\{mode\}`/, "shared standalone and embedded pages use one central frame"); assert.match(pageLayout, /`page-layout-\$\{mode\}`/, "shared standalone and embedded pages use one central frame");
assert.match(pageLayout, /export type PageArchetype = "overview" \| "collection" \| "detail" \| "editor" \| "workspace"/, "page intent uses the five central archetypes");
assert.match(pageLayout, /data-page-archetype=\{archetype\}/, "shared pages expose their semantic archetype");
assert.match(pageLayout, /<PageHeader/, "shared pages use one central responsive heading"); assert.match(pageLayout, /<PageHeader/, "shared pages use one central responsive heading");
assert.match(pageLayout, /data-help-scope="page"/, "shared pages preserve contextual-help identity"); assert.match(pageLayout, /data-help-scope="page"/, "shared pages preserve contextual-help identity");
assert.match(adminPageLayout, /<PageLayout/, "administration composes the central page layout instead of redefining it"); assert.match(adminPageLayout, /<PageLayout/, "administration composes the central page layout instead of redefining it");
@@ -62,7 +64,13 @@ assert.match(actionToolbar, /data-help-scope="toolbar"/, "shared toolbars preser
assert.match(pageActionBar, /variant: "collection"/, "collection pages have a semantic action-bar contract"); assert.match(pageActionBar, /variant: "collection"/, "collection pages have a semantic action-bar contract");
assert.match(pageActionBar, /variant: "detail"/, "detail pages have a semantic action-bar contract"); assert.match(pageActionBar, /variant: "detail"/, "detail pages have a semantic action-bar contract");
assert.match(pageActionBar, /variant: "editor"/, "editors have a semantic action-bar contract"); assert.match(pageActionBar, /variant: "editor"/, "editors have a semantic action-bar contract");
assert.match(pageActionBar, /variant: "overview"/, "overview pages have a semantic action-bar contract");
assert.match(pageActionBar, /variant: "workspace"/, "task workspaces have a semantic action-bar contract");
assert.match(pageActionBar, /refreshable: true;\s*reloadAction: ReactNode;/, "refreshable pages require a Reload action at type level");
assert.match(pageActionBar, /dirty: boolean;/, "editors require an explicit dirty state");
assert.match(pageActionBar, /data-page-dirty-state=/, "editors announce clean, dirty, and saving states");
assert.match(pageActionBar, /<ActionSlot name="reload">/, "page action bars keep reload in a named stable slot"); assert.match(pageActionBar, /<ActionSlot name="reload">/, "page action bars keep reload in a named stable slot");
assert.match(pageActionBar, /data-page-action-separation="destructive"/, "destructive page actions expose a separate semantic group");
assert.match(pageActionBar, /<ActionSlot name="discard">[\s\S]*<ActionSlot name="save">/, "editor save follows discard in the trailing group"); assert.match(pageActionBar, /<ActionSlot name="discard">[\s\S]*<ActionSlot name="save">/, "editor save follows discard in the trailing group");
assert.match(contentGrid, /content-grid-collapse-\$\{collapseAt\}/, "shared grids make their collapse point explicit"); assert.match(contentGrid, /content-grid-collapse-\$\{collapseAt\}/, "shared grids make their collapse point explicit");
assert.match(formSection, /form-section-header/, "shared form sections own heading and action placement"); assert.match(formSection, /form-section-header/, "shared form sections own heading and action placement");
+127 -19
View File
@@ -1,36 +1,71 @@
import type { HTMLAttributes, ReactNode } from "react"; import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import type { PlatformInterfaceIdentityProps } from "../types"; import type { PlatformInterfaceIdentityProps } from "../types";
import ActionToolbar, { ToolbarGroup } from "./ActionToolbar"; import ActionToolbar, { ToolbarGroup } from "./ActionToolbar";
import Button from "./Button";
type RefreshablePageActions = {
refreshable: true;
reloadAction: ReactNode;
} | {
refreshable?: false;
reloadAction?: never;
};
type PageActionBarCommonProps = PlatformInterfaceIdentityProps & type PageActionBarCommonProps = PlatformInterfaceIdentityProps &
Omit<HTMLAttributes<HTMLDivElement>, "children"> & { Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
reloadAction: ReactNode;
contextActions?: ReactNode; contextActions?: ReactNode;
helpAction?: ReactNode; helpAction?: ReactNode;
label?: string; label?: string;
}; };
export type CollectionPageActionBarProps = PageActionBarCommonProps & { export type PageEditorAction = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & PlatformInterfaceIdentityProps & {
label: ReactNode;
disabledReason?: ReactNode;
};
export type OverviewPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "overview";
primaryActions?: ReactNode;
};
export type CollectionPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "collection"; variant: "collection";
createAction?: ReactNode; createAction?: ReactNode;
}; };
export type DetailPageActionBarProps = PageActionBarCommonProps & { export type DetailPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "detail"; variant: "detail";
primaryActions?: ReactNode; primaryActions?: ReactNode;
consequentialActions?: ReactNode; destructiveActions?: ReactNode;
}; };
export type EditorPageActionBarProps = PageActionBarCommonProps & { export type EditorPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "editor"; variant: "editor";
discardAction: ReactNode; dirty: boolean;
saveAction: ReactNode; saving?: boolean;
dirtyLabel?: ReactNode;
cleanLabel?: ReactNode;
savingLabel?: ReactNode;
cleanDisabledReason?: ReactNode;
savingDisabledReason?: ReactNode;
discardAction: PageEditorAction;
saveAction: PageEditorAction;
primaryActions?: ReactNode;
destructiveActions?: ReactNode;
};
export type WorkspacePageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "workspace";
primaryActions?: ReactNode;
destructiveActions?: ReactNode;
}; };
export type PageActionBarProps = export type PageActionBarProps =
| OverviewPageActionBarProps
| CollectionPageActionBarProps | CollectionPageActionBarProps
| DetailPageActionBarProps | DetailPageActionBarProps
| EditorPageActionBarProps; | EditorPageActionBarProps
| WorkspacePageActionBarProps;
function ActionSlot({ name, children }: { name: string; children: ReactNode }) { function ActionSlot({ name, children }: { name: string; children: ReactNode }) {
return ( return (
@@ -41,11 +76,41 @@ function ActionSlot({ name, children }: { name: string; children: ReactNode }) {
} }
const defaultLabels = { const defaultLabels = {
overview: "Overview page actions",
collection: "Collection page actions", collection: "Collection page actions",
detail: "Detail page actions", detail: "Detail page actions",
editor: "Editor page actions" editor: "Editor page actions",
workspace: "Workspace page actions"
} as const; } as const;
function DestructiveSlot({ children }: { children: ReactNode }) {
if (!children) return null;
return (
<ActionSlot name="destructive">
<span className="page-action-destructive-group" data-page-action-separation="destructive">
{children}
</span>
</ActionSlot>
);
}
function EditorAction({
action,
variant,
disabledReason
}: {
action: PageEditorAction;
variant: "ghost" | "primary";
disabledReason?: ReactNode;
}) {
const { label, disabledReason: actionDisabledReason, ...buttonProps } = action;
return (
<Button {...buttonProps} variant={variant} disabledReason={disabledReason ?? actionDisabledReason}>
{label}
</Button>
);
}
/** /**
* Semantic action placement for headed pages. * Semantic action placement for headed pages.
* *
@@ -56,18 +121,33 @@ export default function PageActionBar(props: PageActionBarProps) {
const normalizedProps = props as PageActionBarProps & { const normalizedProps = props as PageActionBarProps & {
createAction?: ReactNode; createAction?: ReactNode;
primaryActions?: ReactNode; primaryActions?: ReactNode;
consequentialActions?: ReactNode; destructiveActions?: ReactNode;
discardAction?: ReactNode; dirty?: boolean;
saveAction?: ReactNode; saving?: boolean;
dirtyLabel?: ReactNode;
cleanLabel?: ReactNode;
savingLabel?: ReactNode;
cleanDisabledReason?: ReactNode;
savingDisabledReason?: ReactNode;
discardAction?: PageEditorAction;
saveAction?: PageEditorAction;
}; };
const { const {
variant, variant,
refreshable = false,
reloadAction, reloadAction,
contextActions, contextActions,
helpAction, helpAction,
createAction, createAction,
primaryActions, primaryActions,
consequentialActions, destructiveActions,
dirty = false,
saving = false,
dirtyLabel = "Unsaved changes",
cleanLabel = "Saved",
savingLabel = "Saving…",
cleanDisabledReason = "There are no unsaved changes.",
savingDisabledReason = "Changes are already being saved.",
discardAction, discardAction,
saveAction, saveAction,
label, label,
@@ -80,20 +160,36 @@ export default function PageActionBar(props: PageActionBarProps) {
} = normalizedProps; } = normalizedProps;
let trailingActions: ReactNode; let trailingActions: ReactNode;
if (variant === "collection") { if (variant === "overview") {
trailingActions = primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null;
} else if (variant === "collection") {
trailingActions = createAction ? <ActionSlot name="create">{createAction}</ActionSlot> : null; trailingActions = createAction ? <ActionSlot name="create">{createAction}</ActionSlot> : null;
} else if (variant === "detail") { } else if (variant === "detail") {
trailingActions = ( trailingActions = (
<> <>
{primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null} {primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null}
{consequentialActions ? <ActionSlot name="consequential">{consequentialActions}</ActionSlot> : null} <DestructiveSlot>{destructiveActions}</DestructiveSlot>
</>
);
} else if (variant === "editor") {
const persistenceDisabledReason = saving ? savingDisabledReason : !dirty ? cleanDisabledReason : undefined;
trailingActions = (
<>
{primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null}
<DestructiveSlot>{destructiveActions}</DestructiveSlot>
<ActionSlot name="discard">
<EditorAction action={discardAction!} variant="ghost" disabledReason={persistenceDisabledReason} />
</ActionSlot>
<ActionSlot name="save">
<EditorAction action={saveAction!} variant="primary" disabledReason={persistenceDisabledReason} />
</ActionSlot>
</> </>
); );
} else { } else {
trailingActions = ( trailingActions = (
<> <>
<ActionSlot name="discard">{discardAction}</ActionSlot> {primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null}
<ActionSlot name="save">{saveAction}</ActionSlot> <DestructiveSlot>{destructiveActions}</DestructiveSlot>
</> </>
); );
} }
@@ -111,12 +207,24 @@ export default function PageActionBar(props: PageActionBarProps) {
helpModuleId={helpModuleId} helpModuleId={helpModuleId}
helpTopicId={helpTopicId} helpTopicId={helpTopicId}
data-page-action-archetype={variant} data-page-action-archetype={variant}
data-page-refreshable={refreshable ? "true" : "false"}
data-page-dirty={variant === "editor" ? (dirty ? "true" : "false") : undefined}
> >
<ToolbarGroup className="page-action-bar-leading" data-page-action-group="leading"> <ToolbarGroup className="page-action-bar-leading" data-page-action-group="leading">
<ActionSlot name="reload">{reloadAction}</ActionSlot> {refreshable ? <ActionSlot name="reload">{reloadAction}</ActionSlot> : null}
{contextActions ? <ActionSlot name="context">{contextActions}</ActionSlot> : null} {contextActions ? <ActionSlot name="context">{contextActions}</ActionSlot> : null}
</ToolbarGroup> </ToolbarGroup>
<ToolbarGroup className="page-action-bar-trailing" align="end" data-page-action-group="trailing"> <ToolbarGroup className="page-action-bar-trailing" align="end" data-page-action-group="trailing">
{variant === "editor" ? (
<span
className={`page-dirty-state page-dirty-state-${saving ? "saving" : dirty ? "dirty" : "clean"}`}
data-page-dirty-state={saving ? "saving" : dirty ? "dirty" : "clean"}
role="status"
aria-live="polite"
>
{saving ? savingLabel : dirty ? dirtyLabel : cleanLabel}
</span>
) : null}
{helpAction ? <ActionSlot name="help">{helpAction}</ActionSlot> : null} {helpAction ? <ActionSlot name="help">{helpAction}</ActionSlot> : null}
{trailingActions} {trailingActions}
</ToolbarGroup> </ToolbarGroup>
+5
View File
@@ -7,6 +7,7 @@ import PageScrollViewport from "./PageScrollViewport";
import PageTitle from "./PageTitle"; import PageTitle from "./PageTitle";
export type PageLayoutMode = "standalone" | "workspace" | "embedded"; export type PageLayoutMode = "standalone" | "workspace" | "embedded";
export type PageArchetype = "overview" | "collection" | "detail" | "editor" | "workspace";
export type PageHeaderProps = { export type PageHeaderProps = {
title: ReactNode; title: ReactNode;
@@ -48,6 +49,8 @@ export function PageHeader({
} }
export type PageLayoutProps = PlatformInterfaceIdentityProps & { export type PageLayoutProps = PlatformInterfaceIdentityProps & {
/** Semantic page intent. This is independent from viewport/layout geometry. */
archetype: PageArchetype;
title: ReactNode; title: ReactNode;
description?: ReactNode; description?: ReactNode;
actions?: ReactNode; actions?: ReactNode;
@@ -70,6 +73,7 @@ export type PageLayoutProps = PlatformInterfaceIdentityProps & {
}; };
export default function PageLayout({ export default function PageLayout({
archetype,
title, title,
description, description,
actions, actions,
@@ -107,6 +111,7 @@ export default function PageLayout({
const layout = ( const layout = (
<div <div
className={layoutClasses} className={layoutClasses}
data-page-archetype={archetype}
data-help-scope="page" data-help-scope="page"
data-interface-id={interfaceId} data-interface-id={interfaceId}
data-help-context-id={helpContextId} data-help-context-id={helpContextId}
@@ -1,8 +1,9 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { PlatformInterfaceIdentityProps } from "../../types"; import type { PlatformInterfaceIdentityProps } from "../../types";
import PageLayout from "../PageLayout"; import PageLayout, { type PageArchetype } from "../PageLayout";
export type AdminPageLayoutProps = PlatformInterfaceIdentityProps & { export type AdminPageLayoutProps = PlatformInterfaceIdentityProps & {
archetype?: PageArchetype;
title: string; title: string;
description: string; description: string;
loading?: boolean; loading?: boolean;
@@ -15,6 +16,7 @@ export type AdminPageLayoutProps = PlatformInterfaceIdentityProps & {
}; };
export default function AdminPageLayout({ export default function AdminPageLayout({
archetype = "detail",
title, title,
description, description,
loading = false, loading = false,
@@ -31,6 +33,7 @@ export default function AdminPageLayout({
}: AdminPageLayoutProps) { }: AdminPageLayoutProps) {
return ( return (
<PageLayout <PageLayout
archetype={archetype}
title={title} title={title}
description={description} description={description}
loading={loading} loading={loading}
@@ -11,6 +11,7 @@ export default function DashboardPage() {
return ( return (
<PageLayout <PageLayout
archetype="overview"
title="i18n:govoplan-core.dashboard.d87f47b4" title="i18n:govoplan-core.dashboard.d87f47b4"
description="Install and enable the dashboard module to make this page configurable." description="Install and enable the dashboard module to make this page configurable."
viewportClassName="core-dashboard-page" viewportClassName="core-dashboard-page"
+33 -31
View File
@@ -8,6 +8,7 @@ import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField"; import PasswordField from "../../components/PasswordField";
import Button from "../../components/Button"; import Button from "../../components/Button";
import PageLayout from "../../components/PageLayout"; import PageLayout from "../../components/PageLayout";
import PageActionBar from "../../components/PageActionBar";
import ToggleSwitch from "../../components/ToggleSwitch"; import ToggleSwitch from "../../components/ToggleSwitch";
import { apiFetch } from "../../api/client"; import { apiFetch } from "../../api/client";
import { fetchAuthProfile, fetchAuthRoles, updateProfile } from "../../api/auth"; import { fetchAuthProfile, fetchAuthRoles, updateProfile } from "../../api/auth";
@@ -306,6 +307,22 @@ export default function SettingsPage({
} }
} }
const editorSection = active === "profile" || active === "interface" || active === "workspace";
const editorDirty = active === "profile" ? profileDirty : editorSection ? uiDirty : false;
const editorSaving = active === "profile" ? profileBusy : editorSection ? uiBusy : false;
const discardEditor = () => {
if (active === "profile") {
setProfileName(auth.user.display_name || "");
setTenantProfileName(auth.user.tenant_display_name || "");
} else {
resetUiPreferences();
}
};
const saveEditor = () => {
if (active === "profile") void saveProfile();
else void saveUiPreferences();
};
return ( return (
<WorkspaceLayout <WorkspaceLayout
className="module-workspace" className="module-workspace"
@@ -314,9 +331,24 @@ export default function SettingsPage({
contentLabel="i18n:govoplan-core.settings.c7f73bb5" contentLabel="i18n:govoplan-core.settings.c7f73bb5"
> >
<PageLayout <PageLayout
archetype={editorSection ? "editor" : "workspace"}
title="i18n:govoplan-core.settings.c7f73bb5" title="i18n:govoplan-core.settings.c7f73bb5"
description="i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56" description="i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56"
actions={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />} actions={editorSection ? (
<PageActionBar
variant="editor"
dirty={editorDirty}
saving={editorSaving}
helpAction={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />}
discardAction={{ label: "i18n:govoplan-core.discard.36fff63c", onClick: discardEditor }}
saveAction={{
label: active === "profile" ? "i18n:govoplan-core.save_profile.f597c0e8" : "i18n:govoplan-core.save_preferences.0f1a7e44",
onClick: saveEditor
}}
/>
) : (
<PageActionBar variant="workspace" helpAction={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />} />
)}
mode="workspace" mode="workspace"
> >
@@ -333,16 +365,6 @@ export default function SettingsPage({
<FormField label="i18n:govoplan-core.email.84add5b2"> <FormField label="i18n:govoplan-core.email.84add5b2">
<input value={auth.user.email} disabled /> <input value={auth.user.email} disabled />
</FormField> </FormField>
<div className="button-row compact-actions">
<Button
variant="primary"
onClick={() => void saveProfile()}
disabled={profileBusy || !profileDirty}
disabledReason={profileBusy ? "Profile changes are already being saved." : !profileDirty ? "There are no unsaved profile changes." : undefined}
>
{profileBusy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_profile.f597c0e8"}
</Button>
</div>
{profileResult && <DismissibleAlert tone={profileResultTone} resetKey={profileResult} floating>{profileResult}</DismissibleAlert>} {profileResult && <DismissibleAlert tone={profileResultTone} resetKey={profileResult} floating>{profileResult}</DismissibleAlert>}
</FormGrid> </FormGrid>
</Card> </Card>
@@ -418,16 +440,6 @@ export default function SettingsPage({
checked={reduceMotion} checked={reduceMotion}
onChange={setReduceMotion} /> onChange={setReduceMotion} />
<div className="button-row compact-actions">
<Button
variant="primary"
onClick={() => void saveUiPreferences()}
disabled={uiBusy || !uiDirty}
disabledReason={uiBusy ? "Interface preferences are already being saved." : !uiDirty ? "There are no unsaved interface changes." : undefined}
>
{uiBusy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_preferences.0f1a7e44"}
</Button>
</div>
{uiResult && <DismissibleAlert tone={uiResultTone} resetKey={uiResult} floating>{uiResult}</DismissibleAlert>} {uiResult && <DismissibleAlert tone={uiResultTone} resetKey={uiResult} floating>{uiResult}</DismissibleAlert>}
</FormGrid> </FormGrid>
</Card> </Card>
@@ -481,16 +493,6 @@ export default function SettingsPage({
disabled disabled
onChange={() => undefined} /> onChange={() => undefined} />
<div className="button-row compact-actions">
<Button
variant="primary"
onClick={() => void saveUiPreferences()}
disabled={uiBusy || !uiDirty}
disabledReason={uiBusy ? "Workspace preferences are already being saved." : !uiDirty ? "There are no unsaved workspace changes." : undefined}
>
{uiBusy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_preferences.0f1a7e44"}
</Button>
</div>
{uiResult && <DismissibleAlert tone={uiResultTone} resetKey={uiResult} floating>{uiResult}</DismissibleAlert>} {uiResult && <DismissibleAlert tone={uiResultTone} resetKey={uiResult} floating>{uiResult}</DismissibleAlert>}
</FormGrid> </FormGrid>
</Card> </Card>
+2 -2
View File
@@ -142,9 +142,9 @@ export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel"; export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
export { default as PageTitle } from "./components/PageTitle"; export { default as PageTitle } from "./components/PageTitle";
export { default as PageLayout, PageHeader } from "./components/PageLayout"; export { default as PageLayout, PageHeader } from "./components/PageLayout";
export type { PageHeaderProps, PageLayoutMode, PageLayoutProps } from "./components/PageLayout"; export type { PageArchetype, PageHeaderProps, PageLayoutMode, PageLayoutProps } from "./components/PageLayout";
export { default as PageActionBar } from "./components/PageActionBar"; export { default as PageActionBar } from "./components/PageActionBar";
export type { CollectionPageActionBarProps, DetailPageActionBarProps, EditorPageActionBarProps, PageActionBarProps } from "./components/PageActionBar"; export type { CollectionPageActionBarProps, DetailPageActionBarProps, EditorPageActionBarProps, OverviewPageActionBarProps, PageActionBarProps, PageEditorAction, WorkspacePageActionBarProps } from "./components/PageActionBar";
export { default as PageScrollViewport } from "./components/PageScrollViewport"; export { default as PageScrollViewport } from "./components/PageScrollViewport";
export type { PageScrollViewportProps } from "./components/PageScrollViewport"; export type { PageScrollViewportProps } from "./components/PageScrollViewport";
export { default as WorkspaceLayout } from "./components/WorkspaceLayout"; export { default as WorkspaceLayout } from "./components/WorkspaceLayout";
+5
View File
@@ -101,6 +101,11 @@
.action-toolbar-spacer { flex: 1 1 auto; min-width: 8px; } .action-toolbar-spacer { flex: 1 1 auto; min-width: 8px; }
.page-action-slot { min-width: 0; display: inline-flex; align-items: center; } .page-action-slot { min-width: 0; display: inline-flex; align-items: center; }
.page-action-bar-trailing { flex: 0 1 auto; } .page-action-bar-trailing { flex: 0 1 auto; }
.page-action-destructive-group { display: inline-flex; align-items: center; gap: var(--space-2); border-inline-start: 2px solid var(--line-dark); margin-inline-start: 4px; padding-inline-start: var(--space-2); }
.page-dirty-state { display: inline-flex; align-items: center; gap: 6px; min-height: 28px; color: var(--text-soft); font-size: 12px; font-weight: 700; white-space: nowrap; }
.page-dirty-state::before { width: 8px; height: 8px; border-radius: var(--radius-pill); background: var(--success); content: ""; }
.page-dirty-state-dirty::before { background: var(--warning); }
.page-dirty-state-saving::before { background: var(--accent); }
.app-content { min-height: 0; overflow: hidden; } .app-content { min-height: 0; overflow: hidden; }
.workspace { height: 100%; min-height: 0; display: grid; grid-template-columns: 198px minmax(0, 1fr); } .workspace { height: 100%; min-height: 0; display: grid; grid-template-columns: 198px minmax(0, 1fr); }
.workspace-layout-primary, .workspace-layout-primary,
+32 -2
View File
@@ -22,6 +22,13 @@ import StatePanel from "../src/components/StatePanel";
import WorkspaceLayout from "../src/components/WorkspaceLayout"; import WorkspaceLayout from "../src/components/WorkspaceLayout";
import WorkspaceFrame from "../src/components/WorkspaceFrame"; import WorkspaceFrame from "../src/components/WorkspaceFrame";
// @ts-expect-error Refreshable pages must provide a Reload action.
const refreshableWithoutReload = <PageActionBar variant="detail" refreshable />;
// @ts-expect-error Editor pages must declare dirty state and persistence actions.
const editorWithoutPersistence = <PageActionBar variant="editor" />;
void refreshableWithoutReload;
void editorWithoutPersistence;
const toolbarMarkup = renderToStaticMarkup( const toolbarMarkup = renderToStaticMarkup(
<PlatformLanguageProvider> <PlatformLanguageProvider>
<ActionToolbar label="Object actions" density="compact" justify="between" surface="subtle" interfaceId="object.toolbar"> <ActionToolbar label="Object actions" density="compact" justify="between" surface="subtle" interfaceId="object.toolbar">
@@ -44,26 +51,49 @@ const editorActionBarMarkup = renderToStaticMarkup(
<PlatformLanguageProvider> <PlatformLanguageProvider>
<PageActionBar <PageActionBar
variant="editor" variant="editor"
refreshable
dirty
label="Editor actions" label="Editor actions"
reloadAction={<button type="button">Reload</button>} reloadAction={<button type="button">Reload</button>}
contextActions={<button type="button">Preview</button>} contextActions={<button type="button">Preview</button>}
helpAction={<button type="button">Help</button>} helpAction={<button type="button">Help</button>}
discardAction={<button type="button">Discard</button>} destructiveActions={<button type="button">Delete</button>}
saveAction={<button type="button">Save</button>} discardAction={{ label: "Discard" }}
saveAction={{ label: "Save" }}
/> />
</PlatformLanguageProvider> </PlatformLanguageProvider>
); );
assert(editorActionBarMarkup.includes('data-page-action-archetype="editor"'), "page action bars expose their semantic archetype"); assert(editorActionBarMarkup.includes('data-page-action-archetype="editor"'), "page action bars expose their semantic archetype");
assert(editorActionBarMarkup.includes('aria-label="Editor actions"'), "page action bars are named toolbars"); assert(editorActionBarMarkup.includes('aria-label="Editor actions"'), "page action bars are named toolbars");
assert(editorActionBarMarkup.includes('data-page-refreshable="true"'), "refreshable pages expose their refresh contract");
assert(editorActionBarMarkup.includes('data-page-dirty="true"'), "editors expose their dirty state");
assert(editorActionBarMarkup.includes('data-page-dirty-state="dirty"'), "dirty editors announce unsaved changes");
assert(editorActionBarMarkup.indexOf('data-page-action-slot="reload"') < editorActionBarMarkup.indexOf('data-page-action-slot="context"'), "reload precedes contextual actions"); assert(editorActionBarMarkup.indexOf('data-page-action-slot="reload"') < editorActionBarMarkup.indexOf('data-page-action-slot="context"'), "reload precedes contextual actions");
assert(editorActionBarMarkup.indexOf('data-page-action-slot="help"') < editorActionBarMarkup.indexOf('data-page-action-slot="discard"'), "help precedes editor persistence actions"); assert(editorActionBarMarkup.indexOf('data-page-action-slot="help"') < editorActionBarMarkup.indexOf('data-page-action-slot="discard"'), "help precedes editor persistence actions");
assert(editorActionBarMarkup.indexOf('data-page-action-slot="destructive"') < editorActionBarMarkup.indexOf('data-page-action-slot="discard"'), "destructive editor actions are separated from persistence actions");
assert(editorActionBarMarkup.includes('data-page-action-separation="destructive"'), "destructive actions expose their visual boundary");
assert(editorActionBarMarkup.indexOf('data-page-action-slot="discard"') < editorActionBarMarkup.indexOf('data-page-action-slot="save"'), "save remains the far-right editor action"); assert(editorActionBarMarkup.indexOf('data-page-action-slot="discard"') < editorActionBarMarkup.indexOf('data-page-action-slot="save"'), "save remains the far-right editor action");
const cleanEditorActionBarMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<PageActionBar
variant="editor"
dirty={false}
discardAction={{ label: "Discard" }}
saveAction={{ label: "Save" }}
/>
</PlatformLanguageProvider>
);
assert(cleanEditorActionBarMarkup.includes('data-page-dirty-state="clean"'), "clean editors announce their persisted state");
assert((cleanEditorActionBarMarkup.match(/disabled=""/g) ?? []).length === 2, "clean editors keep Save and Discard visible but disabled");
assert((cleanEditorActionBarMarkup.match(/disabled-action-tooltip/g) ?? []).length === 2, "clean editor actions expose explanations for unavailable persistence");
const collectionActionBarMarkup = renderToStaticMarkup( const collectionActionBarMarkup = renderToStaticMarkup(
<PlatformLanguageProvider> <PlatformLanguageProvider>
<PageActionBar <PageActionBar
variant="collection" variant="collection"
refreshable
reloadAction={<button type="button">Reload</button>} reloadAction={<button type="button">Reload</button>}
createAction={<button type="button">Create</button>} createAction={<button type="button">Create</button>}
/> />
+8 -4
View File
@@ -3,15 +3,17 @@ function assert(condition: unknown, message = "assertion failed"): void {
} }
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import PageActionBar from "../src/components/PageActionBar";
import PageLayout from "../src/components/PageLayout"; import PageLayout from "../src/components/PageLayout";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
const standaloneMarkup = renderToStaticMarkup( const standaloneMarkup = renderToStaticMarkup(
<PlatformLanguageProvider> <PlatformLanguageProvider>
<PageLayout <PageLayout
archetype="collection"
title="Shared page" title="Shared page"
description="One page frame" description="One page frame"
actions={<button type="button">Reload</button>} actions={<PageActionBar variant="collection" refreshable reloadAction={<button type="button">Reload</button>} />}
error="Could not load" error="Could not load"
success="Saved" success="Saved"
interfaceId="test.page" interfaceId="test.page"
@@ -30,13 +32,14 @@ assert(standaloneMarkup.includes("page-layout-actions"), "route actions use the
assert(standaloneMarkup.includes('data-interface-id="test.page"'), "page identity reaches the shared frame"); assert(standaloneMarkup.includes('data-interface-id="test.page"'), "page identity reaches the shared frame");
assert(standaloneMarkup.includes('data-help-context-id="test.page.help"'), "context help reaches the shared frame"); assert(standaloneMarkup.includes('data-help-context-id="test.page.help"'), "context help reaches the shared frame");
assert(standaloneMarkup.includes('data-help-module-id="test-module"'), "documentation ownership reaches the shared frame"); assert(standaloneMarkup.includes('data-help-module-id="test-module"'), "documentation ownership reaches the shared frame");
assert(standaloneMarkup.includes('data-page-archetype="collection"'), "page intent is independent from layout geometry");
assert(standaloneMarkup.includes("Could not load"), "page errors use the shared alert region"); assert(standaloneMarkup.includes("Could not load"), "page errors use the shared alert region");
assert(standaloneMarkup.includes("Saved"), "page success notices use the shared alert region"); assert(standaloneMarkup.includes("Saved"), "page success notices use the shared alert region");
assert(standaloneMarkup.includes("Page content"), "page content is preserved"); assert(standaloneMarkup.includes("Page content"), "page content is preserved");
const embeddedMarkup = renderToStaticMarkup( const embeddedMarkup = renderToStaticMarkup(
<PlatformLanguageProvider> <PlatformLanguageProvider>
<PageLayout title="Embedded page" mode="embedded" documentationType="admin"> <PageLayout archetype="detail" title="Embedded page" mode="embedded" documentationType="admin">
Embedded content Embedded content
</PageLayout> </PageLayout>
</PlatformLanguageProvider> </PlatformLanguageProvider>
@@ -50,6 +53,7 @@ assert(embeddedMarkup.includes('data-help-documentation-type="admin"'), "embedde
const workspaceMarkup = renderToStaticMarkup( const workspaceMarkup = renderToStaticMarkup(
<PlatformLanguageProvider> <PlatformLanguageProvider>
<PageLayout <PageLayout
archetype="workspace"
title="Workspace page" title="Workspace page"
description={<p className="version-line">Version 3</p>} description={<p className="version-line">Version 3</p>}
mode="workspace" mode="workspace"
@@ -68,7 +72,7 @@ assert(workspaceMarkup.includes("page-layout-notices"), "additional page notices
const headerLoadingMarkup = renderToStaticMarkup( const headerLoadingMarkup = renderToStaticMarkup(
<PlatformLanguageProvider> <PlatformLanguageProvider>
<PageLayout title="Refreshing" mode="workspace" headerLoading> <PageLayout archetype="detail" title="Refreshing" mode="workspace" headerLoading>
Stable content Stable content
</PageLayout> </PageLayout>
</PlatformLanguageProvider> </PlatformLanguageProvider>
@@ -79,7 +83,7 @@ assert(!headerLoadingMarkup.includes('aria-busy="true"'), "partial refresh does
const delegatedHeaderMarkup = renderToStaticMarkup( const delegatedHeaderMarkup = renderToStaticMarkup(
<PlatformLanguageProvider> <PlatformLanguageProvider>
<PageLayout title="Administration" mode="workspace" showHeader={false}> <PageLayout archetype="workspace" title="Administration" mode="workspace" showHeader={false}>
<section><h1>Contributed administration panel</h1></section> <section><h1>Contributed administration panel</h1></section>
</PageLayout> </PageLayout>
</PlatformLanguageProvider> </PlatformLanguageProvider>