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
+3 -2
View File
@@ -55,8 +55,9 @@ than adding custom `F1` listeners:
- `TableActionGroup` action definitions carry the same identities so focused - `TableActionGroup` action definitions carry the same identities so focused
row actions can resolve consequence-specific help. row actions can resolve consequence-specific help.
- `PageLayout` owns the page help scope and documentation identity for ordinary - `PageLayout` owns the page help scope and documentation identity for ordinary
headed pages; specialized full-canvas workspaces reuse `PageHeader` while headed pages. `WorkspaceLayout` owns the full-canvas workspace scope and its
keeping their route-level help identity on the owning workspace. labelled primary/content panes; pages inside it use `PageLayout` in
`workspace` mode and retain their own route-level help identity.
Module routes, public routes, settings sections, and administration sections Module routes, public routes, settings sections, and administration sections
may also declare `helpContextId` and `helpTopicId`. Each module must keep a may also declare `helpContextId` and `helpTopicId`. Each module must keep a
+7 -5
View File
@@ -13,7 +13,8 @@ domain modules own their compositions.
| Reusable credentials | Repeated administration with an adaptive create/edit dialog, optional password generator, and destructive confirmation | Secret values are write-only; generated candidates use the browser cryptographic API without a weak fallback and do not replace the field until explicitly confirmed; scope/permission blockers name the required action, responsible actor, and destination; unavailable row actions remain keyboard-explainable | `CredentialEnvelopeManager.tsx`, shared `PasswordField`, `PasswordGeneratorDialog`, `ActionBlockerHint`, `Button`, `TableActionGroup`, and `ConfirmDialog` | | Reusable credentials | Repeated administration with an adaptive create/edit dialog, optional password generator, and destructive confirmation | Secret values are write-only; generated candidates use the browser cryptographic API without a weak fallback and do not replace the field until explicitly confirmed; scope/permission blockers name the required action, responsible actor, and destination; unavailable row actions remain keyboard-explainable | `CredentialEnvelopeManager.tsx`, shared `PasswordField`, `PasswordGeneratorDialog`, `ActionBlockerHint`, `Button`, `TableActionGroup`, and `ConfirmDialog` |
| Retention policy | Effective-policy editor with inherited source paths and typed, narrowing-only controls | Parent locks and missing write authority are explicit; the save action distinguishes locks, missing target, loading, clean draft, and active save | `RetentionPolicyManagement.tsx`, policy logic tests, `test-core-interface-patterns.mjs` | | Retention policy | Effective-policy editor with inherited source paths and typed, narrowing-only controls | Parent locks and missing write authority are explicit; the save action distinguishes locks, missing target, loading, clean draft, and active save | `RetentionPolicyManagement.tsx`, policy logic tests, `test-core-interface-patterns.mjs` |
| Module lifecycle | Guided operator projection over durable installer-queue evidence | Preflight, handoff, progress, stale evidence, recovery, and rollback consequences remain visible | Admin module lifecycle tests and the Core installer-queue contract | | Module lifecycle | Guided operator projection over durable installer-queue evidence | Preflight, handoff, progress, stale evidence, recovery, and rollback consequences remain visible | Admin module lifecycle tests and the Core installer-queue contract |
| Shared page frame | Domain-neutral headed page layout used by Core and optional modules | Scroll ownership, sticky heading, route actions, page notices, loading, narrow-layout collapse, and contextual-help identity are centralized; `AdminPageLayout` composes the same contract | `PageLayout.tsx`, `page-layout.test.tsx`, Core fallback dashboard, Dashboard module, Ops module, and `check-shared-webui-layouts.py` | | Shared page frame | Domain-neutral headed page layout used by Core and optional modules | Standalone, workspace, and embedded modes make inset and scroll ownership explicit; sticky heading, rich descriptions, route actions, notices, loading, narrow-layout collapse, and contextual-help identity are centralized; `AdminPageLayout` composes the same contract | `PageLayout.tsx`, `page-layout.test.tsx`, Core fallback dashboard, Dashboard module, Ops module, Campaign pages, and `check-shared-webui-layouts.py` |
| Full-canvas workspace | Navigation/content and list/detail canvases that own pane geometry and scrolling | Navigation and split variants, primary-pane width, pane-owned or contained scrolling, responsive stacking or navigation collapse, pane labels, and contextual-help identity are centralized without encoding domain navigation | `WorkspaceLayout.tsx`, `workspace-layout.test.tsx`, Campaign workspace, Campaign module workspace, Templates workspace, Approvals list/detail workspace, and `check-shared-webui-layouts.py` |
| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, route/page/field/action F1 help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests, `CONTEXTUAL_HELP_CONTRACT.md`, and module-permutation build | | Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, route/page/field/action F1 help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests, `CONTEXTUAL_HELP_CONTRACT.md`, and module-permutation build |
## Boundary ## Boundary
@@ -28,7 +29,8 @@ Raw JSON remains permitted only for diagnostics, expert inspection,
interchange, or conflict evidence. It is not a primary Core configuration interchange, or conflict evidence. It is not a primary Core configuration
editor. editor.
Raw page-frame debt is registered in the meta repository and may only decrease. Raw page-frame and full-canvas workspace debt is registered in the meta
New headed pages use `PageLayout`; full-canvas explorers may use `PageHeader` repository and may only decrease. New headed pages use `PageLayout`; pages
while the next shared workspace/split-pane contract is developed. Module CSS inside `WorkspaceLayout` use its `workspace` mode so the pane owns scrolling
continues to own domain content layout, never the shared page frame. while the page retains the standard inset. Module CSS continues to own domain
content layout, never the shared page or workspace frame.
+1
View File
@@ -44,6 +44,7 @@
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js", "test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
"test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js", "test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js",
"test:page-layout": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/page-layout.test.js", "test:page-layout": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/page-layout.test.js",
"test:workspace-layout": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/workspace-layout.test.js",
"test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js", "test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js",
"test:password-field": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/password-generator.test.js", "test:password-field": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/password-generator.test.js",
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js", "test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
@@ -17,6 +17,7 @@ const temporalDataMenu = read("src/layout/TemporalDataMenu.tsx");
const helpMenu = read("src/layout/HelpMenu.tsx"); const helpMenu = read("src/layout/HelpMenu.tsx");
const helpContext = read("src/utils/helpContext.ts"); const helpContext = read("src/utils/helpContext.ts");
const pageLayout = read("src/components/PageLayout.tsx"); const pageLayout = read("src/components/PageLayout.tsx");
const workspaceLayout = read("src/components/WorkspaceLayout.tsx");
const adminPageLayout = read("src/components/admin/AdminPageLayout.tsx"); const adminPageLayout = read("src/components/admin/AdminPageLayout.tsx");
const layoutStyles = read("src/styles/layout.css"); const layoutStyles = read("src/styles/layout.css");
const authGateStyles = read("src/styles/auth-gate.css"); const authGateStyles = read("src/styles/auth-gate.css");
@@ -49,6 +50,8 @@ assert.match(pageLayout, /`page-layout-\$\{mode\}`/, "shared standalone and embe
assert.match(pageLayout, /<PageHeader/, "shared pages use one central responsive heading"); assert.match(pageLayout, /<PageHeader/, "shared pages use one central responsive heading");
assert.match(pageLayout, /data-help-scope="page"/, "shared pages preserve contextual-help identity"); assert.match(pageLayout, /data-help-scope="page"/, "shared pages preserve contextual-help identity");
assert.match(adminPageLayout, /<PageLayout/, "administration composes the central page layout instead of redefining it"); assert.match(adminPageLayout, /<PageLayout/, "administration composes the central page layout instead of redefining it");
assert.match(workspaceLayout, /workspace-layout-\$\{variant\}/, "shared workspaces expose central navigation and split-pane variants");
assert.match(workspaceLayout, /data-help-scope="workspace"/, "shared workspaces preserve contextual-help identity");
assert.match(iconRail, /<div className="icon-rail-scroll">\s*<nav className="icon-nav">/, "the module navigation has a dedicated scroll viewport"); assert.match(iconRail, /<div className="icon-rail-scroll">\s*<nav className="icon-nav">/, "the module navigation has a dedicated scroll viewport");
assert.match(layoutStyles, /\.icon-rail-scroll \{[^}]*min-height: 0;[^}]*flex: 1 1 auto;[^}]*overflow-y: auto;/, "only the middle rail region scrolls"); assert.match(layoutStyles, /\.icon-rail-scroll \{[^}]*min-height: 0;[^}]*flex: 1 1 auto;[^}]*overflow-y: auto;/, "only the middle rail region scrolls");
+12 -4
View File
@@ -6,7 +6,7 @@ import LoadingFrame from "./LoadingFrame";
import PageScrollViewport from "./PageScrollViewport"; import PageScrollViewport from "./PageScrollViewport";
import PageTitle from "./PageTitle"; import PageTitle from "./PageTitle";
export type PageLayoutMode = "standalone" | "embedded"; export type PageLayoutMode = "standalone" | "workspace" | "embedded";
export type PageHeaderProps = { export type PageHeaderProps = {
title: ReactNode; title: ReactNode;
@@ -38,7 +38,9 @@ export function PageHeader({
<header className={classes}> <header className={classes}>
<div className="page-layout-heading-copy"> <div className="page-layout-heading-copy">
<PageTitle loading={loading}>{title}</PageTitle> <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> </div>
{actions && <div className="button-row compact-actions page-layout-actions">{actions}</div>} {actions && <div className="button-row compact-actions page-layout-actions">{actions}</div>}
</header> </header>
@@ -51,9 +53,11 @@ export type PageLayoutProps = PlatformInterfaceIdentityProps & {
actions?: ReactNode; actions?: ReactNode;
children: ReactNode; children: ReactNode;
loading?: boolean; loading?: boolean;
headerLoading?: boolean;
loadingLabel?: string; loadingLabel?: string;
error?: string; error?: string;
success?: string; success?: string;
notices?: ReactNode;
mode?: PageLayoutMode; mode?: PageLayoutMode;
scrollable?: boolean; scrollable?: boolean;
stickyHeader?: boolean; stickyHeader?: boolean;
@@ -70,9 +74,11 @@ export default function PageLayout({
actions, actions,
children, children,
loading = false, loading = false,
headerLoading,
loadingLabel = "i18n:govoplan-core.loading_page_data.85fe9edf", loadingLabel = "i18n:govoplan-core.loading_page_data.85fe9edf",
error = "", error = "",
success = "", success = "",
notices,
mode = "standalone", mode = "standalone",
scrollable, scrollable,
stickyHeader = true, stickyHeader = true,
@@ -87,10 +93,11 @@ export default function PageLayout({
helpTopicId helpTopicId
}: PageLayoutProps) { }: PageLayoutProps) {
const shouldScroll = scrollable ?? mode === "standalone"; const shouldScroll = scrollable ?? mode === "standalone";
const shouldInset = mode !== "embedded";
const layoutClasses = [ const layoutClasses = [
"page-layout", "page-layout",
`page-layout-${mode}`, `page-layout-${mode}`,
mode === "standalone" ? "content-pad workspace-data-page" : "", shouldInset ? "content-pad workspace-data-page" : "",
className className
].filter(Boolean).join(" "); ].filter(Boolean).join(" ");
@@ -109,12 +116,13 @@ export default function PageLayout({
title={title} title={title}
description={description} description={description}
actions={actions} actions={actions}
loading={loading} loading={headerLoading ?? loading}
sticky={stickyHeader} sticky={stickyHeader}
className={headerClassName} className={headerClassName}
/> />
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</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(" ")}> <LoadingFrame loading={loading} label={loadingLabel} className={["page-layout-body", bodyClassName].filter(Boolean).join(" ")}>
{children} {children}
</LoadingFrame> </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 type { PageHeaderProps, PageLayoutMode, PageLayoutProps } from "./components/PageLayout";
export { default as PageScrollViewport } from "./components/PageScrollViewport"; export { default as PageScrollViewport } from "./components/PageScrollViewport";
export type { PageScrollViewportProps } 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 PasswordField } from "./components/PasswordField";
export { default as PasswordGeneratorDialog } from "./components/PasswordGeneratorDialog"; export { default as PasswordGeneratorDialog } from "./components/PasswordGeneratorDialog";
export { default as PeoplePicker } from "./components/people/PeoplePicker"; 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; } .ghost-button { border: 0; background: transparent; color: var(--muted); font-weight: 700; }
.app-content { min-height: 0; overflow: hidden; } .app-content { min-height: 0; overflow: hidden; }
.workspace { height: 100%; min-height: 0; display: grid; grid-template-columns: 198px minmax(0, 1fr); } .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-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 { font-size: 12px; font-weight: 800; color: var(--muted); padding: 0 22px 14px; letter-spacing: .06em; }
.section-title-lower { margin-top: 28px; } .section-title-lower { margin-top: 28px; }
@@ -142,6 +152,7 @@
overflow: visible; overflow: visible;
} }
.ui-no-sticky-section-sidebars .workspace-content { 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; } .content-pad { min-width: 0; max-width: 100%; box-sizing: border-box; padding: 28px 34px; }
.page-heading { margin-bottom: 22px; } .page-heading { margin-bottom: 22px; }
.page-heading h1 { margin: 0; font-size: 26px; color: var(--text-strong); font-weight: 600; } .page-heading h1 { margin: 0; font-size: 26px; color: var(--text-strong); font-weight: 600; }
@@ -187,6 +198,8 @@
width: 100%; width: 100%;
max-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; } .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, .card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); box-shadow: var(--shadow); }
.panel { overflow: hidden; } .panel { overflow: hidden; }
@@ -254,6 +267,8 @@
@media (max-width: 900px) { @media (max-width: 900px) {
.api-mini { display: none; } .api-mini { display: none; }
.workspace { grid-template-columns: 1fr; } .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; } .section-sidebar { display: none; }
.wizard-card { grid-template-columns: 1fr; } .wizard-card { grid-template-columns: 1fr; }
.metric-grid, .dashboard-grid, .settings-grid { grid-template-columns: 1fr; } .metric-grid, .dashboard-grid, .settings-grid { grid-template-columns: 1fr; }
+30
View File
@@ -46,3 +46,33 @@ assert(!embeddedMarkup.includes("page-scroll-viewport"), "embedded pages defer s
assert(!embeddedMarkup.includes("content-pad"), "embedded pages do not add a second content inset"); assert(!embeddedMarkup.includes("content-pad"), "embedded pages do not add a second content inset");
assert(embeddedMarkup.includes("page-layout-embedded"), "embedded mode remains explicit"); assert(embeddedMarkup.includes("page-layout-embedded"), "embedded mode remains explicit");
assert(embeddedMarkup.includes('data-help-documentation-type="admin"'), "embedded administration keeps its documentation type"); assert(embeddedMarkup.includes('data-help-documentation-type="admin"'), "embedded administration keeps its documentation type");
const workspaceMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<PageLayout
title="Workspace page"
description={<p className="version-line">Version 3</p>}
mode="workspace"
notices={<div role="status">Additional notice</div>}
>
Workspace content
</PageLayout>
</PlatformLanguageProvider>
);
assert(!workspaceMarkup.includes("page-scroll-viewport"), "workspace pages defer scrolling to the workspace content pane");
assert(workspaceMarkup.includes("page-layout-workspace content-pad workspace-data-page"), "workspace pages keep the standard content inset");
assert(workspaceMarkup.includes('class="version-line"'), "rich page descriptions keep their own semantic element");
assert(!workspaceMarkup.includes("<p><p"), "rich page descriptions are not nested in an invalid paragraph");
assert(workspaceMarkup.includes("page-layout-notices"), "additional page notices use the central notice region");
const headerLoadingMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<PageLayout title="Refreshing" mode="workspace" headerLoading>
Stable content
</PageLayout>
</PlatformLanguageProvider>
);
assert(headerLoadingMarkup.includes("loading-indicator"), "page headings can expose partial refresh progress");
assert(!headerLoadingMarkup.includes('aria-busy="true"'), "partial refresh does not block the stable page body");
+48
View File
@@ -0,0 +1,48 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
import { renderToStaticMarkup } from "react-dom/server";
import WorkspaceLayout from "../src/components/WorkspaceLayout";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
const navigationMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<WorkspaceLayout
primary={<nav>Sections</nav>}
primaryLabel="Campaign sections"
contentLabel="Campaign content"
interfaceId="campaign.workspace"
helpModuleId="campaign"
>
<main>Selected section</main>
</WorkspaceLayout>
</PlatformLanguageProvider>
);
assert(navigationMarkup.includes("workspace-layout-navigation"), "navigation workspaces use the central navigation geometry");
assert(navigationMarkup.includes("workspace-layout-primary-default"), "the default primary-pane width is explicit");
assert(navigationMarkup.includes("workspace-layout-pane-contained"), "navigation panes defer scrolling to their navigation component");
assert(navigationMarkup.includes('aria-label="Campaign sections"'), "the primary pane can be named");
assert(navigationMarkup.includes('aria-label="Campaign content"'), "the content pane can be named");
assert(navigationMarkup.includes('data-help-scope="workspace"'), "workspace contextual-help scope is centralized");
assert(navigationMarkup.includes('data-interface-id="campaign.workspace"'), "workspace identity reaches the root");
assert(navigationMarkup.includes('data-help-documentation-type="user"'), "workspace documentation type is explicit");
const splitMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<WorkspaceLayout
variant="split"
primarySize="wide"
contentScrollable={false}
primary={<div>Collection</div>}
>
Detail
</WorkspaceLayout>
</PlatformLanguageProvider>
);
assert(splitMarkup.includes("workspace-layout-split"), "list-detail workspaces opt into split-pane geometry");
assert(splitMarkup.includes("workspace-layout-primary-wide"), "split workspaces use central width variants");
assert(splitMarkup.includes("workspace-layout-pane-scrollable"), "split primary panes can own scrolling");
assert(splitMarkup.includes("workspace-layout-content workspace-layout-pane-contained"), "contained content panes can delegate scrolling to their children");
+2
View File
@@ -28,6 +28,7 @@
"tests/mail-components.test.tsx", "tests/mail-components.test.tsx",
"tests/metric-card.test.tsx", "tests/metric-card.test.tsx",
"tests/page-layout.test.tsx", "tests/page-layout.test.tsx",
"tests/workspace-layout.test.tsx",
"tests/people-picker.test.tsx", "tests/people-picker.test.tsx",
"tests/password-generator.test.tsx", "tests/password-generator.test.tsx",
"tests/resource-access-explanation.test.tsx", "tests/resource-access-explanation.test.tsx",
@@ -48,6 +49,7 @@
"src/components/PageLayout.tsx", "src/components/PageLayout.tsx",
"src/components/PageScrollViewport.tsx", "src/components/PageScrollViewport.tsx",
"src/components/PageTitle.tsx", "src/components/PageTitle.tsx",
"src/components/WorkspaceLayout.tsx",
"src/components/LoadingFrame.tsx", "src/components/LoadingFrame.tsx",
"src/components/LoadingIndicator.tsx", "src/components/LoadingIndicator.tsx",
"src/components/people/PeoplePicker.tsx", "src/components/people/PeoplePicker.tsx",