Adopt shared WebUI structural primitives

This commit is contained in:
2026-08-18 13:17:32 +02:00
parent e4663d545a
commit 139dc0cd75
2 changed files with 27 additions and 155 deletions
+25 -25
View File
@@ -19,11 +19,18 @@ import { ActionToolbar,
DocumentationHelpLink, DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
FieldLabel, FieldLabel,
FilterBar,
FormLayout,
IconButton, IconButton,
LoadingIndicator, LoadingIndicator,
PageScrollViewport, PageScrollViewport,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge, StatusBadge,
hasScope, hasScope,
WorkspaceFrame,
type PlatformRouteContext type PlatformRouteContext
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
@@ -205,9 +212,9 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
return ( return (
<main className="projects-page"> <main className="projects-page">
<div className="projects-shell"> <WorkspaceFrame className="projects-shell" label="Projects workspace" interfaceId="projects.workspace" helpContextId="projects.page.workspace" helpModuleId="projects">
<ActionToolbar className="projects-toolbar"> <ActionToolbar surface="panel-header" className="projects-toolbar">
<form className="projects-search" onSubmit={submitSearch}> <FilterBar as="form" surface="control" wrap="never" width="default" className="projects-search" onSubmit={submitSearch}>
<Search size={17} aria-hidden="true" /> <Search size={17} aria-hidden="true" />
<input <input
value={query} value={query}
@@ -216,7 +223,7 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
placeholder="Search portfolios, projects, and milestones" placeholder="Search portfolios, projects, and milestones"
/> />
<Button type="submit" variant="primary">Search</Button> <Button type="submit" variant="primary">Search</Button>
</form> </FilterBar>
<label className="projects-kind-filter"> <label className="projects-kind-filter">
<span>Type</span> <span>Type</span>
<select value={kind} onChange={(event) => setKind(event.target.value as ProjectObjectKind | "")}> <select value={kind} onChange={(event) => setKind(event.target.value as ProjectObjectKind | "")}>
@@ -246,35 +253,28 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
<PageScrollViewport className="projects-list-viewport"> <PageScrollViewport className="projects-list-viewport">
{loading && <LoadingIndicator label="Loading projects" />} {loading && <LoadingIndicator label="Loading projects" />}
{!loading && objects.length === 0 && {!loading && objects.length === 0 &&
<div className="projects-empty">No matching planning objects.</div> <StatePanel size="compact" description="No matching planning objects." />
} }
<div className="projects-list" role="list"> <SelectionList variant="navigation" label="Planning objects">
{objects.map((item) => {objects.map((item) =>
<button <SelectionListItem
type="button"
role="listitem"
key={objectKey(item)} key={objectKey(item)}
className={`project-row${objectKey(item) === selectedKey ? " is-selected" : ""}`} selected={objectKey(item) === selectedKey}
onClick={() => setSelectedKey(objectKey(item))}> onClick={() => setSelectedKey(objectKey(item))}>
<span className="project-row-icon">{kindIcon(item.object_kind)}</span> <SelectionListItemContent leading={kindIcon(item.object_kind)} title={item.title} description={`${item.object_key} · ${formatDate(item.due_at)}`} />
<span className="project-row-main">
<strong>{item.title}</strong>
<small>{item.object_key}</small>
</span>
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} /> <StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
<span className="project-row-date">{formatDate(item.due_at)}</span> </SelectionListItem>
</button>
)} )}
</div> </SelectionList>
</PageScrollViewport> </PageScrollViewport>
<PageScrollViewport className="project-detail-viewport"> <PageScrollViewport className="project-detail-viewport">
{selected ? {selected ?
<ProjectDetail record={selected} canWrite={canWrite} onEdit={openEdit} /> : <ProjectDetail record={selected} canWrite={canWrite} onEdit={openEdit} /> :
<div className="projects-empty">Select a portfolio, project, or milestone.</div> <StatePanel size="fill" title="Planning objects" description="Select a portfolio, project, or milestone." />
} }
</PageScrollViewport> </PageScrollViewport>
</div> </div>
</div> </WorkspaceFrame>
<ProjectEditorDialog <ProjectEditorDialog
open={editorOpen} open={editorOpen}
record={editing} record={editing}
@@ -397,7 +397,7 @@ function ProjectEditorDialog({ open, record, objects, saving, error, onClose, on
</> </>
}> }>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<form id="project-editor-form" className="project-editor-form" onSubmit={submit}> <FormLayout id="project-editor-form" columns={2} gap="compact" collapseAt="narrow" className="project-editor-form" onSubmit={submit}>
<label> <label>
<FieldLabel>Type</FieldLabel> <FieldLabel>Type</FieldLabel>
<select <select
@@ -413,7 +413,7 @@ function ProjectEditorDialog({ open, record, objects, saving, error, onClose, on
</select> </select>
</label> </label>
{values.kind !== "portfolio" && {values.kind !== "portfolio" &&
<label className="project-editor-wide"> <label className="wide">
<FieldLabel>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</FieldLabel> <FieldLabel>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</FieldLabel>
<select <select
value={values.parentRef} value={values.parentRef}
@@ -437,7 +437,7 @@ function ProjectEditorDialog({ open, record, objects, saving, error, onClose, on
<FieldLabel help="Stable identifier used in links and external mappings.">Key</FieldLabel> <FieldLabel help="Stable identifier used in links and external mappings.">Key</FieldLabel>
<input value={values.key} disabled={Boolean(record)} required maxLength={120} onChange={(event) => set("key", event.target.value)} /> <input value={values.key} disabled={Boolean(record)} required maxLength={120} onChange={(event) => set("key", event.target.value)} />
</label> </label>
<label className="project-editor-wide"> <label className="wide">
<FieldLabel>Title</FieldLabel> <FieldLabel>Title</FieldLabel>
<input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} /> <input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} />
</label> </label>
@@ -462,7 +462,7 @@ function ProjectEditorDialog({ open, record, objects, saving, error, onClose, on
<FieldLabel>Due</FieldLabel> <FieldLabel>Due</FieldLabel>
<input type="date" value={values.dueAt} onChange={(event) => set("dueAt", event.target.value)} /> <input type="date" value={values.dueAt} onChange={(event) => set("dueAt", event.target.value)} />
</label> </label>
<label className="project-editor-wide"> <label className="wide">
<FieldLabel>Description</FieldLabel> <FieldLabel>Description</FieldLabel>
<textarea rows={5} value={values.description} onChange={(event) => set("description", event.target.value)} /> <textarea rows={5} value={values.description} onChange={(event) => set("description", event.target.value)} />
</label> </label>
@@ -470,7 +470,7 @@ function ProjectEditorDialog({ open, record, objects, saving, error, onClose, on
<FieldLabel help="Recorded with the immutable project revision and lifecycle evidence.">Change reason</FieldLabel> <FieldLabel help="Recorded with the immutable project revision and lifecycle evidence.">Change reason</FieldLabel>
<input value={values.changeReason} required maxLength={1000} onChange={(event) => set("changeReason", event.target.value)} /> <input value={values.changeReason} required maxLength={1000} onChange={(event) => set("changeReason", event.target.value)} />
</label> </label>
</form> </FormLayout>
</Dialog> </Dialog>
); );
} }
+2 -130
View File
@@ -1,36 +1,11 @@
.projects-page, .projects-page {
.projects-shell {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.projects-shell {
display: flex;
flex-direction: column;
background: var(--surface);
}
.projects-toolbar {
display: flex;
align-items: center;
gap: 12px;
min-height: 58px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
background: var(--surface-raised);
}
.projects-search { .projects-search {
display: flex; flex: 1 1 560px;
align-items: center;
gap: 8px;
width: min(560px, 48vw);
}
.projects-search input {
min-width: 160px;
flex: 1;
} }
.projects-kind-filter { .projects-kind-filter {
@@ -67,69 +42,6 @@
background: var(--surface-subtle, var(--surface)); background: var(--surface-subtle, var(--surface));
} }
.projects-list {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-raised);
}
.project-row {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto minmax(96px, auto);
align-items: center;
gap: 10px;
width: 100%;
min-height: 62px;
padding: 9px 12px;
border: 0;
border-bottom: 1px solid var(--border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.project-row:last-child {
border-bottom: 0;
}
.project-row:hover,
.project-row:focus-visible,
.project-row.is-selected {
background: var(--hover-bg);
}
.project-row.is-selected {
box-shadow: inset 3px 0 0 var(--accent);
}
.project-row-icon {
display: grid;
color: var(--text-soft);
place-items: center;
}
.project-row-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.project-row-main strong,
.project-row-main small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-row-main small,
.project-row-date {
color: var(--text-soft);
font-size: 0.78rem;
}
.project-detail { .project-detail {
width: min(100%, 980px); width: min(100%, 980px);
margin: 0 auto; margin: 0 auto;
@@ -228,22 +140,10 @@
font-size: 0.82rem; font-size: 0.82rem;
} }
.projects-empty {
padding: 36px 10px;
color: var(--text-soft);
text-align: center;
}
.project-editor-dialog { .project-editor-dialog {
width: min(720px, calc(100vw - 32px)); width: min(720px, calc(100vw - 32px));
} }
.project-editor-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.project-editor-form label { .project-editor-form label {
display: flex; display: flex;
min-width: 0; min-width: 0;
@@ -256,20 +156,7 @@
font-size: 0.8rem; font-size: 0.8rem;
} }
.project-editor-wide {
grid-column: 1 / -1;
}
@media (max-width: 920px) { @media (max-width: 920px) {
.projects-toolbar {
align-items: stretch;
flex-wrap: wrap;
}
.projects-search {
width: 100%;
}
.projects-count { .projects-count {
margin-left: 0; margin-left: 0;
} }
@@ -291,19 +178,4 @@
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.project-row {
grid-template-columns: 26px minmax(0, 1fr) auto;
}
.project-row-date {
display: none;
}
.project-editor-form {
grid-template-columns: 1fr;
}
.project-editor-wide {
grid-column: auto;
}
} }