diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md index 479e4ee..dc11c75 100644 --- a/docs/INTERFACE_PATTERN_MIGRATION.md +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -17,6 +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-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 | +| 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 | | 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 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 | diff --git a/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md b/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md new file mode 100644 index 0000000..88fab5f --- /dev/null +++ b/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md @@ -0,0 +1,72 @@ +# Page Layout and Action Guidelines + +This document defines the binding composition grammar for headed GovOPlaN +pages. Core owns the reusable anatomy; each module owns its domain actions, +wording, authorization, consequences, and data state. + +## Required Page Frame + +- Use `PageLayout` for every headed standalone, workspace, or embedded page. +- Use `WorkspaceFrame` for a full-height module surface and + `WorkspaceLayout` only where navigation/content or list/detail panes are + genuinely part of the interaction. +- Put page-wide feedback in `PageLayout` notices. Use `DismissibleAlert` for a + recoverable warning or failure and `StatePanel` when the entire surface is + loading, empty, unavailable, or blocked. +- Do not reproduce shared page padding, heading, toolbar, form-grid, section, + table, dialog, or breakpoint CSS in a module. + +## Page Action Archetypes + +Pass one `PageActionBar` to the `PageLayout` `actions` slot. The variant makes +the page's intent inspectable and preserves the same keyboard and visual order +across modules. + +| Page kind | Leading group | Trailing group | +| --- | --- | --- | +| Collection | Reload, then collection context such as export | Help, then Create at the far right | +| Detail | Reload, then object context | Help, ordinary primary actions, then consequential actions | +| Editor | Reload, then editor context such as preview | Help, Discard, then Save at the far right | + +Reload means re-fetch or re-evaluate the current surface. It remains present +when the page can become stale. Create is a collection-wide action and is not +duplicated in a 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 +`Button`, `IconButton`, or `TableActionGroup` components. When an action is +visible but unavailable because of permission, target, policy, state, or +validation, keep it in its stable slot and supply `disabledReason`. Do not +silently hide a normally applicable action. + +## Forms and Dialogs + +- Compose forms from `FormLayout`/`FormGrid`, `FormSection`, and `FormField`. +- Use `FieldLabel` through `FormField` for every field that is not genuinely + self-explanatory; record justified omissions in the owning UI ledger. +- Use `Dialog`, `DialogForm`, `DialogSection`, and `DialogActions` for modal + work. A dialog can be domain-specific while its anatomy remains central. +- Use `useUnsavedDraftGuard` for explicit Discard and guarded navigation on an + editable page or dialog. +- Explain irreversible or operationally consequential actions before the + commit button, including reversibility and durable evidence. + +## Collections and Details + +- Use `FilterBar` for collection query controls and `DataGrid` for tabular + collections. Keep a single ordered `TableActionGroup` action set per table. +- Use `MetricGrid`/`MetricCard` for summary measures, `Card` or + `ContentSection` for logical sections, and `DescriptionList` for labelled + facts. +- Preserve loaded data after a refresh failure and mark it stale; offer Reload + as the recovery action. Distinguish initial loading, empty, unavailable, + permission-blocked, conflict, success, and retry states. + +## Review Evidence + +Every new or changed page should have structural evidence for its page frame, +semantic action archetype and slot order, shared component usage, stable +disabled actions, and module-owned help identity. Keyboard and narrow-layout +checks must confirm that all commands remain reachable in DOM order and that +the trailing group stays visually trailing after wrapping. diff --git a/docs/UI_UX_DECISION_LEDGER.md b/docs/UI_UX_DECISION_LEDGER.md index d9193a5..0c19455 100644 --- a/docs/UI_UX_DECISION_LEDGER.md +++ b/docs/UI_UX_DECISION_LEDGER.md @@ -57,6 +57,7 @@ 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-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-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 | ## Confirmed Implementation Decisions diff --git a/webui/conformance/ConformanceApp.tsx b/webui/conformance/ConformanceApp.tsx index 294c75a..83bdebf 100644 --- a/webui/conformance/ConformanceApp.tsx +++ b/webui/conformance/ConformanceApp.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from "react"; import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react"; import { useLocation } from "react-router"; -import ActionToolbar, { ToolbarGroup } from "../src/components/ActionToolbar"; +import ActionToolbar from "../src/components/ActionToolbar"; import Button from "../src/components/Button"; import Card from "../src/components/Card"; import ContentGrid, { FormGrid } from "../src/components/ContentGrid"; @@ -18,6 +18,7 @@ import FormSection from "../src/components/FormSection"; import MetricCard from "../src/components/MetricCard"; import MetricGrid from "../src/components/MetricGrid"; import PageLayout from "../src/components/PageLayout"; +import PageActionBar from "../src/components/PageActionBar"; import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList"; import StatePanel from "../src/components/StatePanel"; import WorkspaceFrame from "../src/components/WorkspaceFrame"; @@ -43,14 +44,19 @@ export default function ConformanceApp() { >

