diff --git a/docs/components/Page/Page.stories.mdx b/docs/components/Page/Page.stories.mdx index 61c7767ec9..0a87244862 100644 --- a/docs/components/Page/Page.stories.mdx +++ b/docs/components/Page/Page.stories.mdx @@ -4,4 +4,5 @@ import { Meta } from "@storybook/addon-docs"; # Page -[Page docs](https://atlantis.getjobber.com/components/Page) have moved to the new site. +[Page docs](https://atlantis.getjobber.com/components/Page) have moved to the +new site. diff --git a/docs/components/Page/Web.stories.tsx b/docs/components/Page/Web.stories.tsx index 6d4dd482d8..883871469d 100644 --- a/docs/components/Page/Web.stories.tsx +++ b/docs/components/Page/Web.stories.tsx @@ -1,9 +1,16 @@ import React, { useRef, useState } from "react"; -import { ComponentMeta, ComponentStory } from "@storybook/react"; +import type { Meta, StoryFn } from "@storybook/react"; import { Heading, StatusLabel, Tooltip } from "@jobber/components"; -import { Page } from "@jobber/components/Page"; +import { + Page, + type PageComposableProps, + type PageLegacyProps, +} from "@jobber/components/Page"; import { Content } from "@jobber/components/Content"; +import { Markdown } from "@jobber/components/Markdown"; import { Text } from "@jobber/components/Text"; +import { Menu } from "@jobber/components/Menu"; +import { Button } from "@jobber/components/Button"; import { Popover } from "@jobber/components/Popover"; export default { @@ -13,9 +20,9 @@ export default { viewMode: "story", previewTabs: { code: { hidden: false } }, }, -} as ComponentMeta; +} satisfies Meta; -const BasicTemplate: ComponentStory = args => ( +const BasicTemplate: StoryFn = args => ( Page content here @@ -23,7 +30,7 @@ const BasicTemplate: ComponentStory = args => ( ); -const CustomTitleTemplate: ComponentStory = args => { +const CustomTitleTemplate: StoryFn = args => { const props = { ...args, titleMetaData: undefined }; return ( @@ -42,7 +49,7 @@ const CustomTitleTemplate: ComponentStory = args => { ); }; -const PopoverTemplate: ComponentStory = args => { +const PopoverTemplate: StoryFn = args => { const primaryDivRef = useRef(null); const [showPrimaryPopover, setShowPrimaryPopover] = useState(false); @@ -176,3 +183,183 @@ WithAdditionalTitleFields.parameters = { }, }, }; + +export const ComposableBasic: StoryFn = args => ( + + + Notifications + + + Page content here + + +); + +export const ComposableWithActions: StoryFn = args => ( + + + Clients + + + alert("New Client")} + /> + + + alert("Export")} + /> + + + + alert("Import")}> + + Import + + alert("Archive")}> + + Archive + + + + + + + Page content here + + +); +ComposableWithActions.args = { + width: "fill", +}; + +export const ComposableWithSubtitleAndIntro: StoryFn< + PageComposableProps +> = args => ( + + + Notifications + Notify me of all the work + + + Improve job completion rates, stop chasing payments, and boost your + customer service by automatically communicating with your clients at key + points before, during, and after a job. + + + Page content here + + +); + +export const ComposableWithAllPieces: StoryFn = args => ( + + + + Kitchen Renovation Project + + + + + Everything but the Kitchen Sink + + + alert("Create")} + /> + + + alert("Send")} + /> + + + + alert("Edit")}> + + Edit + + alert("Delete")} + > + + Delete + + + + + + + + Building the greatest kitchen one will ever see. The entire kitchen will + be redone for this renovation. + + + +); +ComposableWithAllPieces.args = { + width: "fill", +}; + +export const ComposableCustomSlot: StoryFn = args => ( + + + Custom Action Elements + + + + + + + Content + , + ); + + expect(screen.getByText("My Custom Button")).toBeVisible(); + }); + + it("renders Page.Menu inside TertiarySlot", () => { + render( + + + Menu Test + + + + + Export + + + + + + Content + , + ); + + expect(screen.getByText("More Actions")).toBeVisible(); + }); + + it("renders Page.Menu with custom trigger label", () => { + render( + + + Menu Test + + + + + Settings + + + + + + Content + , + ); + + expect(screen.getByText("Options")).toBeVisible(); + }); + + it("renders a complete page with all composable pieces", () => { + render( + + + + Full Example + + + + + A subtitle here + + + + + + + + + + + Import + + + + + + Main content + , + ); + + expect( + screen.getByRole("heading", { name: "Full Example", level: 1 }), + ).toBeVisible(); + expect(screen.getByText("Draft")).toBeVisible(); + expect(screen.getByText("A subtitle here")).toBeVisible(); + expect(screen.getByText("Create")).toBeVisible(); + expect(screen.getByText("Export")).toBeVisible(); + expect(screen.getByText("More Actions")).toBeVisible(); + expect(screen.getByText("Main content")).toBeVisible(); + }); +}); diff --git a/packages/components/src/Page/Page.tsx b/packages/components/src/Page/Page.tsx index 4434175ade..0b9f07056a 100644 --- a/packages/components/src/Page/Page.tsx +++ b/packages/components/src/Page/Page.tsx @@ -1,108 +1,72 @@ -import type { ReactNode } from "react"; -import React from "react"; +import type { ReactElement, ReactNode, RefObject } from "react"; +import React, { Children, isValidElement } from "react"; import classnames from "classnames"; -import type { XOR } from "ts-xor"; import { Breakpoints, useResizeObserver } from "@jobber/hooks"; import styles from "./Page.module.css"; +import type { + ButtonActionProps, + PageActionButtonProps, + PageActionsProps, + PageBodyProps, + PageComposableProps, + PageHeaderProps, + PageIntroProps, + PageLegacyProps, + PageMenuProps, + PageProps, + PageSlotProps, + PageSubtitleProps, + PageTitleMetaDataProps, + PageTitleProps, +} from "./types"; import { Heading } from "../Heading"; import { Text } from "../Text"; import { Content } from "../Content"; import { Markdown } from "../Markdown"; import { Button, type ButtonProps } from "../Button"; -import { Menu, type SectionProps } from "../Menu"; +import { Menu } from "../Menu"; import { Emphasis } from "../Emphasis"; +import { Container } from "../Container"; +import { filterDataAttributes } from "../sharedHelpers/filterDataAttributes"; +import type { CommonAtlantisProps } from "../sharedHelpers/types"; -export type ButtonActionProps = ButtonProps & { - ref?: React.RefObject; -}; +/** Discriminates between the props-based API and the composable children API. */ +function isLegacy(props: PageProps): props is PageLegacyProps { + return "title" in props; +} + +export function Page(props: PageLegacyProps): ReactElement; +export function Page(props: PageComposableProps): ReactElement; + +export function Page(props: PageProps): ReactElement { + const pageStyles = classnames(styles.page, styles[props.width ?? "standard"]); + + if (isLegacy(props)) { + return ; + } + + return ( +
+ {props.children} +
+ ); +} -interface PageFoundationProps { - readonly children: ReactNode | ReactNode[]; - - /** - * Title of the page. - * - * Supports any React node. If a string is provided, it will be rendered as an H1 heading. - * Otherwise it will be rendered as is. - * - * **Important**: If you're passing a custom element, it must include an H1-level heading within it. - * Ideally should be used here. - */ - readonly title: ReactNode; - - /** - * TitleMetaData component to be displayed - * next to the title. Only compatible with string titles. - */ - readonly titleMetaData?: ReactNode; - - /** - * Subtitle of the page. - */ - readonly subtitle?: string; - - /** - * Determines the width of the page. - * - * Fill makes the width grow to 100%. - * - * Standard caps out at 1280px. - * - * Narrow caps out at 1024px. - * - * @default standard - */ - readonly width?: "fill" | "standard" | "narrow"; - - /** - * Page title primary action button settings. - */ - readonly primaryAction?: ButtonActionProps; - - /** - * Page title secondary action button settings. - */ - readonly secondaryAction?: ButtonActionProps; - - /** - * Page title Action menu. - */ - readonly moreActionsMenu?: SectionProps[]; -} - -interface PageWithIntroProps extends PageFoundationProps { - /** - * Content of the page. This supports basic markdown node types - * such as `_italic_`, `**bold**`, and `[link name](url)`. - */ - readonly intro: string; - - /** - * Causes any markdown links in the `intro` prop to open in a new - * tab, i.e. with `target="_blank"`. - * - * Can only be used if `intro` prop is also specified. - * - * Defaults to `false`. - */ - readonly externalIntroLinks?: boolean; -} - -export type PageProps = XOR; - -export function Page({ +/** Props-based renderer. Preserves the original Page behavior for existing consumers. */ +function PageLegacy({ title, titleMetaData, intro, externalIntroLinks, subtitle, children, - width = "standard", primaryAction, secondaryAction, moreActionsMenu = [], -}: PageProps) { - const pageStyles = classnames(styles.page, styles[width]); + pageStyles, + ...rest +}: PageLegacyProps & { readonly pageStyles: string }) { + const dataAttrs = filterDataAttributes(rest); const [titleBarRef, { width: titleBarWidth = Breakpoints.large }] = useResizeObserver(); @@ -115,26 +79,8 @@ export function Page({ const showMenu = moreActionsMenu.length > 0; const showActionGroup = showMenu || primaryAction || secondaryAction; - if (primaryAction != undefined) { - primaryAction = Object.assign({ fullWidth: true }, primaryAction); - } - - if (secondaryAction != undefined) { - secondaryAction = Object.assign( - { type: "secondary", fullWidth: true }, - secondaryAction, - ); - } - - if (secondaryAction != undefined) { - secondaryAction = Object.assign( - { type: "secondary", fullWidth: true }, - secondaryAction, - ); - } - return ( -
+
@@ -163,7 +109,10 @@ export function Page({
{primaryAction && (
-
)} {secondaryAction && ( @@ -171,7 +120,11 @@ export function Page({ className={styles.actionButton} ref={secondaryAction.ref} > -
)} {showMenu && ( @@ -198,9 +151,232 @@ export function Page({ ); } -export const getActionProps = (actionProps: ButtonActionProps): ButtonProps => { - const buttonProps = { ...actionProps }; - if (actionProps.ref) delete buttonProps.ref; +/** Groups title, subtitle, and actions. Separates Page.Actions for layout positioning. */ +function PageHeader({ children, ...rest }: PageHeaderProps) { + const dataAttrs = filterDataAttributes(rest); + let actionsElement: ReactNode = null; + const otherChildren: ReactNode[] = []; + + Children.forEach(children, child => { + if (isValidElement(child) && child.type === PageActions) { + actionsElement = child; + } else { + otherChildren.push(child); + } + }); + + return ( + + + +
+
{otherChildren}
+ {actionsElement} +
+
+
+
+ ); +} + +/** Renders the page heading (H1). Extracts Page.TitleMetaData from children for layout. */ +function PageTitle({ children, ...rest }: PageTitleProps) { + const dataAttrs = filterDataAttributes(rest); + let metaDataElement: ReactNode = null; + const otherChildren: ReactNode[] = []; + + Children.forEach(children, child => { + if (isValidElement(child) && child.type === PageTitleMetaData) { + metaDataElement = child; + } else { + otherChildren.push(child); + } + }); + + if (metaDataElement) { + return ( +
+ {otherChildren} + {metaDataElement} +
+ ); + } + + return {otherChildren}; +} + +/** Metadata displayed alongside the page title (e.g. status badges). Use inside Page.Title. */ +function PageTitleMetaData({ children }: PageTitleMetaDataProps) { + return <>{children}; +} + +/** Secondary text below the title. Always applies default Text/Emphasis styling. */ +function PageSubtitle({ children, ...rest }: PageSubtitleProps) { + const dataAttrs = filterDataAttributes(rest); + + return ( +
+ + {children} + +
+ ); +} + +/** Introduction text between the header and body. Always applies default Text styling. */ +function PageIntro({ children }: PageIntroProps) { + return {children}; +} + +/** Container for action buttons and menu. Applies responsive actionGroup layout. */ +function PageActions({ children, ...rest }: PageActionsProps) { + const dataAttrs = filterDataAttributes(rest); + + return ( +
+ {children} +
+ ); +} + +/** Positional slot for the primary action. Renders children in the primary action position. */ +function PagePrimarySlot({ children, ref }: PageSlotProps) { + return ( +
+ {children} +
+ ); +} + +/** Positional slot for the secondary action. Renders children in the secondary action position. */ +function PageSecondarySlot({ children, ref }: PageSlotProps) { + return ( +
+ {children} +
+ ); +} + +/** Positional slot for the tertiary action (typically the menu). */ +function PageTertiarySlot({ children, ref }: PageSlotProps) { + return ( +
+ {children} +
+ ); +} + +/** Primary action button with default styling. Use inside Page.PrimarySlot or Page.Actions. */ +function PagePrimaryAction({ + ref, + label, + onClick, + icon, + disabled, + loading, + ariaLabel, + ...rest +}: PageActionButtonProps) { + const dataAttrs = filterDataAttributes(rest); + + return ( +
+
+ ); +} + +/** Secondary action button with default styling. Use inside Page.SecondarySlot or Page.Actions. */ +function PageSecondaryAction({ + ref, + label, + onClick, + icon, + disabled, + loading, + ariaLabel, + ...rest +}: PageActionButtonProps) { + const dataAttrs = filterDataAttributes(rest); + + return ( +
+
+ ); +} + +/** + * "More Actions" menu with a default trigger button. + * Consumers supply Menu.Item children (in case custom routing is needed, + * e.g. wrapping Menu.Item with createLink() from TanStack Router). + */ +function PageMenu({ + children, + triggerLabel = "More Actions", + ...rest +}: PageMenuProps) { + const dataAttrs = filterDataAttributes(rest); + + return ( +
+ + + +
+ ); +} + +/** Main content area of the page. */ +function PageBody({ children }: PageBodyProps) { + return {children}; +} + +export const getActionProps = ( + actionProps?: ButtonActionProps, +): ButtonProps => { + const buttonProps = (actionProps ?? {}) as ButtonProps & { + ref?: RefObject; + }; + if (actionProps?.ref) delete buttonProps.ref; return buttonProps; }; + +Page.Header = PageHeader; +Page.Title = PageTitle; +Page.TitleMetaData = PageTitleMetaData; +Page.Subtitle = PageSubtitle; +Page.Intro = PageIntro; +Page.Actions = PageActions; +Page.PrimarySlot = PagePrimarySlot; +Page.SecondarySlot = PageSecondarySlot; +Page.TertiarySlot = PageTertiarySlot; +Page.PrimaryAction = PagePrimaryAction; +Page.SecondaryAction = PageSecondaryAction; +Page.Menu = PageMenu; +Page.Body = PageBody; diff --git a/packages/components/src/Page/index.ts b/packages/components/src/Page/index.ts index c4fb9a0cde..4e60306c4e 100644 --- a/packages/components/src/Page/index.ts +++ b/packages/components/src/Page/index.ts @@ -1 +1,10 @@ -export { Page, type PageProps } from "./Page"; +export { Page } from "./Page"; +export type { + ButtonActionProps, + PageActionButtonProps, + PageComposableProps, + PageLegacyProps, + PageMenuProps, + PageProps, + PageSlotProps, +} from "./types"; diff --git a/packages/components/src/Page/types.ts b/packages/components/src/Page/types.ts new file mode 100644 index 0000000000..618fc616d3 --- /dev/null +++ b/packages/components/src/Page/types.ts @@ -0,0 +1,141 @@ +import type { ReactNode, RefObject } from "react"; +import { type ButtonProps } from "../Button"; +import { type SectionProps } from "../Menu"; + +export type ButtonActionProps = ButtonProps & { + ref?: React.RefObject; +}; + +interface PageBaseProps { + readonly children: ReactNode | ReactNode[]; + + /** + * Title of the page. + * + * Supports any React node. If a string is provided, it will be rendered as an H1 heading. + * Otherwise it will be rendered as is. + * + * **Important**: If you're passing a custom element, it must include an H1-level heading within it. + * Ideally should be used here. + */ + readonly title: ReactNode; + + /** + * TitleMetaData component to be displayed + * next to the title. Only compatible with string titles. + */ + readonly titleMetaData?: ReactNode; + + /** + * Subtitle of the page. + */ + readonly subtitle?: string; + + /** + * Determines the width of the page. + * + * Fill makes the width grow to 100%. + * + * Standard caps out at 1280px. + * + * Narrow caps out at 1024px. + * + * @default standard + */ + readonly width?: "fill" | "standard" | "narrow"; + + /** + * Page title primary action button settings. + */ + readonly primaryAction?: ButtonActionProps; + + /** + * Page title secondary action button settings. + */ + readonly secondaryAction?: ButtonActionProps; + + /** + * Page title Action menu. + */ + readonly moreActionsMenu?: SectionProps[]; +} + +interface PageWithIntroProps extends PageBaseProps { + /** + * Content of the page. This supports basic markdown node types + * such as `_italic_`, `**bold**`, and `[link name](url)`. + */ + readonly intro: string; + + /** + * Causes any markdown links in the `intro` prop to open in a new + * tab, i.e. with `target="_blank"`. + * + * Can only be used if `intro` prop is also specified. + * + * Defaults to `false`. + */ + readonly externalIntroLinks?: boolean; +} + +interface PageWithoutIntroProps extends PageBaseProps { + readonly intro?: never; + readonly externalIntroLinks?: never; +} + +export type PageLegacyProps = PageWithIntroProps | PageWithoutIntroProps; + +export interface PageComposableProps { + readonly children: ReactNode; + readonly width?: "fill" | "standard" | "narrow"; +} + +export type PageProps = PageLegacyProps | PageComposableProps; + +export interface PageHeaderProps { + readonly children: ReactNode; +} + +export interface PageTitleProps { + readonly children: ReactNode; +} + +export interface PageTitleMetaDataProps { + readonly children: ReactNode; +} + +export interface PageSubtitleProps { + readonly children: ReactNode; +} + +export interface PageIntroProps { + readonly children: ReactNode; +} + +export interface PageActionsProps { + readonly children: ReactNode; +} + +export interface PageSlotProps { + readonly children: ReactNode; + readonly ref?: RefObject; +} + +export interface PageActionButtonProps { + readonly ref?: RefObject; + readonly label: string; + readonly onClick?: () => void; + readonly icon?: ButtonProps["icon"]; + readonly disabled?: boolean; + readonly loading?: boolean; + readonly ariaLabel?: string; +} + +export interface PageMenuProps { + readonly children: ReactNode; + readonly triggerLabel?: string; +} + +export interface PageBodyProps { + readonly children: ReactNode; +} diff --git a/packages/components/src/utils/meta/meta.json b/packages/components/src/utils/meta/meta.json index 4bbcb5319d..71c996dbd2 100644 --- a/packages/components/src/utils/meta/meta.json +++ b/packages/components/src/utils/meta/meta.json @@ -158,6 +158,19 @@ "MultiSelect", "Option", "Page", + "Page.Actions", + "Page.Body", + "Page.Header", + "Page.Intro", + "Page.Menu", + "Page.PrimaryAction", + "Page.PrimarySlot", + "Page.SecondaryAction", + "Page.SecondarySlot", + "Page.Subtitle", + "Page.TertiarySlot", + "Page.Title", + "Page.TitleMetaData", "Popover", "Popover.Arrow", "Popover.DismissButton", diff --git a/packages/site/src/content/Page/Page.props.json b/packages/site/src/content/Page/Page.props.json index 2c8b159b8f..6b7b20c3e1 100644 --- a/packages/site/src/content/Page/Page.props.json +++ b/packages/site/src/content/Page/Page.props.json @@ -11,7 +11,7 @@ "description": "Content of the page. This supports basic markdown node types\nsuch as `_italic_`, `**bold**`, and `[link name](url)`.", "name": "intro", "parent": { - "fileName": "../components/src/Page/Page.tsx", + "fileName": "packages/components/src/Page/types.ts", "name": "PageWithIntroProps" }, "required": false, @@ -24,7 +24,7 @@ "description": "Causes any markdown links in the `intro` prop to open in a new\ntab, i.e. with `target=\"_blank\"`.\n\nCan only be used if `intro` prop is also specified.\n\nDefaults to `false`.", "name": "externalIntroLinks", "parent": { - "fileName": "../components/src/Page/Page.tsx", + "fileName": "packages/components/src/Page/types.ts", "name": "PageWithIntroProps" }, "required": false, @@ -37,8 +37,8 @@ "description": "Title of the page.\n\nSupports any React node. If a string is provided, it will be rendered as an H1 heading.\nOtherwise it will be rendered as is.\n\n**Important**: If you're passing a custom element, it must include an H1-level heading within it.\nIdeally should be used here.", "name": "title", "parent": { - "fileName": "../components/src/Page/Page.tsx", - "name": "PageFoundationProps" + "fileName": "packages/components/src/Page/types.ts", + "name": "PageBaseProps" }, "required": true, "type": { @@ -50,8 +50,8 @@ "description": "TitleMetaData component to be displayed\nnext to the title. Only compatible with string titles.", "name": "titleMetaData", "parent": { - "fileName": "../components/src/Page/Page.tsx", - "name": "PageFoundationProps" + "fileName": "packages/components/src/Page/types.ts", + "name": "PageBaseProps" }, "required": false, "type": { @@ -63,8 +63,8 @@ "description": "Subtitle of the page.", "name": "subtitle", "parent": { - "fileName": "../components/src/Page/Page.tsx", - "name": "PageFoundationProps" + "fileName": "packages/components/src/Page/types.ts", + "name": "PageBaseProps" }, "required": false, "type": { @@ -78,8 +78,8 @@ "description": "Determines the width of the page.\n\nFill makes the width grow to 100%.\n\nStandard caps out at 1280px.\n\nNarrow caps out at 1024px.", "name": "width", "parent": { - "fileName": "../components/src/Page/Page.tsx", - "name": "PageFoundationProps" + "fileName": "packages/components/src/Page/types.ts", + "name": "PageBaseProps" }, "required": false, "type": { @@ -91,8 +91,8 @@ "description": "Page title primary action button settings.", "name": "primaryAction", "parent": { - "fileName": "../components/src/Page/Page.tsx", - "name": "PageFoundationProps" + "fileName": "packages/components/src/Page/types.ts", + "name": "PageBaseProps" }, "required": false, "type": { @@ -104,8 +104,8 @@ "description": "Page title secondary action button settings.", "name": "secondaryAction", "parent": { - "fileName": "../components/src/Page/Page.tsx", - "name": "PageFoundationProps" + "fileName": "packages/components/src/Page/types.ts", + "name": "PageBaseProps" }, "required": false, "type": { @@ -113,14 +113,12 @@ } }, "moreActionsMenu": { - "defaultValue": { - "value": "[]" - }, + "defaultValue": null, "description": "Page title Action menu.", "name": "moreActionsMenu", "parent": { - "fileName": "../components/src/Page/Page.tsx", - "name": "PageFoundationProps" + "fileName": "packages/components/src/Page/types.ts", + "name": "PageBaseProps" }, "required": false, "type": { @@ -128,5 +126,351 @@ } } } + }, + { + "tags": {}, + "filePath": "../components/src/Page/Page.tsx", + "description": "Groups title, subtitle, and actions. Separates Page.Actions for layout positioning.", + "displayName": "Page.Header", + "methods": [], + "props": {} + }, + { + "tags": {}, + "filePath": "../components/src/Page/Page.tsx", + "description": "Renders the page heading (H1). Extracts Page.TitleMetaData from children for layout.", + "displayName": "Page.Title", + "methods": [], + "props": {} + }, + { + "tags": {}, + "filePath": "../components/src/Page/Page.tsx", + "description": "Metadata displayed alongside the page title (e.g. status badges). Use inside Page.Title.", + "displayName": "Page.TitleMetaData", + "methods": [], + "props": {} + }, + { + "tags": {}, + "filePath": "../components/src/Page/Page.tsx", + "description": "Secondary text below the title. Always applies default Text/Emphasis styling.", + "displayName": "Page.Subtitle", + "methods": [], + "props": {} + }, + { + "tags": {}, + "filePath": "../components/src/Page/Page.tsx", + "description": "Introduction text between the header and body. Always applies default Text styling.", + "displayName": "Page.Intro", + "methods": [], + "props": {} + }, + { + "tags": {}, + "filePath": "../components/src/Page/Page.tsx", + "description": "Container for action buttons and menu. Applies responsive actionGroup layout.", + "displayName": "Page.Actions", + "methods": [], + "props": {} + }, + { + "tags": {}, + "filePath": "../components/src/Page/Page.tsx", + "description": "Positional slot for the primary action. Renders children in the primary action position.", + "displayName": "Page.PrimarySlot", + "methods": [], + "props": { + "ref": { + "defaultValue": null, + "description": "", + "name": "ref", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageSlotProps" + }, + "required": false, + "type": { + "name": "RefObject" + } + } + } + }, + { + "tags": {}, + "filePath": "../components/src/Page/Page.tsx", + "description": "Positional slot for the secondary action. Renders children in the secondary action position.", + "displayName": "Page.SecondarySlot", + "methods": [], + "props": { + "ref": { + "defaultValue": null, + "description": "", + "name": "ref", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageSlotProps" + }, + "required": false, + "type": { + "name": "RefObject" + } + } + } + }, + { + "tags": {}, + "filePath": "../components/src/Page/Page.tsx", + "description": "Positional slot for the tertiary action (typically the menu).", + "displayName": "Page.TertiarySlot", + "methods": [], + "props": { + "ref": { + "defaultValue": null, + "description": "", + "name": "ref", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageSlotProps" + }, + "required": false, + "type": { + "name": "RefObject" + } + } + } + }, + { + "tags": {}, + "filePath": "../components/src/Page/Page.tsx", + "description": "Primary action button with default styling. Use inside Page.PrimarySlot or Page.Actions.", + "displayName": "Page.PrimaryAction", + "methods": [], + "props": { + "ref": { + "defaultValue": null, + "description": "", + "name": "ref", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageActionButtonProps" + }, + "required": false, + "type": { + "name": "RefObject" + } + }, + "label": { + "defaultValue": null, + "description": "", + "name": "label", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageActionButtonProps" + }, + "required": true, + "type": { + "name": "string" + } + }, + "onClick": { + "defaultValue": null, + "description": "", + "name": "onClick", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageActionButtonProps" + }, + "required": false, + "type": { + "name": "() => void" + } + }, + "icon": { + "defaultValue": null, + "description": "", + "name": "icon", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageActionButtonProps" + }, + "required": false, + "type": { + "name": "IconNames" + } + }, + "disabled": { + "defaultValue": null, + "description": "", + "name": "disabled", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageActionButtonProps" + }, + "required": false, + "type": { + "name": "boolean" + } + }, + "loading": { + "defaultValue": null, + "description": "", + "name": "loading", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageActionButtonProps" + }, + "required": false, + "type": { + "name": "boolean" + } + }, + "ariaLabel": { + "defaultValue": null, + "description": "", + "name": "ariaLabel", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageActionButtonProps" + }, + "required": false, + "type": { + "name": "string" + } + } + } + }, + { + "tags": {}, + "filePath": "../components/src/Page/Page.tsx", + "description": "Secondary action button with default styling. Use inside Page.SecondarySlot or Page.Actions.", + "displayName": "Page.SecondaryAction", + "methods": [], + "props": { + "ref": { + "defaultValue": null, + "description": "", + "name": "ref", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageActionButtonProps" + }, + "required": false, + "type": { + "name": "RefObject" + } + }, + "label": { + "defaultValue": null, + "description": "", + "name": "label", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageActionButtonProps" + }, + "required": true, + "type": { + "name": "string" + } + }, + "onClick": { + "defaultValue": null, + "description": "", + "name": "onClick", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageActionButtonProps" + }, + "required": false, + "type": { + "name": "() => void" + } + }, + "icon": { + "defaultValue": null, + "description": "", + "name": "icon", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageActionButtonProps" + }, + "required": false, + "type": { + "name": "IconNames" + } + }, + "disabled": { + "defaultValue": null, + "description": "", + "name": "disabled", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageActionButtonProps" + }, + "required": false, + "type": { + "name": "boolean" + } + }, + "loading": { + "defaultValue": null, + "description": "", + "name": "loading", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageActionButtonProps" + }, + "required": false, + "type": { + "name": "boolean" + } + }, + "ariaLabel": { + "defaultValue": null, + "description": "", + "name": "ariaLabel", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageActionButtonProps" + }, + "required": false, + "type": { + "name": "string" + } + } + } + }, + { + "tags": {}, + "filePath": "../components/src/Page/Page.tsx", + "description": "\"More Actions\" menu with a default trigger button.\nConsumers supply Menu.Item children (in case custom routing is needed,\ne.g. wrapping Menu.Item with createLink() from TanStack Router).", + "displayName": "Page.Menu", + "methods": [], + "props": { + "triggerLabel": { + "defaultValue": { + "value": "More Actions" + }, + "description": "", + "name": "triggerLabel", + "parent": { + "fileName": "packages/components/src/Page/types.ts", + "name": "PageMenuProps" + }, + "required": false, + "type": { + "name": "string" + } + } + } + }, + { + "tags": {}, + "filePath": "../components/src/Page/Page.tsx", + "description": "Main content area of the page.", + "displayName": "Page.Body", + "methods": [], + "props": {} } ] \ No newline at end of file diff --git a/packages/site/src/content/Page/PageNotes.mdx b/packages/site/src/content/Page/PageNotes.mdx new file mode 100644 index 0000000000..cab7bb1764 --- /dev/null +++ b/packages/site/src/content/Page/PageNotes.mdx @@ -0,0 +1,242 @@ +## Composable Version (Web Only) + +Page may be invoked with a subcomponent structure enabling a declarative style, +greater customization, and composability with other components. This is +particularly useful when you need real links in the "More Actions" menu (e.g. +for client-side router integration) or custom action elements. + +``` + + + + Clients + + + + + Manage your client list + + + + + + + + + + + + Import + + + + + + + Page content here + + +``` + +#### Sub Components + +**_Page.Header (Required)_** + +Groups the title area and actions into the page header. Automatically positions +`Page.Actions` alongside the title content using the responsive titlebar layout. + +**_Page.Title (Required)_** + +Renders the page heading as an H1. Can contain `Page.TitleMetaData` as a child +for elements displayed alongside the title. + +**_Page.TitleMetaData (Optional)_** + +Metadata displayed alongside the page title, such as status badges. Use inside +`Page.Title`. + +``` + + My Page Title + + + + +``` + +**_Page.Subtitle (Optional)_** + +Secondary text below the title. Always applies the default Text/Emphasis +styling. Works with any children type including translation components like +``. + +For markdown formatting, provide `` explicitly: + +``` + + + +``` + +**_Page.Intro (Optional)_** + +Introduction text between the header and body. Always applies the default Text +styling. Works with any children type including translation components. + +For markdown formatting with links, provide `` explicitly: + +``` + + + +``` + +**_Page.Actions (Optional)_** + +Container for the action slots and menu. Applies the responsive action group +layout that stacks on small viewports and inlines on larger ones. + +**_Page.PrimarySlot / Page.SecondarySlot / Page.TertiarySlot (Optional)_** + +Positional slots that control where action elements appear. Use these to wrap +either the default action buttons or fully custom elements. + +``` +{/* Default button via slot */} + + + + +{/* Custom element via slot */} + + + +``` + +**_Page.PrimaryAction / Page.SecondaryAction (Optional)_** + +Default action buttons with opinionated styling (`fullWidth`, primary or +secondary type). Use inside their respective slots. + +**_Page.Menu (Optional)_** + +The "More Actions" menu. Provides the default trigger button (kebab icon + +label) so consumers only need to supply `Menu.Item` children. Use inside +`Page.TertiarySlot`. + +This is the primary integration point for client-side routers. For example, if +you are using TanStack Router in the consumer app, you can wrap `Menu.Item` with +TanStack Router's `createLink()` to get right-click and ctrl-click support on +menu links: + +``` +// Create once in the consumer app +const TSRMenuItem = createLink(Menu.Item); + +// Use inside Page.Menu + + + + + Export + + + +``` + +The trigger label defaults to "More Actions" and can be customized via the +`triggerLabel` prop. + +**_Page.Body (Required)_** + +Main content area of the page. Wraps children in a `Content` component. + +## Migrating from Props-based to Composable + +The props-based API remains fully supported. You only need to migrate if you +need composable actions (e.g. for client-side router links in the menu). + +| Props-based | Composable | +| -------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `title="Clients"` | `Clients` | +| `titleMetaData={}` | `` inside `Page.Title` | +| `subtitle="Text"` | `Text` | +| `intro="Markdown **text**"` | `` | +| `externalIntroLinks={true}` | Pass `externalLink` to `` directly | +| `primaryAction={{ label, onClick }}` | `` | +| `secondaryAction={{ label, onClick }}` | `` | +| `moreActionsMenu={[...]}` | `...` | + +### Example migration + +Before: + +``` + + Page content here + +``` + +After: + +``` + + + Clients + Manage your client list + + + + + + + + + Import + + + + + + + Page content here + + +``` + +## Props-based Version + +The original props-based API remains fully supported. If you don't need +composable actions, this is the simplest way to use Page: + +``` + + Page content here + +``` diff --git a/packages/site/src/content/Page/index.tsx b/packages/site/src/content/Page/index.tsx index c6505f09ad..0e66c15da1 100644 --- a/packages/site/src/content/Page/index.tsx +++ b/packages/site/src/content/Page/index.tsx @@ -1,5 +1,6 @@ import Content from "./Page.stories.mdx"; import Props from "./Page.props.json"; +import Notes from "./PageNotes.mdx"; import { ContentExport } from "../../types/content"; import { getStorybookUrl } from "../../layout/getStorybookUrl"; @@ -30,4 +31,5 @@ export default { ), }, ], + notes: () => , } as const satisfies ContentExport; diff --git a/packages/site/src/pages/visualTests/VisualTestPagePage.tsx b/packages/site/src/pages/visualTests/VisualTestPagePage.tsx index 45c8ee5bf9..34e8efa1eb 100644 --- a/packages/site/src/pages/visualTests/VisualTestPagePage.tsx +++ b/packages/site/src/pages/visualTests/VisualTestPagePage.tsx @@ -4,7 +4,9 @@ import { Glimmer, Heading, Icon, + Menu, Page, + StatusLabel, Text, } from "@jobber/components"; @@ -336,6 +338,119 @@ export const VisualTestPagePage = () => { + + {/* Composable: Basic */} + + + Composable - Basic + + + Composable page with just a title and body content. + + + + + + {/* Composable: With Actions and Menu */} + + + Composable - With Actions + + + null} /> + + + null} /> + + + + null}> + + Import + + null}> + + Archive + + + + + + + + Composable page with primary action, secondary action, and a menu. + + + + + + + {/* Composable: Subtitle and Intro */} + + + Composable - Subtitle and Intro + A subtitle with **markdown** support + + + This is an intro section that always applies the default Text styling. + + + + Composable page with subtitle and intro text between header and + body. + + + + + + + {/* Composable: All Pieces */} + + + + Composable - All Pieces + + + + + A subtitle with default styling + + + null} + /> + + + null} /> + + + + null}> + + Import + + null}> + + Settings + + + + + + + + Composable page showing all available sub-components together. + + + + + ); }; diff --git a/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-large-chromium.png b/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-large-chromium.png index 3baff89279..bd51566112 100644 Binary files a/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-large-chromium.png and b/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-large-chromium.png differ diff --git a/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-large-firefox.png b/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-large-firefox.png index 9bdafcfaec..ccfc16e894 100644 Binary files a/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-large-firefox.png and b/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-large-firefox.png differ diff --git a/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-large-webkit.png b/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-large-webkit.png index 365318b16a..386df14302 100644 Binary files a/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-large-webkit.png and b/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-large-webkit.png differ diff --git a/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-small-chromium.png b/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-small-chromium.png index b0e63970dc..f20a1faa63 100644 Binary files a/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-small-chromium.png and b/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-small-chromium.png differ diff --git a/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-small-firefox.png b/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-small-firefox.png index 5c4cf0e079..99b0c73b43 100644 Binary files a/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-small-firefox.png and b/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-small-firefox.png differ diff --git a/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-small-webkit.png b/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-small-webkit.png index 8548d2688f..1c712f25d4 100644 Binary files a/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-small-webkit.png and b/packages/site/tests/visual/page.titlebar.visual.ts-snapshots/page-titlebar-small-webkit.png differ