Define semantic page archetypes
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user