From 5cbb933c6a764863ae76404da0b3c2631781af1e Mon Sep 17 00:00:00 2001 From: vpolessky Date: Fri, 4 Sep 2026 17:05:01 +0200 Subject: [PATCH 1/4] chore(design-system): add cleanup sb overlay [AR-78170] --- .../__snapshots__/ds-drawer.docs.snap | 796 +++++++++++ .../ds-drawer/ds-drawer.stories.module.scss | 44 +- .../ds-drawer/ds-drawer.stories.tsx | 72 +- .../__snapshots__/ds-dropdown-menu.docs.snap | 1165 +++++++++++++++++ .../ds-dropdown-menu.browser.test.tsx | 71 +- .../ds-dropdown-menu-legacy.stories.tsx | 69 +- .../ds-dropdown-menu.stories.scss | 6 +- .../ds-dropdown-menu.stories.tsx | 74 +- .../__snapshots__/ds-modal.docs.snap | 369 ++++++ .../__tests__/ds-modal.browser.test.tsx | 83 +- .../ds-modal/ds-modal.stories.module.scss | 162 --- .../components/ds-modal/ds-modal.stories.tsx | 944 +++---------- .../__snapshots__/ds-panel.docs.snap | 203 +++ .../ds-panel/ds-panel.stories.module.scss | 5 + .../components/ds-panel/ds-panel.stories.tsx | 34 +- .../__snapshots__/ds-popover.docs.snap | 212 +++ .../__snapshots__/ds-toast.docs.snap | 578 ++++++++ .../__tests__/ds-toast.browser.test.tsx | 120 ++ .../ds-toast/ds-toast.stories.module.scss | 16 - .../components/ds-toast/ds-toast.stories.tsx | 580 ++++---- .../__snapshots__/ds-tooltip.docs.snap | 113 ++ .../ds-tooltip/ds-tooltip.stories.tsx | 15 +- .../storybook/docs-snippets.docs.test.ts | 7 + 23 files changed, 4294 insertions(+), 1444 deletions(-) create mode 100644 packages/design-system/src/components/ds-drawer/__tests__/__snapshots__/ds-drawer.docs.snap create mode 100644 packages/design-system/src/components/ds-dropdown-menu/__tests__/__snapshots__/ds-dropdown-menu.docs.snap create mode 100644 packages/design-system/src/components/ds-modal/__tests__/__snapshots__/ds-modal.docs.snap delete mode 100644 packages/design-system/src/components/ds-modal/ds-modal.stories.module.scss create mode 100644 packages/design-system/src/components/ds-panel/__tests__/__snapshots__/ds-panel.docs.snap create mode 100644 packages/design-system/src/components/ds-panel/ds-panel.stories.module.scss create mode 100644 packages/design-system/src/components/ds-popover/__tests__/__snapshots__/ds-popover.docs.snap create mode 100644 packages/design-system/src/components/ds-toast/__tests__/__snapshots__/ds-toast.docs.snap create mode 100644 packages/design-system/src/components/ds-toast/__tests__/ds-toast.browser.test.tsx delete mode 100644 packages/design-system/src/components/ds-toast/ds-toast.stories.module.scss create mode 100644 packages/design-system/src/components/ds-tooltip/__tests__/__snapshots__/ds-tooltip.docs.snap diff --git a/packages/design-system/src/components/ds-drawer/__tests__/__snapshots__/ds-drawer.docs.snap b/packages/design-system/src/components/ds-drawer/__tests__/__snapshots__/ds-drawer.docs.snap new file mode 100644 index 000000000..452fe9b3e --- /dev/null +++ b/packages/design-system/src/components/ds-drawer/__tests__/__snapshots__/ds-drawer.docs.snap @@ -0,0 +1,796 @@ +# DsDrawer docs snippets + +## Default + +### Show code +{ + render: DrawerTemplate, + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + args: { + children: <> + + + Default Drawer + + + + + + + + This is a description caption under a title. + + + + + }} /> + + + +
+ + Drawer content header + + + Out of scope section + +
+
+ + Drawer content header + + + Out of scope section + + + Out of scope section + +
+
+ + + + Cancel + + + Save + + + + + } +} + +### MCP manifest +const Default = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ setIsOpen(true)}>Open Drawer + + (<> + + + Default Drawer + + + + + + + + This is a description caption under a title. + + + + }} + /> + + + +
+ + Drawer content header + + + Out of scope section + +
+
+ + Drawer content header + + + Out of scope section + + + Out of scope section + +
+
+ + + + Cancel + + + Save + + + + ) +
+
+ ); +}; + +## With Tabs + +### Show code +{ + render: DrawerTemplate, + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + args: { + columns: 8, + children: <> + + + Drawer with Tabs + + + + + + + + This is a description caption under a title. + + + +
+ +
+
+ + Drawer content header + + + Out of scope section + +
+
+ + + + Cancel + + + Save + + + + + } +} + +### MCP manifest +const WithTabs = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ setIsOpen(true)}>Open Drawer + + (<> + + + Drawer with Tabs + + + + + + + + This is a description caption under a title. + + + +
+ +
+
+ + Drawer content header + + + Out of scope section + +
+
+ + + + Cancel + + + Save + + + + ) +
+
+ ); +}; + +## With Backdrop And Scroll + +### Show code +{ + render: DrawerTemplate, + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + args: { + backdrop: true, + children: <> + + Basic Drawer + + + +
+ + Drawer content header + + + Out of scope section + +
+
+ + Drawer content header + + + Out of scope section + + + Out of scope section + +
+
+ + } +} + +### MCP manifest +const WithBackdropAndScroll = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ setIsOpen(true)}>Open Drawer + + (<> + + Basic Drawer + + + +
+ + Drawer content header + + + Out of scope section + +
+
+ + Drawer content header + + + Out of scope section + + + Out of scope section + +
+
+ ) +
+
+ ); +}; + +## Dock To Start + +### Show code +{ + render: DrawerTemplate, + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + args: { + position: 'start', + children: <> + + Basic Drawer + + + +
+ + Drawer content header + + + Out of scope section + +
+
+ + Drawer content header + + + Out of scope section + + + Out of scope section + +
+
+ + } +} + +### MCP manifest +const DockToStart = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ setIsOpen(true)}>Open Drawer + + (<> + + Basic Drawer + + + +
+ + Drawer content header + + + Out of scope section + +
+
+ + Drawer content header + + + Out of scope section + + + Out of scope section + +
+
+ ) +
+
+ ); +}; + +## With Grid Content + +### Show code +{ + render: DrawerTemplate, + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + args: { + columns: 10, + children: <> + + Basic Drawer + + + +
+ + Drawer content header + + + Out of scope section + +
+
+ + Drawer content header + + + Out of scope section + +
+
+ + Drawer content header + + + Out of scope section + +
+
+ + } +} + +### MCP manifest +const WithGridContent = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ setIsOpen(true)}>Open Drawer + + (<> + + Basic Drawer + + + +
+ + Drawer content header + + + Out of scope section + +
+
+ + Drawer content header + + + Out of scope section + +
+
+ + Drawer content header + + + Out of scope section + +
+
+ ) +
+
+ ); +}; + +## Responsive + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render() { + const [openDrawer, setOpenDrawer] = useState(null); + const close = () => setOpenDrawer(null); + const variants = [{ + label: '3 cols → 4 on md', + columns: { + lg: 3, + md: 4 + } + }, { + label: '4 cols → 6 on md', + columns: { + lg: 4, + md: 6 + } + }, { + label: '5 cols → 6 on md', + columns: { + lg: 5, + md: 6 + } + }, { + label: '6 cols → 10 on md', + columns: { + lg: 6, + md: 10 + } + }, { + label: '8 cols → 10 on md', + columns: { + lg: 8, + md: 10 + } + }] satisfies Array<{ + label: string; + columns: ResponsiveValue; + }>; + return
+ + Resize the window below 1440 px to see the responsive column change. + + +
+ {variants.map(({ + label + }) => setOpenDrawer(label)}> + {label} + )} +
+ + {variants.map(({ + label, + columns + }) => !open && close()} columns={columns}> + + {label} + + + +
+ + lg: {columns.lg} columns · md: {columns.md} columns + + + Drawer content + +
+
+
)} +
; + } +} + +### MCP manifest +const Responsive = function Render() { + const [openDrawer, setOpenDrawer] = useState(null); + + const close = () => setOpenDrawer(null); + + const variants = [ + { label: '3 cols → 4 on md', columns: { lg: 3, md: 4 } }, + { label: '4 cols → 6 on md', columns: { lg: 4, md: 6 } }, + { label: '5 cols → 6 on md', columns: { lg: 5, md: 6 } }, + { label: '6 cols → 10 on md', columns: { lg: 6, md: 10 } }, + { label: '8 cols → 10 on md', columns: { lg: 8, md: 10 } }, + ] satisfies Array<{ label: string; columns: ResponsiveValue }>; + + return ( +
+ + Resize the window below 1440 px to see the responsive column change. + + +
+ {variants.map(({ label }) => ( + setOpenDrawer(label)}> + {label} + + ))} +
+ + {variants.map(({ label, columns }) => ( + !open && close()} + columns={columns} + > + + {label} + + + +
+ + lg: {columns.lg} columns · md: {columns.md} columns + + + Drawer content + +
+
+
+ ))} +
+ ); +}; + +## Toggle Full Size + +### Show code +{ + args: { + columns: 4 + }, + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args: DsDrawerProps) { + const [isOpen, setIsOpen] = useState(false); + const [isFullScreen, setIsFullScreen] = useState(false); + const toggleFullScreen = () => { + setIsFullScreen(!isFullScreen); + }; + return
+ setIsOpen(true)}>Open Drawer + + + + Expandable Drawer + + + + + + + +
+ + Drawer content header + + + Out of scope section + +
+
+
+
; + } +} + +### MCP manifest +const ToggleFullSize = () => { + const [isOpen, setIsOpen] = useState(false); + const [isFullScreen, setIsFullScreen] = useState(false); + + const toggleFullScreen = () => { + setIsFullScreen(!isFullScreen); + }; + + return ( +
+ setIsOpen(true)}>Open Drawer + + + Expandable Drawer + + + + + + + +
+ Drawer content header + + Out of scope section + +
+
+
+
+ ); +}; + +## Prevent Open Auto Focus + +### Show code +{ + args: { + onOpenAutoFocus: fn((event: Event) => event.preventDefault()) + }, + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args: DsDrawerProps) { + const [query, setQuery] = useState(''); + return
+ + }} /> + + 0} onOpenChange={open => !open && setQuery('')}> + + Suggestions + + + + + Focus stayed in the input — keep typing without losing your place. + + + +
; + } +} + +### MCP manifest +const PreventOpenAutoFocus = () => { + const [query, setQuery] = useState(''); + + return ( +
+ }} /> + event.preventDefault())} + open={query.length > 0} + onOpenChange={(open) => !open && setQuery('')}> + + Suggestions + + + + Focus stayed in the input — keep typing without losing your place. + + + +
+ ); +}; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-drawer/ds-drawer.stories.module.scss b/packages/design-system/src/components/ds-drawer/ds-drawer.stories.module.scss index c92e89144..426abd5dd 100644 --- a/packages/design-system/src/components/ds-drawer/ds-drawer.stories.module.scss +++ b/packages/design-system/src/components/ds-drawer/ds-drawer.stories.module.scss @@ -3,21 +3,8 @@ flex-basis: 100%; } -.headerActions { - display: flex; - align-items: center; - - .expand { - display: flex; - align-items: center; - } - - .divider { - width: 1px; - height: var(--icon-width-tiny); - background: var(--border-secondary); - margin: var(--2xs) var(--xs); - } +.searchInput { + flex: 1; } .tabs { @@ -81,6 +68,29 @@ } } +/* Keeps a section hugging its content instead of growing to fill the body. */ +.tabsSection { + flex: 0; +} + +/* Spans a section across two rows in the grid body layout. */ +.spanTwoRows { + grid-row: span 2; +} + +/* Demo-only height utilities to simulate tall, scrollable content. */ +.tall { + min-height: 12.5rem; +} + +.taller { + min-height: 18.75rem; +} + +.tallest { + min-height: 31.25rem; +} + .storyWrapper { padding: 2rem; } @@ -91,7 +101,3 @@ gap: var(--xs); margin-top: var(--sm); } - -.contentSection { - padding: 1rem; -} diff --git a/packages/design-system/src/components/ds-drawer/ds-drawer.stories.tsx b/packages/design-system/src/components/ds-drawer/ds-drawer.stories.tsx index 36973f7cf..9ed567577 100644 --- a/packages/design-system/src/components/ds-drawer/ds-drawer.stories.tsx +++ b/packages/design-system/src/components/ds-drawer/ds-drawer.stories.tsx @@ -1,11 +1,14 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; -import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; +import { fn } from 'storybook/test'; import classNames from 'classnames'; import DsDrawer from './ds-drawer'; import { DsButton } from '../ds-button'; +import { DsButtonV3 } from '../ds-button-v3'; import { DsTextInput } from '../ds-text-input'; import { DsIcon } from '../ds-icon'; +import { DsDivider } from '../ds-divider'; +import { DsStack } from '../ds-stack'; // TODO: Use DsStatusBadge instead. import { DsSystemStatus } from '../ds-system-status'; import styles from './ds-drawer.stories.module.scss'; @@ -81,6 +84,7 @@ const DrawerTemplate = (args: DsDrawerProps) => { export const Default: Story = { render: DrawerTemplate, + parameters: { docs: { source: { type: 'code' } } }, args: { children: ( <> @@ -88,13 +92,11 @@ export const Default: Story = { Default Drawer -
- -
+ + + -
+ This is a description caption under a title. @@ -102,7 +104,7 @@ export const Default: Story = { }} /> @@ -163,6 +165,7 @@ const Tabs = ({ total = 4 }: { total?: number }) => { export const WithTabs: Story = { render: DrawerTemplate, + parameters: { docs: { source: { type: 'code' } } }, args: { columns: 8, children: ( @@ -171,19 +174,17 @@ export const WithTabs: Story = { Drawer with Tabs -
- -
+ + + -
+ This is a description caption under a title. -
+
@@ -212,6 +213,7 @@ export const WithTabs: Story = { export const WithBackdropAndScroll: Story = { render: DrawerTemplate, + parameters: { docs: { source: { type: 'code' } } }, args: { backdrop: true, children: ( @@ -225,7 +227,7 @@ export const WithBackdropAndScroll: Story = { Drawer content header - + Out of scope section
@@ -233,10 +235,10 @@ export const WithBackdropAndScroll: Story = { Drawer content header - + Out of scope section - + Out of scope section
@@ -248,6 +250,7 @@ export const WithBackdropAndScroll: Story = { export const DockToStart: Story = { render: DrawerTemplate, + parameters: { docs: { source: { type: 'code' } } }, args: { position: 'start', children: ( @@ -284,6 +287,7 @@ export const DockToStart: Story = { export const WithGridContent: Story = { render: DrawerTemplate, + parameters: { docs: { source: { type: 'code' } } }, args: { columns: 10, children: ( @@ -293,7 +297,7 @@ export const WithGridContent: Story = { -
+
Drawer content header @@ -333,6 +337,7 @@ export const WithGridContent: Story = { * - 6+ cols → `{ lg: 6, md: 10 }` (up to 10) */ export const Responsive: Story = { + parameters: { docs: { source: { type: 'code' } } }, render: function Render() { const [openDrawer, setOpenDrawer] = useState(null); @@ -392,6 +397,7 @@ export const ToggleFullSize: Story = { args: { columns: 4, }, + parameters: { docs: { source: { type: 'code' } } }, render: function Render(args: DsDrawerProps) { const [isOpen, setIsOpen] = useState(false); const [isFullScreen, setIsFullScreen] = useState(false); @@ -412,17 +418,17 @@ export const ToggleFullSize: Story = { > Expandable Drawer -
- -
+ /> + -
+
@@ -450,6 +456,7 @@ export const PreventOpenAutoFocus: Story = { args: { onOpenAutoFocus: fn((event: Event) => event.preventDefault()), }, + parameters: { docs: { source: { type: 'code' } } }, render: function Render(args: DsDrawerProps) { const [query, setQuery] = useState(''); @@ -476,15 +483,4 @@ export const PreventOpenAutoFocus: Story = {
); }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = canvas.getByPlaceholderText(/start typing/i); - await userEvent.type(input, 'hello'); - - await waitFor(() => expect(canvas.getByRole('dialog')).toBeVisible()); - - await waitFor(() => expect(input).toHaveFocus()); - await expect(args.onOpenAutoFocus).toHaveBeenCalled(); - }, }; diff --git a/packages/design-system/src/components/ds-dropdown-menu/__tests__/__snapshots__/ds-dropdown-menu.docs.snap b/packages/design-system/src/components/ds-dropdown-menu/__tests__/__snapshots__/ds-dropdown-menu.docs.snap new file mode 100644 index 000000000..fae5c864a --- /dev/null +++ b/packages/design-system/src/components/ds-dropdown-menu/__tests__/__snapshots__/ds-dropdown-menu.docs.snap @@ -0,0 +1,1165 @@ +# DsDropdownMenu.Root docs snippets + +## Default + +### Show code +{ + parameters: { + docs: { + description: { + story: 'A basic dropdown menu with action items. Each item can have an icon and onSelect handler. Items can be disabled. Use separators to divide different action groups.' + } + } + }, + render: () => { + const handleEdit = fn(); + const handleDuplicate = fn(); + const handleShare = fn(); + const handleDelete = fn(); + return + + Actions + + + + + + Edit + + + + Duplicate + + + + Share + + + + + Delete + + + + Disabled Option + + + ; + } +} + +### MCP manifest +const Default = () => { + const handleEdit = fn(); + const handleDuplicate = fn(); + const handleShare = fn(); + const handleDelete = fn(); + + return ( + + + Actions + + + + + + Edit + + + + Duplicate + + + + Share + + + + + Delete + + + + Disabled Option + + + + ); +}; + +## Selectable List with Search + +### Show code +{ + name: 'Selectable List with Search', + parameters: { + docs: { + description: { + story: 'Dropdown with search functionality and selection tracking. Users can filter items and see which item is selected with a check indicator.' + }, + source: { + type: 'code' + } + } + }, + render: function Render() { + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState('option1'); + const options = [{ + value: 'option1', + label: 'Option 1' + }, { + value: 'option2', + label: 'Option 2' + }, { + value: 'option3', + label: 'Option 3' + }, { + value: 'option4', + label: 'Option 4' + }]; + const selectedOption = options.find(opt => opt.value === selected)?.label; + const filteredOptions = options.filter(opt => opt.label.toLowerCase().includes(search.toLowerCase())); + return + + {selectedOption || 'Select an option'} + + + + + e.stopPropagation()} slots={{ + startAdornment: + }} /> + + {filteredOptions.map(option => + {option.label} + {selected === option.value && } + )} + + ; + } +} + +### MCP manifest +const SelectableList = function Render() { + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState('option1'); + + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, + { value: 'option4', label: 'Option 4' }, + ]; + + const selectedOption = options.find((opt) => opt.value === selected)?.label; + const filteredOptions = options.filter((opt) => opt.label.toLowerCase().includes(search.toLowerCase())); + + return ( + + + {selectedOption || 'Select an option'} + + + + + e.stopPropagation()} + slots={{ + startAdornment: , + }} + /> + + {filteredOptions.map((option) => ( + + {option.label} + {selected === option.value && } + + ))} + + + ); +}; + +## Checkbox List with Groups + +### Show code +{ + name: 'Checkbox List with Groups', + parameters: { + docs: { + description: { + story: 'Dropdown with checkbox items in collapsible groups. Uses DsCheckbox component for each item. Includes search and action buttons.' + }, + source: { + type: 'code' + } + } + }, + render: function Render() { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState(new Set(['item1'])); + const items = [{ + id: 'item1', + label: 'Menu text 1', + description: 'Info Text' + }, { + id: 'item2', + label: 'Menu text 2', + description: 'Info Text' + }, { + id: 'item-error', + label: 'Error item', + description: 'Something went wrong', + variant: 'error' as const + }]; + const groupedItems = [{ + id: 'item3', + label: 'Menu text 3', + description: 'Info Text' + }, { + id: 'item4', + label: 'Menu text 4', + description: 'Info Text' + }, { + id: 'item5', + label: 'Menu text 5', + description: 'Info Text' + }, { + id: 'item6', + label: 'Menu text 6', + description: 'Info Text' + }, { + id: 'item7', + label: 'Menu text 7', + description: 'Info Text' + }]; + const filteredItems = items.filter(item => item.label.toLowerCase().includes(search.toLowerCase())); + const filteredGroupedItems = groupedItems.filter(item => item.label.toLowerCase().includes(search.toLowerCase())); + const toggleSelection = (id: string) => { + const newSelected = new Set(selected); + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + setSelected(newSelected); + }; + const handleApply = fn(); + const handleCancel = () => { + setOpen(false); + setSearch(''); + }; + return + + Multi Select ({selected.size}) + + + + + e.stopPropagation()} slots={{ + startAdornment: + }} /> + + {filteredItems.map(item => { + const isError = 'variant' in item && item.variant === 'error'; + return + {isError ? : toggleSelection(item.id)} />} + + + {item.label} + + + {item.description} + + + ; + })} + {!!filteredGroupedItems.length && + Group Name + + {filteredGroupedItems.map(item => + toggleSelection(item.id)} /> + + + {item.label} + + + {item.description} + + + )} + + } + + + Cancel + + + Apply + + + + ; + } +} + +### MCP manifest +const CheckboxList = function Render() { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState(new Set(['item1'])); + + const items = [ + { id: 'item1', label: 'Menu text 1', description: 'Info Text' }, + { id: 'item2', label: 'Menu text 2', description: 'Info Text' }, + { + id: 'item-error', + label: 'Error item', + description: 'Something went wrong', + variant: 'error' as const, + }, + ]; + + const groupedItems = [ + { id: 'item3', label: 'Menu text 3', description: 'Info Text' }, + { id: 'item4', label: 'Menu text 4', description: 'Info Text' }, + { id: 'item5', label: 'Menu text 5', description: 'Info Text' }, + { id: 'item6', label: 'Menu text 6', description: 'Info Text' }, + { id: 'item7', label: 'Menu text 7', description: 'Info Text' }, + ]; + + const filteredItems = items.filter((item) => item.label.toLowerCase().includes(search.toLowerCase())); + const filteredGroupedItems = groupedItems.filter((item) => + item.label.toLowerCase().includes(search.toLowerCase()), + ); + + const toggleSelection = (id: string) => { + const newSelected = new Set(selected); + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + setSelected(newSelected); + }; + + const handleApply = fn(); + + const handleCancel = () => { + setOpen(false); + setSearch(''); + }; + + return ( + + + Multi Select ({selected.size}) + + + + + e.stopPropagation()} + slots={{ + startAdornment: , + }} + /> + + {filteredItems.map((item) => { + const isError = 'variant' in item && item.variant === 'error'; + + return ( + + {isError ? ( + + ) : ( + toggleSelection(item.id)} + /> + )} + + + {item.label} + + + {item.description} + + + + ); + })} + {!!filteredGroupedItems.length && ( + + Group Name + + {filteredGroupedItems.map((item) => ( + + toggleSelection(item.id)} + /> + + + {item.label} + + + {item.description} + + + + ))} + + + )} + + + Cancel + + + Apply + + + + + ); +}; + +## Collapsible Group (Controlled) + +### Show code +{ + name: 'Collapsible Group (Controlled)', + parameters: { + docs: { + description: { + story: 'The collapsed state is controlled externally via props, and the onCollapsedChange callback is triggered when the user clicks the group label.' + }, + source: { + type: 'code' + } + } + }, + render: function Render(args) { + const [collapsed, setCollapsed] = useState(false); + const handleCollapsedChange = (newCollapsed: boolean) => { + setCollapsed(newCollapsed); + args.onCollapsedChange?.(newCollapsed); + }; + return + + Controlled Group + + + + + Settings + + + + Profile + + + + Preferences + + + + Notifications + + + + + ; + }, + args: { + onCollapsedChange: fn() + } +} + +### MCP manifest +const CollapsibleGroupControlled = function Render(args) { + const [collapsed, setCollapsed] = useState(false); + + const handleCollapsedChange = (newCollapsed: boolean) => { + setCollapsed(newCollapsed); + args.onCollapsedChange?.(newCollapsed); + }; + + return ( + + + Controlled Group + + + + + Settings + + + + Profile + + + + Preferences + + + + Notifications + + + + + + ); +}; + +## Radio List with Actions + +### Show code +{ + name: 'Radio List with Actions', + parameters: { + docs: { + description: { + story: 'Dropdown with radio items using DsRadioGroup. Single selection with search and action buttons. Blue background for selected items.' + }, + source: { + type: 'code' + } + } + }, + render: function Render() { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const [tempSelected, setTempSelected] = useState(null); + const options = [{ + value: 'option1', + label: 'Menu text 1', + description: 'Info Text' + }, { + value: 'option2', + label: 'Menu text 2', + description: 'Info Text' + }, { + value: 'option3', + label: 'Menu text 3', + description: 'Info Text' + }, { + value: 'option4', + label: 'Menu text 4', + description: 'Info Text' + }]; + const filteredOptions = options.filter(opt => opt.label.toLowerCase().includes(search.toLowerCase())); + const handleApply = () => { + setOpen(false); + }; + const handleCancel = () => { + setOpen(false); + setSearch(''); + }; + const handleReset = () => { + setTempSelected(''); + setOpen(false); + setSearch(''); + }; + return + + {tempSelected || 'Select an option'} + + + + + e.stopPropagation()} slots={{ + startAdornment: + }} /> + + + {filteredOptions.map(option => + + + + {option.label} + + + {option.description} + + + )} + + + + Reset + + + Cancel + + + Apply + + + + ; + } +} + +### MCP manifest +const RadioList = function Render() { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const [tempSelected, setTempSelected] = useState(null); + + const options = [ + { value: 'option1', label: 'Menu text 1', description: 'Info Text' }, + { value: 'option2', label: 'Menu text 2', description: 'Info Text' }, + { value: 'option3', label: 'Menu text 3', description: 'Info Text' }, + { value: 'option4', label: 'Menu text 4', description: 'Info Text' }, + ]; + + const filteredOptions = options.filter((opt) => opt.label.toLowerCase().includes(search.toLowerCase())); + + const handleApply = () => { + setOpen(false); + }; + + const handleCancel = () => { + setOpen(false); + setSearch(''); + }; + + const handleReset = () => { + setTempSelected(''); + setOpen(false); + setSearch(''); + }; + + return ( + + + {tempSelected || 'Select an option'} + + + + + e.stopPropagation()} + slots={{ + startAdornment: , + }} + /> + + + {filteredOptions.map((option) => ( + + + + + {option.label} + + + {option.description} + + + + ))} + + + + Reset + + + Cancel + + + Apply + + + + + ); +}; + +## User Menu + +### Show code +{ + parameters: { + docs: { + description: { + story: 'The User Menu composed pattern: a `DsUserCard` header identifying the signed-in user, a list of account actions, and a full-width logout action pinned in a sticky `Actions align="stretch"` slot. User Menu is not a component — it is this composition of `DsUserCard`, `DsDropdownMenu.Item`s, and `DsDropdownMenu.Actions`.' + } + } + }, + render: () => { + const handleProfile = fn(); + const handleSettings = fn(); + const handleLogout = fn(); + return + + + + + + + + + + Profile + + + + Settings + + + + + Log out + + + + ; + } +} + +### MCP manifest +const UserMenu = () => { + const handleProfile = fn(); + const handleSettings = fn(); + const handleLogout = fn(); + + return ( + + + + + + + + + + + Profile + + + + Settings + + + + + Log out + + + + + ); +}; + +## Action Menu + +### Show code +{ + parameters: { + docs: { + description: { + story: 'Action Menu pattern from Figma design system demonstrating nested submenus. Features both full-size button and icon button variants. Menu items can trigger submenus using TriggerItem with right arrow indicators. Includes separators and danger-styled items for risky actions.' + } + } + }, + render: () => { + const handleEdit = fn(); + const handleDuplicate = fn(); + const handleShareEmail = fn(); + const handleShareLink = fn(); + const handleShareSocial = fn(); + const handleDelete = fn(); + return + + + + + + + + + Edit + + + + Duplicate + + + + + Share + + + + + Email + + + + Copy Link + + + + Social Media + + + + + + + Delete item + + + ; + } +} + +### MCP manifest +const ActionMenu = () => { + const handleEdit = fn(); + const handleDuplicate = fn(); + const handleShareEmail = fn(); + const handleShareLink = fn(); + const handleShareSocial = fn(); + const handleDelete = fn(); + + return ( + + + + + + + + + + Edit + + + + Duplicate + + + + + Share + + + + + Email + + + + Copy Link + + + + Social Media + + + + + + + Delete item + + + + ); +}; + +## Nested Submenus (3 levels) + +### Show code +{ + name: 'Nested Submenus (3 levels)', + parameters: { + docs: { + description: { + story: 'Submenus nest arbitrarily deep by nesting a `Root` + `TriggerItem` + `Content` inside a parent `Content`. Here a top-level "New" item opens a submenu whose "From Template" item opens a third-level submenu. Each nested `Root` needs its own `placement` (`right-start`) so the flyout opens beside its trigger.' + } + } + }, + render: () => { + const handleBlank = fn(); + const handleResume = fn(); + const handleInvoice = fn(); + const handleLetter = fn(); + const handleOpen = fn(); + return + + File + + + + + + + New + + + + + Blank Document + + + + + From Template + + + + Resume + + + Invoice + + + Letter + + + + + + + + Open… + + + ; + } +} + +### MCP manifest +const NestedSubmenus = () => { + const handleBlank = fn(); + const handleResume = fn(); + const handleInvoice = fn(); + const handleLetter = fn(); + const handleOpen = fn(); + + return ( + + + File + + + + + + + New + + + + + Blank Document + + + + + From Template + + + + Resume + + + Invoice + + + Letter + + + + + + + + Open… + + + + ); +}; + +# DsDropdownMenuLegacy docs snippets + +## Default + +### Show code + {} + }, + { + icon: 'delete', + label: 'Delete', + onClick: () => {} + }, + { + icon: 'share', + label: 'Share', + onClick: () => {} + }, + { + disabled: true, + icon: 'block', + label: 'Disabled Option', + onClick: () => {} + } + ]} +> +
+ + Actions + + +
+
+ +### MCP manifest +const Default = () => { + return ( + +
+ Actions + +
+
+ ); +}; + +## Without Icons + +### Show code + {} + }, + { + label: 'Duplicate', + onClick: () => {} + }, + { + label: 'Archive', + onClick: () => {} + } + ]} +> +
+ + Options + + +
+
+ +### MCP manifest +const WithoutIcons = () => { + return ( + +
+ Options + +
+
+ ); +}; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-dropdown-menu/__tests__/ds-dropdown-menu.browser.test.tsx b/packages/design-system/src/components/ds-dropdown-menu/__tests__/ds-dropdown-menu.browser.test.tsx index 8089dc9ba..7f783420b 100644 --- a/packages/design-system/src/components/ds-dropdown-menu/__tests__/ds-dropdown-menu.browser.test.tsx +++ b/packages/design-system/src/components/ds-dropdown-menu/__tests__/ds-dropdown-menu.browser.test.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { page } from 'vitest/browser'; -import { DsDropdownMenu } from '../ds-dropdown-menu'; +import { page, userEvent } from 'vitest/browser'; +import { DsDropdownMenu, DsDropdownMenuLegacy } from '../ds-dropdown-menu'; import { DsIcon } from '../../ds-icon'; import { DsCheckbox } from '../../ds-checkbox'; import { DsTypography } from '../../ds-typography'; @@ -284,3 +284,70 @@ describe('DsDropdownMenu', () => { }); }); }); + +describe('DsDropdownMenuLegacy', () => { + function LegacyDropdown({ onEdit }: { onEdit?: () => void }) { + return ( + +
+ Actions +
+
+ ); + } + + it('shows all menu items when the trigger is clicked', async () => { + await page.render(); + + await page.getByRole('button', { name: 'Actions' }).click(); + + await expect.element(page.getByRole('menuitem', { name: /Edit/ })).toBeVisible(); + await expect.element(page.getByRole('menuitem', { name: /Delete/ })).toBeVisible(); + await expect.element(page.getByRole('menuitem', { name: /Share/ })).toBeVisible(); + await expect.element(page.getByRole('menuitem', { name: /Disabled Option/ })).toBeVisible(); + }); + + it('marks disabled options with aria-disabled', async () => { + await page.render(); + + await page.getByRole('button', { name: 'Actions' }).click(); + + await expect + .element(page.getByRole('menuitem', { name: /Disabled Option/ })) + .toHaveAttribute('aria-disabled', 'true'); + }); + + it('calls the option onClick handler when an item is selected', async () => { + const onEdit = vi.fn(); + + await page.render(); + + await page.getByRole('button', { name: 'Actions' }).click(); + await page.getByRole('menuitem', { name: /Edit/ }).click(); + + expect(onEdit).toHaveBeenCalledOnce(); + }); + + it('closes on Escape and reopens with items intact', async () => { + await page.render(); + + await page.getByRole('button', { name: 'Actions' }).click(); + await expect.element(page.getByRole('menuitem', { name: /Edit/ })).toBeVisible(); + + await userEvent.keyboard('{Escape}'); + await expect.element(page.getByRole('menuitem', { name: /Edit/ })).not.toBeInTheDocument(); + + await page.getByRole('button', { name: 'Actions' }).click(); + await expect.element(page.getByRole('menuitem', { name: /Edit/ })).toBeVisible(); + await expect.element(page.getByRole('menuitem', { name: /Delete/ })).toBeVisible(); + await expect.element(page.getByRole('menuitem', { name: /Share/ })).toBeVisible(); + await expect.element(page.getByRole('menuitem', { name: /Disabled Option/ })).toBeVisible(); + }); +}); diff --git a/packages/design-system/src/components/ds-dropdown-menu/ds-dropdown-menu-legacy.stories.tsx b/packages/design-system/src/components/ds-dropdown-menu/ds-dropdown-menu-legacy.stories.tsx index 22e690805..0fe464cf4 100644 --- a/packages/design-system/src/components/ds-dropdown-menu/ds-dropdown-menu-legacy.stories.tsx +++ b/packages/design-system/src/components/ds-dropdown-menu/ds-dropdown-menu-legacy.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, screen, userEvent, within } from 'storybook/test'; +import { fn } from 'storybook/test'; import { DsDropdownMenuLegacy } from './ds-dropdown-menu'; import './ds-dropdown-menu.stories.scss'; import { DsIcon } from '../ds-icon'; @@ -37,14 +37,14 @@ export const Default: Story = { }, args: { options: [ - { label: 'Edit', icon: 'edit', onClick: () => console.log('Edit clicked') }, - { label: 'Delete', icon: 'delete', onClick: () => console.log('Delete clicked') }, - { label: 'Share', icon: 'share', onClick: () => console.log('Share clicked') }, + { label: 'Edit', icon: 'edit', onClick: fn() }, + { label: 'Delete', icon: 'delete', onClick: fn() }, + { label: 'Share', icon: 'share', onClick: fn() }, { label: 'Disabled Option', icon: 'block', disabled: true, - onClick: () => console.log('Disabled clicked'), + onClick: fn(), }, ], contentGap: 4, @@ -59,41 +59,30 @@ export const Default: Story = { ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Check initial state - await expect(canvas.getByText('Actions')).toBeInTheDocument(); - - // Open dropdown menu - await userEvent.click(canvas.getByText('Actions')); - - // Check all menu items are present - await expect(screen.getByRole('menuitem', { name: /Edit/ })).toBeInTheDocument(); - await expect(screen.getByRole('menuitem', { name: /Delete/ })).toBeInTheDocument(); - await expect(screen.getByRole('menuitem', { name: /Share/ })).toBeInTheDocument(); - await expect(screen.getByRole('menuitem', { name: /Disabled Option/ })).toBeInTheDocument(); - - // Check disabled state - const disabledOption = screen.getByRole('menuitem', { name: /Disabled Option/ }); - await expect(disabledOption).toHaveAttribute('aria-disabled', 'true'); - - // Click an option - await userEvent.click(screen.getByRole('menuitem', { name: /Edit/ })); - - // Close dropdown with Escape key - await userEvent.keyboard('{Escape}'); - - // Open dropdown again - await userEvent.click(canvas.getByText('Actions')); - - // Check all options are shown again - await expect(screen.getByRole('menuitem', { name: /Edit/ })).toBeInTheDocument(); - await expect(screen.getByRole('menuitem', { name: /Delete/ })).toBeInTheDocument(); - await expect(screen.getByRole('menuitem', { name: /Share/ })).toBeInTheDocument(); - await expect(screen.getByRole('menuitem', { name: /Disabled Option/ })).toBeInTheDocument(); +}; - // Close dropdown with Escape key - await userEvent.keyboard('{Escape}'); +/** + * `options` items work without icons — pass just `label` and `onClick` for a + * compact, text-only action list. `contentGap` sets the space between the trigger + * and the menu. + */ +export const WithoutIcons: Story = { + args: { + options: [ + { label: 'Rename', onClick: fn() }, + { label: 'Duplicate', onClick: fn() }, + { label: 'Archive', onClick: fn() }, + ], + contentGap: 8, + }, + render: function Render(args) { + return ( + +
+ Options + +
+
+ ); }, }; diff --git a/packages/design-system/src/components/ds-dropdown-menu/ds-dropdown-menu.stories.scss b/packages/design-system/src/components/ds-dropdown-menu/ds-dropdown-menu.stories.scss index 353e20ee6..8ac6bfd8a 100644 --- a/packages/design-system/src/components/ds-dropdown-menu/ds-dropdown-menu.stories.scss +++ b/packages/design-system/src/components/ds-dropdown-menu/ds-dropdown-menu.stories.scss @@ -7,7 +7,7 @@ gap: var(--xs); padding: var(--xs) var(--sm); border: 1px solid var(--color-dap-gray-500); - border-radius: 4px; + border-radius: var(--3xs); background: var(--color-dap-gray-050); cursor: pointer; transition: border-color 0.2s; @@ -21,7 +21,7 @@ } span { - font-size: 14px; + font-size: var(--body-font-size-sm); color: var(--color-dap-gray-700); } } @@ -37,7 +37,7 @@ .item-content { display: flex; flex-direction: column; - gap: 2px; + gap: var(--3xs); flex-grow: 1; } diff --git a/packages/design-system/src/components/ds-dropdown-menu/ds-dropdown-menu.stories.tsx b/packages/design-system/src/components/ds-dropdown-menu/ds-dropdown-menu.stories.tsx index 3fa552ce9..f8829154c 100644 --- a/packages/design-system/src/components/ds-dropdown-menu/ds-dropdown-menu.stories.tsx +++ b/packages/design-system/src/components/ds-dropdown-menu/ds-dropdown-menu.stories.tsx @@ -6,6 +6,7 @@ import { DsIcon } from '../ds-icon'; import { DsTextInput } from '../ds-text-input'; import { DsCheckbox } from '../ds-checkbox'; import { DsTypography } from '../ds-typography'; +import { DsStack } from '../ds-stack'; import { DsButton } from '../ds-button'; import { DsRadioGroup } from '../ds-radio-group'; import { DsUserCard } from '../ds-user-card'; @@ -34,10 +35,10 @@ export const Default: Story = { }, }, render: () => { - const handleEdit = () => console.log('Edit clicked'); - const handleDuplicate = () => console.log('Duplicate clicked'); - const handleShare = () => console.log('Share clicked'); - const handleDelete = () => console.log('Delete clicked'); + const handleEdit = fn(); + const handleDuplicate = fn(); + const handleShare = fn(); + const handleDelete = fn(); return ( @@ -81,6 +82,7 @@ export const SelectableList: Story = { story: 'Dropdown with search functionality and selection tracking. Users can filter items and see which item is selected with a check indicator.', }, + source: { type: 'code' }, }, }, render: function Render() { @@ -135,6 +137,7 @@ export const CheckboxList: Story = { story: 'Dropdown with checkbox items in collapsible groups. Uses DsCheckbox component for each item. Includes search and action buttons.', }, + source: { type: 'code' }, }, }, render: function Render() { @@ -176,12 +179,9 @@ export const CheckboxList: Story = { setSelected(newSelected); }; - const handleApply = () => { - console.log('Applied selections:', Array.from(selected)); - }; + const handleApply = fn(); const handleCancel = () => { - console.log('Cancelled'); setOpen(false); setSearch(''); }; @@ -212,7 +212,6 @@ export const CheckboxList: Story = { {filteredItems.map((item) => { const isError = 'variant' in item && item.variant === 'error'; - const errorStyle = isError ? { color: 'var(--font-error)' } : undefined; return ( @@ -225,14 +224,22 @@ export const CheckboxList: Story = { onCheckedChange={() => toggleSelection(item.id)} /> )} -
- + + {item.label} - + {item.description} -
+
); })} @@ -247,14 +254,14 @@ export const CheckboxList: Story = { checked={selected.has(item.id)} onCheckedChange={() => toggleSelection(item.id)} /> -
+ {item.label} {item.description} -
+ ))} @@ -286,6 +293,7 @@ export const CollapsibleGroupControlled: StoryObj { setOpen(false); - console.log(`Applied: ${JSON.stringify(tempSelected)}`); }; const handleCancel = () => { - console.log('Cancelled'); setOpen(false); setSearch(''); }; const handleReset = () => { - console.log('Reset'); setTempSelected(''); setOpen(false); setSearch(''); @@ -403,14 +409,14 @@ export const RadioList: Story = { className={tempSelected === option.value ? 'radio-selected' : ''} > -
+ {option.label} {option.description} -
+ ))} @@ -441,9 +447,9 @@ export const UserMenu: Story = { }, }, render: () => { - const handleProfile = () => console.log('Profile clicked'); - const handleSettings = () => console.log('Settings clicked'); - const handleLogout = () => console.log('Log out clicked'); + const handleProfile = fn(); + const handleSettings = fn(); + const handleLogout = fn(); return ( @@ -484,12 +490,12 @@ export const ActionMenu: Story = { }, }, render: () => { - const handleEdit = () => console.log('Edit clicked'); - const handleDuplicate = () => console.log('Duplicate clicked'); - const handleShareEmail = () => console.log('Share via Email clicked'); - const handleShareLink = () => console.log('Copy Link clicked'); - const handleShareSocial = () => console.log('Share to Social Media clicked'); - const handleDelete = () => console.log('Delete item clicked'); + const handleEdit = fn(); + const handleDuplicate = fn(); + const handleShareEmail = fn(); + const handleShareLink = fn(); + const handleShareSocial = fn(); + const handleDelete = fn(); return ( @@ -549,11 +555,11 @@ export const NestedSubmenus: Story = { }, }, render: () => { - const handleBlank = () => console.log('Blank document clicked'); - const handleResume = () => console.log('Resume template clicked'); - const handleInvoice = () => console.log('Invoice template clicked'); - const handleLetter = () => console.log('Letter template clicked'); - const handleOpen = () => console.log('Open clicked'); + const handleBlank = fn(); + const handleResume = fn(); + const handleInvoice = fn(); + const handleLetter = fn(); + const handleOpen = fn(); return ( diff --git a/packages/design-system/src/components/ds-modal/__tests__/__snapshots__/ds-modal.docs.snap b/packages/design-system/src/components/ds-modal/__tests__/__snapshots__/ds-modal.docs.snap new file mode 100644 index 000000000..3d8fc0711 --- /dev/null +++ b/packages/design-system/src/components/ds-modal/__tests__/__snapshots__/ds-modal.docs.snap @@ -0,0 +1,369 @@ +# DsModal docs snippets + +## Default + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + args: { + columns: 4 + }, + render: function Render(args) { + const [open, setOpen] = useState(false); + return <> + setOpen(true)}>Open modal + + + Modal title + + + + + This is the default modal variant with simple content. + + + + + setOpen(false)}> + Cancel + + setOpen(false)}>Confirm + + + + ; + } +} + +### MCP manifest +const Default = () => { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(true)}>Open modal + + + Modal title + + + + This is the default modal variant with simple content. + + + + + setOpen(false)}>Cancel + + setOpen(false)}>Confirm + + + + + ); +}; + +## Divided + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + args: { + columns: 6, + dividers: true + }, + render: function Render(args) { + const [open, setOpen] = useState(false); + return <> + setOpen(true)}>Open modal + + + User profile + + + + + + + + + + + + + + + setOpen(false)}> + Cancel + + setOpen(false)}>Save changes + + + + ; + } +} + +### MCP manifest +const Divided = () => { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(true)}>Open modal + + + User profile + + + + + + + + + + + + + + + setOpen(false)}>Cancel + + setOpen(false)}>Save changes + + + + + ); +}; + +## Custom + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + args: { + columns: 4, + dividers: true + }, + render: function Render(args) { + const [open, setOpen] = useState(false); + return <> + setOpen(true)}>Open modal + + + + Project details + + Add the core information for your new project. + + + + + + + + + + + + + + + + + + All changes saved + + + setOpen(false)}> + Discard + + setOpen(false)}>Save changes + + + + + ; + } +} + +### MCP manifest +const Custom = () => { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(true)}>Open modal + + + + Project details + Add the core information for your new project. + + + + + + + + + + + + + + + + + All changes saved + + + setOpen(false)}>Discard + + setOpen(false)}>Save changes + + + + + + ); +}; + +## With Icon + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + args: { + columns: 4 + }, + render: function Render(args) { + const [open, setOpen] = useState(false); + return <> + setOpen(true)}>Open modal + + + + Session timeout + + + + + Your session will expire in 5 minutes due to inactivity. + + + + ; + } +} + +### MCP manifest +const WithIcon = () => { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(true)}>Open modal + + + + Session timeout + + + + Your session will expire in 5 minutes due to inactivity. + + + + + ); +}; + +## Without Header + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + args: { + columns: 4 + }, + render: function Render(args) { + const [open, setOpen] = useState(false); + return <> + setOpen(true)}>Open modal + + + + This modal has no header section. Use this pattern when you want a cleaner look without the + header underline. + + + + + setOpen(false)}> + Close + + setOpen(false)}>Continue + + + + ; + } +} + +### MCP manifest +const WithoutHeader = () => { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(true)}>Open modal + + + This modal has no header section. Use this pattern when you want a cleaner look without the + header underline. + + + + + setOpen(false)}>Close + + setOpen(false)}>Continue + + + + + ); +}; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-modal/__tests__/ds-modal.browser.test.tsx b/packages/design-system/src/components/ds-modal/__tests__/ds-modal.browser.test.tsx index 4bb942dbb..a85e1f158 100644 --- a/packages/design-system/src/components/ds-modal/__tests__/ds-modal.browser.test.tsx +++ b/packages/design-system/src/components/ds-modal/__tests__/ds-modal.browser.test.tsx @@ -1,8 +1,41 @@ import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { page } from 'vitest/browser'; +import { page, userEvent } from 'vitest/browser'; import DsModal from '../ds-modal'; +const ModalExample = ({ onOpenChange }: { onOpenChange?: (open: boolean) => void } = {}) => { + const [open, setOpen] = useState(false); + + const handleOpenChange = (next: boolean) => { + setOpen(next); + onOpenChange?.(next); + }; + + return ( + <> + + + + Modal title + + + +

Modal body content.

+
+ + + + + +
+ + ); +}; + const UnmountWhileOpenModal = () => { const [open, setOpen] = useState(false); const [mounted, setMounted] = useState(true); @@ -34,6 +67,54 @@ const UnmountWhileOpenModal = () => { }; describe('DsModal', () => { + it('opens from the trigger and shows the title', async () => { + await page.render(); + + await page.getByRole('button', { name: /open modal/i }).click(); + + await expect.element(page.getByRole('dialog')).toBeVisible(); + await expect.element(page.getByRole('heading', { name: /modal title/i })).toBeVisible(); + }); + + it('closes when a footer action is clicked', async () => { + await page.render(); + + await page.getByRole('button', { name: /open modal/i }).click(); + await expect.element(page.getByRole('dialog')).toBeVisible(); + + await page + .getByRole('dialog') + .getByRole('button', { name: /confirm/i }) + .click(); + + await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); + }); + + it('closes when the close trigger is clicked', async () => { + await page.render(); + + await page.getByRole('button', { name: /open modal/i }).click(); + const dialog = page.getByRole('dialog'); + await expect.element(dialog).toBeVisible(); + + await dialog.getByRole('button', { name: /close/i }).click(); + + await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); + }); + + it('closes and reports the change when Escape is pressed', async () => { + const onOpenChange = vi.fn(); + await page.render(); + + await page.getByRole('button', { name: /open modal/i }).click(); + await expect.element(page.getByRole('dialog')).toBeVisible(); + + await userEvent.keyboard('{Escape}'); + + await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + it('should release body scroll-lock when unmounted while open', async () => { await page.render(); diff --git a/packages/design-system/src/components/ds-modal/ds-modal.stories.module.scss b/packages/design-system/src/components/ds-modal/ds-modal.stories.module.scss deleted file mode 100644 index 76cb8b997..000000000 --- a/packages/design-system/src/components/ds-modal/ds-modal.stories.module.scss +++ /dev/null @@ -1,162 +0,0 @@ -.storyContainer { - padding: 20px; - max-width: 800px; -} - -.storyHeader { - margin-bottom: 20px; -} - -.formResults { - background: #f6f8fa; - border: 1px solid #d0d7de; - border-radius: 6px; - padding: 16px; - margin-top: 20px; -} - -.formResultsTitle { - margin: 0 0 12px 0; - color: #24292f; -} - -.formResultsGrid { - display: grid; - gap: 8px; -} - -.clearResultsButton { - margin-top: 12px; -} - -.formSection { - display: flex; - flex-direction: column; - gap: 24px; -} - -.formSectionTitle { - margin: 0 0 16px 0; - font-size: 14px; - font-weight: 600; - color: #24292f; -} - -.formGrid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; -} - -.formGridThree { - display: grid; - grid-template-columns: 1fr 1fr auto; - gap: 16px; - align-items: end; -} - -.inputLabel { - display: block; - margin-bottom: 8px; - font-size: 12px; -} - -.inputField { - width: 100%; - padding: 8px 12px; - border: 1px solid #d0d7de; - border-radius: 6px; - font-size: 14px; -} - -.customHeader { - display: flex; - gap: 8px; - justify-self: start; - margin-left: auto; -} - -.headerButton { - padding: 4px; - border: none; - background: none; - cursor: pointer; -} - -.customFooter { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 12px; - flex: 1; -} - -.customActions { - gap: 12px; -} - -.statusBadge { - background: #e6f4ea; - color: #1e4620; - padding: 4px 8px; - border-radius: 4px; - font-size: 12px; - margin-right: auto; -} - -.footerButton { - padding: 8px 16px; - border: 1px solid #d0d7de; - background: white; - border-radius: 6px; - cursor: pointer; -} - -.footerButtonPrimary { - padding: 8px 16px; - border: none; - background: #0969da; - color: white; - border-radius: 6px; - cursor: pointer; -} - -.fileUploadArea { - border: 2px dashed #d0d7de; - border-radius: 6px; - padding: 40px; - text-align: center; - background: #f6f8fa; -} - -.uploadButton { - padding: 12px 24px; - border: none; - background: #0969da; - color: white; - border-radius: 6px; - cursor: pointer; - font-size: 14px; - margin-bottom: 8px; -} - -.uploadText { - color: #656d76; - font-size: 14px; -} - -.addButton { - width: 40px; - height: 40px; - border: 1px solid #d0d7de; - background: white; - border-radius: 6px; - cursor: pointer; - font-size: 18px; - font-weight: bold; -} - -.errorMessage { - color: red; - font-size: 12px; -} diff --git a/packages/design-system/src/components/ds-modal/ds-modal.stories.tsx b/packages/design-system/src/components/ds-modal/ds-modal.stories.tsx index c0d9df68c..06b3581f2 100644 --- a/packages/design-system/src/components/ds-modal/ds-modal.stories.tsx +++ b/packages/design-system/src/components/ds-modal/ds-modal.stories.tsx @@ -1,17 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, screen, userEvent, waitFor, within } from 'storybook/test'; import { useState } from 'react'; -import { Dialog } from '@ark-ui/react/dialog'; -import { faker } from '@faker-js/faker'; -import { type SubmitHandler, Controller, FormProvider, useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; import DsModal from './ds-modal'; -import styles from './ds-modal.stories.module.scss'; -import { DsButton } from '../ds-button'; +import { DsButtonV3 } from '../ds-button-v3'; +import { DsTypography } from '../ds-typography'; +import { DsStack } from '../ds-stack'; import { DsFormControl } from '../ds-form-control'; -import { DsRadioGroup } from '../ds-radio-group'; -import { DsCheckbox } from '../ds-checkbox'; import { DsIcon } from '../ds-icon'; const meta: Meta = { @@ -20,878 +13,243 @@ const meta: Meta = { parameters: { layout: 'centered', }, - args: { - open: false, - }, argTypes: { - open: { - control: 'boolean', - description: 'Controls whether the modal is open', - }, - dividers: { - control: 'boolean', - description: 'Show full-width dividers between header, body, and footer sections', - }, columns: { control: 'select', - options: Array(12) - .fill(0) - .map((_, i) => i + 1), + options: Array.from({ length: 12 }, (_, index) => index + 1), description: 'Number of grid columns for modal width', }, - className: { - control: 'text', - description: 'Additional CSS class names', + dividers: { + control: 'boolean', + description: 'Show full-width dividers between header, body, and footer sections', }, + open: { table: { disable: true } }, + onOpenChange: { table: { disable: true } }, + className: { table: { disable: true } }, + style: { table: { disable: true } }, }, }; export default meta; type Story = StoryObj; -// Form schema for the modal -const modalFormSchema = z.object({ - name: z.string().min(1, 'Name is required'), - email: z.email('Invalid email address'), - department: z.string().min(1, 'Please select a department'), - role: z.string().min(1, 'Please select a role'), - description: z.string().min(20, 'Description must be at least 20 characters'), - acceptTerms: z.boolean().refine((v) => v, 'You must accept the terms and conditions'), - subscription: z.enum(['basic', 'pro', 'enterprise'], { - error: () => 'Please select a subscription plan', - }), -}); - -type ModalFormValues = z.infer; - -const defaultFormValues: ModalFormValues = { - name: '', - email: '', - department: '', - role: '', - description: '', - acceptTerms: false, - subscription: 'basic' as const, -}; - +/** + * The default modal has an inset header underline and no footer border. Compose + * DsModal.Header, DsModal.Body, and DsModal.Footer to build a standard confirmation + * dialog, and drive the open state from the component that renders it. + */ export const Default: Story = { - render: function Render() { - const [isOpen, setIsOpen] = useState(false); + parameters: { + docs: { source: { type: 'code' } }, + }, + args: { + columns: 4, + }, + render: function Render(args) { + const [open, setOpen] = useState(false); return ( -
-
-

Default Modal

-

The default variant has an inset header underline and no footer border.

- setIsOpen(true)}> - Open Modal - -
- - + <> + setOpen(true)}>Open modal + - Modal Title + Modal title -

This is the default modal variant with simple content.

+ + This is the default modal variant with simple content. +
- setIsOpen(false)}> + setOpen(false)}> Cancel - - setIsOpen(false)}> - Confirm - + + setOpen(false)}>Confirm
-
+ ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const openModalButton = canvas.getByRole('button', { name: /open modal/i }); - await userEvent.click(openModalButton); - - await waitFor(() => { - return expect(screen.getByRole('dialog')).toBeVisible(); - }); - - await expect(screen.getByRole('heading', { name: /modal title/i })).toBeVisible(); - - const confirmButton = screen.getByRole('button', { name: /confirm/i }); - await userEvent.click(confirmButton); - - await waitFor(() => { - return expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - }, - parameters: { - docs: { - description: { - story: 'Default modal variant with inset header underline and no footer border.', - }, - }, - }, }; +/** + * Set `dividers` to add full-width borders above the footer and below the header, + * giving clearer visual separation when the body holds a form or scrollable content. + * Compose DsFormControl fields inside DsModal.Body for standard form layouts. + */ export const Divided: Story = { - render: function Render() { - const [isOpen, setIsOpen] = useState(false); - const [submittedData, setSubmittedData] = useState(null); - - const methods = useForm({ - resolver: zodResolver(modalFormSchema), - defaultValues: defaultFormValues, - mode: 'onChange', - }); - - const { - register, - handleSubmit, - formState: { errors, isValid, touchedFields, isDirty }, - setValue, - watch, - trigger, - reset, - control, - } = methods; - - const onSubmit: SubmitHandler = (data: ModalFormValues) => { - setSubmittedData(data); - setIsOpen(false); - reset(defaultFormValues); - }; - - const handleReset = () => { - reset(defaultFormValues); - }; - - const handleValueChange = (field: keyof ModalFormValues, value: string | boolean | null) => { - setValue(field, value === 'indeterminate' ? false : (value as ModalFormValues[typeof field]), { - shouldValidate: true, - shouldTouch: true, - shouldDirty: true, - }); - }; + parameters: { + docs: { source: { type: 'code' } }, + }, + args: { + columns: 6, + dividers: true, + }, + render: function Render(args) { + const [open, setOpen] = useState(false); return ( -
-
-

Interactive Form Modal Demo

-

- Click the button below to open a form modal. Fill out the form and click "Save Changes" to - see the results displayed here. -

- setIsOpen(true)}> - Open Form Modal - -
- - {submittedData && ( -
-

Form Results:

-
-
- Name: {submittedData.name} -
-
- Email: {submittedData.email} -
-
- Department: {submittedData.department} -
-
- Role: {submittedData.role || 'Not specified'} -
-
- Description: {submittedData.description} -
-
- Subscription: {submittedData.subscription} -
-
- Terms Accepted: {submittedData.acceptTerms ? 'Yes' : 'No'} -
-
- setSubmittedData(null)} - className={styles.clearResultsButton} - > - Clear Results - -
- )} - - + <> + setOpen(true)}>Open modal + - User Profile Form + User profile - -
- {/* Basic Information Section */} -
-

Basic Information

-
- - ( - handleValueChange('name', event.target.value)} - onBlur={(event) => handleValueChange('name', event.target.value)} - /> - )} - /> - - - ( - handleValueChange('email', event.target.value)} - onBlur={(event) => handleValueChange('email', event.target.value)} - /> - )} - /> - -
-
- - {/* Work Information Section */} -
-

Work Information

-
- - ( - handleValueChange('department', value)} - onBlur={() => handleValueChange('department', field.value)} - /> - )} - /> - - - ( - handleValueChange('role', value)} - onBlur={() => handleValueChange('role', field.value)} - /> - )} - /> - -
-
- - {/* Subscription Section */} -
-

Subscription Plan

- handleValueChange('subscription', value)} - > - - - - - {errors.subscription && ( - {errors.subscription.message} - )} -
- - {/* Additional Information Section */} -
-

Additional Information

- - trigger('description'), - onChange: () => trigger('description'), - })} - /> - -
- - {/* Terms and Conditions */} -
- handleValueChange('acceptTerms', value)} - /> - {errors.acceptTerms && ( - {errors.acceptTerms.message} - )} -
-
-
+ + + + + + + +
- - Reset - - - Save Changes - + setOpen(false)}> + Cancel + + setOpen(false)}>Save changes
-
+ ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const waitForMessage = async (text: string) => { - await waitFor(() => { - return expect(screen.getByText(text)).toBeInTheDocument(); - }); - }; - - const openModalButton = canvas.getByRole('button', { name: /open form modal/i }); - await userEvent.click(openModalButton); - - await waitFor(() => { - return expect(screen.getByText('User Profile Form')).toBeVisible(); - }); - - // 2. Test form validation by interacting with fields and blurring them - // Test name field validation - const nameInput = screen.getByLabelText('Full Name'); - await userEvent.click(nameInput); - await userEvent.tab(); - await waitForMessage('Name is required'); - - // Test email field validation - const emailInput = screen.getByLabelText('Email Address'); - await userEvent.click(emailInput); - await userEvent.tab(); - await waitForMessage('Invalid email address'); - - // Test department field validation - const departmentSelect = screen.getByLabelText('Department'); - await userEvent.click(departmentSelect); - await userEvent.tab(); - await waitForMessage('Please select a department'); - - // Test role field validation - const roleSelect = screen.getByLabelText('Role'); - await userEvent.click(roleSelect); - await userEvent.tab(); - await waitForMessage('Please select a role'); - - // Test description field validation - const descriptionInput = screen.getByLabelText('Description'); - await userEvent.click(descriptionInput); - await userEvent.tab(); - await waitForMessage('Description must be at least 20 characters'); - - // Test terms checkbox validation - const acceptTermsCheckbox = screen.getByLabelText('I accept the terms and conditions'); - await userEvent.click(acceptTermsCheckbox); - await userEvent.click(acceptTermsCheckbox); - await waitForMessage('You must accept the terms and conditions'); - - // 3. Fill out the form with valid data - // Fill name - const fakeName = `${faker.person.firstName()} ${faker.person.lastName()}`; - await userEvent.type(nameInput, fakeName); - await waitFor(() => { - return expect(screen.queryByText('Name is required')).not.toBeInTheDocument(); - }); - - // Fill email - const fakeEmail = faker.internet.email(); - await userEvent.clear(emailInput); - await userEvent.type(emailInput, fakeEmail); - await waitFor(() => { - return expect(screen.queryByText('Invalid email address')).not.toBeInTheDocument(); - }); - - // Select department - await userEvent.click(departmentSelect); - const departmentOption = screen.getByRole('option', { name: 'Product' }); - await userEvent.click(departmentOption); - await waitFor(() => { - return expect(screen.queryByText('Please select a department')).not.toBeInTheDocument(); - }); - - // Select role - await userEvent.click(roleSelect); - const roleOption = screen.getByRole('option', { name: 'Manager' }); - await userEvent.click(roleOption); - await waitFor(() => { - return expect(screen.queryByText('Please select a role')).not.toBeInTheDocument(); - }); - - // Fill description - const fakeDescription = faker.lorem.sentence(5); - await userEvent.clear(descriptionInput); - await userEvent.type(descriptionInput, fakeDescription); - await waitFor(() => { - return expect(screen.queryByText('Description must be at least 20 characters')).not.toBeInTheDocument(); - }); - - // Select subscription - const subscriptionOption = screen.getByLabelText('Pro'); - await userEvent.click(subscriptionOption); - await waitFor(() => { - return expect(screen.queryByText('Please select a subscription plan')).not.toBeInTheDocument(); - }); - - // Accept terms - await userEvent.click(acceptTermsCheckbox); - await waitFor(() => { - return expect(screen.queryByText('You must accept the terms and conditions')).not.toBeInTheDocument(); - }); - - // 4. Verify submit button is enabled - const saveButton = screen.getByRole('button', { name: /save changes/i }); - await waitFor(() => { - return expect(saveButton).toBeEnabled(); - }); - - // 5. Submit the form - await userEvent.click(saveButton); - - // 6. Verify modal is closed and results are displayed - await waitFor(() => { - return expect(screen.queryByText('User Profile Form')).not.toBeVisible(); - }); - - const checkResult = (text: string) => { - return expect( - canvas.getByText((content, element) => { - return element?.textContent === text; - }), - ).toBeInTheDocument(); - }; - - // Verify results are displayed - await waitFor(async () => { - await expect(canvas.getByText('Form Results:')).toBeInTheDocument(); - await checkResult(`Name: ${fakeName}`); - await checkResult(`Email: ${fakeEmail}`); - await checkResult('Role: manager'); - await checkResult('Department: product'); - await checkResult(`Description: ${fakeDescription}`); - await checkResult('Subscription: pro'); - await checkResult('Terms Accepted: Yes'); - }); - - // 7. Test reset functionality - await userEvent.click(openModalButton); - await waitFor(() => { - return expect(screen.getByText('User Profile Form')).toBeInTheDocument(); - }); - - // Verify form is reset to default values - await waitFor(async () => { - await expect(nameInput).toHaveValue(''); - await expect(emailInput).toHaveValue(''); - await expect(descriptionInput).toHaveValue(''); - await expect(acceptTermsCheckbox).not.toBeChecked(); - }); - - // 8. Test reset button - // Fill some data first - await userEvent.type(nameInput, 'Test Name'); - await userEvent.type(emailInput, 'test@example.com'); - await userEvent.type(descriptionInput, 'This is a test description that is long enough'); - await userEvent.click(acceptTermsCheckbox); - - // Click reset button - const resetButton = screen.getByRole('button', { name: /reset/i }); - await userEvent.click(resetButton); - - // Verify form is reset - await waitFor(async () => { - await expect(nameInput).toHaveValue(''); - await expect(emailInput).toHaveValue(''); - await expect(descriptionInput).toHaveValue(''); - await expect(acceptTermsCheckbox).not.toBeChecked(); - }); - - // Exit the modal form - await userEvent.keyboard('{Escape}'); - - // 9. Test clear results functionality - await userEvent.click(canvas.getByRole('button', { name: /clear results/i })); - await waitFor(() => { - return expect(canvas.queryByText('Form Results:')).not.toBeInTheDocument(); - }); - }, - parameters: { - docs: { - description: { - story: - 'The divided layout adds full-width borders on header and footer for clear visual separation. This example demonstrates form state management using react-hook-form with Zod validation.', - }, - }, - }, }; -// Custom story - Button-launched modal with custom header and footer +/** + * Both DsModal.Header and DsModal.Footer accept arbitrary content, so you can build + * a custom header (title plus supporting text) and a footer that mixes status text + * with actions. Use DsModal.CloseTrigger for the close control and DsModal.Actions to + * keep buttons right-aligned. + */ export const Custom: Story = { - render: function Render() { - const [isOpen, setIsOpen] = useState(false); + parameters: { + docs: { source: { type: 'code' } }, + }, + args: { + columns: 4, + dividers: true, + }, + render: function Render(args) { + const [open, setOpen] = useState(false); return ( -
-
-

