Centralize metric and description layouts

This commit is contained in:
2026-08-18 11:30:39 +02:00
parent 6814a41ae4
commit ee5c881df9
12 changed files with 241 additions and 45 deletions
+1
View File
@@ -38,6 +38,7 @@ export default function ConfirmDialog({
open={open}
title={title}
role="alertdialog"
size="small"
className="confirm-dialog"
footerClassName="button-row compact-actions"
closeOnBackdrop={!busy}
@@ -512,7 +512,6 @@ export default function CredentialEnvelopeManager({
helpModuleId="access"
onClose={closeEditor}
closeDisabled={saving}
className="adaptive-config-dialog"
footerClassName="button-row compact-actions"
footer={
<>
+59
View File
@@ -0,0 +1,59 @@
import type { HTMLAttributes, ReactNode } from "react";
export type DescriptionListColumns = 1 | 2 | 3 | 4 | 5;
export type DescriptionListDensity = "compact" | "default";
export type DescriptionListCollapseAt = "narrow" | "workspace" | "standard" | "wide" | "never";
export type DescriptionListVariant = "stacked" | "inline";
export type DescriptionListTermWidth = "compact" | "default" | "wide";
export type DescriptionListProps = HTMLAttributes<HTMLDListElement> & {
children: ReactNode;
columns?: DescriptionListColumns;
density?: DescriptionListDensity;
collapseAt?: DescriptionListCollapseAt;
variant?: DescriptionListVariant;
termWidth?: DescriptionListTermWidth;
};
export default function DescriptionList({
children,
columns,
density = "default",
collapseAt = "workspace",
variant = "stacked",
termWidth = "default",
className = "",
...props
}: DescriptionListProps) {
const resolvedColumns = columns ?? (variant === "inline" ? 1 : 2);
return (
<dl
{...props}
className={[
"description-list-layout",
`description-list-columns-${resolvedColumns}`,
`description-list-density-${density}`,
`description-list-collapse-${collapseAt}`,
`description-list-${variant}`,
`description-list-term-${termWidth}`,
className
].filter(Boolean).join(" ")}
>
{children}
</dl>
);
}
export type DescriptionItemProps = HTMLAttributes<HTMLDivElement> & {
term: ReactNode;
children: ReactNode;
};
export function DescriptionItem({ term, children, className = "", ...props }: DescriptionItemProps) {
return (
<div {...props} className={["description-list-item", className].filter(Boolean).join(" ")}>
<dt>{term}</dt>
<dd>{children}</dd>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import type { HTMLAttributes, ReactNode } from "react";
export type MetricGridColumns = 1 | 2 | 3 | 4 | 5 | "auto";
export type MetricGridDensity = "compact" | "default" | "loose";
export type MetricGridSpacing = "block" | "inset" | "none";
export type MetricGridMinimum = "fluid" | "compact" | "default" | "wide";
export type MetricGridCollapseAt = "narrow" | "workspace" | "standard" | "wide" | "never";
export type MetricGridProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
columns?: MetricGridColumns;
density?: MetricGridDensity;
spacing?: MetricGridSpacing;
minimum?: MetricGridMinimum;
collapseAt?: MetricGridCollapseAt;
};
export default function MetricGrid({
children,
columns = 4,
density = "default",
spacing = "block",
minimum = "default",
collapseAt = "workspace",
className = "",
...props
}: MetricGridProps) {
return (
<div
{...props}
className={[
"metric-group-layout",
`metric-group-columns-${columns}`,
`metric-group-density-${density}`,
`metric-group-spacing-${spacing}`,
`metric-group-minimum-${minimum}`,
`metric-group-collapse-${collapseAt}`,
className
].filter(Boolean).join(" ")}
>
{children}
</div>
);
}
@@ -1,6 +1,8 @@
import ContentGrid from "../../components/ContentGrid";
import Card from "../../components/Card";
import MetricCard from "../../components/MetricCard";
import MetricGrid from "../../components/MetricGrid";
import DescriptionList from "../../components/DescriptionList";
import PageLayout from "../../components/PageLayout";
import { usePlatformModules } from "../../platform/ModuleContext";
@@ -13,22 +15,22 @@ export default function DashboardPage() {
description="Install and enable the dashboard module to make this page configurable."
viewportClassName="core-dashboard-page"
>
<div className="metric-grid">
<MetricGrid>
<MetricCard label="i18n:govoplan-core.installed_modules.32b3e799" value={modules.length} tone="info" detail={modules.length ? moduleLabels(modules).join(", ") : "i18n:govoplan-core.core_only.d46ce7d9"} />
<MetricCard label="Dashboard module" value="not installed" tone="neutral" detail="Core fallback is active." />
</div>
</MetricGrid>
<ContentGrid columns={2} collapseAt="workspace" className="">
<Card title="i18n:govoplan-core.installed_modules.32b3e799">
{modules.length === 0 ? <p className="muted">i18n:govoplan-core.no_feature_modules_are_active.e9c2d936</p> :
<dl className="detail-list">
<DescriptionList variant="inline">
{modules.map((module) =>
<div key={module.id}>
<dt>{module.id}</dt>
<dd><strong>{module.label}</strong><span className="muted"> i18n:govoplan-core.v.26c12f1e{module.version}</span></dd>
</div>
)}
</dl>
</DescriptionList>
}
</Card>
<Card title="Home">
+7 -6
View File
@@ -1,3 +1,4 @@
import DescriptionList from "../../components/DescriptionList";
import ContentGrid, { FormGrid } from "../../components/ContentGrid";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router";
@@ -344,12 +345,12 @@ export default function SettingsPage({
</FormGrid>
</Card>
<Card title="i18n:govoplan-core.account_context.5bb0a918">
<dl className="detail-list compact-detail-list">
<DescriptionList variant="inline" density="compact">
<div><dt>i18n:govoplan-core.account_id.c5e0db28</dt><dd>{auth.user.account_id}</dd></div>
<div><dt>i18n:govoplan-core.active_tenant.39b16ee9</dt><dd>{auth.active_tenant?.name || auth.tenant.name}</dd></div>
<div><dt>i18n:govoplan-core.tenant_roles.51aca82d</dt><dd>{auth.roles.filter((role) => role.level !== "system").map((role) => role.name).join(", ") || "i18n:govoplan-core.none.6eef6648"}</dd></div>
<div><dt>i18n:govoplan-core.system_roles.a9461aa6</dt><dd>{auth.roles.filter((role) => role.level === "system").map((role) => role.name).join(", ") || "i18n:govoplan-core.none.6eef6648"}</dd></div>
</dl>
</DescriptionList>
<p className="muted small-note">i18n:govoplan-core.email_changes_and_password_management_require_de.f7443b69</p>
</Card>
</ContentGrid>
@@ -448,14 +449,14 @@ export default function SettingsPage({
ariaLabel="i18n:govoplan-core.theme.a797e309" />
</FormField>
<ThemePreview theme={theme} />
<dl className="detail-list compact-detail-list">
<DescriptionList variant="inline" density="compact">
<div><dt>i18n:govoplan-core.theme.a797e309</dt><dd>{themeLabel(theme)}</dd></div>
<div><dt>i18n:govoplan-core.accent_color.e49578ed</dt><dd>i18n:govoplan-core.default_brand_accent.606ae693</dd></div>
<div><dt>i18n:govoplan-core.language.89b86ab0</dt><dd>{languageLabel}</dd></div>
<div><dt>i18n:govoplan-core.enabled.df174a3f</dt><dd>{enabledLanguages.map((item) => item.code.toUpperCase()).join(", ")}</dd></div>
<div><dt>i18n:govoplan-core.available.7c62a142</dt><dd>{availableLanguages.map((item) => item.code.toUpperCase()).join(", ")}</dd></div>
<div><dt>i18n:govoplan-core.density.f9160c22</dt><dd>{compactTables ? "i18n:govoplan-core.compact_preview.3e06901d" : "i18n:govoplan-core.comfortable.2313707a"}</dd></div>
</dl>
</DescriptionList>
</FormGrid>
</Card>
</ContentGrid>
@@ -530,11 +531,11 @@ export default function SettingsPage({
</FormGrid>
</Card>
<Card title="i18n:govoplan-core.session_state.b7a3d0f4">
<dl className="detail-list compact-detail-list">
<DescriptionList variant="inline" density="compact">
<div><dt>i18n:govoplan-core.browser_session.cc021a31</dt><dd>i18n:govoplan-core.active_via_httponly_cookie.9d05e6ba</dd></div>
<div><dt>i18n:govoplan-core.automation_key.78f1ddbc</dt><dd>{settings.apiKey ? "i18n:govoplan-core.configured.668c5fff" : "i18n:govoplan-core.not_configured.811931bb"}</dd></div>
<div><dt>i18n:govoplan-core.backend_mode.c863723a</dt><dd>{settings.apiBaseUrl ? "i18n:govoplan-core.explicit_api_url.b117b4b8" : "i18n:govoplan-core.same_origin_proxied.c39e6e2b"}</dd></div>
</dl>
</DescriptionList>
<p className="muted small-note">i18n:govoplan-core.tenant_user_mail_server_and_policy_administratio.2f551271</p>
</Card>
</ContentGrid>
+4
View File
@@ -90,6 +90,8 @@ export { default as Dialog } from "./components/Dialog";
export type { DialogBodyPadding, DialogProps, DialogSize, DialogVariant } from "./components/Dialog";
export { DialogActions, DialogForm, DialogSection } from "./components/DialogAnatomy";
export type { DialogActionAlignment, DialogActionsProps, DialogFormProps, DialogSectionProps } from "./components/DialogAnatomy";
export { default as DescriptionList, DescriptionItem } from "./components/DescriptionList";
export type { DescriptionItemProps, DescriptionListCollapseAt, DescriptionListColumns, DescriptionListDensity, DescriptionListProps, DescriptionListTermWidth, DescriptionListVariant } from "./components/DescriptionList";
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
export { default as DismissibleAlert } from "./components/DismissibleAlert";
@@ -118,6 +120,8 @@ export { default as LoadingIndicator } from "./components/LoadingIndicator";
export { default as ExplorerTree } from "./components/ExplorerTree";
export type { ExplorerTreeNodeContext, ExplorerTreeProps } from "./components/ExplorerTree";
export { default as MetricCard } from "./components/MetricCard";
export { default as MetricGrid } from "./components/MetricGrid";
export type { MetricGridCollapseAt, MetricGridColumns, MetricGridDensity, MetricGridMinimum, MetricGridProps, MetricGridSpacing } from "./components/MetricGrid";
export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel";
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
export { default as PageTitle } from "./components/PageTitle";
+50 -18
View File
@@ -522,10 +522,6 @@
gap: 10px;
}
.module-management-metrics {
grid-template-columns: repeat(5, minmax(120px, 1fr));
}
.module-management-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 170px;
@@ -1179,10 +1175,6 @@
min-width: 0;
}
.adaptive-config-dialog {
width: min(1040px, calc(100vw - 40px));
}
.adaptive-config-dialog-body {
background: var(--panel-soft);
}
@@ -3539,9 +3531,6 @@
color: var(--muted);
font-weight: 700;
}
.compact-detail-list {
gap: 8px;
}
.settings-list {
display: grid;
gap: 12px;
@@ -3576,26 +3565,51 @@
.admin-audit-grid .data-grid-header-cell {
top: 0;
}
.admin-details-grid {
.description-list-layout {
--description-list-term-width: 145px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 18px;
margin: 0;
}
.admin-details-grid > div {
.description-list-columns-1 { grid-template-columns: minmax(0, 1fr); }
.description-list-columns-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.description-list-columns-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.description-list-columns-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.description-list-columns-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.description-list-stacked { gap: 10px 18px; }
.description-list-stacked > div {
min-width: 0;
padding: 10px 0;
border-bottom: var(--border-line);
}
.admin-details-grid dt {
.description-list-stacked.description-list-density-compact { gap: 6px 14px; }
.description-list-stacked.description-list-density-compact > div { padding: 6px 0; }
.description-list-inline { gap: 12px; }
.description-list-inline > div {
min-width: 0;
display: grid;
grid-template-columns: var(--description-list-term-width) minmax(0, 1fr);
align-items: center;
gap: 18px;
padding-bottom: 12px;
border-bottom: var(--border-line);
}
.description-list-inline > div:last-child { padding-bottom: 0; border-bottom: 0; }
.description-list-inline.description-list-density-compact { gap: 8px; }
.description-list-term-compact { --description-list-term-width: minmax(92px, auto); }
.description-list-term-default { --description-list-term-width: 145px; }
.description-list-term-wide { --description-list-term-width: 200px; }
.description-list-layout dt {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.admin-details-grid dd {
.description-list-stacked dd {
margin: 4px 0 0;
overflow-wrap: anywhere;
}
.description-list-inline dt { font-weight: 800; letter-spacing: .04em; }
.description-list-inline dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
.admin-governance-mode {
display: grid;
gap: 8px;
@@ -3748,10 +3762,28 @@ a.dashboard-contribution-row:hover {
margin-top: 12px;
}
@media (max-width: 900px) {
.admin-details-grid,
.description-list-collapse-workspace,
.admin-tenant-assignment-row {
grid-template-columns: 1fr;
}
.description-list-inline.description-list-collapse-workspace > div {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 1100px) {
.description-list-collapse-wide { grid-template-columns: 1fr; }
.description-list-inline.description-list-collapse-wide > div { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 980px) {
.description-list-collapse-standard { grid-template-columns: 1fr; }
.description-list-inline.description-list-collapse-standard > div { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 700px) {
.description-list-collapse-narrow { grid-template-columns: 1fr; }
.description-list-inline.description-list-collapse-narrow > div { grid-template-columns: minmax(0, 1fr); }
}
.stage-rail {
max-width: 100%;
-4
View File
@@ -177,10 +177,6 @@
.dialog-section-separated { padding-top: 16px; border-top: var(--border-line); }
.dialog-section-inset { padding: 14px; border: var(--border-line); border-radius: var(--radius); background: var(--panel-soft); }
.confirm-dialog {
width: min(460px, calc(100vw - 40px));
}
.confirm-dialog > .dialog-header,
.confirm-dialog > .dialog-footer {
background: var(--panel);
+35 -9
View File
@@ -217,8 +217,27 @@
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
.card-body { padding: 22px 24px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 12px; margin-bottom: 18px; }
.metric-grid.inside { margin: 14px 0; }
.metric-group-layout {
--metric-group-column-minimum: 140px;
min-width: 0;
display: grid;
}
.metric-group-columns-1 { grid-template-columns: minmax(0, 1fr); }
.metric-group-columns-2 { grid-template-columns: repeat(2, minmax(var(--metric-group-column-minimum), 1fr)); }
.metric-group-columns-3 { grid-template-columns: repeat(3, minmax(var(--metric-group-column-minimum), 1fr)); }
.metric-group-columns-4 { grid-template-columns: repeat(4, minmax(var(--metric-group-column-minimum), 1fr)); }
.metric-group-columns-5 { grid-template-columns: repeat(5, minmax(var(--metric-group-column-minimum), 1fr)); }
.metric-group-columns-auto { grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--metric-group-column-minimum)), 1fr)); }
.metric-group-density-compact { gap: 8px; }
.metric-group-density-default { gap: 12px; }
.metric-group-density-loose { gap: 18px; }
.metric-group-spacing-block { margin: 0 0 18px; }
.metric-group-spacing-inset { margin: 14px 0; }
.metric-group-spacing-none { margin: 0; }
.metric-group-minimum-fluid { --metric-group-column-minimum: 0px; }
.metric-group-minimum-compact { --metric-group-column-minimum: 112px; }
.metric-group-minimum-default { --metric-group-column-minimum: 140px; }
.metric-group-minimum-wide { --metric-group-column-minimum: 180px; }
.metric-card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); border-top: 4px solid var(--line-dark); }
.metric-good { border-top-color: var(--green); } .metric-warning { border-top-color: var(--amber); } .metric-danger { border-top-color: var(--red); } .metric-info { border-top-color: var(--blue); }
.metric-label { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .05em; }
@@ -280,10 +299,22 @@
.workspace-layout-split { grid-template-columns: 1fr; grid-template-rows: minmax(160px, 34%) minmax(0, 1fr); }
.section-sidebar { display: none; }
.wizard-card { grid-template-columns: 1fr; }
.metric-grid { grid-template-columns: 1fr; }
.metric-group-collapse-workspace { grid-template-columns: minmax(0, 1fr); }
.concurrency-conflict-values { grid-template-columns: 1fr; }
}
@media (max-width: 1100px) {
.metric-group-collapse-wide { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 980px) {
.metric-group-collapse-standard { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 700px) {
.metric-group-collapse-narrow { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 700px) {
.action-toolbar-wrap-responsive { align-items: stretch; flex-wrap: wrap; }
.action-toolbar-wrap-responsive > .action-toolbar-group { flex: 1 1 100%; }
@@ -315,11 +346,6 @@
.crumb-link:hover { background: var(--sidebar-hover-bg); color: var(--text-strong); }
.compact-actions { margin: 0; align-items: center; }
.below-grid { margin-top: 18px; }
.detail-list { display: grid; gap: 12px; margin: 0; }
.detail-list div { display: grid; grid-template-columns: 145px minmax(0, 1fr); align-items: center; gap: 18px; padding-bottom: 12px; border-bottom: var(--border-line); }
.detail-list div:last-child { border-bottom: 0; padding-bottom: 0; }
.detail-list dt { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .04em; }
.detail-list dd { margin: 0; min-width: 0; }
.next-action-card { border-left: 4px solid var(--blue); padding-left: 16px; }
.next-action-card h2 { margin: 0; color: var(--text-strong); font-size: 19px; }
.next-action-card p { margin: 8px 0 0; color: var(--muted); }
@@ -387,7 +413,7 @@
width: 100%;
margin-left: 0;
}
.summary-grid, .detail-list div { grid-template-columns: 1fr; }
.summary-grid { grid-template-columns: 1fr; }
.docs-workspace { grid-template-columns: 1fr; }
.docs-outline { display: none; }
.docs-content { grid-template-columns: 1fr; }
+28
View File
@@ -7,8 +7,10 @@ import ActionToolbar, { ToolbarGroup, ToolbarSpacer } from "../src/components/Ac
import ContentGrid, { FormGrid, FormLayout, GridItem } from "../src/components/ContentGrid";
import Dialog from "../src/components/Dialog";
import { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
import DescriptionList, { DescriptionItem } from "../src/components/DescriptionList";
import FormSection from "../src/components/FormSection";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import MetricGrid from "../src/components/MetricGrid";
const toolbarMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
@@ -39,6 +41,32 @@ assert(gridMarkup.includes("content-grid-columns-3"), "content grids expose expl
assert(gridMarkup.includes("content-grid-collapse-wide"), "content grids expose explicit collapse ownership");
assert(gridMarkup.includes("content-grid-item-full"), "grid items can span the complete layout");
const metricMarkup = renderToStaticMarkup(
<MetricGrid columns="auto" density="compact" spacing="inset" minimum="compact" collapseAt="standard">
<div>Open</div><div>Closed</div>
</MetricGrid>
);
assert(metricMarkup.includes("metric-group-columns-auto"), "metric groups own responsive column geometry");
assert(metricMarkup.includes("metric-group-spacing-inset"), "metric groups own their surrounding rhythm");
assert(metricMarkup.includes("metric-group-collapse-standard"), "metric groups expose explicit collapse ownership");
const descriptionMarkup = renderToStaticMarkup(
<DescriptionList columns={3} density="compact" collapseAt="wide">
<DescriptionItem term="Status">Ready</DescriptionItem>
</DescriptionList>
);
assert(descriptionMarkup.startsWith('<dl class="description-list-layout'), "description lists preserve native list semantics");
assert(descriptionMarkup.includes("description-list-columns-3"), "description lists own their column geometry");
assert(descriptionMarkup.includes("<dt>Status</dt><dd>Ready</dd>"), "description items preserve term and description anatomy");
const inlineDescriptionMarkup = renderToStaticMarkup(
<DescriptionList variant="inline" termWidth="compact"><DescriptionItem term="Owner">Ada</DescriptionItem></DescriptionList>
);
assert(inlineDescriptionMarkup.includes("description-list-columns-1"), "inline description lists default to one list column");
assert(inlineDescriptionMarkup.includes("description-list-term-compact"), "inline description lists centralize term sizing");
const formMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<FormSection title="Identity" description="Stable identifying values" actions={<button type="button">Reset</button>}>