Define semantic page archetypes
This commit is contained in:
@@ -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 1–4 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 1–4 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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user