feat: add shared workspace layouts

This commit is contained in:
2026-08-18 02:17:13 +02:00
parent 934db6d44b
commit dd7ad4d9c7
11 changed files with 207 additions and 11 deletions
+12 -4
View File
@@ -6,7 +6,7 @@ import LoadingFrame from "./LoadingFrame";
import PageScrollViewport from "./PageScrollViewport";
import PageTitle from "./PageTitle";
export type PageLayoutMode = "standalone" | "embedded";
export type PageLayoutMode = "standalone" | "workspace" | "embedded";
export type PageHeaderProps = {
title: ReactNode;
@@ -38,7 +38,9 @@ export function PageHeader({
<header className={classes}>
<div className="page-layout-heading-copy">
<PageTitle loading={loading}>{title}</PageTitle>
{description && <p>{translateReactNode(description, translateText)}</p>}
{description && (typeof description === "string"
? <p className="page-layout-description">{translateText(description)}</p>
: <div className="page-layout-description">{translateReactNode(description, translateText)}</div>)}
</div>
{actions && <div className="button-row compact-actions page-layout-actions">{actions}</div>}
</header>
@@ -51,9 +53,11 @@ export type PageLayoutProps = PlatformInterfaceIdentityProps & {
actions?: ReactNode;
children: ReactNode;
loading?: boolean;
headerLoading?: boolean;
loadingLabel?: string;
error?: string;
success?: string;
notices?: ReactNode;
mode?: PageLayoutMode;
scrollable?: boolean;
stickyHeader?: boolean;
@@ -70,9 +74,11 @@ export default function PageLayout({
actions,
children,
loading = false,
headerLoading,
loadingLabel = "i18n:govoplan-core.loading_page_data.85fe9edf",
error = "",
success = "",
notices,
mode = "standalone",
scrollable,
stickyHeader = true,
@@ -87,10 +93,11 @@ export default function PageLayout({
helpTopicId
}: PageLayoutProps) {
const shouldScroll = scrollable ?? mode === "standalone";
const shouldInset = mode !== "embedded";
const layoutClasses = [
"page-layout",
`page-layout-${mode}`,
mode === "standalone" ? "content-pad workspace-data-page" : "",
shouldInset ? "content-pad workspace-data-page" : "",
className
].filter(Boolean).join(" ");
@@ -109,12 +116,13 @@ export default function PageLayout({
title={title}
description={description}
actions={actions}
loading={loading}
loading={headerLoading ?? loading}
sticky={stickyHeader}
className={headerClassName}
/>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
{notices && <div className="page-layout-notices">{notices}</div>}
<LoadingFrame loading={loading} label={loadingLabel} className={["page-layout-body", bodyClassName].filter(Boolean).join(" ")}>
{children}
</LoadingFrame>
+84
View File
@@ -0,0 +1,84 @@
import type { ReactNode } from "react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../types";
export type WorkspaceLayoutVariant = "navigation" | "split";
export type WorkspacePrimarySize = "compact" | "default" | "wide";
export type WorkspaceLayoutProps = PlatformInterfaceIdentityProps & {
primary: ReactNode;
children: ReactNode;
variant?: WorkspaceLayoutVariant;
primarySize?: WorkspacePrimarySize;
primaryLabel?: string;
contentLabel?: string;
documentationType?: "user" | "admin";
primaryScrollable?: boolean;
contentScrollable?: boolean;
className?: string;
primaryClassName?: string;
contentClassName?: string;
};
export default function WorkspaceLayout({
primary,
children,
variant = "navigation",
primarySize = "default",
primaryLabel,
contentLabel,
documentationType = "user",
primaryScrollable,
contentScrollable = true,
className = "",
primaryClassName = "",
contentClassName = "",
interfaceId,
helpContextId,
helpModuleId,
helpTopicId
}: WorkspaceLayoutProps) {
const { translateText } = usePlatformLanguage();
const shouldScrollPrimary = primaryScrollable ?? variant === "split";
return (
<div
className={[
"workspace",
"workspace-layout",
`workspace-layout-${variant}`,
`workspace-layout-primary-${primarySize}`,
className
].filter(Boolean).join(" ")}
data-help-scope="workspace"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-module-id={helpModuleId}
data-help-topic-id={helpTopicId}
data-help-documentation-type={documentationType}
>
<div
className={[
"workspace-layout-primary",
shouldScrollPrimary ? "workspace-layout-pane-scrollable" : "workspace-layout-pane-contained",
primaryClassName
].filter(Boolean).join(" ")}
role={primaryLabel ? "region" : undefined}
aria-label={primaryLabel ? translateText(primaryLabel) : undefined}
>
{primary}
</div>
<section
className={[
"workspace-content",
"workspace-layout-content",
contentScrollable ? "workspace-layout-pane-scrollable" : "workspace-layout-pane-contained",
contentClassName
].filter(Boolean).join(" ")}
aria-label={contentLabel ? translateText(contentLabel) : undefined}
>
{children}
</section>
</div>
);
}
+2
View File
@@ -116,6 +116,8 @@ export { default as PageLayout, PageHeader } from "./components/PageLayout";
export type { PageHeaderProps, PageLayoutMode, PageLayoutProps } from "./components/PageLayout";
export { default as PageScrollViewport } from "./components/PageScrollViewport";
export type { PageScrollViewportProps } from "./components/PageScrollViewport";
export { default as WorkspaceLayout } from "./components/WorkspaceLayout";
export type { WorkspaceLayoutProps, WorkspaceLayoutVariant, WorkspacePrimarySize } from "./components/WorkspaceLayout";
export { default as PasswordField } from "./components/PasswordField";
export { default as PasswordGeneratorDialog } from "./components/PasswordGeneratorDialog";
export { default as PeoplePicker } from "./components/people/PeoplePicker";
+15
View File
@@ -82,6 +82,16 @@
.ghost-button { border: 0; background: transparent; color: var(--muted); font-weight: 700; }
.app-content { min-height: 0; overflow: hidden; }
.workspace { height: 100%; min-height: 0; display: grid; grid-template-columns: 198px minmax(0, 1fr); }
.workspace-layout-primary,
.workspace-layout-content { min-width: 0; min-height: 0; }
.workspace-layout-pane-scrollable { overflow: auto; }
.workspace-layout-pane-contained { overflow: hidden; }
.workspace-layout-primary > .section-sidebar { width: 100%; height: 100%; box-sizing: border-box; }
.workspace-layout-navigation.workspace-layout-primary-compact { grid-template-columns: 168px minmax(0, 1fr); }
.workspace-layout-navigation.workspace-layout-primary-wide { grid-template-columns: 240px minmax(0, 1fr); }
.workspace-layout-split { grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); }
.workspace-layout-split.workspace-layout-primary-compact { grid-template-columns: minmax(250px, 320px) minmax(0, 1fr); }
.workspace-layout-split.workspace-layout-primary-wide { grid-template-columns: minmax(340px, 440px) minmax(0, 1fr); }
.section-sidebar { background: var(--sidebar-bg); border-right: var(--border-line-dark); padding: 18px 0; border-left: 3px solid var(--sidebar-border); box-shadow: var(--shadow-chrome); z-index: 80; overflow-y: scroll; }
.section-title { font-size: 12px; font-weight: 800; color: var(--muted); padding: 0 22px 14px; letter-spacing: .06em; }
.section-title-lower { margin-top: 28px; }
@@ -142,6 +152,7 @@
overflow: visible;
}
.ui-no-sticky-section-sidebars .workspace-content { overflow: visible; }
.ui-no-sticky-section-sidebars .workspace-layout-primary { overflow: visible; }
.content-pad { min-width: 0; max-width: 100%; box-sizing: border-box; padding: 28px 34px; }
.page-heading { margin-bottom: 22px; }
.page-heading h1 { margin: 0; font-size: 26px; color: var(--text-strong); font-weight: 600; }
@@ -187,6 +198,8 @@
width: 100%;
max-width: 100%;
}
.page-layout-notices { display: grid; gap: 12px; min-width: 0; }
.page-layout-notices > .alert { margin-bottom: 0; }
.workspace-heading .mono-small { margin-top: 8px; }
.panel, .card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); box-shadow: var(--shadow); }
.panel { overflow: hidden; }
@@ -254,6 +267,8 @@
@media (max-width: 900px) {
.api-mini { display: none; }
.workspace { grid-template-columns: 1fr; }
.workspace-layout-navigation > .workspace-layout-primary { display: none; }
.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, .dashboard-grid, .settings-grid { grid-template-columns: 1fr; }