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 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 new file mode 100644 index 00000000000..45aa0a244ad --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.recipe.ts @@ -0,0 +1,354 @@ +import { dialogAnatomy } from "@ark-ui/react/anatomy"; + +import { sva } from "@hashintel/ds-helpers/css"; + +export const styles = sva({ + className: "dialog", + slots: dialogAnatomy + .extendWith( + "stackRoot", + "header", + "titleIcon", + "headerActions", + "headerRight", + "hasCustomHeader", + "body", + "footer", + "footerActions", + "footerSecondaryActions", + "closeButton", + "loadingOverlay", + "loadingSpinner", + ) + .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", + inset: "0", + width: "[100dvw]", + height: "[100dvh]", + zIndex: "zIndex.modal", + _open: { + animationName: "fadeIn", + animationDuration: "normal", + }, + _closed: { + animationName: "fadeOut", + animationDuration: "fast", + }, + }, + positioner: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + position: "fixed", + inset: "0", + width: "[100dvw]", + height: "[100dvh]", + overflow: "auto", + 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)", + "--dialog-top-padding": "var(--spacing-4)", + "--dialog-close-button-gap": "var(--spacing-2)", + position: "relative", + display: "flex", + flexDirection: "column", + width: "[100%]", + maxHeight: "[calc(100dvh - 2rem)]", + outline: "none", + // 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", + }, + _closed: { + 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]": { + transition: "[transform 0.10s ease]", + transform: + "translate(calc(var(--nested-layer-count) * -22px), calc(var(--nested-layer-count) * -22px))", + }, + }, + header: { + flex: "[0 0 auto]", + backgroundColor: "white", + border: "[1px solid {colors.neutral.s50}]", + borderTopRadius: "lg", + borderBottom: "[1px solid {colors.neutral.s30}]", + paddingX: "[var(--dialog-horizontal-padding)]", + paddingTop: "[var(--dialog-top-padding)]", + paddingBottom: "3.5", + }, + hasCustomHeader: { + display: "flex", + alignItems: "flex-start", + 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", + borderRadius: "full", + padding: "1", + alignSelf: "flex-start", + top: "[1.5px]", + position: "relative", + }, + title: { + display: "inline", + fontWeight: "semibold", + textStyle: "lg", + color: "fg.body", + }, + description: { + color: "fg.muted", + textStyle: "sm", + marginTop: "-0.5", + }, + headerRight: { + float: "end", + display: "flex", + alignItems: "center", + gap: "[1px]", + }, + headerActions: { + display: "flex", + marginLeft: "auto", + alignItems: "center", + gap: "[1px]", + flex: "[0 0 auto]", + marginTop: + "[calc(var(--dialog-top-padding) * -1 + var(--dialog-close-button-gap))]", + }, + body: { + position: "relative", + flex: "[1 1 auto]", + minHeight: "0", + overflow: "auto", + background: "white", + border: "[1px solid {colors.neutral.s50}]", + borderTop: "none", + color: "fg.body", + textStyle: "sm", + paddingX: "[var(--dialog-horizontal-padding)]", + paddingTop: "4", + paddingBottom: "5", + // While loading, lock the body's scroll so the absolutely-positioned + // overlay stays pinned to the visible area instead of riding the + // scrolled content. + '[aria-busy="true"] &': { + overflow: "hidden", + }, + _focusVisible: { + outlineColor: "neutral.a50", + }, + }, + footer: { + flex: "[0 0 auto]", + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + gap: "3", + paddingX: "[var(--dialog-horizontal-padding)]", + paddingTop: "3.5", + paddingBottom: "3", + }, + footerActions: { + display: "flex", + flexWrap: "wrap", + justifyContent: "flex-end", + alignItems: "center", + gap: "2", + marginLeft: "auto", + }, + footerSecondaryActions: { + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: "2", + }, + closeButton: { + flex: "[0 0 auto]", + marginLeft: "auto", + float: "end", + position: "relative", + zIndex: "1", + marginTop: + "[calc(var(--dialog-top-padding) * -1 + var(--dialog-close-button-gap))]", + marginRight: + "[calc(var(--dialog-horizontal-padding) * -1 + var(--dialog-close-button-gap))]", + }, + loadingOverlay: { + position: "absolute", + inset: "0", + display: "flex", + alignItems: "center", + justifyContent: "center", + background: "[rgba(255, 255, 255, 0.88)]", + zIndex: "1", + borderRadius: "[inherit]", + }, + loadingSpinner: { + width: "[auto !important]", + aspectRatio: "1", + maxHeight: "[60%]", + color: "black", + }, + }, + variants: { + size: { + xs: { + content: { + maxWidth: "[400px]", + "--dialog-horizontal-padding": "var(--spacing-4)", + "--dialog-top-padding": "var(--spacing-3\\.5)", + }, + header: { + paddingBottom: "3", + }, + body: { + paddingTop: "4", + paddingBottom: "4.5", + }, + footer: { + paddingTop: "3", + paddingBottom: "2.5", + }, + }, + sm: { + content: { maxWidth: "[520px]" }, + loadingSpinner: { height: "[38px !important]" }, + }, + md: { + content: { maxWidth: "[640px]" }, + loadingSpinner: { height: "[40px !important]" }, + }, + lg: { + content: { maxWidth: "[860px]" }, + loadingSpinner: { + height: "[45px !important]", + color: "neutral.s115", + }, + }, + xl: { + content: { maxWidth: "[1060px]" }, + loadingSpinner: { + height: "[50px !important]", + color: "neutral.s115", + }, + }, + fullScreen: { + positioner: { padding: "0" }, + content: { + maxWidth: "[100dvw]", + width: "[100dvw]", + height: "[100dvh]", + maxHeight: "[100dvh]", + borderRadius: "[0]", + }, + loadingSpinner: { + height: "[50px !important]", + color: "neutral.s110", + }, + }, + }, + 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" }, + }, + }, + headerless: { + true: { + header: { + paddingBottom: "0", + borderBottom: "none", + }, + closeButton: { + marginBottom: "-1.5", + }, + body: { + paddingTop: "0", + paddingBottom: "6", + }, + }, + }, + }, + compoundVariants: [ + { + headerless: true, + size: "xs", + css: { + header: { + paddingBottom: "0", + }, + body: { + paddingBottom: "5", + }, + }, + }, + ], + defaultVariants: { + size: "md", + }, +}); 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..019a46466d9 --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.stories.tsx @@ -0,0 +1,757 @@ +import { useState } from "react"; + +import { css } from "@hashintel/ds-helpers/css"; + +import { Button } from "../Button/button"; +import { Icon } from "../Icon/icon"; +import { Dialog, 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; + renderDialog: (close: () => void) => React.ReactElement; +}; + +const DialogExample = ({ buttonLabel, renderDialog }: ExampleProps) => { + const [open, setOpen] = useState(false); + const close = () => setOpen(false); + + return ( + <> + + {open ? renderDialog(close) : null} + + ); +}; + +const stackStyles = css({ + display: "flex", + flexWrap: "wrap", + gap: "3", + alignItems: "flex-start", +}); + +type DialogVariant = "partitionedFooter" | "plain"; + +const buildExampleEntries = (variant: DialogVariant): ExampleProps[] => [ + { + buttonLabel: "Title only", + renderDialog: (close) => ( + + + {sampleBody} + + ), + }, + { + buttonLabel: "Title + icon", + renderDialog: (close) => ( + + + {sampleBody} + + ), + }, + { + buttonLabel: "Description only", + renderDialog: (close) => ( + + + {sampleBody} + + ), + }, + { + buttonLabel: "Footer actions", + renderDialog: (close) => ( + + +

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

+
+ + + + + } + /> +
+ ), + }, + { + buttonLabel: "Kitchen sink", + renderDialog: (close) => ( + + + } + /> + {sampleBody} + + Save changes + + } + secondaryActions={ + + } + /> + + ), + }, + { + buttonLabel: "Custom header", + renderDialog: (close) => ( + + +
+
+ +
+
+
+ Custom header layout +
+
+ Built from arbitrary content. +
+
+
+
+ {sampleBody} +
+ ), + }, + { + buttonLabel: "Custom footer", + renderDialog: (close) => ( + + + {sampleBody} + +
+ + + All changes are saved automatically. + + +
+
+
+ ), + }, + { + buttonLabel: "Kitchen sink (no padding)", + 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={ + + } + /> +
+ ), + }, +]; + +export const Examples: Story = () => ( +
+ {(["partitionedFooter", "plain"] as const).map((variant) => ( +
+
+ {variant} +
+ {buildExampleEntries(variant).map((entry) => ( + + ))} +
+ ))} +
+); + +const sizes = [ + "xs", + "sm", + "md", + "lg", + "xl", + "fullScreen", +] as const satisfies readonly DialogSize[]; + +const renderKitchenSink = ( + size: DialogSize, + close: () => void, + options?: { loading?: boolean }, +) => ( + + + } + /> + {size === "fullScreen" ? longBody : sampleBody} + + Save changes + + } + secondaryActions={ + + } + /> + +); + +export const Sizes: Story = () => ( +
+ {sizes.map((size) => ( +
+
+ {size} +
+ renderKitchenSink(size, close)} + /> + + renderKitchenSink(size, close, { loading: true }) + } + /> + {size === "xs" ? ( + <> + ( + + {sampleBody} + + )} + /> + ( + + {sampleBody} + + )} + /> + + ) : null} +
+ ))} +
+
+ custom +
+ ( + + + {sampleBody} + + Save changes + + } + /> + + )} + /> +
+
+); + +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 renderOverflowKitchenSink = ( + close: () => void, + options?: { loading?: boolean }, +) => ( + + + + + + + } + secondaryActions={ + + } + /> + +); + +const renderOverflowCustom = ( + close: () => void, + options?: { loading?: boolean }, +) => ( + + +
+
+ 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. +
+
+
+ {overflowingBody} + +
+ + A custom footer with a long status message to test wrapping behaviour + and layout adjustments under content pressure. + + +
+
+
+); + +const renderOverflowBodyOnly = ( + close: () => void, + options?: { loading?: boolean }, +) => ( + + {overflowingBody} + +); + +export const Overflow: Story = () => ( +
+ {([false, true] as const).map((loading) => ( +
+
+ {loading ? "loading" : "default"} +
+ + renderOverflowKitchenSink(close, { loading }) + } + /> + renderOverflowCustom(close, { loading })} + /> + renderOverflowBodyOnly(close, { loading })} + /> +
+ ))} +
+); + +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)}> + + {sampleBody} + setSecond(true)} + > + Open second dialog + + } + /> + + ) : null} + {second ? ( + setSecond(false)}> + + {sampleBody} + setThird(true)} + > + Open small dialog + + } + /> + + ) : null} + {third ? ( + setThird(false)}> + + {sampleBody} + setFourth(true)} + > + Open another small dialog + + } + /> + + ) : null} + {fourth ? ( + setFourth(false)}> + + {sampleBody} + setFourth(false)} + > + Done + + } + /> + + ) : null} + + ); +}; + +export const Stacked: Story = () => ; + +export const ShouldCloseOn: Story = () => ( +
+ ( + + + {sampleBody} + + Done + + } + /> + + )} + /> + + ( + + + {sampleBody} + + Done + + } + /> + + )} + /> + + ( + + + {sampleBody} + + Done + + } + /> + + )} + /> + + ( + + {sampleBody} + + Done + + } + /> + + )} + /> +
+); 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..067b08e59f3 --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx @@ -0,0 +1,275 @@ +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 { css, 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"; + +import type { ExclusifyUnion, RequireAtLeastOne } from "type-fest"; + +export type DialogSize = "xs" | "sm" | "md" | "lg" | "xl" | "fullScreen"; + +export type DialogShouldCloseOn = + | "closeButtonAndOverlay" + | "closeButton" + | "none"; + +const DialogContext = createContext<{ + classes: ReturnType; + onClose?: () => void; + 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; + iconName?: IconName; + actions?: React.ReactNode; + } + | { + children?: React.ReactNode; + } +>; +const Header = ({ + title, + description, + iconName, + actions, + children, +}: HeaderProps) => { + const { classes, onClose, renderCloseButton } = useDialogContext(); + + const hasStructuredHeader = + title !== undefined || + description !== undefined || + iconName !== undefined || + actions !== undefined; + + const closeButton = renderCloseButton && ( +