Define semantic page archetypes

This commit is contained in:
2026-08-19 14:26:25 +02:00
parent c042244da8
commit 41db78c201
18 changed files with 315 additions and 87 deletions
+127 -19
View File
@@ -1,36 +1,71 @@
import type { HTMLAttributes, ReactNode } from "react";
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import type { PlatformInterfaceIdentityProps } from "../types";
import ActionToolbar, { ToolbarGroup } from "./ActionToolbar";
import Button from "./Button";
type RefreshablePageActions = {
refreshable: true;
reloadAction: ReactNode;
} | {
refreshable?: false;
reloadAction?: never;
};
type PageActionBarCommonProps = PlatformInterfaceIdentityProps &
Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
reloadAction: ReactNode;
contextActions?: ReactNode;
helpAction?: ReactNode;
label?: string;
};
export type CollectionPageActionBarProps = PageActionBarCommonProps & {
export type PageEditorAction = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & PlatformInterfaceIdentityProps & {
label: ReactNode;
disabledReason?: ReactNode;
};
export type OverviewPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "overview";
primaryActions?: ReactNode;
};
export type CollectionPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "collection";
createAction?: ReactNode;
};
export type DetailPageActionBarProps = PageActionBarCommonProps & {
export type DetailPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "detail";
primaryActions?: ReactNode;
consequentialActions?: ReactNode;
destructiveActions?: ReactNode;
};
export type EditorPageActionBarProps = PageActionBarCommonProps & {
export type EditorPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "editor";
discardAction: ReactNode;
saveAction: ReactNode;
dirty: boolean;
saving?: boolean;
dirtyLabel?: ReactNode;
cleanLabel?: ReactNode;
savingLabel?: ReactNode;
cleanDisabledReason?: ReactNode;
savingDisabledReason?: ReactNode;
discardAction: PageEditorAction;
saveAction: PageEditorAction;
primaryActions?: ReactNode;
destructiveActions?: ReactNode;
};
export type WorkspacePageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "workspace";
primaryActions?: ReactNode;
destructiveActions?: ReactNode;
};
export type PageActionBarProps =
| OverviewPageActionBarProps
| CollectionPageActionBarProps
| DetailPageActionBarProps
| EditorPageActionBarProps;
| EditorPageActionBarProps
| WorkspacePageActionBarProps;
function ActionSlot({ name, children }: { name: string; children: ReactNode }) {
return (
@@ -41,11 +76,41 @@ function ActionSlot({ name, children }: { name: string; children: ReactNode }) {
}
const defaultLabels = {
overview: "Overview page actions",
collection: "Collection page actions",
detail: "Detail page actions",
editor: "Editor page actions"
editor: "Editor page actions",
workspace: "Workspace page actions"
} as const;
function DestructiveSlot({ children }: { children: ReactNode }) {
if (!children) return null;
return (
<ActionSlot name="destructive">
<span className="page-action-destructive-group" data-page-action-separation="destructive">
{children}
</span>
</ActionSlot>
);
}
function EditorAction({
action,
variant,
disabledReason
}: {
action: PageEditorAction;
variant: "ghost" | "primary";
disabledReason?: ReactNode;
}) {
const { label, disabledReason: actionDisabledReason, ...buttonProps } = action;
return (
<Button {...buttonProps} variant={variant} disabledReason={disabledReason ?? actionDisabledReason}>
{label}
</Button>
);
}
/**
* Semantic action placement for headed pages.
*
@@ -56,18 +121,33 @@ export default function PageActionBar(props: PageActionBarProps) {
const normalizedProps = props as PageActionBarProps & {
createAction?: ReactNode;
primaryActions?: ReactNode;
consequentialActions?: ReactNode;
discardAction?: ReactNode;
saveAction?: ReactNode;
destructiveActions?: ReactNode;
dirty?: boolean;
saving?: boolean;
dirtyLabel?: ReactNode;
cleanLabel?: ReactNode;
savingLabel?: ReactNode;
cleanDisabledReason?: ReactNode;
savingDisabledReason?: ReactNode;
discardAction?: PageEditorAction;
saveAction?: PageEditorAction;
};
const {
variant,
refreshable = false,
reloadAction,
contextActions,
helpAction,
createAction,
primaryActions,
consequentialActions,
destructiveActions,
dirty = false,
saving = false,
dirtyLabel = "Unsaved changes",
cleanLabel = "Saved",
savingLabel = "Saving…",
cleanDisabledReason = "There are no unsaved changes.",
savingDisabledReason = "Changes are already being saved.",
discardAction,
saveAction,
label,
@@ -80,20 +160,36 @@ export default function PageActionBar(props: PageActionBarProps) {
} = normalizedProps;
let trailingActions: ReactNode;
if (variant === "collection") {
if (variant === "overview") {
trailingActions = primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null;
} else if (variant === "collection") {
trailingActions = createAction ? <ActionSlot name="create">{createAction}</ActionSlot> : null;
} else if (variant === "detail") {
trailingActions = (
<>
{primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null}
{consequentialActions ? <ActionSlot name="consequential">{consequentialActions}</ActionSlot> : null}
<DestructiveSlot>{destructiveActions}</DestructiveSlot>
</>
);
} else if (variant === "editor") {
const persistenceDisabledReason = saving ? savingDisabledReason : !dirty ? cleanDisabledReason : undefined;
trailingActions = (
<>
{primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null}
<DestructiveSlot>{destructiveActions}</DestructiveSlot>
<ActionSlot name="discard">
<EditorAction action={discardAction!} variant="ghost" disabledReason={persistenceDisabledReason} />
</ActionSlot>
<ActionSlot name="save">
<EditorAction action={saveAction!} variant="primary" disabledReason={persistenceDisabledReason} />
</ActionSlot>
</>
);
} else {
trailingActions = (
<>
<ActionSlot name="discard">{discardAction}</ActionSlot>
<ActionSlot name="save">{saveAction}</ActionSlot>
{primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null}
<DestructiveSlot>{destructiveActions}</DestructiveSlot>
</>
);
}
@@ -111,12 +207,24 @@ export default function PageActionBar(props: PageActionBarProps) {
helpModuleId={helpModuleId}
helpTopicId={helpTopicId}
data-page-action-archetype={variant}
data-page-refreshable={refreshable ? "true" : "false"}
data-page-dirty={variant === "editor" ? (dirty ? "true" : "false") : undefined}
>
<ToolbarGroup className="page-action-bar-leading" data-page-action-group="leading">
<ActionSlot name="reload">{reloadAction}</ActionSlot>
{refreshable ? <ActionSlot name="reload">{reloadAction}</ActionSlot> : null}
{contextActions ? <ActionSlot name="context">{contextActions}</ActionSlot> : null}
</ToolbarGroup>
<ToolbarGroup className="page-action-bar-trailing" align="end" data-page-action-group="trailing">
{variant === "editor" ? (
<span
className={`page-dirty-state page-dirty-state-${saving ? "saving" : dirty ? "dirty" : "clean"}`}
data-page-dirty-state={saving ? "saving" : dirty ? "dirty" : "clean"}
role="status"
aria-live="polite"
>
{saving ? savingLabel : dirty ? dirtyLabel : cleanLabel}
</span>
) : null}
{helpAction ? <ActionSlot name="help">{helpAction}</ActionSlot> : null}
{trailingActions}
</ToolbarGroup>
+5
View File
@@ -7,6 +7,7 @@ import PageScrollViewport from "./PageScrollViewport";
import PageTitle from "./PageTitle";
export type PageLayoutMode = "standalone" | "workspace" | "embedded";
export type PageArchetype = "overview" | "collection" | "detail" | "editor" | "workspace";
export type PageHeaderProps = {
title: ReactNode;
@@ -48,6 +49,8 @@ export function PageHeader({
}
export type PageLayoutProps = PlatformInterfaceIdentityProps & {
/** Semantic page intent. This is independent from viewport/layout geometry. */
archetype: PageArchetype;
title: ReactNode;
description?: ReactNode;
actions?: ReactNode;
@@ -70,6 +73,7 @@ export type PageLayoutProps = PlatformInterfaceIdentityProps & {
};
export default function PageLayout({
archetype,
title,
description,
actions,
@@ -107,6 +111,7 @@ export default function PageLayout({
const layout = (
<div
className={layoutClasses}
data-page-archetype={archetype}
data-help-scope="page"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
@@ -1,8 +1,9 @@
import type { ReactNode } from "react";
import type { PlatformInterfaceIdentityProps } from "../../types";
import PageLayout from "../PageLayout";
import PageLayout, { type PageArchetype } from "../PageLayout";
export type AdminPageLayoutProps = PlatformInterfaceIdentityProps & {
archetype?: PageArchetype;
title: string;
description: string;
loading?: boolean;
@@ -15,6 +16,7 @@ export type AdminPageLayoutProps = PlatformInterfaceIdentityProps & {
};
export default function AdminPageLayout({
archetype = "detail",
title,
description,
loading = false,
@@ -31,6 +33,7 @@ export default function AdminPageLayout({
}: AdminPageLayoutProps) {
return (
<PageLayout
archetype={archetype}
title={title}
description={description}
loading={loading}