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
+9 -9
View File
@@ -33,29 +33,29 @@ import type { AuthInfo } from "../src/types";
export default function ConformanceApp() {
const [dialogOpen, setDialogOpen] = useState(false);
const [editorDirty, setEditorDirty] = useState(true);
return (
<main className="conformance-root" data-conformance-id="shared-ui-lab">
<PageLayout
archetype="overview"
mode="embedded"
title="Zentrale GovOPlaN-Oberflächen"
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">
<h2 id="actions-heading">Aktionen, Filter und Status</h2>
<PageActionBar
variant="editor"
refreshable
dirty={editorDirty}
label="Bearbeitungsaktionen"
reloadAction={<Button variant="ghost">Neu laden</Button>}
contextActions={(
<>
<Button>Vorschau öffnen</Button>
<Button variant="danger" disabledReason="Nur die federführende Stelle darf diesen Vorgang endgültig löschen.">Löschen</Button>
</>
)}
discardAction={<Button variant="ghost">Verwerfen</Button>}
saveAction={<Button variant="primary">Änderungen speichern</Button>}
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>}
discardAction={{ label: "Verwerfen", onClick: () => setEditorDirty(false) }}
saveAction={{ label: "Änderungen speichern", onClick: () => setEditorDirty(false) }}
/>
<FilterBar surface="control" aria-label="Vorgangsliste filtern">
<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);
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([
"Neu laden",
"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 page.keyboard.press("Tab");
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");
await opener.focus();
@@ -92,5 +111,8 @@ test("narrow layout preserves task order without horizontal overflow", async ({
expect(overflowing).toEqual([]);
await expect(page.locator("[data-page-action-archetype='editor'] [data-page-action-group='trailing']"))
.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 });
});
+10 -2
View File
@@ -28,8 +28,8 @@ const layoutStyles = read("src/styles/layout.css");
const authGateStyles = read("src/styles/auth-gate.css");
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, /There are no unsaved interface changes\./, "preference save explains its clean state");
assert.match(settings, /archetype=\{editorSection \? "editor" : "workspace"\}/, "settings declare editor intent only for draft-owning sections");
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, /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.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, /data-help-scope="page"/, "shared pages preserve contextual-help identity");
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: "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: "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, /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(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");
+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 ActionToolbar, { ToolbarGroup } from "./ActionToolbar";
import Button from "./Button";
type RefreshablePageActions = {
refreshable: true;
reloadAction: ReactNode;
} | {
refreshable?: false;
reloadAction?: never;
};
type PageActionBarCommonProps = PlatformInterfaceIdentityProps &
Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
reloadAction: ReactNode;
contextActions?: ReactNode;
helpAction?: ReactNode;
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";
createAction?: ReactNode;
};
export type DetailPageActionBarProps = PageActionBarCommonProps & {
export type DetailPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "detail";
primaryActions?: ReactNode;
consequentialActions?: ReactNode;
destructiveActions?: ReactNode;
};
export type EditorPageActionBarProps = PageActionBarCommonProps & {
export type EditorPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "editor";
discardAction: ReactNode;
saveAction: ReactNode;
dirty: boolean;
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 =
| OverviewPageActionBarProps
| CollectionPageActionBarProps
| DetailPageActionBarProps
| EditorPageActionBarProps;
| EditorPageActionBarProps
| WorkspacePageActionBarProps;
function ActionSlot({ name, children }: { name: string; children: ReactNode }) {
return (
@@ -41,11 +76,41 @@ function ActionSlot({ name, children }: { name: string; children: ReactNode }) {
}
const defaultLabels = {
overview: "Overview page actions",
collection: "Collection page actions",
detail: "Detail page actions",
editor: "Editor page actions"
editor: "Editor page actions",
workspace: "Workspace page actions"
} 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.
*
@@ -56,18 +121,33 @@ export default function PageActionBar(props: PageActionBarProps) {
const normalizedProps = props as PageActionBarProps & {
createAction?: ReactNode;
primaryActions?: ReactNode;
consequentialActions?: ReactNode;
discardAction?: ReactNode;
saveAction?: ReactNode;
destructiveActions?: ReactNode;
dirty?: boolean;
saving?: boolean;
dirtyLabel?: ReactNode;
cleanLabel?: ReactNode;
savingLabel?: ReactNode;
cleanDisabledReason?: ReactNode;
savingDisabledReason?: ReactNode;
discardAction?: PageEditorAction;
saveAction?: PageEditorAction;
};
const {
variant,
refreshable = false,
reloadAction,
contextActions,
helpAction,
createAction,
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,
saveAction,
label,
@@ -80,20 +160,36 @@ export default function PageActionBar(props: PageActionBarProps) {
} = normalizedProps;
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;
} else if (variant === "detail") {
trailingActions = (
<>
{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 {
trailingActions = (
<>
<ActionSlot name="discard">{discardAction}</ActionSlot>
<ActionSlot name="save">{saveAction}</ActionSlot>
{primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null}
<DestructiveSlot>{destructiveActions}</DestructiveSlot>
</>
);
}
@@ -111,12 +207,24 @@ export default function PageActionBar(props: PageActionBarProps) {
helpModuleId={helpModuleId}
helpTopicId={helpTopicId}
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">
<ActionSlot name="reload">{reloadAction}</ActionSlot>
{refreshable ? <ActionSlot name="reload">{reloadAction}</ActionSlot> : null}
{contextActions ? <ActionSlot name="context">{contextActions}</ActionSlot> : null}
</ToolbarGroup>
<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}
{trailingActions}
</ToolbarGroup>
+5
View File
@@ -7,6 +7,7 @@ import PageScrollViewport from "./PageScrollViewport";
import PageTitle from "./PageTitle";
export type PageLayoutMode = "standalone" | "workspace" | "embedded";
export type PageArchetype = "overview" | "collection" | "detail" | "editor" | "workspace";
export type PageHeaderProps = {
title: ReactNode;
@@ -48,6 +49,8 @@ export function PageHeader({
}
export type PageLayoutProps = PlatformInterfaceIdentityProps & {
/** Semantic page intent. This is independent from viewport/layout geometry. */
archetype: PageArchetype;
title: ReactNode;
description?: ReactNode;
actions?: ReactNode;
@@ -70,6 +73,7 @@ export type PageLayoutProps = PlatformInterfaceIdentityProps & {
};
export default function PageLayout({
archetype,
title,
description,
actions,
@@ -107,6 +111,7 @@ export default function PageLayout({
const layout = (
<div
className={layoutClasses}
data-page-archetype={archetype}
data-help-scope="page"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
@@ -1,8 +1,9 @@
import type { ReactNode } from "react";
import type { PlatformInterfaceIdentityProps } from "../../types";
import PageLayout from "../PageLayout";
import PageLayout, { type PageArchetype } from "../PageLayout";
export type AdminPageLayoutProps = PlatformInterfaceIdentityProps & {
archetype?: PageArchetype;
title: string;
description: string;
loading?: boolean;
@@ -15,6 +16,7 @@ export type AdminPageLayoutProps = PlatformInterfaceIdentityProps & {
};
export default function AdminPageLayout({
archetype = "detail",
title,
description,
loading = false,
@@ -31,6 +33,7 @@ export default function AdminPageLayout({
}: AdminPageLayoutProps) {
return (
<PageLayout
archetype={archetype}
title={title}
description={description}
loading={loading}
@@ -11,6 +11,7 @@ export default function DashboardPage() {
return (
<PageLayout
archetype="overview"
title="i18n:govoplan-core.dashboard.d87f47b4"
description="Install and enable the dashboard module to make this page configurable."
viewportClassName="core-dashboard-page"
+33 -31
View File
@@ -8,6 +8,7 @@ import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField";
import Button from "../../components/Button";
import PageLayout from "../../components/PageLayout";
import PageActionBar from "../../components/PageActionBar";
import ToggleSwitch from "../../components/ToggleSwitch";
import { apiFetch } from "../../api/client";
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 (
<WorkspaceLayout
className="module-workspace"
@@ -314,9 +331,24 @@ export default function SettingsPage({
contentLabel="i18n:govoplan-core.settings.c7f73bb5"
>
<PageLayout
archetype={editorSection ? "editor" : "workspace"}
title="i18n:govoplan-core.settings.c7f73bb5"
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"
>
@@ -333,16 +365,6 @@ export default function SettingsPage({
<FormField label="i18n:govoplan-core.email.84add5b2">
<input value={auth.user.email} disabled />
</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>}
</FormGrid>
</Card>
@@ -418,16 +440,6 @@ export default function SettingsPage({
checked={reduceMotion}
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>}
</FormGrid>
</Card>
@@ -481,16 +493,6 @@ export default function SettingsPage({
disabled
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>}
</FormGrid>
</Card>
+2 -2
View File
@@ -142,9 +142,9 @@ export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
export { default as PageTitle } from "./components/PageTitle";
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 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 type { PageScrollViewportProps } from "./components/PageScrollViewport";
export { default as WorkspaceLayout } from "./components/WorkspaceLayout";
+5
View File
@@ -101,6 +101,11 @@
.action-toolbar-spacer { flex: 1 1 auto; min-width: 8px; }
.page-action-slot { min-width: 0; display: inline-flex; align-items: center; }
.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; }
.workspace { height: 100%; min-height: 0; display: grid; grid-template-columns: 198px minmax(0, 1fr); }
.workspace-layout-primary,
+32 -2
View File
@@ -22,6 +22,13 @@ import StatePanel from "../src/components/StatePanel";
import WorkspaceLayout from "../src/components/WorkspaceLayout";
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(
<PlatformLanguageProvider>
<ActionToolbar label="Object actions" density="compact" justify="between" surface="subtle" interfaceId="object.toolbar">
@@ -44,26 +51,49 @@ const editorActionBarMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<PageActionBar
variant="editor"
refreshable
dirty
label="Editor actions"
reloadAction={<button type="button">Reload</button>}
contextActions={<button type="button">Preview</button>}
helpAction={<button type="button">Help</button>}
discardAction={<button type="button">Discard</button>}
saveAction={<button type="button">Save</button>}
destructiveActions={<button type="button">Delete</button>}
discardAction={{ label: "Discard" }}
saveAction={{ label: "Save" }}
/>
</PlatformLanguageProvider>
);
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('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="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");
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(
<PlatformLanguageProvider>
<PageActionBar
variant="collection"
refreshable
reloadAction={<button type="button">Reload</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 PageActionBar from "../src/components/PageActionBar";
import PageLayout from "../src/components/PageLayout";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
const standaloneMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<PageLayout
archetype="collection"
title="Shared page"
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"
success="Saved"
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-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-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("Saved"), "page success notices use the shared alert region");
assert(standaloneMarkup.includes("Page content"), "page content is preserved");
const embeddedMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<PageLayout title="Embedded page" mode="embedded" documentationType="admin">
<PageLayout archetype="detail" title="Embedded page" mode="embedded" documentationType="admin">
Embedded content
</PageLayout>
</PlatformLanguageProvider>
@@ -50,6 +53,7 @@ assert(embeddedMarkup.includes('data-help-documentation-type="admin"'), "embedde
const workspaceMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<PageLayout
archetype="workspace"
title="Workspace page"
description={<p className="version-line">Version 3</p>}
mode="workspace"
@@ -68,7 +72,7 @@ assert(workspaceMarkup.includes("page-layout-notices"), "additional page notices
const headerLoadingMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<PageLayout title="Refreshing" mode="workspace" headerLoading>
<PageLayout archetype="detail" title="Refreshing" mode="workspace" headerLoading>
Stable content
</PageLayout>
</PlatformLanguageProvider>
@@ -79,7 +83,7 @@ assert(!headerLoadingMarkup.includes('aria-busy="true"'), "partial refresh does
const delegatedHeaderMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<PageLayout title="Administration" mode="workspace" showHeader={false}>
<PageLayout archetype="workspace" title="Administration" mode="workspace" showHeader={false}>
<section><h1>Contributed administration panel</h1></section>
</PageLayout>
</PlatformLanguageProvider>