Add shared WebUI layout primitives

This commit is contained in:
2026-08-18 10:42:55 +02:00
parent dd7ad4d9c7
commit 6814a41ae4
22 changed files with 574 additions and 111 deletions
+88
View File
@@ -0,0 +1,88 @@
import type { HTMLAttributes, ReactNode } from "react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../types";
export type ActionToolbarDensity = "compact" | "default";
export type ActionToolbarSurface = "plain" | "subtle";
export type ActionToolbarWrap = "responsive" | "always" | "never";
export type ActionToolbarProps = PlatformInterfaceIdentityProps & Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
children: ReactNode;
label?: string;
density?: ActionToolbarDensity;
surface?: ActionToolbarSurface;
wrap?: ActionToolbarWrap;
};
export default function ActionToolbar({
children,
label,
density = "default",
surface = "plain",
wrap = "responsive",
className = "",
role,
"aria-label": ariaLabel,
interfaceId,
helpContextId,
helpModuleId,
helpTopicId,
...props
}: ActionToolbarProps) {
const { translateText } = usePlatformLanguage();
const translatedLabel = label || ariaLabel ? translateText(label ?? ariaLabel ?? "") : undefined;
return (
<div
{...props}
className={[
"action-toolbar",
`action-toolbar-density-${density}`,
`action-toolbar-surface-${surface}`,
`action-toolbar-wrap-${wrap}`,
className
].filter(Boolean).join(" ")}
role={role ?? (translatedLabel ? "toolbar" : undefined)}
aria-label={translatedLabel}
data-help-scope="toolbar"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-module-id={helpModuleId}
data-help-topic-id={helpTopicId}
>
{children}
</div>
);
}
export type ToolbarGroupProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
align?: "start" | "center" | "end";
grow?: boolean;
};
export function ToolbarGroup({
children,
align = "start",
grow = false,
className = "",
...props
}: ToolbarGroupProps) {
return (
<div
{...props}
className={[
"action-toolbar-group",
`action-toolbar-group-${align}`,
grow ? "action-toolbar-group-grow" : "",
className
].filter(Boolean).join(" ")}
>
{children}
</div>
);
}
export function ToolbarSpacer({ className = "", ...props }: HTMLAttributes<HTMLSpanElement>) {
return <span {...props} className={["action-toolbar-spacer", className].filter(Boolean).join(" ")} aria-hidden="true" />;
}
+109
View File
@@ -0,0 +1,109 @@
import type { FormHTMLAttributes, HTMLAttributes, ReactNode } from "react";
export type ContentGridColumns = 1 | 2 | 3 | 4;
export type ContentGridGap = "compact" | "small" | "default" | "loose";
export type ContentGridCollapseAt = "narrow" | "workspace" | "standard" | "wide" | "never";
export type ContentGridProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
columns?: ContentGridColumns;
gap?: ContentGridGap;
collapseAt?: ContentGridCollapseAt;
align?: "start" | "stretch";
};
export default function ContentGrid({
children,
columns = 2,
gap = "default",
collapseAt = "workspace",
align = "start",
className = "",
...props
}: ContentGridProps) {
return (
<div
{...props}
className={[
"content-grid-layout",
`content-grid-columns-${columns}`,
`content-grid-gap-${gap}`,
`content-grid-collapse-${collapseAt}`,
`content-grid-align-${align}`,
className
].filter(Boolean).join(" ")}
>
{children}
</div>
);
}
export type FormGridProps = Omit<ContentGridProps, "align">;
export function FormGrid({
children,
columns = 1,
gap = "default",
collapseAt = "standard",
className = "",
...props
}: FormGridProps) {
return (
<ContentGrid
{...props}
columns={columns}
gap={gap}
collapseAt={collapseAt}
align="start"
className={["form-grid-layout", className].filter(Boolean).join(" ")}
>
{children}
</ContentGrid>
);
}
export type FormLayoutProps = FormHTMLAttributes<HTMLFormElement> & {
children: ReactNode;
columns?: ContentGridColumns;
gap?: ContentGridGap;
collapseAt?: ContentGridCollapseAt;
};
export function FormLayout({
children,
columns = 1,
gap = "default",
collapseAt = "standard",
className = "",
...props
}: FormLayoutProps) {
return (
<form
{...props}
className={[
"content-grid-layout",
"form-grid-layout",
`content-grid-columns-${columns}`,
`content-grid-gap-${gap}`,
`content-grid-collapse-${collapseAt}`,
"content-grid-align-start",
className
].filter(Boolean).join(" ")}
>
{children}
</form>
);
}
export type GridItemProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
span?: "one" | "two" | "full";
};
export function GridItem({ children, span = "one", className = "", ...props }: GridItemProps) {
return (
<div {...props} className={["content-grid-item", `content-grid-item-${span}`, className].filter(Boolean).join(" ")}>
{children}
</div>
);
}
@@ -1,3 +1,4 @@
import { FormGrid } from "./ContentGrid";
import { useEffect, useMemo, useState } from "react";
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import type {
@@ -504,14 +505,14 @@ export default function CredentialEnvelopeManager({
</LoadingFrame>
</Card>
<Dialog
<Dialog variant="administration" size="wide"
open={Boolean(editing)}
title={editing === "new" ? "Add reusable credential" : "Edit reusable credential"}
helpContextId="access.credentials.editor"
helpModuleId="access"
onClose={closeEditor}
closeDisabled={saving}
className="admin-dialog admin-dialog-wide adaptive-config-dialog"
className="adaptive-config-dialog"
footerClassName="button-row compact-actions"
footer={
<>
@@ -528,7 +529,7 @@ export default function CredentialEnvelopeManager({
<h3>Identity</h3>
<p>The name and type are visible; secret values are never returned by the API.</p>
</header>
<div className="form-grid two">
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Name" helpContextId="access.credentials.field.name" helpModuleId="access">
<input data-help-context-id="access.credentials.field.name" data-help-module-id="access" value={draft.name} disabled={saving} onChange={(event) => setDraft({ ...draft, name: event.target.value })} autoFocus />
</FormField>
@@ -563,14 +564,14 @@ export default function CredentialEnvelopeManager({
label="Remove configured secret"
/>
)}
</div>
</FormGrid>
</section>
<section className="adaptive-config-section">
<header>
<h3>Availability</h3>
<p>Empty module or server lists mean every module or server allowed by scope.</p>
</header>
<div className="form-grid two">
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Modules" help="Choose installed modules that may use this credential. Empty means every module allowed by scope." helpContextId="access.credentials.field.allowed-modules" helpModuleId="access">
<ReferenceMultiSelect
values={splitValues(draft.allowedModules)}
@@ -594,7 +595,7 @@ export default function CredentialEnvelopeManager({
</FormField>
<ToggleSwitch checked={draft.inheritToLowerScopes} disabled={saving} helpContextId="access.credentials.field.inherit-to-lower-scopes" helpModuleId="access" onChange={(inheritToLowerScopes) => setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" />
<ToggleSwitch checked={draft.isActive} disabled={saving} helpContextId="access.credentials.field.active" helpModuleId="access" onChange={(isActive) => setDraft({ ...draft, isActive })} label="Active" />
</div>
</FormGrid>
</section>
</div>
</Dialog>
+26 -3
View File
@@ -9,12 +9,23 @@ import {
type DialogStackId
} from "./dialogStack";
import type { PlatformInterfaceIdentityProps } from "../types";
import { DialogActions, type DialogActionAlignment } from "./DialogAnatomy";
export type DialogSize = "small" | "default" | "large" | "wide" | "full";
export type DialogVariant = "default" | "administration";
export type DialogBodyPadding = "none" | "compact" | "default";
export type DialogProps = PlatformInterfaceIdentityProps & {
open: boolean;
title: ReactNode;
children: ReactNode;
footer?: ReactNode;
description?: ReactNode;
notices?: ReactNode;
size?: DialogSize;
variant?: DialogVariant;
bodyPadding?: DialogBodyPadding;
footerAlignment?: DialogActionAlignment;
role?: "dialog" | "alertdialog";
ariaDescribedBy?: string;
closeLabel?: string;
@@ -42,6 +53,12 @@ export default function Dialog({
title,
children,
footer,
description,
notices,
size = "default",
variant = "default",
bodyPadding = "default",
footerAlignment = "end",
role = "dialog",
ariaDescribedBy,
closeLabel = "i18n:govoplan-core.close.bbfa773e",
@@ -83,6 +100,8 @@ export default function Dialog({
const renderedTitle = translateReactNode(title, translateText);
const renderedChildren = translateReactNode(children, translateText);
const renderedFooter = translateReactNode(footer, translateText);
const renderedDescription = translateReactNode(description, translateText);
const renderedNotices = translateReactNode(notices, translateText);
const translatedCloseLabel = translateText(closeLabel);
useEffect(() => {
@@ -134,7 +153,7 @@ export default function Dialog({
<section
ref={panelRef}
tabIndex={-1}
className={joinClasses("dialog-panel", className)}
className={joinClasses("dialog-panel", `dialog-panel-size-${size}`, `dialog-panel-${variant}`, className)}
style={panelStyle}
role={role}
aria-modal="true"
@@ -162,8 +181,12 @@ export default function Dialog({
</button>
)}
</div>
<div className={joinClasses("dialog-body", bodyClassName)}>{renderedChildren}</div>
{footer && <div className={joinClasses("dialog-footer", footerClassName)}>{renderedFooter}</div>}
<div className={joinClasses("dialog-body", `dialog-body-padding-${bodyPadding}`, bodyClassName)}>
{description && <div className="dialog-description">{renderedDescription}</div>}
{notices && <div className="dialog-notices">{renderedNotices}</div>}
{renderedChildren}
</div>
{footer && <div className={joinClasses("dialog-footer", footerClassName)}><DialogActions align={footerAlignment}>{renderedFooter}</DialogActions></div>}
</section>
</div>
);
+42
View File
@@ -0,0 +1,42 @@
import type { FormHTMLAttributes, HTMLAttributes, ReactNode } from "react";
export type DialogActionAlignment = "start" | "between" | "end";
export type DialogActionsProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
align?: DialogActionAlignment;
};
export function DialogActions({ children, align = "end", className = "", ...props }: DialogActionsProps) {
return (
<div {...props} className={["dialog-actions", `dialog-actions-${align}`, className].filter(Boolean).join(" ")}>
{children}
</div>
);
}
export type DialogFormProps = FormHTMLAttributes<HTMLFormElement> & {
children: ReactNode;
spacing?: "compact" | "default" | "loose";
};
export function DialogForm({ children, spacing = "default", className = "", ...props }: DialogFormProps) {
return (
<form {...props} className={["dialog-form-layout", `dialog-form-spacing-${spacing}`, className].filter(Boolean).join(" ")}>
{children}
</form>
);
}
export type DialogSectionProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
variant?: "plain" | "separated" | "inset";
};
export function DialogSection({ children, variant = "plain", className = "", ...props }: DialogSectionProps) {
return (
<div {...props} className={["dialog-section-layout", `dialog-section-${variant}`, className].filter(Boolean).join(" ")}>
{children}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { HTMLAttributes, ReactNode } from "react";
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
export type FormSectionVariant = "plain" | "separated" | "panel";
export type FormSectionProps = HTMLAttributes<HTMLElement> & {
children: ReactNode;
title?: ReactNode;
description?: ReactNode;
actions?: ReactNode;
variant?: FormSectionVariant;
contentClassName?: string;
};
export default function FormSection({
children,
title,
description,
actions,
variant = "plain",
className = "",
contentClassName = "",
...props
}: FormSectionProps) {
const { translateText } = usePlatformLanguage();
return (
<section {...props} className={["form-section-layout", `form-section-${variant}`, className].filter(Boolean).join(" ")}>
{(title || description || actions) && (
<header className="form-section-header">
<div className="form-section-heading-copy">
{title && <h3>{translateReactNode(title, translateText)}</h3>}
{description && <div className="form-section-description">{translateReactNode(description, translateText)}</div>}
</div>
{actions && <div className="form-section-actions">{translateReactNode(actions, translateText)}</div>}
</header>
)}
<div className={["form-section-content", contentClassName].filter(Boolean).join(" ")}>{children}</div>
</section>
);
}
@@ -1,3 +1,4 @@
import { FormGrid } from "./ContentGrid";
import type { ResourceAccessExplanationResponse } from "../api/resourceAccessContracts";
export type ResourceAccessExplanationProps = {
@@ -23,12 +24,12 @@ export default function ResourceAccessExplanation({
return (
<>
<div className="form-grid compact responsive-form-grid">
<FormGrid columns={2} collapseAt="wide" className="">
<div><span className="form-label">i18n:govoplan-core.user.9f8a2389</span><p>{userLabel}</p></div>
<div><span className="form-label">i18n:govoplan-core.resource.d1c626a9</span><p>{resourceLabel}</p></div>
<div><span className="form-label">i18n:govoplan-core.action.97c89a4d</span><p><code>{explanation.action}</code></p></div>
<div><span className="form-label">i18n:govoplan-core.evidence.8487d192</span><p>{explanation.provenance.length}</p></div>
</div>
</FormGrid>
{explanation.provenance.length === 0 ? (
<p className="muted small-note">i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e</p>
) : (
+16 -14
View File
@@ -1,3 +1,4 @@
import { FormGrid } from "./ContentGrid";
import {
mergeAttributes,
Node,
@@ -36,6 +37,7 @@ import {
type MouseEvent
} from "react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import ActionToolbar, { ToolbarGroup } from "./ActionToolbar";
import Button from "./Button";
import Dialog from "./Dialog";
import FormField from "./FormField";
@@ -478,8 +480,8 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
<div className={rootClassName} style={editorStyle} role="group" aria-label={translatedAriaLabel}>
<div className="wysiwyg-editor-header">
{mode === "visual" && (
<div className="wysiwyg-editor-toolbar" role="toolbar" aria-label={translatedLabels.editor}>
<div className="wysiwyg-toolbar-group">
<ActionToolbar className="wysiwyg-editor-toolbar" label={translatedLabels.editor}>
<ToolbarGroup className="wysiwyg-toolbar-group">
<IconButton
variant="ghost"
className="wysiwyg-toolbar-button"
@@ -498,7 +500,7 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
onMouseDown={preserveSelection}
onClick={() => editor.chain().focus().redo().run()}
/>
</div>
</ToolbarGroup>
<label className="wysiwyg-block-style">
<span className="sr-only">{translatedLabels.blockStyle}</span>
<select
@@ -513,25 +515,25 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
<option value="heading-3">{translatedLabels.heading3}</option>
</select>
</label>
<div className="wysiwyg-toolbar-group">
<ToolbarGroup className="wysiwyg-toolbar-group">
<ToolbarButton active={toolbarState.bold} disabled={disabled} label={translatedLabels.bold} icon={<Bold />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleBold().run()} />
<ToolbarButton active={toolbarState.italic} disabled={disabled} label={translatedLabels.italic} icon={<Italic />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleItalic().run()} />
<ToolbarButton active={toolbarState.underline} disabled={disabled} label={translatedLabels.underline} icon={<Underline />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleUnderline().run()} />
<ToolbarButton active={toolbarState.strike} disabled={disabled} label={translatedLabels.strike} icon={<Strikethrough />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleStrike().run()} />
<ToolbarButton active={toolbarState.code} disabled={disabled} label={translatedLabels.inlineCodeLabel} icon={<Code2 />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleCode().run()} />
</div>
<div className="wysiwyg-toolbar-group">
</ToolbarGroup>
<ToolbarGroup className="wysiwyg-toolbar-group">
<ToolbarButton active={toolbarState.bulletList} disabled={disabled} label={translatedLabels.bulletList} icon={<List />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleBulletList().run()} />
<ToolbarButton active={toolbarState.numberedList} disabled={disabled} label={translatedLabels.numberedList} icon={<ListOrdered />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleOrderedList().run()} />
<ToolbarButton active={toolbarState.quote} disabled={disabled} label={translatedLabels.quote} icon={<Quote />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleBlockquote().run()} />
</div>
<div className="wysiwyg-toolbar-group">
</ToolbarGroup>
<ToolbarGroup className="wysiwyg-toolbar-group">
<IconButton variant="ghost" className="wysiwyg-toolbar-button" label={translatedLabels.insertLink} icon={<Link2 />} disabled={disabled} onMouseDown={preserveSelection} onClick={openLinkDialog} />
<IconButton variant="ghost" className="wysiwyg-toolbar-button" label={translatedLabels.removeLink} icon={<Link2Off />} disabled={disabled || !toolbarState.link} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().unsetLink().run()} />
<IconButton variant="ghost" className="wysiwyg-toolbar-button" label={translatedLabels.insertImage} icon={<ImagePlus />} disabled={disabled} onMouseDown={preserveSelection} onClick={openImageDialog} />
<IconButton variant="ghost" className="wysiwyg-toolbar-button" label={translatedLabels.clearFormatting} icon={<RemoveFormatting />} disabled={disabled} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()} />
</div>
</div>
</ToolbarGroup>
</ActionToolbar>
)}
{allowSourceMode && (
<SegmentedControl
@@ -579,7 +581,7 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
</>
)}
>
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
{linkSelectionRef.current.from === linkSelectionRef.current.to && (
<FormField label={translatedLabels.linkText}>
<input value={linkText} onChange={(event) => setLinkText(event.target.value)} />
@@ -589,7 +591,7 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
<input value={linkUrl} inputMode="url" onChange={(event) => setLinkUrl(event.target.value)} />
</FormField>
{linkError && <p className="wysiwyg-editor-dialog-error" role="alert">{linkError}</p>}
</div>
</FormGrid>
</Dialog>
<Dialog
@@ -604,7 +606,7 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
</>
)}
>
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
<FormField label={translatedLabels.imageUrlLabel}>
<input value={imageUrl} inputMode="url" onChange={(event) => setImageUrl(event.target.value)} />
</FormField>
@@ -612,7 +614,7 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
<input value={imageAlt} onChange={(event) => setImageAlt(event.target.value)} />
</FormField>
{imageError && <p className="wysiwyg-editor-dialog-error" role="alert">{imageError}</p>}
</div>
</FormGrid>
</Dialog>
</div>
);
@@ -1,3 +1,4 @@
import { FormGrid } from "../ContentGrid";
import { useEffect, useRef, useState, type ReactNode } from "react";
import Button from "../Button";
import { CredentialFields } from "../CredentialPanel";
@@ -307,7 +308,7 @@ export default function MailServerSettingsPanel({
<div className="mail-server-settings-view">
{activeSection === "smtp" &&
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.smtp_settings.f103e570">
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
<FormGrid columns={2} collapseAt="wide" className="mail-server-form-grid">
{showServerFields &&
<>
<FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
@@ -324,7 +325,7 @@ export default function MailServerSettingsPanel({
savedPassword={smtpPasswordSaved}
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder} />
}
</div>
</FormGrid>
{onTestSmtp &&
<div className="button-row compact-actions mail-server-actions">
<Button type="button" variant="primary" onClick={onTestSmtp} disabled={smtpActionsDisabled || busyAction === "smtp"} disabledReason={busyAction === "smtp" ? "SMTP connection testing is already running." : smtpActionsDisabled ? smtpActionDisabledReason : undefined}>{busyAction === "smtp" ? "i18n:govoplan-core.testing.15ccc832" : smtpTestLabel}</Button>
@@ -336,7 +337,7 @@ export default function MailServerSettingsPanel({
{activeSection === "imap" &&
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.imap_settings.ab8d8247">
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
<FormGrid columns={2} collapseAt="wide" className="mail-server-form-grid">
{showServerFields &&
<>
<FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
@@ -353,7 +354,7 @@ export default function MailServerSettingsPanel({
savedPassword={imapPasswordSaved}
savedPasswordPlaceholder={imapSavedPasswordPlaceholder} />
}
</div>
</FormGrid>
{onTestImap &&
<div className="button-row compact-actions mail-server-actions">
<Button type="button" variant="primary" onClick={onTestImap} disabled={imapActionsDisabled || busyAction === "imap"} disabledReason={busyAction === "imap" ? "IMAP connection testing is already running." : imapActionsDisabled ? imapActionDisabledReason : undefined}>{busyAction === "imap" ? "i18n:govoplan-core.testing.15ccc832" : imapTestLabel}</Button>