diff --git a/.gitignore b/.gitignore index dc26d56..3c4a858 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ dist-ssr *storybook.log storybook-static -test-results/ \ No newline at end of file +test-results/ +playwright-report/ diff --git a/.storybook/preview.ts b/.storybook/preview.ts index a79b36a..79cc4f0 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -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: { diff --git a/.storybook/previewDecorator.tsx b/.storybook/previewDecorator.tsx index 748f2f5..35b0d89 100644 --- a/.storybook/previewDecorator.tsx +++ b/.storybook/previewDecorator.tsx @@ -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 ( - + {useGlobalStore ? ( diff --git a/playwright-report/data/647f4a0057687583a39a3354a294707b3b98bfd3.md b/playwright-report/data/647f4a0057687583a39a3354a294707b3b98bfd3.md deleted file mode 100644 index e15db18..0000000 --- a/playwright-report/data/647f4a0057687583a39a3354a294707b3b98bfd3.md +++ /dev/null @@ -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] -``` \ No newline at end of file diff --git a/playwright-report/index.html b/playwright-report/index.html index fa40773..811b7e5 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+a.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/src/Griddy/Griddy.stories.tsx b/src/Griddy/Griddy.stories.tsx index 09b0b8b..987fa66 100644 --- a/src/Griddy/Griddy.stories.tsx +++ b/src/Griddy/Griddy.stories.tsx @@ -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[] = [ @@ -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; } diff --git a/src/Griddy/adapters/Adapters.stories.tsx b/src/Griddy/adapters/Adapters.stories.tsx index f17eb81..54f54ce 100644 --- a/src/Griddy/adapters/Adapters.stories.tsx +++ b/src/Griddy/adapters/Adapters.stories.tsx @@ -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) => , @@ -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) => ( diff --git a/src/Griddy/core/Griddy.tsx b/src/Griddy/core/Griddy.tsx index 01a2563..ae22d7a 100644 --- a/src/Griddy/core/Griddy.tsx +++ b/src/Griddy/core/Griddy.tsx @@ -206,6 +206,7 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { // 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({ tableRef }: { tableRef: Ref> }) { // Lazy tree expansion useLazyTreeExpansion({ data: transformedData, + expanded, setData, setTreeChildrenCache, setTreeLoadingNode, @@ -284,6 +286,7 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { // Auto-expand on search useAutoExpandOnSearch({ + globalFilter, table, tree, }); diff --git a/src/Griddy/core/GriddyStore.ts b/src/Griddy/core/GriddyStore.ts index c30bf62..4d1965c 100644 --- a/src/Griddy/core/GriddyStore.ts +++ b/src/Griddy/core/GriddyStore.ts @@ -82,16 +82,16 @@ export interface GriddyStoreState extends GriddyUIState { setScrollRef: (el: HTMLDivElement | null) => void; // ─── Internal ref setters ─── setTable: (table: Table) => void; - setVirtualizer: (virtualizer: Virtualizer) => void; + setTreeChildrenCache: (nodeId: string, children: any[]) => void; + setTreeLoadingNode: (nodeId: string, loading: boolean) => void; + setVirtualizer: (virtualizer: Virtualizer) => void; showToolbar?: boolean; sorting?: SortingState; // ─── Tree/Hierarchical Data ─── tree?: TreeConfig; - treeLoadingNodes: Set; treeChildrenCache: Map; - setTreeLoadingNode: (nodeId: string, loading: boolean) => void; - setTreeChildrenCache: (nodeId: string, children: any[]) => void; + treeLoadingNodes: Set; // ─── 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(), })); diff --git a/src/Griddy/core/types.ts b/src/Griddy/core/types.ts index be5d468..8aca75f 100644 --- a/src/Griddy/core/types.ts +++ b/src/Griddy/core/types.ts @@ -305,49 +305,49 @@ export interface SelectionConfig { // ─── Tree/Hierarchical Data ────────────────────────────────────────────────── export interface TreeConfig { - /** 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[]; - /** 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[]; + + /** Enable tree/hierarchical data mode */ + enabled: boolean; + /** Controlled expanded state */ expanded?: Record; - /** Callback when expanded state changes */ - onExpandedChange?: (expanded: Record) => void; + // ─── Lazy Mode ─── + /** Async function to fetch children for a parent node */ + getChildren?: (parent: T) => Promise | 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) => 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 ────────────────────────────────────────────── diff --git a/src/Griddy/features/keyboard/useKeyboardNavigation.ts b/src/Griddy/features/keyboard/useKeyboardNavigation.ts index 8034d4f..2521b1d 100644 --- a/src/Griddy/features/keyboard/useKeyboardNavigation.ts +++ b/src/Griddy/features/keyboard/useKeyboardNavigation.ts @@ -16,23 +16,6 @@ interface UseKeyboardNavigationOptions { virtualizer: Virtualizer } -/** - * Helper to find parent row in tree structure - */ -function findParentRow(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({ editingEnabled, scrollRef, @@ -307,3 +290,20 @@ export function useKeyboardNavigation({ return () => el.removeEventListener('keydown', handleKeyDown) }, [handleKeyDown, scrollRef]) } + +/** + * Helper to find parent row in tree structure + */ +function findParentRow(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; +} diff --git a/src/Griddy/features/toolbar/GridToolbar.tsx b/src/Griddy/features/toolbar/GridToolbar.tsx index 2259179..63a04c4 100644 --- a/src/Griddy/features/toolbar/GridToolbar.tsx +++ b/src/Griddy/features/toolbar/GridToolbar.tsx @@ -33,7 +33,7 @@ export function GridToolbar({ } return ( - + {filterPresets && ( )} diff --git a/src/Griddy/features/tree/TreeExpandButton.tsx b/src/Griddy/features/tree/TreeExpandButton.tsx index 7391fe5..a8c83eb 100644 --- a/src/Griddy/features/tree/TreeExpandButton.tsx +++ b/src/Griddy/features/tree/TreeExpandButton.tsx @@ -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 }; diff --git a/src/Griddy/features/tree/index.ts b/src/Griddy/features/tree/index.ts index 0048b5e..de8aef2 100644 --- a/src/Griddy/features/tree/index.ts +++ b/src/Griddy/features/tree/index.ts @@ -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'; diff --git a/src/Griddy/features/tree/transformTreeData.ts b/src/Griddy/features/tree/transformTreeData.ts index b533fc5..9432c38 100644 --- a/src/Griddy/features/tree/transformTreeData.ts +++ b/src/Griddy/features/tree/transformTreeData.ts @@ -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>( - 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(); - 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>( 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>( + 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(); + 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; +} diff --git a/src/Griddy/features/tree/useAutoExpandOnSearch.ts b/src/Griddy/features/tree/useAutoExpandOnSearch.ts index 1ebeb3f..c59cff7 100644 --- a/src/Griddy/features/tree/useAutoExpandOnSearch.ts +++ b/src/Griddy/features/tree/useAutoExpandOnSearch.ts @@ -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(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 { - tree?: TreeConfig; + globalFilter?: string; table: Table; + tree?: TreeConfig; } /** * Hook to auto-expand parent nodes when search matches child nodes */ export function useAutoExpandOnSearch({ - tree, + globalFilter: globalFilterProp, table, + tree, }: UseAutoExpandOnSearchOptions) { const previousFilterRef = useRef(undefined); const previousExpandedRef = useRef>({}); @@ -53,8 +35,7 @@ export function useAutoExpandOnSearch({ // 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({ 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 = {}; - 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(); + 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({ } table.setExpanded(newExpanded); } - }, [tree, table]); + }, [tree, table, globalFilterProp]); } diff --git a/src/Griddy/features/tree/useLazyTreeExpansion.ts b/src/Griddy/features/tree/useLazyTreeExpansion.ts index 34ed59e..31beb6b 100644 --- a/src/Griddy/features/tree/useLazyTreeExpansion.ts +++ b/src/Griddy/features/tree/useLazyTreeExpansion.ts @@ -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 { - tree?: TreeConfig; - table: Table; data: T[]; + expanded: Record | 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; + tree?: TreeConfig; treeChildrenCache: Map; } @@ -19,12 +21,13 @@ interface UseLazyTreeExpansionOptions { * Hook to handle lazy loading of tree children when nodes are expanded */ export function useLazyTreeExpansion>({ - tree, - table, data, + expanded: expandedState, setData, - setTreeLoadingNode, setTreeChildrenCache, + setTreeLoadingNode, + table, + tree, treeChildrenCache, }: UseLazyTreeExpansionOptions) { const expandedRef = useRef>({}); @@ -35,7 +38,7 @@ export function useLazyTreeExpansion>({ 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>({ }, [ tree, table, + expandedState, data, setData, setTreeLoadingNode, diff --git a/src/Griddy/features/tree/useTreeData.ts b/src/Griddy/features/tree/useTreeData.ts index ddf8088..1b1f824 100644 --- a/src/Griddy/features/tree/useTreeData.ts +++ b/src/Griddy/features/tree/useTreeData.ts @@ -22,6 +22,20 @@ export function useTreeData>( 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>( }); } - 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; } diff --git a/src/Griddy/plan.md b/src/Griddy/plan.md index 18ab292..278bb0a 100644 --- a/src/Griddy/plan.md +++ b/src/Griddy/plan.md @@ -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 diff --git a/src/Griddy/rendering/TableCell.tsx b/src/Griddy/rendering/TableCell.tsx index b529a75..11d4f26 100644 --- a/src/Griddy/rendering/TableCell.tsx +++ b/src/Griddy/rendering/TableCell.tsx @@ -65,9 +65,12 @@ export function TableCell({ cell, showGrouping }: TableCellProps) { // 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; diff --git a/src/Griddy/styles/griddy.module.css b/src/Griddy/styles/griddy.module.css index 1913dbc..59c58cf 100644 --- a/src/Griddy/styles/griddy.module.css +++ b/src/Griddy/styles/griddy.module.css @@ -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); +} diff --git a/tests/e2e/griddy-core.spec.ts b/tests/e2e/griddy-core.spec.ts new file mode 100644 index 0000000..0a945e3 --- /dev/null +++ b/tests/e2e/griddy-core.spec.ts @@ -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 }); + } + }); +}); diff --git a/tests/e2e/tree-hierarchical.spec.ts b/tests/e2e/tree-hierarchical.spec.ts index c876750..c8098f4 100644 --- a/tests/e2e/tree-hierarchical.spec.ts +++ b/tests/e2e/tree-hierarchical.spec.ts @@ -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');