feat: add shared workspace layouts
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user