Define semantic page action layouts

This commit is contained in:
2026-08-19 13:33:51 +02:00
parent 5a2e99f496
commit c042244da8
18 changed files with 306 additions and 10 deletions
+125
View File
@@ -0,0 +1,125 @@
import type { HTMLAttributes, ReactNode } from "react";
import type { PlatformInterfaceIdentityProps } from "../types";
import ActionToolbar, { ToolbarGroup } from "./ActionToolbar";
type PageActionBarCommonProps = PlatformInterfaceIdentityProps &
Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
reloadAction: ReactNode;
contextActions?: ReactNode;
helpAction?: ReactNode;
label?: string;
};
export type CollectionPageActionBarProps = PageActionBarCommonProps & {
variant: "collection";
createAction?: ReactNode;
};
export type DetailPageActionBarProps = PageActionBarCommonProps & {
variant: "detail";
primaryActions?: ReactNode;
consequentialActions?: ReactNode;
};
export type EditorPageActionBarProps = PageActionBarCommonProps & {
variant: "editor";
discardAction: ReactNode;
saveAction: ReactNode;
};
export type PageActionBarProps =
| CollectionPageActionBarProps
| DetailPageActionBarProps
| EditorPageActionBarProps;
function ActionSlot({ name, children }: { name: string; children: ReactNode }) {
return (
<span className={`page-action-slot page-action-slot-${name}`} data-page-action-slot={name}>
{children}
</span>
);
}
const defaultLabels = {
collection: "Collection page actions",
detail: "Detail page actions",
editor: "Editor page actions"
} as const;
/**
* Semantic action placement for headed pages.
*
* The named slots deliberately encode ordering. Product modules still own the
* actions, wording, permissions, blockers, and consequences placed in them.
*/
export default function PageActionBar(props: PageActionBarProps) {
const normalizedProps = props as PageActionBarProps & {
createAction?: ReactNode;
primaryActions?: ReactNode;
consequentialActions?: ReactNode;
discardAction?: ReactNode;
saveAction?: ReactNode;
};
const {
variant,
reloadAction,
contextActions,
helpAction,
createAction,
primaryActions,
consequentialActions,
discardAction,
saveAction,
label,
className = "",
interfaceId,
helpContextId,
helpModuleId,
helpTopicId,
...toolbarProps
} = normalizedProps;
let trailingActions: ReactNode;
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}
</>
);
} else {
trailingActions = (
<>
<ActionSlot name="discard">{discardAction}</ActionSlot>
<ActionSlot name="save">{saveAction}</ActionSlot>
</>
);
}
return (
<ActionToolbar
{...toolbarProps}
className={["page-action-bar", `page-action-bar-${variant}`, className].filter(Boolean).join(" ")}
density="compact"
justify="between"
wrap="responsive"
label={label ?? defaultLabels[variant]}
interfaceId={interfaceId}
helpContextId={helpContextId}
helpModuleId={helpModuleId}
helpTopicId={helpTopicId}
data-page-action-archetype={variant}
>
<ToolbarGroup className="page-action-bar-leading" data-page-action-group="leading">
<ActionSlot name="reload">{reloadAction}</ActionSlot>
{contextActions ? <ActionSlot name="context">{contextActions}</ActionSlot> : null}
</ToolbarGroup>
<ToolbarGroup className="page-action-bar-trailing" align="end" data-page-action-group="trailing">
{helpAction ? <ActionSlot name="help">{helpAction}</ActionSlot> : null}
{trailingActions}
</ToolbarGroup>
</ActionToolbar>
);
}
+2
View File
@@ -143,6 +143,8 @@ export type { MessageDisplayAttachment, MessageDisplayField } from "./components
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 PageActionBar } from "./components/PageActionBar";
export type { CollectionPageActionBarProps, DetailPageActionBarProps, EditorPageActionBarProps, PageActionBarProps } from "./components/PageActionBar";
export { default as PageScrollViewport } from "./components/PageScrollViewport";
export type { PageScrollViewportProps } from "./components/PageScrollViewport";
export { default as WorkspaceLayout } from "./components/WorkspaceLayout";
+3
View File
@@ -99,6 +99,8 @@
.action-toolbar-justify-end { justify-content: flex-end; }
.action-toolbar-group-grow { flex: 1 1 auto; }
.action-toolbar-spacer { flex: 1 1 auto; min-width: 8px; }
.page-action-slot { min-width: 0; display: inline-flex; align-items: center; }
.page-action-bar-trailing { flex: 0 1 auto; }
.app-content { min-height: 0; overflow: hidden; }
.workspace { height: 100%; min-height: 0; display: grid; grid-template-columns: 198px minmax(0, 1fr); }
.workspace-layout-primary,
@@ -355,6 +357,7 @@
.action-toolbar-wrap-responsive { align-items: stretch; flex-wrap: wrap; }
.action-toolbar-wrap-responsive > .action-toolbar-group { flex: 1 1 100%; }
.action-toolbar-wrap-responsive > .action-toolbar-group-end { margin-inline-start: 0; justify-content: flex-start; }
.page-action-bar.action-toolbar-wrap-responsive > .page-action-bar-trailing { justify-content: flex-end; }
.filter-bar-wrap-responsive.filter-bar-layout-row { align-items: stretch; flex-wrap: wrap; }
.filter-bar-wrap-responsive.filter-bar-layout-row > input:not([type="checkbox"]):not([type="radio"]),
.filter-bar-wrap-responsive.filter-bar-layout-row > select { flex-basis: 100%; }