From 73628b1a6b941da9dda5cca5c0e454bf68080821 Mon Sep 17 00:00:00 2001 From: Daniel Dipper Date: Thu, 6 Aug 2026 15:10:40 +0100 Subject: [PATCH] feat(dialog): add dialog header component --- skills/carbon-react/components/alert.md | 2 +- skills/carbon-react/components/dialog.md | 294 +++++++++++++++++- .../__next__/components-test.pw.tsx | 96 ++++-- .../dialog-header/dialog-header.component.tsx | 188 ++++------- .../dialog-header/dialog-header.test.tsx | 293 +++++------------ .../__next__/dialog-test.stories.tsx | 85 +++-- .../__next__/dialog.component.tsx | 147 +++++---- .../__internal__/__next__/dialog.pw.tsx | 9 + .../__internal__/__next__/dialog.style.ts | 5 - .../__internal__/__next__/dialog.test.tsx | 118 ++++++- .../dialog/__internal__/__next__/index.ts | 2 + .../dialog/__internal__/__next__/utils.ts | 15 + src/components/dialog/dialog.component.tsx | 6 + src/components/dialog/dialog.mdx | 45 +++ src/components/dialog/dialog.pw.tsx | 27 +- src/components/dialog/dialog.stories.tsx | 217 ++++++++++++- src/components/dialog/dialog.test.tsx | 6 +- src/components/dialog/index.ts | 2 + 18 files changed, 1078 insertions(+), 479 deletions(-) create mode 100644 src/components/dialog/__internal__/__next__/utils.ts diff --git a/skills/carbon-react/components/alert.md b/skills/carbon-react/components/alert.md index 41c603c48a..fde89059ec 100644 --- a/skills/carbon-react/components/alert.md +++ b/skills/carbon-react/components/alert.md @@ -36,7 +36,6 @@ description: Carbon Alert component props and usage examples. | greyBackground | boolean \| undefined | No | | | | Change the background color of the content to grey | | | headerChildren | React.ReactNode | No | | | | Container for components to be displayed in the header | | | height | string \| undefined | No | | | | Allows developers to specify a specific height for the dialog. | | -| help | string \| undefined | No | | | | Adds Help tooltip to Header | | | onCancel | ((ev: React.KeyboardEvent \| KeyboardEvent \| React.MouseEvent) => void) \| undefined | No | | | | A custom close event handler | | | restoreFocusOnClose | boolean \| undefined | No | | | | Enables the automatic restoration of focus to the element that invoked the modal when the modal is closed. | | | role | string \| undefined | No | | | | The ARIA role to be applied to the Dialog container | | @@ -54,6 +53,7 @@ description: Carbon Alert component props and usage examples. | disableClose | boolean \| undefined | No | | Yes | Use `showCloseIcon={false}` instead. | | | | disableContentPadding | boolean \| undefined | No | | Yes | Use `contentPadding` instead. | | | | fullscreen | boolean \| undefined | No | | Yes | Use `size="fullscreen"` instead. | | | +| help | string \| undefined | No | | Yes | This prop no longer has any effect and will be removed in a future release. | Adds Help tooltip to Header. | | | highlightVariant | string \| undefined | No | | Yes | Use `gradientKeyLine` instead. | | | | pagesStyling | boolean \| undefined | No | | Yes | PagesStyling is now deprecated and will be removed in a future release | | | diff --git a/skills/carbon-react/components/dialog.md b/skills/carbon-react/components/dialog.md index 7ea6e43ce8..e0955d4073 100644 --- a/skills/carbon-react/components/dialog.md +++ b/skills/carbon-react/components/dialog.md @@ -34,7 +34,6 @@ description: Carbon Dialog component props and usage examples. | greyBackground | boolean \| undefined | No | | | | Change the background color of the content to grey | | | headerChildren | React.ReactNode | No | | | | Container for components to be displayed in the header | | | height | string \| undefined | No | | | | Allows developers to specify a specific height for the dialog. | | -| help | string \| undefined | No | | | | Adds Help tooltip to Header | | | onCancel | ((ev: React.KeyboardEvent \| KeyboardEvent \| React.MouseEvent) => void) \| undefined | No | | | | A custom close event handler | | | restoreFocusOnClose | boolean \| undefined | No | | | | Enables the automatic restoration of focus to the element that invoked the modal when the modal is closed. | | | role | string \| undefined | No | | | | The ARIA role to be applied to the Dialog container | | @@ -52,6 +51,7 @@ description: Carbon Dialog component props and usage examples. | disableClose | boolean \| undefined | No | | Yes | Use `showCloseIcon={false}` instead. | | | | disableContentPadding | boolean \| undefined | No | | Yes | Use `contentPadding` instead. | | | | fullscreen | boolean \| undefined | No | | Yes | Use `size="fullscreen"` instead. | | | +| help | string \| undefined | No | | Yes | This prop no longer has any effect and will be removed in a future release. | Adds Help tooltip to Header. | | | highlightVariant | string \| undefined | No | | Yes | Use `gradientKeyLine` instead. | | | | pagesStyling | boolean \| undefined | No | | Yes | PagesStyling is now deprecated and will be removed in a future release | | | @@ -1022,6 +1022,261 @@ function WithHeaderChildrenRender({ ``` +### WithStatusHeaderSubtle + +**Args** + +```tsx +{ + open: isChromatic(), + size: "medium", + } +``` + +**Render** + +```tsx +function WithStatusHeaderSubtleRender({ + onCancel, + ...args + }: Partial) { + const buttonRef = useRef(null); + const [open, setOpen] = useState(args.open || false); + + return ( + <> + + { + onCancel?.(ev); + setOpen(false); + setTimeout(() => buttonRef.current?.focus(), 0); + }} + title={ + + } + footer={} + > + {dialogContent} + + + ); + } +``` + + +### WithStatusHeaderPositive + +**Args** + +```tsx +{ + open: isChromatic(), + size: "medium", + } +``` + +**Render** + +```tsx +function WithStatusHeaderPositiveRender({ + onCancel, + ...args + }: Partial) { + const buttonRef = useRef(null); + const [open, setOpen] = useState(args.open || false); + + return ( + <> + + { + onCancel?.(ev); + setOpen(false); + setTimeout(() => buttonRef.current?.focus(), 0); + }} + title={ + + } + footer={} + > + {dialogContent} + + + ); + } +``` + + +### WithStatusHeaderNegative + +**Args** + +```tsx +{ + open: isChromatic(), + size: "medium", + } +``` + +**Render** + +```tsx +function WithStatusHeaderNegativeRender({ + onCancel, + ...args + }: Partial) { + const buttonRef = useRef(null); + const [open, setOpen] = useState(args.open || false); + + return ( + <> + + { + onCancel?.(ev); + setOpen(false); + setTimeout(() => buttonRef.current?.focus(), 0); + }} + title={ + + } + footer={} + > + {dialogContent} + + + ); + } +``` + + +### WithStatusHeaderCaution + +**Args** + +```tsx +{ + open: isChromatic(), + size: "medium", + } +``` + +**Render** + +```tsx +function WithStatusHeaderCautionRender({ + onCancel, + ...args + }: Partial) { + const buttonRef = useRef(null); + const [open, setOpen] = useState(args.open || false); + + return ( + <> + + { + onCancel?.(ev); + setOpen(false); + setTimeout(() => buttonRef.current?.focus(), 0); + }} + title={ + + } + footer={} + > + {dialogContent} + + + ); + } +``` + + +### WithStatusHeaderInfo + +**Args** + +```tsx +{ + open: isChromatic(), + size: "medium", + } +``` + +**Render** + +```tsx +function WithStatusHeaderInfoRender({ + onCancel, + ...args + }: Partial) { + const buttonRef = useRef(null); + const [open, setOpen] = useState(args.open || false); + + return ( + <> + + { + onCancel?.(ev); + setOpen(false); + setTimeout(() => buttonRef.current?.focus(), 0); + }} + title={ + + } + footer={} + > + {dialogContent} + + + ); + } +``` + + ### MDX Example 1 **Args** @@ -1105,6 +1360,41 @@ Use the `headerChildren` prop to render additional content — such as action bu +### With status header + +Use the `DialogHeader` component (exported from Dialog) to render a status-styled header with an icon before the title. This is commonly used to indicate the status or severity of the dialog content. The icon is automatically marked as `aria-hidden="true"` since it's decorative - the status should be conveyed through the title text itself for accessibility. + +`DialogHeader` automatically generates IDs for its title and subtitle elements, which the Dialog component detects and uses for proper ARIA labeling. No additional `aria-label` or `aria-labelledby` props are needed: +``` + + +### MDX Example 2 + +**Args** + +```tsx +#### Available status variants + +**Subtle** - For informational dialogs + + + +**Positive** - For success or confirmation dialogs + + + +**Negative** - For error or critical dialogs + + + +**Caution** - For warning dialogs + + + +**Info** - For general information dialogs + + + ### Gradient keyline Setting `gradientKeyLine` adds a decorative gradient keyline below the dialog header. @@ -1142,7 +1432,7 @@ To achieve this, forward a custom ref handle to the `Dialog` component using the ``` -### MDX Example 2 +### MDX Example 3 **Args** diff --git a/src/components/dialog/__internal__/__next__/components-test.pw.tsx b/src/components/dialog/__internal__/__next__/components-test.pw.tsx index ff31adbc24..39447cd07d 100644 --- a/src/components/dialog/__internal__/__next__/components-test.pw.tsx +++ b/src/components/dialog/__internal__/__next__/components-test.pw.tsx @@ -1,7 +1,10 @@ import React, { useRef, useState } from "react"; import Textbox from "../../../textbox"; import Button from "../../../button"; -import Dialog, { withDialogHeader, DialogProps } from "./dialog.component"; +import Dialog, { type DialogProps } from "./dialog.component"; +import DialogHeadingStatus, { + DialogHeadingStatusProps, +} from "./dialog-header/dialog-header.component"; export const DialogComponent = (props: Partial) => { const [isOpen, setIsOpen] = useState(true); @@ -54,41 +57,47 @@ export const DialogFullscreen = (props: Partial) => { ); }; -const DialogWithHeadingVariant = withDialogHeader(Dialog); - export const DialogWithHeadingVariantPositive = ( - props: Partial, + props: Partial, ) => { return ( - + } onCancel={() => {}} {...props} > {}} label="Textbox1" value="Textbox1" /> {}} label="Textbox2" value="Textbox2" /> {}} label="Textbox3" value="Textbox3" /> - + ); }; export const DialogWithHeadingVariantSubtle = (props: Partial) => { return ( - + } onCancel={() => {}} {...props} > {}} label="Textbox1" value="Textbox1" /> {}} label="Textbox2" value="Textbox2" /> {}} label="Textbox3" value="Textbox3" /> - + ); }; @@ -96,18 +105,22 @@ export const DialogWithHeadingVariantNegative = ( props: Partial, ) => { return ( - + } onCancel={() => {}} {...props} > {}} label="Textbox1" value="Textbox1" /> {}} label="Textbox2" value="Textbox2" /> {}} label="Textbox3" value="Textbox3" /> - + ); }; @@ -115,34 +128,59 @@ export const DialogWithHeadingVariantCaution = ( props: Partial, ) => { return ( - + } onCancel={() => {}} {...props} > {}} label="Textbox1" value="Textbox1" /> {}} label="Textbox2" value="Textbox2" /> {}} label="Textbox3" value="Textbox3" /> - + ); }; export const DialogWithHeadingVariantInfo = (props: Partial) => { return ( - + } onCancel={() => {}} {...props} > {}} label="Textbox1" value="Textbox1" /> {}} label="Textbox2" value="Textbox2" /> {}} label="Textbox3" value="Textbox3" /> - + + ); +}; + +export const DialogWithHeadingNoSubtitle = (props: Partial) => { + return ( + + } + onCancel={() => {}} + {...props} + > + {}} label="Textbox1" value="Textbox1" /> + {}} label="Textbox2" value="Textbox2" /> + {}} label="Textbox3" value="Textbox3" /> + ); }; diff --git a/src/components/dialog/__internal__/__next__/dialog-header/dialog-header.component.tsx b/src/components/dialog/__internal__/__next__/dialog-header/dialog-header.component.tsx index c82df76b68..e4646aebdf 100644 --- a/src/components/dialog/__internal__/__next__/dialog-header/dialog-header.component.tsx +++ b/src/components/dialog/__internal__/__next__/dialog-header/dialog-header.component.tsx @@ -1,5 +1,4 @@ -import React, { forwardRef, useRef } from "react"; -import { DialogProps, DialogHandle } from "../dialog.component"; +import React, { forwardRef, useRef, useContext } from "react"; import { StyledSubtitle } from "../dialog.style"; import Box from "../../../../box"; import Icon, { IconColor } from "../../../../icon"; @@ -7,6 +6,12 @@ import { IconType } from "../../../../icon/icon-type"; import Typography from "../../../../typography"; import createGuid from "../../../../../__internal__/utils/helpers/guid"; +/** @internal Context for passing IDs from Dialog to DialogHeader */ +export const DialogHeadingStatusContext = React.createContext<{ + titleId?: string; + subtitleId?: string; +} | null>(null); + /** Allowed status variants for the dialog heading icon. */ export type DialogHeadingStatus = | "subtle" @@ -15,7 +20,6 @@ export type DialogHeadingStatus = | "caution" | "info"; -/** Map each status to its icon type and colour token. */ const STATUS_CONFIG: Record< DialogHeadingStatus, { iconType: IconType; color: IconColor } @@ -42,136 +46,68 @@ const STATUS_CONFIG: Record< }, }; -// Define the extra props the HOC injects -interface WithCustomHeadingProps { - /** Custom heading renderer — receives the original title and subtitle */ - renderHeading?: ( - title: React.ReactNode, - subtitle: React.ReactNode, - ) => React.ReactNode; - /** Renders a status icon to the left of the title */ - statusIcon?: DialogHeadingStatus; +export interface DialogHeadingStatusProps { + title: React.ReactNode; + subtitle?: React.ReactNode; + status: DialogHeadingStatus; } -type EnhancedDialogProps = Omit & - WithCustomHeadingProps & { - title?: React.ReactNode; - subtitle?: React.ReactNode; - }; - -function withDialogHeader( - WrappedDialog: React.ForwardRefExoticComponent< - DialogProps & React.RefAttributes - >, -) { - const Enhanced = forwardRef( - ( - { - renderHeading, - statusIcon, - title, - subtitle, - "aria-labelledby": propAriaLabelledBy, - "aria-describedby": propAriaDescribedBy, - "aria-label": propAriaLabel, - ...rest - }, - ref, - ) => { - const statusTitleId = useRef(createGuid()).current; - const statusSubtitleId = useRef(createGuid()).current; - - let resolvedTitle: React.ReactNode = title; - let passSubtitle = true; - let ariaLabelledBy: string | undefined = propAriaLabelledBy; - let ariaDescribedBy: string | undefined = propAriaDescribedBy; - let ariaLabel: string | undefined = propAriaLabel; - - if (renderHeading) { - resolvedTitle = renderHeading(title, subtitle); - passSubtitle = false; +interface DialogHeadingStatusComponent + extends React.ForwardRefExoticComponent< + DialogHeadingStatusProps & React.RefAttributes + > { + $$carbonDialogHeadingStatus?: boolean; +} - // istanbul ignore next: This is a dev-time warning to encourage accessibility best practices. - if (!propAriaLabelledBy && !propAriaLabel) { - // eslint-disable-next-line no-console - console.warn( - "Dialog withDialogHeader: When using `renderHeading`, you must provide " + - "`aria-labelledby` or `aria-label` so the dialog has an accessible name.", - ); - } - } else if (statusIcon) { - const { iconType, color } = STATUS_CONFIG[statusIcon]; +const DialogHeadingStatus: DialogHeadingStatusComponent = forwardRef< + HTMLDivElement, + DialogHeadingStatusProps +>(({ title, subtitle, status }, ref) => { + const { iconType, color } = STATUS_CONFIG[status]; + const context = useContext(DialogHeadingStatusContext); - resolvedTitle = ( - - - - {title} - - {subtitle && ( - - {subtitle} - - )} - - ); - passSubtitle = false; + // Always call hooks unconditionally at the top level + const generatedTitleId = useRef(context?.titleId || createGuid()).current; + const generatedSubtitleId = useRef(createGuid()).current; - // Point aria-labelledby at the id we generated (only when consumer didn't provide one) - ariaLabelledBy ??= statusTitleId; - // Also set aria-label as a belt-and-suspenders fallback - // (aria-labelledby takes precedence when both are present, - // but aria-label alone satisfies axe if the id ref fails) - // istanbul ignore else - if (typeof title === "string") { - ariaLabel = ariaLabel ?? title; - } - if (subtitle) { - ariaDescribedBy ??= statusSubtitleId; - } - } + // Use context IDs if available, otherwise use generated ones + const titleId = context?.titleId || generatedTitleId; + const subtitleId = context?.subtitleId || generatedSubtitleId; - return ( - - ); - }, + return ( + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + ); +}); - Enhanced.displayName = `withDialogHeader(${ - WrappedDialog.displayName || WrappedDialog.name || "Component" - })`; +DialogHeadingStatus.displayName = "DialogHeadingStatus"; - return Enhanced; -} +// Static marker to identify this component even when wrapped in memo/styled-components +DialogHeadingStatus.$$carbonDialogHeadingStatus = true; -export default withDialogHeader; -export type { EnhancedDialogProps }; +export default DialogHeadingStatus; diff --git a/src/components/dialog/__internal__/__next__/dialog-header/dialog-header.test.tsx b/src/components/dialog/__internal__/__next__/dialog-header/dialog-header.test.tsx index 34ac6ce4d9..042de3e043 100644 --- a/src/components/dialog/__internal__/__next__/dialog-header/dialog-header.test.tsx +++ b/src/components/dialog/__internal__/__next__/dialog-header/dialog-header.test.tsx @@ -1,10 +1,8 @@ -import React, { createRef, forwardRef } from "react"; +import React, { createRef } from "react"; import { render, screen, within } from "@testing-library/react"; -import Dialog, { DialogHandle, DialogProps } from "../dialog.component"; -import withDialogHeader from "./dialog-header.component"; - -const DialogWithHeadingVariant = withDialogHeader(Dialog); +import Dialog from "../dialog.component"; +import DialogHeadingStatus from "./dialog-header.component"; beforeEach(() => { jest.useFakeTimers(); @@ -15,227 +13,108 @@ afterEach(() => { jest.useRealTimers(); }); -describe("withDialogHeader", () => { - describe("without renderHeading", () => { - test("renders the default title as a heading", () => { - render(); - - const dialog = screen.getByRole("dialog", { name: /Default Title/i }); - const heading = within(dialog).getByRole("heading", { - level: 1, - name: /Default Title/i, - }); - - expect(heading).toBeVisible(); - }); - - test("renders the default subtitle", () => { +describe("DialogHeader", () => { + test.each([ + ["subtle", "info"], + ["positive", "tick_circle"], + ["negative", "error"], + ["caution", "warning"], + ["info", "info"], + ] as const)( + "renders the %s status icon with the correct icon type", + (status, expectedIconType) => { render( - , ); - const dialog = screen.getByRole("dialog", { - description: /My Subtitle/i, - }); - - expect(dialog).toHaveTextContent("My Subtitle"); - }); - }); + const statusHeading = screen.getByTestId("status-heading"); + expect(statusHeading).toBeVisible(); - describe("with renderHeading", () => { - test("calls renderHeading with the title and subtitle", () => { - const renderHeading = jest.fn( - (title: React.ReactNode, subtitle: React.ReactNode) => ( -
-