Custom Modal Demo

-

Click the button below to open a custom modal with custom header and footer content.

- setIsOpen(true)}> - Open Custom Modal - -
- - + <> + setOpen(true)}>Open modal + -
- - -
+ + Project details + + Add the core information for your new project. + + +
-
- {/* First Section */} -
-

Project Details

-
-
- - -
-
- - -
-
-
- - {/* Second Section */} -
-

Team Information

-
-
- - -
-
- - -
-
-
- - {/* Third Group */} -
-
-
- - -
-
- - -
- -
-
- - {/* File Upload Section */} -
-

Project Documents

-
- -
or drag and drop to upload
-
-
-
+ + + + + + + +
-
-
All done
- - - + + + All changes saved + + + setOpen(false)}> + Discard + + setOpen(false)}>Save changes -
+
-
+ ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const openButton = canvas.getByRole('button', { name: /open custom modal/i }); - await userEvent.click(openButton); - - await waitFor(() => { - return expect(screen.getByRole('dialog')).toBeVisible(); - }); - - await expect(screen.getByText('Project Details')).toBeVisible(); - - const closeButton = screen.getByRole('button', { name: /✕/i }); - await userEvent.click(closeButton); - - await waitFor(() => { - return expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - }, - parameters: { - docs: { - description: { - story: - 'A custom modal with custom header and footer content, demonstrating how to override the default modal structure with custom components.', - }, - }, - }, }; +/** + * Compose DsIcon before DsModal.Title inside the header to add a leading icon. Useful + * for status or informational dialogs where the icon reinforces the message. + */ export const WithIcon: Story = { - render: function Render() { - const [isOpen, setIsOpen] = useState(false); + parameters: { + docs: { source: { type: 'code' } }, + }, + args: { + columns: 4, + }, + render: function Render(args) { + const [open, setOpen] = useState(false); return ( -
-
-

Modal with Icon

-

Example showing how to add an icon to the modal header.

- setIsOpen(true)}> - Open Modal - -
- - + <> + setOpen(true)}>Open modal + - Session Timeout + Session timeout -

Your session will expire in 5 minutes due to inactivity.

+ + Your session will expire in 5 minutes due to inactivity. +
-
+ ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const openModalButton = canvas.getByRole('button', { name: /open modal/i }); - await userEvent.click(openModalButton); - - await waitFor(() => { - return expect(screen.getByRole('dialog')).toBeVisible(); - }); - - await expect(screen.getByRole('heading', { name: /session timeout/i })).toBeVisible(); - await expect(screen.getByText(/session will expire/i)).toBeVisible(); - - await userEvent.keyboard('{Escape}'); - - await waitFor(() => { - return expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - }, - parameters: { - docs: { - description: { - story: - 'Example showing how to add an icon to the modal header by composing DsIcon with DsModal.Header.', - }, - }, - }, }; +/** + * Omit DsModal.Header entirely for a cleaner surface without the header underline. + * Use this pattern instead of a variant when the body content is self-explanatory. + */ export const WithoutHeader: Story = { - render: function Render() { - const [isOpen, setIsOpen] = useState(false); + parameters: { + docs: { source: { type: 'code' } }, + }, + args: { + columns: 4, + }, + render: function Render(args) { + const [open, setOpen] = useState(false); return ( -
-
-

Modal Without Header

-

Modal with only body and footer content, no header section.

- setIsOpen(true)}> - Open Modal - -
- - + <> + setOpen(true)}>Open modal + -

+ This modal has no header section. Use this pattern when you want a cleaner look without the header underline. -

+
- setIsOpen(false)}> + setOpen(false)}> Close - - setIsOpen(false)}> - Continue - + + setOpen(false)}>Continue
-
+ ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const openModalButton = canvas.getByRole('button', { name: /open modal/i }); - await userEvent.click(openModalButton); - - await waitFor(() => { - return expect(screen.getByRole('dialog')).toBeVisible(); - }); - - const dialog = screen.getByRole('dialog'); - await expect(within(dialog).getByText(/no header section/i)).toBeVisible(); - - const continueButton = screen.getByRole('button', { name: /continue/i }); - await expect(continueButton).toBeVisible(); - - await userEvent.click(continueButton); - - await waitFor(() => { - return expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - }, - parameters: { - docs: { - description: { - story: - 'Modal without a header component - use this pattern instead of a variant when you want no header underline.', - }, - }, - }, }; diff --git a/packages/design-system/src/components/ds-panel/__tests__/__snapshots__/ds-panel.docs.snap b/packages/design-system/src/components/ds-panel/__tests__/__snapshots__/ds-panel.docs.snap new file mode 100644 index 000000000..db9f91d8f --- /dev/null +++ b/packages/design-system/src/components/ds-panel/__tests__/__snapshots__/ds-panel.docs.snap @@ -0,0 +1,203 @@ +# DsPanel docs snippets + +## Default + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render({ + variant + }) { + const [open, setOpen] = useState(true); + return <> + {!open && setOpen(true)}>Open Panel} + + + + This is a panel. It can contain any content you like, such as text, images, or other components. + + + It is collapsible. Hover it to see the trigger button. + + Primary Action + + ; + } +} + +### MCP manifest +const Default = function Render({ variant }) { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && setOpen(true)}>Open Panel} + + + + This is a panel. It can contain any content you like, such as text, images, or other components. + + + It is collapsible. Hover it to see the trigger button. + + Primary Action + + + ); +}; + +## Responsive + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render() { + const [open, setOpen] = useState(true); + return <> + {!open && setOpen(true)}>Open Panel} + + + This panel uses a responsive width. + Large screens: 480px. Medium screens: 240px. + + Primary Action + + ; + } +} + +### MCP manifest +const Responsive = function Render() { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && setOpen(true)}>Open Panel} + + + This panel uses a responsive width. + Large screens: 480px. Medium screens: 240px. + + Primary Action + + + ); +}; + +## Draggable + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render() { + const [panelVariant, setPanelVariant] = useState('docked'); + const [activeStep, setActiveStep] = useState(0); + const isFloating = panelVariant === 'floating'; + const togglePanelVariant = () => { + setPanelVariant(isFloating ? 'docked' : 'floating'); + }; + const steps = [{ + label: 'Configure network', + description: 'Set up interfaces and routing policies' + }, { + label: 'Assign resources', + description: 'Allocate compute and storage for the deployment' + }, { + label: 'Review & deploy', + description: 'Verify configuration and launch' + }]; + return
+ + setActiveStep(step)} variant={isFloating ? 'single' : undefined} floating={isFloating}> + {steps.map((s, index) => + {index === steps.length - 1 ? 'Deploy' : 'Next'}} /> + )} + + +
; + } +} + +### MCP manifest +const Draggable = function Render() { + const [panelVariant, setPanelVariant] = useState('docked'); + const [activeStep, setActiveStep] = useState(0); + + const isFloating = panelVariant === 'floating'; + + const togglePanelVariant = () => { + setPanelVariant(isFloating ? 'docked' : 'floating'); + }; + + const steps = [ + { label: 'Configure network', description: 'Set up interfaces and routing policies' }, + { label: 'Assign resources', description: 'Allocate compute and storage for the deployment' }, + { label: 'Review & deploy', description: 'Verify configuration and launch' }, + ]; + + return ( +
+ + setActiveStep(step)} + variant={isFloating ? 'single' : undefined} + floating={isFloating} + > + {steps.map((s, index) => ( + + {index === steps.length - 1 ? 'Deploy' : 'Next'} + } + /> + + ))} + + +
+ ); +}; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-panel/ds-panel.stories.module.scss b/packages/design-system/src/components/ds-panel/ds-panel.stories.module.scss new file mode 100644 index 000000000..d0e078ae5 --- /dev/null +++ b/packages/design-system/src/components/ds-panel/ds-panel.stories.module.scss @@ -0,0 +1,5 @@ +.draggableCanvas { + position: relative; + width: 600px; + height: 500px; +} diff --git a/packages/design-system/src/components/ds-panel/ds-panel.stories.tsx b/packages/design-system/src/components/ds-panel/ds-panel.stories.tsx index b27f3f8c7..70bd3c93b 100644 --- a/packages/design-system/src/components/ds-panel/ds-panel.stories.tsx +++ b/packages/design-system/src/components/ds-panel/ds-panel.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; import { DsPanel } from './'; import { DsButton } from '../ds-button/'; import { DsStepper, DsStep, DsStepContent, DsNextStepButton } from '../ds-stepper'; -import { useState } from 'react'; +import { DsTypography } from '../ds-typography'; import type { DsPanelVariant } from './ds-panel.types'; +import styles from './ds-panel.stories.module.scss'; export default { title: 'Components/Panel', @@ -15,7 +17,12 @@ export default { type Story = StoryObj; +/** + * A collapsible panel that holds arbitrary content. Hover it to reveal the + * collapse trigger; collapsing hides the body behind an "Open Panel" button. + */ export const Default: Story = { + parameters: { docs: { source: { type: 'code' } } }, render: function Render({ variant }) { const [open, setOpen] = useState(true); @@ -24,11 +31,13 @@ export const Default: Story = { {!open && setOpen(true)}>Open Panel} -

+ This is a panel. It can contain any content you like, such as text, images, or other components. -

+ -

It is collapsible. Hover it to see the trigger button.

+ + It is collapsible. Hover it to see the trigger button. + Primary Action
@@ -37,7 +46,12 @@ export const Default: Story = { }, }; +/** + * The `width` prop accepts a `ResponsiveValue` so the panel can widen on large + * screens and narrow on medium ones without extra layout code. + */ export const Responsive: Story = { + parameters: { docs: { source: { type: 'code' } } }, render: function Render() { const [open, setOpen] = useState(true); @@ -46,8 +60,8 @@ export const Responsive: Story = { {!open && setOpen(true)}>Open Panel} -

This panel uses a responsive width.

-

Large screens: 480px. Medium screens: 240px.

+ This panel uses a responsive width. + Large screens: 480px. Medium screens: 240px. Primary Action
@@ -56,7 +70,13 @@ export const Responsive: Story = { }, }; +/** + * Toggling the collapse button switches the panel between `docked` and + * `floating`. The floating variant is draggable and drops its inner padding so a + * `DsStepper` can bleed to the edges. + */ export const Draggable: Story = { + parameters: { docs: { source: { type: 'code' } } }, render: function Render() { const [panelVariant, setPanelVariant] = useState('docked'); const [activeStep, setActiveStep] = useState(0); @@ -74,7 +94,7 @@ export const Draggable: Story = { ]; return ( -
+
+ + + Release lock + + + + }> + Release lock + + + }> + Releases a physical lock on a device or asset, granting immediate access to the selected inventory item. + + + + + + + John Smith • 23-May-2024 04:47 PM + + + + + + + DAP / Inventory / Physical + + + + + Confirm + + + + + +### MCP manifest +const WithContentItemsAndCTA = () => + + Release lock + + + }>Release lock + + + }>Releases a physical lock on a device or asset, granting immediate access to the selected inventory + item. + + + + + + John Smith • 23-May-2024 04:47 PM + + + + + + DAP / Inventory / Physical + + + + Confirm + + + +; + +## Single Content Item + +### Show code + + + + Set element status + + + + }> + Set element status + + + }> + This node has no outgoing connections. Link it to another node to continue the workflow. + + + + + +### MCP manifest +const SingleContentItem = () => + + Set element status + + + }>Set element status + + + }>This node has no outgoing connections. Link it to another node to continue the workflow. + + + +; + +## Legacy + +### Show code +{ + tags: ['deprecated'], + render: () => Legacy call form}> + }> + Legacy call form + + + + Still works after the rewrite — same one-liner, new panel styling. + + + +} + +### MCP manifest +const Legacy = () => ( + Legacy call form}> + }> + Legacy call form + + + + Still works after the rewrite — same one-liner, new panel styling. + + + +); + +## With Image + +### Show code + + + + London + + + + }> + London + + + + Map of London + + + + + +### MCP manifest +const WithImage = () => + + London + + + }>London + + + + Map of London + + + +; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-toast/__tests__/__snapshots__/ds-toast.docs.snap b/packages/design-system/src/components/ds-toast/__tests__/__snapshots__/ds-toast.docs.snap new file mode 100644 index 000000000..293a3f6d6 --- /dev/null +++ b/packages/design-system/src/components/ds-toast/__tests__/__snapshots__/ds-toast.docs.snap @@ -0,0 +1,578 @@ +# DsToast docs snippets + +## Success + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: () => { + const ToastTrigger = () => { + const { + createToast + } = useToaster(); + return createToast({ + variant: 'success', + title: 'Success!', + description: 'Your action was completed successfully.' + })}> + Show success toast + ; + }; + return + + ; + } +} + +### MCP manifest +const Success = () => { + const ToastTrigger = () => { + const { createToast } = useToaster(); + + return ( + + createToast({ + variant: 'success', + title: 'Success!', + description: 'Your action was completed successfully.', + }) + } + > + Show success toast + + ); + }; + + return ( + + + + ); +}; + +## Info + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: () => { + const ToastTrigger = () => { + const { + createToast + } = useToaster(); + return createToast({ + variant: 'info', + title: 'Information', + description: 'Here is some helpful information for you.' + })}> + Show info toast + ; + }; + return + + ; + } +} + +### MCP manifest +const Info = () => { + const ToastTrigger = () => { + const { createToast } = useToaster(); + + return ( + + createToast({ + variant: 'info', + title: 'Information', + description: 'Here is some helpful information for you.', + }) + } + > + Show info toast + + ); + }; + + return ( + + + + ); +}; + +## Warning + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: () => { + const ToastTrigger = () => { + const { + createToast + } = useToaster(); + return createToast({ + variant: 'warning', + title: 'Warning', + description: 'Please be aware of this important notice.' + })}> + Show warning toast + ; + }; + return + + ; + } +} + +### MCP manifest +const Warning = () => { + const ToastTrigger = () => { + const { createToast } = useToaster(); + + return ( + + createToast({ + variant: 'warning', + title: 'Warning', + description: 'Please be aware of this important notice.', + }) + } + > + Show warning toast + + ); + }; + + return ( + + + + ); +}; + +## Error + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: () => { + const ToastTrigger = () => { + const { + createToast + } = useToaster(); + return createToast({ + variant: 'error', + title: 'Error', + description: 'Something went wrong. Please try again.', + persistent: true + })}> + Show error toast + ; + }; + return + + ; + } +} + +### MCP manifest +const Error = () => { + const ToastTrigger = () => { + const { createToast } = useToaster(); + + return ( + + createToast({ + variant: 'error', + title: 'Error', + description: 'Something went wrong. Please try again.', + persistent: true, + }) + } + > + Show error toast + + ); + }; + + return ( + + + + ); +}; + +## No Title + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: () => { + const ToastTrigger = () => { + const { + createToast + } = useToaster(); + return createToast({ + variant: 'warning', + description: 'Something went wrong. Please try again.' + })}> + Show toast without title + ; + }; + return + + ; + } +} + +### MCP manifest +const NoTitle = () => { + const ToastTrigger = () => { + const { createToast } = useToaster(); + + return ( + + createToast({ + variant: 'warning', + description: 'Something went wrong. Please try again.', + }) + } + > + Show toast without title + + ); + }; + + return ( + + + + ); +}; + +## With Actions + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: () => { + const ToastTrigger = () => { + const { + createToast, + dismissToast + } = useToaster(); + const showToast = () => { + const id = createToast({ + variant: 'warning', + title: 'File upload failed', + description: 'Your file could not be uploaded.', + persistent: true, + actions: + dismissToast(id)}> + Abort + + dismissToast(id)}> + Re-try + + + }); + }; + return + Show toast with actions + ; + }; + return + + ; + } +} + +### MCP manifest +const WithActions = () => { + const ToastTrigger = () => { + const { createToast, dismissToast } = useToaster(); + + const showToast = () => { + const id = createToast({ + variant: 'warning', + title: 'File upload failed', + description: 'Your file could not be uploaded.', + persistent: true, + actions: ( + + dismissToast(id)}> + Abort + + dismissToast(id)}> + Re-try + + + ), + }); + }; + + return ( + + Show toast with actions + + ); + }; + + return ( + + + + ); +}; + +## Long Content + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: () => { + const ToastTrigger = () => { + const { + createToast + } = useToaster(); + return createToast({ + variant: 'warning', + title: 'Important notice', + description: 'This is a longer message that demonstrates how the toast handles extended content. ' + 'The text wraps and stays readable while remaining within the toast boundaries.' + })}> + Show long content toast + ; + }; + return + + ; + } +} + +### MCP manifest +const LongContent = () => { + const ToastTrigger = () => { + const { createToast } = useToaster(); + + return ( + + createToast({ + variant: 'warning', + title: 'Important notice', + description: + 'This is a longer message that demonstrates how the toast handles extended content. ' + + 'The text wraps and stays readable while remaining within the toast boundaries.', + }) + } + > + Show long content toast + + ); + }; + + return ( + + + + ); +}; + +## Persistent + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: () => { + const ToastTrigger = () => { + const { + createToast + } = useToaster(); + return createToast({ + variant: 'info', + title: 'Sync in progress', + description: 'This toast stays until you close it.', + persistent: true + })}> + Show persistent toast + ; + }; + return + + ; + } +} + +### MCP manifest +const Persistent = () => { + const ToastTrigger = () => { + const { createToast } = useToaster(); + + return ( + + createToast({ + variant: 'info', + title: 'Sync in progress', + description: 'This toast stays until you close it.', + persistent: true, + }) + } + > + Show persistent toast + + ); + }; + + return ( + + + + ); +}; + +## Multiple Toasts + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: () => { + const ToastTrigger = () => { + const { + createToast, + dismissAllToasts + } = useToaster(); + const showToasts = () => { + createToast({ + variant: 'success', + title: 'First toast', + description: 'This is the first message.' + }); + createToast({ + variant: 'info', + title: 'Second toast', + description: 'This is the second message.' + }); + createToast({ + variant: 'warning', + title: 'Third toast', + description: 'This is the third message.' + }); + }; + return + + Show multiple toasts + + dismissAllToasts()}> + Dismiss all + + ; + }; + return + + ; + } +} + +### MCP manifest +const MultipleToasts = () => { + const ToastTrigger = () => { + const { createToast, dismissAllToasts } = useToaster(); + + const showToasts = () => { + createToast({ variant: 'success', title: 'First toast', description: 'This is the first message.' }); + createToast({ variant: 'info', title: 'Second toast', description: 'This is the second message.' }); + createToast({ variant: 'warning', title: 'Third toast', description: 'This is the third message.' }); + }; + + return ( + + + Show multiple toasts + + dismissAllToasts()}> + Dismiss all + + + ); + }; + + return ( + + + + ); +}; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-toast/__tests__/ds-toast.browser.test.tsx b/packages/design-system/src/components/ds-toast/__tests__/ds-toast.browser.test.tsx new file mode 100644 index 000000000..bc1d781ae --- /dev/null +++ b/packages/design-system/src/components/ds-toast/__tests__/ds-toast.browser.test.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { page } from 'vitest/browser'; +import { DsToastProvider, useToaster } from '../ds-toast-context'; +import { DsButton } from '../../ds-button'; +import { DsStack } from '../../ds-stack'; + +describe('DsToast', () => { + it('renders a toast with actions and dismisses it when an action is clicked', async () => { + const onAbort = vi.fn(); + + const Harness = () => { + const { createToast, dismissToast } = useToaster(); + const [lastAction, setLastAction] = useState(); + + const showToast = () => { + const id = createToast({ + variant: 'warning', + title: 'File upload failed', + description: 'Your file could not be uploaded.', + persistent: true, + actions: ( + + { + onAbort(); + setLastAction('abort'); + dismissToast(id); + }} + > + Abort + + dismissToast(id)}> + Re-try + + + ), + }); + }; + + return ( + <> + + Show toast + + {lastAction && {lastAction}} + + ); + }; + + await page.render( + + + , + ); + + await page.getByRole('button', { name: 'Show toast' }).click(); + + await expect.element(page.getByText('File upload failed')).toBeVisible(); + await expect.element(page.getByText('Your file could not be uploaded.')).toBeVisible(); + await expect.element(page.getByRole('button', { name: 'Abort' })).toBeVisible(); + await expect.element(page.getByRole('button', { name: 'Re-try' })).toBeVisible(); + + await page.getByRole('button', { name: 'Abort' }).click(); + + expect(onAbort).toHaveBeenCalledOnce(); + await expect.element(page.getByTestId('last-action')).toHaveTextContent('abort'); + await expect.element(page.getByText('File upload failed')).not.toBeInTheDocument(); + }); + + it('stacks multiple toasts and clears them with Dismiss all', async () => { + const Harness = () => { + const { createToast, dismissAllToasts, getToastsCount } = useToaster(); + const [count, setCount] = useState(0); + + useEffect(() => { + const id = setInterval(() => setCount(getToastsCount()), 50); + return () => clearInterval(id); + }, [getToastsCount]); + + const showToasts = () => { + createToast({ variant: 'success', title: 'First toast', description: 'First message.' }); + createToast({ variant: 'info', title: 'Second toast', description: 'Second message.' }); + createToast({ variant: 'warning', title: 'Third toast', description: 'Third message.' }); + }; + + // Toasts render fixed at the top, so keep the controls clear of that overlay + // to ensure the "Dismiss all" trigger stays clickable while toasts are shown. + return ( +
+ + Show multiple toasts + + dismissAllToasts()}> + Dismiss all + + {count} +
+ ); + }; + + await page.render( + + + , + ); + + await expect.element(page.getByTestId('toast-count')).toHaveTextContent('0'); + + await page.getByRole('button', { name: 'Show multiple toasts' }).click(); + + await expect.element(page.getByTestId('toast-count')).toHaveTextContent('3'); + + await page.getByRole('button', { name: 'Dismiss all' }).click(); + + await expect.element(page.getByTestId('toast-count')).toHaveTextContent('0'); + }); +}); diff --git a/packages/design-system/src/components/ds-toast/ds-toast.stories.module.scss b/packages/design-system/src/components/ds-toast/ds-toast.stories.module.scss deleted file mode 100644 index be34745a7..000000000 --- a/packages/design-system/src/components/ds-toast/ds-toast.stories.module.scss +++ /dev/null @@ -1,16 +0,0 @@ -.demoContainer { - display: flex; - flex-direction: column; - gap: 1rem; - align-items: center; -} - -.actionButtonsContainer { - display: flex; - justify-content: end; -} - -.buttonGroup { - display: flex; - gap: 0.5rem; -} diff --git a/packages/design-system/src/components/ds-toast/ds-toast.stories.tsx b/packages/design-system/src/components/ds-toast/ds-toast.stories.tsx index 7d89da608..b8dd2665d 100644 --- a/packages/design-system/src/components/ds-toast/ds-toast.stories.tsx +++ b/packages/design-system/src/components/ds-toast/ds-toast.stories.tsx @@ -1,11 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { type ReactNode, useState } from 'react'; -import { expect, userEvent, waitFor, within } from 'storybook/test'; import { DsToast } from './ds-toast'; import { DsToastProvider, useToaster } from './ds-toast-context'; +import { toastVariants } from './ds-toast.types'; import { DsButton } from '../ds-button'; -import { type DsToastProps, type ToastVariant, toastVariants } from './ds-toast.types'; -import styles from './ds-toast.stories.module.scss'; +import { DsStack } from '../ds-stack'; const meta: Meta = { title: 'Components/Toast', @@ -14,7 +12,9 @@ const meta: Meta = { layout: 'centered', docs: { description: { - component: 'A toast component for displaying temporary messages with different variants and actions.', + component: + 'Temporary, non-blocking messages surfaced through `DsToastProvider` and the `useToaster` hook. ' + + 'Toasts are created imperatively from an event handler, so every example wraps a trigger in the provider.', }, }, }, @@ -22,27 +22,6 @@ const meta: Meta = { variant: { control: { type: 'select' }, options: toastVariants, - description: 'The visual variant of the toast', - }, - title: { - control: { type: 'text' }, - description: 'The title of the toast', - }, - description: { - control: { type: 'text' }, - description: 'The description text of the toast', - }, - persistent: { - control: { type: 'boolean' }, - description: 'Whether the toast should persist until manually dismissed', - }, - duration: { - control: { type: 'number' }, - description: 'Duration in milliseconds before auto-dismiss (ignored if persistent is true)', - }, - onDismiss: { - control: false, - description: 'Callback function called when the toast is dismissed', }, }, }; @@ -50,370 +29,323 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const ToastDemo = ({ - variant, - title, - description, - persistent, - actions, -}: { - variant: ToastVariant; - title?: string; - description: string; - persistent?: boolean; - actions?: ReactNode; -}) => { - const { createToast } = useToaster(); - - const showToast = () => { - createToast({ - variant, - title, - description, - persistent, - actions, - }); - }; +/** + * Confirms an action completed. Auto-dismisses after the default duration. + */ +export const Success: Story = { + parameters: { docs: { source: { type: 'code' } } }, + render: () => { + const ToastTrigger = () => { + const { createToast } = useToaster(); - return ( -
-

{variant.charAt(0).toUpperCase() + variant.slice(1)} Toast

- - Show {variant.charAt(0).toUpperCase() + variant.slice(1)} Toast - -
- ); -}; + return ( + + createToast({ + variant: 'success', + title: 'Success!', + description: 'Your action was completed successfully.', + }) + } + > + Show success toast + + ); + }; -export const Success: Story = { - render: () => ( - - - - ), + return ( + + + + ); + }, }; +/** + * Neutral, informational message. + */ export const Info: Story = { - render: () => ( - - - - ), + parameters: { docs: { source: { type: 'code' } } }, + render: () => { + const ToastTrigger = () => { + const { createToast } = useToaster(); + + return ( + + createToast({ + variant: 'info', + title: 'Information', + description: 'Here is some helpful information for you.', + }) + } + > + Show info toast + + ); + }; + + return ( + + + + ); + }, }; +/** + * Draws attention to something that may need action but is not an error. + */ export const Warning: Story = { - render: () => ( - - - - ), + parameters: { docs: { source: { type: 'code' } } }, + render: () => { + const ToastTrigger = () => { + const { createToast } = useToaster(); + + return ( + + createToast({ + variant: 'warning', + title: 'Warning', + description: 'Please be aware of this important notice.', + }) + } + > + Show warning toast + + ); + }; + + return ( + + + + ); + }, }; -export const WarningNoTitle: Story = { - render: () => ( - - - - ), +/** + * Reports a failure. Errors are usually persistent so the user can read and act on them. + */ +export const Error: Story = { + parameters: { docs: { source: { type: 'code' } } }, + render: () => { + const ToastTrigger = () => { + const { createToast } = useToaster(); + + return ( + + createToast({ + variant: 'error', + title: 'Error', + description: 'Something went wrong. Please try again.', + persistent: true, + }) + } + > + Show error toast + + ); + }; + + return ( + + + + ); + }, }; -export const WarningNoTitleAction: Story = { - render: () => ( - - - Restart - - } - persistent - > - - ), +/** + * Omit `title` for a compact, single-line toast that shows only the description. + */ +export const NoTitle: Story = { + parameters: { docs: { source: { type: 'code' } } }, + render: () => { + const ToastTrigger = () => { + const { createToast } = useToaster(); + + return ( + + createToast({ + variant: 'warning', + description: 'Something went wrong. Please try again.', + }) + } + > + Show toast without title + + ); + }; + + return ( + + + + ); + }, }; -export const WarningWithActions: Story = { +/** + * Pass `actions` to render buttons inside the toast. Action handlers typically dismiss the + * toast with `dismissToast(id)` — pair actions with `persistent` so the toast waits for a choice. + */ +export const WithActions: Story = { + parameters: { docs: { source: { type: 'code' } } }, render: () => { - const WithActionsDemo = () => { + const ToastTrigger = () => { const { createToast, dismissToast } = useToaster(); - const [action, setAction] = useState(); - const showToastWithAction = () => { - const toastId = createToast({ + const showToast = () => { + const id = createToast({ + variant: 'warning', title: 'File upload failed', description: 'Your file could not be uploaded.', - variant: 'warning', + persistent: true, actions: ( -
- { - setAction('abort'); - dismissToast(toastId); - }} - variant="ghost" - > + + dismissToast(id)}> Abort - { - setAction('retry'); - dismissToast(toastId); - }} - variant="danger" - > + dismissToast(id)}> Re-try -
+ ), - persistent: true, // No auto-dismiss }); }; return ( -
-

Toast with Action

- - Show Toast with Actions - - {action &&

{action}

} -
+ + Show toast with actions + ); }; return ( - + ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Test toast creation with actions - await userEvent.click(canvas.getByTestId('show-toast-button')); - - // Wait for toast to appear and verify content - await waitFor(async () => { - await expect(canvas.getByText('File upload failed')).toBeInTheDocument(); - await expect(canvas.getByText('Your file could not be uploaded.')).toBeInTheDocument(); - }); - - // Verify action buttons are present - await expect(canvas.getByTestId('abort-button')).toBeInTheDocument(); - await expect(canvas.getByTestId('retry-button')).toBeInTheDocument(); - - // Test abort button functionality - await userEvent.click(canvas.getByTestId('abort-button')); - - // Verify toast is dismissed and action is recorded - await waitFor(() => { - return expect(canvas.getByTestId('action-result')).toHaveTextContent('abort'); - }); - - // Wait a bit to ensure toast is dismissed - await waitFor( - () => { - return expect(canvas.queryByText('File upload failed')).not.toBeInTheDocument(); - }, - { timeout: 1000 }, - ); - }, -}; - -export const Error: Story = { - render: () => ( - - - - ), }; +/** + * Long descriptions wrap and the toast grows to fit while staying within its max width. + */ export const LongContent: Story = { + parameters: { docs: { source: { type: 'code' } } }, render: () => { - const LongContentDemo = () => { + const ToastTrigger = () => { const { createToast } = useToaster(); - const showLongToast = () => { - createToast({ - title: 'Important Notice', - description: - 'This is a longer message that demonstrates how the toast component handles extended content. The text will wrap appropriately and maintain good readability while staying within the toast boundaries.', - variant: 'warning', - duration: 8000, - }); - }; - return ( -
-

Long Content Toast

- - Show Long Content Toast - -
+ + createToast({ + variant: 'warning', + title: 'Important notice', + description: + 'This is a longer message that demonstrates how the toast handles extended content. ' + + 'The text wraps and stays readable while remaining within the toast boundaries.', + }) + } + > + Show long content toast + ); }; return ( - + ); }, }; -const MultipleToastsDemo = () => { - const { createToast, dismissAllToasts, getToastsCount } = useToaster(); - const [count, setCount] = useState(getToastsCount()); - - const createToastWithDelay = (toast: DsToastProps, delay: number = 0) => { - setTimeout(() => { - createToast(toast); - setCount(getToastsCount()); - }, delay); - }; - - const dismissAllVisibleToasts = () => { - dismissAllToasts(); - setTimeout(() => { - setCount(getToastsCount()); - }, 500); - }; - - const createMultipleToasts = () => { - createToastWithDelay({ - variant: 'success', - title: 'First Toast', - description: 'This is the first toast message.', - }); - - createToastWithDelay( - { - variant: 'info', - title: 'Second Toast', - description: 'This is the second toast message.', - }, - 500, - ); - - createToastWithDelay( - { - variant: 'warning', - title: 'Third Toast', - description: 'This is the third toast message.', - }, - 1000, - ); - }; +/** + * Set `persistent` to keep a toast open until the user dismisses it. Persistent toasts never + * auto-dismiss, so they cannot also set `duration`. + */ +export const Persistent: Story = { + parameters: { docs: { source: { type: 'code' } } }, + render: () => { + const ToastTrigger = () => { + const { createToast } = useToaster(); - return ( -
-

Multiple Toasts Demo

-
+ return ( + createToast({ + variant: 'info', + title: 'Sync in progress', + description: 'This toast stays until you close it.', + persistent: true, + }) + } > - Create Multiple Toasts + Show persistent toast - - Dismiss All - -
- Count: {count} -
- ); -}; - -export const MultipleToasts: Story = { - render: () => ( - - - - ), - parameters: { - docs: { - description: { - story: 'Demo showing multiple toasts stacked together with dismiss all functionality.', - }, - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Verify initial state - await expect(canvas.getByTestId('toast-count')).toHaveTextContent('0'); - - // Test creating multiple toasts - await userEvent.click(canvas.getByTestId('create-multiple-button')); - - // First toast should appear immediately - await waitFor(() => { - return expect(canvas.getByText('First Toast')).toBeInTheDocument(); - }); - - // Wait for second toast (500ms delay) - await waitFor( - () => { - return expect(canvas.getByText('Second Toast')).toBeInTheDocument(); - }, - { timeout: 1000 }, - ); + ); + }; - // Wait for third toast (1000ms delay) - await waitFor( - () => { - return expect(canvas.getByText('Third Toast')).toBeInTheDocument(); - }, - { timeout: 1500 }, + return ( + + + ); + }, +}; - // Verify all toasts are visible - await expect(canvas.getByText('First Toast')).toBeInTheDocument(); - await expect(canvas.getByText('Second Toast')).toBeInTheDocument(); - await expect(canvas.getByText('Third Toast')).toBeInTheDocument(); - - // Verify count shows 3 toasts - await expect(canvas.getByTestId('toast-count')).toHaveTextContent('3'); +/** + * Toasts stack up to the provider's `max` (default 3). `dismissAllToasts` clears them at once. + */ +export const MultipleToasts: Story = { + parameters: { docs: { source: { type: 'code' } } }, + render: () => { + const ToastTrigger = () => { + const { createToast, dismissAllToasts } = useToaster(); - // Test dismiss all functionality - await userEvent.click(canvas.getByTestId('dismiss-all-button')); + const showToasts = () => { + createToast({ variant: 'success', title: 'First toast', description: 'This is the first message.' }); + createToast({ variant: 'info', title: 'Second toast', description: 'This is the second message.' }); + createToast({ variant: 'warning', title: 'Third toast', description: 'This is the third message.' }); + }; - // Verify all toasts are dismissed - await waitFor(async () => { - await expect(canvas.queryByText('First Toast')).not.toBeInTheDocument(); - await expect(canvas.queryByText('Second Toast')).not.toBeInTheDocument(); - await expect(canvas.queryByText('Third Toast')).not.toBeInTheDocument(); - }); + return ( + + + Show multiple toasts + + dismissAllToasts()}> + Dismiss all + + + ); + }; - // Verify count is back to 0 - await waitFor( - () => { - return expect(canvas.getByTestId('toast-count')).toHaveTextContent('0'); - }, - { timeout: 1000 }, + return ( + + + ); }, }; diff --git a/packages/design-system/src/components/ds-tooltip/__tests__/__snapshots__/ds-tooltip.docs.snap b/packages/design-system/src/components/ds-tooltip/__tests__/__snapshots__/ds-tooltip.docs.snap new file mode 100644 index 000000000..2e08f220b --- /dev/null +++ b/packages/design-system/src/components/ds-tooltip/__tests__/__snapshots__/ds-tooltip.docs.snap @@ -0,0 +1,113 @@ +# DsTooltip docs snippets + +## Default + +### Show code + + + + +### MCP manifest +const Default = () => ; + +## Long Text + +### Show code + + + + +### MCP manifest +const LongText = () => ; + +## Rich Content + +### Show code +Multi-line tooltip with JSXNo truncation should occur.}> + + + +### MCP manifest +const RichContent = () => + Multi-line tooltip with JSX + No truncation should occur. + )}>; + +## Placement End + +### Show code + + + + +### MCP manifest +const PlacementEnd = () => ; + +## Disabled + +### Show code + + + + +### MCP manifest +const Disabled = () => ; + +## Interactive + +### Show code +Open in catalog} + interactive +> + + + +### MCP manifest +const Interactive = () => Open in catalog + )} + interactive + closeDelay={150}>; + +## Custom Width With Ellipsis + +### Show code + + + + +### MCP manifest +const CustomWidthWithEllipsis = () => ; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-tooltip/ds-tooltip.stories.tsx b/packages/design-system/src/components/ds-tooltip/ds-tooltip.stories.tsx index 9782bdbba..32ebc6ddf 100644 --- a/packages/design-system/src/components/ds-tooltip/ds-tooltip.stories.tsx +++ b/packages/design-system/src/components/ds-tooltip/ds-tooltip.stories.tsx @@ -3,6 +3,8 @@ import DsTooltip from './ds-tooltip'; import { tooltipPlacements } from './ds-tooltip.types'; import { DsButtonV3 } from '../ds-button-v3'; import { DsIcon } from '../ds-icon'; +import { DsStack } from '../ds-stack'; +import { DsTypography } from '../ds-typography'; const meta: Meta = { title: 'Components/Tooltip', @@ -56,14 +58,17 @@ export const LongText: Story = { }, }; +/** + * Tooltips accept rich JSX content, not just strings. Compose `DsStack` and + * `DsTypography` so the layout and text styles inherit the on-dark tooltip palette. + */ export const RichContent: Story = { args: { content: ( -
- Multi-line tooltip with JSX -
- No truncation should occur. -
+ + Multi-line tooltip with JSX + No truncation should occur. + ), children: , }, diff --git a/packages/design-system/tests/storybook/docs-snippets.docs.test.ts b/packages/design-system/tests/storybook/docs-snippets.docs.test.ts index d56963214..2ea3c7d69 100644 --- a/packages/design-system/tests/storybook/docs-snippets.docs.test.ts +++ b/packages/design-system/tests/storybook/docs-snippets.docs.test.ts @@ -23,6 +23,8 @@ const COMPONENTS = [ 'date-range-picker', 'dialog', 'divider', + 'drawer', + 'dropdown-menu', 'expandable-text-input', 'file-upload', 'filter-status-icon', @@ -32,8 +34,11 @@ const COMPONENTS = [ 'key-value-pair', 'loader', 'main-menu', + 'modal', 'number-input', + 'panel', 'password-input', + 'popover', 'progress-arc', 'progress-donut', 'progress-linear', @@ -54,7 +59,9 @@ const COMPONENTS = [ 'text-input', 'textarea', 'time-picker', + 'toast', 'toggle', + 'tooltip', 'tree', 'typography', 'vertical-tabs', From becf029414174ed8a08e623d222990f1f194f7fc Mon Sep 17 00:00:00 2001 From: vpolessky Date: Fri, 4 Sep 2026 17:23:44 +0200 Subject: [PATCH 2/4] chore(design-system): add cleanup sb overlay [AR-78170] --- .../__tests__/__snapshots__/ds-panel.docs.snap | 10 ++++++++-- .../src/components/ds-panel/ds-panel.stories.tsx | 3 ++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/design-system/src/components/ds-panel/__tests__/__snapshots__/ds-panel.docs.snap b/packages/design-system/src/components/ds-panel/__tests__/__snapshots__/ds-panel.docs.snap index db9f91d8f..d9cea700f 100644 --- a/packages/design-system/src/components/ds-panel/__tests__/__snapshots__/ds-panel.docs.snap +++ b/packages/design-system/src/components/ds-panel/__tests__/__snapshots__/ds-panel.docs.snap @@ -23,7 +23,10 @@ This is a panel. It can contain any content you like, such as text, images, or other components. - It is collapsible. Hover it to see the trigger button. + + It is collapsible — hover over the panel to reveal the trigger button that expands or collapses the + content. + Primary Action
@@ -44,7 +47,10 @@ const Default = function Render({ variant }) { This is a panel. It can contain any content you like, such as text, images, or other components. - It is collapsible. Hover it to see the trigger button. + + It is collapsible — hover over the panel to reveal the trigger button that expands or collapses the + content. + Primary Action diff --git a/packages/design-system/src/components/ds-panel/ds-panel.stories.tsx b/packages/design-system/src/components/ds-panel/ds-panel.stories.tsx index 70bd3c93b..dbd93f215 100644 --- a/packages/design-system/src/components/ds-panel/ds-panel.stories.tsx +++ b/packages/design-system/src/components/ds-panel/ds-panel.stories.tsx @@ -36,7 +36,8 @@ export const Default: Story = { - It is collapsible. Hover it to see the trigger button. + It is collapsible — hover over the panel to reveal the trigger button that expands or collapses + the content. Primary Action From 6eaf118f17f93d889d36e367d4a157c6cb95c3e1 Mon Sep 17 00:00:00 2001 From: vpolessky Date: Fri, 4 Sep 2026 17:50:46 +0200 Subject: [PATCH 3/4] chore(design-system): add cleanup sb overlay [AR-78170] --- .../__snapshots__/ds-panel.docs.snap | 20 +++++-------------- .../components/ds-panel/ds-panel.stories.tsx | 11 +++------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/packages/design-system/src/components/ds-panel/__tests__/__snapshots__/ds-panel.docs.snap b/packages/design-system/src/components/ds-panel/__tests__/__snapshots__/ds-panel.docs.snap index d9cea700f..d1dd36766 100644 --- a/packages/design-system/src/components/ds-panel/__tests__/__snapshots__/ds-panel.docs.snap +++ b/packages/design-system/src/components/ds-panel/__tests__/__snapshots__/ds-panel.docs.snap @@ -19,14 +19,9 @@ {!open && setOpen(true)}>Open Panel} - - This is a panel. It can contain any content you like, such as text, images, or other components. - + Panels hold any content you like. - - It is collapsible — hover over the panel to reveal the trigger button that expands or collapses the - content. - + Hover to reveal the collapse trigger. Primary Action @@ -43,14 +38,9 @@ const Default = function Render({ variant }) { {!open && setOpen(true)}>Open Panel} - - This is a panel. It can contain any content you like, such as text, images, or other components. - - - - It is collapsible — hover over the panel to reveal the trigger button that expands or collapses the - content. - + Panels hold any content you like. + + Hover to reveal the collapse trigger. Primary Action diff --git a/packages/design-system/src/components/ds-panel/ds-panel.stories.tsx b/packages/design-system/src/components/ds-panel/ds-panel.stories.tsx index dbd93f215..292da431b 100644 --- a/packages/design-system/src/components/ds-panel/ds-panel.stories.tsx +++ b/packages/design-system/src/components/ds-panel/ds-panel.stories.tsx @@ -31,14 +31,9 @@ export const Default: Story = { {!open && setOpen(true)}>Open Panel} - - This is a panel. It can contain any content you like, such as text, images, or other components. - - - - It is collapsible — hover over the panel to reveal the trigger button that expands or collapses - the content. - + Panels hold any content you like. + + Hover to reveal the collapse trigger. Primary Action From df48e7b38380964e1c1e23363ee55c8831d744ce Mon Sep 17 00:00:00 2001 From: vpolessky Date: Fri, 4 Sep 2026 18:10:11 +0200 Subject: [PATCH 4/4] chore(design-system): add cleanup sb overlay [AR-78170] --- .../ds-main-menu/__tests__/ds-main-menu.browser.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/design-system/src/components/ds-main-menu/__tests__/ds-main-menu.browser.test.tsx b/packages/design-system/src/components/ds-main-menu/__tests__/ds-main-menu.browser.test.tsx index 6fbc191ba..916623ffe 100644 --- a/packages/design-system/src/components/ds-main-menu/__tests__/ds-main-menu.browser.test.tsx +++ b/packages/design-system/src/components/ds-main-menu/__tests__/ds-main-menu.browser.test.tsx @@ -278,7 +278,8 @@ describe('DsMainMenu — tile and utility link behavior', () => { const comingSoonTile = page.getByRole('button', { name: 'Coming soon app' }); const badge = comingSoonTile.element().querySelector('[class*="badge"]') as HTMLElement; - await userEvent.hover(badge); + await comingSoonTile.hover(); + await page.elementLocator(badge).hover(); await expect.element(page.getByRole('tooltip', { name: COMING_SOON_TOOLTIP })).toBeVisible(); });