diff --git a/package.json b/package.json
index 062647e..724e91c 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
"@changesets/cli": "^2.29.8",
"@eslint/js": "^10.0.1",
"@microsoft/api-extractor": "^7.56.3",
+ "@playwright/test": "^1.58.2",
"@sentry/react": "^10.38.0",
"@storybook/react-vite": "^10.2.8",
"@testing-library/jest-dom": "^6.9.1",
diff --git a/playwright-report/data/647f4a0057687583a39a3354a294707b3b98bfd3.md b/playwright-report/data/647f4a0057687583a39a3354a294707b3b98bfd3.md
new file mode 100644
index 0000000..e15db18
--- /dev/null
+++ b/playwright-report/data/647f4a0057687583a39a3354a294707b3b98bfd3.md
@@ -0,0 +1,500 @@
+# 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
new file mode 100644
index 0000000..fa40773
--- /dev/null
+++ b/playwright-report/index.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..93c3468
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,23 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ forbidOnly: !!process.env.CI,
+ fullyParallel: true,
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+ reporter: 'html',
+ retries: process.env.CI ? 2 : 0,
+ testDir: './tests/e2e',
+ use: {
+ baseURL: 'http://localhost:6006',
+ trace: 'on-first-retry',
+ },
+
+ webServer: undefined,
+
+ workers: process.env.CI ? 1 : undefined,
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d5e8b7f..54c064d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -75,6 +75,9 @@ importers:
'@microsoft/api-extractor':
specifier: ^7.56.3
version: 7.56.3(@types/node@25.2.3)
+ '@playwright/test':
+ specifier: ^1.58.2
+ version: 1.58.2
'@sentry/react':
specifier: ^10.38.0
version: 10.38.0(react@19.2.4)
@@ -817,6 +820,11 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
+ '@playwright/test@1.58.2':
+ resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@rolldown/pluginutils@1.0.0-rc.2':
resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==}
@@ -2257,6 +2265,11 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -3033,6 +3046,16 @@ packages:
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
+ playwright-core@1.58.2:
+ resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.58.2:
+ resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
+ engines: {node: '>=18'}
+ hasBin: true
+
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -4745,6 +4768,10 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.19.1
+ '@playwright/test@1.58.2':
+ dependencies:
+ playwright: 1.58.2
+
'@rolldown/pluginutils@1.0.0-rc.2': {}
'@rollup/pluginutils@5.3.0(rollup@4.50.2)':
@@ -6425,6 +6452,9 @@ snapshots:
fs.realpath@1.0.0: {}
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -7177,6 +7207,14 @@ snapshots:
exsolve: 1.0.7
pathe: 2.0.3
+ playwright-core@1.58.2: {}
+
+ playwright@1.58.2:
+ dependencies:
+ playwright-core: 1.58.2
+ optionalDependencies:
+ fsevents: 2.3.2
+
possible-typed-array-names@1.1.0: {}
postcss-js@4.1.0(postcss@8.5.6):
diff --git a/src/Griddy/CONTEXT.md b/src/Griddy/CONTEXT.md
index bdb3997..3e5e6a0 100644
--- a/src/Griddy/CONTEXT.md
+++ b/src/Griddy/CONTEXT.md
@@ -80,6 +80,71 @@ Griddy is a new data grid component in the Oranguru package (`@warkypublic/orang
Uses **Mantine** components (not raw HTML):
- `Checkbox` from `@mantine/core` for row/header checkboxes
- `TextInput` from `@mantine/core` for search input
+- `Select`, `MultiSelect`, `NumberInput`, `Radio`, `Popover`, `Menu`, `ActionIcon` for filtering (Phase 5)
+
+## Phase 5: Column Filtering UI (COMPLETE)
+
+### User Interaction Pattern
+1. **Filter Status Indicator**: Gray filter icon in each column header (disabled, non-clickable)
+2. **Right-Click Context Menu**: Shows on header right-click with options:
+ - `Sort` — Toggle column sorting
+ - `Reset Sorting` — Clear sort (shown only if column is sorted)
+ - `Reset Filter` — Clear filters (shown only if column has active filter)
+ - `Open Filters` — Opens filter popover
+3. **Filter Popover**: Opened from "Open Filters" menu item
+ - Positioned below header
+ - Contains filter operator dropdown and value input(s)
+ - Apply and Clear buttons
+ - Filter type determined by `column.filterConfig.type`
+
+### Filter Types & Operators
+| Type | Operators | Input Component |
+|------|-----------|-----------------|
+| `text` | contains, equals, startsWith, endsWith, notContains, isEmpty, isNotEmpty | TextInput with debounce |
+| `number` | equals, notEquals, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, between | NumberInput (single or dual for between) |
+| `enum` | includes, excludes, isEmpty | MultiSelect with custom options |
+| `boolean` | isTrue, isFalse, isEmpty | Radio.Group (True/False/All) |
+
+### API
+```typescript
+interface FilterConfig {
+ type: 'text' | 'number' | 'boolean' | 'enum'
+ operators?: FilterOperator[] // custom operators (optional)
+ enumOptions?: Array<{ label: string; value: any }> // for enum type
+}
+
+// Usage in column definition:
+{
+ id: 'firstName',
+ accessor: 'firstName',
+ header: 'First Name',
+ filterable: true,
+ filterConfig: { type: 'text' }
+}
+```
+
+### Key Implementation Details
+- **Default filterFn**: Automatically assigned when `filterable: true` and no custom `filterFn` provided
+- **Operator-based filtering**: Uses `createOperatorFilter()` that delegates to type-specific implementations
+- **Debouncing**: Text inputs debounced 300ms to reduce re-renders
+- **TanStack Integration**: Uses `column.setFilterValue()` and `column.getFilterValue()`
+- **AND Logic**: Multiple column filters applied together (AND by default)
+- **Controlled State**: Filter state managed by parent via `columnFilters` prop and `onColumnFiltersChange` callback
+
+### Files Structure (Phase 5)
+```
+src/Griddy/features/filtering/
+├── types.ts # FilterOperator, FilterConfig, FilterValue
+├── operators.ts # TEXT_OPERATORS, NUMBER_OPERATORS, etc.
+├── filterFunctions.ts # TanStack FilterFn implementations
+├── FilterInput.tsx # Text/number input with debounce
+├── FilterSelect.tsx # Multi-select for enums
+├── FilterBoolean.tsx # Radio group for booleans
+├── ColumnFilterButton.tsx # Status indicator icon
+├── ColumnFilterPopover.tsx # Popover UI container
+├── ColumnFilterContextMenu.tsx # Right-click context menu
+└── index.ts # Public exports
+```
## Implementation Status
- [x] Phase 1: Core foundation + TanStack Table
@@ -87,7 +152,14 @@ Uses **Mantine** components (not raw HTML):
- [x] Phase 3: Row selection (single + multi)
- [x] Phase 4: Search (Ctrl+F overlay)
- [x] Sorting (click header)
-- [ ] Phase 5: Column filtering UI
+- [x] Phase 5: Column filtering UI (COMPLETE ✅)
+ - Right-click context menu on headers
+ - Sort, Reset Sort, Reset Filter, Open Filters menu items
+ - Text, number, enum, boolean filtering
+ - Filter popover UI with operators
+ - 6 Storybook stories with examples
+ - 8 Playwright E2E test cases
+- [ ] Phase 5.5: Date filtering (requires @mantine/dates)
- [ ] Phase 6: In-place editing
- [ ] Phase 7: Pagination + remote data adapters
- [ ] Phase 8: Grouping, pinning, column reorder, export
@@ -96,7 +168,64 @@ Uses **Mantine** components (not raw HTML):
## Dependencies Added
- `@tanstack/react-table` ^8.21.3 (in both dependencies and peerDependencies)
-## Build
-- `pnpm run typecheck` — clean
-- `pnpm run build` — clean
-- `pnpm run storybook` — stories render correctly
+## Build & Testing Status
+- [x] `pnpm run typecheck` — ✅ PASS (0 errors)
+- [x] `pnpm run lint` — ✅ PASS (0 errors in Phase 5 code)
+- [x] `pnpm run build` — ✅ PASS
+- [x] `pnpm run storybook` — ✅ 6 Phase 5 stories working
+- [x] Playwright test suite created (8 E2E test cases)
+
+### Commands
+```bash
+# Run all checks
+pnpm run typecheck && pnpm run lint && pnpm run build
+
+# Start Storybook (see filtering stories)
+pnpm run storybook
+
+# Install and run Playwright tests
+pnpm exec playwright install
+pnpm exec playwright test
+
+# Run specific test file
+pnpm exec playwright test tests/e2e/filtering-context-menu.spec.ts
+
+# Debug mode
+pnpm exec playwright test --debug
+
+# View HTML report
+pnpm exec playwright show-report
+```
+
+## Next Phase (Phase 5.5 - Date Filtering)
+
+**Planned Tasks**:
+1. Install `@mantine/dates` dependency
+2. Create `FilterDate.tsx` component with date range picker
+3. Add date operators: after, before, between, exactDate
+4. Integrate into ColumnFilterPopover
+5. Add date filtering Storybook story
+6. Add Playwright E2E tests for date filtering
+
+**Estimated Effort**: 1-2 hours
+
+## Resume Instructions (When Returning)
+
+1. **Run full build check**:
+ ```bash
+ pnpm run typecheck && pnpm run lint && pnpm run build
+ ```
+
+2. **Start Storybook to verify Phase 5**:
+ ```bash
+ pnpm run storybook
+ # Open http://localhost:6006
+ # Check stories: WithTextFiltering, WithNumberFiltering, WithEnumFiltering, WithBooleanFiltering, WithAllFilterTypes, LargeDatasetWithFiltering
+ ```
+
+3. **Run Playwright tests** (requires Storybook running in another terminal):
+ ```bash
+ pnpm exec playwright test
+ ```
+
+4. **Next task**: Begin Phase 5.5 (Date Filtering) with explicit user approval
diff --git a/src/Griddy/Griddy.stories.tsx b/src/Griddy/Griddy.stories.tsx
index d564cd4..40f371f 100644
--- a/src/Griddy/Griddy.stories.tsx
+++ b/src/Griddy/Griddy.stories.tsx
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
-import type { RowSelectionState } from '@tanstack/react-table'
+import type { ColumnFiltersState, RowSelectionState } from '@tanstack/react-table'
import { Box } from '@mantine/core'
import { useState } from 'react'
@@ -227,3 +227,356 @@ export const KeyboardNavigation: Story = {
)
},
}
+
+/** Text filtering with operators like contains, equals, starts with, etc. */
+export const WithTextFiltering: Story = {
+ render: () => {
+ const [filters, setFilters] = useState([])
+
+ const filterColumns: GriddyColumn[] = [
+ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 },
+ {
+ accessor: 'firstName',
+ filterable: true,
+ filterConfig: { type: 'text' },
+ header: 'First Name',
+ id: 'firstName',
+ sortable: true,
+ width: 120,
+ },
+ {
+ accessor: 'lastName',
+ filterable: true,
+ filterConfig: { type: 'text' },
+ header: 'Last Name',
+ id: 'lastName',
+ sortable: true,
+ width: 120,
+ },
+ { accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
+ { accessor: 'age', header: 'Age', id: 'age', sortable: true, width: 70 },
+ { accessor: 'department', header: 'Department', id: 'department', sortable: true, width: 130 },
+ { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
+ { accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
+ { accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', sortable: true, width: 80 },
+ ]
+
+ return (
+
+
+ columnFilters={filters}
+ columns={filterColumns}
+ data={smallData}
+ getRowId={(row) => String(row.id)}
+ height={500}
+ onColumnFiltersChange={setFilters}
+ />
+
+ Active Filters:
+ {JSON.stringify(filters, null, 2)}
+
+
+ )
+ },
+}
+
+/** Number filtering with operators like equals, between, greater than, etc. */
+export const WithNumberFiltering: Story = {
+ render: () => {
+ const [filters, setFilters] = useState([])
+
+ const filterColumns: GriddyColumn[] = [
+ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 },
+ { accessor: 'firstName', header: 'First Name', id: 'firstName', sortable: true, width: 120 },
+ { accessor: 'lastName', header: 'Last Name', id: 'lastName', sortable: true, width: 120 },
+ { accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
+ {
+ accessor: 'age',
+ filterable: true,
+ filterConfig: { type: 'number' },
+ header: 'Age',
+ id: 'age',
+ sortable: true,
+ width: 70,
+ },
+ { accessor: 'department', header: 'Department', id: 'department', sortable: true, width: 130 },
+ {
+ accessor: (row) => row.salary,
+ filterable: true,
+ filterConfig: { type: 'number' },
+ header: 'Salary',
+ id: 'salary',
+ sortable: true,
+ width: 110,
+ },
+ { accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
+ { accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', sortable: true, width: 80 },
+ ]
+
+ return (
+
+
+ columnFilters={filters}
+ columns={filterColumns}
+ data={smallData}
+ getRowId={(row) => String(row.id)}
+ height={500}
+ onColumnFiltersChange={setFilters}
+ />
+
+ Active Filters:
+ {JSON.stringify(filters, null, 2)}
+
+
+ )
+ },
+}
+
+/** Enum (multi-select) filtering with includes/excludes operators */
+export const WithEnumFiltering: Story = {
+ render: () => {
+ const [filters, setFilters] = useState([])
+
+ const filterColumns: GriddyColumn[] = [
+ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 },
+ { accessor: 'firstName', header: 'First Name', id: 'firstName', sortable: true, width: 120 },
+ { accessor: 'lastName', header: 'Last Name', id: 'lastName', sortable: true, width: 120 },
+ { accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
+ { accessor: 'age', header: 'Age', id: 'age', sortable: true, width: 70 },
+ {
+ accessor: 'department',
+ filterable: true,
+ filterConfig: {
+ enumOptions: departments.map((dept) => ({ label: dept, value: dept })),
+ type: 'enum',
+ },
+ header: 'Department',
+ id: 'department',
+ sortable: true,
+ width: 130,
+ },
+ { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
+ { accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
+ { accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', sortable: true, width: 80 },
+ ]
+
+ return (
+
+
+ columnFilters={filters}
+ columns={filterColumns}
+ data={smallData}
+ getRowId={(row) => String(row.id)}
+ height={500}
+ onColumnFiltersChange={setFilters}
+ />
+
+ Active Filters:
+ {JSON.stringify(filters, null, 2)}
+
+
+ )
+ },
+}
+
+/** Boolean filtering with true/false/all operators */
+export const WithBooleanFiltering: Story = {
+ render: () => {
+ const [filters, setFilters] = useState([])
+
+ const filterColumns: GriddyColumn[] = [
+ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 },
+ { accessor: 'firstName', header: 'First Name', id: 'firstName', sortable: true, width: 120 },
+ { accessor: 'lastName', header: 'Last Name', id: 'lastName', sortable: true, width: 120 },
+ { accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
+ { accessor: 'age', header: 'Age', id: 'age', sortable: true, width: 70 },
+ { accessor: 'department', header: 'Department', id: 'department', sortable: true, width: 130 },
+ { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
+ { accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
+ {
+ accessor: 'active',
+ filterable: true,
+ filterConfig: { type: 'boolean' },
+ header: 'Active',
+ id: 'active',
+ sortable: true,
+ width: 80,
+ },
+ ]
+
+ return (
+
+
+ columnFilters={filters}
+ columns={filterColumns}
+ data={smallData}
+ getRowId={(row) => String(row.id)}
+ height={500}
+ onColumnFiltersChange={setFilters}
+ />
+
+ Active Filters:
+ {JSON.stringify(filters, null, 2)}
+
+
+ )
+ },
+}
+
+/** Combined filtering - all filter types together */
+export const WithAllFilterTypes: Story = {
+ render: () => {
+ const [filters, setFilters] = useState([])
+
+ const filterColumns: GriddyColumn[] = [
+ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 },
+ {
+ accessor: 'firstName',
+ filterable: true,
+ filterConfig: { type: 'text' },
+ header: 'First Name',
+ id: 'firstName',
+ sortable: true,
+ width: 120,
+ },
+ {
+ accessor: 'lastName',
+ filterable: true,
+ filterConfig: { type: 'text' },
+ header: 'Last Name',
+ id: 'lastName',
+ sortable: true,
+ width: 120,
+ },
+ { accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
+ {
+ accessor: 'age',
+ filterable: true,
+ filterConfig: { type: 'number' },
+ header: 'Age',
+ id: 'age',
+ sortable: true,
+ width: 70,
+ },
+ {
+ accessor: 'department',
+ filterable: true,
+ filterConfig: {
+ enumOptions: departments.map((dept) => ({ label: dept, value: dept })),
+ type: 'enum',
+ },
+ header: 'Department',
+ id: 'department',
+ sortable: true,
+ width: 130,
+ },
+ { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
+ { accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
+ {
+ accessor: 'active',
+ filterable: true,
+ filterConfig: { type: 'boolean' },
+ header: 'Active',
+ id: 'active',
+ sortable: true,
+ width: 80,
+ },
+ ]
+
+ return (
+
+
+ columnFilters={filters}
+ columns={filterColumns}
+ data={smallData}
+ getRowId={(row) => String(row.id)}
+ height={500}
+ onColumnFiltersChange={setFilters}
+ />
+
+ Active Filters (AND logic - all must match):
+ {JSON.stringify(filters, null, 2)}
+
+
+ )
+ },
+}
+
+/** Large dataset with filtering and sorting */
+export const LargeDatasetWithFiltering: Story = {
+ render: () => {
+ const [filters, setFilters] = useState([])
+
+ const filterColumns: GriddyColumn[] = [
+ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 },
+ {
+ accessor: 'firstName',
+ filterable: true,
+ filterConfig: { type: 'text' },
+ header: 'First Name',
+ id: 'firstName',
+ sortable: true,
+ width: 120,
+ },
+ {
+ accessor: 'lastName',
+ filterable: true,
+ filterConfig: { type: 'text' },
+ header: 'Last Name',
+ id: 'lastName',
+ sortable: true,
+ width: 120,
+ },
+ { accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
+ {
+ accessor: 'age',
+ filterable: true,
+ filterConfig: { type: 'number' },
+ header: 'Age',
+ id: 'age',
+ sortable: true,
+ width: 70,
+ },
+ {
+ accessor: 'department',
+ filterable: true,
+ filterConfig: {
+ enumOptions: departments.map((dept) => ({ label: dept, value: dept })),
+ type: 'enum',
+ },
+ header: 'Department',
+ id: 'department',
+ sortable: true,
+ width: 130,
+ },
+ { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
+ { accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
+ {
+ accessor: 'active',
+ filterable: true,
+ filterConfig: { type: 'boolean' },
+ header: 'Active',
+ id: 'active',
+ sortable: true,
+ width: 80,
+ },
+ ]
+
+ return (
+
+
+ columnFilters={filters}
+ columns={filterColumns}
+ data={largeData}
+ getRowId={(row) => String(row.id)}
+ height={600}
+ onColumnFiltersChange={setFilters}
+ />
+
+ Active Filters:
+ {JSON.stringify(filters, null, 2)}
+
+
+ )
+ },
+}
diff --git a/src/Griddy/core/columnMapper.ts b/src/Griddy/core/columnMapper.ts
index 437b966..fd290f8 100644
--- a/src/Griddy/core/columnMapper.ts
+++ b/src/Griddy/core/columnMapper.ts
@@ -2,6 +2,7 @@ import type { ColumnDef } from '@tanstack/react-table'
import type { GriddyColumn, SelectionConfig } from './types'
+import { createOperatorFilter } from '../features/filtering'
import { DEFAULTS, SELECTION_COLUMN_ID, SELECTION_COLUMN_SIZE } from './constants'
/**
@@ -38,10 +39,21 @@ export function mapColumns(
meta: { griddy: col },
minSize: col.minWidth ?? DEFAULTS.minColumnWidth,
size: col.width,
- // For function accessors, TanStack can't auto-detect the sort type, so default to 'auto'
- sortingFn: col.sortFn ?? (isStringAccessor ? undefined : 'auto') as any,
}
- if (col.filterFn) def.filterFn = col.filterFn
+
+ // For function accessors, TanStack can't auto-detect the sort type, so provide a default
+ if (col.sortFn) {
+ def.sortingFn = col.sortFn
+ } else if (!isStringAccessor && col.sortable !== false) {
+ // Use alphanumeric sorting for function accessors
+ def.sortingFn = 'alphanumeric'
+ }
+
+ if (col.filterFn) {
+ def.filterFn = col.filterFn
+ } else if (col.filterable) {
+ def.filterFn = createOperatorFilter()
+ }
return def
})
diff --git a/src/Griddy/core/constants.ts b/src/Griddy/core/constants.ts
index 28e5f35..fd3973d 100644
--- a/src/Griddy/core/constants.ts
+++ b/src/Griddy/core/constants.ts
@@ -5,7 +5,10 @@ export const CSS = {
cellEditing: 'griddy-cell--editing',
checkbox: 'griddy-checkbox',
container: 'griddy-container',
+ filterButton: 'griddy-filter-button',
+ filterButtonActive: 'griddy-filter-button--active',
headerCell: 'griddy-header-cell',
+ headerCellContent: 'griddy-header-cell-content',
headerCellSortable: 'griddy-header-cell--sortable',
headerCellSorted: 'griddy-header-cell--sorted',
headerRow: 'griddy-header-row',
@@ -27,6 +30,7 @@ export const CSS = {
// ─── Defaults ────────────────────────────────────────────────────────────────
export const DEFAULTS = {
+ filterDebounceMs: 300,
headerHeight: 36,
maxColumnWidth: 800,
minColumnWidth: 50,
diff --git a/src/Griddy/core/types.ts b/src/Griddy/core/types.ts
index 8c7e8e3..e879416 100644
--- a/src/Griddy/core/types.ts
+++ b/src/Griddy/core/types.ts
@@ -2,6 +2,8 @@ import type { ColumnDef, ColumnFiltersState, ColumnOrderState, ColumnPinningStat
import type { Virtualizer } from '@tanstack/react-virtual'
import type { ReactNode } from 'react'
+import type { FilterConfig } from '../features/filtering'
+
// ─── Column Definition ───────────────────────────────────────────────────────
export type CellRenderer = (props: RendererProps) => ReactNode
@@ -45,6 +47,7 @@ export interface GriddyColumn {
editable?: ((row: T) => boolean) | boolean
editor?: EditorComponent
filterable?: boolean
+ filterConfig?: FilterConfig
filterFn?: FilterFn
header: ReactNode | string
headerGroup?: string
diff --git a/src/Griddy/features/filtering/ColumnFilterButton.tsx b/src/Griddy/features/filtering/ColumnFilterButton.tsx
new file mode 100644
index 0000000..d009819
--- /dev/null
+++ b/src/Griddy/features/filtering/ColumnFilterButton.tsx
@@ -0,0 +1,33 @@
+import type { Column } from '@tanstack/react-table'
+
+import { ActionIcon } from '@mantine/core'
+import { IconFilter } from '@tabler/icons-react'
+
+import { CSS } from '../../core/constants'
+import styles from '../../styles/griddy.module.css'
+
+interface ColumnFilterButtonProps {
+ column: Column
+}
+
+export function ColumnFilterButton({ column }: ColumnFilterButtonProps) {
+ const isActive = !!column.getFilterValue()
+
+ return (
+
+
+
+ )
+}
diff --git a/src/Griddy/features/filtering/ColumnFilterContextMenu.tsx b/src/Griddy/features/filtering/ColumnFilterContextMenu.tsx
new file mode 100644
index 0000000..3e00cba
--- /dev/null
+++ b/src/Griddy/features/filtering/ColumnFilterContextMenu.tsx
@@ -0,0 +1,105 @@
+import type { Column } from '@tanstack/react-table'
+
+import { Menu } from '@mantine/core'
+import { IconFilter, IconSortAscending, IconTrash } from '@tabler/icons-react'
+import { useState } from 'react'
+
+interface HeaderContextMenuProps {
+ children: React.ReactNode
+ column: Column
+ onOpenFilter: () => void
+}
+
+export function HeaderContextMenu({
+ children,
+ column,
+ onOpenFilter,
+}: HeaderContextMenuProps) {
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
+
+ const handleContextMenu = (e: React.MouseEvent) => {
+ e.preventDefault()
+ setContextMenu({ x: e.clientX, y: e.clientY })
+ }
+
+ const handleSort = () => {
+ if (column.getCanSort()) {
+ const handler = column.getToggleSortingHandler()
+ if (handler) {
+ handler({} as any)
+ }
+ }
+ setContextMenu(null)
+ }
+
+ const handleClearSort = () => {
+ if (column.getCanSort()) {
+ const handler = column.getToggleSortingHandler()
+ if (handler) {
+ handler({} as any)
+ }
+ }
+ setContextMenu(null)
+ }
+
+ const handleResetFilter = () => {
+ if (column.getCanFilter()) {
+ column.setFilterValue(undefined)
+ }
+ setContextMenu(null)
+ }
+
+ const handleOpenFilter = () => {
+ if (column.getCanFilter()) {
+ onOpenFilter()
+ }
+ setContextMenu(null)
+ }
+
+ return (
+ <>
+
+ {children}
+
+
+ {contextMenu && (
+
+ )}
+ >
+ )
+}
diff --git a/src/Griddy/features/filtering/ColumnFilterPopover.tsx b/src/Griddy/features/filtering/ColumnFilterPopover.tsx
new file mode 100644
index 0000000..11dd82c
--- /dev/null
+++ b/src/Griddy/features/filtering/ColumnFilterPopover.tsx
@@ -0,0 +1,118 @@
+import type { Column } from '@tanstack/react-table'
+
+import { Button, Group, Popover, Stack, Text } from '@mantine/core'
+import { useState } from 'react'
+
+import type { FilterConfig, FilterValue } from './types'
+
+import { getGriddyColumn } from '../../core/columnMapper'
+import { ColumnFilterButton } from './ColumnFilterButton'
+import { FilterBoolean } from './FilterBoolean'
+import { FilterInput } from './FilterInput'
+import { FilterSelect } from './FilterSelect'
+import { OPERATORS_BY_TYPE } from './operators'
+
+interface ColumnFilterPopoverProps {
+ column: Column
+ onOpenedChange?: (opened: boolean) => void
+ opened?: boolean
+}
+
+export function ColumnFilterPopover({ column, onOpenedChange, opened: externalOpened }: ColumnFilterPopoverProps) {
+ const [internalOpened, setInternalOpened] = useState(false)
+
+ // Support both internal and external control
+ const opened = externalOpened !== undefined ? externalOpened : internalOpened
+ const setOpened = (value: boolean) => {
+ if (externalOpened !== undefined) {
+ onOpenedChange?.(value)
+ } else {
+ setInternalOpened(value)
+ }
+ }
+ const [localValue, setLocalValue] = useState(
+ (column.getFilterValue() as FilterValue) || undefined,
+ )
+
+ const griddyColumn = getGriddyColumn(column)
+ const filterConfig: FilterConfig | undefined = (griddyColumn as any)?.filterConfig
+
+ if (!filterConfig) {
+ return null
+ }
+
+ const handleApply = () => {
+ column.setFilterValue(localValue)
+ setOpened(false)
+ }
+
+ const handleClear = () => {
+ setLocalValue(undefined)
+ column.setFilterValue(undefined)
+ setOpened(false)
+ }
+
+ const handleClose = () => {
+ setOpened(false)
+ // Reset to previous value if popover is closed without applying
+ setLocalValue((column.getFilterValue() as FilterValue) || undefined)
+ }
+
+ const operators =
+ filterConfig.operators || OPERATORS_BY_TYPE[filterConfig.type]
+
+ return (
+
+
+
+
+
+
+
+ Filter: {column.id}
+
+
+ {filterConfig.type === 'text' && (
+
+ )}
+
+ {filterConfig.type === 'number' && (
+
+ )}
+
+ {filterConfig.type === 'enum' && filterConfig.enumOptions && (
+
+ )}
+
+ {filterConfig.type === 'boolean' && (
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/Griddy/features/filtering/FilterBoolean.tsx b/src/Griddy/features/filtering/FilterBoolean.tsx
new file mode 100644
index 0000000..56c222a
--- /dev/null
+++ b/src/Griddy/features/filtering/FilterBoolean.tsx
@@ -0,0 +1,32 @@
+import { Radio, Stack } from '@mantine/core'
+import { useState } from 'react'
+
+import type { FilterValue } from './types'
+
+interface FilterBooleanProps {
+ onChange: (value: FilterValue) => void
+ value?: FilterValue
+}
+
+export function FilterBoolean({ onChange, value }: FilterBooleanProps) {
+ const [operator, setOperator] = useState(value?.operator || 'isEmpty')
+
+ const handleChange = (op: string) => {
+ setOperator(op)
+ onChange({
+ operator: op,
+ })
+ }
+
+ return (
+ e.stopPropagation()}>
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/Griddy/features/filtering/FilterInput.tsx b/src/Griddy/features/filtering/FilterInput.tsx
new file mode 100644
index 0000000..9cd6398
--- /dev/null
+++ b/src/Griddy/features/filtering/FilterInput.tsx
@@ -0,0 +1,141 @@
+import { ActionIcon, Group, NumberInput, Select, Stack, TextInput } from '@mantine/core'
+import { IconX } from '@tabler/icons-react'
+import { useEffect, useRef, useState } from 'react'
+
+import type { FilterOperator, FilterValue } from './types'
+
+interface FilterInputProps {
+ onChange: (value: FilterValue) => void
+ operators: FilterOperator[]
+ type: 'number' | 'text'
+ value?: FilterValue
+}
+
+export function FilterInput({ onChange, operators, type, value }: FilterInputProps) {
+ const [operator, setOperator] = useState(value?.operator || operators[0]?.id || '')
+ const [inputValue, setInputValue] = useState(
+ value?.value !== undefined && value?.value !== null ? value.value : undefined,
+ )
+ const debounceTimerRef = useRef(null)
+
+ useEffect(() => {
+ // Clear previous timer
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current)
+ }
+
+ // For text inputs, debounce the changes
+ if (type === 'text' && inputValue !== undefined && inputValue !== '') {
+ debounceTimerRef.current = setTimeout(() => {
+ onChange({
+ operator,
+ value: inputValue,
+ })
+ }, 300)
+ }
+
+ return () => {
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current)
+ }
+ }
+ }, [inputValue, operator, onChange, type])
+
+ const selectedOperator = operators.find((op) => op.id === operator)
+ const requiresValue = selectedOperator?.requiresValue !== false
+
+ const handleClear = () => {
+ setInputValue(undefined)
+ }
+
+ const handleOperatorChange = (newOp: null | string) => {
+ if (newOp) {
+ setOperator(newOp)
+ }
+ }
+
+ // Handle "between" operator specially
+ if (operator === 'between' && type === 'number') {
+ const min = value?.min !== undefined ? Number(value.min) : undefined
+ const max = value?.max !== undefined ? Number(value.max) : undefined
+
+ return (
+ e.stopPropagation()}>
+
+ )
+ }
+
+ return (
+ e.stopPropagation()}>
+
+ )
+}
diff --git a/src/Griddy/features/filtering/FilterSelect.tsx b/src/Griddy/features/filtering/FilterSelect.tsx
new file mode 100644
index 0000000..4eb7660
--- /dev/null
+++ b/src/Griddy/features/filtering/FilterSelect.tsx
@@ -0,0 +1,69 @@
+import { MultiSelect, Select, Stack } from '@mantine/core'
+import { useState } from 'react'
+
+import type { FilterEnumOption, FilterOperator, FilterValue } from './types'
+
+interface FilterSelectProps {
+ onChange: (value: FilterValue) => void
+ operators: FilterOperator[]
+ options: FilterEnumOption[]
+ value?: FilterValue
+}
+
+export function FilterSelect({ onChange, operators, options, value }: FilterSelectProps) {
+ const [operator, setOperator] = useState(value?.operator || operators[0]?.id || 'includes')
+ const [selectedValues, setSelectedValues] = useState(
+ value?.values?.map(String) || [],
+ )
+
+ const handleOperatorChange = (newOp: null | string) => {
+ if (newOp) {
+ setOperator(newOp)
+ }
+ }
+
+ const handleValuesChange = (vals: string[]) => {
+ setSelectedValues(vals)
+ if (operator !== 'isEmpty') {
+ onChange({
+ operator,
+ values: vals.map((v) => {
+ // Try to convert back to original value type
+ const option = options.find((opt) => String(opt.value) === v)
+ return option?.value ?? v
+ }),
+ })
+ }
+ }
+
+ const selectOptions = options.map((opt) => ({
+ label: opt.label,
+ value: String(opt.value),
+ }))
+
+ return (
+ e.stopPropagation()}>
+
+ )
+}
diff --git a/src/Griddy/features/filtering/filterFunctions.ts b/src/Griddy/features/filtering/filterFunctions.ts
new file mode 100644
index 0000000..e1da5f3
--- /dev/null
+++ b/src/Griddy/features/filtering/filterFunctions.ts
@@ -0,0 +1,179 @@
+import type { FilterFn } from '@tanstack/react-table'
+
+import type { FilterValue } from './types'
+
+// ─── Text Filter Functions ──────────────────────────────────────────────────
+
+const textContains: FilterFn = (row: any, columnId: string, filterValue: FilterValue) => {
+ const value = row.getValue(columnId)
+ if (value == null) return false
+ return String(value).toLowerCase().includes(String(filterValue.value).toLowerCase())
+}
+
+const textEquals: FilterFn = (row: any, columnId: string, filterValue: FilterValue) => {
+ const value = row.getValue(columnId)
+ if (value == null) return false
+ return String(value).toLowerCase() === String(filterValue.value).toLowerCase()
+}
+
+const textStartsWith: FilterFn = (row: any, columnId: string, filterValue: FilterValue) => {
+ const value = row.getValue(columnId)
+ if (value == null) return false
+ return String(value).toLowerCase().startsWith(String(filterValue.value).toLowerCase())
+}
+
+const textEndsWith: FilterFn = (row: any, columnId: string, filterValue: FilterValue) => {
+ const value = row.getValue(columnId)
+ if (value == null) return false
+ return String(value).toLowerCase().endsWith(String(filterValue.value).toLowerCase())
+}
+
+const textNotContains: FilterFn = (row: any, columnId: string, filterValue: FilterValue) => {
+ const value = row.getValue(columnId)
+ if (value == null) return true
+ return !String(value).toLowerCase().includes(String(filterValue.value).toLowerCase())
+}
+
+const textIsEmpty: FilterFn = (row: any, columnId: string) => {
+ const value = row.getValue(columnId)
+ return value == null || String(value).trim() === ''
+}
+
+const textIsNotEmpty: FilterFn = (row: any, columnId: string) => {
+ const value = row.getValue(columnId)
+ return value != null && String(value).trim() !== ''
+}
+
+// ─── Number Filter Functions ────────────────────────────────────────────────
+
+const numberEquals: FilterFn = (row: any, columnId: string, filterValue: FilterValue) => {
+ const value = row.getValue(columnId)
+ if (value == null) return false
+ return Number(value) === Number(filterValue.value)
+}
+
+const numberNotEquals: FilterFn = (row: any, columnId: string, filterValue: FilterValue) => {
+ const value = row.getValue(columnId)
+ if (value == null) return false
+ return Number(value) !== Number(filterValue.value)
+}
+
+const numberGreaterThan: FilterFn = (row: any, columnId: string, filterValue: FilterValue) => {
+ const value = row.getValue(columnId)
+ if (value == null) return false
+ return Number(value) > Number(filterValue.value)
+}
+
+const numberGreaterThanOrEqual: FilterFn = (row: any, columnId: string, filterValue: FilterValue) => {
+ const value = row.getValue(columnId)
+ if (value == null) return false
+ return Number(value) >= Number(filterValue.value)
+}
+
+const numberLessThan: FilterFn = (row: any, columnId: string, filterValue: FilterValue) => {
+ const value = row.getValue(columnId)
+ if (value == null) return false
+ return Number(value) < Number(filterValue.value)
+}
+
+const numberLessThanOrEqual: FilterFn = (row: any, columnId: string, filterValue: FilterValue) => {
+ const value = row.getValue(columnId)
+ if (value == null) return false
+ return Number(value) <= Number(filterValue.value)
+}
+
+const numberBetween: FilterFn = (row: any, columnId: string, filterValue: FilterValue) => {
+ const value = row.getValue(columnId)
+ if (value == null) return false
+ const num = Number(value)
+ const min = filterValue.min != null ? Number(filterValue.min) : -Infinity
+ const max = filterValue.max != null ? Number(filterValue.max) : Infinity
+ return num >= min && num <= max
+}
+
+// ─── Enum Filter Functions ──────────────────────────────────────────────────
+
+const enumIncludes: FilterFn = (row: any, columnId: string, filterValue: FilterValue) => {
+ const value = row.getValue(columnId)
+ if (value == null) return false
+ const values = filterValue.values || []
+ return values.includes(value)
+}
+
+const enumExcludes: FilterFn = (row: any, columnId: string, filterValue: FilterValue) => {
+ const value = row.getValue(columnId)
+ if (value == null) return true
+ const values = filterValue.values || []
+ return !values.includes(value)
+}
+
+// ─── Boolean Filter Functions ───────────────────────────────────────────────
+
+const booleanIsTrue: FilterFn = (row: any, columnId: string) => {
+ const value = row.getValue(columnId)
+ return value === true || value === 1 || String(value).toLowerCase() === 'true'
+}
+
+const booleanIsFalse: FilterFn = (row: any, columnId: string) => {
+ const value = row.getValue(columnId)
+ return value === false || value === 0 || String(value).toLowerCase() === 'false'
+}
+
+// ─── Filter Function Map ────────────────────────────────────────────────────
+
+const FILTER_FN_MAP: Record> = {
+ between: numberBetween,
+ contains: textContains,
+ endsWith: textEndsWith,
+ enumExcludes,
+ enumIncludes,
+ equals: ((row: any, columnId: string, filterValue: FilterValue, addMeta: any) => {
+ const value = row.getValue(columnId)
+ // Detect type and use appropriate equals function
+ if (typeof value === 'number') {
+ return numberEquals(row, columnId, filterValue, addMeta)
+ }
+ return textEquals(row, columnId, filterValue, addMeta)
+ }) as FilterFn,
+ excludes: enumExcludes,
+ greaterThan: numberGreaterThan,
+ greaterThanOrEqual: numberGreaterThanOrEqual,
+ includes: enumIncludes,
+ isEmpty: (
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ (row: any, columnId: string, _filterValue: any, _addMeta: any) => {
+ const value = row.getValue(columnId)
+ return value == null || value === '' || (Array.isArray(value) && value.length === 0)
+ }
+ ) as FilterFn,
+ isFalse: booleanIsFalse,
+ isNotEmpty: (
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ (row: any, columnId: string, _filterValue: any, _addMeta: any) => {
+ const value = row.getValue(columnId)
+ return value != null && value !== '' && (!Array.isArray(value) || value.length > 0)
+ }
+ ) as FilterFn,
+ isTrue: booleanIsTrue,
+ lessThan: numberLessThan,
+ lessThanOrEqual: numberLessThanOrEqual,
+ notContains: textNotContains,
+ notEquals: numberNotEquals,
+ startsWith: textStartsWith,
+ textIsEmpty,
+ textIsNotEmpty,
+}
+
+// ─── Universal Filter Function ──────────────────────────────────────────────
+
+export function createOperatorFilter(): FilterFn {
+ return (row: any, columnId: string, filterValue: FilterValue, addMeta: any) => {
+ if (!filterValue?.operator) return true
+ const filterFn = FILTER_FN_MAP[filterValue.operator]
+ if (!filterFn) {
+ console.warn(`Unknown filter operator: ${filterValue.operator}`)
+ return true
+ }
+ return filterFn(row, columnId, filterValue, addMeta)
+ }
+}
diff --git a/src/Griddy/features/filtering/index.ts b/src/Griddy/features/filtering/index.ts
new file mode 100644
index 0000000..ea8a445
--- /dev/null
+++ b/src/Griddy/features/filtering/index.ts
@@ -0,0 +1,9 @@
+export { ColumnFilterButton } from './ColumnFilterButton'
+export { HeaderContextMenu } from './ColumnFilterContextMenu'
+export { ColumnFilterPopover } from './ColumnFilterPopover'
+export { FilterBoolean } from './FilterBoolean'
+export { createOperatorFilter } from './filterFunctions'
+export { FilterInput } from './FilterInput'
+export { FilterSelect } from './FilterSelect'
+export { BOOLEAN_OPERATORS, ENUM_OPERATORS, NUMBER_OPERATORS, OPERATORS_BY_TYPE, TEXT_OPERATORS } from './operators'
+export type { FilterConfig, FilterEnumOption, FilterOperator, FilterState, FilterValue } from './types'
diff --git a/src/Griddy/features/filtering/operators.ts b/src/Griddy/features/filtering/operators.ts
new file mode 100644
index 0000000..3ce9fc8
--- /dev/null
+++ b/src/Griddy/features/filtering/operators.ts
@@ -0,0 +1,51 @@
+import type { FilterOperator } from './types'
+
+// ─── Text Operators ─────────────────────────────────────────────────────────
+
+export const TEXT_OPERATORS: FilterOperator[] = [
+ { id: 'contains', label: 'Contains' },
+ { id: 'equals', label: 'Equals' },
+ { id: 'startsWith', label: 'Starts with' },
+ { id: 'endsWith', label: 'Ends with' },
+ { id: 'notContains', label: 'Does not contain' },
+ { id: 'isEmpty', label: 'Is empty', requiresValue: false },
+ { id: 'isNotEmpty', label: 'Is not empty', requiresValue: false },
+]
+
+// ─── Number Operators ───────────────────────────────────────────────────────
+
+export const NUMBER_OPERATORS: FilterOperator[] = [
+ { id: 'equals', label: 'Equals (=)' },
+ { id: 'notEquals', label: 'Not equals (≠)' },
+ { id: 'greaterThan', label: 'Greater than (>)' },
+ { id: 'greaterThanOrEqual', label: 'Greater or equal (≥)' },
+ { id: 'lessThan', label: 'Less than (<)' },
+ { id: 'lessThanOrEqual', label: 'Less or equal (≤)' },
+ { id: 'between', label: 'Between' },
+ { id: 'isEmpty', label: 'Is empty', requiresValue: false },
+]
+
+// ─── Enum Operators ─────────────────────────────────────────────────────────
+
+export const ENUM_OPERATORS: FilterOperator[] = [
+ { id: 'includes', label: 'Includes' },
+ { id: 'excludes', label: 'Excludes' },
+ { id: 'isEmpty', label: 'Is empty', requiresValue: false },
+]
+
+// ─── Boolean Operators ──────────────────────────────────────────────────────
+
+export const BOOLEAN_OPERATORS: FilterOperator[] = [
+ { id: 'isTrue', label: 'True' },
+ { id: 'isFalse', label: 'False' },
+ { id: 'isEmpty', label: 'All', requiresValue: false },
+]
+
+// ─── Operator Maps ──────────────────────────────────────────────────────────
+
+export const OPERATORS_BY_TYPE = {
+ boolean: BOOLEAN_OPERATORS,
+ enum: ENUM_OPERATORS,
+ number: NUMBER_OPERATORS,
+ text: TEXT_OPERATORS,
+} as const
diff --git a/src/Griddy/features/filtering/types.ts b/src/Griddy/features/filtering/types.ts
new file mode 100644
index 0000000..8abdad5
--- /dev/null
+++ b/src/Griddy/features/filtering/types.ts
@@ -0,0 +1,33 @@
+// ─── Filter Types ───────────────────────────────────────────────────────────
+
+export interface FilterConfig {
+ enumOptions?: FilterEnumOption[]
+ operators?: FilterOperator[]
+ type: 'boolean' | 'enum' | 'number' | 'text'
+}
+
+export interface FilterEnumOption {
+ label: string
+ value: any
+}
+
+export interface FilterOperator {
+ id: string
+ label: string
+ requiresValue?: boolean
+}
+
+// ─── Filter Value Structure ─────────────────────────────────────────────────
+
+export interface FilterState {
+ id: string
+ value: FilterValue
+}
+
+export interface FilterValue {
+ max?: number
+ min?: number
+ operator: string
+ value?: any
+ values?: any[]
+}
diff --git a/src/Griddy/plan.md b/src/Griddy/plan.md
index 76a8a18..f703b06 100644
--- a/src/Griddy/plan.md
+++ b/src/Griddy/plan.md
@@ -983,14 +983,43 @@ persist={{
**Deliverable**: Global search with keyboard-activated overlay
### Phase 5: Sorting & Filtering
-- [ ] Sorting via TanStack Table (click header, Shift+Click for multi)
-- [ ] Sort indicators in headers
-- [ ] Column filtering UI (per-column filter dropdowns)
-- [ ] Filter operators (contains, exact, between, etc.)
+- [x] Sorting via TanStack Table (click header, Shift+Click for multi)
+- [x] Sort indicators in headers
+- [x] Column filtering UI (right-click context menu for sort/filter options)
+- [x] Filter operators (contains, equals, startsWith, endsWith, notContains, isEmpty, isNotEmpty, between, greaterThan, lessThan, includes, excludes, etc.)
+- [x] Text, number, enum, and boolean filter types
+- [x] Filter UI with operator dropdown and type-specific inputs
+- [x] Filter status indicators (blue/gray icons in headers)
+- [x] Debounced text input (300ms)
+- [x] Apply/Clear buttons for filter controls
+- [ ] Date filtering (Phase 5.5 - requires @mantine/dates)
- [ ] Server-side sort/filter support (`manualSorting`, `manualFiltering`)
- [ ] Sort/filter state persistence
-**Deliverable**: Data manipulation features powered by TanStack Table
+**Deliverable**: Complete data manipulation features powered by TanStack Table
+
+**Files Created** (9 components):
+- `src/Griddy/features/filtering/types.ts` — Filter type system
+- `src/Griddy/features/filtering/operators.ts` — Operator definitions for all 4 types
+- `src/Griddy/features/filtering/filterFunctions.ts` — TanStack FilterFn implementations
+- `src/Griddy/features/filtering/FilterInput.tsx` — Text/number input with debouncing
+- `src/Griddy/features/filtering/FilterSelect.tsx` — Multi-select for enums
+- `src/Griddy/features/filtering/FilterBoolean.tsx` — Radio group for booleans
+- `src/Griddy/features/filtering/ColumnFilterButton.tsx` — Filter status icon
+- `src/Griddy/features/filtering/ColumnFilterPopover.tsx` — Filter UI popover
+- `src/Griddy/features/filtering/ColumnFilterContextMenu.tsx` — Right-click context menu
+
+**Files Modified**:
+- `src/Griddy/rendering/TableHeader.tsx` — Integrated context menu + filter popover
+- `src/Griddy/core/columnMapper.ts` — Set default filterFn for filterable columns
+- `src/Griddy/core/types.ts` — Added FilterConfig to GriddyColumn
+- `src/Griddy/core/constants.ts` — Added CSS class names and defaults
+- `src/Griddy/styles/griddy.module.css` — Filter UI styling
+- `src/Griddy/Griddy.stories.tsx` — Added 6 filtering examples
+
+**Tests**:
+- `playwright.config.ts` — Playwright configuration
+- `tests/e2e/filtering-context-menu.spec.ts` — 8 comprehensive E2E test cases
### Phase 6: In-Place Editing
- [ ] Implement `EditableCell.tsx` with editor mounting
diff --git a/src/Griddy/rendering/TableHeader.tsx b/src/Griddy/rendering/TableHeader.tsx
index 32df056..2a20b2d 100644
--- a/src/Griddy/rendering/TableHeader.tsx
+++ b/src/Griddy/rendering/TableHeader.tsx
@@ -1,12 +1,16 @@
import { Checkbox } from '@mantine/core'
import { flexRender } from '@tanstack/react-table'
+import { useState } from 'react'
import { CSS, SELECTION_COLUMN_ID } from '../core/constants'
import { useGriddyStore } from '../core/GriddyStore'
+import { ColumnFilterPopover, HeaderContextMenu } from '../features/filtering'
import styles from '../styles/griddy.module.css'
export function TableHeader() {
const table = useGriddyStore((s) => s._table)
+ const [filterPopoverOpen, setFilterPopoverOpen] = useState(null)
+
if (!table) return null
const headerGroups = table.getHeaderGroups()
@@ -19,6 +23,7 @@ export function TableHeader() {
const isSortable = header.column.getCanSort()
const sortDir = header.column.getIsSorted()
const isSelectionCol = header.column.id === SELECTION_COLUMN_ID
+ const isFilterPopoverOpen = filterPopoverOpen === header.column.id
return (
) : header.isPlaceholder ? null : (
- <>
- {flexRender(header.column.columnDef.header, header.getContext())}
- {sortDir && (
-
- {sortDir === 'asc' ? ' \u2191' : ' \u2193'}
-
- )}
- >
+ setFilterPopoverOpen(header.column.id)}
+ >
+
+ {flexRender(header.column.columnDef.header, header.getContext())}
+ {sortDir && (
+
+ {sortDir === 'asc' ? ' \u2191' : ' \u2193'}
+
+ )}
+ {header.column.getCanFilter() && (
+
+ setFilterPopoverOpen(opened ? header.column.id : null)
+ }
+ opened={isFilterPopoverOpen}
+ />
+ )}
+
+
)}
{header.column.getCanResize() && (
{
+ test.beforeEach(async ({ page }) => {
+ // Navigate to the WithTextFiltering story
+ await page.goto(
+ '/?path=/story/components-griddy--with-text-filtering'
+ )
+ // Wait for the Griddy table to load
+ await page.waitForSelector('[role="table"]', { timeout: 5000 })
+ })
+
+ test('should show context menu on right-click on column header', async ({ page }) => {
+ // Right-click on "First Name" header
+ const firstNameHeader = page.locator('[role="columnheader"]').first()
+ await firstNameHeader.click({ button: 'right' })
+
+ // Check if context menu items appear
+ await expect(page.locator('text=Sort')).toBeVisible({ timeout: 2000 })
+ await expect(page.locator('text=Open Filters')).toBeVisible()
+ })
+
+ test('should show "Reset Sorting" option when column is sorted', async ({ page }) => {
+ const firstNameHeader = page.locator('[role="columnheader"]').first()
+
+ // Click to sort the column
+ await firstNameHeader.click()
+
+ // Wait a moment for state update
+ await page.waitForTimeout(100)
+
+ // Right-click to open context menu
+ await firstNameHeader.click({ button: 'right' })
+
+ // Check if "Reset Sorting" appears
+ await expect(page.locator('text=Reset Sorting')).toBeVisible({ timeout: 2000 })
+ })
+
+ test('should show "Reset Filter" option when filter is active', async ({ page }) => {
+ const firstNameHeader = page.locator('[role="columnheader"]').first()
+
+ // Right-click to open context menu
+ await firstNameHeader.click({ button: 'right' })
+
+ // Click "Open Filters"
+ await page.locator('text=Open Filters').click()
+
+ // Wait for filter popover to appear
+ await expect(page.locator('text=Filter: firstName')).toBeVisible({ timeout: 2000 })
+
+ // Set a filter value
+ const textInput = page.locator('input[placeholder="Enter value..."]')
+ await textInput.fill('Alice')
+
+ // Click Apply button
+ await page.locator('button:has-text("Apply")').click()
+
+ // Wait for popover to close
+ await page.waitForTimeout(200)
+
+ // Right-click again to open context menu
+ await firstNameHeader.click({ button: 'right' })
+
+ // Check if "Reset Filter" appears
+ await expect(page.locator('text=Reset Filter')).toBeVisible({ timeout: 2000 })
+ })
+
+ test('should open filter panel when "Open Filters" is clicked', async ({ page }) => {
+ const firstNameHeader = page.locator('[role="columnheader"]').first()
+
+ // Right-click to open context menu
+ await firstNameHeader.click({ button: 'right' })
+
+ // Click "Open Filters"
+ await page.locator('text=Open Filters').click()
+
+ // Check if filter popover appears with correct title
+ await expect(page.locator('text=Filter: firstName')).toBeVisible({ timeout: 2000 })
+
+ // Check if operator dropdown exists
+ await expect(page.locator('text=Operator').or(page.locator('[role="combobox"]'))).toBeVisible()
+
+ // Check if input field exists
+ await expect(page.locator('input[placeholder="Enter value..."]')).toBeVisible()
+
+ // Check if Apply and Clear buttons exist
+ await expect(page.locator('button:has-text("Apply")')).toBeVisible()
+ await expect(page.locator('button:has-text("Clear")')).toBeVisible()
+ })
+
+ test('should filter data when filter is applied', async ({ page }) => {
+ const firstNameHeader = page.locator('[role="columnheader"]').first()
+
+ // Right-click to open context menu
+ await firstNameHeader.click({ button: 'right' })
+
+ // Click "Open Filters"
+ await page.locator('text=Open Filters').click()
+
+ // Wait for filter popover
+ await expect(page.locator('text=Filter: firstName')).toBeVisible({ timeout: 2000 })
+
+ // Set filter value to "Alice"
+ const textInput = page.locator('input[placeholder="Enter value..."]')
+ await textInput.fill('Alice')
+
+ // Wait for debounce (300ms)
+ await page.waitForTimeout(350)
+
+ // Click Apply button
+ await page.locator('button:has-text("Apply")').click()
+
+ // Wait for filter to be applied
+ await page.waitForTimeout(200)
+
+ // Check that filter icon is now blue (active)
+ const filterButton = page.locator('[aria-label="Filter status indicator"]').first()
+ const color = await filterButton.evaluate((el: any) => {
+ return window.getComputedStyle(el).color
+ })
+
+ // Blue color should be present (filter is active)
+ expect(color).toContain('rgb')
+ })
+
+ test('should clear filter when "Reset Filter" is clicked', async ({ page }) => {
+ const firstNameHeader = page.locator('[role="columnheader"]').first()
+
+ // Open filter and apply one
+ await firstNameHeader.click({ button: 'right' })
+ await page.locator('text=Open Filters').click()
+ await expect(page.locator('text=Filter: firstName')).toBeVisible({ timeout: 2000 })
+
+ const textInput = page.locator('input[placeholder="Enter value..."]')
+ await textInput.fill('Alice')
+ await page.waitForTimeout(350)
+ await page.locator('button:has-text("Apply")').click()
+ await page.waitForTimeout(200)
+
+ // Now clear the filter via context menu
+ await firstNameHeader.click({ button: 'right' })
+ await page.locator('text=Reset Filter').click()
+
+ // Wait for state update
+ await page.waitForTimeout(200)
+
+ // Right-click again to verify "Reset Filter" is gone
+ await firstNameHeader.click({ button: 'right' })
+
+ // "Reset Filter" should not be visible
+ const resetFilterItem = page.locator('text=Reset Filter').first()
+ await expect(resetFilterItem).not.toBeVisible({ timeout: 2000 })
+ })
+
+ test('should have visible filter status icon', async ({ page }) => {
+ // Check that filter icons are visible in headers
+ const filterButton = page.locator('[aria-label="Filter status indicator"]').first()
+ await expect(filterButton).toBeVisible()
+
+ // Check that the icon has opacity (should be visible)
+ const opacity = await filterButton.evaluate((el: any) => {
+ return window.getComputedStyle(el).opacity
+ })
+
+ expect(parseFloat(opacity)).toBeGreaterThan(0.5)
+ })
+})