From 0a77ba1ca53b8c505d32ee5575b28ef8c853f89f Mon Sep 17 00:00:00 2001 From: alex leon Date: Fri, 29 May 2026 13:30:24 +0200 Subject: [PATCH 01/36] Initial pass of dialog component --- .../src/components/Dialog/dialog.recipe.ts | 224 ++++++++++++ .../src/components/Dialog/dialog.stories.tsx | 325 ++++++++++++++++++ .../src/components/Dialog/dialog.tsx | 211 ++++++++++++ 3 files changed, 760 insertions(+) create mode 100644 libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts create mode 100644 libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx create mode 100644 libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts new file mode 100644 index 00000000000..b81f061f4d0 --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts @@ -0,0 +1,224 @@ +import { dialogAnatomy } from "@ark-ui/react/anatomy"; + +import { sva } from "@hashintel/ds-helpers/css"; + +export const styles = sva({ + className: "dialog", + slots: dialogAnatomy + .extendWith( + "header", + "titleRow", + "titleIcon", + "headerActions", + "body", + "footer", + "footerActions", + "footerSecondaryActions", + "closeButton", + "loadingOverlay", + ) + .keys(), + base: { + backdrop: { + background: "black.a60", + position: "fixed", + inset: "0", + width: "[100dvw]", + height: "[100dvh]", + zIndex: "zIndex.modal", + _open: { + animationName: "fadeIn", + animationDuration: "normal", + }, + _closed: { + animationName: "fadeOut", + animationDuration: "fast", + }, + }, + positioner: { + display: "flex", + alignItems: "center", + justifyContent: "center", + position: "fixed", + inset: "0", + width: "[100dvw]", + height: "[100dvh]", + overflow: "auto", + overscrollBehaviorY: "none", + zIndex: "zIndex.modal", + padding: "4", + }, + content: { + position: "relative", + display: "flex", + flexDirection: "column", + background: "white", + borderRadius: "lg", + boxShadow: "[0 10px 40px rgba(0, 0, 0, 0.2)]", + width: "[100%]", + maxHeight: "[calc(100dvh - 2rem)]", + outline: "none", + overflow: "hidden", + _open: { + animationName: "fadeIn", + animationDuration: "normal", + }, + _closed: { + animationName: "fadeOut", + animationDuration: "fast", + }, + }, + header: { + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + gap: "3", + flex: "[0 0 auto]", + }, + titleRow: { + display: "flex", + alignItems: "center", + gap: "2", + flex: "[1 1 auto]", + minWidth: "0", + }, + titleIcon: { + color: "fg.muted", + flex: "[0 0 auto]", + }, + title: { + fontWeight: "semibold", + textStyle: "lg", + color: "fg.body", + }, + description: { + color: "fg.muted", + textStyle: "sm", + marginTop: "1", + }, + headerActions: { + display: "flex", + alignItems: "center", + gap: "2", + flex: "[0 0 auto]", + }, + body: { + display: "flex", + flexDirection: "column", + flex: "[1 1 auto]", + minHeight: "0", + overflow: "auto", + color: "fg.body", + textStyle: "sm", + }, + footer: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "3", + flex: "[0 0 auto]", + borderTop: "[1px solid]", + borderColor: "neutral.a50", + }, + footerActions: { + display: "flex", + alignItems: "center", + gap: "2", + marginLeft: "auto", + }, + footerSecondaryActions: { + display: "flex", + alignItems: "center", + gap: "2", + }, + closeButton: { + position: "absolute", + top: "3", + right: "3", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: "7", + height: "7", + padding: "0", + borderRadius: "md", + background: "[transparent]", + border: "none", + cursor: "pointer", + color: "fg.muted", + transition: "[background 0.15s ease, color 0.15s ease]", + "&:hover": { + background: "neutral.a20", + color: "fg.body", + }, + "&:focus-visible": { + outline: "[2px solid]", + outlineColor: "neutral.s30", + outlineOffset: "[2px]", + }, + }, + loadingOverlay: { + position: "absolute", + inset: "0", + display: "flex", + alignItems: "center", + justifyContent: "center", + background: "[rgba(255, 255, 255, 0.7)]", + zIndex: "1", + }, + }, + variants: { + size: { + xs: { content: { maxWidth: "[20rem]" } }, + sm: { content: { maxWidth: "[24rem]" } }, + md: { content: { maxWidth: "[32rem]" } }, + lg: { content: { maxWidth: "[42rem]" } }, + xl: { content: { maxWidth: "[56rem]" } }, + fullScreen: { + positioner: { padding: "0" }, + content: { + maxWidth: "[100dvw]", + width: "[100dvw]", + height: "[100dvh]", + maxHeight: "[100dvh]", + borderRadius: "[0]", + }, + }, + }, + withPadding: { + true: { + header: { padding: "5" }, + body: { paddingX: "5", paddingY: "4" }, + footer: { padding: "4" }, + }, + false: { + header: { padding: "5", paddingBottom: "0" }, + body: { padding: "0" }, + footer: { padding: "4" }, + }, + }, + headerless: { + true: { + body: { paddingTop: "5" }, + }, + }, + footerless: { + true: { + footer: { display: "none" }, + }, + }, + }, + compoundVariants: [ + { + withPadding: false, + headerless: true, + css: { + body: { paddingTop: "0" }, + }, + }, + ], + defaultVariants: { + size: "md", + withPadding: true, + }, +}); diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx new file mode 100644 index 00000000000..47369fcc870 --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx @@ -0,0 +1,325 @@ +import { useState } from "react"; + +import { css } from "@hashintel/ds-helpers/css"; + +import { Button } from "../Button/button"; +import { Icon } from "../Icon/icon"; +import { Dialog, type DialogProps, type DialogSize } from "./dialog"; + +import type { Story, StoryDefault } from "@ladle/react"; + +export default { + title: "Components/Dialog", +} satisfies StoryDefault; + +const sampleBody = ( +

+ The body of the dialog can contain any content you like — forms, + descriptions, lists, or rich text. It scrolls independently of the header + and footer. +

+); + +const longBody = ( +
+ {Array.from({ length: 6 }).map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key +

+ Paragraph {index + 1}. Lorem ipsum dolor sit amet, consectetur + adipiscing elit. Donec efficitur, nisl sed eleifend dictum, ipsum nisi + rhoncus odio, et fringilla justo lectus ac neque. +

+ ))} +
+); + +type ExampleProps = { + buttonLabel: string; + dialogProps: (close: () => void) => DialogProps; +}; + +const DialogExample = ({ buttonLabel, dialogProps }: ExampleProps) => { + const [open, setOpen] = useState(false); + const close = () => setOpen(false); + + return ( + <> + + {open ? : null} + + ); +}; + +const stackStyles = css({ + display: "flex", + flexWrap: "wrap", + gap: "3", + alignItems: "flex-start", +}); + +export const Examples: Story = () => ( +
+ ({ + title: "Account settings", + children: sampleBody, + })} + /> + + ({ + title: "Settings", + titleIconName: "gear", + children: sampleBody, + })} + /> + + ({ + title: "Save changes", + children:

Do you want to save your changes before closing?

, + footerActions: ( + <> + + + + ), + })} + /> + + ({ + title: "Edit workspace", + titleIconName: "gear", + description: "Update the details for your workspace.", + actions: ( + + ), + footerSecondaryActions: ( + + ), + })} + /> + + ({ + header: ( +
+
+ +
+
+
+ Custom header layout +
+
+ Built from arbitrary content. +
+
+
+ ), + children: sampleBody, + })} + /> + + ({ + title: "Custom footer", + children: sampleBody, + footer: ( +
+ + + All changes are saved automatically. + + +
+ ), + })} + /> + + ({ + title: "Edit workspace", + titleIconName: "gear", + description: "Body content controls its own padding.", + withPadding: false, + children: ( +
+

+ This body container has zero padding from the dialog, so it spans + edge-to-edge. The content within decides its own layout. +

+
+ ), + footerActions: ( + + ), + footerSecondaryActions: ( + + ), + })} + /> +
+); + +const sizes = [ + "xs", + "sm", + "md", + "lg", + "xl", + "fullScreen", +] as const satisfies readonly DialogSize[]; + +const buildKitchenSink = ( + size: DialogSize, + close: () => void, + options?: { loading?: boolean }, +): DialogProps => ({ + size, + loading: options?.loading, + title: `Kitchen sink (${size})`, + titleIconName: "gear", + description: "All the bells and whistles, sized for this width.", + actions: ( + + ), + footerSecondaryActions: ( + + ), +}); + +export const Sizes: Story = () => ( +
+ {sizes.map((size) => ( +
+
+ {size} +
+ buildKitchenSink(size, close)} + /> + + buildKitchenSink(size, close, { loading: true }) + } + /> +
+ ))} +
+); + +export const DisableDefaultClose: Story = () => ( + ({ + title: "No default close button", + titleIconName: "info", + description: + "The X button in the corner is hidden — close via the footer or by pressing escape.", + disableDefaultClose: true, + children: sampleBody, + footerActions: ( + + ), + })} + /> +); diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx new file mode 100644 index 00000000000..5669b5c063b --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { Dialog as ArkDialog } from "@ark-ui/react/dialog"; +import { Portal } from "@ark-ui/react/portal"; + +import { cx } from "@hashintel/ds-helpers/css"; + +import { usePortalContainerRef } from "../../util/portal-container-context"; +import { Icon, type IconName } from "../Icon/icon"; +import { LoadingSpinner } from "../Loading/loading-spinner"; +import { styles } from "./dialog.recipe"; + +import type { RequireOneOrNone } from "type-fest"; + +export type DialogSize = "xs" | "sm" | "md" | "lg" | "xl" | "fullScreen"; + +type SharedDialogProps = { + className?: string; + size?: DialogSize; + children: React.ReactNode; + disableDefaultClose?: boolean; + loading?: boolean; + onClose?: () => void; + /** Turn padding on/off. Used when the dialog content controls padding itself. defaults to true */ + withPadding?: boolean; + allowBodyScroll?: boolean; + initialFocusRef?: React.RefObject; + returnFocusRef?: React.RefObject; +}; + +type StructuredHeader = { + title?: React.ReactNode; + description?: React.ReactNode; + titleIconName?: IconName; + actions?: React.ReactNode; +}; + +type CustomHeader = { + header?: React.ReactNode; +}; + +type FooterChoice = RequireOneOrNone< + | { footer?: React.ReactNode } + | { + footerActions?: React.ReactNode; + footerSecondaryActions?: React.ReactNode; + } +>; + +export type DialogProps = SharedDialogProps & + (StructuredHeader | CustomHeader) & + FooterChoice & + React.AriaAttributes; + +const ariaAttributeKeys = new Set([ + "aria-label", + "aria-labelledby", + "aria-describedby", + "aria-modal", +]); + +export const Dialog = (props: DialogProps) => { + const { + className, + size = "md", + children, + disableDefaultClose, + loading, + onClose, + withPadding = true, + allowBodyScroll, + initialFocusRef, + returnFocusRef, + } = props; + + const portalContainerRef = usePortalContainerRef(); + + const header = "header" in props ? props.header : undefined; + const title = "title" in props ? props.title : undefined; + const description = "description" in props ? props.description : undefined; + const titleIconName = + "titleIconName" in props ? props.titleIconName : undefined; + const actions = "actions" in props ? props.actions : undefined; + + const footer = "footer" in props ? props.footer : undefined; + const footerActions = + "footerActions" in props ? props.footerActions : undefined; + const footerSecondaryActions = + "footerSecondaryActions" in props + ? props.footerSecondaryActions + : undefined; + + const hasStructuredHeader = + title !== undefined || + description !== undefined || + titleIconName !== undefined || + actions !== undefined; + const hasHeader = header !== undefined || hasStructuredHeader; + + const hasStructuredFooter = + footerActions !== undefined || footerSecondaryActions !== undefined; + const hasFooter = footer !== undefined || hasStructuredFooter; + + const ariaProps: React.AriaAttributes = {}; + const propsRecord = props as unknown as Record; + for (const key of Object.keys(propsRecord)) { + if (ariaAttributeKeys.has(key)) { + (ariaProps as Record)[key] = propsRecord[key]; + } + } + + const classes = styles({ + size, + withPadding, + headerless: !hasHeader, + footerless: !hasFooter, + }); + + return ( + { + if (!event.open) { + onClose?.(); + } + }} + initialFocusEl={ + initialFocusRef ? () => initialFocusRef.current : undefined + } + finalFocusEl={returnFocusRef ? () => returnFocusRef.current : undefined} + > + + + + + {hasHeader ? ( +
+ {header ?? ( + <> +
+ {titleIconName ? ( + + ) : null} +
+ {title !== undefined ? ( + + {title} + + ) : null} + {description !== undefined ? ( + + {description} + + ) : null} +
+
+ {actions ? ( +
{actions}
+ ) : null} + + )} +
+ ) : null} + +
{children}
+ + {hasFooter ? ( +
+ {footer ?? ( + <> +
+ {footerSecondaryActions} +
+
{footerActions}
+ + )} +
+ ) : null} + + {disableDefaultClose ? null : ( + + + + )} + + {loading ? ( +
+ +
+ ) : null} +
+
+
+
+ ); +}; From 33a0cba98300c0394551ab2ed035e9c56fbf359f Mon Sep 17 00:00:00 2001 From: alex leon Date: Sat, 30 May 2026 19:07:25 +0200 Subject: [PATCH 02/36] Adjust dialog props --- .../src/components/Dialog/dialog.stories.tsx | 6 +- .../src/components/Dialog/dialog.tsx | 116 +++++++----------- 2 files changed, 45 insertions(+), 77 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx index 47369fcc870..713777aca1e 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx @@ -4,7 +4,7 @@ import { css } from "@hashintel/ds-helpers/css"; import { Button } from "../Button/button"; import { Icon } from "../Icon/icon"; -import { Dialog, type DialogProps, type DialogSize } from "./dialog"; +import { Dialog, type DialogSize } from "./dialog"; import type { Story, StoryDefault } from "@ladle/react"; @@ -35,7 +35,7 @@ const longBody = ( type ExampleProps = { buttonLabel: string; - dialogProps: (close: () => void) => DialogProps; + dialogProps: (close: () => void) => React.ComponentProps; }; const DialogExample = ({ buttonLabel, dialogProps }: ExampleProps) => { @@ -247,7 +247,7 @@ const buildKitchenSink = ( size: DialogSize, close: () => void, options?: { loading?: boolean }, -): DialogProps => ({ +): React.ComponentProps => ({ size, loading: options?.loading, title: `Kitchen sink (${size})`, diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx index 5669b5c063b..b64ae122a42 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Dialog as ArkDialog } from "@ark-ui/react/dialog"; import { Portal } from "@ark-ui/react/portal"; @@ -10,11 +8,31 @@ import { Icon, type IconName } from "../Icon/icon"; import { LoadingSpinner } from "../Loading/loading-spinner"; import { styles } from "./dialog.recipe"; -import type { RequireOneOrNone } from "type-fest"; +import type { ExclusifyUnion } from "type-fest"; export type DialogSize = "xs" | "sm" | "md" | "lg" | "xl" | "fullScreen"; -type SharedDialogProps = { +export const Dialog = ({ + className, + size = "md", + children, + disableDefaultClose, + loading, + onClose, + withPadding = true, + allowBodyScroll, + initialFocusRef, + returnFocusRef, + header, + title, + description, + titleIconName, + actions, + footer, + footerActions, + footerSecondaryActions, + ...ariaAttributes +}: { className?: string; size?: DialogSize; children: React.ReactNode; @@ -23,73 +41,31 @@ type SharedDialogProps = { onClose?: () => void; /** Turn padding on/off. Used when the dialog content controls padding itself. defaults to true */ withPadding?: boolean; + /** Allow the root document/html container to scroll while the dialog is open */ allowBodyScroll?: boolean; initialFocusRef?: React.RefObject; returnFocusRef?: React.RefObject; -}; - -type StructuredHeader = { - title?: React.ReactNode; - description?: React.ReactNode; - titleIconName?: IconName; - actions?: React.ReactNode; -}; - -type CustomHeader = { - header?: React.ReactNode; -}; - -type FooterChoice = RequireOneOrNone< - | { footer?: React.ReactNode } +} & ExclusifyUnion< | { - footerActions?: React.ReactNode; - footerSecondaryActions?: React.ReactNode; + title?: React.ReactNode; + description?: React.ReactNode; + titleIconName?: IconName; + actions?: React.ReactNode; } ->; - -export type DialogProps = SharedDialogProps & - (StructuredHeader | CustomHeader) & - FooterChoice & - React.AriaAttributes; - -const ariaAttributeKeys = new Set([ - "aria-label", - "aria-labelledby", - "aria-describedby", - "aria-modal", -]); - -export const Dialog = (props: DialogProps) => { - const { - className, - size = "md", - children, - disableDefaultClose, - loading, - onClose, - withPadding = true, - allowBodyScroll, - initialFocusRef, - returnFocusRef, - } = props; - + | { + header?: React.ReactNode; + } +> & + ExclusifyUnion< + | { footer?: React.ReactNode } + | { + footerActions?: React.ReactNode; + footerSecondaryActions?: React.ReactNode; + } + > & + React.AriaAttributes) => { const portalContainerRef = usePortalContainerRef(); - const header = "header" in props ? props.header : undefined; - const title = "title" in props ? props.title : undefined; - const description = "description" in props ? props.description : undefined; - const titleIconName = - "titleIconName" in props ? props.titleIconName : undefined; - const actions = "actions" in props ? props.actions : undefined; - - const footer = "footer" in props ? props.footer : undefined; - const footerActions = - "footerActions" in props ? props.footerActions : undefined; - const footerSecondaryActions = - "footerSecondaryActions" in props - ? props.footerSecondaryActions - : undefined; - const hasStructuredHeader = title !== undefined || description !== undefined || @@ -101,14 +77,6 @@ export const Dialog = (props: DialogProps) => { footerActions !== undefined || footerSecondaryActions !== undefined; const hasFooter = footer !== undefined || hasStructuredFooter; - const ariaProps: React.AriaAttributes = {}; - const propsRecord = props as unknown as Record; - for (const key of Object.keys(propsRecord)) { - if (ariaAttributeKeys.has(key)) { - (ariaProps as Record)[key] = propsRecord[key]; - } - } - const classes = styles({ size, withPadding, @@ -135,9 +103,9 @@ export const Dialog = (props: DialogProps) => { {hasHeader ? (
From 0d9012d68693ef68b451773d889f9f74c0551fe2 Mon Sep 17 00:00:00 2001 From: alex leon Date: Sat, 30 May 2026 19:13:19 +0200 Subject: [PATCH 03/36] refactor dialog html slightly --- .../src/components/Dialog/dialog.tsx | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx index b64ae122a42..4b4dd42b16b 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx @@ -107,44 +107,53 @@ export const Dialog = ({ className={cx(classes.content, className)} aria-busy={loading ?? undefined} > - {hasHeader ? ( + {disableDefaultClose ? null : ( + + + + )} + + {hasHeader && (
{header ?? ( <>
- {titleIconName ? ( + {titleIconName && ( - ) : null} + )}
- {title !== undefined ? ( + {title && ( {title} - ) : null} - {description !== undefined ? ( + )} + {description && ( {description} - ) : null} + )}
- {actions ? ( + {actions && (
{actions}
- ) : null} + )} )}
- ) : null} + )}
{children}
- {hasFooter ? ( + {hasFooter && (
{footer ?? ( <> @@ -155,15 +164,6 @@ export const Dialog = ({ )}
- ) : null} - - {disableDefaultClose ? null : ( - - - )} {loading ? ( From 1d2dc586c9436b0ea53cf67d91153cfec6573956 Mon Sep 17 00:00:00 2001 From: alex leon Date: Sat, 30 May 2026 19:29:07 +0200 Subject: [PATCH 04/36] Switch dialog to use ds button --- .../src/components/Button/button.tsx | 1 + .../src/components/Dialog/dialog.recipe.ts | 21 ---- .../src/components/Dialog/dialog.tsx | 118 ++++++++++-------- 3 files changed, 64 insertions(+), 76 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Button/button.tsx b/libs/@hashintel/ds-components/src/components/Button/button.tsx index 72d6ea66951..1b522746ec7 100644 --- a/libs/@hashintel/ds-components/src/components/Button/button.tsx +++ b/libs/@hashintel/ds-components/src/components/Button/button.tsx @@ -42,6 +42,7 @@ type SharedButtonProps = "children" | "content" >; } & RequireAtLeastOne<{ + "aria-label"?: string; tooltip?: string; children?: React.ReactNode; }> & diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts index b81f061f4d0..3a4277008eb 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts @@ -135,27 +135,6 @@ export const styles = sva({ position: "absolute", top: "3", right: "3", - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - width: "7", - height: "7", - padding: "0", - borderRadius: "md", - background: "[transparent]", - border: "none", - cursor: "pointer", - color: "fg.muted", - transition: "[background 0.15s ease, color 0.15s ease]", - "&:hover": { - background: "neutral.a20", - color: "fg.body", - }, - "&:focus-visible": { - outline: "[2px solid]", - outlineColor: "neutral.s30", - outlineOffset: "[2px]", - }, }, loadingOverlay: { position: "absolute", diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx index 4b4dd42b16b..ad5b78310eb 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx @@ -4,6 +4,7 @@ import { Portal } from "@ark-ui/react/portal"; import { cx } from "@hashintel/ds-helpers/css"; import { usePortalContainerRef } from "../../util/portal-container-context"; +import { Button } from "../Button/button"; import { Icon, type IconName } from "../Icon/icon"; import { LoadingSpinner } from "../Loading/loading-spinner"; import { styles } from "./dialog.recipe"; @@ -84,6 +85,52 @@ export const Dialog = ({ footerless: !hasFooter, }); + const headerEl = + hasHeader && + (header ?? ( + <> +
+ {titleIconName && ( + + )} +
+ {title && ( + + {title} + + )} + {description && ( + + {description} + + )} +
+
+ {actions &&
{actions}
} + + )); + + const footerEl = hasFooter && ( +
+ {footer ?? ( + <> + {footerSecondaryActions && ( +
+ {footerSecondaryActions} +
+ )} + {footerActions && ( +
{footerActions}
+ )} + + )} +
+ ); + return ( - {disableDefaultClose ? null : ( - - - - )} - - {hasHeader && ( -
- {header ?? ( - <> -
- {titleIconName && ( - - )} -
- {title && ( - - {title} - - )} - {description && ( - - {description} - - )} -
-
- {actions && ( -
{actions}
- )} - - )} -
- )} +
+ {headerEl} + {!disableDefaultClose && ( +
{children}
- {hasFooter && ( -
- {footer ?? ( - <> -
- {footerSecondaryActions} -
-
{footerActions}
- - )} -
- )} + {footerEl} {loading ? (
From dcbab0b0ccdf8d0e6f85ecadee0c24c7d5249556 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 1 Jun 2026 12:05:18 +0200 Subject: [PATCH 05/36] Adjust dialog spacings --- .../src/components/Dialog/dialog.recipe.ts | 79 +++++++++++------ .../src/components/Dialog/dialog.stories.tsx | 16 +--- .../src/components/Dialog/dialog.tsx | 87 +++++++++---------- 3 files changed, 100 insertions(+), 82 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts index 3a4277008eb..55d69ebd8f3 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts @@ -10,6 +10,8 @@ export const styles = sva({ "titleRow", "titleIcon", "headerActions", + "headerRight", + "hasCustomHeader", "body", "footer", "footerActions", @@ -49,16 +51,19 @@ export const styles = sva({ padding: "4", }, content: { - position: "relative", + "--dialog-horizontal-padding": "var(--spacing-5\\.5)", display: "flex", flexDirection: "column", - background: "white", - borderRadius: "lg", - boxShadow: "[0 10px 40px rgba(0, 0, 0, 0.2)]", width: "[100%]", maxHeight: "[calc(100dvh - 2rem)]", outline: "none", - overflow: "hidden", + // boxShadow: + // "[0px 0px 1px 0px rgba(0,0,0,0.02), 0px 1px 1px -0.5px rgba(0,0,0,0.04), 0px 6px 6px -3px rgba(0,0,0,0.04), 0px 12px 12px -6px rgba(0,0,0,0.03), 0px 24px 24px -12px rgba(0,0,0,0.02)]", + boxShadow: "[0 10px 40px rgba(0, 0, 0, 0.2)]", + borderRadius: "xl", + backgroundColor: "neutral.s10", + padding: "1", + _open: { animationName: "fadeIn", animationDuration: "normal", @@ -69,11 +74,21 @@ export const styles = sva({ }, }, header: { + flex: "[0 0 auto]", + backgroundColor: "white", + border: "[1px solid {colors.neutral.s50}]", + borderTopRadius: "lg", + borderBottom: "[1px solid {colors.neutral.s30}]", + paddingBottom: "3.5", + paddingTop: "4", + paddingX: "[var(--dialog-horizontal-padding)]", + }, + hasCustomHeader: { display: "flex", alignItems: "flex-start", - justifyContent: "space-between", - gap: "3", - flex: "[0 0 auto]", + gap: "2", + flex: "[1 1 auto]", + minWidth: "0", }, titleRow: { display: "flex", @@ -94,7 +109,13 @@ export const styles = sva({ description: { color: "fg.muted", textStyle: "sm", - marginTop: "1", + marginTop: "-0.5", + }, + headerRight: { + marginLeft: "auto", + display: "flex", + alignItems: "center", + gap: "1", }, headerActions: { display: "flex", @@ -103,22 +124,28 @@ export const styles = sva({ flex: "[0 0 auto]", }, body: { - display: "flex", - flexDirection: "column", flex: "[1 1 auto]", minHeight: "0", overflow: "auto", + background: "white", + border: "[1px solid {colors.neutral.s50}]", + borderTop: "none", + borderBottomRadius: "lg", + paddingTop: "4", + paddingBottom: "5", + paddingX: "[var(--dialog-horizontal-padding)]", color: "fg.body", textStyle: "sm", }, footer: { + flex: "[0 0 auto]", + paddingX: "[var(--dialog-horizontal-padding)]", + paddingTop: "3.5", + paddingBottom: "3", display: "flex", - alignItems: "center", + alignItems: "flex-start", justifyContent: "space-between", gap: "3", - flex: "[0 0 auto]", - borderTop: "[1px solid]", - borderColor: "neutral.a50", }, footerActions: { display: "flex", @@ -132,9 +159,9 @@ export const styles = sva({ gap: "2", }, closeButton: { - position: "absolute", - top: "3", - right: "3", + flex: "[0 0 auto]", + alignSelf: "flex-start", + marginLeft: "auto", }, loadingOverlay: { position: "absolute", @@ -166,19 +193,19 @@ export const styles = sva({ }, withPadding: { true: { - header: { padding: "5" }, - body: { paddingX: "5", paddingY: "4" }, - footer: { padding: "4" }, + // header: { padding: "5" }, + // body: { paddingX: "5", paddingY: "4" }, + // footer: { padding: "4" }, }, false: { - header: { padding: "5", paddingBottom: "0" }, - body: { padding: "0" }, - footer: { padding: "4" }, + // header: { padding: "5", paddingBottom: "0" }, + // body: { padding: "0" }, + // footer: { padding: "4" }, }, }, headerless: { true: { - body: { paddingTop: "5" }, + // body: { paddingTop: "5" }, }, }, footerless: { @@ -192,7 +219,7 @@ export const styles = sva({ withPadding: false, headerless: true, css: { - body: { paddingTop: "0" }, + // body: { paddingTop: "0" }, }, }, ], diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx index 713777aca1e..ba9475bc426 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx @@ -206,18 +206,10 @@ export const Examples: Story = () => ( description: "Body content controls its own padding.", withPadding: false, children: ( -
-

- This body container has zero padding from the dialog, so it spans - edge-to-edge. The content within decides its own layout. -

-
+

+ This body container has zero padding from the dialog, so it spans + edge-to-edge. The content within decides its own layout. +

), footerActions: (
+ ) : ( +
+ {header &&
{header}
} + {closeButton} +
+ ); const footerEl = hasFooter && (
@@ -154,24 +169,8 @@ export const Dialog = ({ className={cx(classes.content, className)} aria-busy={loading ?? undefined} > -
- {headerEl} - {!disableDefaultClose && ( -
- + {headerEl}
{children}
- {footerEl} {loading ? ( From de702f34e173755c1a580f89e4c34d45d2ff88bc Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 1 Jun 2026 13:00:49 +0200 Subject: [PATCH 06/36] Update spacing for close buttons --- .../ds-components/src/components/Dialog/dialog.recipe.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts index 55d69ebd8f3..40dfd68edae 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts @@ -98,6 +98,7 @@ export const styles = sva({ minWidth: "0", }, titleIcon: { + marginLeft: "-0.5", color: "fg.muted", flex: "[0 0 auto]", }, @@ -115,13 +116,14 @@ export const styles = sva({ marginLeft: "auto", display: "flex", alignItems: "center", - gap: "1", + gap: "[1px]", }, headerActions: { display: "flex", alignItems: "center", - gap: "2", + gap: "[1px]", flex: "[0 0 auto]", + marginTop: "[calc(var(--spacing-4) * -1 + var(--spacing-2))]", }, body: { flex: "[1 1 auto]", @@ -159,6 +161,9 @@ export const styles = sva({ gap: "2", }, closeButton: { + marginRight: + "[calc(var(--dialog-horizontal-padding) * -1 + var(--spacing-2))]", + marginTop: "[calc(var(--spacing-4) * -1 + var(--spacing-2))]", flex: "[0 0 auto]", alignSelf: "flex-start", marginLeft: "auto", From aa636989683e882817a4a76142dbeedf82860258 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 1 Jun 2026 13:23:50 +0200 Subject: [PATCH 07/36] Rename actions to titleActions --- .../src/components/Dialog/dialog.stories.tsx | 4 ++-- .../ds-components/src/components/Dialog/dialog.tsx | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx index ba9475bc426..4f34e141e61 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx @@ -100,7 +100,7 @@ export const Examples: Story = () => ( title: "Edit workspace", titleIconName: "gear", description: "Update the details for your workspace.", - actions: ( + titleActions: (
From fa504234d9d51ab4983060267b4275f753544b69 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 1 Jun 2026 15:19:31 +0200 Subject: [PATCH 15/36] Add overflow example --- .../src/components/Dialog/dialog.recipe.ts | 4 + .../src/components/Dialog/dialog.stories.tsx | 148 ++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts index 2b6421a00a3..dbca5089334 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts @@ -108,6 +108,9 @@ export const styles = sva({ backgroundColor: "neutral.s25", borderRadius: "full", padding: "1", + alignSelf: "flex-start", + top: "[1.5px]", + position: "relative", }, title: { fontWeight: "semibold", @@ -124,6 +127,7 @@ export const styles = sva({ display: "flex", alignItems: "center", gap: "[1px]", + alignSelf: "flex-start", }, headerActions: { display: "flex", diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx index 1f0d9e01c3c..ee7e2b24a7d 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx @@ -320,6 +320,154 @@ export const Sizes: Story = () => (
); +const overflowingTitle = + "A really, really long title that probably wraps onto multiple lines and helps verify how the header handles wrapped text without breaking the layout"; + +const overflowingDescription = + "And the description gets a similarly verbose treatment so we can verify the header subtext also handles wrapping across multiple lines, especially when paired with a long title and a row of title actions."; + +const overflowingBody = ( +
+ {Array.from({ length: 20 }).map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key +

+ Paragraph {index + 1}. Lorem ipsum dolor sit amet, consectetur + adipiscing elit. Donec efficitur, nisl sed eleifend dictum, ipsum nisi + rhoncus odio, et fringilla justo lectus ac neque. +

+ ))} +
+); + +const buildOverflowKitchenSink = ( + close: () => void, + options?: { loading?: boolean }, +): React.ComponentProps => ({ + loading: options?.loading, + title: overflowingTitle, + titleIconName: "gear", + description: overflowingDescription, + titleActions: ( + <> + + ), + footerSecondaryActions: ( + + ), +}); + +const buildOverflowCustom = ( + close: () => void, + options?: { loading?: boolean }, +): React.ComponentProps => ({ + loading: options?.loading, + header: ( +
+
+ A custom header with significant content that should test how arbitrary + header content wraps and lays out +
+
+ Plus a fairly long subtitle so we can validate multi-line wrapping + behaviour within a custom header slot. +
+
+ ), + children: overflowingBody, + footer: ( +
+ + A custom footer with a long status message to test wrapping behaviour + and layout adjustments under content pressure. + + +
+ ), +}); + +const buildOverflowBodyOnly = (options?: { + loading?: boolean; +}): React.ComponentProps => ({ + loading: options?.loading, + children: overflowingBody, +}); + +export const Overflow: Story = () => ( +
+ {([false, true] as const).map((loading) => ( +
+
+ {loading ? "loading" : "default"} +
+ buildOverflowKitchenSink(close, { loading })} + /> + buildOverflowCustom(close, { loading })} + /> + buildOverflowBodyOnly({ loading })} + /> +
+ ))} +
+); + export const DisableDefaultClose: Story = () => ( Date: Mon, 1 Jun 2026 15:25:56 +0200 Subject: [PATCH 16/36] Adjust overflowing footer action styles in dialog --- .../src/components/Dialog/dialog.recipe.ts | 3 +++ .../src/components/Dialog/dialog.stories.tsx | 14 +++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts index dbca5089334..c7808a39cd7 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts @@ -164,12 +164,15 @@ export const styles = sva({ }, footerActions: { display: "flex", + flexWrap: "wrap", + justifyContent: "flex-end", alignItems: "center", gap: "2", marginLeft: "auto", }, footerSecondaryActions: { display: "flex", + flexWrap: "wrap", alignItems: "center", gap: "2", }, diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx index ee7e2b24a7d..cea2b01a47a 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx @@ -367,9 +367,17 @@ const buildOverflowKitchenSink = ( ), children: overflowingBody, footerActions: ( - + <> + + + + ), footerSecondaryActions: ( - ), - })} - /> +export const ShouldCloseOn: Story = () => ( +
+ ({ + title: "Close button and overlay", + titleIconName: "info", + description: + "Escape, the close button, and clicking the overlay all close the dialog.", + shouldCloseOn: "closeButtonAndOverlay", + children: sampleBody, + footerActions: ( + + ), + })} + /> + + ({ + title: "Close button only", + titleIconName: "info", + description: + "Escape and the close button close the dialog. Overlay clicks do not.", + shouldCloseOn: "closeButton", + children: sampleBody, + footerActions: ( + + ), + })} + /> + + ({ + title: "No default close", + titleIconName: "info", + description: + "No close button is rendered, and neither escape nor overlay clicks close the dialog.", + shouldCloseOn: "none", + children: sampleBody, + footerActions: ( + + ), + })} + /> +
); diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx index 56e87e88e04..85bc84a736a 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx @@ -13,11 +13,16 @@ import type { ExclusifyUnion } from "type-fest"; export type DialogSize = "xs" | "sm" | "md" | "lg" | "xl" | "fullScreen"; +export type DialogShouldCloseOn = + | "closeButtonAndOverlay" + | "closeButton" + | "none"; + export const Dialog = ({ className, size = "md", children, - disableDefaultClose, + shouldCloseOn = "closeButtonAndOverlay", loading, onClose, withPadding = true, @@ -37,7 +42,7 @@ export const Dialog = ({ className?: string; size?: DialogSize; children: React.ReactNode; - disableDefaultClose?: boolean; + shouldCloseOn?: DialogShouldCloseOn; loading?: boolean; onClose?: () => void; /** Turn padding on/off. Used when the dialog content controls padding itself. defaults to true */ @@ -85,7 +90,11 @@ export const Dialog = ({ hasIcon: !!titleIconName, }); - const closeButton = !disableDefaultClose && ( + const renderCloseButton = shouldCloseOn !== "none"; + const closeOnEscape = shouldCloseOn !== "none"; + const closeOnInteractOutside = shouldCloseOn === "closeButtonAndOverlay"; + + const closeButton = renderCloseButton && ( + ), + })} + /> + ); From f9faabf1dcb38126cefc4eedb5cc22d536487a2b Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 1 Jun 2026 16:30:38 +0200 Subject: [PATCH 20/36] Float close button and actions for better title wrapping --- .../src/components/Dialog/dialog.recipe.ts | 17 ++++++----------- .../src/components/Dialog/dialog.tsx | 8 ++++---- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts index ebc73835066..527a54795e6 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts @@ -7,7 +7,6 @@ export const styles = sva({ slots: dialogAnatomy .extendWith( "header", - "titleRow", "titleIcon", "headerActions", "headerRight", @@ -94,15 +93,10 @@ export const styles = sva({ flex: "[1 1 auto]", minWidth: "0", }, - titleRow: { - display: "flex", - alignItems: "center", - gap: "2", - flex: "[1 1 auto]", - minWidth: "0", - }, titleIcon: { + float: "start", marginLeft: "-0.5", + marginRight: "2", color: "neutral.s90", flex: "[0 0 auto]", backgroundColor: "neutral.s25", @@ -113,6 +107,7 @@ export const styles = sva({ position: "relative", }, title: { + display: "inline", fontWeight: "semibold", textStyle: "lg", color: "fg.body", @@ -123,14 +118,14 @@ export const styles = sva({ marginTop: "-0.5", }, headerRight: { - marginLeft: "auto", + float: "end", display: "flex", alignItems: "center", gap: "[1px]", - alignSelf: "flex-start", }, headerActions: { display: "flex", + marginLeft: "auto", alignItems: "center", gap: "[1px]", flex: "[0 0 auto]", @@ -184,8 +179,8 @@ export const styles = sva({ }, closeButton: { flex: "[0 0 auto]", - alignSelf: "flex-start", marginLeft: "auto", + float: "end", position: "relative", zIndex: "1", marginTop: diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx index 85bc84a736a..8de3c16e913 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx @@ -109,13 +109,10 @@ export const Dialog = ({ const headerEl = hasStructuredHeader ? (
-
+
{titleIconName && ( )} - {title && ( - {title} - )} {titleActions ? (
{titleActions}
@@ -124,6 +121,9 @@ export const Dialog = ({ ) : ( closeButton )} + {title && ( + {title} + )}
{description && ( From be3a3f1b96a34c1818463458ef05c58798120c53 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 1 Jun 2026 16:41:48 +0200 Subject: [PATCH 21/36] Extra margin tweaks for headerless variation --- .../ds-components/src/components/Dialog/dialog.recipe.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts index 527a54795e6..431f56245dd 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts @@ -277,9 +277,12 @@ export const styles = sva({ headerless: { true: { header: { - paddingBottom: "1", + paddingBottom: "0", borderBottom: "none", }, + closeButton: { + marginBottom: "-1.5", + }, body: { paddingTop: "0", paddingBottom: "6", From 5ca8d78c88c5c5b5d3e98a329f79bd28dbbbd256 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 1 Jun 2026 16:45:27 +0200 Subject: [PATCH 22/36] Add stacked dialog story --- .../src/components/Dialog/dialog.stories.tsx | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx index 730e91dec97..ade9d420ca0 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx @@ -502,6 +502,97 @@ export const Overflow: Story = () => (
); +const StackedDialogs = () => { + const [first, setFirst] = useState(false); + const [second, setSecond] = useState(false); + const [third, setThird] = useState(false); + const [fourth, setFourth] = useState(false); + + return ( + <> + + {first ? ( + setFirst(false)} + footerActions={ + + } + > + {sampleBody} + + ) : null} + {second ? ( + setSecond(false)} + footerActions={ + + } + > + {sampleBody} + + ) : null} + {third ? ( + setThird(false)} + footerActions={ + + } + > + {sampleBody} + + ) : null} + {fourth ? ( + setFourth(false)} + footerActions={ + + } + > + {sampleBody} + + ) : null} + + ); +}; + +export const Stacked: Story = () => ; + export const ShouldCloseOn: Story = () => (
Date: Mon, 1 Jun 2026 17:16:47 +0200 Subject: [PATCH 23/36] Add nesting to dialogs --- .../src/components/Dialog/dialog.recipe.ts | 16 +++++++ .../src/components/Dialog/dialog.tsx | 46 ++++++++++--------- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts index 431f56245dd..dd9fdd260e6 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts @@ -6,6 +6,7 @@ export const styles = sva({ className: "dialog", slots: dialogAnatomy .extendWith( + "stackRoot", "header", "titleIcon", "headerActions", @@ -21,6 +22,15 @@ export const styles = sva({ ) .keys(), base: { + stackRoot: { + display: "contents", + // Hide the backdrop of any dialog that has a nested dialog above it so + // the overlay doesn't darken cumulatively as the stack grows. + '&:has([data-scope="dialog"][data-part="content"][data-has-nested]) [data-scope="dialog"][data-part="backdrop"]': + { + visibility: "hidden", + }, + }, backdrop: { background: "black.a60", position: "fixed", @@ -75,6 +85,12 @@ export const styles = sva({ animationName: "fadeOut", animationDuration: "fast", }, + // When another dialog is opened on top, shift this one up-and-left by + // 30px per layer above it so the stack reads visually. + "&[data-has-nested]": { + transform: + "translate(calc(var(--nested-layer-count) * -22px), calc(var(--nested-layer-count) * -22px))", + }, }, header: { flex: "[0 0 auto]", diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx index 8de3c16e913..eee4315385c 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx @@ -173,28 +173,30 @@ export const Dialog = ({ finalFocusEl={returnFocusRef ? () => returnFocusRef.current : undefined} > - - - - {headerEl} -
- {children} - {loading ? ( -
- -
- ) : null} -
- {footerEl} -
-
+
+ + + + {headerEl} +
+ {children} + {loading ? ( +
+ +
+ ) : null} +
+ {footerEl} +
+
+
); From 8e20c1e45d715f21c9c1a4aa56942224909ec9ca Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 1 Jun 2026 17:27:34 +0200 Subject: [PATCH 24/36] Remove allowBodyScroll prop from dialog --- .../ds-components/src/components/Dialog/dialog.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx index eee4315385c..382029140be 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx @@ -26,7 +26,6 @@ export const Dialog = ({ loading, onClose, withPadding = true, - allowBodyScroll, initialFocusRef, returnFocusRef, header, @@ -47,8 +46,6 @@ export const Dialog = ({ onClose?: () => void; /** Turn padding on/off. Used when the dialog content controls padding itself. defaults to true */ withPadding?: boolean; - /** Allow the root document/html container to scroll while the dialog is open */ - allowBodyScroll?: boolean; initialFocusRef?: React.RefObject; returnFocusRef?: React.RefObject; } & ExclusifyUnion< @@ -158,8 +155,6 @@ export const Dialog = ({ return ( { From a262cf3c904769f95c508ddac969c99521e4db20 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 1 Jun 2026 17:42:27 +0200 Subject: [PATCH 25/36] Better scrollable area outline --- .../ds-components/src/components/Dialog/dialog.recipe.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts index dd9fdd260e6..29ea671820a 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts @@ -168,6 +168,9 @@ export const styles = sva({ '[aria-busy="true"] &': { overflow: "hidden", }, + _focusVisible: { + outlineColor: "neutral.a50", + }, }, footer: { flex: "[0 0 auto]", From 5f1b197d03713bf86a4d24b49147582db842bacc Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 1 Jun 2026 17:45:34 +0200 Subject: [PATCH 26/36] Add transition to nested transform --- .../ds-components/src/components/Dialog/dialog.recipe.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts index 29ea671820a..8154d3e9636 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts @@ -88,6 +88,7 @@ export const styles = sva({ // When another dialog is opened on top, shift this one up-and-left by // 30px per layer above it so the stack reads visually. "&[data-has-nested]": { + transition: "[transform 0.10s ease]", transform: "translate(calc(var(--nested-layer-count) * -22px), calc(var(--nested-layer-count) * -22px))", }, From d7178118951a0b0ff152190666c16f2239978df0 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 1 Jun 2026 18:48:40 +0200 Subject: [PATCH 27/36] Remove unused data-autofocus --- .../ds-components/src/components/TextInput/base-input.tsx | 1 - libs/@hashintel/ds-components/src/util/form-shared.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/TextInput/base-input.tsx b/libs/@hashintel/ds-components/src/components/TextInput/base-input.tsx index 22bab1b8025..fcba54e7600 100644 --- a/libs/@hashintel/ds-components/src/components/TextInput/base-input.tsx +++ b/libs/@hashintel/ds-components/src/components/TextInput/base-input.tsx @@ -272,7 +272,6 @@ export const BaseInput = ({ styledValue && !focused ? classes.hiddenInput : undefined, )} autoFocus={autoFocus === true ? true : undefined} - data-no-autofocus={autoFocus === "never" ? true : undefined} {...ariaProps} /> ); diff --git a/libs/@hashintel/ds-components/src/util/form-shared.ts b/libs/@hashintel/ds-components/src/util/form-shared.ts index 02ef7ae41fb..44adf8fe800 100644 --- a/libs/@hashintel/ds-components/src/util/form-shared.ts +++ b/libs/@hashintel/ds-components/src/util/form-shared.ts @@ -45,6 +45,6 @@ export type SharedInputProps< ref?: React.Ref; /** The input ref - this could be different to the ref, which may be a containing element. Use this to access the internal input state and/or to set focus */ inputRef?: React.Ref; - /** Set to true to make the element focused on mount - set to never to prevent the element from ever being auto-focused */ - autoFocus?: true | "never"; + /** Set to true to make the element focused on mount */ + autoFocus?: boolean; } & SharedInputAndFieldProps; From 6a29f0201a93dda296c78a238cdd9b08b04a103d Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 1 Jun 2026 19:40:56 +0200 Subject: [PATCH 28/36] Add a changeset --- .changeset/hungry-hounds-happen.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hungry-hounds-happen.md diff --git a/.changeset/hungry-hounds-happen.md b/.changeset/hungry-hounds-happen.md new file mode 100644 index 00000000000..38f132cdc89 --- /dev/null +++ b/.changeset/hungry-hounds-happen.md @@ -0,0 +1,5 @@ +--- +"@hashintel/ds-components": patch +--- + +Adds a Dialog component From 172b8a55e7dd31bdca17cb1a5fa5faa53381b54b Mon Sep 17 00:00:00 2001 From: alex leon Date: Tue, 2 Jun 2026 10:19:14 +0200 Subject: [PATCH 29/36] Add plain variant to dialog --- .../src/components/Dialog/dialog.recipe.ts | 22 +- .../src/components/Dialog/dialog.stories.tsx | 338 ++++++++++-------- .../src/components/Dialog/dialog.tsx | 3 + 3 files changed, 205 insertions(+), 158 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts index 8154d3e9636..c95a2e86c23 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts @@ -157,7 +157,6 @@ export const styles = sva({ background: "white", border: "[1px solid {colors.neutral.s50}]", borderTop: "none", - borderBottomRadius: "lg", color: "fg.body", textStyle: "sm", paddingX: "[var(--dialog-horizontal-padding)]", @@ -282,6 +281,27 @@ export const styles = sva({ }, }, }, + variant: { + partitionedFooter: { + body: { + borderBottomRadius: "lg", + }, + }, + plain: { + header: { + borderBottomColor: "neutral.s20", + }, + body: { + borderBottom: "none", + }, + footer: { + backgroundColor: "white", + border: "[1px solid {colors.neutral.s50}]", + borderBottomRadius: "lg", + borderTop: "[1px solid {colors.neutral.s20}]", + }, + }, + }, hasIcon: { true: { description: { marginTop: "0.5" }, diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx index ade9d420ca0..832a38b115e 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx @@ -57,176 +57,200 @@ const stackStyles = css({ alignItems: "flex-start", }); -export const Examples: Story = () => ( -
- ({ - title: "Account settings", - children: sampleBody, - })} - /> - - ({ - title: "Settings", - titleIconName: "gear", - children: sampleBody, - })} - /> - - ({ - children: ( -

- Do you want to save your changes before closing? Select close to go - back. -

- ), - footerActions: ( - <> - - - - ), - })} - /> - - ({ - title: "Edit workspace", - titleIconName: "gear", - description: "Update the details for your workspace.", - titleActions: ( - - ), - footerSecondaryActions: ( - - ), - })} - /> - - ({ - header: ( + + ), + }), + }, + { + buttonLabel: "Kitchen sink", + dialogProps: (close) => ({ + variant, + title: "Edit workspace", + titleIconName: "gear", + description: "Update the details for your workspace.", + titleActions: ( + + ), + footerSecondaryActions: ( + + ), + }), + }, + { + buttonLabel: "Custom header", + dialogProps: () => ({ + variant, + header: ( +
-
- + +
+
+
+ Custom header layout
-
-
- Custom header layout -
-
- Built from arbitrary content. -
+
+ Built from arbitrary content.
- ), - children: sampleBody, - })} - /> - - ({ - title: "Custom footer", - children: sampleBody, - footer: ( -
+ ), + children: sampleBody, + }), + }, + { + buttonLabel: "Custom footer", + dialogProps: (close) => ({ + variant, + title: "Custom footer", + children: sampleBody, + footer: ( +
+ + + All changes are saved automatically. + + -
- ), - })} - /> - - ({ - title: "Edit workspace", - titleIconName: "gear", - description: "Body content controls its own padding.", - withPadding: false, - children: ( -

- This body container has zero padding from the dialog, so it spans - edge-to-edge. The content within decides its own layout. -

- ), - footerActions: ( - - ), - footerSecondaryActions: ( - - ), - })} - /> +
+ ), + }), + }, + { + buttonLabel: "Kitchen sink (no padding)", + dialogProps: (close) => ({ + variant, + title: "Edit workspace", + titleIconName: "gear", + description: "Body content controls its own padding.", + withPadding: false, + children: ( +

+ This body container has zero padding from the dialog, so it spans + edge-to-edge. The content within decides its own layout. +

+ ), + footerActions: ( + + ), + footerSecondaryActions: ( + + ), + }), + }, +]; + +export const Examples: Story = () => ( +
+ {(["partitionedFooter", "plain"] as const).map((variant) => ( +
+
+ {variant} +
+ {buildExampleEntries(variant).map((entry) => ( + + ))} +
+ ))}
); diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx index 382029140be..5f82354a127 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx @@ -21,6 +21,7 @@ export type DialogShouldCloseOn = export const Dialog = ({ className, size = "md", + variant = "partitionedFooter", children, shouldCloseOn = "closeButtonAndOverlay", loading, @@ -40,6 +41,7 @@ export const Dialog = ({ }: { className?: string; size?: DialogSize; + variant?: "partitionedFooter" | "plain"; children: React.ReactNode; shouldCloseOn?: DialogShouldCloseOn; loading?: boolean; @@ -85,6 +87,7 @@ export const Dialog = ({ withPadding, headerless: !hasHeader, hasIcon: !!titleIconName, + variant, }); const renderCloseButton = shouldCloseOn !== "none"; From 93b04b1b47297c44931be9f0a395913c11cc49f1 Mon Sep 17 00:00:00 2001 From: alex leon Date: Tue, 2 Jun 2026 10:22:37 +0200 Subject: [PATCH 30/36] Add description example --- .../src/components/Dialog/dialog.stories.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx index 832a38b115e..5ecda7d1998 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx @@ -77,6 +77,15 @@ const buildExampleEntries = (variant: DialogVariant): ExampleProps[] => [ children: sampleBody, }), }, + { + buttonLabel: "Description only", + dialogProps: () => ({ + variant, + description: + "A description without a title, written long enough that it wraps onto a second line so we can check how the header lays out when only the subtext is present.", + children: sampleBody, + }), + }, { buttonLabel: "Footer actions", dialogProps: (close) => ({ From 2b5660fb97fb3d9b90a59a35e72a9c4f8f389667 Mon Sep 17 00:00:00 2001 From: alex leon Date: Tue, 2 Jun 2026 10:48:49 +0200 Subject: [PATCH 31/36] Adjust dialog vertical centering so its slightly biased to the top --- .../src/components/Dialog/dialog.recipe.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts index c95a2e86c23..47929a44f4b 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts @@ -49,6 +49,7 @@ export const styles = sva({ }, positioner: { display: "flex", + flexDirection: "column", alignItems: "center", justifyContent: "center", position: "fixed", @@ -59,6 +60,16 @@ export const styles = sva({ overscrollBehaviorY: "none", zIndex: "zIndex.modal", padding: "4", + // Bias the dialog slightly above center: spacers split free vertical + // space 35/65 and shrink to 0 when the content fills the viewport. + _before: { + content: '""', + flex: "[38 1 0]", + }, + _after: { + content: '""', + flex: "[62 1 0]", + }, }, content: { "--dialog-horizontal-padding": "var(--spacing-5\\.5)", From 7508ed15cef2b62097217d7b74211302c39e1d95 Mon Sep 17 00:00:00 2001 From: alex leon Date: Tue, 2 Jun 2026 13:15:31 +0200 Subject: [PATCH 32/36] Switch Dialog to a Slot based api --- .../src/components/Dialog/dialog.recipe.ts | 8 - .../src/components/Dialog/dialog.stories.tsx | 849 ++++++++++-------- .../src/components/Dialog/dialog.tsx | 260 ++++-- 3 files changed, 619 insertions(+), 498 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts index 47929a44f4b..45aa0a244ad 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts @@ -318,13 +318,6 @@ export const styles = sva({ description: { marginTop: "0.5" }, }, }, - withPadding: { - false: { - body: { - padding: "[0 !important]", - }, - }, - }, headerless: { true: { header: { @@ -357,6 +350,5 @@ export const styles = sva({ ], defaultVariants: { size: "md", - withPadding: true, }, }); diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx index 5ecda7d1998..edec3f2d42f 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx @@ -35,17 +35,17 @@ const longBody = ( type ExampleProps = { buttonLabel: string; - dialogProps: (close: () => void) => React.ComponentProps; + renderDialog: (close: () => void) => React.ReactElement; }; -const DialogExample = ({ buttonLabel, dialogProps }: ExampleProps) => { +const DialogExample = ({ buttonLabel, renderDialog }: ExampleProps) => { const [open, setOpen] = useState(false); const close = () => setOpen(false); return ( <> - {open ? : null} + {open ? renderDialog(close) : null} ); }; @@ -62,177 +62,196 @@ type DialogVariant = "partitionedFooter" | "plain"; const buildExampleEntries = (variant: DialogVariant): ExampleProps[] => [ { buttonLabel: "Title only", - dialogProps: () => ({ - variant, - title: "Account settings", - children: sampleBody, - }), + renderDialog: (close) => ( + + + {sampleBody} + + ), }, { buttonLabel: "Title + icon", - dialogProps: () => ({ - variant, - title: "Settings", - titleIconName: "gear", - children: sampleBody, - }), + renderDialog: (close) => ( + + + {sampleBody} + + ), }, { buttonLabel: "Description only", - dialogProps: () => ({ - variant, - description: - "A description without a title, written long enough that it wraps onto a second line so we can check how the header lays out when only the subtext is present.", - children: sampleBody, - }), + renderDialog: (close) => ( + + + {sampleBody} + + ), }, { buttonLabel: "Footer actions", - dialogProps: (close) => ({ - variant, - children: ( -

- Do you want to save your changes before closing? Select close to go - back. -

- ), - footerActions: ( - <> - - - - ), - }), + renderDialog: (close) => ( + + +

+ Do you want to save your changes before closing? Select close to go + back. +

+
+ + + + + } + /> +
+ ), }, { buttonLabel: "Kitchen sink", - dialogProps: (close) => ({ - variant, - title: "Edit workspace", - titleIconName: "gear", - description: "Update the details for your workspace.", - titleActions: ( - - ), - footerSecondaryActions: ( - - ), - }), + {sampleBody} + + Save changes + + } + secondaryActions={ + + } + /> +
+ ), }, { buttonLabel: "Custom header", - dialogProps: () => ({ - variant, - header: ( -
+ renderDialog: (close) => ( + +
- -
-
-
- Custom header layout +
+
-
- Built from arbitrary content. +
+
+ Custom header layout +
+
+ Built from arbitrary content. +
-
- ), - children: sampleBody, - }), + + {sampleBody} +
+ ), }, { buttonLabel: "Custom footer", - dialogProps: (close) => ({ - variant, - title: "Custom footer", - children: sampleBody, - footer: ( -
- - - All changes are saved automatically. - - -
- ), - }), + + + All changes are saved automatically. + + +
+ +
+ ), }, { buttonLabel: "Kitchen sink (no padding)", - dialogProps: (close) => ({ - variant, - title: "Edit workspace", - titleIconName: "gear", - description: "Body content controls its own padding.", - withPadding: false, - children: ( -

- This body container has zero padding from the dialog, so it spans - edge-to-edge. The content within decides its own layout. -

- ), - footerActions: ( - - ), - footerSecondaryActions: ( - - ), - }), + renderDialog: (close) => ( + + + +

+ This body container has zero padding from the dialog, so it spans + edge-to-edge. The content within decides its own layout. +

+
+ + Save + + } + secondaryActions={ + + } + /> +
+ ), }, ]; @@ -255,7 +274,7 @@ export const Examples: Story = () => ( ))} @@ -272,37 +291,41 @@ const sizes = [ "fullScreen", ] as const satisfies readonly DialogSize[]; -const buildKitchenSink = ( +const renderKitchenSink = ( size: DialogSize, close: () => void, options?: { loading?: boolean }, -): React.ComponentProps => ({ - size, - loading: options?.loading, - title: `Kitchen sink (${size})`, - titleIconName: "gear", - description: "All the bells and whistles, sized for this width.", - titleActions: ( - - ), - footerSecondaryActions: ( - - ), -}); + {size === "fullScreen" ? longBody : sampleBody} + + Save changes + + } + secondaryActions={ + + } + /> + +); export const Sizes: Story = () => (
@@ -321,30 +344,31 @@ export const Sizes: Story = () => (
buildKitchenSink(size, close)} + renderDialog={(close) => renderKitchenSink(size, close)} /> - buildKitchenSink(size, close, { loading: true }) + renderDialog={(close) => + renderKitchenSink(size, close, { loading: true }) } /> {size === "xs" ? ( <> ({ - size, - children: sampleBody, - })} + renderDialog={(close) => ( + + {sampleBody} + + )} /> ({ - size, - withPadding: false, - children: sampleBody, - })} + renderDialog={(close) => ( + + {sampleBody} + + )} /> ) : null} @@ -363,17 +387,22 @@ export const Sizes: Story = () => ( ({ - className: css({ maxWidth: "[480px]" }), - title: "Custom width (480px)", - description: "maxWidth is overridden via className.", - children: sampleBody, - footerActions: ( - - ), - })} + renderDialog={(close) => ( + + + {sampleBody} + + Save changes + + } + /> + + )} /> @@ -398,110 +427,118 @@ const overflowingBody = ( ); -const buildOverflowKitchenSink = ( +const renderOverflowKitchenSink = ( close: () => void, options?: { loading?: boolean }, -): React.ComponentProps => ({ - loading: options?.loading, - title: overflowingTitle, - titleIconName: "gear", - description: overflowingDescription, - titleActions: ( - <> - - - - - ), - footerSecondaryActions: ( - - ), -}); +) => ( + + + + + + + } + secondaryActions={ + + } + /> + +); -const buildOverflowCustom = ( +const renderOverflowCustom = ( close: () => void, options?: { loading?: boolean }, -): React.ComponentProps => ({ - loading: options?.loading, - header: ( -
-
- A custom header with significant content that should test how arbitrary - header content wraps and lays out -
-
- Plus a fairly long subtitle so we can validate multi-line wrapping - behaviour within a custom header slot. +) => ( + + +
+
+ A custom header with significant content that should test how + arbitrary header content wraps and lays out +
+
+ Plus a fairly long subtitle so we can validate multi-line wrapping + behaviour within a custom header slot. +
-
- ), - children: overflowingBody, - footer: ( -
- - A custom footer with a long status message to test wrapping behaviour - and layout adjustments under content pressure. - - -
- ), -}); + + A custom footer with a long status message to test wrapping behaviour + and layout adjustments under content pressure. + + +
+
+ +); -const buildOverflowBodyOnly = (options?: { - loading?: boolean; -}): React.ComponentProps => ({ - loading: options?.loading, - children: overflowingBody, -}); +const renderOverflowBodyOnly = ( + close: () => void, + options?: { loading?: boolean }, +) => ( + + {overflowingBody} + +); export const Overflow: Story = () => (
@@ -520,15 +557,17 @@ export const Overflow: Story = () => (
buildOverflowKitchenSink(close, { loading })} + renderDialog={(close) => + renderOverflowKitchenSink(close, { loading }) + } /> buildOverflowCustom(close, { loading })} + renderDialog={(close) => renderOverflowCustom(close, { loading })} /> buildOverflowBodyOnly({ loading })} + renderDialog={(close) => renderOverflowBodyOnly(close, { loading })} /> ))} @@ -545,79 +584,87 @@ const StackedDialogs = () => { <> {first ? ( - setFirst(false)} - footerActions={ - - } - > - {sampleBody} + setFirst(false)}> + + {sampleBody} + setSecond(true)} + > + Open second dialog + + } + /> ) : null} {second ? ( - setSecond(false)} - footerActions={ - - } - > - {sampleBody} + setSecond(false)}> + + {sampleBody} + setThird(true)} + > + Open small dialog + + } + /> ) : null} {third ? ( - setThird(false)} - footerActions={ - - } - > - {sampleBody} + setThird(false)}> + + {sampleBody} + setFourth(true)} + > + Open another small dialog + + } + /> ) : null} {fourth ? ( - setFourth(false)} - footerActions={ - - } - > - {sampleBody} + setFourth(false)}> + + {sampleBody} + setFourth(false)} + > + Done + + } + /> ) : null} @@ -630,53 +677,65 @@ export const ShouldCloseOn: Story = () => (
({ - title: "Close button and overlay", - titleIconName: "info", - description: - "Escape, the close button, and clicking the overlay all close the dialog.", - shouldCloseOn: "closeButtonAndOverlay", - children: sampleBody, - footerActions: ( - - ), - })} + renderDialog={(close) => ( + + + {sampleBody} + + Done + + } + /> + + )} /> ({ - title: "Close button only", - titleIconName: "info", - description: - "Escape and the close button close the dialog. Overlay clicks do not.", - shouldCloseOn: "closeButton", - children: sampleBody, - footerActions: ( - - ), - })} + renderDialog={(close) => ( + + + {sampleBody} + + Done + + } + /> + + )} /> ({ - title: "No default close", - titleIconName: "info", - description: - "No close button is rendered, and neither escape nor overlay clicks close the dialog.", - shouldCloseOn: "none", - children: sampleBody, - footerActions: ( - - ), - })} + renderDialog={(close) => ( + + + {sampleBody} + + Done + + } + /> + + )} />
); diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx index 5f82354a127..2c88622bf03 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx @@ -1,7 +1,14 @@ import { Dialog as ArkDialog } from "@ark-ui/react/dialog"; import { Portal } from "@ark-ui/react/portal"; +import { + Children, + createContext, + isValidElement, + useContext, + useMemo, +} from "react"; -import { cx } from "@hashintel/ds-helpers/css"; +import { css, cx } from "@hashintel/ds-helpers/css"; import { usePortalContainerRef } from "../../util/portal-container-context"; import { Button } from "../Button/button"; @@ -9,7 +16,7 @@ import { Icon, type IconName } from "../Icon/icon"; import { LoadingSpinner } from "../Loading/loading-spinner"; import { styles } from "./dialog.recipe"; -import type { ExclusifyUnion } from "type-fest"; +import type { ExclusifyUnion, RequireAtLeastOne } from "type-fest"; export type DialogSize = "xs" | "sm" | "md" | "lg" | "xl" | "fullScreen"; @@ -18,81 +25,48 @@ export type DialogShouldCloseOn = | "closeButton" | "none"; -export const Dialog = ({ - className, - size = "md", - variant = "partitionedFooter", - children, - shouldCloseOn = "closeButtonAndOverlay", - loading, - onClose, - withPadding = true, - initialFocusRef, - returnFocusRef, - header, - title, - description, - titleIconName, - titleActions, - footer, - footerActions, - footerSecondaryActions, - ...ariaAttributes -}: { - className?: string; - size?: DialogSize; - variant?: "partitionedFooter" | "plain"; - children: React.ReactNode; - shouldCloseOn?: DialogShouldCloseOn; - loading?: boolean; +const DialogContext = createContext<{ + classes: ReturnType; onClose?: () => void; - /** Turn padding on/off. Used when the dialog content controls padding itself. defaults to true */ - withPadding?: boolean; - initialFocusRef?: React.RefObject; - returnFocusRef?: React.RefObject; -} & ExclusifyUnion< + renderCloseButton: boolean; + loading?: boolean; +} | null>(null); + +const useDialogContext = () => { + const ctx = useContext(DialogContext); + if (!ctx) { + throw new Error( + "Dialog.Header, Dialog.Body and Dialog.Footer must be rendered inside ", + ); + } + return ctx; +}; + +type HeaderProps = ExclusifyUnion< | { title?: React.ReactNode; description?: React.ReactNode; - titleIconName?: IconName; - titleActions?: React.ReactNode; + iconName?: IconName; + actions?: React.ReactNode; } | { - header?: React.ReactNode; + children?: React.ReactNode; } -> & - ExclusifyUnion< - | { footer?: React.ReactNode } - | { - footerActions?: React.ReactNode; - footerSecondaryActions?: React.ReactNode; - } - > & - React.AriaAttributes) => { - const portalContainerRef = usePortalContainerRef(); +>; +const Header = ({ + title, + description, + iconName, + actions, + children, +}: HeaderProps) => { + const { classes, onClose, renderCloseButton } = useDialogContext(); const hasStructuredHeader = title !== undefined || description !== undefined || - titleIconName !== undefined || - titleActions !== undefined; - const hasHeader = header !== undefined || hasStructuredHeader; - - const hasStructuredFooter = - footerActions !== undefined || footerSecondaryActions !== undefined; - const hasFooter = footer !== undefined || hasStructuredFooter; - - const classes = styles({ - size, - withPadding, - headerless: !hasHeader, - hasIcon: !!titleIconName, - variant, - }); - - const renderCloseButton = shouldCloseOn !== "none"; - const closeOnEscape = shouldCloseOn !== "none"; - const closeOnInteractOutside = shouldCloseOn === "closeButtonAndOverlay"; + iconName !== undefined || + actions !== undefined; const closeButton = renderCloseButton && ( )} /> + + ( + + {sampleBody} + + Done + + } + /> + + )} + /> ); From 45af63eb657b25a43460b4649da265b48aa9d4ea Mon Sep 17 00:00:00 2001 From: alex leon Date: Tue, 2 Jun 2026 13:39:39 +0200 Subject: [PATCH 34/36] Switch from defaultOpen to open in ArkDialog to keep it controlled --- libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx index 2c88622bf03..6aab0d6566b 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx @@ -227,7 +227,7 @@ const DialogRoot = ({ return ( { From abca4491a1bd42b505dbd91a16e7c03dd4a16c89 Mon Sep 17 00:00:00 2001 From: alex leon Date: Tue, 2 Jun 2026 13:42:44 +0200 Subject: [PATCH 35/36] Export Dialog from main --- libs/@hashintel/ds-components/src/main.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/@hashintel/ds-components/src/main.ts b/libs/@hashintel/ds-components/src/main.ts index e185c3e4721..19dba9b991b 100644 --- a/libs/@hashintel/ds-components/src/main.ts +++ b/libs/@hashintel/ds-components/src/main.ts @@ -2,6 +2,7 @@ export { Avatar, type AvatarProps } from "./components/Avatar/avatar"; export { Badge, type BadgeProps } from "./components/Badge/badge"; export { Button, type ButtonProps } from "./components/Button/button"; export { Checkbox, type CheckboxProps } from "./components/Checkbox/checkbox"; +export { Dialog } from "./components/Dialog/dialog"; export { Form } from "./components/Form/form"; export { Icon, type IconName, iconNames } from "./components/Icon/icon"; export { From 45bfeed7f0fbecf0dd977ff81749ba43118b5d5c Mon Sep 17 00:00:00 2001 From: alex leon Date: Tue, 2 Jun 2026 14:34:55 +0200 Subject: [PATCH 36/36] Add extra useMemo to classes in dialog --- .../src/components/Dialog/dialog.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx index 6aab0d6566b..067b08e59f3 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx @@ -209,12 +209,16 @@ const DialogRoot = ({ const hasHeader = !!headerChild; const titleIconName = headerChild?.props.iconName; - const classes = styles({ - size, - headerless: !hasHeader, - hasIcon: !!titleIconName, - variant, - }); + const classes = useMemo( + () => + styles({ + size, + headerless: !hasHeader, + hasIcon: !!titleIconName, + variant, + }), + [size, hasHeader, titleIconName, variant], + ); const renderCloseButton = shouldCloseOn !== "none"; const closeOnEscape = shouldCloseOn !== "none";