feat(tree): add flat and lazy modes for tree data handling

* Implement flat mode to transform flat data with parentId to nested structure.
* Introduce lazy mode for on-demand loading of children.
* Update tree rendering logic to accommodate new modes.
* Enhance tree cell expansion logic to support lazy loading.
* Add dark mode styles for improved UI experience.
* Create comprehensive end-to-end tests for tree functionality.
This commit is contained in:
Hein
2026-02-17 13:03:20 +02:00
parent 9ddc960578
commit 93568891cd
23 changed files with 1131 additions and 707 deletions

3
.gitignore vendored
View File

@@ -25,4 +25,5 @@ dist-ssr
*storybook.log
storybook-static
test-results/
test-results/
playwright-report/

View File

@@ -4,6 +4,23 @@ import { PreviewDecorator } from './previewDecorator';
const preview: Preview = {
decorators: [PreviewDecorator],
globalTypes: {
colorScheme: {
description: 'Mantine color scheme',
toolbar: {
dynamicTitle: true,
icon: 'paintbrush',
items: [
{ icon: 'sun', title: 'Light', value: 'light' },
{ icon: 'moon', title: 'Dark', value: 'dark' },
],
title: 'Color Scheme',
},
},
},
initialGlobals: {
colorScheme: 'light',
},
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {

View File

@@ -8,7 +8,8 @@ import { ModalsProvider } from '@mantine/modals';
import { GlobalStateStoreProvider } from '../src/GlobalStateStore';
export const PreviewDecorator: Decorator = (Story, context) => {
const { parameters } = context;
const { parameters, globals } = context;
const colorScheme = globals.colorScheme as 'light' | 'dark';
// Allow stories to opt-out of GlobalStateStore provider
const useGlobalStore = parameters.globalStore !== false;
@@ -21,7 +22,7 @@ export const PreviewDecorator: Decorator = (Story, context) => {
};
return (
<MantineProvider>
<MantineProvider forceColorScheme={colorScheme}>
<ModalsProvider>
{useGlobalStore ? (

View File

@@ -1,500 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner "Storybook" [ref=e6]:
- heading "Storybook" [level=1] [ref=e7]
- img
- generic [ref=e11]:
- generic [ref=e12]:
- generic [ref=e13]:
- link "Skip to content" [ref=e14] [cursor=pointer]:
- /url: "#storybook-preview-wrapper"
- link "Storybook" [ref=e16] [cursor=pointer]:
- /url: ./
- img "Storybook" [ref=e17]
- switch "Settings" [ref=e22] [cursor=pointer]:
- img [ref=e23]
- generic [ref=e28]:
- generic [ref=e30] [cursor=pointer]:
- button "Open onboarding guide" [ref=e34]:
- img [ref=e36]
- strong [ref=e38]: Get started
- generic [ref=e39]:
- button "Collapse onboarding checklist" [expanded] [ref=e40]:
- img [ref=e41]
- button "25% completed" [ref=e43]:
- generic [ref=e44]:
- img [ref=e45]
- img [ref=e47]
- generic [ref=e50]: 25%
- list [ref=e52]:
- listitem [ref=e53]:
- button "Open onboarding guide for See what's new" [ref=e54] [cursor=pointer]:
- img [ref=e56]
- generic [ref=e59]: See what's new
- button "Go"
- listitem [ref=e60]:
- button "Open onboarding guide for Change a story with Controls" [ref=e61] [cursor=pointer]:
- img [ref=e63]
- generic [ref=e66]: Change a story with Controls
- listitem [ref=e67]:
- button "Open onboarding guide for Install Vitest addon" [ref=e68] [cursor=pointer]:
- img [ref=e70]
- generic [ref=e73]: Install Vitest addon
- generic [ref=e74]: Search for components
- search [ref=e75]:
- combobox "Search for components" [ref=e76]:
- generic:
- img
- searchbox "Search for components" [ref=e77]
- code: ⌃ K
- button "Tag filters" [ref=e79] [cursor=pointer]:
- img [ref=e80]
- button "Create a new story" [ref=e82] [cursor=pointer]:
- img [ref=e83]
- navigation "Stories" [ref=e86]:
- heading "Stories" [level=2] [ref=e87]
- generic [ref=e89]:
- generic [ref=e90]:
- button "Collapse" [expanded] [ref=e91] [cursor=pointer]:
- img [ref=e93]
- text: Components
- button "Expand all" [ref=e95] [cursor=pointer]:
- img [ref=e96]
- button "Boxer" [ref=e99] [cursor=pointer]:
- generic [ref=e100]:
- img [ref=e102]
- img [ref=e104]
- text: Boxer
- button "Griddy" [expanded] [ref=e107] [cursor=pointer]:
- generic [ref=e108]:
- img [ref=e110]
- img [ref=e112]
- text: Griddy
- link "Basic" [ref=e115] [cursor=pointer]:
- /url: /?path=/story/components-griddy--basic
- img [ref=e117]
- text: Basic
- link "Large Dataset" [ref=e120] [cursor=pointer]:
- /url: /?path=/story/components-griddy--large-dataset
- img [ref=e122]
- text: Large Dataset
- link "Single Selection" [ref=e125] [cursor=pointer]:
- /url: /?path=/story/components-griddy--single-selection
- img [ref=e127]
- text: Single Selection
- link "Multi Selection" [ref=e130] [cursor=pointer]:
- /url: /?path=/story/components-griddy--multi-selection
- img [ref=e132]
- text: Multi Selection
- link "Large Multi Selection" [ref=e135] [cursor=pointer]:
- /url: /?path=/story/components-griddy--large-multi-selection
- img [ref=e137]
- text: Large Multi Selection
- link "With Search" [ref=e140] [cursor=pointer]:
- /url: /?path=/story/components-griddy--with-search
- img [ref=e142]
- text: With Search
- link "Keyboard Navigation" [ref=e145] [cursor=pointer]:
- /url: /?path=/story/components-griddy--keyboard-navigation
- img [ref=e147]
- text: Keyboard Navigation
- generic [ref=e149]:
- link "With Text Filtering" [ref=e150] [cursor=pointer]:
- /url: /?path=/story/components-griddy--with-text-filtering
- img [ref=e152]
- text: With Text Filtering
- link "Skip to content" [ref=e154] [cursor=pointer]:
- /url: "#storybook-preview-wrapper"
- link "With Number Filtering" [ref=e156] [cursor=pointer]:
- /url: /?path=/story/components-griddy--with-number-filtering
- img [ref=e158]
- text: With Number Filtering
- link "With Enum Filtering" [ref=e161] [cursor=pointer]:
- /url: /?path=/story/components-griddy--with-enum-filtering
- img [ref=e163]
- text: With Enum Filtering
- link "With Boolean Filtering" [ref=e166] [cursor=pointer]:
- /url: /?path=/story/components-griddy--with-boolean-filtering
- img [ref=e168]
- text: With Boolean Filtering
- link "With All Filter Types" [ref=e171] [cursor=pointer]:
- /url: /?path=/story/components-griddy--with-all-filter-types
- img [ref=e173]
- text: With All Filter Types
- link "Large Dataset With Filtering" [ref=e176] [cursor=pointer]:
- /url: /?path=/story/components-griddy--large-dataset-with-filtering
- img [ref=e178]
- text: Large Dataset With Filtering
- generic [ref=e180]:
- button "Collapse" [expanded] [ref=e181] [cursor=pointer]:
- img [ref=e183]
- text: Former
- button "Expand all" [ref=e185] [cursor=pointer]:
- img [ref=e186]
- button "Former Basic" [ref=e189] [cursor=pointer]:
- generic [ref=e190]:
- img [ref=e192]
- img [ref=e194]
- text: Former Basic
- button "Controls Basic" [ref=e197] [cursor=pointer]:
- generic [ref=e198]:
- img [ref=e200]
- img [ref=e202]
- text: Controls Basic
- generic [ref=e204]:
- button "Collapse" [expanded] [ref=e205] [cursor=pointer]:
- img [ref=e207]
- text: State
- button "Expand all" [ref=e209] [cursor=pointer]:
- img [ref=e210]
- button "GlobalStateStore" [ref=e213] [cursor=pointer]:
- generic [ref=e214]:
- img [ref=e216]
- img [ref=e218]
- text: GlobalStateStore
- generic [ref=e220]:
- button "Collapse" [expanded] [ref=e221] [cursor=pointer]:
- img [ref=e223]
- text: Grid
- button "Expand all" [ref=e225] [cursor=pointer]:
- img [ref=e226]
- button "Gridler API" [ref=e229] [cursor=pointer]:
- generic [ref=e230]:
- img [ref=e232]
- img [ref=e234]
- text: Gridler API
- button "Gridler Local" [ref=e237] [cursor=pointer]:
- generic [ref=e238]:
- img [ref=e240]
- img [ref=e242]
- text: Gridler Local
- generic [ref=e244]:
- button "Collapse" [expanded] [ref=e245] [cursor=pointer]:
- img [ref=e247]
- text: UI
- button "Expand all" [ref=e249] [cursor=pointer]:
- img [ref=e250]
- button "Mantine Better Menu" [ref=e253] [cursor=pointer]:
- generic [ref=e254]:
- img [ref=e256]
- img [ref=e258]
- text: Mantine Better Menu
- generic [ref=e261]:
- region "Toolbar" [ref=e262]:
- heading "Toolbar" [level=2] [ref=e263]
- toolbar [ref=e264]:
- generic [ref=e265]:
- button "Reload story" [ref=e266] [cursor=pointer]:
- img [ref=e267]
- switch "Grid visibility" [ref=e269] [cursor=pointer]:
- img [ref=e270]
- button "Preview background" [ref=e272] [cursor=pointer]:
- img [ref=e273]
- switch "Measure tool" [ref=e276] [cursor=pointer]:
- img [ref=e277]
- switch "Outline tool" [ref=e280] [cursor=pointer]:
- img [ref=e281]
- button "Viewport size" [ref=e283] [cursor=pointer]:
- img [ref=e284]
- generic [ref=e288]:
- switch "Change zoom level" [ref=e289] [cursor=pointer]: 100%
- button "Enter full screen" [ref=e290] [cursor=pointer]:
- img [ref=e291]
- button "Share" [ref=e293] [cursor=pointer]:
- img [ref=e294]
- button "Open in editor" [ref=e297] [cursor=pointer]:
- img [ref=e298]
- main "Main preview area" [ref=e301]:
- heading "Main preview area" [level=2] [ref=e302]
- generic [ref=e304]:
- link "Skip to sidebar" [ref=e305] [cursor=pointer]:
- /url: "#components-griddy--with-text-filtering"
- iframe [ref=e309]:
- generic [ref=f1e4]:
- grid "Data grid" [ref=f1e5]:
- generic [ref=f1e6]:
- rowgroup [ref=f1e7]:
- row "ID First Name Filter status indicator Last Name Filter status indicator Email Age Department Salary Start Date Active" [ref=f1e8]:
- columnheader "ID" [ref=f1e9] [cursor=pointer]:
- generic [ref=f1e11]: ID
- columnheader "First Name Filter status indicator" [ref=f1e13] [cursor=pointer]:
- generic [ref=f1e15]:
- text: First Name
- button "Filter status indicator" [disabled] [ref=f1e16]:
- img [ref=f1e18]
- columnheader "Last Name Filter status indicator" [ref=f1e21] [cursor=pointer]:
- generic [ref=f1e23]:
- text: Last Name
- button "Filter status indicator" [disabled] [ref=f1e24]:
- img [ref=f1e26]
- columnheader "Email" [ref=f1e29] [cursor=pointer]:
- generic [ref=f1e31]: Email
- columnheader "Age" [ref=f1e33] [cursor=pointer]:
- generic [ref=f1e35]: Age
- columnheader "Department" [ref=f1e37] [cursor=pointer]:
- generic [ref=f1e39]: Department
- columnheader "Salary" [ref=f1e41] [cursor=pointer]:
- generic [ref=f1e43]: Salary
- columnheader "Start Date" [ref=f1e45] [cursor=pointer]:
- generic [ref=f1e47]: Start Date
- columnheader "Active" [ref=f1e49] [cursor=pointer]:
- generic [ref=f1e51]: Active
- rowgroup [ref=f1e53]:
- row "1 Alice Smith alice.smith@example.com 22 Engineering $40,000 2020-01-01 No" [ref=f1e54]:
- gridcell "1" [ref=f1e55]
- gridcell "Alice" [ref=f1e56]
- gridcell "Smith" [ref=f1e57]
- gridcell "alice.smith@example.com" [ref=f1e58]
- gridcell "22" [ref=f1e59]
- gridcell "Engineering" [ref=f1e60]
- gridcell "$40,000" [ref=f1e61]
- gridcell "2020-01-01" [ref=f1e62]
- gridcell "No" [ref=f1e63]
- row "2 Bob Johnson bob.johnson@example.com 23 Marketing $41,234 2021-02-02 Yes" [ref=f1e64]:
- gridcell "2" [ref=f1e65]
- gridcell "Bob" [ref=f1e66]
- gridcell "Johnson" [ref=f1e67]
- gridcell "bob.johnson@example.com" [ref=f1e68]
- gridcell "23" [ref=f1e69]
- gridcell "Marketing" [ref=f1e70]
- gridcell "$41,234" [ref=f1e71]
- gridcell "2021-02-02" [ref=f1e72]
- gridcell "Yes" [ref=f1e73]
- row "3 Charlie Williams charlie.williams@example.com 24 Sales $42,468 2022-03-03 Yes" [ref=f1e74]:
- gridcell "3" [ref=f1e75]
- gridcell "Charlie" [ref=f1e76]
- gridcell "Williams" [ref=f1e77]
- gridcell "charlie.williams@example.com" [ref=f1e78]
- gridcell "24" [ref=f1e79]
- gridcell "Sales" [ref=f1e80]
- gridcell "$42,468" [ref=f1e81]
- gridcell "2022-03-03" [ref=f1e82]
- gridcell "Yes" [ref=f1e83]
- row "4 Diana Brown diana.brown@example.com 25 HR $43,702 2023-04-04 No" [ref=f1e84]:
- gridcell "4" [ref=f1e85]
- gridcell "Diana" [ref=f1e86]
- gridcell "Brown" [ref=f1e87]
- gridcell "diana.brown@example.com" [ref=f1e88]
- gridcell "25" [ref=f1e89]
- gridcell "HR" [ref=f1e90]
- gridcell "$43,702" [ref=f1e91]
- gridcell "2023-04-04" [ref=f1e92]
- gridcell "No" [ref=f1e93]
- row "5 Eve Jones eve.jones@example.com 26 Finance $44,936 2024-05-05 Yes" [ref=f1e94]:
- gridcell "5" [ref=f1e95]
- gridcell "Eve" [ref=f1e96]
- gridcell "Jones" [ref=f1e97]
- gridcell "eve.jones@example.com" [ref=f1e98]
- gridcell "26" [ref=f1e99]
- gridcell "Finance" [ref=f1e100]
- gridcell "$44,936" [ref=f1e101]
- gridcell "2024-05-05" [ref=f1e102]
- gridcell "Yes" [ref=f1e103]
- row "6 Frank Garcia frank.garcia@example.com 27 Design $46,170 2020-06-06 Yes" [ref=f1e104]:
- gridcell "6" [ref=f1e105]
- gridcell "Frank" [ref=f1e106]
- gridcell "Garcia" [ref=f1e107]
- gridcell "frank.garcia@example.com" [ref=f1e108]
- gridcell "27" [ref=f1e109]
- gridcell "Design" [ref=f1e110]
- gridcell "$46,170" [ref=f1e111]
- gridcell "2020-06-06" [ref=f1e112]
- gridcell "Yes" [ref=f1e113]
- row "7 Grace Miller grace.miller@example.com 28 Legal $47,404 2021-07-07 No" [ref=f1e114]:
- gridcell "7" [ref=f1e115]
- gridcell "Grace" [ref=f1e116]
- gridcell "Miller" [ref=f1e117]
- gridcell "grace.miller@example.com" [ref=f1e118]
- gridcell "28" [ref=f1e119]
- gridcell "Legal" [ref=f1e120]
- gridcell "$47,404" [ref=f1e121]
- gridcell "2021-07-07" [ref=f1e122]
- gridcell "No" [ref=f1e123]
- row "8 Henry Davis henry.davis@example.com 29 Support $48,638 2022-08-08 Yes" [ref=f1e124]:
- gridcell "8" [ref=f1e125]
- gridcell "Henry" [ref=f1e126]
- gridcell "Davis" [ref=f1e127]
- gridcell "henry.davis@example.com" [ref=f1e128]
- gridcell "29" [ref=f1e129]
- gridcell "Support" [ref=f1e130]
- gridcell "$48,638" [ref=f1e131]
- gridcell "2022-08-08" [ref=f1e132]
- gridcell "Yes" [ref=f1e133]
- row "9 Ivy Martinez ivy.martinez@example.com 30 Engineering $49,872 2023-09-09 Yes" [ref=f1e134]:
- gridcell "9" [ref=f1e135]
- gridcell "Ivy" [ref=f1e136]
- gridcell "Martinez" [ref=f1e137]
- gridcell "ivy.martinez@example.com" [ref=f1e138]
- gridcell "30" [ref=f1e139]
- gridcell "Engineering" [ref=f1e140]
- gridcell "$49,872" [ref=f1e141]
- gridcell "2023-09-09" [ref=f1e142]
- gridcell "Yes" [ref=f1e143]
- row "10 Jack Anderson jack.anderson@example.com 31 Marketing $51,106 2024-10-10 No" [ref=f1e144]:
- gridcell "10" [ref=f1e145]
- gridcell "Jack" [ref=f1e146]
- gridcell "Anderson" [ref=f1e147]
- gridcell "jack.anderson@example.com" [ref=f1e148]
- gridcell "31" [ref=f1e149]
- gridcell "Marketing" [ref=f1e150]
- gridcell "$51,106" [ref=f1e151]
- gridcell "2024-10-10" [ref=f1e152]
- gridcell "No" [ref=f1e153]
- row "11 Karen Taylor karen.taylor@example.com 32 Sales $52,340 2020-11-11 Yes" [ref=f1e154]:
- gridcell "11" [ref=f1e155]
- gridcell "Karen" [ref=f1e156]
- gridcell "Taylor" [ref=f1e157]
- gridcell "karen.taylor@example.com" [ref=f1e158]
- gridcell "32" [ref=f1e159]
- gridcell "Sales" [ref=f1e160]
- gridcell "$52,340" [ref=f1e161]
- gridcell "2020-11-11" [ref=f1e162]
- gridcell "Yes" [ref=f1e163]
- row "12 Leo Thomas leo.thomas@example.com 33 HR $53,574 2021-12-12 Yes" [ref=f1e164]:
- gridcell "12" [ref=f1e165]
- gridcell "Leo" [ref=f1e166]
- gridcell "Thomas" [ref=f1e167]
- gridcell "leo.thomas@example.com" [ref=f1e168]
- gridcell "33" [ref=f1e169]
- gridcell "HR" [ref=f1e170]
- gridcell "$53,574" [ref=f1e171]
- gridcell "2021-12-12" [ref=f1e172]
- gridcell "Yes" [ref=f1e173]
- row "13 Mia Hernandez mia.hernandez@example.com 34 Finance $54,808 2022-01-13 No" [ref=f1e174]:
- gridcell "13" [ref=f1e175]
- gridcell "Mia" [ref=f1e176]
- gridcell "Hernandez" [ref=f1e177]
- gridcell "mia.hernandez@example.com" [ref=f1e178]
- gridcell "34" [ref=f1e179]
- gridcell "Finance" [ref=f1e180]
- gridcell "$54,808" [ref=f1e181]
- gridcell "2022-01-13" [ref=f1e182]
- gridcell "No" [ref=f1e183]
- row "14 Nick Moore nick.moore@example.com 35 Design $56,042 2023-02-14 Yes" [ref=f1e184]:
- gridcell "14" [ref=f1e185]
- gridcell "Nick" [ref=f1e186]
- gridcell "Moore" [ref=f1e187]
- gridcell "nick.moore@example.com" [ref=f1e188]
- gridcell "35" [ref=f1e189]
- gridcell "Design" [ref=f1e190]
- gridcell "$56,042" [ref=f1e191]
- gridcell "2023-02-14" [ref=f1e192]
- gridcell "Yes" [ref=f1e193]
- row "15 Olivia Martin olivia.martin@example.com 36 Legal $57,276 2024-03-15 Yes" [ref=f1e194]:
- gridcell "15" [ref=f1e195]
- gridcell "Olivia" [ref=f1e196]
- gridcell "Martin" [ref=f1e197]
- gridcell "olivia.martin@example.com" [ref=f1e198]
- gridcell "36" [ref=f1e199]
- gridcell "Legal" [ref=f1e200]
- gridcell "$57,276" [ref=f1e201]
- gridcell "2024-03-15" [ref=f1e202]
- gridcell "Yes" [ref=f1e203]
- row "16 Paul Jackson paul.jackson@example.com 37 Support $58,510 2020-04-16 No" [ref=f1e204]:
- gridcell "16" [ref=f1e205]
- gridcell "Paul" [ref=f1e206]
- gridcell "Jackson" [ref=f1e207]
- gridcell "paul.jackson@example.com" [ref=f1e208]
- gridcell "37" [ref=f1e209]
- gridcell "Support" [ref=f1e210]
- gridcell "$58,510" [ref=f1e211]
- gridcell "2020-04-16" [ref=f1e212]
- gridcell "No" [ref=f1e213]
- row "17 Quinn Thompson quinn.thompson@example.com 38 Engineering $59,744 2021-05-17 Yes" [ref=f1e214]:
- gridcell "17" [ref=f1e215]
- gridcell "Quinn" [ref=f1e216]
- gridcell "Thompson" [ref=f1e217]
- gridcell "quinn.thompson@example.com" [ref=f1e218]
- gridcell "38" [ref=f1e219]
- gridcell "Engineering" [ref=f1e220]
- gridcell "$59,744" [ref=f1e221]
- gridcell "2021-05-17" [ref=f1e222]
- gridcell "Yes" [ref=f1e223]
- row "18 Rose White rose.white@example.com 39 Marketing $60,978 2022-06-18 Yes" [ref=f1e224]:
- gridcell "18" [ref=f1e225]
- gridcell "Rose" [ref=f1e226]
- gridcell "White" [ref=f1e227]
- gridcell "rose.white@example.com" [ref=f1e228]
- gridcell "39" [ref=f1e229]
- gridcell "Marketing" [ref=f1e230]
- gridcell "$60,978" [ref=f1e231]
- gridcell "2022-06-18" [ref=f1e232]
- gridcell "Yes" [ref=f1e233]
- row "19 Sam Lopez sam.lopez@example.com 40 Sales $62,212 2023-07-19 No" [ref=f1e234]:
- gridcell "19" [ref=f1e235]
- gridcell "Sam" [ref=f1e236]
- gridcell "Lopez" [ref=f1e237]
- gridcell "sam.lopez@example.com" [ref=f1e238]
- gridcell "40" [ref=f1e239]
- gridcell "Sales" [ref=f1e240]
- gridcell "$62,212" [ref=f1e241]
- gridcell "2023-07-19" [ref=f1e242]
- gridcell "No" [ref=f1e243]
- row "20 Tina Lee tina.lee@example.com 41 HR $63,446 2024-08-20 Yes" [ref=f1e244]:
- gridcell "20" [ref=f1e245]
- gridcell "Tina" [ref=f1e246]
- gridcell "Lee" [ref=f1e247]
- gridcell "tina.lee@example.com" [ref=f1e248]
- gridcell "41" [ref=f1e249]
- gridcell "HR" [ref=f1e250]
- gridcell "$63,446" [ref=f1e251]
- gridcell "2024-08-20" [ref=f1e252]
- gridcell "Yes" [ref=f1e253]
- generic [ref=f1e254]:
- strong [ref=f1e255]: "Active Filters:"
- generic [ref=f1e256]: "[]"
- region "Addon panel" [ref=e312]:
- heading "Addon panel" [level=2] [ref=e313]
- generic [ref=e314]:
- generic [ref=e315]:
- generic [ref=e316]:
- button "Move addon panel to right" [ref=e317] [cursor=pointer]:
- img [ref=e318]
- button "Hide addon panel" [ref=e321] [cursor=pointer]:
- img [ref=e322]
- tablist "Available addons" [ref=e327]:
- tab "Controls" [selected] [ref=e328] [cursor=pointer]:
- generic [ref=e330]: Controls
- tab "Actions" [ref=e331] [cursor=pointer]:
- generic [ref=e333]: Actions
- tab "Interactions" [ref=e334] [cursor=pointer]:
- generic [ref=e336]: Interactions
- tabpanel "Controls" [ref=e337]:
- generic [ref=e344]:
- button "Reset controls" [ref=e346] [cursor=pointer]:
- img [ref=e347]
- table [ref=e349]:
- rowgroup [ref=e350]:
- row "Name Description Default Control" [ref=e351]:
- columnheader "Name" [ref=e352]
- columnheader "Description" [ref=e353]
- columnheader "Default" [ref=e354]
- columnheader "Control" [ref=e355]
- rowgroup [ref=e356]:
- row "columns array - -" [ref=e357]:
- cell "columns" [ref=e358]
- cell "array" [ref=e359]:
- generic [ref=e362]: array
- cell "-" [ref=e363]
- cell "-" [ref=e364]
- row "data array - -" [ref=e365]:
- cell "data" [ref=e366]
- cell "array" [ref=e367]:
- generic [ref=e370]: array
- cell "-" [ref=e371]
- cell "-" [ref=e372]
- row "getRowId function - -" [ref=e373]:
- cell "getRowId" [ref=e374]
- cell "function" [ref=e375]:
- generic [ref=e378]: function
- cell "-" [ref=e379]
- cell "-" [ref=e380]
- row "height number - -" [ref=e381]:
- cell "height" [ref=e382]
- cell "number" [ref=e383]:
- generic [ref=e386]: number
- cell "-" [ref=e387]
- cell "-" [ref=e388]
```

File diff suppressed because one or more lines are too long

View File

@@ -2088,12 +2088,12 @@ export const WithSearchHistory: Story = {
// ─── Tree/Hierarchical Data Stories ──────────────────────────────────────────
interface TreeNode {
children?: TreeNode[];
email?: string;
id: string;
name: string;
type: 'department' | 'team' | 'person';
email?: string;
role?: string;
children?: TreeNode[];
type: 'department' | 'person' | 'team';
}
const treeData: TreeNode[] = [
@@ -2203,11 +2203,11 @@ export const TreeNestedMode: Story = {
export const TreeFlatMode: Story = {
render: () => {
interface FlatNode {
id: string;
parentId?: string;
name: string;
type: string;
email?: string;
id: string;
name: string;
parentId?: string;
type: string;
}
const flatData: FlatNode[] = [
@@ -2219,7 +2219,7 @@ export const TreeFlatMode: Story = {
{ email: 'charlie@example.com', id: 'p3', name: 'Charlie Brown', parentId: 't2', type: 'person' },
{ id: 'd2', name: 'Design', type: 'department' },
{ id: 't3', name: 'Product Design', parentId: 'd2', type: 'team' },
{ email: 'diana@example.com', id: 'p4', name: 'Diana Prince', parentId: 't3', type: 'person' },
{ email: 'frank@example.com', id: 'p4', name: 'Frank Miller', parentId: 't3', type: 'person' },
];
const flatColumns: GriddyColumn<FlatNode>[] = [
@@ -2263,9 +2263,9 @@ export const TreeFlatMode: Story = {
export const TreeLazyMode: Story = {
render: () => {
interface LazyNode {
hasChildren: boolean;
id: string;
name: string;
hasChildren: boolean;
type: string;
}

View File

@@ -209,13 +209,16 @@ export const ResolveSpec: Story = {
export const HeaderSpec: Story = {
args: {
baseUrl: 'https://utils.btsys.tech/api',
columnMap: {
active: 'inactive',
department: 'department',
email: 'email',
name: 'name',
email: 'logmessage',
name: 'type',
},
token: ' 773EB99C-F625-4E99-9DB9-CDDA7CA17639',
entity: 'synclog',
},
render: (args) => <HeaderSpecAdapterStory baseUrl={''} entity={''} schema={''} {...args} />,
@@ -394,8 +397,15 @@ export const WithOffsetPagination: Story = {
export const HeaderSpecInfiniteScroll: Story = {
args: {
baseUrl: 'https://utils.btsys.tech/api',
columnMap: {},
columnMap: {
name: 'logtype',
email: 'logmessage',
},
token: ' 773EB99C-F625-4E99-9DB9-CDDA7CA17639',
entity: 'synclog',
cursorField: 'id',
},
render: (args) => (

View File

@@ -206,6 +206,7 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
// Tree support: configure getSubRows for TanStack Table
...(tree?.enabled
? {
filterFromLeafRows: true,
getSubRows: (row: any) => {
const childrenField = (tree.childrenField as string) || 'children';
if (childrenField !== 'subRows' && row[childrenField]) {
@@ -274,6 +275,7 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
// Lazy tree expansion
useLazyTreeExpansion({
data: transformedData,
expanded,
setData,
setTreeChildrenCache,
setTreeLoadingNode,
@@ -284,6 +286,7 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
// Auto-expand on search
useAutoExpandOnSearch({
globalFilter,
table,
tree,
});

View File

@@ -82,16 +82,16 @@ export interface GriddyStoreState extends GriddyUIState {
setScrollRef: (el: HTMLDivElement | null) => void;
// ─── Internal ref setters ───
setTable: (table: Table<any>) => void;
setVirtualizer: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void;
setTreeChildrenCache: (nodeId: string, children: any[]) => void;
setTreeLoadingNode: (nodeId: string, loading: boolean) => void;
setVirtualizer: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void;
showToolbar?: boolean;
sorting?: SortingState;
// ─── Tree/Hierarchical Data ───
tree?: TreeConfig<any>;
treeLoadingNodes: Set<string>;
treeChildrenCache: Map<string, any[]>;
setTreeLoadingNode: (nodeId: string, loading: boolean) => void;
setTreeChildrenCache: (nodeId: string, children: any[]) => void;
treeLoadingNodes: Set<string>;
// ─── Synced from GriddyProps (written by $sync) ───
uniqueId?: string;
}
@@ -150,10 +150,12 @@ export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSync
setTable: (table) => set({ _table: table }),
setTotalRows: (count) => set({ totalRows: count }),
setVirtualizer: (virtualizer) => set({ _virtualizer: virtualizer }),
// ─── Tree State ───
treeLoadingNodes: new Set(),
treeChildrenCache: new Map(),
setTreeChildrenCache: (nodeId, children) =>
set((state) => {
const newMap = new Map(state.treeChildrenCache);
newMap.set(nodeId, children);
return { treeChildrenCache: newMap };
}),
setTreeLoadingNode: (nodeId, loading) =>
set((state) => {
const newSet = new Set(state.treeLoadingNodes);
@@ -164,12 +166,10 @@ export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSync
}
return { treeLoadingNodes: newSet };
}),
setTreeChildrenCache: (nodeId, children) =>
set((state) => {
const newMap = new Map(state.treeChildrenCache);
newMap.set(nodeId, children);
return { treeChildrenCache: newMap };
}),
setVirtualizer: (virtualizer) => set({ _virtualizer: virtualizer }),
// ─── Row Count ───
totalRows: 0,
treeChildrenCache: new Map(),
// ─── Tree State ───
treeLoadingNodes: new Set(),
}));

View File

@@ -305,49 +305,49 @@ export interface SelectionConfig {
// ─── Tree/Hierarchical Data ──────────────────────────────────────────────────
export interface TreeConfig<T> {
/** Enable tree/hierarchical data mode */
enabled: boolean;
/** Tree data mode. Default: 'nested' */
mode?: 'flat' | 'lazy' | 'nested';
// ─── Flat Mode ───
/** Field name for parent ID in flat mode. Default: 'parentId' */
parentIdField?: keyof T | string;
// ─── UI Configuration ───
/** Auto-expand parent nodes when search matches children. Default: true */
autoExpandOnSearch?: boolean;
// ─── Nested Mode ───
/** Field name for children array in nested mode. Default: 'children' */
childrenField?: keyof T | string;
// ─── Lazy Mode ───
/** Async function to fetch children for a parent node */
getChildren?: (parent: T) => Promise<T[]> | T[];
/** Function to determine if a node has children (for lazy mode) */
hasChildren?: (row: T) => boolean;
// ─── Expansion State ───
/** Default expanded state (record or array of IDs) */
defaultExpanded?: Record<string, boolean> | string[];
/** Enable tree/hierarchical data mode */
enabled: boolean;
/** Controlled expanded state */
expanded?: Record<string, boolean>;
/** Callback when expanded state changes */
onExpandedChange?: (expanded: Record<string, boolean>) => void;
// ─── Lazy Mode ───
/** Async function to fetch children for a parent node */
getChildren?: (parent: T) => Promise<T[]> | T[];
// ─── UI Configuration ───
/** Auto-expand parent nodes when search matches children. Default: true */
autoExpandOnSearch?: boolean;
/** Indentation size per depth level in pixels. Default: 20 */
indentSize?: number;
/** Maximum tree depth to render. Default: Infinity */
maxDepth?: number;
/** Show expand/collapse icon. Default: true */
showExpandIcon?: boolean;
/** Function to determine if a node has children (for lazy mode) */
hasChildren?: (row: T) => boolean;
/** Custom icons for tree states */
icons?: {
collapsed?: ReactNode;
expanded?: ReactNode;
leaf?: ReactNode;
};
/** Indentation size per depth level in pixels. Default: 20 */
indentSize?: number;
/** Maximum tree depth to render. Default: Infinity */
maxDepth?: number;
/** Tree data mode. Default: 'nested' */
mode?: 'flat' | 'lazy' | 'nested';
/** Callback when expanded state changes */
onExpandedChange?: (expanded: Record<string, boolean>) => void;
// ─── Flat Mode ───
/** Field name for parent ID in flat mode. Default: 'parentId' */
parentIdField?: keyof T | string;
/** Show expand/collapse icon. Default: true */
showExpandIcon?: boolean;
}
// ─── Re-exports for convenience ──────────────────────────────────────────────

View File

@@ -16,23 +16,6 @@ interface UseKeyboardNavigationOptions<TData = unknown> {
virtualizer: Virtualizer<HTMLDivElement, Element>
}
/**
* Helper to find parent row in tree structure
*/
function findParentRow<TData>(rows: any[], childRow: any): any | null {
const childIndex = rows.findIndex((r) => r.id === childRow.id);
if (childIndex === -1) return null;
const targetDepth = childRow.depth - 1;
// Search backwards from child position
for (let i = childIndex - 1; i >= 0; i--) {
if (rows[i].depth === targetDepth) {
return rows[i];
}
}
return null;
}
export function useKeyboardNavigation<TData = unknown>({
editingEnabled,
scrollRef,
@@ -307,3 +290,20 @@ export function useKeyboardNavigation<TData = unknown>({
return () => el.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown, scrollRef])
}
/**
* Helper to find parent row in tree structure
*/
function findParentRow<TData>(rows: any[], childRow: any): any | null {
const childIndex = rows.findIndex((r) => r.id === childRow.id);
if (childIndex === -1) return null;
const targetDepth = childRow.depth - 1;
// Search backwards from child position
for (let i = childIndex - 1; i >= 0; i--) {
if (rows[i].depth === targetDepth) {
return rows[i];
}
}
return null;
}

View File

@@ -33,7 +33,7 @@ export function GridToolbar<T>({
}
return (
<Group gap="xs" justify="flex-end" p="xs" style={{ borderBottom: '1px solid #e0e0e0' }}>
<Group gap="xs" justify="flex-end" p="xs" style={{ borderBottom: '1px solid var(--griddy-border-color, #e0e0e0)' }}>
{filterPresets && (
<FilterPresetsMenu persistenceKey={persistenceKey} table={table} />
)}

View File

@@ -1,18 +1,19 @@
import { Loader } from '@mantine/core';
import type { ReactNode } from 'react';
import { Loader } from '@mantine/core';
import styles from '../../styles/griddy.module.css';
interface TreeExpandButtonProps {
canExpand: boolean;
isExpanded: boolean;
isLoading?: boolean;
onToggle: () => void;
icons?: {
collapsed?: ReactNode;
expanded?: ReactNode;
leaf?: ReactNode;
};
isExpanded: boolean;
isLoading?: boolean;
onToggle: () => void;
}
const DEFAULT_ICONS = {
@@ -23,10 +24,10 @@ const DEFAULT_ICONS = {
export function TreeExpandButton({
canExpand,
icons = DEFAULT_ICONS,
isExpanded,
isLoading = false,
onToggle,
icons = DEFAULT_ICONS,
}: TreeExpandButtonProps) {
const displayIcons = { ...DEFAULT_ICONS, ...icons };

View File

@@ -1,9 +1,9 @@
export { TreeExpandButton } from './TreeExpandButton';
export {
hasChildren,
insertChildrenIntoData,
transformFlatToNested,
} from './transformTreeData';
export { TreeExpandButton } from './TreeExpandButton';
export { useAutoExpandOnSearch } from './useAutoExpandOnSearch';
export { useLazyTreeExpansion } from './useLazyTreeExpansion';
export { useTreeData } from './useTreeData';

View File

@@ -1,74 +1,5 @@
import type { TreeConfig } from '../../core/types';
/**
* Transforms flat data with parentId references into nested tree structure
* @param data - Flat array of data with parent references
* @param parentIdField - Field name containing parent ID (default: 'parentId')
* @param idField - Field name containing node ID (default: 'id')
* @param maxDepth - Maximum tree depth to build (default: Infinity)
* @returns Array of root nodes with subRows property
*/
export function transformFlatToNested<T extends Record<string, any>>(
data: T[],
parentIdField: keyof T | string = 'parentId',
idField: keyof T | string = 'id',
maxDepth = Infinity,
): T[] {
// Build a map of id -> node for quick lookups
const nodeMap = new Map<any, T & { subRows?: T[] }>();
const roots: (T & { subRows?: T[] })[] = [];
// First pass: create map of all nodes
data.forEach((item) => {
nodeMap.set(item[idField], { ...item, subRows: [] });
});
// Second pass: build tree structure
data.forEach((item) => {
const node = nodeMap.get(item[idField])!;
const parentId = item[parentIdField];
if (parentId == null || parentId === '') {
// Root node (no parent or empty parent)
roots.push(node);
} else {
const parent = nodeMap.get(parentId);
if (parent) {
// Add to parent's children
if (!parent.subRows) {
parent.subRows = [];
}
parent.subRows.push(node);
} else {
// Orphaned node (parent doesn't exist) - treat as root
roots.push(node);
}
}
});
// Enforce max depth by removing children beyond the limit
if (maxDepth !== Infinity) {
const enforceDepth = (nodes: (T & { subRows?: T[] })[], currentDepth: number) => {
if (currentDepth >= maxDepth) {
return;
}
nodes.forEach((node) => {
if (node.subRows && node.subRows.length > 0) {
if (currentDepth + 1 >= maxDepth) {
// Remove children at max depth
delete node.subRows;
} else {
enforceDepth(node.subRows, currentDepth + 1);
}
}
});
};
enforceDepth(roots, 0);
}
return roots;
}
/**
* Determines if a node has children (can be expanded)
* @param row - The data row
@@ -136,3 +67,72 @@ export function insertChildrenIntoData<T extends Record<string, any>>(
return item;
});
}
/**
* Transforms flat data with parentId references into nested tree structure
* @param data - Flat array of data with parent references
* @param parentIdField - Field name containing parent ID (default: 'parentId')
* @param idField - Field name containing node ID (default: 'id')
* @param maxDepth - Maximum tree depth to build (default: Infinity)
* @returns Array of root nodes with subRows property
*/
export function transformFlatToNested<T extends Record<string, any>>(
data: T[],
parentIdField: keyof T | string = 'parentId',
idField: keyof T | string = 'id',
maxDepth = Infinity,
): T[] {
// Build a map of id -> node for quick lookups
const nodeMap = new Map<any, { subRows?: T[] } & T>();
const roots: ({ subRows?: T[] } & T)[] = [];
// First pass: create map of all nodes
data.forEach((item) => {
nodeMap.set(item[idField], { ...item, subRows: [] });
});
// Second pass: build tree structure
data.forEach((item) => {
const node = nodeMap.get(item[idField])!;
const parentId = item[parentIdField];
if (parentId == null || parentId === '') {
// Root node (no parent or empty parent)
roots.push(node);
} else {
const parent = nodeMap.get(parentId);
if (parent) {
// Add to parent's children
if (!parent.subRows) {
parent.subRows = [];
}
parent.subRows.push(node);
} else {
// Orphaned node (parent doesn't exist) - treat as root
roots.push(node);
}
}
});
// Enforce max depth by removing children beyond the limit
if (maxDepth !== Infinity) {
const enforceDepth = (nodes: ({ subRows?: T[] } & T)[], currentDepth: number) => {
if (currentDepth >= maxDepth) {
return;
}
nodes.forEach((node) => {
if (node.subRows && node.subRows.length > 0) {
if (currentDepth + 1 >= maxDepth) {
// Remove children at max depth
delete node.subRows;
} else {
enforceDepth(node.subRows, currentDepth + 1);
}
}
});
};
enforceDepth(roots, 0);
}
return roots;
}

View File

@@ -1,40 +1,22 @@
import type { Table } from '@tanstack/react-table';
import { useEffect, useRef } from 'react';
import type { TreeConfig } from '../../core/types';
/**
* Helper to find all ancestor rows for a given row
*/
function findAncestors<TData>(rows: any[], targetRow: any): any[] {
const ancestors: any[] = [];
let currentDepth = targetRow.depth;
const targetIndex = rows.findIndex((r) => r.id === targetRow.id);
if (targetIndex === -1) return ancestors;
// Walk backwards to find all ancestors
for (let i = targetIndex - 1; i >= 0 && currentDepth > 0; i--) {
if (rows[i].depth === currentDepth - 1) {
ancestors.unshift(rows[i]);
currentDepth = rows[i].depth;
}
}
return ancestors;
}
interface UseAutoExpandOnSearchOptions<TData> {
tree?: TreeConfig<TData>;
globalFilter?: string;
table: Table<TData>;
tree?: TreeConfig<TData>;
}
/**
* Hook to auto-expand parent nodes when search matches child nodes
*/
export function useAutoExpandOnSearch<TData>({
tree,
globalFilter: globalFilterProp,
table,
tree,
}: UseAutoExpandOnSearchOptions<TData>) {
const previousFilterRef = useRef<string | undefined>(undefined);
const previousExpandedRef = useRef<Record<string, boolean>>({});
@@ -53,8 +35,7 @@ export function useAutoExpandOnSearch<TData>({
// If filter was cleared, optionally restore previous expanded state
if (!globalFilter && previousFilter) {
// Filter was cleared - could restore previous state here if config option added
// For now, just leave expanded state as-is
// Filter was cleared - leave expanded state as-is
return;
}
@@ -63,23 +44,30 @@ export function useAutoExpandOnSearch<TData>({
return;
}
// Get filtered rows
const filteredRows = table.getFilteredRowModel().rows;
// Use flatRows to get all rows at all depths in the filtered model
const filteredFlatRows = table.getFilteredRowModel().flatRows;
if (filteredRows.length === 0) {
if (filteredFlatRows.length === 0) {
return;
}
// Build set of all ancestors that should be expanded
const toExpand: Record<string, boolean> = {};
filteredRows.forEach((row) => {
// If row has depth > 0, it's a child node - expand all ancestors
// Build a lookup map from flatRows for fast parent resolution
const rowById = new Map<string, (typeof filteredFlatRows)[0]>();
filteredFlatRows.forEach((row) => rowById.set(row.id, row));
filteredFlatRows.forEach((row) => {
// If row has depth > 0, walk up parent chain and expand all ancestors
if (row.depth > 0) {
const ancestors = findAncestors(table.getRowModel().rows, row);
ancestors.forEach((ancestor) => {
toExpand[ancestor.id] = true;
});
let current = row;
while (current.parentId) {
toExpand[current.parentId] = true;
const parent = rowById.get(current.parentId);
if (!parent) break;
current = parent;
}
}
});
@@ -95,5 +83,5 @@ export function useAutoExpandOnSearch<TData>({
}
table.setExpanded(newExpanded);
}
}, [tree, table]);
}, [tree, table, globalFilterProp]);
}

View File

@@ -1,4 +1,5 @@
import type { Table } from '@tanstack/react-table';
import { useEffect, useRef } from 'react';
import type { TreeConfig } from '../../core/types';
@@ -6,12 +7,13 @@ import type { TreeConfig } from '../../core/types';
import { insertChildrenIntoData } from './transformTreeData';
interface UseLazyTreeExpansionOptions<T> {
tree?: TreeConfig<T>;
table: Table<T>;
data: T[];
expanded: Record<string, boolean> | true;
setData: (data: T[]) => void;
setTreeLoadingNode: (nodeId: string, loading: boolean) => void;
setTreeChildrenCache: (nodeId: string, children: T[]) => void;
setTreeLoadingNode: (nodeId: string, loading: boolean) => void;
table: Table<T>;
tree?: TreeConfig<T>;
treeChildrenCache: Map<string, T[]>;
}
@@ -19,12 +21,13 @@ interface UseLazyTreeExpansionOptions<T> {
* Hook to handle lazy loading of tree children when nodes are expanded
*/
export function useLazyTreeExpansion<T extends Record<string, any>>({
tree,
table,
data,
expanded: expandedState,
setData,
setTreeLoadingNode,
setTreeChildrenCache,
setTreeLoadingNode,
table,
tree,
treeChildrenCache,
}: UseLazyTreeExpansionOptions<T>) {
const expandedRef = useRef<Record<string, boolean>>({});
@@ -35,7 +38,7 @@ export function useLazyTreeExpansion<T extends Record<string, any>>({
return;
}
const expanded = table.getState().expanded;
const expanded = typeof expandedState === 'object' ? expandedState : {};
const previousExpanded = expandedRef.current;
// Find newly expanded nodes
@@ -91,6 +94,7 @@ export function useLazyTreeExpansion<T extends Record<string, any>>({
}, [
tree,
table,
expandedState,
data,
setData,
setTreeLoadingNode,

View File

@@ -22,6 +22,20 @@ export function useTreeData<T extends Record<string, any>>(
const mode = tree.mode || 'nested';
switch (mode) {
case 'flat': {
// Transform flat data with parentId to nested structure
const parentIdField = tree.parentIdField || 'parentId';
const idField = 'id'; // Assume 'id' field exists
const maxDepth = tree.maxDepth || Infinity;
return transformFlatToNested(data, parentIdField, idField, maxDepth);
}
case 'lazy': {
// Lazy mode: data is already structured, children loaded on-demand
// Just return data as-is, lazy loading hook will handle expansion
return data;
}
case 'nested': {
// If childrenField is not 'subRows', map it
const childrenField = (tree.childrenField as string) || 'children';
@@ -43,20 +57,6 @@ export function useTreeData<T extends Record<string, any>>(
});
}
case 'flat': {
// Transform flat data with parentId to nested structure
const parentIdField = tree.parentIdField || 'parentId';
const idField = 'id'; // Assume 'id' field exists
const maxDepth = tree.maxDepth || Infinity;
return transformFlatToNested(data, parentIdField, idField, maxDepth);
}
case 'lazy': {
// Lazy mode: data is already structured, children loaded on-demand
// Just return data as-is, lazy loading hook will handle expansion
return data;
}
default:
return data;
}

View File

@@ -1261,6 +1261,12 @@ The grid follows WAI-ARIA grid pattern:
## Phase 10: Future Enhancements
### Bug Fixes
- [ ] **Unique Row ID** - Add a unique row ID to the data if no uniqueId is provided.
- [ ] **Tree row selection** - The tree row selection breaks, it selects the same item. Suspect the uniqueId
- [ ] **Infinite scroll** - Header Spec infinite scroll server side filtering and sorting not working
### Data & State Management
- [ ] **Column layout persistence** - Save/restore column order, widths, visibility to localStorage

View File

@@ -65,9 +65,12 @@ export function TableCell<T>({ cell, showGrouping }: TableCellProps<T>) {
// Tree support
const depth = cell.row.depth;
const canExpand = cell.row.getCanExpand();
const canExpand =
cell.row.getCanExpand() ||
(tree?.enabled && tree?.mode === 'lazy' && tree?.hasChildren?.(cell.row.original as any)) ||
false;
const isExpanded = cell.row.getIsExpanded();
const hasSelection = selection?.mode !== 'none';
const hasSelection = selection != null && selection.mode !== 'none';
const columnIndex = cell.column.getIndex();
// First content column is index 0 if no selection, or index 1 if selection enabled
const isFirstColumn = hasSelection ? columnIndex === 1 : columnIndex === 0;

View File

@@ -581,3 +581,74 @@
.griddy-row--tree-depth-4 .griddy-cell:first-child {
border-left: 2px solid var(--mantine-color-grape-3, #da77f2);
}
/* ─── Dark Mode ──────────────────────────────────────────────────────── */
:global([data-mantine-color-scheme="dark"]) .griddy {
--griddy-border-color: #373a40;
--griddy-header-bg: #25262b;
--griddy-header-color: #c1c2c5;
--griddy-row-bg: #1a1b1e;
--griddy-row-hover-bg: #25262b;
--griddy-row-even-bg: #1a1b1e;
--griddy-focus-color: #339af0;
--griddy-selection-bg: rgba(51, 154, 240, 0.15);
--griddy-search-bg: #25262b;
--griddy-search-border: #373a40;
}
:global([data-mantine-color-scheme="dark"]) .griddy-header-cell--sortable:hover {
background: rgba(255, 255, 255, 0.04);
}
:global([data-mantine-color-scheme="dark"]) .griddy-row--selected:hover {
background-color: rgba(51, 154, 240, 0.2);
}
:global([data-mantine-color-scheme="dark"]) .griddy-search-overlay {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
:global([data-mantine-color-scheme="dark"]) .griddy-search-input:focus {
box-shadow: 0 0 0 2px rgba(51, 154, 240, 0.25);
}
:global([data-mantine-color-scheme="dark"]) .griddy-error {
background: #2c2e33;
border-color: #e03131;
}
:global([data-mantine-color-scheme="dark"]) .griddy-error-message {
color: #ff6b6b;
}
:global([data-mantine-color-scheme="dark"]) .griddy-error-detail {
color: #909296;
}
:global([data-mantine-color-scheme="dark"]) .griddy-skeleton-bar {
background: linear-gradient(90deg, #373a40 25%, #2c2e33 50%, #373a40 75%);
background-size: 200% 100%;
}
:global([data-mantine-color-scheme="dark"]) .griddy-loading-overlay {
background: rgba(0, 0, 0, 0.4);
}
:global([data-mantine-color-scheme="dark"]) .griddy-renderer-progress {
background: #373a40;
}
:global([data-mantine-color-scheme="dark"]) .griddy-renderer-progress-label {
color: #c1c2c5;
}
:global([data-mantine-color-scheme="dark"]) .griddy-header-cell--pinned-left,
:global([data-mantine-color-scheme="dark"]) .griddy-cell--pinned-left {
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.3);
}
:global([data-mantine-color-scheme="dark"]) .griddy-header-cell--pinned-right,
:global([data-mantine-color-scheme="dark"]) .griddy-cell--pinned-right {
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.3);
}

View File

@@ -0,0 +1,815 @@
import { expect, test } from '@playwright/test';
// Helper to navigate to a story inside the Storybook iframe
async function gotoStory(page: any, storyId: string) {
await page.goto(`/iframe.html?id=components-griddy--${storyId}&viewMode=story`);
await page.waitForSelector('[role="grid"]', { timeout: 10000 });
}
// ─── 1. Basic Rendering ────────────────────────────────────────────────────────
test.describe('Basic Rendering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'basic');
});
test('renders the grid with rows and columns', async ({ page }) => {
await expect(page.locator('[role="grid"]')).toBeVisible();
// Header row should exist
const headers = page.locator('[role="columnheader"]');
await expect(headers.first()).toBeVisible();
// Should have all 9 column headers
await expect(headers).toHaveCount(9);
});
test('renders correct column headers', async ({ page }) => {
await expect(page.locator('[role="columnheader"]', { hasText: 'ID' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'First Name' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Last Name' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Age' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Department' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Salary' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Start Date' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Active' })).toBeVisible();
});
test('renders data rows', async ({ page }) => {
// Should render multiple data rows (20 in small dataset)
const rows = page.locator('[role="row"]');
// At least header + some data rows
const count = await rows.count();
expect(count).toBeGreaterThan(1);
});
test('clicking a column header sorts data', async ({ page }) => {
const idHeader = page.locator('[role="columnheader"]', { hasText: 'ID' });
await idHeader.click();
// After clicking, should have a sort indicator
const sortAttr = await idHeader.getAttribute('aria-sort');
expect(sortAttr).toBeTruthy();
expect(['ascending', 'descending']).toContain(sortAttr);
});
test('clicking column header twice reverses sort', async ({ page }) => {
const idHeader = page.locator('[role="columnheader"]', { hasText: 'ID' });
await idHeader.click();
const firstSort = await idHeader.getAttribute('aria-sort');
await idHeader.click();
const secondSort = await idHeader.getAttribute('aria-sort');
expect(firstSort).not.toEqual(secondSort);
});
});
// ─── 2. Large Dataset (Virtualization) ─────────────────────────────────────────
test.describe('Large Dataset', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'large-dataset');
});
test('renders without crashing on 10k rows', async ({ page }) => {
await expect(page.locator('[role="grid"]')).toBeVisible();
const rows = page.locator('[role="row"]');
const count = await rows.count();
// Virtualized: should render far fewer rows than 10,000
expect(count).toBeGreaterThan(1);
expect(count).toBeLessThan(200);
});
test('grid aria-rowcount reflects total data size', async ({ page }) => {
const grid = page.locator('[role="grid"]');
const rowCount = await grid.getAttribute('aria-rowcount');
expect(Number(rowCount)).toBe(10000);
});
});
// ─── 3. Single Selection ────────────────────────────────────────────────────────
test.describe('Single Selection', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'single-selection');
});
test('renders checkboxes in each row', async ({ page }) => {
const checkboxes = page.locator('[role="row"] input[type="checkbox"]');
await expect(checkboxes.first()).toBeVisible({ timeout: 5000 });
const count = await checkboxes.count();
expect(count).toBeGreaterThan(0);
});
test('clicking a row selects it', async ({ page }) => {
const firstDataRow = page.locator('[role="row"]').nth(1);
await firstDataRow.click();
await page.waitForTimeout(300);
// Selection state should show in the debug output
const selectionText = page.locator('text=Selected:');
await expect(selectionText).toBeVisible();
});
test('clicking another row deselects previous in single mode', async ({ page }) => {
// Click first data row
const firstDataRow = page.locator('[role="row"]').nth(1);
await firstDataRow.click();
await page.waitForTimeout(300);
// Click second data row
const secondDataRow = page.locator('[role="row"]').nth(2);
await secondDataRow.click();
await page.waitForTimeout(300);
// In single selection, only one should be selected
const checkboxes = page.locator('[role="row"] input[type="checkbox"]:checked');
const checkedCount = await checkboxes.count();
expect(checkedCount).toBeLessThanOrEqual(1);
});
});
// ─── 4. Multi Selection ─────────────────────────────────────────────────────────
test.describe('Multi Selection', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'multi-selection');
});
test('renders select-all checkbox in header', async ({ page }) => {
const selectAllCheckbox = page.locator('[aria-label="Select all rows"]');
await expect(selectAllCheckbox).toBeVisible({ timeout: 5000 });
});
test('selecting multiple rows shows count', async ({ page }) => {
// Click first data row
await page.locator('[role="row"]').nth(1).click();
await page.waitForTimeout(200);
// Shift-click third row to extend selection
await page.locator('[role="row"]').nth(3).click({ modifiers: ['Shift'] });
await page.waitForTimeout(300);
// The selected count should show in the debug output
const countText = page.locator('text=/Selected \\(\\d+ rows\\)/');
await expect(countText).toBeVisible({ timeout: 3000 });
});
test('select-all checkbox selects all rows', async ({ page }) => {
const selectAll = page.locator('[aria-label="Select all rows"]');
await selectAll.click();
await page.waitForTimeout(300);
// Should show all 20 rows selected
await expect(page.locator('text=/Selected \\(20 rows\\)/')).toBeVisible({ timeout: 3000 });
});
});
// ─── 5. Search ──────────────────────────────────────────────────────────────────
test.describe('Search', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-search');
});
test('Ctrl+F opens search overlay', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
});
test('search filters rows', async ({ page }) => {
const initialRowCount = await page.locator('[role="row"]').count();
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
const searchInput = page.locator('[aria-label="Search grid"]');
await searchInput.fill('Alice');
await page.waitForTimeout(500);
const filteredRowCount = await page.locator('[role="row"]').count();
expect(filteredRowCount).toBeLessThan(initialRowCount);
});
test('Escape closes search overlay', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
await page.keyboard.press('Escape');
await expect(page.locator('[aria-label="Search grid"]')).not.toBeVisible({ timeout: 3000 });
});
test('clearing search restores all rows', async ({ page }) => {
const initialRowCount = await page.locator('[role="row"]').count();
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
const searchInput = page.locator('[aria-label="Search grid"]');
await searchInput.fill('Alice');
await page.waitForTimeout(500);
// Clear the search
await searchInput.fill('');
await page.waitForTimeout(500);
const restoredRowCount = await page.locator('[role="row"]').count();
expect(restoredRowCount).toBeGreaterThanOrEqual(initialRowCount);
});
});
// ─── 6. Keyboard Navigation ────────────────────────────────────────────────────
test.describe('Keyboard Navigation', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'keyboard-navigation');
});
test('ArrowDown moves focus to next row', async ({ page }) => {
// Focus the grid container
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
// Grid should still have focus (not lost)
await expect(page.locator('[role="grid"]')).toBeVisible();
});
test('Space toggles row selection', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
// Move to first data row
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Space');
await page.waitForTimeout(300);
// Should show "Selected: 1 rows" in the debug text
await expect(page.locator('text=/Selected: \\d+ rows/')).toBeVisible({ timeout: 3000 });
});
test('Ctrl+A selects all rows', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+a');
await page.waitForTimeout(300);
await expect(page.locator('text=/Selected: 20 rows/')).toBeVisible({ timeout: 3000 });
});
test('Ctrl+F opens search from keyboard nav story', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
});
});
// ─── 7. Inline Editing ──────────────────────────────────────────────────────────
test.describe('Inline Editing', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-inline-editing');
});
test('double-click on editable cell enters edit mode', async ({ page }) => {
// Find a First Name cell (column index 1, since ID is 0)
const firstNameCell = page.locator('[role="row"]').nth(1).locator('[role="gridcell"]').nth(1);
await firstNameCell.dblclick();
// An input should appear
const input = page.locator('[role="row"]').nth(1).locator('input');
await expect(input).toBeVisible({ timeout: 3000 });
});
test('Enter commits the edit', async ({ page }) => {
const firstNameCell = page.locator('[role="row"]').nth(1).locator('[role="gridcell"]').nth(1);
const originalText = await firstNameCell.innerText();
await firstNameCell.dblclick();
const input = page.locator('[role="row"]').nth(1).locator('input').first();
await expect(input).toBeVisible({ timeout: 3000 });
await input.fill('TestName');
await page.keyboard.press('Enter');
await page.waitForTimeout(300);
// The cell text should now show the new value
const updatedText = await firstNameCell.innerText();
expect(updatedText).toContain('TestName');
});
test('Escape cancels the edit', async ({ page }) => {
const firstNameCell = page.locator('[role="row"]').nth(1).locator('[role="gridcell"]').nth(1);
const originalText = await firstNameCell.innerText();
await firstNameCell.dblclick();
const input = page.locator('[role="row"]').nth(1).locator('input').first();
await expect(input).toBeVisible({ timeout: 3000 });
await input.fill('CancelledValue');
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
// The cell should revert to original value
const restoredText = await firstNameCell.innerText();
expect(restoredText).toBe(originalText);
});
});
// ─── 8. Client-Side Pagination ──────────────────────────────────────────────────
test.describe('Client-Side Pagination', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-client-side-pagination');
});
test('renders pagination controls', async ({ page }) => {
await expect(page.locator('text=Page 1 of')).toBeVisible({ timeout: 5000 });
await expect(page.locator('text=Rows per page:')).toBeVisible();
});
test('shows correct initial page info', async ({ page }) => {
// 10,000 rows / 25 per page = 400 pages
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
});
test('next page button navigates forward', async ({ page }) => {
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
// Click the "next page" button (single chevron right)
const nextPageBtn = page.locator('[class*="griddy-pagination"] button').nth(2);
await nextPageBtn.click();
await page.waitForTimeout(300);
await expect(page.locator('text=Page 2 of 400')).toBeVisible();
});
test('last page button navigates to end', async ({ page }) => {
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
// Click the "last page" button (double chevron right)
const lastPageBtn = page.locator('[class*="griddy-pagination"] button').nth(3);
await lastPageBtn.click();
await page.waitForTimeout(300);
await expect(page.locator('text=Page 400 of 400')).toBeVisible();
});
test('first page button is disabled on first page', async ({ page }) => {
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
// First page button (double chevron left) should be disabled
const firstPageBtn = page.locator('[class*="griddy-pagination"] button').first();
await expect(firstPageBtn).toBeDisabled();
});
test('page size selector changes rows per page', async ({ page }) => {
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
// Click the page size select (Mantine Select renders as textbox)
const pageSizeSelect = page.getByRole('textbox');
await pageSizeSelect.click();
// Select 50 rows per page
await page.locator('[role="option"]', { hasText: '50' }).click();
await page.waitForTimeout(300);
// 10,000 / 50 = 200 pages
await expect(page.locator('text=Page 1 of 200')).toBeVisible({ timeout: 3000 });
});
});
// ─── 9. Server-Side Pagination ──────────────────────────────────────────────────
test.describe('Server-Side Pagination', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-server-side-pagination');
});
test('renders data after initial load', async ({ page }) => {
// Wait for server-side data to load (300ms delay)
await expect(page.locator('text=Displayed Rows: 25')).toBeVisible({ timeout: 5000 });
const rows = page.locator('[role="row"]');
const count = await rows.count();
expect(count).toBeGreaterThan(1);
});
test('shows server state info', async ({ page }) => {
await expect(page.locator('text=Total Rows: 10000')).toBeVisible({ timeout: 5000 });
await expect(page.locator('text=Displayed Rows: 25')).toBeVisible();
});
test('navigating pages updates server state', async ({ page }) => {
await expect(page.locator('text=Current Page: 1')).toBeVisible({ timeout: 5000 });
// Navigate to next page
const nextPageBtn = page.locator('[class*="griddy-pagination"] button').nth(2);
await nextPageBtn.click();
await expect(page.locator('text=Current Page: 2')).toBeVisible({ timeout: 5000 });
});
});
// ─── 10. Toolbar ────────────────────────────────────────────────────────────────
test.describe('Toolbar', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-toolbar');
});
test('renders export button', async ({ page }) => {
const exportBtn = page.locator('[aria-label="Export to CSV"]');
await expect(exportBtn).toBeVisible({ timeout: 5000 });
});
test('renders column toggle button', async ({ page }) => {
const columnToggleBtn = page.locator('[aria-label="Toggle columns"]');
await expect(columnToggleBtn).toBeVisible({ timeout: 5000 });
});
test('column toggle menu shows all columns', async ({ page }) => {
await page.locator('[aria-label="Toggle columns"]').click();
await expect(page.locator('text=Toggle Columns')).toBeVisible({ timeout: 3000 });
// Should show checkboxes for each column
const checkboxes = page.locator('.mantine-Menu-dropdown input[type="checkbox"]');
const count = await checkboxes.count();
expect(count).toBeGreaterThanOrEqual(8);
});
test('toggling column visibility hides a column', async ({ page }) => {
// Verify "Email" header is initially visible
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).toBeVisible({ timeout: 5000 });
// Open column toggle menu
await page.locator('[aria-label="Toggle columns"]').click();
await expect(page.locator('text=Toggle Columns')).toBeVisible({ timeout: 3000 });
// Uncheck the "Email" column
const emailCheckbox = page.locator('.mantine-Checkbox-root', { hasText: 'Email' }).locator('input[type="checkbox"]');
await emailCheckbox.click();
await page.waitForTimeout(300);
// Close the menu by clicking elsewhere
await page.locator('[role="grid"]').click();
await page.waitForTimeout(300);
// Email header should now be hidden
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).not.toBeVisible();
});
});
// ─── 11. Column Pinning ─────────────────────────────────────────────────────────
test.describe('Column Pinning', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-column-pinning');
});
test('renders grid with pinned columns', async ({ page }) => {
// ID and First Name should be visible (pinned left)
await expect(page.locator('[role="columnheader"]', { hasText: 'ID' })).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="columnheader"]', { hasText: 'First Name' })).toBeVisible();
// Active should be visible (pinned right)
await expect(page.locator('[role="columnheader"]', { hasText: 'Active' })).toBeVisible();
});
test('all column headers render', async ({ page }) => {
const headers = page.locator('[role="columnheader"]');
const count = await headers.count();
expect(count).toBeGreaterThanOrEqual(8);
});
});
// ─── 12. Header Grouping ────────────────────────────────────────────────────────
test.describe('Header Grouping', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-header-grouping');
});
test('renders group headers', async ({ page }) => {
await expect(page.locator('[role="columnheader"]', { hasText: 'Personal Info' })).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="columnheader"]', { hasText: 'Contact' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Employment' })).toBeVisible();
});
test('renders child column headers under groups', async ({ page }) => {
await expect(page.locator('[role="columnheader"]', { hasText: 'First Name' })).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="columnheader"]', { hasText: 'Last Name' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Age' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Department' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Salary' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Start Date' })).toBeVisible();
});
test('has multiple header rows', async ({ page }) => {
// Should have at least 2 header rows (group + column)
const headerRows = page.locator('[role="row"]').filter({ has: page.locator('[role="columnheader"]') });
const count = await headerRows.count();
expect(count).toBeGreaterThanOrEqual(2);
});
});
// ─── 13. Data Grouping ──────────────────────────────────────────────────────────
test.describe('Data Grouping', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-data-grouping');
});
test('renders grouped rows by department', async ({ page }) => {
// Should show department names as group headers
await expect(page.locator('[role="row"]', { hasText: 'Engineering' })).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="row"]', { hasText: 'Marketing' })).toBeVisible();
});
test('expanding a group shows its members', async ({ page }) => {
// Click on Engineering group to expand it
const engineeringRow = page.locator('[role="row"]', { hasText: 'Engineering' });
await engineeringRow.locator('button').first().click();
await page.waitForTimeout(300);
// Should show individual people from Engineering department
// From generateData: index 0 is Engineering (Alice Smith)
const rows = page.locator('[role="row"]');
const count = await rows.count();
// After expanding one group, should have more rows visible
expect(count).toBeGreaterThan(8); // 8 department groups
});
});
// ─── 14. Column Reordering ──────────────────────────────────────────────────────
test.describe('Column Reordering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-column-reordering');
});
test('renders all column headers', async ({ page }) => {
await expect(page.locator('[role="columnheader"]', { hasText: 'ID' })).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="columnheader"]', { hasText: 'First Name' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Last Name' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).toBeVisible();
});
test('column headers have draggable attribute', async ({ page }) => {
// Column headers should be draggable for reordering
const headers = page.locator('[role="columnheader"][draggable="true"]');
const count = await headers.count();
// At least some headers should be draggable (selection column is excluded)
expect(count).toBeGreaterThan(0);
});
});
// ─── 15. Infinite Scroll ────────────────────────────────────────────────────────
test.describe('Infinite Scroll', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-infinite-scroll');
});
test('renders initial batch of rows', async ({ page }) => {
// Initial batch is 50 rows
await expect(page.locator('text=Current: 50 rows')).toBeVisible({ timeout: 5000 });
});
test('scrolling to bottom loads more data', async ({ page }) => {
await expect(page.locator('text=Current: 50 rows')).toBeVisible({ timeout: 5000 });
// Scroll to bottom of the grid container
const container = page.locator('[role="grid"] [tabindex="0"]');
await container.click();
// Use End key to jump to last row, triggering infinite scroll
await page.keyboard.press('End');
await page.waitForTimeout(2000); // Wait for the 1000ms load delay + buffer
// Should now have more than 50 rows
await expect(page.locator('text=Current: 100 rows')).toBeVisible({ timeout: 5000 });
});
});
// ─── 16. Text Filtering ─────────────────────────────────────────────────────────
test.describe('Text Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-text-filtering');
});
test('filterable columns show filter icon', async ({ page }) => {
const firstNameHeader = page.locator('[role="columnheader"]').filter({ hasText: 'First Name' });
await expect(firstNameHeader).toBeVisible({ timeout: 5000 });
const filterIcon = firstNameHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('opening filter popover shows text filter UI', async ({ page }) => {
const firstNameHeader = page.locator('[role="columnheader"]').filter({ hasText: 'First Name' });
const filterIcon = firstNameHeader.locator('[aria-label="Open column filter"]');
await filterIcon.click();
await expect(page.locator('text=Filter: firstName')).toBeVisible({ timeout: 5000 });
});
test('non-filterable columns have no filter icon', async ({ page }) => {
const idHeader = page.locator('[role="columnheader"]').filter({ hasText: 'ID' });
await expect(idHeader).toBeVisible({ timeout: 5000 });
const filterIcon = idHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).not.toBeVisible();
});
});
// ─── 17. Number Filtering ───────────────────────────────────────────────────────
test.describe('Number Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-number-filtering');
});
test('age column has filter icon', async ({ page }) => {
const ageHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Age' });
await expect(ageHeader).toBeVisible({ timeout: 5000 });
const filterIcon = ageHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('salary column has filter icon', async ({ page }) => {
const salaryHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Salary' });
await expect(salaryHeader).toBeVisible({ timeout: 5000 });
const filterIcon = salaryHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('opening number filter shows numeric filter UI', async ({ page }) => {
const ageHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Age' });
const filterIcon = ageHeader.locator('[aria-label="Open column filter"]');
await filterIcon.click();
await expect(page.locator('text=Filter: age')).toBeVisible({ timeout: 5000 });
});
});
// ─── 18. Enum Filtering ─────────────────────────────────────────────────────────
test.describe('Enum Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-enum-filtering');
});
test('department column has filter icon', async ({ page }) => {
const deptHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Department' });
await expect(deptHeader).toBeVisible({ timeout: 5000 });
const filterIcon = deptHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('opening enum filter shows the filter popover', async ({ page }) => {
const deptHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Department' });
const filterIcon = deptHeader.locator('[aria-label="Open column filter"]');
await filterIcon.click();
await expect(page.locator('text=Filter: department')).toBeVisible({ timeout: 5000 });
});
});
// ─── 19. Boolean Filtering ──────────────────────────────────────────────────────
test.describe('Boolean Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-boolean-filtering');
});
test('active column has filter icon', async ({ page }) => {
const activeHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Active' });
await expect(activeHeader).toBeVisible({ timeout: 5000 });
const filterIcon = activeHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('opening boolean filter shows the filter popover', async ({ page }) => {
const activeHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Active' });
const filterIcon = activeHeader.locator('[aria-label="Open column filter"]');
await filterIcon.click();
await expect(page.locator('text=Filter: active')).toBeVisible({ timeout: 5000 });
});
});
// ─── 20. Date Filtering ─────────────────────────────────────────────────────────
test.describe('Date Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-date-filtering');
});
test('start date column has filter icon', async ({ page }) => {
const dateHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Start Date' });
await expect(dateHeader).toBeVisible({ timeout: 5000 });
const filterIcon = dateHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('opening date filter shows the filter popover', async ({ page }) => {
const dateHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Start Date' });
const filterIcon = dateHeader.locator('[aria-label="Open column filter"]');
await filterIcon.click();
await expect(page.locator('text=Filter: startDate')).toBeVisible({ timeout: 5000 });
});
});
// ─── 21. All Filter Types ───────────────────────────────────────────────────────
test.describe('All Filter Types', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-all-filter-types');
});
test('all filterable columns show filter icons', async ({ page }) => {
const filterableColumns = ['First Name', 'Last Name', 'Age', 'Department', 'Start Date', 'Active'];
for (const colName of filterableColumns) {
const header = page.locator('[role="columnheader"]').filter({ hasText: colName });
await expect(header).toBeVisible({ timeout: 5000 });
const filterIcon = header.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
}
});
test('non-filterable columns have no filter icon', async ({ page }) => {
const nonFilterableColumns = ['ID', 'Email', 'Salary'];
for (const colName of nonFilterableColumns) {
const header = page.locator('[role="columnheader"]').filter({ hasText: colName });
await expect(header).toBeVisible({ timeout: 5000 });
const filterIcon = header.locator('[aria-label="Open column filter"]');
await expect(filterIcon).not.toBeVisible();
}
});
});
// ─── 22. Server-Side Filtering & Sorting ────────────────────────────────────────
test.describe('Server-Side Filtering & Sorting', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'server-side-filtering-sorting');
});
test('renders data from simulated server', async ({ page }) => {
// Wait for server data to load (300ms simulated delay)
await expect(page.locator('text=Loading: false')).toBeVisible({ timeout: 5000 });
const rows = page.locator('[role="row"]');
const count = await rows.count();
expect(count).toBeGreaterThan(1);
});
test('shows server-side mode indicator', async ({ page }) => {
await expect(page.locator('text=Server-Side Mode:')).toBeVisible({ timeout: 5000 });
});
test('sorting updates server state', async ({ page }) => {
await expect(page.locator('[role="grid"]')).toBeVisible({ timeout: 5000 });
// Wait for initial load
await page.waitForTimeout(500);
// Click First Name header to sort
const firstNameHeader = page.locator('[role="columnheader"]', { hasText: 'First Name' });
await firstNameHeader.click();
await page.waitForTimeout(500);
// Active Sorting section should now show sorting state
await expect(page.locator('text=Active Sorting:')).toBeVisible();
});
});
// ─── 23. Large Dataset with Filtering ───────────────────────────────────────────
test.describe('Large Dataset with Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'large-dataset-with-filtering');
});
test('renders large dataset with filter columns', async ({ page }) => {
await expect(page.locator('[role="grid"]')).toBeVisible({ timeout: 5000 });
const rows = page.locator('[role="row"]');
const count = await rows.count();
expect(count).toBeGreaterThan(1);
});
test('all expected filter columns have filter icons', async ({ page }) => {
const filterableColumns = ['First Name', 'Last Name', 'Age', 'Department', 'Start Date', 'Active'];
for (const colName of filterableColumns) {
const header = page.locator('[role="columnheader"]').filter({ hasText: colName });
await expect(header).toBeVisible({ timeout: 5000 });
const filterIcon = header.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
}
});
});

View File

@@ -211,11 +211,14 @@ test.describe('Tree with Search Auto-Expand', () => {
});
test('Ctrl+F opens search overlay', async ({ page }) => {
// Focus the grid scroll container before pressing keyboard shortcut
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
});
test('searching for a leaf node auto-expands ancestors', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
const searchInput = page.locator('[aria-label="Search grid"]');
await searchInput.fill('Alice');
@@ -230,6 +233,7 @@ test.describe('Tree with Search Auto-Expand', () => {
});
test('clearing search preserves expanded state', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
const searchInput = page.locator('[aria-label="Search grid"]');
await searchInput.fill('Alice');