feat: centralize shared page layouts
This commit is contained in:
@@ -54,6 +54,9 @@ than adding custom `F1` listeners:
|
|||||||
controls.
|
controls.
|
||||||
- `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
|
||||||
|
headed pages; specialized full-canvas workspaces reuse `PageHeader` while
|
||||||
|
keeping their route-level help identity on the owning workspace.
|
||||||
|
|
||||||
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,6 +13,7 @@ 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 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
|
||||||
@@ -26,3 +27,8 @@ they are not reasons to add sibling-private behavior to Core.
|
|||||||
Raw JSON remains permitted only for diagnostics, expert inspection,
|
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.
|
||||||
|
New headed pages use `PageLayout`; full-canvas explorers may use `PageHeader`
|
||||||
|
while the next shared workspace/split-pane contract is developed. Module CSS
|
||||||
|
continues to own domain content layout, never the shared page frame.
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
||||||
"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: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",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const titlebar = read("src/layout/Titlebar.tsx");
|
|||||||
const temporalDataMenu = read("src/layout/TemporalDataMenu.tsx");
|
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 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");
|
||||||
|
|
||||||
@@ -43,6 +45,11 @@ assert.match(credentials, /helpModuleId="access"/, "embedded credential controls
|
|||||||
assert.match(credentials, /disabledReason: writeDisabledReason/, "credential row actions retain actionable disabled reasons");
|
assert.match(credentials, /disabledReason: writeDisabledReason/, "credential row actions retain actionable disabled reasons");
|
||||||
assert.doesNotMatch(credentials, /<textarea/, "credentials use typed controls rather than a primary JSON editor");
|
assert.doesNotMatch(credentials, /<textarea/, "credentials use typed controls rather than a primary JSON editor");
|
||||||
|
|
||||||
|
assert.match(pageLayout, /`page-layout-\$\{mode\}`/, "shared standalone and embedded pages use one central frame");
|
||||||
|
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(adminPageLayout, /<PageLayout/, "administration composes the central page layout instead of redefining it");
|
||||||
|
|
||||||
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");
|
||||||
assert.match(layoutStyles, /\.icon-rail-header \{[^}]*flex: 0 0 auto;/, "the rail logo remains fixed");
|
assert.match(layoutStyles, /\.icon-rail-header \{[^}]*flex: 0 0 auto;/, "the rail logo remains fixed");
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
|
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||||
|
import DismissibleAlert from "./DismissibleAlert";
|
||||||
|
import LoadingFrame from "./LoadingFrame";
|
||||||
|
import PageScrollViewport from "./PageScrollViewport";
|
||||||
|
import PageTitle from "./PageTitle";
|
||||||
|
|
||||||
|
export type PageLayoutMode = "standalone" | "embedded";
|
||||||
|
|
||||||
|
export type PageHeaderProps = {
|
||||||
|
title: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
loading?: boolean;
|
||||||
|
sticky?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
loading = false,
|
||||||
|
sticky = true,
|
||||||
|
className = ""
|
||||||
|
}: PageHeaderProps) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const classes = [
|
||||||
|
"page-heading",
|
||||||
|
"split",
|
||||||
|
sticky ? "workspace-heading" : "",
|
||||||
|
"page-layout-header",
|
||||||
|
className
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={classes}>
|
||||||
|
<div className="page-layout-heading-copy">
|
||||||
|
<PageTitle loading={loading}>{title}</PageTitle>
|
||||||
|
{description && <p>{translateReactNode(description, translateText)}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="button-row compact-actions page-layout-actions">{actions}</div>}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PageLayoutProps = PlatformInterfaceIdentityProps & {
|
||||||
|
title: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
loading?: boolean;
|
||||||
|
loadingLabel?: string;
|
||||||
|
error?: string;
|
||||||
|
success?: string;
|
||||||
|
mode?: PageLayoutMode;
|
||||||
|
scrollable?: boolean;
|
||||||
|
stickyHeader?: boolean;
|
||||||
|
documentationType?: "user" | "admin";
|
||||||
|
className?: string;
|
||||||
|
viewportClassName?: string;
|
||||||
|
headerClassName?: string;
|
||||||
|
bodyClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PageLayout({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
loading = false,
|
||||||
|
loadingLabel = "i18n:govoplan-core.loading_page_data.85fe9edf",
|
||||||
|
error = "",
|
||||||
|
success = "",
|
||||||
|
mode = "standalone",
|
||||||
|
scrollable,
|
||||||
|
stickyHeader = true,
|
||||||
|
documentationType = "user",
|
||||||
|
className = "",
|
||||||
|
viewportClassName = "",
|
||||||
|
headerClassName = "",
|
||||||
|
bodyClassName = "",
|
||||||
|
interfaceId,
|
||||||
|
helpContextId,
|
||||||
|
helpModuleId,
|
||||||
|
helpTopicId
|
||||||
|
}: PageLayoutProps) {
|
||||||
|
const shouldScroll = scrollable ?? mode === "standalone";
|
||||||
|
const layoutClasses = [
|
||||||
|
"page-layout",
|
||||||
|
`page-layout-${mode}`,
|
||||||
|
mode === "standalone" ? "content-pad workspace-data-page" : "",
|
||||||
|
className
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
const layout = (
|
||||||
|
<div
|
||||||
|
className={layoutClasses}
|
||||||
|
data-help-scope="page"
|
||||||
|
data-interface-id={interfaceId}
|
||||||
|
data-help-context-id={helpContextId}
|
||||||
|
data-help-module-id={helpModuleId}
|
||||||
|
data-help-topic-id={helpTopicId}
|
||||||
|
data-help-documentation-type={documentationType}
|
||||||
|
data-help-key={typeof title === "string" ? title : undefined}
|
||||||
|
>
|
||||||
|
<PageHeader
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
actions={actions}
|
||||||
|
loading={loading}
|
||||||
|
sticky={stickyHeader}
|
||||||
|
className={headerClassName}
|
||||||
|
/>
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
|
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||||
|
<LoadingFrame loading={loading} label={loadingLabel} className={["page-layout-body", bodyClassName].filter(Boolean).join(" ")}>
|
||||||
|
{children}
|
||||||
|
</LoadingFrame>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shouldScroll) return layout;
|
||||||
|
return <PageScrollViewport className={viewportClassName}>{layout}</PageScrollViewport>;
|
||||||
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import DismissibleAlert from "../DismissibleAlert";
|
|
||||||
import LoadingFrame from "../LoadingFrame";
|
|
||||||
import PageTitle from "../PageTitle";
|
|
||||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
|
||||||
import type { PlatformInterfaceIdentityProps } from "../../types";
|
import type { PlatformInterfaceIdentityProps } from "../../types";
|
||||||
|
import PageLayout from "../PageLayout";
|
||||||
|
|
||||||
type Props = PlatformInterfaceIdentityProps & {
|
export type AdminPageLayoutProps = PlatformInterfaceIdentityProps & {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -31,31 +28,27 @@ export default function AdminPageLayout({
|
|||||||
helpContextId,
|
helpContextId,
|
||||||
helpModuleId,
|
helpModuleId,
|
||||||
helpTopicId
|
helpTopicId
|
||||||
}: Props) {
|
}: AdminPageLayoutProps) {
|
||||||
const { translateText } = usePlatformLanguage();
|
|
||||||
return (
|
return (
|
||||||
<div
|
<PageLayout
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
loading={loading}
|
||||||
|
loadingLabel={loadingLabel}
|
||||||
|
error={error}
|
||||||
|
success={success}
|
||||||
|
actions={actions}
|
||||||
|
mode="embedded"
|
||||||
|
documentationType="admin"
|
||||||
className={`admin-section-page ${className}`.trim()}
|
className={`admin-section-page ${className}`.trim()}
|
||||||
data-help-scope="page"
|
headerClassName="admin-page-heading"
|
||||||
data-interface-id={interfaceId}
|
interfaceId={interfaceId}
|
||||||
data-help-context-id={helpContextId}
|
helpContextId={helpContextId}
|
||||||
data-help-module-id={helpModuleId}
|
helpModuleId={helpModuleId}
|
||||||
data-help-topic-id={helpTopicId}
|
helpTopicId={helpTopicId}
|
||||||
data-help-documentation-type="admin"
|
|
||||||
data-help-key={title}
|
|
||||||
>
|
>
|
||||||
<div className="page-heading split workspace-heading admin-page-heading">
|
|
||||||
<div>
|
|
||||||
<PageTitle loading={loading}>{title}</PageTitle>
|
|
||||||
<p>{translateText(description)}</p>
|
|
||||||
</div>
|
|
||||||
{actions && <div className="button-row compact-actions admin-page-actions">{actions}</div>}
|
|
||||||
</div>
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
|
||||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
|
||||||
<LoadingFrame loading={loading} label={translateText(loadingLabel)}>
|
|
||||||
{children}
|
{children}
|
||||||
</LoadingFrame>
|
</PageLayout>
|
||||||
</div>);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import MetricCard from "../../components/MetricCard";
|
import MetricCard from "../../components/MetricCard";
|
||||||
import PageScrollViewport from "../../components/PageScrollViewport";
|
import PageLayout from "../../components/PageLayout";
|
||||||
import PageTitle from "../../components/PageTitle";
|
|
||||||
import { usePlatformModules } from "../../platform/ModuleContext";
|
import { usePlatformModules } from "../../platform/ModuleContext";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const modules = usePlatformModules();
|
const modules = usePlatformModules();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScrollViewport className="core-dashboard-page">
|
<PageLayout
|
||||||
<div className="content-pad workspace-data-page">
|
title="i18n:govoplan-core.dashboard.d87f47b4"
|
||||||
<div className="page-heading split workspace-heading">
|
description="Install and enable the dashboard module to make this page configurable."
|
||||||
<div>
|
viewportClassName="core-dashboard-page"
|
||||||
<PageTitle>i18n:govoplan-core.dashboard.d87f47b4</PageTitle>
|
>
|
||||||
<p>Install and enable the dashboard module to make this page configurable.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="metric-grid">
|
<div className="metric-grid">
|
||||||
<MetricCard label="i18n:govoplan-core.installed_modules.32b3e799" value={modules.length} tone="info" detail={modules.length ? moduleLabels(modules).join(", ") : "i18n:govoplan-core.core_only.d46ce7d9"} />
|
<MetricCard label="i18n:govoplan-core.installed_modules.32b3e799" value={modules.length} tone="info" detail={modules.length ? moduleLabels(modules).join(", ") : "i18n:govoplan-core.core_only.d46ce7d9"} />
|
||||||
<MetricCard label="Dashboard module" value="not installed" tone="neutral" detail="Core fallback is active." />
|
<MetricCard label="Dashboard module" value="not installed" tone="neutral" detail="Core fallback is active." />
|
||||||
@@ -39,8 +34,7 @@ export default function DashboardPage() {
|
|||||||
<p className="muted">This minimal core home is only shown while no dashboard WebUI module is available. Feature modules own their pages and can expose dashboard widgets once the dashboard module is installed.</p>
|
<p className="muted">This minimal core home is only shown while no dashboard WebUI module is available. Feature modules own their pages and can expose dashboard widgets once the dashboard module is installed.</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageLayout>);
|
||||||
</PageScrollViewport>);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export { default as AdvancedOptionsPanel } from "./components/AdvancedOptionsPan
|
|||||||
export { default as AdminIconButton } from "./components/admin/AdminIconButton";
|
export { default as AdminIconButton } from "./components/admin/AdminIconButton";
|
||||||
export type { AdminIconButtonProps } from "./components/admin/AdminIconButton";
|
export type { AdminIconButtonProps } from "./components/admin/AdminIconButton";
|
||||||
export { default as AdminPageLayout } from "./components/admin/AdminPageLayout";
|
export { default as AdminPageLayout } from "./components/admin/AdminPageLayout";
|
||||||
|
export type { AdminPageLayoutProps } from "./components/admin/AdminPageLayout";
|
||||||
export { default as AdminSelectionList } from "./components/admin/AdminSelectionList";
|
export { default as AdminSelectionList } from "./components/admin/AdminSelectionList";
|
||||||
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "./components/admin/adminUtils";
|
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "./components/admin/adminUtils";
|
||||||
export { default as Button } from "./components/Button";
|
export { default as Button } from "./components/Button";
|
||||||
@@ -111,6 +112,8 @@ export { default as MetricCard } from "./components/MetricCard";
|
|||||||
export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel";
|
export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel";
|
||||||
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
||||||
export { default as PageTitle } from "./components/PageTitle";
|
export { default as PageTitle } from "./components/PageTitle";
|
||||||
|
export { default as PageLayout, PageHeader } 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 PasswordField } from "./components/PasswordField";
|
export { default as PasswordField } from "./components/PasswordField";
|
||||||
|
|||||||
@@ -159,6 +159,34 @@
|
|||||||
margin: -28px -34px 22px;
|
margin: -28px -34px 22px;
|
||||||
padding: 18px 34px 16px;
|
padding: 18px 34px 16px;
|
||||||
}
|
}
|
||||||
|
.page-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.page-layout-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 18px;
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.page-layout-heading-copy,
|
||||||
|
.page-layout-body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.page-layout-actions {
|
||||||
|
align-self: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.page-layout > .page-layout-body {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
.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; }
|
||||||
@@ -324,6 +352,11 @@
|
|||||||
.docs-unavailable-reason { margin-top: 16px; border-left: 3px solid var(--amber); padding-left: 12px; }
|
.docs-unavailable-reason { margin-top: 16px; border-left: 3px solid var(--amber); padding-left: 12px; }
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.page-heading.split { align-items: flex-start; flex-direction: column; }
|
.page-heading.split { align-items: flex-start; flex-direction: column; }
|
||||||
|
.page-layout-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
.summary-grid, .detail-list div { grid-template-columns: 1fr; }
|
.summary-grid, .detail-list div { grid-template-columns: 1fr; }
|
||||||
.docs-workspace { grid-template-columns: 1fr; }
|
.docs-workspace { grid-template-columns: 1fr; }
|
||||||
.docs-outline { display: none; }
|
.docs-outline { display: none; }
|
||||||
|
|||||||
@@ -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 PageLayout from "../src/components/PageLayout";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
|
||||||
|
const standaloneMarkup = renderToStaticMarkup(
|
||||||
|
<PlatformLanguageProvider>
|
||||||
|
<PageLayout
|
||||||
|
title="Shared page"
|
||||||
|
description="One page frame"
|
||||||
|
actions={<button type="button">Reload</button>}
|
||||||
|
error="Could not load"
|
||||||
|
success="Saved"
|
||||||
|
interfaceId="test.page"
|
||||||
|
helpContextId="test.page.help"
|
||||||
|
helpModuleId="test-module"
|
||||||
|
>
|
||||||
|
<section>Page content</section>
|
||||||
|
</PageLayout>
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(standaloneMarkup.includes('class="page-scroll-viewport"'), "standalone pages own their scroll viewport");
|
||||||
|
assert(standaloneMarkup.includes("page-layout-standalone content-pad workspace-data-page"), "standalone pages use the shared padded workspace frame");
|
||||||
|
assert(standaloneMarkup.includes("page-layout-header"), "the central page header is rendered");
|
||||||
|
assert(standaloneMarkup.includes("page-layout-actions"), "route actions use the central responsive action region");
|
||||||
|
assert(standaloneMarkup.includes('data-interface-id="test.page"'), "page identity reaches the shared frame");
|
||||||
|
assert(standaloneMarkup.includes('data-help-context-id="test.page.help"'), "context help reaches the shared frame");
|
||||||
|
assert(standaloneMarkup.includes('data-help-module-id="test-module"'), "documentation ownership reaches the shared frame");
|
||||||
|
assert(standaloneMarkup.includes("Could not load"), "page errors use the shared alert region");
|
||||||
|
assert(standaloneMarkup.includes("Saved"), "page success notices use the shared alert region");
|
||||||
|
assert(standaloneMarkup.includes("Page content"), "page content is preserved");
|
||||||
|
|
||||||
|
const embeddedMarkup = renderToStaticMarkup(
|
||||||
|
<PlatformLanguageProvider>
|
||||||
|
<PageLayout title="Embedded page" mode="embedded" documentationType="admin">
|
||||||
|
Embedded content
|
||||||
|
</PageLayout>
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(!embeddedMarkup.includes("page-scroll-viewport"), "embedded pages defer scrolling to their owner");
|
||||||
|
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('data-help-documentation-type="admin"'), "embedded administration keeps its documentation type");
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"tests/icon-button.test.tsx",
|
"tests/icon-button.test.tsx",
|
||||||
"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/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",
|
||||||
@@ -44,6 +45,11 @@
|
|||||||
"src/components/passwordGenerator.ts",
|
"src/components/passwordGenerator.ts",
|
||||||
"src/components/MessageDisplayPanel.tsx",
|
"src/components/MessageDisplayPanel.tsx",
|
||||||
"src/components/MetricCard.tsx",
|
"src/components/MetricCard.tsx",
|
||||||
|
"src/components/PageLayout.tsx",
|
||||||
|
"src/components/PageScrollViewport.tsx",
|
||||||
|
"src/components/PageTitle.tsx",
|
||||||
|
"src/components/LoadingFrame.tsx",
|
||||||
|
"src/components/LoadingIndicator.tsx",
|
||||||
"src/components/people/PeoplePicker.tsx",
|
"src/components/people/PeoplePicker.tsx",
|
||||||
"src/components/people/peoplePickerTypes.ts",
|
"src/components/people/peoplePickerTypes.ts",
|
||||||
"src/components/ResourceAccessExplanation.tsx",
|
"src/components/ResourceAccessExplanation.tsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user