{title}

-

{subtitle}

-
- ), - ); + const icon = within(statusHeading).getByTestId("icon"); + expect(icon).toHaveAttribute("type", expectedIconType); + }, + ); + + test("renders the title text inside the status heading", () => { + render( + , + ); - render( - , - ); + const statusHeading = screen.getByTestId("status-heading"); + expect( + within(statusHeading).getByRole("heading", { + level: 1, + name: /Dialog title with positive icon/i, + }), + ).toBeVisible(); + }); - expect(renderHeading).toHaveBeenCalledWith( - "Custom Title", - "Custom Subtitle", - ); - }); + test("renders the subtitle inside the status heading", () => { + render( + , + ); - test("renders the custom heading output", () => { - render( - ( -
-

{title}

-

{subtitle}

-
- )} - />, - ); + const statusHeading = screen.getByTestId("status-heading"); + expect(within(statusHeading).getByText("Subheading")).toBeVisible(); + }); - const customHeading = screen.getByTestId("custom-heading"); - expect(customHeading).toBeVisible(); - expect( - within(customHeading).getByRole("heading", { - level: 1, - name: /Custom Title/i, - }), - ).toBeVisible(); - expect(customHeading).toHaveTextContent("Custom Subtitle"); - }); - - test("does not render subtitle separately when renderHeading is provided", () => { - render( -

{title}

} - />, - ); + test("renders without subtitle when subtitle is not provided", () => { + render(); - expect( - screen.queryByTestId("subtitle") ?? - screen.queryByText("Subtitle", { - selector: '[data-element="subtitle"]', - }), - ).not.toBeInTheDocument(); - }); + const statusHeading = screen.getByTestId("status-heading"); + expect(statusHeading).toBeVisible(); + expect( + within(statusHeading).queryByTestId("subtitle"), + ).not.toBeInTheDocument(); }); - describe("ref forwarding", () => { - test("forwards the ref to the underlying Dialog", () => { - const ref = createRef(); + test("forwards ref to the container element", () => { + const ref = createRef(); - render(); + render(); - expect(ref.current).not.toBeNull(); - expect(ref.current).toHaveProperty("focus"); - }); + expect(ref.current).not.toBeNull(); + expect(ref.current?.tagName).toBe("DIV"); }); - describe("with statusIcon", () => { - test.each([ - ["subtle", "info"], - ["positive", "tick_circle"], - ["negative", "error"], - ["caution", "warning"], - ["info", "info"], - ] as const)( - "renders the %s status icon with the correct icon type", - (status, expectedIconType) => { - render( - , - ); - - const statusHeading = screen.getByTestId("status-heading"); - expect(statusHeading).toBeVisible(); - - const icon = within(statusHeading).getByTestId("icon"); - expect(icon).toHaveAttribute("type", expectedIconType); - }, + test("works when passed as title prop to Dialog", () => { + render( + + } + />, ); - test("renders the title text inside the status heading", () => { - render( - , - ); - - const statusHeading = screen.getByTestId("status-heading"); - expect( - within(statusHeading).getByRole("heading", { - level: 1, - name: /Dialog title with positive icon/i, - }), - ).toBeVisible(); - }); - - test("renders the subtitle inside the status heading", () => { - render( - , - ); - - const statusHeading = screen.getByTestId("status-heading"); - expect(within(statusHeading).getByText("Subheading")).toBeVisible(); - }); - - test("does not render subtitle separately from the status heading", () => { - render( - , - ); - - // Subtitle should only appear inside the status heading, not as a - // separate element rendered by the base Dialog - const subtitleElements = screen.getAllByText("Subheading"); - expect(subtitleElements).toHaveLength(1); - - const statusHeading = screen.getByTestId("status-heading"); - expect(within(statusHeading).getByText("Subheading")).toBeVisible(); - }); - - test("renders without subtitle when subtitle is not provided", () => { - render( - , - ); - - const statusHeading = screen.getByTestId("status-heading"); - expect(statusHeading).toBeVisible(); - expect( - within(statusHeading).queryByTestId("subtitle"), - ).not.toBeInTheDocument(); - }); + const dialog = screen.getByRole("dialog"); + const statusHeading = within(dialog).getByTestId("status-heading"); + expect(statusHeading).toBeVisible(); + expect( + within(statusHeading).getByRole("heading", { + level: 1, + name: /Dialog with status/i, + }), + ).toBeVisible(); + expect(within(statusHeading).getByText("Subtitle text")).toBeVisible(); }); - describe("displayName", () => { - test("sets the correct displayName", () => { - expect(DialogWithHeadingVariant.displayName).toBe( - "withDialogHeader(Dialog)", - ); - }); - - test("falls back to 'Component' when displayName is not set", () => { - const AnonymousDialog = forwardRef( - (props, ref) => , - ); - delete (AnonymousDialog as unknown as Record) - .displayName; - - const Wrapped = withDialogHeader(AnonymousDialog as typeof Dialog); - - expect(Wrapped.displayName).toBe("withDialogHeader(Component)"); - }); + test("displayName is set correctly", () => { + expect(DialogHeadingStatus.displayName).toBe("DialogHeadingStatus"); }); }); diff --git a/src/components/dialog/__internal__/__next__/dialog-test.stories.tsx b/src/components/dialog/__internal__/__next__/dialog-test.stories.tsx index 8af2fdc002..b7af315682 100644 --- a/src/components/dialog/__internal__/__next__/dialog-test.stories.tsx +++ b/src/components/dialog/__internal__/__next__/dialog-test.stories.tsx @@ -8,9 +8,8 @@ import Button from "../../../button/__next__/"; import Typography from "../../../typography"; import Textbox from "../../../textbox"; -import Dialog, { withDialogHeader } from "./dialog.component"; - -const DialogWithHeadingVariant = withDialogHeader(Dialog); +import Dialog from "./dialog.component"; +import DialogHeader from "./dialog-header/dialog-header.component"; const meta: Meta = { title: "Dialog/Test", @@ -124,82 +123,102 @@ export const SizeFullScreen: Story = { ), }; -export const HeadingSubtle: StoryObj = { +export const HeadingSubtle: StoryObj = { name: "Heading Subtle", render: () => ( - + } onCancel={() => {}} footer={} > {dialogContent} - + ), }; -export const HeadingPositive: StoryObj = { +export const HeadingPositive: StoryObj = { name: "Heading Positive", render: () => ( - + } onCancel={() => {}} footer={} > {dialogContent} - + ), }; -export const HeadingNegative: StoryObj = { +export const HeadingNegative: StoryObj = { name: "Heading Negative", render: () => ( - + } onCancel={() => {}} footer={} > {dialogContent} - + ), }; -export const HeadingCaution: StoryObj = { +export const HeadingCaution: StoryObj = { name: "Heading Caution", render: () => ( - + } onCancel={() => {}} footer={} > {dialogContent} - + ), }; -export const HeadingInfo: StoryObj = { +export const HeadingInfo: StoryObj = { name: "Heading Info", render: () => ( - + } onCancel={() => {}} footer={} > {dialogContent} - + ), }; diff --git a/src/components/dialog/__internal__/__next__/dialog.component.tsx b/src/components/dialog/__internal__/__next__/dialog.component.tsx index c5bb018339..13172d9e30 100644 --- a/src/components/dialog/__internal__/__next__/dialog.component.tsx +++ b/src/components/dialog/__internal__/__next__/dialog.component.tsx @@ -14,13 +14,17 @@ import { StyledSubtitle, } from "./dialog.style"; -import { StyledHeaderHelp } from "../../../heading/heading.style"; +import DialogHeadingStatus, { + DialogHeadingStatusContext, +} from "./dialog-header/dialog-header.component"; + +import Button from "../../../button/__next__"; import Icon from "../../../icon"; import Typography from "../../../typography"; import Modal, { ModalProps } from "../../../../__internal__/modal"; +import FullScreenHeading from "../../../../__internal__/full-screen-heading"; import FocusTrap from "../../../../__internal__/focus-trap"; -import FullScreenHeading from "../../../../__internal__/full-screen-heading"; import createGuid from "../../../../__internal__/utils/helpers/guid"; import tagComponent, { TagProps, @@ -28,9 +32,9 @@ import tagComponent, { import useLocale from "../../../../hooks/__internal__/useLocale"; import useModalAria from "../../../../hooks/__internal__/useModalAria/useModalAria"; -import Button from "../../../button/__next__"; import { Size, ContentPaddingInterface } from "./dialog.config"; +import isDialogHeadingStatusComponent from "./utils"; export type { Size, ContentPaddingInterface }; @@ -92,7 +96,10 @@ export interface DialogProps extends ModalProps, TagProps { headerChildren?: React.ReactNode; /** Allows developers to specify a specific height for the dialog. */ height?: string; - /** Adds Help tooltip to Header */ + /** + * Adds Help tooltip to Header. + * @deprecated This prop no longer has any effect and will be removed in a future release. + */ help?: string; /** Adds a gradient keyline to the dialog header */ gradientKeyLine?: boolean; @@ -209,6 +216,24 @@ export const Dialog = forwardRef( [], ); + // Check if title is a DialogHeader component + const isDialogHeader = React.useMemo(() => { + if (!title || !React.isValidElement(title)) { + return false; + } + + // Check direct type match + if (title.type === DialogHeadingStatus) { + return true; + } + + return isDialogHeadingStatusComponent(title.type); + }, [title]); + + const isValidDialogHeader = + isDialogHeader && + React.isValidElement<{ subtitle?: React.ReactNode }>(title); + const closeIcon = showCloseIcon && onCancel && ( + { + onCancel?.(ev); + setOpen(false); + setTimeout(() => buttonRef.current?.focus(), 0); + }} + title={ + + } + footer={} + > + {dialogContent} + + + ); + }, +}; + +export const WithStatusHeaderPositive: Story = { + name: "With Status Header - Positive", + args: { + open: isChromatic(), + size: "medium", + }, + parameters: { chromatic: { disableSnapshot: true } }, + render: function WithStatusHeaderPositiveRender({ + onCancel, + ...args + }: Partial) { + const buttonRef = useRef(null); + const [open, setOpen] = useState(args.open || false); + + return ( + <> + + { + onCancel?.(ev); + setOpen(false); + setTimeout(() => buttonRef.current?.focus(), 0); + }} + title={ + + } + footer={} + > + {dialogContent} + + + ); + }, +}; + +export const WithStatusHeaderNegative: Story = { + name: "With Status Header - Negative", + args: { + open: isChromatic(), + size: "medium", + }, + parameters: { chromatic: { disableSnapshot: true } }, + render: function WithStatusHeaderNegativeRender({ + onCancel, + ...args + }: Partial) { + const buttonRef = useRef(null); + const [open, setOpen] = useState(args.open || false); + + return ( + <> + + { + onCancel?.(ev); + setOpen(false); + setTimeout(() => buttonRef.current?.focus(), 0); + }} + title={ + + } + footer={} + > + {dialogContent} + + + ); + }, +}; + +export const WithStatusHeaderCaution: Story = { + name: "With Status Header - Caution", + args: { + open: isChromatic(), + size: "medium", + }, + parameters: { chromatic: { disableSnapshot: true } }, + render: function WithStatusHeaderCautionRender({ + onCancel, + ...args + }: Partial) { + const buttonRef = useRef(null); + const [open, setOpen] = useState(args.open || false); + + return ( + <> + + { + onCancel?.(ev); + setOpen(false); + setTimeout(() => buttonRef.current?.focus(), 0); + }} + title={ + + } + footer={} + > + {dialogContent} + + + ); + }, +}; + +export const WithStatusHeaderInfo: Story = { + name: "With Status Header - Info", + args: { + open: isChromatic(), + size: "medium", + }, + parameters: { chromatic: { disableSnapshot: true } }, + render: function WithStatusHeaderInfoRender({ + onCancel, + ...args + }: Partial) { + const buttonRef = useRef(null); + const [open, setOpen] = useState(args.open || false); + + return ( + <> + + { + onCancel?.(ev); + setOpen(false); + setTimeout(() => buttonRef.current?.focus(), 0); + }} + title={ + + } + footer={} + > + {dialogContent} + + + ); + }, +}; diff --git a/src/components/dialog/dialog.test.tsx b/src/components/dialog/dialog.test.tsx index cfcb831e1d..e359e0eea1 100644 --- a/src/components/dialog/dialog.test.tsx +++ b/src/components/dialog/dialog.test.tsx @@ -136,12 +136,12 @@ describe("Modal Dialog", () => { ); }); - test("help icon is displayed when help prop is passed", () => { + test("help icon is not displayed when deprecated help prop is passed", () => { render(); - const help = screen.getByLabelText("help"); + const help = screen.queryByLabelText("help"); - expect(help).toBeVisible(); + expect(help).not.toBeInTheDocument(); }); test("close button is displayed when onCancel prop is passed", () => { diff --git a/src/components/dialog/index.ts b/src/components/dialog/index.ts index b9554b3534..847549a31d 100644 --- a/src/components/dialog/index.ts +++ b/src/components/dialog/index.ts @@ -1,3 +1,5 @@ export { default } from "./dialog.component"; export type { DialogProps, DialogHandle } from "./dialog.component"; export type { DialogSizes } from "./dialog.config"; +export { DialogHeader, DialogHeadingStatus } from "./dialog.component"; +export type { DialogHeadingStatusProps } from "./dialog.component";