Aktionen, Filter und Status

- - - - - - - - + Neu laden} + contextActions={( + <> + + + + )} + discardAction={} + saveAction={} + /> diff --git a/webui/conformance/tests/snapshots/shared-ui-dark-desktop.png b/webui/conformance/tests/snapshots/shared-ui-dark-desktop.png index cb595c4..1c52f5c 100644 Binary files a/webui/conformance/tests/snapshots/shared-ui-dark-desktop.png and b/webui/conformance/tests/snapshots/shared-ui-dark-desktop.png differ diff --git a/webui/conformance/tests/snapshots/shared-ui-light-desktop.png b/webui/conformance/tests/snapshots/shared-ui-light-desktop.png index f0ebc64..dd6a095 100644 Binary files a/webui/conformance/tests/snapshots/shared-ui-light-desktop.png and b/webui/conformance/tests/snapshots/shared-ui-light-desktop.png differ diff --git a/webui/conformance/tests/snapshots/shared-ui-light-narrow.png b/webui/conformance/tests/snapshots/shared-ui-light-narrow.png index 940a279..6f73cd0 100644 Binary files a/webui/conformance/tests/snapshots/shared-ui-light-narrow.png and b/webui/conformance/tests/snapshots/shared-ui-light-narrow.png differ diff --git a/webui/conformance/tests/ui-conformance.spec.ts b/webui/conformance/tests/ui-conformance.spec.ts index f489140..81d88af 100644 --- a/webui/conformance/tests/ui-conformance.spec.ts +++ b/webui/conformance/tests/ui-conformance.spec.ts @@ -24,6 +24,25 @@ test("shared components remain accessible and keyboard operable", async ({ page await expect(page.getByRole("heading", { level: 1, name: "Zentrale GovOPlaN-Oberflächen" })).toBeVisible(); await expectNoAccessibilityViolations(page); + const editorActions = page.getByRole("toolbar", { name: "Bearbeitungsaktionen" }); + await expect(editorActions.getByRole("button")).toHaveText([ + "Neu laden", + "Vorschau öffnen", + "Löschen", + "Verwerfen", + "Änderungen speichern" + ]); + await editorActions.getByRole("button", { name: "Neu laden" }).focus(); + await page.keyboard.press("Tab"); + await expect(editorActions.getByRole("button", { name: "Vorschau öffnen" })).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(editorActions.locator(".disabled-action-tooltip")).toBeFocused(); + await expect(page.getByRole("tooltip")).toContainText("Nur die federführende Stelle"); + await page.keyboard.press("Tab"); + await expect(editorActions.getByRole("button", { name: "Verwerfen" })).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(editorActions.getByRole("button", { name: "Änderungen speichern" })).toBeFocused(); + const opener = page.getByTestId("open-dialog"); await opener.focus(); await page.keyboard.press("Enter"); @@ -71,5 +90,7 @@ test("narrow layout preserves task order without horizontal overflow", async ({ .filter(({ left, right }) => left < -1 || right > window.innerWidth + 1) .slice(0, 20)); expect(overflowing).toEqual([]); + await expect(page.locator("[data-page-action-archetype='editor'] [data-page-action-group='trailing']")) + .toHaveCSS("justify-content", "flex-end"); await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-light-narrow.png", { animations: "disabled", maxDiffPixelRatio: 0.005 }); }); diff --git a/webui/package-lock.json b/webui/package-lock.json index 57d8ea4..538d898 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -32,6 +32,7 @@ "@govoplan/notifications-webui": "file:../../govoplan-notifications/webui", "@govoplan/ops-webui": "file:../../govoplan-ops/webui", "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui", + "@govoplan/payments-webui": "file:../../govoplan-payments/webui", "@govoplan/policy-webui": "file:../../govoplan-policy/webui", "@govoplan/portal-webui": "file:../../govoplan-portal/webui", "@govoplan/postbox-webui": "file:../../govoplan-postbox/webui", @@ -497,6 +498,22 @@ } } }, + "../../govoplan-payments/webui": { + "name": "@govoplan/payments-webui", + "version": "0.1.20", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.18", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, "../../govoplan-policy/webui": { "name": "@govoplan/policy-webui", "version": "0.1.18", @@ -1605,6 +1622,10 @@ "resolved": "../../govoplan-organizations/webui", "link": true }, + "node_modules/@govoplan/payments-webui": { + "resolved": "../../govoplan-payments/webui", + "link": true + }, "node_modules/@govoplan/policy-webui": { "resolved": "../../govoplan-policy/webui", "link": true diff --git a/webui/package.json b/webui/package.json index d7bd5c3..3c1bff1 100644 --- a/webui/package.json +++ b/webui/package.json @@ -82,6 +82,7 @@ "@govoplan/notifications-webui": "file:../../govoplan-notifications/webui", "@govoplan/ops-webui": "file:../../govoplan-ops/webui", "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui", + "@govoplan/payments-webui": "file:../../govoplan-payments/webui", "@govoplan/policy-webui": "file:../../govoplan-policy/webui", "@govoplan/portal-webui": "file:../../govoplan-portal/webui", "@govoplan/postbox-webui": "file:../../govoplan-postbox/webui", diff --git a/webui/scripts/test-core-interface-patterns.mjs b/webui/scripts/test-core-interface-patterns.mjs index ca474f6..edce4e6 100644 --- a/webui/scripts/test-core-interface-patterns.mjs +++ b/webui/scripts/test-core-interface-patterns.mjs @@ -19,6 +19,7 @@ const helpContext = read("src/utils/helpContext.ts"); const pageLayout = read("src/components/PageLayout.tsx"); const workspaceLayout = read("src/components/WorkspaceLayout.tsx"); const actionToolbar = read("src/components/ActionToolbar.tsx"); +const pageActionBar = read("src/components/PageActionBar.tsx"); const contentGrid = read("src/components/ContentGrid.tsx"); const formSection = read("src/components/FormSection.tsx"); const dialogAnatomy = read("src/components/DialogAnatomy.tsx"); @@ -58,6 +59,11 @@ assert.match(workspaceLayout, /workspace-layout-\$\{variant\}/, "shared workspac assert.match(workspaceLayout, /data-help-scope="workspace"/, "shared workspaces preserve contextual-help identity"); assert.match(actionToolbar, /action-toolbar-wrap-\$\{wrap\}/, "shared toolbars own responsive wrapping"); assert.match(actionToolbar, /data-help-scope="toolbar"/, "shared toolbars preserve contextual-help identity"); +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, //, "page action bars keep reload in a named stable slot"); +assert.match(pageActionBar, /[\s\S]*/, "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"); assert.match(dialogAnatomy, /dialog-actions-\$\{align\}/, "shared dialog anatomy owns footer action placement"); diff --git a/webui/scripts/test-module-permutations.mjs b/webui/scripts/test-module-permutations.mjs index 7b23089..71cb853 100644 --- a/webui/scripts/test-module-permutations.mjs +++ b/webui/scripts/test-module-permutations.mjs @@ -26,6 +26,7 @@ const packageByModule = { notifications: "@govoplan/notifications-webui", organizations: "@govoplan/organizations-webui", ops: "@govoplan/ops-webui", + payments: "@govoplan/payments-webui", policy: "@govoplan/policy-webui", portal: "@govoplan/portal-webui", postbox: "@govoplan/postbox-webui", @@ -74,6 +75,7 @@ const cases = [ { name: "mail-only", modules: ["mail"] }, { name: "notifications-only", modules: ["notifications"] }, { name: "organizations-only", modules: ["organizations"] }, + { name: "payments-only", modules: ["payments"] }, { name: "idm-with-organizations", modules: ["organizations", "idm"] }, { name: "identity-trust-only", modules: ["identity_trust"] }, { name: "identity-trust-with-access", modules: ["access", "identity_trust"] }, @@ -100,7 +102,7 @@ const cases = [ { name: "tasks-only", modules: ["access", "tasks"] }, { name: "tasks-with-contributors", modules: ["access", "approvals", "postbox", "workflow", "dashboard", "tasks"] }, { name: "voting-only", modules: ["access", "voting"] }, - { name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "identity_trust", "encryption", "cases", "committee", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "portal", "postbox", "projects", "quick_access", "reporting", "records", "risk_compliance", "search", "tasks", "voting"] } + { name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "identity_trust", "encryption", "cases", "committee", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "payments", "calendar", "scheduling", "portal", "postbox", "projects", "quick_access", "reporting", "records", "risk_compliance", "search", "tasks", "voting"] } ]; const npmExec = process.env.npm_execpath; diff --git a/webui/src/components/PageActionBar.tsx b/webui/src/components/PageActionBar.tsx new file mode 100644 index 0000000..3372e2e --- /dev/null +++ b/webui/src/components/PageActionBar.tsx @@ -0,0 +1,125 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import type { PlatformInterfaceIdentityProps } from "../types"; +import ActionToolbar, { ToolbarGroup } from "./ActionToolbar"; + +type PageActionBarCommonProps = PlatformInterfaceIdentityProps & + Omit, "children"> & { + reloadAction: ReactNode; + contextActions?: ReactNode; + helpAction?: ReactNode; + label?: string; + }; + +export type CollectionPageActionBarProps = PageActionBarCommonProps & { + variant: "collection"; + createAction?: ReactNode; +}; + +export type DetailPageActionBarProps = PageActionBarCommonProps & { + variant: "detail"; + primaryActions?: ReactNode; + consequentialActions?: ReactNode; +}; + +export type EditorPageActionBarProps = PageActionBarCommonProps & { + variant: "editor"; + discardAction: ReactNode; + saveAction: ReactNode; +}; + +export type PageActionBarProps = + | CollectionPageActionBarProps + | DetailPageActionBarProps + | EditorPageActionBarProps; + +function ActionSlot({ name, children }: { name: string; children: ReactNode }) { + return ( + + {children} + + ); +} + +const defaultLabels = { + collection: "Collection page actions", + detail: "Detail page actions", + editor: "Editor page actions" +} as const; + +/** + * Semantic action placement for headed pages. + * + * The named slots deliberately encode ordering. Product modules still own the + * actions, wording, permissions, blockers, and consequences placed in them. + */ +export default function PageActionBar(props: PageActionBarProps) { + const normalizedProps = props as PageActionBarProps & { + createAction?: ReactNode; + primaryActions?: ReactNode; + consequentialActions?: ReactNode; + discardAction?: ReactNode; + saveAction?: ReactNode; + }; + const { + variant, + reloadAction, + contextActions, + helpAction, + createAction, + primaryActions, + consequentialActions, + discardAction, + saveAction, + label, + className = "", + interfaceId, + helpContextId, + helpModuleId, + helpTopicId, + ...toolbarProps + } = normalizedProps; + + let trailingActions: ReactNode; + if (variant === "collection") { + trailingActions = createAction ? {createAction} : null; + } else if (variant === "detail") { + trailingActions = ( + <> + {primaryActions ? {primaryActions} : null} + {consequentialActions ? {consequentialActions} : null} + + ); + } else { + trailingActions = ( + <> + {discardAction} + {saveAction} + + ); + } + + return ( + + + {reloadAction} + {contextActions ? {contextActions} : null} + + + {helpAction ? {helpAction} : null} + {trailingActions} + + + ); +} diff --git a/webui/src/index.ts b/webui/src/index.ts index 64d861a..354d2e3 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -143,6 +143,8 @@ export type { MessageDisplayAttachment, MessageDisplayField } from "./components export { default as PageTitle } from "./components/PageTitle"; export { default as PageLayout, PageHeader } from "./components/PageLayout"; export type { PageHeaderProps, PageLayoutMode, PageLayoutProps } from "./components/PageLayout"; +export { default as PageActionBar } from "./components/PageActionBar"; +export type { CollectionPageActionBarProps, DetailPageActionBarProps, EditorPageActionBarProps, PageActionBarProps } from "./components/PageActionBar"; export { default as PageScrollViewport } from "./components/PageScrollViewport"; export type { PageScrollViewportProps } from "./components/PageScrollViewport"; export { default as WorkspaceLayout } from "./components/WorkspaceLayout"; diff --git a/webui/src/styles/layout.css b/webui/src/styles/layout.css index a28e6d2..8e72c93 100644 --- a/webui/src/styles/layout.css +++ b/webui/src/styles/layout.css @@ -99,6 +99,8 @@ .action-toolbar-justify-end { justify-content: flex-end; } .action-toolbar-group-grow { flex: 1 1 auto; } .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; } .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, @@ -355,6 +357,7 @@ .action-toolbar-wrap-responsive { align-items: stretch; flex-wrap: wrap; } .action-toolbar-wrap-responsive > .action-toolbar-group { flex: 1 1 100%; } .action-toolbar-wrap-responsive > .action-toolbar-group-end { margin-inline-start: 0; justify-content: flex-start; } + .page-action-bar.action-toolbar-wrap-responsive > .page-action-bar-trailing { justify-content: flex-end; } .filter-bar-wrap-responsive.filter-bar-layout-row { align-items: stretch; flex-wrap: wrap; } .filter-bar-wrap-responsive.filter-bar-layout-row > input:not([type="checkbox"]):not([type="radio"]), .filter-bar-wrap-responsive.filter-bar-layout-row > select { flex-basis: 100%; } diff --git a/webui/tests/layout-primitives.test.tsx b/webui/tests/layout-primitives.test.tsx index 2876927..ae3c6e1 100644 --- a/webui/tests/layout-primitives.test.tsx +++ b/webui/tests/layout-primitives.test.tsx @@ -16,6 +16,7 @@ import FloatingStatus from "../src/components/FloatingStatus"; import FormSection from "../src/components/FormSection"; import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; import MetricGrid from "../src/components/MetricGrid"; +import PageActionBar from "../src/components/PageActionBar"; import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList"; import StatePanel from "../src/components/StatePanel"; import WorkspaceLayout from "../src/components/WorkspaceLayout"; @@ -39,6 +40,37 @@ assert(toolbarMarkup.includes("action-toolbar-group-grow"), "toolbar groups can assert(toolbarMarkup.includes("action-toolbar-justify-between"), "toolbar distribution is centrally controlled"); assert(toolbarMarkup.includes("action-toolbar-spacer"), "the standard action spacer is available"); +const editorActionBarMarkup = renderToStaticMarkup( + + Reload} + contextActions={} + helpAction={} + discardAction={} + saveAction={} + /> + +); + +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.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="discard"') < editorActionBarMarkup.indexOf('data-page-action-slot="save"'), "save remains the far-right editor action"); + +const collectionActionBarMarkup = renderToStaticMarkup( + + Reload} + createAction={} + /> + +); +assert(collectionActionBarMarkup.indexOf('data-page-action-slot="reload"') < collectionActionBarMarkup.indexOf('data-page-action-slot="create"'), "collection creation remains the far-right action"); + const gridMarkup = renderToStaticMarkup( A diff --git a/webui/tsconfig.component-tests.json b/webui/tsconfig.component-tests.json index b7fa390..bb280d1 100644 --- a/webui/tsconfig.component-tests.json +++ b/webui/tsconfig.component-tests.json @@ -48,6 +48,7 @@ "src/components/MessageDisplayPanel.tsx", "src/components/MetricCard.tsx", "src/components/PageLayout.tsx", + "src/components/PageActionBar.tsx", "src/components/PageScrollViewport.tsx", "src/components/PageTitle.tsx", "src/components/WorkspaceLayout.tsx", diff --git a/webui/vite.config.ts b/webui/vite.config.ts index c8f8f3e..bd583e0 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -37,6 +37,7 @@ const defaultWebModulePackages = [ "@govoplan/notifications-webui", "@govoplan/organizations-webui", "@govoplan/ops-webui", + "@govoplan/payments-webui", "@govoplan/policy-webui", "@govoplan/portal-webui", "@govoplan/postbox-webui", @@ -268,6 +269,7 @@ export default defineConfig({ fileURLToPath(new URL('../../govoplan-organizations/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url)), + fileURLToPath(new URL('../../govoplan-payments/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-portal/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-postbox/webui', import.meta.url)),