chore: griddy work

This commit is contained in:
Hein
2026-02-13 17:09:49 +02:00
parent 7ecafc8461
commit b49d008745
24 changed files with 2184 additions and 22 deletions

View File

@@ -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",

View File

@@ -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]
```

File diff suppressed because one or more lines are too long

23
playwright.config.ts Normal file
View File

@@ -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,
});

38
pnpm-lock.yaml generated
View File

@@ -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):

View File

@@ -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

View File

@@ -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<ColumnFiltersState>([])
const filterColumns: GriddyColumn<Person>[] = [
{ 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 (
<Box h="100%" mih="500px" w="100%">
<Griddy<Person>
columnFilters={filters}
columns={filterColumns}
data={smallData}
getRowId={(row) => String(row.id)}
height={500}
onColumnFiltersChange={setFilters}
/>
<Box mt="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}>
<strong>Active Filters:</strong>
<pre style={{ margin: '4px 0' }}>{JSON.stringify(filters, null, 2)}</pre>
</Box>
</Box>
)
},
}
/** Number filtering with operators like equals, between, greater than, etc. */
export const WithNumberFiltering: Story = {
render: () => {
const [filters, setFilters] = useState<ColumnFiltersState>([])
const filterColumns: GriddyColumn<Person>[] = [
{ 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 (
<Box h="100%" mih="500px" w="100%">
<Griddy<Person>
columnFilters={filters}
columns={filterColumns}
data={smallData}
getRowId={(row) => String(row.id)}
height={500}
onColumnFiltersChange={setFilters}
/>
<Box mt="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}>
<strong>Active Filters:</strong>
<pre style={{ margin: '4px 0' }}>{JSON.stringify(filters, null, 2)}</pre>
</Box>
</Box>
)
},
}
/** Enum (multi-select) filtering with includes/excludes operators */
export const WithEnumFiltering: Story = {
render: () => {
const [filters, setFilters] = useState<ColumnFiltersState>([])
const filterColumns: GriddyColumn<Person>[] = [
{ 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 (
<Box h="100%" mih="500px" w="100%">
<Griddy<Person>
columnFilters={filters}
columns={filterColumns}
data={smallData}
getRowId={(row) => String(row.id)}
height={500}
onColumnFiltersChange={setFilters}
/>
<Box mt="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}>
<strong>Active Filters:</strong>
<pre style={{ margin: '4px 0' }}>{JSON.stringify(filters, null, 2)}</pre>
</Box>
</Box>
)
},
}
/** Boolean filtering with true/false/all operators */
export const WithBooleanFiltering: Story = {
render: () => {
const [filters, setFilters] = useState<ColumnFiltersState>([])
const filterColumns: GriddyColumn<Person>[] = [
{ 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 (
<Box h="100%" mih="500px" w="100%">
<Griddy<Person>
columnFilters={filters}
columns={filterColumns}
data={smallData}
getRowId={(row) => String(row.id)}
height={500}
onColumnFiltersChange={setFilters}
/>
<Box mt="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}>
<strong>Active Filters:</strong>
<pre style={{ margin: '4px 0' }}>{JSON.stringify(filters, null, 2)}</pre>
</Box>
</Box>
)
},
}
/** Combined filtering - all filter types together */
export const WithAllFilterTypes: Story = {
render: () => {
const [filters, setFilters] = useState<ColumnFiltersState>([])
const filterColumns: GriddyColumn<Person>[] = [
{ 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 (
<Box h="100%" mih="500px" w="100%">
<Griddy<Person>
columnFilters={filters}
columns={filterColumns}
data={smallData}
getRowId={(row) => String(row.id)}
height={500}
onColumnFiltersChange={setFilters}
/>
<Box mt="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}>
<strong>Active Filters (AND logic - all must match):</strong>
<pre style={{ margin: '4px 0' }}>{JSON.stringify(filters, null, 2)}</pre>
</Box>
</Box>
)
},
}
/** Large dataset with filtering and sorting */
export const LargeDatasetWithFiltering: Story = {
render: () => {
const [filters, setFilters] = useState<ColumnFiltersState>([])
const filterColumns: GriddyColumn<Person>[] = [
{ 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 (
<Box h="100%" mih="600px" w="100%">
<Griddy<Person>
columnFilters={filters}
columns={filterColumns}
data={largeData}
getRowId={(row) => String(row.id)}
height={600}
onColumnFiltersChange={setFilters}
/>
<Box mt="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}>
<strong>Active Filters:</strong>
<pre style={{ margin: '4px 0' }}>{JSON.stringify(filters, null, 2)}</pre>
</Box>
</Box>
)
},
}

View File

@@ -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<T>(
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
})

View File

@@ -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,

View File

@@ -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<T> = (props: RendererProps<T>) => ReactNode
@@ -45,6 +47,7 @@ export interface GriddyColumn<T> {
editable?: ((row: T) => boolean) | boolean
editor?: EditorComponent<T>
filterable?: boolean
filterConfig?: FilterConfig
filterFn?: FilterFn<T>
header: ReactNode | string
headerGroup?: string

View File

@@ -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<any, any>
}
export function ColumnFilterButton({ column }: ColumnFilterButtonProps) {
const isActive = !!column.getFilterValue()
return (
<ActionIcon
aria-label="Filter status indicator"
className={[
styles[CSS.filterButton],
isActive ? styles[CSS.filterButtonActive] : '',
]
.filter(Boolean)
.join(' ')}
color={isActive ? 'blue' : 'gray'}
disabled
size="xs"
variant="subtle"
>
<IconFilter size={14} />
</ActionIcon>
)
}

View File

@@ -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<any, any>
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 (
<>
<div onContextMenu={handleContextMenu}>
{children}
</div>
{contextMenu && (
<Menu
closeOnClickOutside
closeOnEscape
onClose={() => setContextMenu(null)}
opened={true}
position="bottom-start"
withinPortal
>
<Menu.Dropdown style={{ left: contextMenu.x, position: 'fixed', top: contextMenu.y }}>
{column.getCanSort() && (
<>
<Menu.Item leftSection={<IconSortAscending size={14} />} onClick={handleSort}>
Sort {column.getIsSorted() === 'asc' ? '↓' : '↑'}
</Menu.Item>
{column.getIsSorted() && (
<Menu.Item leftSection={<IconTrash size={14} />} onClick={handleClearSort}>
Reset Sorting
</Menu.Item>
)}
</>
)}
{column.getCanFilter() && (
<>
{column.getFilterValue() && (
<Menu.Item leftSection={<IconTrash size={14} />} onClick={handleResetFilter}>
Reset Filter
</Menu.Item>
)}
<Menu.Item leftSection={<IconFilter size={14} />} onClick={handleOpenFilter}>
Open Filters
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
)}
</>
)
}

View File

@@ -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<any, any>
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<FilterValue | undefined>(
(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 (
<Popover onChange={setOpened} onClose={handleClose} opened={opened} position="bottom-start" withinPortal>
<Popover.Target>
<ColumnFilterButton column={column} />
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm" w={280}>
<Text fw={600} size="sm">
Filter: {column.id}
</Text>
{filterConfig.type === 'text' && (
<FilterInput
onChange={setLocalValue}
operators={operators}
type="text"
value={localValue}
/>
)}
{filterConfig.type === 'number' && (
<FilterInput
onChange={setLocalValue}
operators={operators}
type="number"
value={localValue}
/>
)}
{filterConfig.type === 'enum' && filterConfig.enumOptions && (
<FilterSelect
onChange={setLocalValue}
operators={operators}
options={filterConfig.enumOptions}
value={localValue}
/>
)}
{filterConfig.type === 'boolean' && (
<FilterBoolean onChange={setLocalValue} value={localValue} />
)}
<Group justify="flex-end">
<Button onClick={handleClear} size="xs" variant="subtle">
Clear
</Button>
<Button onClick={handleApply} size="xs">
Apply
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
)
}

View File

@@ -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<string>(value?.operator || 'isEmpty')
const handleChange = (op: string) => {
setOperator(op)
onChange({
operator: op,
})
}
return (
<Stack gap="xs" onClick={(e) => e.stopPropagation()}>
<Radio.Group label="Filter by" onChange={handleChange} value={operator}>
<Stack gap="xs">
<Radio label="True" value="isTrue" />
<Radio label="False" value="isFalse" />
<Radio label="All (no filter)" value="isEmpty" />
</Stack>
</Radio.Group>
</Stack>
)
}

View File

@@ -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<string>(value?.operator || operators[0]?.id || '')
const [inputValue, setInputValue] = useState<number | string | undefined>(
value?.value !== undefined && value?.value !== null ? value.value : undefined,
)
const debounceTimerRef = useRef<NodeJS.Timeout | null>(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 (
<Stack gap="xs" onClick={(e) => e.stopPropagation()}>
<Select
data={operators.map((op) => ({ label: op.label, value: op.id }))}
label="Operator"
onChange={handleOperatorChange}
searchable
size="xs"
value={operator}
/>
<Group grow>
<NumberInput
label="Min"
onChange={(val) => {
onChange({
max: max === undefined ? undefined : Number(max),
min: val === undefined || val === null ? undefined : Number(val),
operator: 'between',
})
}}
placeholder="Minimum"
size="xs"
value={min}
/>
<NumberInput
label="Max"
onChange={(val) => {
onChange({
max: val === undefined || val === null ? undefined : Number(val),
min: min === undefined ? undefined : Number(min),
operator: 'between',
})
}}
placeholder="Maximum"
size="xs"
value={max}
/>
</Group>
</Stack>
)
}
return (
<Stack gap="xs" onClick={(e) => e.stopPropagation()}>
<Select
data={operators.map((op) => ({ label: op.label, value: op.id }))}
label="Operator"
onChange={handleOperatorChange}
searchable
size="xs"
value={operator}
/>
{requiresValue && type === 'text' && (
<TextInput
autoFocus
onChange={(e) => setInputValue(e.currentTarget.value)}
placeholder="Enter value..."
rightSection={
inputValue && (
<ActionIcon color="gray" onClick={handleClear} size="xs" variant="subtle">
<IconX size={14} />
</ActionIcon>
)
}
size="xs"
value={inputValue === undefined ? '' : String(inputValue)}
/>
)}
{requiresValue && type === 'number' && (
<NumberInput
autoFocus
onChange={(val) => setInputValue(val)}
placeholder="Enter number..."
size="xs"
value={inputValue as number | undefined}
/>
)}
</Stack>
)
}

View File

@@ -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<string>(value?.operator || operators[0]?.id || 'includes')
const [selectedValues, setSelectedValues] = useState<string[]>(
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 (
<Stack gap="xs" onClick={(e) => e.stopPropagation()}>
<Select
data={operators.map((op) => ({ label: op.label, value: op.id }))}
label="Operator"
onChange={handleOperatorChange}
searchable
size="xs"
value={operator}
/>
{operator !== 'isEmpty' && (
<MultiSelect
autoFocus
clearable
data={selectOptions}
label="Select values"
onChange={handleValuesChange}
placeholder="Choose values..."
searchable
size="xs"
value={selectedValues}
/>
)}
</Stack>
)
}

View File

@@ -0,0 +1,179 @@
import type { FilterFn } from '@tanstack/react-table'
import type { FilterValue } from './types'
// ─── Text Filter Functions ──────────────────────────────────────────────────
const textContains: FilterFn<any> = (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<any> = (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<any> = (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<any> = (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<any> = (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<any> = (row: any, columnId: string) => {
const value = row.getValue(columnId)
return value == null || String(value).trim() === ''
}
const textIsNotEmpty: FilterFn<any> = (row: any, columnId: string) => {
const value = row.getValue(columnId)
return value != null && String(value).trim() !== ''
}
// ─── Number Filter Functions ────────────────────────────────────────────────
const numberEquals: FilterFn<any> = (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<any> = (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<any> = (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<any> = (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<any> = (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<any> = (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<any> = (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<any> = (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<any> = (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<any> = (row: any, columnId: string) => {
const value = row.getValue(columnId)
return value === true || value === 1 || String(value).toLowerCase() === 'true'
}
const booleanIsFalse: FilterFn<any> = (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<string, FilterFn<any>> = {
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<any>,
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<any>,
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<any>,
isTrue: booleanIsTrue,
lessThan: numberLessThan,
lessThanOrEqual: numberLessThanOrEqual,
notContains: textNotContains,
notEquals: numberNotEquals,
startsWith: textStartsWith,
textIsEmpty,
textIsNotEmpty,
}
// ─── Universal Filter Function ──────────────────────────────────────────────
export function createOperatorFilter(): FilterFn<any> {
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)
}
}

View File

@@ -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'

View File

@@ -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

View File

@@ -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[]
}

View File

@@ -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

View File

@@ -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 | string>(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 (
<div
@@ -36,14 +41,28 @@ export function TableHeader() {
{isSelectionCol ? (
<SelectAllCheckbox />
) : header.isPlaceholder ? null : (
<>
<HeaderContextMenu
column={header.column}
onOpenFilter={() => setFilterPopoverOpen(header.column.id)}
>
<div className={styles[CSS.headerCellContent]}>
{flexRender(header.column.columnDef.header, header.getContext())}
{sortDir && (
<span className={styles[CSS.sortIndicator]}>
{sortDir === 'asc' ? ' \u2191' : ' \u2193'}
</span>
)}
</>
{header.column.getCanFilter() && (
<ColumnFilterPopover
column={header.column}
onOpenedChange={(opened) =>
setFilterPopoverOpen(opened ? header.column.id : null)
}
opened={isFilterPopoverOpen}
/>
)}
</div>
</HeaderContextMenu>
)}
{header.column.getCanResize() && (
<div

View File

@@ -89,6 +89,35 @@
font-size: 12px;
}
/* ─── Header Cell Content ──────────────────────────────────────────────── */
.griddy-header-cell-content {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ─── Filter Button ────────────────────────────────────────────────────── */
.griddy-filter-button {
flex-shrink: 0;
opacity: 0.65;
transition: opacity 0.2s ease;
}
.griddy-filter-button:hover {
opacity: 0.85;
}
.griddy-filter-button--active {
opacity: 1;
}
/* ─── Resize Handle ────────────────────────────────────────────────────── */
.griddy-resize-handle {

View File

@@ -0,0 +1,167 @@
import { expect, test } from '@playwright/test'
test.describe('Griddy Filtering - Context Menu', () => {
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)
})
})