Define semantic page action layouts
This commit is contained in:
@@ -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-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 |
|
||||||
| 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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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-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 |
|
||||||
|
|
||||||
## Confirmed Implementation Decisions
|
## Confirmed Implementation Decisions
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react";
|
import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react";
|
||||||
import { useLocation } from "react-router";
|
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 Button from "../src/components/Button";
|
||||||
import Card from "../src/components/Card";
|
import Card from "../src/components/Card";
|
||||||
import ContentGrid, { FormGrid } from "../src/components/ContentGrid";
|
import ContentGrid, { FormGrid } from "../src/components/ContentGrid";
|
||||||
@@ -18,6 +18,7 @@ import FormSection from "../src/components/FormSection";
|
|||||||
import MetricCard from "../src/components/MetricCard";
|
import MetricCard from "../src/components/MetricCard";
|
||||||
import MetricGrid from "../src/components/MetricGrid";
|
import MetricGrid from "../src/components/MetricGrid";
|
||||||
import PageLayout from "../src/components/PageLayout";
|
import PageLayout from "../src/components/PageLayout";
|
||||||
|
import PageActionBar from "../src/components/PageActionBar";
|
||||||
import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList";
|
import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList";
|
||||||
import StatePanel from "../src/components/StatePanel";
|
import StatePanel from "../src/components/StatePanel";
|
||||||
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
||||||
@@ -43,14 +44,19 @@ export default function ConformanceApp() {
|
|||||||
>
|
>
|
||||||
<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>
|
||||||
<ActionToolbar surface="subtle" justify="between" aria-label="Vorgangsaktionen">
|
<PageActionBar
|
||||||
<ToolbarGroup>
|
variant="editor"
|
||||||
<Button variant="primary">Änderungen speichern</Button>
|
label="Bearbeitungsaktionen"
|
||||||
|
reloadAction={<Button variant="ghost">Neu laden</Button>}
|
||||||
|
contextActions={(
|
||||||
|
<>
|
||||||
<Button>Vorschau öffnen</Button>
|
<Button>Vorschau öffnen</Button>
|
||||||
<Button variant="ghost">Verwerfen</Button>
|
|
||||||
</ToolbarGroup>
|
|
||||||
<Button variant="danger" disabledReason="Nur die federführende Stelle darf diesen Vorgang endgültig löschen.">Löschen</Button>
|
<Button variant="danger" disabledReason="Nur die federführende Stelle darf diesen Vorgang endgültig löschen.">Löschen</Button>
|
||||||
</ActionToolbar>
|
</>
|
||||||
|
)}
|
||||||
|
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>
|
||||||
<label>Status<select defaultValue="open"><option value="open">Offen</option><option value="done">Abgeschlossen</option></select></label>
|
<label>Status<select defaultValue="open"><option value="open">Offen</option><option value="done">Abgeschlossen</option></select></label>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 230 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 212 KiB |
@@ -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 expect(page.getByRole("heading", { level: 1, name: "Zentrale GovOPlaN-Oberflächen" })).toBeVisible();
|
||||||
await expectNoAccessibilityViolations(page);
|
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");
|
const opener = page.getByTestId("open-dialog");
|
||||||
await opener.focus();
|
await opener.focus();
|
||||||
await page.keyboard.press("Enter");
|
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)
|
.filter(({ left, right }) => left < -1 || right > window.innerWidth + 1)
|
||||||
.slice(0, 20));
|
.slice(0, 20));
|
||||||
expect(overflowing).toEqual([]);
|
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 });
|
await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-light-narrow.png", { animations: "disabled", maxDiffPixelRatio: 0.005 });
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+21
@@ -32,6 +32,7 @@
|
|||||||
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
|
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
|
||||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
||||||
|
"@govoplan/payments-webui": "file:../../govoplan-payments/webui",
|
||||||
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
||||||
"@govoplan/portal-webui": "file:../../govoplan-portal/webui",
|
"@govoplan/portal-webui": "file:../../govoplan-portal/webui",
|
||||||
"@govoplan/postbox-webui": "file:../../govoplan-postbox/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": {
|
"../../govoplan-policy/webui": {
|
||||||
"name": "@govoplan/policy-webui",
|
"name": "@govoplan/policy-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.18",
|
||||||
@@ -1605,6 +1622,10 @@
|
|||||||
"resolved": "../../govoplan-organizations/webui",
|
"resolved": "../../govoplan-organizations/webui",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@govoplan/payments-webui": {
|
||||||
|
"resolved": "../../govoplan-payments/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@govoplan/policy-webui": {
|
"node_modules/@govoplan/policy-webui": {
|
||||||
"resolved": "../../govoplan-policy/webui",
|
"resolved": "../../govoplan-policy/webui",
|
||||||
"link": true
|
"link": true
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
|
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
|
||||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
||||||
|
"@govoplan/payments-webui": "file:../../govoplan-payments/webui",
|
||||||
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
||||||
"@govoplan/portal-webui": "file:../../govoplan-portal/webui",
|
"@govoplan/portal-webui": "file:../../govoplan-portal/webui",
|
||||||
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
|
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const helpContext = read("src/utils/helpContext.ts");
|
|||||||
const pageLayout = read("src/components/PageLayout.tsx");
|
const pageLayout = read("src/components/PageLayout.tsx");
|
||||||
const workspaceLayout = read("src/components/WorkspaceLayout.tsx");
|
const workspaceLayout = read("src/components/WorkspaceLayout.tsx");
|
||||||
const actionToolbar = read("src/components/ActionToolbar.tsx");
|
const actionToolbar = read("src/components/ActionToolbar.tsx");
|
||||||
|
const pageActionBar = read("src/components/PageActionBar.tsx");
|
||||||
const contentGrid = read("src/components/ContentGrid.tsx");
|
const contentGrid = read("src/components/ContentGrid.tsx");
|
||||||
const formSection = read("src/components/FormSection.tsx");
|
const formSection = read("src/components/FormSection.tsx");
|
||||||
const dialogAnatomy = read("src/components/DialogAnatomy.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(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, /action-toolbar-wrap-\$\{wrap\}/, "shared toolbars own responsive wrapping");
|
||||||
assert.match(actionToolbar, /data-help-scope="toolbar"/, "shared toolbars preserve contextual-help identity");
|
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, /<ActionSlot name="reload">/, "page action bars keep reload in a named stable slot");
|
||||||
|
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");
|
||||||
assert.match(dialogAnatomy, /dialog-actions-\$\{align\}/, "shared dialog anatomy owns footer action placement");
|
assert.match(dialogAnatomy, /dialog-actions-\$\{align\}/, "shared dialog anatomy owns footer action placement");
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const packageByModule = {
|
|||||||
notifications: "@govoplan/notifications-webui",
|
notifications: "@govoplan/notifications-webui",
|
||||||
organizations: "@govoplan/organizations-webui",
|
organizations: "@govoplan/organizations-webui",
|
||||||
ops: "@govoplan/ops-webui",
|
ops: "@govoplan/ops-webui",
|
||||||
|
payments: "@govoplan/payments-webui",
|
||||||
policy: "@govoplan/policy-webui",
|
policy: "@govoplan/policy-webui",
|
||||||
portal: "@govoplan/portal-webui",
|
portal: "@govoplan/portal-webui",
|
||||||
postbox: "@govoplan/postbox-webui",
|
postbox: "@govoplan/postbox-webui",
|
||||||
@@ -74,6 +75,7 @@ const cases = [
|
|||||||
{ name: "mail-only", modules: ["mail"] },
|
{ name: "mail-only", modules: ["mail"] },
|
||||||
{ name: "notifications-only", modules: ["notifications"] },
|
{ name: "notifications-only", modules: ["notifications"] },
|
||||||
{ name: "organizations-only", modules: ["organizations"] },
|
{ name: "organizations-only", modules: ["organizations"] },
|
||||||
|
{ name: "payments-only", modules: ["payments"] },
|
||||||
{ name: "idm-with-organizations", modules: ["organizations", "idm"] },
|
{ name: "idm-with-organizations", modules: ["organizations", "idm"] },
|
||||||
{ name: "identity-trust-only", modules: ["identity_trust"] },
|
{ name: "identity-trust-only", modules: ["identity_trust"] },
|
||||||
{ name: "identity-trust-with-access", modules: ["access", "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-only", modules: ["access", "tasks"] },
|
||||||
{ name: "tasks-with-contributors", modules: ["access", "approvals", "postbox", "workflow", "dashboard", "tasks"] },
|
{ name: "tasks-with-contributors", modules: ["access", "approvals", "postbox", "workflow", "dashboard", "tasks"] },
|
||||||
{ name: "voting-only", modules: ["access", "voting"] },
|
{ 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;
|
const npmExec = process.env.npm_execpath;
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from "react";
|
||||||
|
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||||
|
import ActionToolbar, { ToolbarGroup } from "./ActionToolbar";
|
||||||
|
|
||||||
|
type PageActionBarCommonProps = PlatformInterfaceIdentityProps &
|
||||||
|
Omit<HTMLAttributes<HTMLDivElement>, "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 (
|
||||||
|
<span className={`page-action-slot page-action-slot-${name}`} data-page-action-slot={name}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ? <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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
trailingActions = (
|
||||||
|
<>
|
||||||
|
<ActionSlot name="discard">{discardAction}</ActionSlot>
|
||||||
|
<ActionSlot name="save">{saveAction}</ActionSlot>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionToolbar
|
||||||
|
{...toolbarProps}
|
||||||
|
className={["page-action-bar", `page-action-bar-${variant}`, className].filter(Boolean).join(" ")}
|
||||||
|
density="compact"
|
||||||
|
justify="between"
|
||||||
|
wrap="responsive"
|
||||||
|
label={label ?? defaultLabels[variant]}
|
||||||
|
interfaceId={interfaceId}
|
||||||
|
helpContextId={helpContextId}
|
||||||
|
helpModuleId={helpModuleId}
|
||||||
|
helpTopicId={helpTopicId}
|
||||||
|
data-page-action-archetype={variant}
|
||||||
|
>
|
||||||
|
<ToolbarGroup className="page-action-bar-leading" data-page-action-group="leading">
|
||||||
|
<ActionSlot name="reload">{reloadAction}</ActionSlot>
|
||||||
|
{contextActions ? <ActionSlot name="context">{contextActions}</ActionSlot> : null}
|
||||||
|
</ToolbarGroup>
|
||||||
|
<ToolbarGroup className="page-action-bar-trailing" align="end" data-page-action-group="trailing">
|
||||||
|
{helpAction ? <ActionSlot name="help">{helpAction}</ActionSlot> : null}
|
||||||
|
{trailingActions}
|
||||||
|
</ToolbarGroup>
|
||||||
|
</ActionToolbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -143,6 +143,8 @@ export type { MessageDisplayAttachment, MessageDisplayField } from "./components
|
|||||||
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 { 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 { 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";
|
||||||
|
|||||||
@@ -99,6 +99,8 @@
|
|||||||
.action-toolbar-justify-end { justify-content: flex-end; }
|
.action-toolbar-justify-end { justify-content: flex-end; }
|
||||||
.action-toolbar-group-grow { flex: 1 1 auto; }
|
.action-toolbar-group-grow { flex: 1 1 auto; }
|
||||||
.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-bar-trailing { flex: 0 1 auto; }
|
||||||
.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,
|
||||||
@@ -355,6 +357,7 @@
|
|||||||
.action-toolbar-wrap-responsive { align-items: stretch; flex-wrap: wrap; }
|
.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 { flex: 1 1 100%; }
|
||||||
.action-toolbar-wrap-responsive > .action-toolbar-group-end { margin-inline-start: 0; justify-content: flex-start; }
|
.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 { 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 > input:not([type="checkbox"]):not([type="radio"]),
|
||||||
.filter-bar-wrap-responsive.filter-bar-layout-row > select { flex-basis: 100%; }
|
.filter-bar-wrap-responsive.filter-bar-layout-row > select { flex-basis: 100%; }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import FloatingStatus from "../src/components/FloatingStatus";
|
|||||||
import FormSection from "../src/components/FormSection";
|
import FormSection from "../src/components/FormSection";
|
||||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
import MetricGrid from "../src/components/MetricGrid";
|
import MetricGrid from "../src/components/MetricGrid";
|
||||||
|
import PageActionBar from "../src/components/PageActionBar";
|
||||||
import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList";
|
import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList";
|
||||||
import StatePanel from "../src/components/StatePanel";
|
import StatePanel from "../src/components/StatePanel";
|
||||||
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
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-justify-between"), "toolbar distribution is centrally controlled");
|
||||||
assert(toolbarMarkup.includes("action-toolbar-spacer"), "the standard action spacer is available");
|
assert(toolbarMarkup.includes("action-toolbar-spacer"), "the standard action spacer is available");
|
||||||
|
|
||||||
|
const editorActionBarMarkup = renderToStaticMarkup(
|
||||||
|
<PlatformLanguageProvider>
|
||||||
|
<PageActionBar
|
||||||
|
variant="editor"
|
||||||
|
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>}
|
||||||
|
/>
|
||||||
|
</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.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(
|
||||||
|
<PlatformLanguageProvider>
|
||||||
|
<PageActionBar
|
||||||
|
variant="collection"
|
||||||
|
reloadAction={<button type="button">Reload</button>}
|
||||||
|
createAction={<button type="button">Create</button>}
|
||||||
|
/>
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
);
|
||||||
|
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(
|
const gridMarkup = renderToStaticMarkup(
|
||||||
<ContentGrid columns={3} gap="compact" collapseAt="wide">
|
<ContentGrid columns={3} gap="compact" collapseAt="wide">
|
||||||
<GridItem>A</GridItem>
|
<GridItem>A</GridItem>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
"src/components/MessageDisplayPanel.tsx",
|
"src/components/MessageDisplayPanel.tsx",
|
||||||
"src/components/MetricCard.tsx",
|
"src/components/MetricCard.tsx",
|
||||||
"src/components/PageLayout.tsx",
|
"src/components/PageLayout.tsx",
|
||||||
|
"src/components/PageActionBar.tsx",
|
||||||
"src/components/PageScrollViewport.tsx",
|
"src/components/PageScrollViewport.tsx",
|
||||||
"src/components/PageTitle.tsx",
|
"src/components/PageTitle.tsx",
|
||||||
"src/components/WorkspaceLayout.tsx",
|
"src/components/WorkspaceLayout.tsx",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const defaultWebModulePackages = [
|
|||||||
"@govoplan/notifications-webui",
|
"@govoplan/notifications-webui",
|
||||||
"@govoplan/organizations-webui",
|
"@govoplan/organizations-webui",
|
||||||
"@govoplan/ops-webui",
|
"@govoplan/ops-webui",
|
||||||
|
"@govoplan/payments-webui",
|
||||||
"@govoplan/policy-webui",
|
"@govoplan/policy-webui",
|
||||||
"@govoplan/portal-webui",
|
"@govoplan/portal-webui",
|
||||||
"@govoplan/postbox-webui",
|
"@govoplan/postbox-webui",
|
||||||
@@ -268,6 +269,7 @@ export default defineConfig({
|
|||||||
fileURLToPath(new URL('../../govoplan-organizations/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-organizations/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-campaign/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-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-policy/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-portal/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-portal/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-postbox/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-postbox/webui', import.meta.url)),
|
||||||
|
|||||||
Reference in New Issue
Block a user