Adopt shared WebUI structural primitives

This commit is contained in:
2026-08-18 13:17:32 +02:00
parent f0f0286866
commit dc9fdc9143
2 changed files with 44 additions and 107 deletions
+44 -30
View File
@@ -10,8 +10,15 @@ import { ActionToolbar,
FormField,
IconButton,
LoadingIndicator,
MetricCard,
MetricGrid,
PageScrollViewport,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
WorkspaceLayout,
hasScope,
i18nMessage,
usePlatformLanguage,
@@ -144,28 +151,40 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
return (
<main className="voting-page">
<div className="voting-shell">
<aside className="voting-catalogue">
<ActionToolbar className="voting-toolbar">
<WorkspaceLayout
variant="split"
primarySize="default"
primaryScrollable={false}
contentScrollable={false}
surface="contained"
primaryClassName="voting-catalogue"
contentClassName="voting-workspace"
primaryLabel="Ballots"
contentLabel="Ballot details"
interfaceId="voting.workspace"
helpContextId="voting.workspace"
helpModuleId="voting"
primary={<>
<ActionToolbar surface="panel-header" className="voting-toolbar">
<IconButton label="Refresh ballots" icon={<RefreshCw size={16} />} disabled={loading || busy} disabledReason={loading ? VOTING_I18N.loading : busy ? VOTING_I18N.busy : undefined} onClick={() => void loadList()} />
<Button variant="primary" disabled={!canManage} disabledReason={!canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>
<DocumentationHelpLink reference={VOTING_DOCUMENTATION} />
</ActionToolbar>
<PageScrollViewport className="voting-list-viewport">
{loading && <LoadingIndicator label="Loading ballots" />}
{!loading && items.length === 0 && <div className="voting-empty">No ballots.</div>}
<div className="voting-list" role="list">
{items.map((item) => <button type="button" role="listitem" className={`voting-list-row${item.id === selectedId ? " is-selected" : ""}`} key={item.id} onClick={() => setSelectedId(item.id)}>
<span><strong>{item.title}</strong><small>{humanize(item.assurance_profile)}</small></span>
{!loading && items.length === 0 && <StatePanel size="compact" description="No ballots." />}
<SelectionList variant="navigation" label="Ballots">
{items.map((item) => <SelectionListItem selected={item.id === selectedId} key={item.id} onClick={() => setSelectedId(item.id)}>
<SelectionListItemContent title={item.title} description={humanize(item.assurance_profile)} />
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
</button>)}
</div>
</SelectionListItem>)}
</SelectionList>
</PageScrollViewport>
</aside>
<section className="voting-workspace">
</>}
>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{notice && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
{!selected && !loading && <div className="voting-empty">Select a ballot.</div>}
{!selected && !loading && <StatePanel size="fill" title="Ballots" description="Select a ballot." />}
{selected && <PageScrollViewport className="voting-detail-viewport">
<div className="voting-detail-heading">
<div><h2>{selected.title}</h2><span>Revision {selected.revision}</span></div>
@@ -179,12 +198,12 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
{selected.state !== "annulled" && <Button variant="danger" disabled={busy || !canAdmin} disabledReason={busy ? VOTING_I18N.busy : !canAdmin ? VOTING_I18N.adminReason : undefined} onClick={() => setReasonAction("annul")}>Annul</Button>}
</div>
</div>
<div className="voting-metrics">
<Metric label="Electors" value={selected.electorate.length} />
<Metric label="Eligible weight" value={selected.electorate.reduce((total, item) => total + item.weight, 0)} />
<Metric label="Quorum" value={selected.quorum_weight} />
<Metric label="Assurance" value={humanize(selected.assurance_profile)} />
</div>
<MetricGrid columns={4} spacing="block">
<MetricCard density="compact" label="Electors" value={selected.electorate.length} />
<MetricCard density="compact" label="Eligible weight" value={selected.electorate.reduce((total, item) => total + item.weight, 0)} />
<MetricCard density="compact" label="Quorum" value={selected.quorum_weight} />
<MetricCard density="compact" label="Assurance" value={humanize(selected.assurance_profile)} />
</MetricGrid>
{selected.description && <p className="voting-description">{selected.description}</p>}
{selected.state === "open" && selected.assurance_profile === "recorded" && canCast && eligible && <section className="voting-cast-panel">
<h3>Cast vote</h3>
@@ -215,12 +234,12 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
</section>
{selected.result && <section className="voting-section">
<h3>Result</h3>
<div className="voting-metrics">
<Metric label="Votes" value={`${selected.result.cast_count} / ${selected.result.eligible_count}`} />
<Metric label="Cast weight" value={`${selected.result.cast_weight} / ${selected.result.eligible_weight}`} />
<Metric label="Quorum" value={selected.result.quorum_met ? "Met" : "Not met"} />
<Metric label="Threshold" value={selected.result.threshold_met ? "Met" : "Not met"} />
</div>
<MetricGrid columns={4} spacing="block">
<MetricCard density="compact" label="Votes" value={`${selected.result.cast_count} / ${selected.result.eligible_count}`} />
<MetricCard density="compact" label="Cast weight" value={`${selected.result.cast_weight} / ${selected.result.eligible_weight}`} />
<MetricCard density="compact" label="Quorum" value={selected.result.quorum_met ? "Met" : "Not met"} />
<MetricCard density="compact" label="Threshold" value={selected.result.threshold_met ? "Met" : "Not met"} />
</MetricGrid>
<Hash label="Result hash" value={selected.result.result_sha256} />
</section>}
<section className="voting-section voting-assurance">
@@ -235,8 +254,7 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
</div>
</section>
</PageScrollViewport>}
</section>
</div>
</WorkspaceLayout>
{editing && <VotingBallotDialog
open
settings={settings}
@@ -299,10 +317,6 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
);
}
function Metric({ label, value }: { label: string; value: string | number }) {
return <div><span>{label}</span><strong>{value}</strong></div>;
}
function Hash({ label, value }: { label: string; value?: string | null }) {
return <div className="voting-hash"><span>{label}</span><code>{value || "Not frozen"}</code></div>;
}
-77
View File
@@ -4,22 +4,12 @@
overflow: hidden;
}
.voting-shell {
display: grid;
grid-template-columns: minmax(250px, 320px) minmax(0, 1fr);
min-height: 0;
height: 100%;
background: var(--surface, #fff);
}
.voting-catalogue {
display: flex;
min-height: 0;
flex-direction: column;
border-right: 1px solid var(--border-color, #d8dde3);
}
.voting-toolbar,
.voting-detail-heading,
.voting-actions,
.voting-editor-heading {
@@ -28,52 +18,18 @@
gap: 8px;
}
.voting-toolbar {
min-height: 50px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #d8dde3);
}
.voting-list-viewport,
.voting-detail-viewport {
min-height: 0;
flex: 1;
}
.voting-list {
display: flex;
flex-direction: column;
padding: 6px;
}
.voting-list-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 52px;
padding: 7px 8px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.voting-list-row:hover,
.voting-list-row.is-selected {
background: var(--hover-bg, rgba(54, 99, 135, 0.1));
}
.voting-list-row > span:first-child,
.voting-option-results > div > span:first-child {
display: flex;
min-width: 0;
flex-direction: column;
}
.voting-list-row small,
.voting-option-results small {
color: var(--text-muted, #65717e);
}
@@ -118,23 +74,6 @@
justify-content: flex-end;
}
.voting-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(110px, 1fr));
gap: 10px;
margin: 16px 0;
}
.voting-metrics > div {
display: flex;
min-width: 0;
flex-direction: column;
padding: 10px 12px;
border: 1px solid var(--border-color, #d8dde3);
border-radius: 4px;
}
.voting-metrics span,
.voting-hash span {
color: var(--text-muted, #65717e);
font-size: 0.75rem;
@@ -200,11 +139,6 @@
text-overflow: ellipsis;
}
.voting-empty {
padding: 24px;
color: var(--text-muted, #65717e);
}
.voting-ballot-dialog {
height: min(820px, calc(100vh - 32px));
}
@@ -256,17 +190,6 @@
}
@media (max-width: 800px) {
.voting-shell {
grid-template-columns: 1fr;
grid-template-rows: minmax(160px, 34%) minmax(0, 1fr);
}
.voting-catalogue {
border-right: 0;
border-bottom: 1px solid var(--border-color, #d8dde3);
}
.voting-metrics,
.voting-option-row,
.voting-elector-row {
grid-template-columns: 1fr;