diff --git a/.agents/skills/docs-tests/SKILL.md b/.agents/skills/docs-tests/SKILL.md index 05244e55b..f06369ba8 100644 --- a/.agents/skills/docs-tests/SKILL.md +++ b/.agents/skills/docs-tests/SKILL.md @@ -37,6 +37,8 @@ ds-{name}/__tests__/__snapshots__/ Coverage is centralized in the global test runner; each component gets one aggregated golden snapshot file colocated in its `__tests__/__snapshots__/` folder. +**Split folders** (listed in `SPLIT_PER_MANIFEST`): a folder whose stories fan out into many story titles (e.g. `ds-table` → `Components/Table`, `Components/Table/Selection`, …) would produce one unnavigable multi-thousand-line golden. Those write **one golden per manifest component**, named from the title-derived id (`components-table-selection` → `ds-table-selection.docs.snap`), colocated in the same `__tests__/__snapshots__/` folder. + ## Opt in a component Add one kebab folder suffix (the `ds-` prefix is implied) to the `COMPONENTS` allowlist in [`docs-snippets.docs.test.ts`](../../../packages/design-system/tests/storybook/docs-snippets.docs.test.ts): 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 c6f527bbe..11cf2e390 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,6 +278,7 @@ 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 comingSoonTile.hover(); await page.elementLocator(badge).hover(); await expect diff --git a/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-active-row.docs.snap b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-active-row.docs.snap new file mode 100644 index 000000000..1d0d9fba0 --- /dev/null +++ b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-active-row.docs.snap @@ -0,0 +1,330 @@ +# DsTable docs snippets + +## Active Row + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + } + ]} + emptyState={} + fullWidth + onRowClick={() => {}} + stickyHeader +/> + +### MCP manifest +const ActiveRow = () => } + onRowClick={fn()} + activeRowId="3" />; + +## Active Row with Drawer + +### Show code +{ + name: 'Active Row with Drawer', + args: { + data: defaultData.slice(0, 10) + }, + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args) { + const [selectedPerson, setSelectedPerson] = useState(null); + const activeRowId = selectedPerson?.id; + const isDrawerOpen = !!activeRowId; + const handleRowClick = (person: Person) => { + const isSameRow = activeRowId === person.id; + setSelectedPerson(isSameRow ? null : person); + }; + return <> + + + { + if (!open) { + setSelectedPerson(null); + } + }} columns={4} position="end"> + {selectedPerson &&
+ + + Person Details + setSelectedPerson(null)} /> + + + + + + Full Name + + + {selectedPerson.firstName} {selectedPerson.lastName} + + + + + + Age + + {selectedPerson.age} years old + + + + + Visits + + {selectedPerson.visits} visits + + + + + Status + + + {selectedPerson.status.charAt(0).toUpperCase() + selectedPerson.status.slice(1)} + + + + + + Profile Progress + + + + + +
} +
+ ; + } +} + +### MCP manifest +const WithDrawerAndActiveRow = () => { + const [selectedPerson, setSelectedPerson] = useState(null); + + const activeRowId = selectedPerson?.id; + const isDrawerOpen = !!activeRowId; + + const handleRowClick = (person: Person) => { + const isSameRow = activeRowId === person.id; + + setSelectedPerson(isSameRow ? null : person); + }; + + return ( + <> + } + activeRowId={activeRowId} + onRowClick={handleRowClick} /> + { + if (!open) { + setSelectedPerson(null); + } + }} + columns={4} + position="end"> + {selectedPerson && ( +
+ + + Person Details + setSelectedPerson(null)} + /> + + + + + + Full Name + + + {selectedPerson.firstName} {selectedPerson.lastName} + + + + + + Age + + {selectedPerson.age} years old + + + + + Visits + + {selectedPerson.visits} visits + + + + + Status + + + {selectedPerson.status.charAt(0).toUpperCase() + selectedPerson.status.slice(1)} + + + + + + Profile Progress + + + + + +
+ )} +
+ + ); +}; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-column-groups.docs.snap b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-column-groups.docs.snap new file mode 100644 index 000000000..fae275784 --- /dev/null +++ b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-column-groups.docs.snap @@ -0,0 +1,904 @@ +# DsTable docs snippets + +## Default + +### Show code + {}, + header: 'First Name', + meta: { + keepVisibleWhenCollapsed: true + } + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + } + ], + header: 'Identity', + id: 'identity', + meta: { + group: { + collapsible: true, + defaultCollapsed: false + } + } + }, + { + columns: [ + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits', + meta: { + keepVisibleWhenCollapsed: true + } + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ], + header: 'Activity', + id: 'activity', + meta: { + group: { + collapsible: true + } + } + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + selectable + stickyHeader +/> + +### MCP manifest +const Default = () => } />; + +## Initially Collapsed + +### Show code + {}, + header: 'First Name', + meta: { + keepVisibleWhenCollapsed: true + } + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + } + ], + header: 'Identity', + id: 'identity', + meta: { + group: { + collapsible: true, + defaultCollapsed: true + } + } + }, + { + columns: [ + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits', + meta: { + keepVisibleWhenCollapsed: true + } + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ], + header: 'Activity', + id: 'activity', + meta: { + group: { + collapsible: true + } + } + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + selectable + stickyHeader +/> + +### MCP manifest +const InitiallyCollapsed = () => } />; + +## Controlled + +### Show code + {}, + header: 'First Name', + meta: { + keepVisibleWhenCollapsed: true + } + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + } + ], + header: 'Identity', + id: 'identity', + meta: { + group: { + collapsible: true, + defaultCollapsed: false + } + } + }, + { + columns: [ + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits', + meta: { + keepVisibleWhenCollapsed: true + } + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ], + header: 'Activity', + id: 'activity', + meta: { + group: { + collapsible: true + } + } + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + onCollapsedColumnGroupsChange={() => {}} + selectable + stickyHeader +/> + +### MCP manifest +const Controlled = () => { + const [collapsed, setCollapsed] = useState(['activity']); + + return ( + } + collapsedColumnGroups={collapsed} + onCollapsedColumnGroupsChange={setCollapsed} /> + ); +}; + +## With Controls + +### Show code + {}, + header: 'First Name', + meta: { + keepVisibleWhenCollapsed: true + } + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + } + ], + header: 'Identity', + id: 'identity', + meta: { + group: { + collapsible: true, + defaultCollapsed: false + } + } + }, + { + columns: [ + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits', + meta: { + keepVisibleWhenCollapsed: true + } + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ], + header: 'Activity', + id: 'activity', + meta: { + group: { + collapsible: true + } + } + } + ]} + controls={RED version V2Import REDExport REDActions} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + selectable + stickyHeader +/> + +### MCP manifest +const WithControls = () => } + controls={( + + RED version V2 + + + + Import RED + + + Export RED + + Actions + + )} />; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-columns.docs.snap b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-columns.docs.snap new file mode 100644 index 000000000..3e1faac80 --- /dev/null +++ b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-columns.docs.snap @@ -0,0 +1,309 @@ +# DsTable docs snippets + +## Progress as Infographic + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + onRowClick={() => {}} + stickyHeader +/> + +### MCP manifest +const WithProgressInfographic = () => { + if ('accessorKey' in col && col.accessorKey === 'progress') { + return { + ...col, + header: 'Profile Progress', + cell: (info) => , + }; + } else if ('accessorKey' in col && col.accessorKey === 'status') { + return { + ...col, + header: 'Status', + cell: (info) => ( + + {info.getValue() as string} + + ), + }; + } + return col; + })} + data={defaultData} + stickyHeader + bordered + fullWidth + expandable={false} + emptyState={} + onRowClick={fn()} />; + +## Column Hiding + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args) { + const columnsToToggle = [{ + id: 'age', + label: 'Age' + }, { + id: 'visits', + label: 'Visits' + }, { + id: 'status', + label: 'Status' + }, { + id: 'progress', + label: 'Profile Progress' + }]; + const [columnVisibility, setColumnVisibility] = useState({ + age: true, + visits: true, + status: true, + progress: true + }); + const toggleColumn = (columnId: string) => { + setColumnVisibility(prev => ({ + ...prev, + [columnId]: !prev[columnId] + })); + }; + return + + {columnsToToggle.map(column => toggleColumn(column.id)} />)} + + + + ; + } +} + +### MCP manifest +const ColumnHiding = () => { + const columnsToToggle = [ + { id: 'age', label: 'Age' }, + { id: 'visits', label: 'Visits' }, + { id: 'status', label: 'Status' }, + { id: 'progress', label: 'Profile Progress' }, + ]; + const [columnVisibility, setColumnVisibility] = useState({ + age: true, + visits: true, + status: true, + progress: true, + }); + + const toggleColumn = (columnId: string) => { + setColumnVisibility((prev) => ({ + ...prev, + [columnId]: !prev[columnId], + })); + }; + + return ( + + + {columnsToToggle.map((column) => ( + toggleColumn(column.id)} + /> + ))} + + } + onRowClick={fn()} + columnVisibility={columnVisibility} + onColumnVisibilityChange={setColumnVisibility} /> + + ); +}; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-editable.docs.snap b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-editable.docs.snap new file mode 100644 index 000000000..e5ef23ff2 --- /dev/null +++ b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-editable.docs.snap @@ -0,0 +1,485 @@ +# DsTable docs snippets + +## Editable + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args) { + const [data, setData] = useState(defaultData); + const statusOptions = [{ + label: 'Single', + value: 'single' + }, { + label: 'Relationship', + value: 'relationship' + }, { + label: 'Complicated', + value: 'complicated' + }]; + const statusLabels: Record = { + single: 'Single', + relationship: 'Relationship', + complicated: 'Complicated' + }; + const progressPresets = [25, 50, 75, 100]; + const ProgressEditor = ({ + cellContext + }: { + cellContext: CellContext; + }) => { + const { + value, + setValue, + error + } = useCellEditor({ + cellContext + }); + return +
+ {progressPresets.map(preset => setValue(preset)} />)} +
+ +
; + }; + const columns: ColumnDef[] = [{ + accessorKey: 'id', + header: 'ID', + size: 60, + cell: info => {info.getValue() as string} + }, { + accessorKey: 'firstName', + header: 'First Name', + cell: info => info.getValue(), + editCell: (info: CellContext) => + }, { + accessorKey: 'lastName', + header: 'Last Name', + cell: info => info.getValue(), + editCell: (info: CellContext) => + }, { + accessorKey: 'age', + header: 'Age', + size: 100, + cell: info => info.getValue(), + editCell: (info: CellContext) => + }, { + accessorKey: 'visits', + header: 'Visits', + size: 100, + cell: info => info.getValue(), + editCell: (info: CellContext) => , + editDisabled: (info: CellContext) => { + if (info.row.original.status === 'complicated') { + return { + reason: 'Visits are locked while the status is “Complicated”.' + }; + } + if (info.row.original.age >= 40) { + return true; + } + return false; + } + }, { + accessorKey: 'status', + header: 'Status', + size: 160, + cell: info => statusLabels[info.getValue() as Status], + editCell: (info: CellContext) => + }, { + accessorKey: 'progress', + header: 'Profile Progress', + cell: info => {`${String(info.getValue())}%`}, + editCell: (info: CellContext) => + }]; + return { + setData(rows => rows.map(person => person.id === row.id ? { + ...person, + [columnId]: value + } : person)); + }} />; + } +} + +### MCP manifest +const Editable = () => { + const [data, setData] = useState(defaultData); + + const statusOptions = [ + { label: 'Single', value: 'single' }, + { label: 'Relationship', value: 'relationship' }, + { label: 'Complicated', value: 'complicated' }, + ]; + + const statusLabels: Record = { + single: 'Single', + relationship: 'Relationship', + complicated: 'Complicated', + }; + + const progressPresets = [25, 50, 75, 100]; + + const ProgressEditor = ({ cellContext }: { cellContext: CellContext }) => { + const { value, setValue, error } = useCellEditor({ cellContext }); + + return ( + +
+ {progressPresets.map((preset) => ( + setValue(preset)} + /> + ))} +
+ +
+ ); + }; + + const columns: ColumnDef[] = [ + { + accessorKey: 'id', + header: 'ID', + size: 60, + cell: (info) => {info.getValue() as string}, + }, + { + accessorKey: 'firstName', + header: 'First Name', + cell: (info) => info.getValue(), + editCell: (info: CellContext) => ( + + ), + }, + { + accessorKey: 'lastName', + header: 'Last Name', + cell: (info) => info.getValue(), + editCell: (info: CellContext) => ( + + ), + }, + { + accessorKey: 'age', + header: 'Age', + size: 100, + cell: (info) => info.getValue(), + editCell: (info: CellContext) => ( + + ), + }, + { + accessorKey: 'visits', + header: 'Visits', + size: 100, + cell: (info) => info.getValue(), + editCell: (info: CellContext) => , + editDisabled: (info: CellContext) => { + if (info.row.original.status === 'complicated') { + return { reason: 'Visits are locked while the status is “Complicated”.' }; + } + if (info.row.original.age >= 40) { + return true; + } + return false; + }, + }, + { + accessorKey: 'status', + header: 'Status', + size: 160, + cell: (info) => statusLabels[info.getValue() as Status], + editCell: (info: CellContext) => ( + + ), + }, + { + accessorKey: 'progress', + header: 'Profile Progress', + cell: (info) => {`${String(info.getValue())}%`}, + editCell: (info: CellContext) => , + }, + ]; + + return ( + } + data={data} + columns={columns} + selectable + onRowClick={fn()} + primaryRowActions={[{ icon: 'delete_outline', label: 'Delete', onClick: fn() }]} + secondaryRowActions={[{ icon: 'info', label: 'Details', onClick: fn() }]} + onCellEdit={(row, columnId, value) => { + setData((rows) => + rows.map((person) => (person.id === row.id ? { ...person, [columnId]: value } : person)), + ); + }} /> + ); +}; + +## Live Validation + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args) { + const [data, setData] = useState(defaultData); + const personSchema = z.object({ + firstName: z.string().trim().min(1, 'First name is required').max(50, 'Max 50 characters'), + lastName: z.string().trim().min(1, 'Last name is required').max(50, 'Max 50 characters') + }); + const validateField = (columnId: string, value: unknown): string | null => { + const shape: Record = personSchema.shape; + const fieldSchema = shape[columnId]; + if (!fieldSchema) { + return null; + } + const result = fieldSchema.safeParse(value); + return result.success ? null : result.error.issues[0]?.message ?? null; + }; + const columns: ColumnDef[] = [{ + accessorKey: 'id', + header: 'ID', + size: 60, + cell: info => {info.getValue() as string} + }, { + accessorKey: 'firstName', + header: 'First Name', + cell: info => info.getValue(), + editCell: (info: CellContext) => + }, { + accessorKey: 'lastName', + header: 'Last Name', + cell: info => info.getValue(), + editCell: (info: CellContext) => + }]; + return validateField(columnId, value)} onCellEdit={(row, columnId, value) => { + setData(rows => rows.map(person => person.id === row.id ? { + ...person, + [columnId]: value + } : person)); + }} />; + } +} + +### MCP manifest +const LiveValidation = () => { + const [data, setData] = useState(defaultData); + + const personSchema = z.object({ + firstName: z.string().trim().min(1, 'First name is required').max(50, 'Max 50 characters'), + lastName: z.string().trim().min(1, 'Last name is required').max(50, 'Max 50 characters'), + }); + + const validateField = (columnId: string, value: unknown): string | null => { + const shape: Record = personSchema.shape; + const fieldSchema = shape[columnId]; + if (!fieldSchema) { + return null; + } + const result = fieldSchema.safeParse(value); + return result.success ? null : (result.error.issues[0]?.message ?? null); + }; + + const columns: ColumnDef[] = [ + { + accessorKey: 'id', + header: 'ID', + size: 60, + cell: (info) => {info.getValue() as string}, + }, + { + accessorKey: 'firstName', + header: 'First Name', + cell: (info) => info.getValue(), + editCell: (info: CellContext) => ( + + ), + }, + { + accessorKey: 'lastName', + header: 'Last Name', + cell: (info) => info.getValue(), + editCell: (info: CellContext) => ( + + ), + }, + ]; + + return ( + } + data={data} + columns={columns} + onCellValidate={(_row, columnId, value) => validateField(columnId, value)} + onCellEdit={(row, columnId, value) => { + setData((rows) => + rows.map((person) => (person.id === row.id ? { ...person, [columnId]: value } : person)), + ); + }} /> + ); +}; + +## Validate on Async Save + +### Show code +{ + name: 'Validate on Async Save', + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args) { + const [data, setData] = useState(defaultData); + const saveFirstName = (value: string, signal: AbortSignal): Promise => new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const trimmed = value.trim(); + if (trimmed.length === 0) { + resolve('First name is required'); + return; + } + if (trimmed.toLowerCase() === 'taken') { + resolve('This name is already taken'); + return; + } + resolve(null); + }, 900); + signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + const columns: ColumnDef[] = [{ + accessorKey: 'id', + header: 'ID', + size: 60, + cell: info => {info.getValue() as string} + }, { + accessorKey: 'firstName', + header: 'First Name', + cell: info => info.getValue(), + editCell: (info: CellContext) => + }, { + accessorKey: 'lastName', + header: 'Last Name', + cell: info => info.getValue() + }]; + return { + const error = await saveFirstName(value as string, signal); + if (error !== null) { + return error; + } + setData(rows => rows.map(person => person.id === row.id ? { + ...person, + [columnId]: value + } : person)); + }} />; + } +} + +### MCP manifest +const ValidateOnAsyncSave = () => { + const [data, setData] = useState(defaultData); + + const saveFirstName = (value: string, signal: AbortSignal): Promise => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const trimmed = value.trim(); + if (trimmed.length === 0) { + resolve('First name is required'); + return; + } + if (trimmed.toLowerCase() === 'taken') { + resolve('This name is already taken'); + return; + } + resolve(null); + }, 900); + + signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + + const columns: ColumnDef[] = [ + { + accessorKey: 'id', + header: 'ID', + size: 60, + cell: (info) => {info.getValue() as string}, + }, + { + accessorKey: 'firstName', + header: 'First Name', + cell: (info) => info.getValue(), + editCell: (info: CellContext) => ( + + ), + }, + { + accessorKey: 'lastName', + header: 'Last Name', + cell: (info) => info.getValue(), + }, + ]; + + return ( + } + data={data} + columns={columns} + onCellEdit={async (row, columnId, value, signal) => { + const error = await saveFirstName(value as string, signal); + if (error !== null) { + return error; + } + setData((rows) => + rows.map((person) => (person.id === row.id ? { ...person, [columnId]: value } : person)), + ); + }} /> + ); +}; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-expansion.docs.snap b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-expansion.docs.snap new file mode 100644 index 000000000..86a900927 --- /dev/null +++ b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-expansion.docs.snap @@ -0,0 +1,391 @@ +# DsTable docs snippets + +## Expandable + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + } + ]} + emptyState={} + expandable={() => {}} + fullWidth + onRowClick={() => {}} + renderExpandedRow={() => {}} + stickyHeader +/> + +### MCP manifest +const Expandable = () => row.firstName !== 'Tanner'} + emptyState={} + onRowClick={fn()} + renderExpandedRow={(row) => ( + + + Expanded Details for {row.firstName} + ID: {row.id} + + Full Name: {row.firstName} {row.lastName} + + Status: {row.status} + + + + + )} />; + +## Custom Expander Column Width + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + } + ]} + emptyState={} + expandable + expandableColumnWidth={48} + fullWidth + onRowClick={() => {}} + renderExpandedRow={() => {}} + stickyHeader +/> + +### MCP manifest +const CustomExpanderColumnWidth = () => } + onRowClick={fn()} + expandableColumnWidth={48} + renderExpandedRow={(row) => ( + + Details for {row.firstName} + + )} />; + +## Programmatic Expansion + +### Show code +{ + args: { + data: defaultData.slice(0, 5), + expandable: row => row.firstName !== 'Tanner', + renderExpandedRow: row => + Expanded Details for {row.firstName} + ID: {row.id} + + Full Name: {row.firstName} {row.lastName} + + Status: {row.status} + + }, + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args) { + const tableRef = useRef>(null); + const [expandedRows, setExpandedRows] = useState([]); + const expandRow = (rowId: string) => { + tableRef.current?.expandRow(rowId); + setExpandedRows(prev => prev.includes(rowId) ? prev : [...prev, rowId]); + }; + const expandAllRows = () => { + tableRef.current?.expandAllRows(); + const expandableRowIds = defaultData.slice(0, 5).filter(row => row.firstName !== 'Tanner').map(row => row.id); + setExpandedRows(expandableRowIds); + }; + const collapseAllRows = () => { + tableRef.current?.collapseAllRows(); + setExpandedRows([]); + }; + const expandFirstThreeRows = () => { + const firstThreeIds = ['2', '3', '4']; + tableRef.current?.expandRows(firstThreeIds); + setExpandedRows(firstThreeIds); + }; + return + + Expanded rows: {expandedRows.length > 0 ? expandedRows.join(', ') : 'None'} + + + + expandRow('2')}> + Expand Kevin + + expandRow('3')}> + Expand John + + expandRow('4')}> + Expand Jane + + + Expand All + + + Collapse All + + + Expand First 3 Expandable + + + + + ; + } +} + +### MCP manifest +const ProgrammaticExpansion = () => { + const tableRef = useRef>(null); + const [expandedRows, setExpandedRows] = useState([]); + + const expandRow = (rowId: string) => { + tableRef.current?.expandRow(rowId); + setExpandedRows((prev) => (prev.includes(rowId) ? prev : [...prev, rowId])); + }; + + const expandAllRows = () => { + tableRef.current?.expandAllRows(); + const expandableRowIds = defaultData + .slice(0, 5) + .filter((row) => row.firstName !== 'Tanner') + .map((row) => row.id); + setExpandedRows(expandableRowIds); + }; + + const collapseAllRows = () => { + tableRef.current?.collapseAllRows(); + setExpandedRows([]); + }; + + const expandFirstThreeRows = () => { + const firstThreeIds = ['2', '3', '4']; + tableRef.current?.expandRows(firstThreeIds); + setExpandedRows(firstThreeIds); + }; + + return ( + + Expanded rows: {expandedRows.length > 0 ? expandedRows.join(', ') : 'None'} + + + expandRow('2')}>Expand Kevin + + expandRow('3')}>Expand John + + expandRow('4')}>Expand Jane + + Expand All + + Collapse All + + Expand First 3 Expandable + + + row.firstName !== 'Tanner'} + emptyState={} + onRowClick={fn()} + renderExpandedRow={(row) => ( + + Expanded Details for {row.firstName} + ID: {row.id} + + Full Name: {row.firstName} {row.lastName} + + Status: {row.status} + + )} + ref={tableRef} /> + + ); +}; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-filters.docs.snap b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-filters.docs.snap new file mode 100644 index 000000000..906a9d7e4 --- /dev/null +++ b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-filters.docs.snap @@ -0,0 +1,219 @@ +# DsTable docs snippets + +## Per-Column — Popover + +### Show code +import type { ColumnDef } from '@tanstack/react-table'; +import { + DsTable, + DsTagFilter, + createCheckboxFilterAdapter, + useTableFilters, +} from '@drivenets/design-system'; + +type DeviceRow = { + id: string; + name: string; + type: 'PP-LGX' | 'ME10' | 'OLT'; + site: string; + status: 'active' | 'warning' | 'failed'; +}; + +const typeOptions = [ + { value: 'PP-LGX' as const, label: 'PP-LGX' }, + { value: 'ME10' as const, label: 'ME10' }, + { value: 'OLT' as const, label: 'OLT' }, +]; + +const siteOptions = [ + { value: 'NYC-DC1', label: 'NYC-DC1' }, + { value: 'LON-DC2', label: 'LON-DC2' }, + { value: 'SFO-DC3', label: 'SFO-DC3' }, +]; + +const typeFilter = createCheckboxFilterAdapter({ + id: 'type', + label: 'Type', + items: typeOptions, + searchable: true, + selectAll: true, +}); + +const siteFilter = createCheckboxFilterAdapter({ + id: 'site', + label: 'Site', + items: siteOptions, + searchable: true, + selectAll: true, +}); + +const columns: ColumnDef[] = [ + { id: 'name', accessorKey: 'name', header: 'Name' }, + { id: 'type', accessorKey: 'type', header: 'Type', meta: { filter: { adapterId: 'type' } } }, + { id: 'site', accessorKey: 'site', header: 'Site', meta: { filter: { adapterId: 'site' } } }, + { id: 'status', accessorKey: 'status', header: 'Status' }, +]; + +function DevicesTable({ rows }: { rows: DeviceRow[] }) { + const { columnFilters, filterChips, enhancedColumns, handlers } = useTableFilters({ + filterAdapters: [typeFilter, siteFilter], + baseColumns: columns, + }); + + return ( + <> + {filterChips.length > 0 && ( + + )} + + + ); +} + +### MCP manifest +const ColumnFilters = function Render() { + const { columnFilters, filterChips, enhancedColumns, handlers } = useTableFilters({ + filterAdapters: adapters, + baseColumns, + }); + + return ( + + {filterChips.length > 0 && ( + + )} + + + + ); +}; + +## Per-Column — Controlled + +### Show code +import { useState } from 'react'; +import type { ColumnDef } from '@tanstack/react-table'; +import { + DsTable, + DsTagFilter, + type CheckboxFilterItem, + type FilterState, + createCheckboxFilterAdapter, + useTableFilters, +} from '@drivenets/design-system'; + +type DeviceRow = { + id: string; + name: string; + type: 'PP-LGX' | 'ME10' | 'OLT'; + site: string; +}; + +const typeFilter = createCheckboxFilterAdapter({ + id: 'type', + label: 'Type', + items: [ + { value: 'PP-LGX', label: 'PP-LGX' }, + { value: 'ME10', label: 'ME10' }, + { value: 'OLT', label: 'OLT' }, + ], + searchable: true, + selectAll: true, +}); + +const columns: ColumnDef[] = [ + { id: 'name', accessorKey: 'name', header: 'Name' }, + { id: 'type', accessorKey: 'type', header: 'Type', meta: { filter: { adapterId: 'type' } } }, + { id: 'site', accessorKey: 'site', header: 'Site' }, +]; + +function ControlledDevicesTable({ rows }: { rows: DeviceRow[] }) { + // Source of truth lives outside the hook (URL, server, parent store, etc.). + const [appliedFilters, setAppliedFilters] = useState>({ + type: [{ value: 'PP-LGX', label: 'PP-LGX' }], + }); + + const { columnFilters, filterChips, enhancedColumns, handlers } = useTableFilters({ + filterAdapters: [typeFilter], + baseColumns: columns, + appliedFilters, + onFiltersChange: setAppliedFilters, + }); + + return ( + <> + {filterChips.length > 0 && ( + + )} + + + ); +} + +### MCP manifest +const ControlledColumnFilters = function Render() { + const [appliedFilters, setAppliedFilters] = useState>({ + type: [{ value: 'PP-LGX', label: 'PP-LGX' }], + }); + + const { columnFilters, filterChips, enhancedColumns, handlers } = useTableFilters({ + filterAdapters: adapters, + baseColumns, + appliedFilters, + onFiltersChange: setAppliedFilters, + }); + + return ( + + {filterChips.length > 0 && ( + + )} + + + + ); +}; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-loading.docs.snap b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-loading.docs.snap new file mode 100644 index 000000000..d2afe164b --- /dev/null +++ b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-loading.docs.snap @@ -0,0 +1,382 @@ +# DsTable docs snippets + +## Loading + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + loading + stickyHeader +/> + +### MCP manifest +const Loading = () => } + loading />; + +## Custom Loading Cell + +### Show code + {}, + header: 'First Name', + loadingCell: () => {} + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + loading + stickyHeader +/> + +### MCP manifest +const CustomLoadingCell = () => } + loading />; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-resizable-columns.docs.snap b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-resizable-columns.docs.snap new file mode 100644 index 000000000..e7ffb5266 --- /dev/null +++ b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-resizable-columns.docs.snap @@ -0,0 +1,939 @@ +# DsTable docs snippets + +## Default + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + resizableColumns + stickyHeader +/> + +### MCP manifest +const Default = () => } />; + +## Fixed And Fill + +### Show code + {}, + header: 'First Name', + size: 200 + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name', + size: 200 + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age', + size: 120 + }, + { + accessorKey: 'visits', + cell: () => {}, + enableResizing: false, + header: 'Visits', + size: 120 + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + resizableColumns + stickyHeader +/> + +### MCP manifest +const FixedAndFill = () => info.getValue(), size: 200 }, + { accessorKey: 'lastName', header: 'Last Name', cell: (info) => info.getValue(), size: 200 }, + { accessorKey: 'age', header: 'Age', cell: (info) => info.getValue(), size: 120 }, + { + accessorKey: 'visits', + header: 'Visits', + cell: (info) => info.getValue(), + enableResizing: false, + size: 120, + }, + { accessorKey: 'status', header: 'Status', cell: (info) => info.getValue() }, + ]} + resizableColumns + stickyHeader + bordered + fullWidth + emptyState={} />; + +## Grouped Columns + +### Show code + {}, + header: 'First Name', + maxSize: 280, + minSize: 80 + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + } + ], + header: 'Identity', + id: 'identity' + }, + { + columns: [ + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Progress' + } + ], + header: 'Activity', + id: 'activity' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + resizableColumns + stickyHeader +/> + +### MCP manifest +const GroupedColumns = () => info.getValue(), + minSize: 80, + maxSize: 280, + }, + { + accessorKey: 'lastName', + header: 'Last Name', + cell: (info) => info.getValue(), + }, + ], + }, + { + id: 'activity', + header: 'Activity', + columns: [ + { accessorKey: 'visits', header: 'Visits', cell: (info) => info.getValue() }, + { accessorKey: 'status', header: 'Status', cell: (info) => info.getValue() }, + { + accessorKey: 'progress', + header: 'Progress', + cell: (info) => `${String(info.getValue())}%`, + }, + ], + }, + ]} + resizableColumns + stickyHeader + bordered + fullWidth + emptyState={} />; + +## Min And Max Size + +### Show code + {}, + header: 'First Name', + maxSize: 280, + minSize: 80, + size: 200 + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name', + minSize: 120, + size: 200 + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age', + maxSize: 180, + size: 120 + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits', + size: 120 + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + resizableColumns + stickyHeader +/> + +### MCP manifest +const MinAndMaxSize = () => info.getValue(), + size: 200, + minSize: 80, + maxSize: 280, + }, + { + accessorKey: 'lastName', + header: 'Last Name', + cell: (info) => info.getValue(), + size: 200, + minSize: 120, + }, + { + accessorKey: 'age', + header: 'Age', + cell: (info) => info.getValue(), + size: 120, + maxSize: 180, + }, + { accessorKey: 'visits', header: 'Visits', cell: (info) => info.getValue(), size: 120 }, + { accessorKey: 'status', header: 'Status', cell: (info) => info.getValue() }, + ]} + resizableColumns + stickyHeader + bordered + fullWidth + emptyState={} />; + +## Persisted widths + +### Show code +{ + name: 'Persisted widths', + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args) { + const STORAGE_KEY = 'storybook.ds-table.resizable.persisted-widths'; + const [tableKey, setTableKey] = useState(0); + let persistedWidths: Record; + try { + persistedWidths = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record; + } catch { + persistedWidths = {}; + } + return + + Persisted widths + + Persist the map from onColumnSizingChange, then pass it back as the columnSizing prop on the next + mount to restore widths — no need to stamp columnDef.size. Columns absent from the map are + measured automatically. Resize a column, refresh the story, and the widths come back. Reset stored + widths remounts from the default layout. + + + + + { + localStorage.removeItem(STORAGE_KEY); + setTableKey(key => key + 1); + }}> + Reset stored widths + + + + { + localStorage.setItem(STORAGE_KEY, JSON.stringify(columnSizing)); + }} /> + ; + } +} + +### MCP manifest +const PersistedWidths = () => { + const STORAGE_KEY = 'storybook.ds-table.resizable.persisted-widths'; + const [tableKey, setTableKey] = useState(0); + + let persistedWidths: Record; + try { + persistedWidths = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record; + } catch { + persistedWidths = {}; + } + + return ( + + + Persisted widths + Persist the map from onColumnSizingChange, then pass it back as the columnSizing prop on the next + mount to restore widths — no need to stamp columnDef.size. Columns absent from the map are + measured automatically. Resize a column, refresh the story, and the widths come back. Reset stored + widths remounts from the default layout. + + + + { + localStorage.removeItem(STORAGE_KEY); + setTableKey((key) => key + 1); + }}>Reset stored widths + + + } + key={tableKey} + columnSizing={persistedWidths} + onColumnSizingChange={(columnSizing) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(columnSizing)); + }} /> + + ); +}; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-row-actions.docs.snap b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-row-actions.docs.snap new file mode 100644 index 000000000..03b1505b5 --- /dev/null +++ b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-row-actions.docs.snap @@ -0,0 +1,1223 @@ +# DsTable docs snippets + +## Reorderable + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + } + ]} + emptyState={} + fullWidth + onOrderChange={() => {}} + onRowClick={() => {}} + reorderable + stickyHeader +/> + +### MCP manifest +const Reorderable = () => } + onRowClick={fn()} + reorderable + onOrderChange={fn()} />; + +## Custom Reorder Column Width + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + } + ]} + emptyState={} + fullWidth + onOrderChange={() => {}} + onRowClick={() => {}} + reorderable + reorderableColumnWidth={80} + stickyHeader +/> + +### MCP manifest +const CustomReorderColumnWidth = () => } + onRowClick={fn()} + reorderable + reorderableColumnWidth={80} + onOrderChange={fn()} />; + +## With Row Actions + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + onRowClick={() => {}} + primaryRowActions={[ + { + icon: 'edit', + label: 'Edit', + onClick: () => {} + }, + { + disabled: () => {}, + icon: 'open_in_new', + label: 'Open in New Window', + onClick: () => {} + } + ]} + secondaryRowActions={[ + { + className: '_destructiveAction_12uvr_138', + disabled: () => {}, + icon: 'delete_outline', + label: 'Delete', + onClick: () => {}, + tooltip: 'Delete this row' + }, + { + icon: 'info', + label: 'Details', + onClick: () => {}, + tooltip: 'Show details' + }, + { + icon: 'call', + label: () => {}, + onClick: () => {} + } + ]} + stickyHeader +/> + +### MCP manifest +const WithRowActions = () => } + onRowClick={fn()} + primaryRowActions={[ + { + icon: 'edit', + label: 'Edit', + onClick: editClickHandler, + }, + { + icon: 'open_in_new', + label: 'Open in New Window', + disabled: (data) => data.firstName === 'Tanner', + onClick: openInNewWindowClickHandler, + }, + ]} + secondaryRowActions={[ + { + icon: 'delete_outline', + label: 'Delete', + tooltip: 'Delete this row', + disabled: (data) => data.status === 'single', + className: styles.destructiveAction, + onClick: fn(), + }, + { + icon: 'info', + label: 'Details', + tooltip: 'Show details', + onClick: fn(), + }, + { + icon: 'call', + label: (row) => `Call ${row.firstName}`, + onClick: fn(), + }, + ]} />; + +## With Conditionally Hidden Actions + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + onRowClick={() => {}} + primaryRowActions={[ + { + icon: 'edit', + label: 'Edit', + onClick: () => {} + }, + { + hidden: () => {}, + icon: 'open_in_new', + label: 'Open in New Window', + onClick: () => {} + } + ]} + secondaryRowActions={[ + { + hidden: () => {}, + icon: 'check_circle', + label: 'Approve', + onClick: () => {} + }, + { + hidden: () => {}, + icon: 'inventory_2', + label: 'Archive', + onClick: () => {} + }, + { + className: '_destructiveAction_12uvr_138', + hidden: () => {}, + icon: 'delete_outline', + label: 'Delete', + onClick: () => {} + }, + { + icon: 'info', + label: 'Details', + onClick: () => {} + } + ]} + stickyHeader +/> + +### MCP manifest +const WithConditionallyHiddenActions = () => } + onRowClick={fn()} + primaryRowActions={[ + { + icon: 'edit', + label: 'Edit', + onClick: fn(), + }, + { + icon: 'open_in_new', + label: 'Open in New Window', + // hidden on 'complicated' rows (e.g. cannot open a record in a bad state) + hidden: (data) => data.status === 'complicated', + onClick: fn(), + }, + ]} + secondaryRowActions={[ + { + icon: 'check_circle', + label: 'Approve', + // only shown on 'single' rows (pending approval) + hidden: (data) => data.status !== 'single', + onClick: fn(), + }, + { + icon: 'inventory_2', + label: 'Archive', + // only shown on 'relationship' rows (live records) + hidden: (data) => data.status !== 'relationship', + onClick: fn(), + }, + { + icon: 'delete_outline', + label: 'Delete', + // hidden on 'relationship' rows (cannot delete live records) + hidden: (data) => data.status === 'relationship', + className: styles.destructiveAction, + onClick: fn(), + }, + { + icon: 'info', + label: 'Details', + onClick: fn(), + }, + ]} />; + +## With Conditionally Disabled Actions + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + onRowClick={() => {}} + primaryRowActions={[ + { + icon: 'edit', + label: 'Edit', + onClick: () => {} + }, + { + disabled: () => {}, + icon: 'open_in_new', + label: 'Open in New Window', + onClick: () => {} + } + ]} + secondaryRowActions={[ + { + className: '_destructiveAction_12uvr_138', + disabled: () => {}, + icon: 'delete_outline', + label: 'Delete', + onClick: () => {}, + tooltip: 'Delete this row' + }, + { + icon: 'info', + label: 'Details', + onClick: () => {}, + tooltip: 'Show details' + } + ]} + stickyHeader +/> + +### MCP manifest +const WithConditionallyDisabledActions = () => } + onRowClick={fn()} + primaryRowActions={[ + { + icon: 'edit', + label: 'Edit', + onClick: fn(), + }, + { + icon: 'open_in_new', + label: 'Open in New Window', + // disabled on Tanner's row (item stays visible but greyed out) + disabled: (data) => data.firstName === 'Tanner', + onClick: fn(), + }, + ]} + secondaryRowActions={[ + { + icon: 'delete_outline', + label: 'Delete', + tooltip: 'Delete this row', + // disabled on 'single' rows (destructive action guarded) + disabled: (data) => data.status === 'single', + className: styles.destructiveAction, + onClick: fn(), + }, + { + icon: 'info', + label: 'Details', + tooltip: 'Show details', + onClick: fn(), + }, + ]} />; + +## With Bulk Actions + +### Show code + {} + }, + { + icon: 'folder_open', + label: 'Folder', + onClick: () => {} + }, + { + icon: 'delete_outline', + label: 'Delete', + onClick: () => {} + } + ]} + bordered + columns={[ + { + accessorKey: 'firstName', + cell: () => {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + onRowClick={() => {}} + selectable + stickyHeader +/> + +### MCP manifest +const WithBulkActions = () => } + onRowClick={fn()} + selectable + actions={[ + { + icon: 'alarm', + label: 'Notify', + onClick: fn(), + }, + { + icon: 'folder_open', + label: 'Folder', + onClick: fn(), + }, + { + icon: 'delete_outline', + label: 'Delete', + onClick: fn(), + }, + ]} />; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-selection.docs.snap b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-selection.docs.snap new file mode 100644 index 000000000..f8fa32c5a --- /dev/null +++ b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-selection.docs.snap @@ -0,0 +1,575 @@ +# DsTable docs snippets + +## Selectable + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + onRowClick={() => {}} + onSelectionChange={() => {}} + selectable + stickyHeader +/> + +### MCP manifest +const Selectable = () => } + onRowClick={fn()} + selectable + onSelectionChange={fn()} />; + +## Custom Select Column Width + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + onRowClick={() => {}} + onSelectionChange={() => {}} + selectable + selectableColumnWidth={48} + stickyHeader +/> + +### MCP manifest +const CustomSelectColumnWidth = () => } + onRowClick={fn()} + selectable + selectableColumnWidth={48} + onSelectionChange={fn()} />; + +## Programmatic Row Selection + +### Show code +{ + args: { + selectable: true, + showSelectAllCheckbox: false, + stickyHeader: true, + onSelectionChange: fn() + }, + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args) { + const tableRef = useRef>(null); + const [selectedRows, setSelectedRows] = useState([]); + const handleSelectionChange = (selection: Record) => { + setSelectedRows(Object.keys(selection)); + }; + return + + Selected rows: {selectedRows.length > 0 ? selectedRows.join(', ') : 'None'} + + + + tableRef.current?.selectRow('1')}> + Select Row 1 + + tableRef.current?.selectRow('2')}> + Select Row 2 + + tableRef.current?.selectRow('3')}> + Select Row 3 + + tableRef.current?.selectAllRows()}> + Select All + + tableRef.current?.deselectAllRows()}> + Deselect All + + tableRef.current?.selectRows(['1', '2', '3'])}> + Select First 3 Rows + + + + + ; + } +} + +### MCP manifest +const ProgrammaticRowSelection = () => { + const tableRef = useRef>(null); + const [selectedRows, setSelectedRows] = useState([]); + + const handleSelectionChange = (selection: Record) => { + setSelectedRows(Object.keys(selection)); + }; + + return ( + + Selected rows: {selectedRows.length > 0 ? selectedRows.join(', ') : 'None'} + + + tableRef.current?.selectRow('1')}>Select Row 1 + + tableRef.current?.selectRow('2')}>Select Row 2 + + tableRef.current?.selectRow('3')}>Select Row 3 + + tableRef.current?.selectAllRows()}>Select All + + tableRef.current?.deselectAllRows()}>Deselect All + + tableRef.current?.selectRows(['1', '2', '3'])}>Select First 3 Rows + + + } + onRowClick={fn()} + selectable + showSelectAllCheckbox={false} + ref={tableRef} + onSelectionChange={handleSelectionChange} /> + + ); +}; + +## Max N Selections + +### Show code +{ + name: 'Max N Selections', + args: { + showSelectAllCheckbox: false, + onSelectionChange: fn() + }, + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args) { + const [rowSelection, setRowSelection] = useState>({}); + const maxSelections = 2; + const selectedCount = Object.keys(rowSelection).filter(id => rowSelection[id]).length; + const handleSelectionChange = (selection: Record) => { + setRowSelection(selection); + args.onSelectionChange?.(selection); + }; + return + + Selected: {selectedCount} / {maxSelections} + + + rowSelection[rowData.id] || selectedCount < maxSelections} /> + ; + } +} + +### MCP manifest +const MaxSelectionLimit = () => { + const [rowSelection, setRowSelection] = useState>({}); + const maxSelections = 2; + + const selectedCount = Object.keys(rowSelection).filter((id) => rowSelection[id]).length; + + const handleSelectionChange = (selection: Record) => { + setRowSelection(selection); + args.onSelectionChange?.(selection); + }; + + return ( + + Selected: {selectedCount}/ {maxSelections} + + } + onRowClick={fn()} + showSelectAllCheckbox={false} + onSelectionChange={handleSelectionChange} + selectable={(rowData) => rowSelection[rowData.id] || selectedCount < maxSelections} /> + + ); +}; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-virtualized.docs.snap b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-virtualized.docs.snap new file mode 100644 index 000000000..cdbd8db1f --- /dev/null +++ b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table-virtualized.docs.snap @@ -0,0 +1,640 @@ +# DsTable docs snippets + +## Empty State + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[]} + emptyState={} + fullWidth + onRowClick={() => {}} + stickyHeader + virtualized +/> + +### MCP manifest +const EmptyState = () => } + onRowClick={fn()} + virtualized />; + +## Virtualized Selectable Table + +### Show code +{ + name: 'Virtualized Selectable Table', + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args) { + const pageSize = 10; + const [sorting, setSorting] = useState([]); + const { + data: infiniteQueryData, + fetchNextPage, + isFetching, + isLoading + } = useInfiniteQuery({ + queryKey: ['people', sorting], + queryFn: async ({ + pageParam + }) => { + const start = pageParam * pageSize; + return await fetchData(start, pageSize, sorting); + }, + initialPageParam: 0, + getNextPageParam: (_lastGroup, groups) => groups.length, + placeholderData: keepPreviousData + }, queryClient); + const flatData = useMemo(() => infiniteQueryData?.pages.flatMap(page => page.data) ?? [], [infiniteQueryData]); + const totalRows = infiniteQueryData?.pages[0]?.meta.totalRowCount ?? 0; + const hasMore = flatData.length < totalRows; + return
+ + {isLoading &&
+ + + + Loading data... + + +
} +
; + }, + args: { + selectable: true, + columns: columns.map(col => { + if ('accessorKey' in col && col.accessorKey === 'age') { + return { + ...col, + size: 100 + }; + } + return col; + }), + onScroll: fn() + } +} + +### MCP manifest +const VirtualizedSelectable = () => { + const pageSize = 10; + const [sorting, setSorting] = useState([]); + + const { + data: infiniteQueryData, + fetchNextPage, + isFetching, + isLoading, + } = useInfiniteQuery( + { + queryKey: ['people', sorting], + queryFn: async ({ pageParam }) => { + const start = pageParam * pageSize; + return await fetchData(start, pageSize, sorting); + }, + initialPageParam: 0, + getNextPageParam: (_lastGroup, groups) => groups.length, + placeholderData: keepPreviousData, + }, + queryClient, + ); + + const flatData = useMemo( + () => infiniteQueryData?.pages.flatMap((page) => page.data) ?? [], + [infiniteQueryData], + ); + + const totalRows = infiniteQueryData?.pages[0]?.meta.totalRowCount ?? 0; + const hasMore = flatData.length < totalRows; + + return ( +
+ { + if ('accessorKey' in col && col.accessorKey === 'age') { + return { + ...col, + size: 100, + }; + } + return col; + })} + stickyHeader + bordered + fullWidth + expandable={false} + emptyState={} + onRowClick={fn()} + selectable + onScroll={fn()} + data={flatData} + onSortingChange={setSorting} + virtualized={true} + infiniteScroll={{ + hasMore, + isLoadingMore: isFetching, + onLoadMore: fetchNextPage, + }} /> + {isLoading && ( +
+ + + + Loading data... + + +
+ )} +
+ ); +}; + +## Virtualized Expandable Table + +### Show code +{ + name: 'Virtualized Expandable Table', + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args) { + const pageSize = 10; + const [sorting, setSorting] = useState([]); + const { + data: infiniteQueryData, + fetchNextPage, + isFetching, + isLoading + } = useInfiniteQuery({ + queryKey: ['people-expandable', sorting], + queryFn: async ({ + pageParam + }) => { + const start = pageParam * pageSize; + return await fetchData(start, pageSize, sorting); + }, + initialPageParam: 0, + getNextPageParam: (_lastGroup, groups) => groups.length, + placeholderData: keepPreviousData + }, queryClient); + const flatData = useMemo(() => infiniteQueryData?.pages.flatMap(page => page.data) ?? [], [infiniteQueryData]); + const totalRows = infiniteQueryData?.pages[0]?.meta.totalRowCount ?? 0; + const hasMore = flatData.length < totalRows; + return
+ + + Expanded Details for {row.firstName} + ID: {row.id} + + Full Name: {row.firstName} {row.lastName} + + Status: {row.status} + + + + } /> + {isLoading &&
+ + + + Loading data... + + +
} +
; + }, + args: { + columns: columns.map(col => { + if ('accessorKey' in col && col.accessorKey === 'age') { + return { + ...col, + size: 100 + }; + } + return col; + }), + onScroll: fn() + } +} + +### MCP manifest +const VirtualizedExpandable = () => { + const pageSize = 10; + const [sorting, setSorting] = useState([]); + + const { + data: infiniteQueryData, + fetchNextPage, + isFetching, + isLoading, + } = useInfiniteQuery( + { + queryKey: ['people-expandable', sorting], + queryFn: async ({ pageParam }) => { + const start = pageParam * pageSize; + return await fetchData(start, pageSize, sorting); + }, + initialPageParam: 0, + getNextPageParam: (_lastGroup, groups) => groups.length, + placeholderData: keepPreviousData, + }, + queryClient, + ); + + const flatData = useMemo( + () => infiniteQueryData?.pages.flatMap((page) => page.data) ?? [], + [infiniteQueryData], + ); + + const totalRows = infiniteQueryData?.pages[0]?.meta.totalRowCount ?? 0; + const hasMore = flatData.length < totalRows; + + return ( +
+ { + if ('accessorKey' in col && col.accessorKey === 'age') { + return { + ...col, + size: 100, + }; + } + return col; + })} + stickyHeader + bordered + fullWidth + emptyState={} + onRowClick={fn()} + onScroll={fn()} + data={flatData} + onSortingChange={setSorting} + virtualized={true} + expandable={true} + infiniteScroll={{ + hasMore, + isLoadingMore: isFetching, + onLoadMore: fetchNextPage, + }} + renderExpandedRow={(row) => ( + + + Expanded Details for {row.firstName} + ID: {row.id} + + Full Name: {row.firstName} {row.lastName} + + Status: {row.status} + + + + + )} /> + {isLoading && ( +
+ + + + Loading data... + + +
+ )} +
+ ); +}; + +## Virtualized Infinite Scroll + +### Show code +{ + name: 'Virtualized Infinite Scroll', + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args) { + const pageSize = 5; + const totalRows = 60; + const [sorting, setSorting] = useState([]); + const { + data: infiniteQueryData, + fetchNextPage, + isFetching + } = useInfiniteQuery({ + queryKey: ['people-autofill', sorting], + queryFn: async ({ + pageParam + }) => { + const start = pageParam * pageSize; + return await fetchData(start, pageSize, sorting, totalRows); + }, + initialPageParam: 0, + getNextPageParam: (_lastGroup, groups) => groups.length, + placeholderData: keepPreviousData + }, queryClient); + const flatData = useMemo(() => infiniteQueryData?.pages.flatMap(page => page.data) ?? [], [infiniteQueryData]); + const fetchedTotal = infiniteQueryData?.pages[0]?.meta.totalRowCount ?? totalRows; + const hasMore = flatData.length < fetchedTotal; + return
+ +
; + } +} + +### MCP manifest +const InfiniteScroll = () => { + const pageSize = 5; + const totalRows = 60; + const [sorting, setSorting] = useState([]); + + const { + data: infiniteQueryData, + fetchNextPage, + isFetching, + } = useInfiniteQuery( + { + queryKey: ['people-autofill', sorting], + queryFn: async ({ pageParam }) => { + const start = pageParam * pageSize; + return await fetchData(start, pageSize, sorting, totalRows); + }, + initialPageParam: 0, + getNextPageParam: (_lastGroup, groups) => groups.length, + placeholderData: keepPreviousData, + }, + queryClient, + ); + + const flatData = useMemo( + () => infiniteQueryData?.pages.flatMap((page) => page.data) ?? [], + [infiniteQueryData], + ); + + const fetchedTotal = infiniteQueryData?.pages[0]?.meta.totalRowCount ?? totalRows; + const hasMore = flatData.length < fetchedTotal; + + return ( +
+ } + onRowClick={fn()} + data={flatData} + onSortingChange={setSorting} + virtualized={true} + infiniteScroll={{ + hasMore, + isLoadingMore: isFetching, + onLoadMore: fetchNextPage, + }} /> +
+ ); +}; + +## Virtualized Editable Table + +### Show code +{ + name: 'Virtualized Editable Table', + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args) { + const [data, setData] = useState(() => generatePersonData(0, VIRTUALIZED_ROW_COUNT, []).data); + return
+ { + setData(rows => updateRow(rows, row.id, columnId as keyof Person, value as never)); + }} /> +
; + } +} + +### MCP manifest +const VirtualizedEditable = () => { + const [data, setData] = useState(() => generatePersonData(0, VIRTUALIZED_ROW_COUNT, []).data); + + return ( +
+ } + onRowClick={fn()} + data={data} + columns={editableColumns} + virtualized + onCellEdit={(row: Person, columnId, value) => { + setData((rows) => updateRow(rows, row.id, columnId as keyof Person, value as never)); + }} /> +
+ ); +}; + +## Virtualized With Controls + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args) { + const [data] = useState(() => generatePersonData(0, VIRTUALIZED_ROW_COUNT, []).data); + return ; + }, + args: { + controls: + + RED version V2 + + + + Import RED + + + Export RED + + Actions + + + } +} + +### MCP manifest +const VirtualizedWithControls = () => { + const [data] = useState(() => generatePersonData(0, VIRTUALIZED_ROW_COUNT, []).data); + + return ( + } + onRowClick={fn()} + controls={( + + RED version V2 + + + + Import RED + + + Export RED + + Actions + + )} + data={data} + virtualized /> + ); +}; + +## Virtualized Resizable Columns + +### Show code +{ + name: 'Virtualized Resizable Columns', + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: function Render(args) { + const [data] = useState(() => generatePersonData(0, VIRTUALIZED_ROW_COUNT, []).data); + return
+ +
; + }, + args: { + virtualized: true, + resizableColumns: true + } +} + +### MCP manifest +const VirtualizedResizable = () => { + const [data] = useState(() => generatePersonData(0, VIRTUALIZED_ROW_COUNT, []).data); + + return ( +
+ } + onRowClick={fn()} + virtualized + resizableColumns + data={data} /> +
+ ); +}; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table.docs.snap b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table.docs.snap new file mode 100644 index 000000000..b44f3c231 --- /dev/null +++ b/packages/design-system/src/components/ds-table/__tests__/__snapshots__/ds-table.docs.snap @@ -0,0 +1,840 @@ +# DsTable docs snippets + +## Default + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + onRowClick={() => {}} + stickyHeader +/> + +### MCP manifest +const Default = () => } + onRowClick={fn()} + data={defaultData} />; + +## With Controls + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + controls={RED version V2Import REDExport REDActions} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + onRowClick={() => {}} + stickyHeader +/> + +### MCP manifest +const WithControls = () => } + onRowClick={fn()} + data={defaultData} + controls={( + + RED version V2 + + + + Import RED + + + Export RED + + Actions + + )} />; + +## Empty State + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[]} + emptyState={} + fullWidth + onRowClick={() => {}} + stickyHeader +/> + +### MCP manifest +const EmptyState = () => } + onRowClick={fn()} + data={[]} />; + +## No Border + +### Show code + {}, + header: 'First Name' + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name' + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age' + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Visits' + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Status' + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress' + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + onRowClick={() => {}} + stickyHeader +/> + +### MCP manifest +const NoBorder = () => } + onRowClick={fn()} + data={defaultData} />; + +## Horizontal Scroll + +### Show code + {}, + header: 'First Name', + size: 250 + }, + { + accessorKey: 'lastName', + cell: () => {}, + header: 'Last Name', + size: 250 + }, + { + accessorKey: 'age', + cell: () => {}, + header: 'Age (years)', + size: 200 + }, + { + accessorKey: 'visits', + cell: () => {}, + header: 'Number of Visits', + size: 250 + }, + { + accessorKey: 'status', + cell: () => {}, + header: 'Relationship Status', + size: 250 + }, + { + accessorKey: 'progress', + cell: () => {}, + header: 'Profile Progress', + size: 250 + } + ]} + data={[ + { + age: 33, + firstName: 'Tanner', + id: '1', + lastName: 'Linsley', + progress: 75, + status: 'single', + visits: 100 + }, + { + age: 28, + firstName: 'Kevin', + id: '2', + lastName: 'Fine', + progress: 50, + status: 'relationship', + visits: 200 + }, + { + age: 45, + firstName: 'John', + id: '3', + lastName: 'Doe', + progress: 90, + status: 'complicated', + visits: 50 + }, + { + age: 30, + firstName: 'Jane', + id: '4', + lastName: 'Smith', + progress: 60, + status: 'single', + visits: 150 + }, + { + age: 22, + firstName: 'Peter', + id: '5', + lastName: 'Jones', + progress: 30, + status: 'relationship', + visits: 250 + }, + { + age: 38, + firstName: 'Mary', + id: '6', + lastName: 'Jane', + progress: 85, + status: 'complicated', + visits: 80 + }, + { + age: 50, + firstName: 'David', + id: '7', + lastName: 'Williams', + progress: 40, + status: 'single', + visits: 120 + }, + { + age: 25, + firstName: 'Sarah', + id: '8', + lastName: 'Brown', + progress: 70, + status: 'relationship', + visits: 180 + }, + { + age: 41, + firstName: 'Michael', + id: '9', + lastName: 'Davis', + progress: 20, + status: 'complicated', + visits: 95 + }, + { + age: 36, + firstName: 'Emily', + id: '10', + lastName: 'Miller', + progress: 55, + status: 'single', + visits: 110 + }, + { + age: 29, + firstName: 'Daniel', + id: '11', + lastName: 'Wilson', + progress: 80, + status: 'relationship', + visits: 220 + }, + { + age: 48, + firstName: 'Olivia', + id: '12', + lastName: 'Moore', + progress: 15, + status: 'complicated', + visits: 65 + }, + { + age: 31, + firstName: 'James', + id: '13', + lastName: 'Taylor', + progress: 95, + status: 'single', + visits: 135 + }, + { + age: 27, + firstName: 'Sophia', + id: '14', + lastName: 'Anderson', + progress: 25, + status: 'relationship', + visits: 170 + }, + { + age: 43, + firstName: 'Robert', + id: '15', + lastName: 'Thomas', + progress: 50, + status: 'complicated', + visits: 88 + } + ]} + emptyState={} + fullWidth + onRowClick={() => {}} + stickyHeader +/> + +### MCP manifest +const HorizontalScroll = () => } + onRowClick={fn()} + data={defaultData} />; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-table/__tests__/ds-table-filters-panel.browser.test.tsx b/packages/design-system/src/components/ds-table/__tests__/ds-table-filters-panel.browser.test.tsx new file mode 100644 index 000000000..60ba3bd5f --- /dev/null +++ b/packages/design-system/src/components/ds-table/__tests__/ds-table-filters-panel.browser.test.tsx @@ -0,0 +1,439 @@ +import { useState } from 'react'; +import { describe, expect, it } from 'vitest'; +import { page } from 'vitest/browser'; +import type { ColumnDef } from '@tanstack/react-table'; +import DsTable from '../ds-table'; +import { DsButtonV3 } from '../../ds-button-v3'; +import { DsModal } from '../../ds-modal'; +import { DsVerticalTabs } from '../../ds-vertical-tabs'; +import { DsTypography } from '../../ds-typography'; +import { DsTagFilter } from '../../ds-tag-filter'; +import { useTableFilters } from '../filters'; +import { type Workflow, workflowFilters } from '../stories/filters-panel/workflow-filters.config'; + +/** + * The story `render` is 100+ lines of modal + vertical-tabs layout. These tests + * reproduce a minimal-but-faithful harness so the behavioral assertions ported + * from `filters-panel.stories.tsx` (`FiltersPanel.play` / `Controlled.play`) + * live in a dedicated browser test instead of a Storybook `play` function. + * + * The columns and 12-row dataset are NOT exported from the story module, so an + * equivalent dataset is replicated here (statuses span active/running/etc.) to + * keep the row-count assertions meaningful. + */ + +const sampleUsers = [ + { name: 'Marry Levin', colorIndex: 0 }, + { name: 'Emery Frank', colorIndex: 1 }, + { name: 'Ryan Franco', colorIndex: 2 }, + { name: 'Roger Dias', colorIndex: 0 }, + { name: 'Lindsey Westerner', colorIndex: 1 }, + { name: 'Neil Sims', colorIndex: 2 }, +] as const; + +const columns: ColumnDef[] = [ + { id: 'status', accessorKey: 'status', header: 'Status', cell: (info) => info.getValue() }, + { id: 'name', accessorKey: 'name', header: 'Name', cell: (info) => info.getValue() }, + { + id: 'runningCompleted', + accessorKey: 'runningCompleted', + header: 'Running/completed', + cell: (info) => { + const value = info.getValue() as { running: number; completed: number }; + return `${String(value.running)}/${String(value.completed)}`; + }, + }, + { id: 'category', accessorKey: 'category', header: 'Category', cell: (info) => info.getValue() }, + { id: 'version', accessorKey: 'version', header: 'Version', cell: (info) => info.getValue() }, + { id: 'lastEdited', accessorKey: 'lastEdited', header: 'Last edited' }, +]; + +const defaultData: Workflow[] = [ + { + id: '1', + name: 'Scheduled Config Backup', + status: 'active', + runningCompleted: { running: 3, completed: 41 }, + category: 'Network Built', + version: '000.0003', + lastEdited: { + editor: sampleUsers[0].name, + timestamp: '2025-11-26T16:47:00', + colorIndex: sampleUsers[0].colorIndex, + }, + }, + { + id: '2', + name: 'Network Provisioning', + status: 'running', + runningCompleted: { running: 8, completed: 14 }, + category: 'Network Built', + version: '000.0002', + lastEdited: { + editor: sampleUsers[1].name, + timestamp: '2025-11-26T15:32:00', + colorIndex: sampleUsers[1].colorIndex, + }, + }, + { + id: '3', + name: 'Service Provisioning', + status: 'inactive', + runningCompleted: { running: 0, completed: 243 }, + category: 'Network Built', + version: '000.0033', + lastEdited: { + editor: sampleUsers[2].name, + timestamp: '2025-11-25T11:15:00', + colorIndex: sampleUsers[2].colorIndex, + }, + }, + { + id: '4', + name: 'Assign IPv4 Address', + status: 'active', + runningCompleted: { running: 14, completed: 123 }, + category: 'Network Built', + version: '000.0001', + lastEdited: { + editor: sampleUsers[3].name, + timestamp: '2025-11-24T14:20:00', + colorIndex: sampleUsers[3].colorIndex, + }, + }, + { + id: '5', + name: 'Shutdown Decommissioned Device', + status: 'active', + runningCompleted: { running: 45, completed: 45 }, + category: 'Optical Optimization', + version: '000.0022', + lastEdited: { + editor: sampleUsers[4].name, + timestamp: '2025-11-23T13:05:00', + colorIndex: sampleUsers[4].colorIndex, + }, + }, + { + id: '6', + name: 'Optical Power Level Calibration', + status: 'draft', + runningCompleted: { running: 99, completed: 23 }, + category: 'Optical Optimization', + version: '000.0001', + lastEdited: { + editor: sampleUsers[5].name, + timestamp: '2025-11-20T09:30:00', + colorIndex: sampleUsers[5].colorIndex, + }, + }, + { + id: '7', + name: 'Deploy Layer 2 VPN Instance', + status: 'pending', + runningCompleted: { running: 49, completed: 100 }, + category: 'Optical Optimization', + version: '000.0012', + lastEdited: { + editor: sampleUsers[0].name, + timestamp: '2025-11-18T12:45:00', + colorIndex: sampleUsers[0].colorIndex, + }, + }, + { + id: '8', + name: 'Initiate Scheduled Firmware Upgrade', + status: 'active', + runningCompleted: { running: 25, completed: 75 }, + category: 'Service Provisioning', + version: '000.0010', + lastEdited: { + editor: sampleUsers[1].name, + timestamp: '2025-11-15T17:10:00', + colorIndex: sampleUsers[1].colorIndex, + }, + }, + { + id: '9', + name: 'Enable High Availability Mode', + status: 'running', + runningCompleted: { running: 77, completed: 88 }, + category: 'Service Provisioning', + version: '000.0001', + lastEdited: { + editor: sampleUsers[2].name, + timestamp: '2025-11-10T10:22:00', + colorIndex: sampleUsers[2].colorIndex, + }, + }, + { + id: '10', + name: 'Audit Access Control Policies', + status: 'active', + runningCompleted: { running: 65, completed: 200 }, + category: 'Service Provisioning', + version: '000.0001', + lastEdited: { + editor: sampleUsers[3].name, + timestamp: '2025-11-05T15:15:00', + colorIndex: sampleUsers[3].colorIndex, + }, + }, + { + id: '11', + name: 'Synchronize NTP Across Network Nodes', + status: 'warning', + runningCompleted: { running: 49, completed: 142 }, + category: 'Service Provisioning', + version: '000.0001', + lastEdited: { + editor: sampleUsers[4].name, + timestamp: '2025-10-28T08:40:00', + colorIndex: sampleUsers[4].colorIndex, + }, + }, + { + id: '12', + name: 'Validate Optical Link Integrity', + status: 'failed', + runningCompleted: { running: 90, completed: 300 }, + category: 'Network Built', + version: '000.0001', + lastEdited: { + editor: sampleUsers[5].name, + timestamp: '2025-10-15T16:47:00', + colorIndex: sampleUsers[5].colorIndex, + }, + }, +]; + +/** + * Minimal filter-panel harness mirroring the story: filter button, generated + * chips, table with enhanced columns, and a modal with a vertical-tabs nav + + * content + footer wired to `useTableFilters`. In `controlled` mode it holds + * `appliedFilters` externally and renders a `
` snapshot of that state.
+ */
+function FiltersPanelHarness({ controlled = false }: { controlled?: boolean }) {
+	const [appliedFilters, setAppliedFilters] = useState>({});
+
+	const { columnFilters, filterChips, filterNavItems, enhancedColumns, handlers, renderFilterContent } =
+		useTableFilters({
+			filterAdapters: workflowFilters,
+			baseColumns: columns,
+			...(controlled ? { appliedFilters, onFiltersChange: setAppliedFilters } : {}),
+		});
+
+	const [isOpen, setIsOpen] = useState(false);
+	const [selectedFilterId, setSelectedFilterId] = useState(filterNavItems[0]?.id ?? '');
+
+	const handleValueChange = (value: string | null) => {
+		if (value) {
+			setSelectedFilterId(value);
+		}
+	};
+
+	const handleApply = () => {
+		handlers.applyFilters();
+		setIsOpen(false);
+	};
+
+	const handleClearAll = () => {
+		handlers.clearAll();
+		setIsOpen(false);
+	};
+
+	return (
+		
+ {controlled &&
{JSON.stringify(appliedFilters, null, 2)}
} + + setIsOpen(true)} + /> + + {filterChips.length > 0 && ( + + )} + + No data available
} + /> + + + + Filters + + + + + + + {filterNavItems.map((item) => ( + + {item.label} + + ))} + + {filterNavItems.map((item) => ( + + {renderFilterContent(item)} + + ))} + + + + + + Clear all + + + + Apply + + + + + + ); +} + +// Body rows only (drop the header row), matching the sibling column-filters test. +const getDataRows = () => page.getByRole('row').all().slice(1); + +const filterButton = () => page.getByRole('button', { name: 'Filter', exact: true }); +const applyButton = () => page.getByRole('button', { name: 'Apply' }); +const tagFilterClearAll = () => page.getByRole('button', { name: 'Clear all filters' }); + +/** Open the modal, pick the Status tab, and check Active + Running. */ +async function selectActiveAndRunning() { + await filterButton().click(); + await page.getByRole('tab', { name: /status/i }).click(); + await page.getByRole('checkbox', { name: /^active$/i }).click(); + await page.getByRole('checkbox', { name: /^running$/i }).click(); +} + +describe('DsTable — filters panel', () => { + it('opens the modal and exposes the filter category tabs', async () => { + await page.render(); + + await filterButton().click(); + + await expect.element(page.getByRole('tab', { name: /status/i })).toBeVisible(); + await expect.element(page.getByRole('tab', { name: /running\/completed/i })).toBeVisible(); + await expect.element(page.getByRole('tab', { name: /last edited/i })).toBeVisible(); + }); + + it('checks the selected Status options and preserves them after reopening', async () => { + await page.render(); + + await selectActiveAndRunning(); + + await expect.element(page.getByRole('checkbox', { name: /^active$/i })).toBeChecked(); + await expect.element(page.getByRole('checkbox', { name: /^running$/i })).toBeChecked(); + + await applyButton().click(); + + // Reopen — the previously applied Status filters remain checked. + await filterButton().click(); + await page.getByRole('tab', { name: /status/i }).click(); + + await expect.element(page.getByRole('checkbox', { name: /^active$/i })).toBeChecked(); + await expect.element(page.getByRole('checkbox', { name: /^running$/i })).toBeChecked(); + }); + + it('applies status, range, editor, and time filters to produce chips and filter rows', async () => { + await page.render(); + + expect(getDataRows()).toHaveLength(12); + + // Status: Active + Running + await selectActiveAndRunning(); + + // Running/Completed: first two spin buttons are the Running field's From/To. + await page.getByRole('tab', { name: /running\/completed/i }).click(); + const spinButtons = page.getByRole('spinbutton'); + await spinButtons.nth(0).fill('0'); + await spinButtons.nth(1).fill('50'); + + // Last edited: an editor + a preset time range + await page.getByRole('tab', { name: /last edited/i }).click(); + await page.getByRole('checkbox', { name: /marry levin/i }).click(); + await page.getByRole('radio', { name: /last 3 months/i }).click(); + + await expect.element(page.getByRole('checkbox', { name: /marry levin/i })).toBeChecked(); + await expect.element(page.getByRole('radio', { name: /last 3 months/i })).toBeChecked(); + + await applyButton().click(); + + // Chips are queried by role so the aria-hidden measurement portal copies + // (rendered to document.body by DsTagFilter) are excluded. + await expect.element(page.getByRole('button', { name: /status: active/i })).toBeVisible(); + await expect.element(page.getByRole('button', { name: /status: running/i })).toBeVisible(); + await expect.element(page.getByRole('button', { name: /running.*0.*50/i })).toBeVisible(); + await expect.element(page.getByRole('button', { name: /editor: marry levin/i })).toBeVisible(); + await expect.element(page.getByRole('button', { name: /last 3 months/i })).toBeVisible(); + + expect(getDataRows().length).toBeLessThan(12); + }); + + it('removes a single chip via its Delete tag button', async () => { + await page.render(); + + await selectActiveAndRunning(); + await applyButton().click(); + + await expect.element(page.getByRole('button', { name: /^status: active$/i })).toBeVisible(); + + // The chip is itself role="button" (aria-label), so its nested Delete tag + // button is presentational to role queries — reach it by its label. The + // delete button only reveals (opacity/visibility) on hover, so hover first. + const activeChip = page.getByRole('button', { name: /^status: active$/i }); + await activeChip.hover(); + await activeChip.getByLabelText(/delete tag/i).click(); + + await expect.element(page.getByRole('button', { name: /^status: active$/i })).not.toBeInTheDocument(); + // The other status chip is untouched. + await expect.element(page.getByRole('button', { name: /^status: running$/i })).toBeVisible(); + }); + + it('clears all chips and restores every row via Clear all', async () => { + await page.render(); + + await selectActiveAndRunning(); + await applyButton().click(); + + await expect.element(page.getByRole('button', { name: /status: active/i })).toBeVisible(); + expect(getDataRows().length).toBeLessThan(12); + + await tagFilterClearAll().click(); + + await expect.element(page.getByRole('button', { name: /status:/i })).not.toBeInTheDocument(); + expect(getDataRows()).toHaveLength(12); + }); + + it('reflects applied filter state externally and resets it on Clear all (controlled)', async () => { + await page.render(); + + // External state starts empty. + await expect.element(page.getByText('{}', { exact: true })).toBeVisible(); + + await filterButton().click(); + await page.getByRole('tab', { name: /status/i }).click(); + await page.getByRole('checkbox', { name: /^active$/i }).click(); + await applyButton().click(); + + // External state now carries the status filter, and the chip appears. + await expect.element(page.getByText(/"status"/)).toBeVisible(); + await expect.element(page.getByRole('button', { name: /status: active/i })).toBeVisible(); + + await tagFilterClearAll().click(); + + await expect.element(page.getByText('{}', { exact: true })).toBeVisible(); + await expect.element(page.getByRole('button', { name: /status: active/i })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/ds-table/stories/ds-table-active-row.stories.tsx b/packages/design-system/src/components/ds-table/stories/ds-table-active-row.stories.tsx index 0e4aaa9b5..461940bb4 100644 --- a/packages/design-system/src/components/ds-table/stories/ds-table-active-row.stories.tsx +++ b/packages/design-system/src/components/ds-table/stories/ds-table-active-row.stories.tsx @@ -1,8 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; -import classnames from 'classnames'; -import { DsIcon } from '../../ds-icon'; +import { fn } from 'storybook/test'; import { DsDrawer } from '../../ds-drawer'; +import { DsStack } from '../../ds-stack'; +import { DsButtonV3 } from '../../ds-button-v3'; +import { DsTypography } from '../../ds-typography'; import DsTable from '../ds-table'; import styles from './ds-table.stories.module.scss'; import { columns, defaultData, type Person } from './common/story-data'; @@ -23,7 +25,7 @@ const meta: Meta> = { fullWidth: true, expandable: false, emptyState: , - onRowClick: (row) => console.log('Row clicked:', row), + onRowClick: fn(), }, decorators: [fullHeightDecorator], }; @@ -31,11 +33,32 @@ const meta: Meta> = { export default meta; type Story = StoryObj>; +/** + * Pass `activeRowId` to highlight a single row independently of selection. The + * highlight persists until you change or clear the id — useful for marking the + * record a side panel or detail view is currently showing. + */ +export const ActiveRow: Story = { + args: { + data: defaultData.slice(0, 10), + activeRowId: '3', + }, +}; + +/** + * Pass `activeRowId` to keep a row highlighted independently of selection — + * ideal for a master/detail layout where clicking a row opens a drawer. Track + * the clicked record in state, derive `activeRowId` from it, and clear it when + * the drawer closes. Clicking the active row again toggles the drawer shut. + */ export const WithDrawerAndActiveRow: Story = { name: 'Active Row with Drawer', args: { data: defaultData.slice(0, 10), }, + parameters: { + docs: { source: { type: 'code' } }, + }, render: function Render(args) { const [selectedPerson, setSelectedPerson] = useState(null); @@ -49,15 +72,7 @@ export const WithDrawerAndActiveRow: Story = { }; return ( -
-
-

Active Row with Drawer Demo

-

- Click on any row to open a drawer with detailed information. The clicked row will remain - highlighted to indicate which record the drawer is displaying. -

-
- + <> {selectedPerson && (
-
-

Person Details

- -
+ + + Person Details + setSelectedPerson(null)} + /> + -
-
- Full Name -

- {selectedPerson.firstName} {selectedPerson.lastName} -

-
+ + + + Full Name + + + {selectedPerson.firstName} {selectedPerson.lastName} + + -
- Age -

{selectedPerson.age} years old

-
+ + + Age + + {selectedPerson.age} years old + -
- Visits -

{selectedPerson.visits} visits

-
+ + + Visits + + {selectedPerson.visits} visits + -
- Status -

- {selectedPerson.status} -

-
+ + + Status + + + {selectedPerson.status.charAt(0).toUpperCase() + selectedPerson.status.slice(1)} + + -
- Profile Progress -
+ + + Profile Progress + -
-
- -
-

- Note: The row in the table remains highlighted while this drawer is open, - helping you keep track of which record you're viewing. -

-
-
+
+ +
)}
-
+ ); }, }; diff --git a/packages/design-system/src/components/ds-table/stories/ds-table-column-filters.stories.tsx b/packages/design-system/src/components/ds-table/stories/ds-table-column-filters.stories.tsx index e82d413e8..c2e120165 100644 --- a/packages/design-system/src/components/ds-table/stories/ds-table-column-filters.stories.tsx +++ b/packages/design-system/src/components/ds-table/stories/ds-table-column-filters.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import type { ColumnDef } from '@tanstack/react-table'; import { useState } from 'react'; import DsTable from '../ds-table'; +import { DsStack } from '../../ds-stack'; import { DsTagFilter } from '../../ds-tag-filter'; import { type CheckboxFilterItem, @@ -10,7 +11,6 @@ import { useTableFilters, } from '../filters'; import { fullHeightDecorator } from './common/story-decorators'; -import styles from './ds-table.stories.module.scss'; type DeviceRow = { id: string; @@ -308,7 +308,7 @@ draft and closes it (same as pressing Escape). }); return ( -
+ {filterChips.length > 0 && ( -
+ ); }, }; @@ -357,7 +357,7 @@ external state, leaving other filters untouched. }); return ( -
+ {filterChips.length > 0 && ( -
+ ); }, }; diff --git a/packages/design-system/src/components/ds-table/stories/ds-table-columns.stories.tsx b/packages/design-system/src/components/ds-table/stories/ds-table-columns.stories.tsx index 7a40c9efe..fb843e631 100644 --- a/packages/design-system/src/components/ds-table/stories/ds-table-columns.stories.tsx +++ b/packages/design-system/src/components/ds-table/stories/ds-table-columns.stories.tsx @@ -1,8 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; +import { fn } from 'storybook/test'; import type { VisibilityState } from '@tanstack/react-table'; import classnames from 'classnames'; import { DsCheckbox } from '../../ds-checkbox'; +import { DsStack } from '../../ds-stack'; import DsTable from '../ds-table'; import styles from './ds-table.stories.module.scss'; import { columns, defaultData, type Person, type Status } from './common/story-data'; @@ -23,7 +25,7 @@ const meta: Meta> = { fullWidth: true, expandable: false, emptyState: , - onRowClick: (row) => console.log('Row clicked:', row), + onRowClick: fn(), }, decorators: [fullHeightDecorator], }; @@ -60,7 +62,15 @@ export const WithProgressInfographic: Story = { }, }; +/** + * Show or hide columns dynamically via the controlled `columnVisibility` / + * `onColumnVisibilityChange` props — useful for customizable table views or + * responsive layouts. Toggle a checkbox to add or remove the matching column. + */ export const ColumnHiding: Story = { + parameters: { + docs: { source: { type: 'code' } }, + }, render: function Render(args) { const columnsToToggle = [ { id: 'age', label: 'Age' }, @@ -83,16 +93,8 @@ export const ColumnHiding: Story = { }; return ( -
-
-

Column Hiding Demo

-

- Use the checkboxes below to show or hide specific columns dynamically. This is useful for - customizable table views or responsive layouts. -

-
- -
+ + {columnsToToggle.map((column) => ( toggleColumn(column.id)} /> ))} -
+ -
+ ); }, }; diff --git a/packages/design-system/src/components/ds-table/stories/ds-table-expansion.stories.tsx b/packages/design-system/src/components/ds-table/stories/ds-table-expansion.stories.tsx index 454816a97..d989c4782 100644 --- a/packages/design-system/src/components/ds-table/stories/ds-table-expansion.stories.tsx +++ b/packages/design-system/src/components/ds-table/stories/ds-table-expansion.stories.tsx @@ -3,7 +3,9 @@ import { fn } from 'storybook/test'; import { useRef, useState } from 'react'; import DsTable from '../ds-table'; import type { DsTableApi } from '../ds-table.types'; -import styles from './ds-table.stories.module.scss'; +import { DsStack } from '../../ds-stack'; +import { DsButtonV3 } from '../../ds-button-v3'; +import { DsTypography } from '../../ds-typography'; import { columns, defaultData, type Person } from './common/story-data'; import { fullHeightDecorator } from './common/story-decorators'; import { TableEmptyState } from './components'; @@ -35,15 +37,15 @@ export const Expandable: Story = { data: defaultData.slice(0, 5), expandable: (row) => row.firstName !== 'Tanner', renderExpandedRow: (row) => ( - <> -
-

Expanded Details for {row.firstName}

-

ID: {row.id}

-

+ + + Expanded Details for {row.firstName} + ID: {row.id} + Full Name: {row.firstName} {row.lastName} -

-

Status: {row.status}

-
+ + Status: {row.status} + - + ), }, }; @@ -76,25 +78,37 @@ export const CustomExpanderColumnWidth: Story = { data: defaultData.slice(0, 5), expandable: true, expandableColumnWidth: 48, - renderExpandedRow: (row) =>
Details for {row.firstName}
, + renderExpandedRow: (row) => ( + + Details for {row.firstName} + + ), }, }; +/** + * Drive expansion imperatively through the table ref. `expandRow`, `expandRows`, + * `expandAllRows`, and `collapseAllRows` on `DsTableApi` let a parent expand or + * collapse rows from outside the table. + */ export const ProgrammaticExpansion: Story = { args: { data: defaultData.slice(0, 5), expandable: (row) => row.firstName !== 'Tanner', renderExpandedRow: (row) => ( -
-

Expanded Details for {row.firstName}

-

ID: {row.id}

-

+ + Expanded Details for {row.firstName} + ID: {row.id} + Full Name: {row.firstName} {row.lastName} -

-

Status: {row.status}

-
+ + Status: {row.status} + ), }, + parameters: { + docs: { source: { type: 'code' } }, + }, render: function Render(args) { const tableRef = useRef>(null); const [expandedRows, setExpandedRows] = useState([]); @@ -125,40 +139,34 @@ export const ProgrammaticExpansion: Story = { }; return ( -
-
-

Programmatic Row Expansion Demo

-

- Use the buttons below to programmatically control row expansion using TanStack Table v8 APIs. -

-

- Expanded rows: {expandedRows.length > 0 ? expandedRows.join(', ') : 'None'} -

-
+ + + Expanded rows: {expandedRows.length > 0 ? expandedRows.join(', ') : 'None'} + -
- - - - - - -
+ +
-
+ ); }, }; diff --git a/packages/design-system/src/components/ds-table/stories/ds-table-loading.stories.tsx b/packages/design-system/src/components/ds-table/stories/ds-table-loading.stories.tsx index 42cb51bd2..8197c6cd7 100644 --- a/packages/design-system/src/components/ds-table/stories/ds-table-loading.stories.tsx +++ b/packages/design-system/src/components/ds-table/stories/ds-table-loading.stories.tsx @@ -17,7 +17,7 @@ const meta: Meta> = { layout: 'fullscreen', }, args: { - columns: loadingColumns, + columns, data: defaultData, stickyHeader: true, bordered: true, @@ -30,8 +30,24 @@ const meta: Meta> = { export default meta; type Story = StoryObj>; +/** + * Set `loading` to render skeleton rows. Each cell falls back to a default + * skeleton bar sized to its column. + */ export const Loading: Story = { args: { loading: true, }, }; + +/** + * Provide a per-column `loadingCell` to control the skeleton shown while + * `loading` is true — here the first column renders a circular skeleton instead + * of the default bar. + */ +export const CustomLoadingCell: Story = { + args: { + loading: true, + columns: loadingColumns, + }, +}; diff --git a/packages/design-system/src/components/ds-table/stories/ds-table-row-actions.stories.tsx b/packages/design-system/src/components/ds-table/stories/ds-table-row-actions.stories.tsx index 2057656a2..3a22b287f 100644 --- a/packages/design-system/src/components/ds-table/stories/ds-table-row-actions.stories.tsx +++ b/packages/design-system/src/components/ds-table/stories/ds-table-row-actions.stories.tsx @@ -20,7 +20,7 @@ const meta: Meta> = { fullWidth: true, expandable: false, emptyState: , - onRowClick: (row) => console.log('Row clicked:', row), + onRowClick: fn(), }, decorators: [fullHeightDecorator], }; diff --git a/packages/design-system/src/components/ds-table/stories/ds-table-search.stories.tsx b/packages/design-system/src/components/ds-table/stories/ds-table-search.stories.tsx index ed91e45fe..43e40536e 100644 --- a/packages/design-system/src/components/ds-table/stories/ds-table-search.stories.tsx +++ b/packages/design-system/src/components/ds-table/stories/ds-table-search.stories.tsx @@ -1,10 +1,12 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; import { useMemo, useState } from 'react'; import type { ColumnDef, ColumnFiltersState } from '@tanstack/react-table'; -import type { IconType } from '../../ds-icon'; +import { DsIcon, type IconType } from '../../ds-icon'; import { DsSmartTabs } from '../../ds-smart-tabs'; +import { DsStack } from '../../ds-stack'; +import { DsTextInput } from '../../ds-text-input'; import DsTable from '../ds-table'; -import styles from './ds-table.stories.module.scss'; import { columns, defaultData, type Person, type Status } from './common/story-data'; import { fullHeightDecorator } from './common/story-decorators'; import { StatusItem, TableEmptyState } from './components'; @@ -23,7 +25,7 @@ const meta: Meta> = { fullWidth: true, expandable: false, emptyState: , - onRowClick: (row) => console.log('Row clicked:', row), + onRowClick: fn(), }, decorators: [fullHeightDecorator], }; @@ -31,8 +33,16 @@ const meta: Meta> = { export default meta; type Story = StoryObj>; +/** + * Global search across every column, owned by the consumer. Keep the query in + * state, derive the filtered rows, and pass them to `data` — the table stays a + * pure presentational view of whatever rows you hand it. + */ export const AdvancedSearch: Story = { name: 'Search — Global Input', + parameters: { + docs: { source: { type: 'code' } }, + }, render: function Render(args) { const [globalFilter, setGlobalFilter] = useState(''); @@ -49,24 +59,31 @@ export const AdvancedSearch: Story = { }, [globalFilter, args.data]); return ( -
-
- + + setGlobalFilter(e.target.value)} + onValueChange={setGlobalFilter} placeholder="Search all columns..." - style={{ padding: '0.5rem', width: '300px' }} + slots={{ startAdornment: }} /> -
+ -
+ ); }, }; +/** + * Drive a column filter from a tab bar. `DsSmartTabs` owns the active tab; each + * tab maps to a `columnFilters` entry (or clears it for "All"). The table is + * controlled via `columnFilters` / `onColumnFiltersChange`. + */ export const TabFilters: Story = { name: 'Tabs — Status Filter', + parameters: { + docs: { source: { type: 'code' } }, + }, render: function Render(args) { const [columnFilters, setColumnFilters] = useState([]); const [activeTab, setActiveTab] = useState('all'); @@ -107,7 +124,7 @@ export const TabFilters: Story = { ); return ( -
+ -
+ ); }, }; diff --git a/packages/design-system/src/components/ds-table/stories/ds-table-selection.stories.tsx b/packages/design-system/src/components/ds-table/stories/ds-table-selection.stories.tsx index 97fffb3ed..4563cafcd 100644 --- a/packages/design-system/src/components/ds-table/stories/ds-table-selection.stories.tsx +++ b/packages/design-system/src/components/ds-table/stories/ds-table-selection.stories.tsx @@ -3,7 +3,9 @@ import { fn } from 'storybook/test'; import { useRef, useState } from 'react'; import DsTable from '../ds-table'; import type { DsTableApi } from '../ds-table.types'; -import styles from './ds-table.stories.module.scss'; +import { DsStack } from '../../ds-stack'; +import { DsButtonV3 } from '../../ds-button-v3'; +import { DsTypography } from '../../ds-typography'; import { columns, defaultData, type Person } from './common/story-data'; import { fullHeightDecorator } from './common/story-decorators'; import { TableEmptyState } from './components'; @@ -22,7 +24,7 @@ const meta: Meta> = { fullWidth: true, expandable: false, emptyState: , - onRowClick: (row) => console.log('Row clicked:', row), + onRowClick: fn(), }, decorators: [fullHeightDecorator], }; @@ -49,83 +51,81 @@ export const CustomSelectColumnWidth: Story = { }, }; +/** + * Drive selection imperatively through the table ref. `selectRow`, `selectRows`, + * `selectAllRows`, and `deselectAllRows` on `DsTableApi` let a parent control + * selection from outside the table (toolbars, keyboard shortcuts, bulk flows). + */ export const ProgrammaticRowSelection: Story = { args: { selectable: true, showSelectAllCheckbox: false, stickyHeader: true, - onSelectionChange: (selectedRows) => console.log('Selected rows:', selectedRows), + onSelectionChange: fn(), + }, + parameters: { + docs: { source: { type: 'code' } }, }, render: function Render(args) { const tableRef = useRef>(null); const [selectedRows, setSelectedRows] = useState([]); - const selectRow = (rowId: string) => { - tableRef.current?.selectRow(rowId); - }; - - const selectAllRows = () => { - tableRef.current?.selectAllRows(); - }; - - const deselectAllRows = () => { - tableRef.current?.deselectAllRows(); - }; - - const selectSpecificRows = () => { - tableRef.current?.selectRows(['1', '2', '3']); - }; - const handleSelectionChange = (selection: Record) => { - const selectedIds = Object.keys(selection); - setSelectedRows(selectedIds); + setSelectedRows(Object.keys(selection)); }; return ( -
-
-

Programmatic Row Selection Demo

-

- Use the buttons below to programmatically control row selection using TanStack Table v8 APIs. -

-

- Selected rows: {selectedRows.length > 0 ? selectedRows.join(', ') : 'None'} -

-
+ + + Selected rows: {selectedRows.length > 0 ? selectedRows.join(', ') : 'None'} + -
- - - - - - -
+ +
-
+ ); }, }; +/** + * Cap how many rows can be selected at once. Track selection in state and make + * `selectable` a predicate: a row stays selectable only if it is already + * selected or the count is under the limit, so the remaining checkboxes disable + * once the cap is reached. + */ export const MaxSelectionLimit: Story = { name: 'Max N Selections', args: { showSelectAllCheckbox: false, onSelectionChange: fn(), }, + parameters: { + docs: { source: { type: 'code' } }, + }, render: function Render(args) { const [rowSelection, setRowSelection] = useState>({}); const maxSelections = 2; @@ -138,26 +138,17 @@ export const MaxSelectionLimit: Story = { }; return ( -
-
-

Max Selection Limit Demo

-

- You can select at most {maxSelections} rows. Once the limit is reached, checkboxes for other rows - are disabled. -

-

- Selected: {selectedCount} / {maxSelections} -

-
+ + + Selected: {selectedCount} / {maxSelections} + { - return rowSelection[rowData.id] || selectedCount < maxSelections; - }} + selectable={(rowData) => rowSelection[rowData.id] || selectedCount < maxSelections} /> -
+ ); }, }; diff --git a/packages/design-system/src/components/ds-table/stories/ds-table-virtualized.stories.tsx b/packages/design-system/src/components/ds-table/stories/ds-table-virtualized.stories.tsx index fb4989629..5162cdaf1 100644 --- a/packages/design-system/src/components/ds-table/stories/ds-table-virtualized.stories.tsx +++ b/packages/design-system/src/components/ds-table/stories/ds-table-virtualized.stories.tsx @@ -12,6 +12,7 @@ import { import { DsSpinner } from '../../ds-spinner'; import { DsStack } from '../../ds-stack'; import { DsButtonV3 } from '../../ds-button-v3'; +import { DsTypography } from '../../ds-typography'; import { generatePersonData, simulateApiCall } from './common/story-data-generator'; import styles from './ds-table.stories.module.scss'; import editableStyles from './ds-table-editable.stories.module.scss'; @@ -33,12 +34,12 @@ const meta: Meta> = { fullWidth: true, expandable: false, emptyState: , - onRowClick: (row) => console.log('Row clicked:', row), + onRowClick: fn(), }, decorators: [ (Story) => fullHeightDecorator(() => ( -
+
)), @@ -69,8 +70,17 @@ export const EmptyState: Story = { }, }; +/** + * Row virtualization with selection, backed by TanStack Query infinite scroll. + * Keep the flattened pages in `data`, wire `infiniteScroll` to the query's + * `fetchNextPage` / loading flags, and the table renders only visible rows — + * performant even for very large datasets. + */ export const VirtualizedSelectable: Story = { name: 'Virtualized Selectable Table', + parameters: { + docs: { source: { type: 'code' } }, + }, render: function Render(args) { const pageSize = 10; const [sorting, setSorting] = useState([]); @@ -103,39 +113,28 @@ export const VirtualizedSelectable: Story = { const hasMore = flatData.length < totalRows; return ( -
-
-

Virtualized Table Demo

-

- This table uses infinite query to fetch data as you scroll, making it performant even with large - datasets. Try scrolling to see the data loading! -

-

- ({flatData.length} of {totalRows} rows fetched) -

-
- -
- - {isLoading && ( -
-
- - Loading data... -
-
- )} -
+
+ + {isLoading && ( +
+ + + + Loading data... + + +
+ )}
); }, @@ -154,8 +153,15 @@ export const VirtualizedSelectable: Story = { }, }; +/** + * Combine row virtualization with expandable rows. `renderExpandedRow` supplies + * the detail content for each expanded row while the body stays virtualized. + */ export const VirtualizedExpandable: Story = { name: 'Virtualized Expandable Table', + parameters: { + docs: { source: { type: 'code' } }, + }, render: function Render(args) { const pageSize = 10; const [sorting, setSorting] = useState([]); @@ -188,70 +194,59 @@ export const VirtualizedExpandable: Story = { const hasMore = flatData.length < totalRows; return ( -
-
-

Virtualized Table with Expandable Rows

-

- This table combines virtualization for large datasets with expandable rows. Click the chevron to - expand rows and see additional details. -

-

- ({flatData.length} of {totalRows} rows fetched) -

-
- -
- ( - <> -
-

Expanded Details for {row.firstName}

-

ID: {row.id}

-

- Full Name: {row.firstName} {row.lastName} -

-

Status: {row.status}

-
- - - - )} - /> - {isLoading && ( -
-
- - Loading data... -
-
+
+ ( + + + Expanded Details for {row.firstName} + ID: {row.id} + + Full Name: {row.firstName} {row.lastName} + + Status: {row.status} + + + + )} -
+ /> + {isLoading && ( +
+ + + + Loading data... + + +
+ )}
); }, @@ -269,8 +264,17 @@ export const VirtualizedExpandable: Story = { }, }; +/** + * When the first page returns too few rows to fill the viewport, `autoFill` + * (on by default) keeps requesting pages until the content becomes scrollable, + * so infinite scroll can take over. Wire `infiniteScroll` to your query's + * `fetchNextPage` and loading flags. + */ export const InfiniteScroll: Story = { name: 'Virtualized Infinite Scroll', + parameters: { + docs: { source: { type: 'code' } }, + }, render: function Render(args) { const pageSize = 5; const totalRows = 60; @@ -303,32 +307,18 @@ export const InfiniteScroll: Story = { const hasMore = flatData.length < fetchedTotal; return ( -
-
-

Virtualized Infinite Scroll

-

- The first page only returns {pageSize} rows - too few to fill the viewport. With{' '} - autoFill: true (the default), the Table keeps requesting pages until the content - becomes scrollable. -

-

- ({flatData.length} of {fetchedTotal} rows fetched) -

-
- -
- -
+
+
); }, @@ -414,54 +404,42 @@ const editableColumns: ColumnDef[] = [ }, ]; +/** + * Inline editing works with row virtualization for large datasets. Scroll + * through thousands of rows and double-click any editable cell to edit in place; + * `onCellEdit` reports the row, column, and new value to persist. + */ export const VirtualizedEditable: Story = { name: 'Virtualized Editable Table', parameters: { - docs: { - description: { - story: - 'Editable cells work with row virtualization for large datasets. Scroll through thousands of rows and double-click any editable cell to edit in place.', - }, - }, + docs: { source: { type: 'code' } }, }, render: function Render(args) { const [data, setData] = useState(() => generatePersonData(0, VIRTUALIZED_ROW_COUNT, []).data); return ( -
-
-

Virtualized Editable Table

-

- Inline editing with row virtualization. Scroll through {VIRTUALIZED_ROW_COUNT.toLocaleString()}{' '} - rows and double-click a cell to edit in place. -

-

({data.length.toLocaleString()} rows loaded)

-
- -
- { - setData((rows) => updateRow(rows, row.id, columnId as keyof Person, value as never)); - }} - /> -
+
+ { + setData((rows) => updateRow(rows, row.id, columnId as keyof Person, value as never)); + }} + />
); }, }; +/** + * The pinned `controls` bar stays fixed above the header while the virtualized + * body scrolls through a large dataset. + */ export const VirtualizedWithControls: Story = { parameters: { - docs: { - description: { - story: - 'The pinned controls bar stays fixed above the header while the virtualized body scrolls through a large dataset.', - }, - }, + docs: { source: { type: 'code' } }, }, render: function Render(args) { const [data] = useState(() => generatePersonData(0, VIRTUALIZED_ROW_COUNT, []).data); @@ -496,31 +474,14 @@ export const VirtualizedWithControls: Story = { export const VirtualizedResizable: Story = { name: 'Virtualized Resizable Columns', parameters: { - docs: { - source: { type: 'code' }, - description: { - story: - 'Column resize works with row virtualization. Scroll through a large dataset and drag a header edge to resize; widths apply to virtualized rows. Double-click a handle to restore the snapshotted width.', - }, - }, + docs: { source: { type: 'code' } }, }, render: function Render(args) { const [data] = useState(() => generatePersonData(0, VIRTUALIZED_ROW_COUNT, []).data); return ( -
-
-

Virtualized Resizable Columns

-

- Row virtualization with column resize. Scroll through {VIRTUALIZED_ROW_COUNT.toLocaleString()}{' '} - rows and drag a header edge to resize. -

-

({data.length.toLocaleString()} rows loaded)

-
- -
- -
+
+
); }, diff --git a/packages/design-system/src/components/ds-table/stories/ds-table.stories.module.scss b/packages/design-system/src/components/ds-table/stories/ds-table.stories.module.scss index cd737b129..b5c26cb57 100644 --- a/packages/design-system/src/components/ds-table/stories/ds-table.stories.module.scss +++ b/packages/design-system/src/components/ds-table/stories/ds-table.stories.module.scss @@ -2,21 +2,25 @@ .storyPadding { height: 100%; - padding: 1rem; + padding: var(--standard); } -.expandedRowDetails { - padding: 10px; - margin-bottom: 20px; - background-color: #f9f9f9; - border-left: 3px solid lightblue; +.virtualizedStoryHeight { + height: 700px; + min-height: 100%; } +.horizontalScrollWrapper { + width: 700px; + height: 320px; +} + +// Custom status chip cell renderer (see Columns → Progress as Infographic) .statusCell { - padding: 4px 8px; - border-radius: 4px; - font-size: 12px; - font-weight: 600; + padding: var(--3xs) var(--xs); + border-radius: var(--3xs); + font-size: var(--body-font-size-sm); + font-weight: var(--font-weight-semi-bold); color: var(--color-dap-gray-050); &--single { @@ -32,17 +36,32 @@ } } -.tableFilterContainer { - display: flex; - flex-direction: column; - height: 100%; +// Virtualized stories: the table body needs a bounded, positioned host so the +// loading scrim can overlay it while rows are fetched. +.virtualizedTableWrapper { + position: relative; flex: 1; - margin: 20px; - gap: 20px; } -.toolbar { - margin-left: auto; +.loadingOverlay { + position: absolute; + inset: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + background-color: rgb(255 255 255 / 80%); + border-radius: var(--3xs); +} + +// Active-row drawer content padding (Drawer owns no inner padding). +.drawerContent { + padding: var(--lg); +} + +// Filters modal layout (two-column: nav list + content). +.filterModal { + height: 600px; } .filterHeader { @@ -52,18 +71,12 @@ padding: var(--md) var(--standard); } -.headerLeft { - display: flex; - align-items: center; - gap: var(--xs); -} - .filterBody { display: flex; flex: 1; min-height: 0; - overflow: hidden; padding: 0; + overflow: hidden; } .filterTabs { @@ -72,9 +85,9 @@ } .filterTabList { + flex-basis: 40%; border-right: 1px solid var(--border-secondary); background: var(--background-secondary); - flex-basis: 40%; } .filterContent { @@ -86,10 +99,10 @@ .filterFooter { display: flex; + flex-shrink: 0; align-items: center; justify-content: space-between; padding: var(--sm) var(--standard); - flex-shrink: 0; } .filterTabLabel { @@ -98,233 +111,28 @@ color: inherit; } -.filterTabBadge { - display: flex; - align-items: center; - gap: var(--xs); - flex-shrink: 0; -} - .filterTabDot { width: 10px; height: 10px; + flex-shrink: 0; border-radius: 50%; background: var(--background-primary); border: 1px solid var(--background); - flex-shrink: 0; -} - -.filterTabCount { - color: var(--font-secondary); -} - -.programmaticSelectionDemo { - margin-bottom: 1rem; - padding: 1rem; - background-color: #f5f5f5; - border-radius: 4px; - - &__title { - margin: 0 0 0.5rem 0; - } - - &__description { - margin: 0; - font-size: 14px; - color: #666; - } - - &__selectedRows { - margin: 0.5rem 0 0 0; - font-size: 12px; - color: #888; - } -} - -.programmaticSelectionControls { - margin-bottom: 1rem; - display: flex; - gap: 0.5rem; - flex-wrap: wrap; -} - -.programmaticSelectionButton { - padding: 0.5rem 1rem; - border: 1px solid #ccc; - border-radius: 4px; - cursor: pointer; - background: white; - transition: all 0.2s ease; - - &:hover { - background-color: #f8f9fa; - border-color: #adb5bd; - } - - &:active { - background-color: #e9ecef; - } -} - -.virtualizedDemoContainer { - display: flex; - flex-direction: column; - gap: 1rem; - position: relative; - height: 100%; -} - -.virtualizedDemoHeader { - margin-bottom: 1rem; - padding: 1rem; - background-color: #f5f5f5; - border-radius: 4px; - - &__title { - margin: 0 0 0.5rem 0; - } - - &__description { - margin: 0; - font-size: 14px; - color: #666; - } - - &__stats { - margin: 0.5rem 0 0 0; - font-size: 12px; - color: #888; - } -} - -.developmentNotice { - margin-bottom: 1rem; - font-size: 12px; - color: #888; -} - -.virtualizedTableWrapper { - position: relative; - flex: 1; -} - -.loadingOverlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(255, 255, 255, 0.8); - display: flex; - align-items: center; - justify-content: center; - z-index: 10; - border-radius: 4px; -} - -.loadingContent { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; -} - -.loadingText { - font-size: 14px; - color: #666; -} - -// Drawer story styles -.drawerContent { - padding: var(--lg); -} - -.drawerHeader { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--lg); -} - -.drawerTitle { - margin: 0; - font-size: var(--heading-font-size-2xl); - font-weight: var(--font-weight-semi-bold); -} - -.drawerCloseButton { - background: none; - border: none; - cursor: pointer; - padding: var(--xs); - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - transition: background-color 0.2s ease; - - &:hover { - background-color: var(--background-action-hover-weak); - } -} - -.drawerDetails { - display: flex; - flex-direction: column; - gap: var(--standard); -} - -.drawerDetailItem { - display: flex; - flex-direction: column; -} - -.drawerDetailLabel { - color: var(--font-secondary); - font-weight: var(--font-weight-medium); - font-size: var(--body-font-size-sm); - margin-bottom: var(--3xs); -} - -.drawerDetailValue { - margin: 0; - font-size: var(--body-font-size-md); - color: var(--font-main); -} - -.drawerDetailValueCapitalized { - text-transform: capitalize; -} - -.drawerProgressContainer { - margin-top: var(--xs); -} - -.drawerNote { - margin-top: var(--lg); - padding: var(--standard); - background: var(--background-info); - border-radius: 4px; - - p { - margin: 0; - font-size: var(--body-font-size-sm); - color: var(--font-secondary); - } } +// Controlled filters demo: JSON debug panel showing external filter state. .debugPanel { padding: var(--md); - background: var(--background-secondary); border: 1px solid var(--border-secondary); - border-radius: 4px; + border-radius: var(--3xs); + background: var(--background-secondary); } .debugCode { margin: var(--xs) 0 0; padding: var(--sm); + border-radius: var(--3xs); background: var(--background); - border-radius: 4px; font-family: monospace; font-size: var(--body-font-size-sm); overflow-x: auto; @@ -335,8 +143,3 @@ .destructiveAction { color: var(--background-error); } - -.horizontalScrollWrapper { - width: 700px; - height: 320px; -} diff --git a/packages/design-system/src/components/ds-table/stories/filters-panel.stories.tsx b/packages/design-system/src/components/ds-table/stories/filters-panel.stories.tsx index e9117b35a..a4a5b0498 100644 --- a/packages/design-system/src/components/ds-table/stories/filters-panel.stories.tsx +++ b/packages/design-system/src/components/ds-table/stories/filters-panel.stories.tsx @@ -1,17 +1,20 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, screen, userEvent, within } from 'storybook/test'; +import { fn } from 'storybook/test'; import { useState } from 'react'; import type { ColumnDef } from '@tanstack/react-table'; import { DsIcon } from '../../ds-icon'; import DsTable from '../ds-table'; import { DsButtonV3 } from '../../ds-button-v3'; import { DsModal } from '../../ds-modal'; +import { DsStack } from '../../ds-stack'; import { DsVerticalTabs } from '../../ds-vertical-tabs'; import { DsTypography } from '../../ds-typography'; import { DsTagFilter } from '../../ds-tag-filter'; import { useTableFilters } from '../filters/hooks/use-table-filters'; import type { FilterNavItem } from '../filters/types/filter-adapter.types'; import { type Workflow, workflowFilters } from './filters-panel/workflow-filters.config'; +import { fullHeightDecorator } from './common/story-decorators'; +import { TableEmptyState } from './components'; import styles from './ds-table.stories.module.scss'; const sampleUsers = [ @@ -378,21 +381,10 @@ createCustomFilterAdapter({ bordered: true, fullWidth: true, expandable: false, - emptyState:
No data available
, - onRowClick: (row) => console.log('Row clicked:', row), + emptyState: , + onRowClick: fn(), }, - decorators: [ - (Story) => ( -
- - -
- ), - ], + decorators: [fullHeightDecorator], }; export default meta; @@ -402,6 +394,7 @@ export const FiltersPanel: Story = { name: 'Toolbar — Filters Panel', parameters: { docs: { + source: { type: 'code' }, description: { story: ` ### Interactive Filter Example @@ -548,50 +541,45 @@ To add a new filter, just add one adapter to \`workflowFilters\` array. No other setIsOpen(false); }; - // Helper component for filter tab content (label + count badge) const TabLabel = ({ item }: { item: FilterNavItem }) => ( <> {item.label} {!!item.count && ( -
+ - + {item.count} -
+ )} ); return ( -
- {/* Toolbar with filter button */} -
+ + setIsOpen(true)} /> -
+ - {/* Filter chips (automatically generated from filter state) */} {filterChips.length > 0 && ( )} - {/* Table with enhanced columns (includes filter functions) */} - {/* Filter modal with two-column layout pattern */} - + -
+ Filters -
+
@@ -627,102 +615,16 @@ To add a new filter, just add one adapter to \`workflowFilters\` array. No other
-
+ ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Verify initial state: table shows all 12 rows - const getTableRows = () => canvas.getAllByRole('row').filter((row) => !row.querySelector('th')); - await expect(getTableRows()).toHaveLength(12); - - // 1. Open filter modal - const filterButton = canvas.getByRole('button', { name: /filter/i }); - await userEvent.click(filterButton); - - // 2. Verify all tabs exist - const statusTab = screen.getByRole('tab', { name: /status/i }); - const runningTab = screen.getByRole('tab', { name: /running\/completed/i }); - const lastEditedTab = screen.getByRole('tab', { name: /last edited/i }); - - // 3. Status filter - select "Active" and "Running" - await userEvent.click(statusTab); - - const activeCheckbox = screen.getByRole('checkbox', { name: /^active$/i }); - const runningCheckbox = screen.getByRole('checkbox', { name: /^running$/i }); - - await userEvent.click(activeCheckbox); - await userEvent.click(runningCheckbox); - - await expect(activeCheckbox).toBeChecked(); - await expect(runningCheckbox).toBeChecked(); - - // 4. Running/Completed filter - set range - await userEvent.click(runningTab); - - const [runningFrom, runningTo] = screen.getAllByRole('spinbutton'); - await userEvent.type(runningFrom as HTMLElement, '0'); - await userEvent.type(runningTo as HTMLElement, '50'); - - // 5. Last edited filter - select editor + time range - await userEvent.click(lastEditedTab); - - const editorCheckbox = screen.getByRole('checkbox', { name: /marry levin/i }); - const timeRangeRadio = screen.getByRole('radio', { name: /last 3 months/i }); - - await userEvent.click(editorCheckbox); - await userEvent.click(timeRangeRadio); - - await expect(editorCheckbox).toBeChecked(); - await expect(timeRangeRadio).toBeChecked(); - - // 6. Apply filters - await userEvent.click(screen.getByRole('button', { name: /apply/i })); - - // Verify chips appear - await expect(canvas.getByText(/status: active/i)).toBeInTheDocument(); - await expect(canvas.getByText(/status: running/i)).toBeInTheDocument(); - await expect(canvas.getByText(/running.*0.*50/i)).toBeInTheDocument(); - await expect(canvas.getByText(/editor: marry levin/i)).toBeInTheDocument(); - await expect(canvas.getByText(/last 3 months/i)).toBeInTheDocument(); - - // 7. Verify table is filtered - await expect(getTableRows().length).toBeLessThan(12); - - // 8. Re-open modal - verify filters preserved - await userEvent.click(filterButton); - - await userEvent.click(screen.getByRole('tab', { name: /status/i })); - - await expect(screen.getByRole('checkbox', { name: /^active$/i })).toBeChecked(); - await expect(screen.getByRole('checkbox', { name: /^running$/i })).toBeChecked(); - - await userEvent.click(screen.getByRole('button', { name: /apply/i })); - - // 9. Delete individual chip. DsTagFilter renders a `Delete tag` X inside each - // chip; the chip itself is also role="button", so the nested button is - // excluded from the accessibility tree — query by label directly. - const activeChip = canvas.getByRole('button', { name: /status: active/i }); - const deleteButton = within(activeChip).getByLabelText(/delete tag/i); - await userEvent.click(deleteButton); - - await expect(canvas.queryByRole('button', { name: /status: active/i })).not.toBeInTheDocument(); - - // 10. Clear all filters - await userEvent.click(canvas.getByRole('button', { name: /clear all/i })); - - await expect(canvas.queryByText(/status:/i)).not.toBeInTheDocument(); - - // Verify table shows all rows again - await expect(getTableRows()).toHaveLength(12); - }, }; export const Controlled: Story = { name: 'Toolbar — Controlled', parameters: { docs: { + source: { type: 'code' }, description: { story: ` ### Controlled Mode Example @@ -794,32 +696,31 @@ The debug panel below shows the current filter state as JSON. {item.label} {!!item.count && ( -
+ - + {item.count} -
+ )} ); return ( -
- {/* Debug panel showing external state */} -
+ + External Filter State (controlled):
{JSON.stringify(appliedFilters, null, 2) || '{}'}
-
+ -
+ setIsOpen(true)} /> -
+ {filterChips.length > 0 && ( @@ -827,12 +728,12 @@ The debug panel below shows the current filter state as JSON. - + -
+ Filters -
+
@@ -868,41 +769,7 @@ The debug panel below shows the current filter state as JSON.
-
+ ); }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Verify initial state: debug panel shows empty object - const debugPanel = canvas.getByText('External Filter State (controlled):'); - await expect(debugPanel).toBeInTheDocument(); - await expect(canvas.getByText('{}')).toBeInTheDocument(); - - // 1. Open filter modal and apply a filter - const filterButton = canvas.getByRole('button', { name: /filter/i }); - await userEvent.click(filterButton); - - // Select Active status - const statusTab = screen.getByRole('tab', { name: /status/i }); - await userEvent.click(statusTab); - - const activeCheckbox = screen.getByRole('checkbox', { name: /^active$/i }); - await userEvent.click(activeCheckbox); - - // Apply - await userEvent.click(screen.getByRole('button', { name: /apply/i })); - - // 2. Verify external state is updated (debug panel shows filter) - await expect(canvas.getByText(/"status"/)).toBeInTheDocument(); - - // 3. Verify chip appears - await expect(canvas.getByText(/status: active/i)).toBeInTheDocument(); - - // 4. Clear all and verify state resets - await userEvent.click(canvas.getByRole('button', { name: /clear all/i })); - - await expect(canvas.getByText('{}')).toBeInTheDocument(); - await expect(canvas.queryByText(/status: active/i)).not.toBeInTheDocument(); - }, }; 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 0199ebe12..a9d7e29a7 100644 --- a/packages/design-system/tests/storybook/docs-snippets.docs.test.ts +++ b/packages/design-system/tests/storybook/docs-snippets.docs.test.ts @@ -54,6 +54,7 @@ const COMPONENTS = [ 'status-badge', 'status-badge-v2', 'stepper', + 'table', 'tabs', 'tag', 'tag-filter', @@ -68,12 +69,27 @@ const COMPONENTS = [ 'workspace-layout', ]; +// Folders whose stories fan out into many story titles (each a separate manifest +// component) produce a single aggregated golden that runs to thousands of lines +// where every section shares the same `# DsTable` header — unnavigable for humans +// and agents alike. Split those into one golden per manifest component instead. +const SPLIT_PER_MANIFEST = new Set(['table']); + function getComponentSnapshotPath(name: string): string { const folder = `ds-${name}`; return path.join(packageRoot, 'src/components', folder, '__tests__/__snapshots__', `${folder}.docs.snap`); } +// Names the per-manifest golden from the title-derived id so the file mirrors the +// story hierarchy (`components-table-selection` → `ds-table-selection.docs.snap`). +function getManifestSnapshotPath(name: string, component: ManifestComponent): string { + const folder = `ds-${name}`; + const fileName = component.id.replace(/^components-/, 'ds-'); + + return path.join(packageRoot, 'src/components', folder, '__tests__/__snapshots__', `${fileName}.docs.snap`); +} + async function buildComponentDocsSnapshot(page: Page, component: ManifestComponent): Promise { const sections: string[] = [`# ${component.name} docs snippets`, '']; @@ -118,7 +134,7 @@ describe('docs snippets', () => { it.concurrent(`ds-${name} docs snippets match staged authoring rules`, async ({ expect }) => { // A folder may resolve to several manifest components (e.g. ds-form-control); // build each on its own page in parallel, then aggregate in resolved order. - const sections = await Promise.all( + const built = await Promise.all( manifestComponents.map(async (component) => { const page = await browser.newPage({ viewport: { width: 1400, height: 900 }, @@ -126,16 +142,32 @@ describe('docs snippets', () => { }); try { - return await buildComponentDocsSnapshot(page, component); + return { component, document: await buildComponentDocsSnapshot(page, component) }; } finally { await page.close(); } }), ); - const document = sections.join('\n\n'); - - await expect(document).toMatchFileSnapshot(getComponentSnapshotPath(name)); + // Split folders write one golden per manifest component; the rest aggregate + // every manifest component into a single colocated golden. + const snapshots = SPLIT_PER_MANIFEST.has(name) + ? built.map(({ component, document }) => ({ + path: getManifestSnapshotPath(name, component), + document, + })) + : [ + { + path: getComponentSnapshotPath(name), + document: built.map((entry) => entry.document).join('\n\n'), + }, + ]; + + await Promise.all( + snapshots.map(({ path: snapshotPath, document }) => + expect(document).toMatchFileSnapshot(snapshotPath), + ), + ); }, 180_000); } });