Adopt shared WebUI structural primitives

This commit is contained in:
2026-08-18 13:17:31 +02:00
parent abf35d75f3
commit 50e0172f65
5 changed files with 27 additions and 73 deletions
+5 -3
View File
@@ -29,8 +29,10 @@ submissions, receipts, and handoff execution.
The module uses shared controls, dialogs, blockers, field help, statuses, The module uses shared controls, dialogs, blockers, field help, statuses,
loading, empty/error states, disabled reasons, confirmations, and unsaved-draft loading, empty/error states, disabled reasons, confirmations, and unsaved-draft
guards. The catalogue action row now uses the shared responsive guards. The catalogue now composes `WorkspaceFrame`, `ActionToolbar`,
`ActionToolbar`; equal-column definition/editor groups use `FormGrid` and its `FilterBar`, and `StatePanel`; equal-column definition/editor groups use
named narrow-layout collapse policy. These layout contracts do not change `FormGrid`, while field, page, localization, and preview headings use the
shared section-header toolbar surface. Core therefore owns viewport, search,
state, heading, and narrow-layout geometry. These contracts do not change
filter scope, permissions, revision semantics, or publication consequences. filter scope, permissions, revision semantics, or publication consequences.
English and German catalogues cover the owned route and editor vocabulary. English and German catalogues cover the owned route and editor vocabulary.
+3 -2
View File
@@ -194,8 +194,9 @@ manifest = ModuleManifest(
summary="Create immutable, versioned schemas consumed by Forms Runtime and institutional services.", summary="Create immutable, versioned schemas consumed by Forms Runtime and institutional services.",
body=( body=(
"Each revision fixes field types, options, constraints, draft, attachment, signature, policy, and handoff requirements. " "Each revision fixes field types, options, constraints, draft, attachment, signature, policy, and handoff requirements. "
"Publishing is explicit; existing submissions continue to retain their exact revision. The catalogue action row and " "Publishing is explicit; existing submissions continue to retain their exact revision. The catalogue, filter, empty states, "
"definition field groups reflow at narrow widths without changing filter scope, permissions, or lifecycle effects." "section headings, and definition field groups use the shared responsive layout language and reflow at narrow widths without "
"changing filter scope, permissions, or lifecycle effects."
), ),
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -1,6 +1,7 @@
import { ArrowDown, ArrowUp, Eye, Languages, Plus, Trash2 } from "lucide-react"; import { ArrowDown, ArrowUp, Eye, Languages, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { FormGrid, import { FormGrid,
ActionToolbar,
Button, Button,
ConfirmDialog, ConfirmDialog,
Dialog, Dialog,
@@ -262,12 +263,12 @@ export default function FormDefinitionDialog({
</Field> </Field>
</FormGrid> </FormGrid>
<div className="form-field-editor-heading"> <ActionToolbar surface="section-header" className="form-field-editor-heading">
<h3>Fields</h3> <h3>Fields</h3>
<Button onClick={() => setDraft(addField(draft))} disabled={busy}> <Button onClick={() => setDraft(addField(draft))} disabled={busy}>
<Plus size={16} aria-hidden="true" />Add field <Plus size={16} aria-hidden="true" />Add field
</Button> </Button>
</div> </ActionToolbar>
<div className="form-field-editor-list"> <div className="form-field-editor-list">
{draft.fields.map((field, index) => {draft.fields.map((field, index) =>
<div className="form-field-editor-row" key={`${index}:${field.key}`}> <div className="form-field-editor-row" key={`${index}:${field.key}`}>
@@ -417,12 +418,12 @@ function PageEditor({ draft, disabled, onChange }: { draft: FormDefinition; disa
} }
return ( return (
<section className="form-composition-section"> <section className="form-composition-section">
<div className="form-field-editor-heading"> <ActionToolbar surface="section-header" className="form-field-editor-heading">
<h3>Pages and sections</h3> <h3>Pages and sections</h3>
{pages.length === 0 {pages.length === 0
? <Button disabled={disabled} onClick={() => updatePages([defaultPage(draft.fields)])}><Plus size={16} aria-hidden="true" />Enable pages</Button> ? <Button disabled={disabled} onClick={() => updatePages([defaultPage(draft.fields)])}><Plus size={16} aria-hidden="true" />Enable pages</Button>
: <Button disabled={disabled} onClick={() => updatePages([...pages, emptyPage(pages.length + 1)])}><Plus size={16} aria-hidden="true" />Add page</Button>} : <Button disabled={disabled} onClick={() => updatePages([...pages, emptyPage(pages.length + 1)])}><Plus size={16} aria-hidden="true" />Add page</Button>}
</div> </ActionToolbar>
{pages.length === 0 && <p className="form-section-note">Fields render in their declared order on one page.</p>} {pages.length === 0 && <p className="form-section-note">Fields render in their declared order on one page.</p>}
{pages.map((page, pageIndex) => {pages.map((page, pageIndex) =>
<div className="form-page-editor" key={`${pageIndex}:${page.key}`}> <div className="form-page-editor" key={`${pageIndex}:${page.key}`}>
@@ -470,10 +471,10 @@ function LocalizationEditor({ draft, disabled, onChange }: { draft: FormDefiniti
} }
return ( return (
<section className="form-composition-section"> <section className="form-composition-section">
<div className="form-field-editor-heading"> <ActionToolbar surface="section-header" className="form-field-editor-heading">
<h3><Languages size={17} aria-hidden="true" />Localizations</h3> <h3><Languages size={17} aria-hidden="true" />Localizations</h3>
<Button disabled={disabled} onClick={() => update([...localizations, emptyLocalization()])}><Plus size={16} aria-hidden="true" />Add locale</Button> <Button disabled={disabled} onClick={() => update([...localizations, emptyLocalization()])}><Plus size={16} aria-hidden="true" />Add locale</Button>
</div> </ActionToolbar>
{localizations.length === 0 && <p className="form-section-note">The canonical labels are used for every locale.</p>} {localizations.length === 0 && <p className="form-section-note">The canonical labels are used for every locale.</p>}
{localizations.map((localization, index) => {localizations.map((localization, index) =>
<div className="form-localization-editor" key={`${index}:${localization.locale}`}> <div className="form-localization-editor" key={`${index}:${localization.locale}`}>
@@ -505,7 +506,7 @@ function DefinitionPreview({ definition, previous }: { definition: FormDefinitio
const changed = previous ? definitionChanges(previous, definition) : ["New definition"]; const changed = previous ? definitionChanges(previous, definition) : ["New definition"];
return ( return (
<section className="form-composition-section form-definition-preview"> <section className="form-composition-section form-definition-preview">
<div className="form-field-editor-heading"><h3><Eye size={17} aria-hidden="true" />Preview and revision changes</h3></div> <ActionToolbar surface="section-header" className="form-field-editor-heading"><h3><Eye size={17} aria-hidden="true" />Preview and revision changes</h3></ActionToolbar>
<FormGrid columns={2} collapseAt="narrow" className="form-preview-grid"> <FormGrid columns={2} collapseAt="narrow" className="form-preview-grid">
<div> <div>
<strong>{definition.title || "Untitled Form"}</strong> <strong>{definition.title || "Untitled Form"}</strong>
+9 -6
View File
@@ -8,12 +8,15 @@ import { ActionToolbar,
DismissibleAlert, DismissibleAlert,
IconButton, IconButton,
FormField, FormField,
FilterBar,
LoadingIndicator, LoadingIndicator,
PageScrollViewport, PageScrollViewport,
StatePanel,
StatusBadge, StatusBadge,
hasScope, hasScope,
useUnsavedChanges, useUnsavedChanges,
useUnsavedDraftGuard, useUnsavedDraftGuard,
WorkspaceFrame,
type PlatformRouteContext type PlatformRouteContext
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
@@ -144,13 +147,13 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
return ( return (
<main className="forms-page"> <main className="forms-page">
<div className="forms-shell"> <WorkspaceFrame className="forms-shell" label="Form definitions" interfaceId="forms.catalogue" helpContextId="forms.page.catalogue" helpModuleId="forms">
<ActionToolbar className="forms-toolbar"> <ActionToolbar surface="panel-header" className="forms-toolbar">
<form onSubmit={search} className="forms-search"> <FilterBar as="form" surface="control" wrap="never" width="default" onSubmit={search} className="forms-search">
<Search size={17} aria-hidden="true" /> <Search size={17} aria-hidden="true" />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search definitions" aria-label="Search Form definitions" /> <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search definitions" aria-label="Search Form definitions" />
<Button type="submit">Search</Button> <Button type="submit">Search</Button>
</form> </FilterBar>
<label> <label>
<span>State</span> <span>State</span>
<select value={state} onChange={(event) => setState(event.target.value)}> <select value={state} onChange={(event) => setState(event.target.value)}>
@@ -171,7 +174,7 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{!canWrite && <ActionBlockerHint tone="info" reason={{ summary: "No Form management permission", details: FORMS_I18N.writeReason, requiredAction: FORMS_I18N.permissionAction, actor: FORMS_I18N.permissionActor, target: FORMS_I18N.permissionDestination }} labels={{ requiredAction: FORMS_I18N.requiredAction, actor: FORMS_I18N.actor, target: FORMS_I18N.destination }} documentation={FORMS_DOCUMENTATION} />} {!canWrite && <ActionBlockerHint tone="info" reason={{ summary: "No Form management permission", details: FORMS_I18N.writeReason, requiredAction: FORMS_I18N.permissionAction, actor: FORMS_I18N.permissionActor, target: FORMS_I18N.permissionDestination }} labels={{ requiredAction: FORMS_I18N.requiredAction, actor: FORMS_I18N.actor, target: FORMS_I18N.destination }} documentation={FORMS_DOCUMENTATION} />}
{loading && <LoadingIndicator label="Loading Form definitions" />} {loading && <LoadingIndicator label="Loading Form definitions" />}
{!loading && !error && items.length === 0 && <div className="forms-empty">No matching definitions.</div>} {!loading && !error && items.length === 0 && <StatePanel size="compact" description="No matching definitions." />}
{!loading && items.length > 0 && {!loading && items.length > 0 &&
<div className="forms-list" role="list"> <div className="forms-list" role="list">
{items.map((item) => { {items.map((item) => {
@@ -192,7 +195,7 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
</div> </div>
} }
</PageScrollViewport> </PageScrollViewport>
</div> </WorkspaceFrame>
{editing && {editing &&
<FormDefinitionDialog <FormDefinitionDialog
open open
+2 -55
View File
@@ -1,39 +1,14 @@
.forms-page, .forms-page {
.forms-shell {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.forms-shell {
display: flex;
flex-direction: column;
background: var(--surface);
}
.forms-toolbar {
display: flex;
align-items: center;
gap: 10px;
min-height: 58px;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
background: var(--surface-raised);
}
.forms-search { .forms-search {
display: flex; flex: 1 1 520px;
align-items: center;
gap: 8px;
width: min(520px, 100%);
margin-right: auto; margin-right: auto;
} }
.forms-search input {
min-width: 120px;
flex: 1;
}
.forms-toolbar > label { .forms-toolbar > label {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -122,12 +97,6 @@
color: var(--text-soft); color: var(--text-soft);
} }
.forms-empty {
padding: 36px 0;
color: var(--text-soft);
text-align: center;
}
.form-definition-dialog { .form-definition-dialog {
width: min(1120px, calc(100vw - 32px)); width: min(1120px, calc(100vw - 32px));
height: min(860px, calc(100vh - 32px)); height: min(860px, calc(100vh - 32px));
@@ -171,19 +140,6 @@
font-weight: 600; font-weight: 600;
} }
.form-field-editor-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.form-field-editor-heading h3 {
margin: 0;
font-size: 0.98rem;
letter-spacing: 0;
}
.form-field-editor-list { .form-field-editor-list {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
@@ -313,15 +269,6 @@
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.forms-toolbar {
align-items: stretch;
flex-wrap: wrap;
}
.forms-search {
width: 100%;
}
.forms-row { .forms-row {
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(0, 1fr) auto auto;
} }