@mostrom/app-shell 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/.claude/ralph-loop.local.md +9 -0
  2. package/README.md +172 -0
  3. package/bin/init.js +269 -0
  4. package/bun.lock +401 -0
  5. package/components.json +28 -0
  6. package/package.json +74 -0
  7. package/scripts/publish-npm.sh +202 -0
  8. package/src/AppShell.tsx +847 -0
  9. package/src/components/PageHeader.tsx +160 -0
  10. package/src/components/data-table/README.md +447 -0
  11. package/src/components/data-table/data-table-preferences.tsx +184 -0
  12. package/src/components/data-table/data-table-toolbar.tsx +118 -0
  13. package/src/components/data-table/data-table.tsx +37 -0
  14. package/src/components/data-table/index.ts +32 -0
  15. package/src/components/global-header/AllServicesButton.tsx +127 -0
  16. package/src/components/global-header/CategoriesButton.tsx +120 -0
  17. package/src/components/global-header/GlobalHeader.tsx +59 -0
  18. package/src/components/global-header/GlobalHeaderSearch.tsx +57 -0
  19. package/src/components/global-header/HeaderUtilities.tsx +243 -0
  20. package/src/components/global-header/ServicesMenu.tsx +246 -0
  21. package/src/components/layout/AppBreadcrumb.tsx +70 -0
  22. package/src/components/layout/AppFlashbar.tsx +95 -0
  23. package/src/components/layout/AppLayout.tsx +271 -0
  24. package/src/components/layout/AppNavigation.tsx +313 -0
  25. package/src/components/layout/AppSidebar.tsx +229 -0
  26. package/src/components/patterns/index.ts +14 -0
  27. package/src/components/patterns/p-alert-5.tsx +19 -0
  28. package/src/components/patterns/p-autocomplete-5.tsx +89 -0
  29. package/src/components/patterns/p-breadcrumb-1.tsx +28 -0
  30. package/src/components/patterns/p-button-42.tsx +37 -0
  31. package/src/components/patterns/p-button-51.tsx +14 -0
  32. package/src/components/patterns/p-button-6.tsx +5 -0
  33. package/src/components/patterns/p-calendar-1.tsx +18 -0
  34. package/src/components/patterns/p-card-1.tsx +33 -0
  35. package/src/components/patterns/p-card-2.tsx +26 -0
  36. package/src/components/patterns/p-card-5.tsx +31 -0
  37. package/src/components/patterns/p-collapsible-7.tsx +121 -0
  38. package/src/components/patterns/p-command-6.tsx +113 -0
  39. package/src/components/patterns/p-dialog-1.tsx +56 -0
  40. package/src/components/patterns/p-dropdown-menu-1.tsx +38 -0
  41. package/src/components/patterns/p-dropdown-menu-11.tsx +122 -0
  42. package/src/components/patterns/p-dropdown-menu-14.tsx +165 -0
  43. package/src/components/patterns/p-dropdown-menu-9.tsx +108 -0
  44. package/src/components/patterns/p-empty-2.tsx +34 -0
  45. package/src/components/patterns/p-file-upload-1.tsx +72 -0
  46. package/src/components/patterns/p-filters-1.tsx +666 -0
  47. package/src/components/patterns/p-frame-2.tsx +26 -0
  48. package/src/components/patterns/p-tabs-2.tsx +129 -0
  49. package/src/components/reui/alert.tsx +92 -0
  50. package/src/components/reui/autocomplete.tsx +343 -0
  51. package/src/components/reui/badge.tsx +87 -0
  52. package/src/components/reui/data-grid/data-grid-column-filter.tsx +165 -0
  53. package/src/components/reui/data-grid/data-grid-column-header.tsx +339 -0
  54. package/src/components/reui/data-grid/data-grid-column-visibility.tsx +55 -0
  55. package/src/components/reui/data-grid/data-grid-pagination.tsx +224 -0
  56. package/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx +260 -0
  57. package/src/components/reui/data-grid/data-grid-table-dnd.tsx +253 -0
  58. package/src/components/reui/data-grid/data-grid-table.tsx +639 -0
  59. package/src/components/reui/data-grid/data-grid.tsx +209 -0
  60. package/src/components/reui/date-selector.tsx +1330 -0
  61. package/src/components/reui/filters.tsx +1869 -0
  62. package/src/components/reui/frame.tsx +134 -0
  63. package/src/components/reui/index.ts +17 -0
  64. package/src/components/reui/timeline.tsx +219 -0
  65. package/src/components/search/Autocomplete.tsx +183 -0
  66. package/src/components/search/AutocompleteClient.tsx +293 -0
  67. package/src/components/search/GlobalSearch.tsx +187 -0
  68. package/src/components/section-drawer/deal-drawer-content.tsx +891 -0
  69. package/src/components/section-drawer/index.ts +19 -0
  70. package/src/components/section-drawer/section-drawer.css +665 -0
  71. package/src/components/section-drawer/section-drawer.tsx +467 -0
  72. package/src/components/sectioned-list-board/README.md +78 -0
  73. package/src/components/sectioned-list-board/board-card-content.tsx +340 -0
  74. package/src/components/sectioned-list-board/date-range-filter.tsx +249 -0
  75. package/src/components/sectioned-list-board/index.ts +19 -0
  76. package/src/components/sectioned-list-board/sectioned-list-board.css +564 -0
  77. package/src/components/sectioned-list-board/sectioned-list-board.tsx +731 -0
  78. package/src/components/sectioned-list-board/sortable-card.tsx +314 -0
  79. package/src/components/sectioned-list-board/sortable-section.tsx +319 -0
  80. package/src/components/sectioned-list-board/types.ts +216 -0
  81. package/src/components/sectioned-list-table/README.md +80 -0
  82. package/src/components/sectioned-list-table/index.ts +14 -0
  83. package/src/components/sectioned-list-table/sectioned-list-table.css +534 -0
  84. package/src/components/sectioned-list-table/sectioned-list-table.tsx +740 -0
  85. package/src/components/sectioned-list-table/sortable-column-header.tsx +120 -0
  86. package/src/components/sectioned-list-table/sortable-row.tsx +420 -0
  87. package/src/components/sectioned-list-table/sortable-section.tsx +251 -0
  88. package/src/components/sectioned-list-table/table-cell-content.tsx +129 -0
  89. package/src/components/sectioned-list-table/types.ts +120 -0
  90. package/src/components/sectioned-list-table/use-column-preferences.ts +103 -0
  91. package/src/components/ui/actions-dropdown.tsx +109 -0
  92. package/src/components/ui/assignee-selector.tsx +209 -0
  93. package/src/components/ui/avatar.tsx +107 -0
  94. package/src/components/ui/breadcrumb.tsx +109 -0
  95. package/src/components/ui/button-group.tsx +83 -0
  96. package/src/components/ui/button.tsx +64 -0
  97. package/src/components/ui/calendar.tsx +220 -0
  98. package/src/components/ui/card.tsx +92 -0
  99. package/src/components/ui/chart.tsx +376 -0
  100. package/src/components/ui/checkbox.tsx +30 -0
  101. package/src/components/ui/collapsible.tsx +33 -0
  102. package/src/components/ui/command.tsx +182 -0
  103. package/src/components/ui/context-menu.tsx +250 -0
  104. package/src/components/ui/create-button-group.tsx +128 -0
  105. package/src/components/ui/dialog.tsx +156 -0
  106. package/src/components/ui/drawer.tsx +133 -0
  107. package/src/components/ui/dropdown-menu.tsx +255 -0
  108. package/src/components/ui/empty.tsx +104 -0
  109. package/src/components/ui/field.tsx +248 -0
  110. package/src/components/ui/form.tsx +165 -0
  111. package/src/components/ui/index.ts +37 -0
  112. package/src/components/ui/input-group.tsx +168 -0
  113. package/src/components/ui/input.tsx +21 -0
  114. package/src/components/ui/kbd.tsx +28 -0
  115. package/src/components/ui/label.tsx +22 -0
  116. package/src/components/ui/navigation-menu.tsx +168 -0
  117. package/src/components/ui/page-header.tsx +80 -0
  118. package/src/components/ui/popover.tsx +87 -0
  119. package/src/components/ui/scroll-area.tsx +56 -0
  120. package/src/components/ui/select.tsx +190 -0
  121. package/src/components/ui/separator.tsx +26 -0
  122. package/src/components/ui/sheet.tsx +141 -0
  123. package/src/components/ui/sidebar.tsx +726 -0
  124. package/src/components/ui/skeleton.tsx +13 -0
  125. package/src/components/ui/sonner.tsx +38 -0
  126. package/src/components/ui/switch.tsx +33 -0
  127. package/src/components/ui/tabs.tsx +91 -0
  128. package/src/components/ui/textarea.tsx +18 -0
  129. package/src/components/ui/toggle-group.tsx +83 -0
  130. package/src/components/ui/toggle.tsx +45 -0
  131. package/src/components/ui/tooltip.tsx +57 -0
  132. package/src/hooks/use-copy-to-clipboard.ts +37 -0
  133. package/src/hooks/use-file-upload.ts +415 -0
  134. package/src/hooks/use-mobile.ts +19 -0
  135. package/src/index.ts +95 -0
  136. package/src/lib/utils.ts +6 -0
  137. package/src/styles.css +1859 -0
  138. package/src/urls.ts +83 -0
  139. package/src/vite.d.ts +22 -0
  140. package/src/vite.js +241 -0
  141. package/tsconfig.base.json +18 -0
  142. package/tsconfig.json +24 -0
@@ -0,0 +1,1869 @@
1
+ "use client"
2
+
3
+ import type React from "react"
4
+ import {
5
+ createContext,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useId,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ } from "react"
14
+ import { cva } from "class-variance-authority"
15
+
16
+ import { cn } from "@/lib/utils"
17
+ import { Button } from "@/components/ui/button"
18
+ import {
19
+ ButtonGroup,
20
+ ButtonGroupText,
21
+ } from "@/components/ui/button-group"
22
+ import {
23
+ DropdownMenu,
24
+ DropdownMenuCheckboxItem,
25
+ DropdownMenuContent,
26
+ DropdownMenuGroup,
27
+ DropdownMenuItem,
28
+ DropdownMenuSeparator,
29
+ DropdownMenuSub,
30
+ DropdownMenuSubContent,
31
+ DropdownMenuSubTrigger,
32
+ DropdownMenuTrigger,
33
+ } from "@/components/ui/dropdown-menu"
34
+ import { Input } from "@/components/ui/input"
35
+ import {
36
+ InputGroup,
37
+ InputGroupAddon,
38
+ InputGroupButton,
39
+ InputGroupInput,
40
+ InputGroupText,
41
+ } from "@/components/ui/input-group"
42
+ import { Kbd } from "@/components/ui/kbd"
43
+ import { ScrollArea } from "@/components/ui/scroll-area"
44
+ import {
45
+ Tooltip,
46
+ TooltipContent,
47
+ TooltipProvider,
48
+ TooltipTrigger,
49
+ } from "@/components/ui/tooltip"
50
+ import { AlertCircleIcon, XIcon, CheckIcon, PlusIcon } from "lucide-react"
51
+
52
+ // i18n Configuration Interface
53
+ export interface FilterI18nConfig {
54
+ // UI Labels
55
+ addFilter: string
56
+ searchFields: string
57
+ noFieldsFound: string
58
+ noResultsFound: string
59
+ select: string
60
+ true: string
61
+ false: string
62
+ min: string
63
+ max: string
64
+ to: string
65
+ typeAndPressEnter: string
66
+ selected: string
67
+ selectedCount: string
68
+ percent: string
69
+ defaultCurrency: string
70
+ defaultColor: string
71
+ addFilterTitle: string
72
+
73
+ // Operators
74
+ operators: {
75
+ is: string
76
+ isNot: string
77
+ isAnyOf: string
78
+ isNotAnyOf: string
79
+ includesAll: string
80
+ excludesAll: string
81
+ before: string
82
+ after: string
83
+ between: string
84
+ notBetween: string
85
+ contains: string
86
+ notContains: string
87
+ startsWith: string
88
+ endsWith: string
89
+ isExactly: string
90
+ equals: string
91
+ notEquals: string
92
+ greaterThan: string
93
+ lessThan: string
94
+ overlaps: string
95
+ includes: string
96
+ excludes: string
97
+ includesAllOf: string
98
+ includesAnyOf: string
99
+ empty: string
100
+ notEmpty: string
101
+ }
102
+
103
+ // Placeholders
104
+ placeholders: {
105
+ enterField: (fieldType: string) => string
106
+ selectField: string
107
+ searchField: (fieldName: string) => string
108
+ enterKey: string
109
+ enterValue: string
110
+ }
111
+
112
+ // Helper functions
113
+ helpers: {
114
+ formatOperator: (operator: string) => string
115
+ }
116
+
117
+ // Validation
118
+ validation: {
119
+ invalidEmail: string
120
+ invalidUrl: string
121
+ invalidTel: string
122
+ invalid: string
123
+ }
124
+ }
125
+
126
+ // Default English i18n configuration
127
+ export const DEFAULT_I18N: FilterI18nConfig = {
128
+ // UI Labels
129
+ addFilter: "Filter",
130
+ searchFields: "Filter...",
131
+ noFieldsFound: "No filters found.",
132
+ noResultsFound: "No results found.",
133
+ select: "Select...",
134
+ true: "True",
135
+ false: "False",
136
+ min: "Min",
137
+ max: "Max",
138
+ to: "to",
139
+ typeAndPressEnter: "Type and press Enter to add tag",
140
+ selected: "selected",
141
+ selectedCount: "selected",
142
+ percent: "%",
143
+ defaultCurrency: "$",
144
+ defaultColor: "#000000",
145
+ addFilterTitle: "Add filter",
146
+
147
+ // Operators
148
+ operators: {
149
+ is: "is",
150
+ isNot: "is not",
151
+ isAnyOf: "is any of",
152
+ isNotAnyOf: "is not any of",
153
+ includesAll: "includes all",
154
+ excludesAll: "excludes all",
155
+ before: "before",
156
+ after: "after",
157
+ between: "between",
158
+ notBetween: "not between",
159
+ contains: "contains",
160
+ notContains: "does not contain",
161
+ startsWith: "starts with",
162
+ endsWith: "ends with",
163
+ isExactly: "is exactly",
164
+ equals: "equals",
165
+ notEquals: "not equals",
166
+ greaterThan: "greater than",
167
+ lessThan: "less than",
168
+ overlaps: "overlaps",
169
+ includes: "includes",
170
+ excludes: "excludes",
171
+ includesAllOf: "includes all of",
172
+ includesAnyOf: "includes any of",
173
+ empty: "is empty",
174
+ notEmpty: "is not empty",
175
+ },
176
+
177
+ // Placeholders
178
+ placeholders: {
179
+ enterField: (fieldType: string) => `Enter ${fieldType}...`,
180
+ selectField: "Select...",
181
+ searchField: (fieldName: string) => `Search ${fieldName.toLowerCase()}...`,
182
+ enterKey: "Enter key...",
183
+ enterValue: "Enter value...",
184
+ },
185
+
186
+ // Helper functions
187
+ helpers: {
188
+ formatOperator: (operator: string) => operator.replace(/_/g, " "),
189
+ },
190
+
191
+ // Validation
192
+ validation: {
193
+ invalidEmail: "Invalid email format",
194
+ invalidUrl: "Invalid URL format",
195
+ invalidTel: "Invalid phone format",
196
+ invalid: "Invalid input format",
197
+ },
198
+ }
199
+
200
+ // Context for all Filter component props
201
+ interface FilterContextValue {
202
+ variant: "solid" | "default"
203
+ size: "sm" | "default" | "lg"
204
+ radius: "default" | "full"
205
+ i18n: FilterI18nConfig
206
+ className?: string
207
+ showSearchInput?: boolean
208
+ trigger?: React.ReactNode
209
+ allowMultiple?: boolean
210
+ }
211
+
212
+ const FilterContext = createContext<FilterContextValue>({
213
+ variant: "default",
214
+ size: "default",
215
+ radius: "default",
216
+ i18n: DEFAULT_I18N,
217
+ className: undefined,
218
+ showSearchInput: true,
219
+ trigger: undefined,
220
+ allowMultiple: true,
221
+ })
222
+
223
+ const useFilterContext = () => useContext(FilterContext)
224
+
225
+ // Container variant for filters wrapper
226
+ const filtersContainerVariants = cva("flex flex-wrap items-center", {
227
+ variants: {
228
+ variant: {
229
+ solid: "gap-2",
230
+ default: "",
231
+ },
232
+ size: {
233
+ sm: "gap-1.5",
234
+ default: "gap-2.5",
235
+ lg: "gap-3.5",
236
+ },
237
+ },
238
+ defaultVariants: {
239
+ variant: "default",
240
+ size: "default",
241
+ },
242
+ })
243
+
244
+ function FilterInput<T = unknown>({
245
+ field,
246
+ onBlur,
247
+ onKeyDown,
248
+ className,
249
+ ...props
250
+ }: React.InputHTMLAttributes<HTMLInputElement> & {
251
+ className?: string
252
+ field?: FilterFieldConfig<T>
253
+ }) {
254
+ const context = useFilterContext()
255
+ const [isValid, setIsValid] = useState(true)
256
+ const [validationMessage, setValidationMessage] = useState("")
257
+ const inputRef = useRef<HTMLInputElement>(null)
258
+
259
+ useEffect(() => {
260
+ if (props.autoFocus) {
261
+ const timer = setTimeout(() => {
262
+ inputRef.current?.focus()
263
+ }, 300)
264
+ return () => clearTimeout(timer)
265
+ }
266
+ }, [props.autoFocus])
267
+
268
+ // Validation function to check if input matches pattern
269
+ const validateInput = (value: string, pattern?: string): boolean => {
270
+ if (!pattern || !value) return true
271
+ const regex = new RegExp(pattern)
272
+ return regex.test(value)
273
+ }
274
+
275
+ // Get validation message for field type
276
+ const getValidationMessage = (): string => {
277
+ return context.i18n.validation.invalid
278
+ }
279
+
280
+ // Handle blur event - validate when user leaves input
281
+ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
282
+ const value = e.target.value
283
+ const pattern = field?.pattern || props.pattern
284
+
285
+ // Only validate if there's a value and (pattern or validation function)
286
+ if (value && (pattern || field?.validation)) {
287
+ let valid = true
288
+ let customMessage = ""
289
+
290
+ // If there's a custom validation function, use it
291
+ if (field?.validation) {
292
+ const result = field.validation(value)
293
+ // Handle both boolean and object return types
294
+ if (typeof result === "boolean") {
295
+ valid = result
296
+ } else {
297
+ valid = result.valid
298
+ customMessage = result.message || ""
299
+ }
300
+ } else if (pattern) {
301
+ // Use pattern validation
302
+ valid = validateInput(value, pattern)
303
+ }
304
+
305
+ setIsValid(valid)
306
+ setValidationMessage(valid ? "" : customMessage || getValidationMessage())
307
+ } else {
308
+ // Reset validation state for empty values or no validation
309
+ setIsValid(true)
310
+ setValidationMessage("")
311
+ }
312
+
313
+ // Call the original onBlur if provided
314
+ onBlur?.(e)
315
+ }
316
+
317
+ // Handle keydown event - hide validation error when user starts typing
318
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
319
+ // Hide validation error when user starts typing (any key except special keys)
320
+ if (
321
+ !isValid &&
322
+ ![
323
+ "Tab",
324
+ "Escape",
325
+ "Enter",
326
+ "ArrowUp",
327
+ "ArrowDown",
328
+ "ArrowLeft",
329
+ "ArrowRight",
330
+ ].includes(e.key)
331
+ ) {
332
+ setIsValid(true)
333
+ setValidationMessage("")
334
+ }
335
+
336
+ // Call the original onKeyDown if provided
337
+ onKeyDown?.(e)
338
+ }
339
+
340
+ return (
341
+ <InputGroup className={cn("w-36", className)}>
342
+ {field?.prefix && (
343
+ <InputGroupAddon>
344
+ <InputGroupText>{field.prefix}</InputGroupText>
345
+ </InputGroupAddon>
346
+ )}
347
+ <InputGroupInput
348
+ ref={inputRef}
349
+ aria-invalid={!isValid}
350
+ aria-describedby={
351
+ !isValid && validationMessage
352
+ ? `${field?.key || "input"}-error`
353
+ : undefined
354
+ }
355
+ onBlur={handleBlur}
356
+ onKeyDown={handleKeyDown}
357
+ {...props}
358
+ />
359
+ {!isValid && validationMessage && (
360
+ <InputGroupAddon align="inline-end">
361
+ <TooltipProvider>
362
+ <Tooltip>
363
+ <TooltipTrigger asChild>
364
+ <InputGroupButton size="icon-xs">
365
+ <AlertCircleIcon className="text-destructive size-3.5" />
366
+ </InputGroupButton>
367
+ </TooltipTrigger>
368
+ <TooltipContent>
369
+ <p className="text-sm">{validationMessage}</p>
370
+ </TooltipContent>
371
+ </Tooltip>
372
+ </TooltipProvider>
373
+ </InputGroupAddon>
374
+ )}
375
+
376
+ {field?.suffix && (
377
+ <InputGroupAddon align="inline-end">
378
+ <InputGroupText>{field.suffix}</InputGroupText>
379
+ </InputGroupAddon>
380
+ )}
381
+ </InputGroup>
382
+ )
383
+ }
384
+
385
+ interface FilterRemoveButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
386
+ icon?: React.ReactNode
387
+ }
388
+
389
+ function FilterRemoveButton({
390
+ className,
391
+ icon = (
392
+ <XIcon
393
+ />
394
+ ),
395
+ ...props
396
+ }: FilterRemoveButtonProps) {
397
+ const context = useFilterContext()
398
+
399
+ const sizeMap = {
400
+ sm: "sm" as const,
401
+ default: "sm" as const,
402
+ lg: "default" as const,
403
+ }
404
+
405
+ return (
406
+ <Button
407
+ variant="outline"
408
+ size={
409
+ context.size === "sm"
410
+ ? "icon-sm"
411
+ : context.size === "lg"
412
+ ? "icon-lg"
413
+ : "icon"
414
+ }
415
+ {...props}
416
+ >
417
+ {icon}
418
+ </Button>
419
+ )
420
+ }
421
+
422
+ // Generic types for flexible filter system
423
+ export interface FilterOption<T = unknown> {
424
+ value: T
425
+ label: string
426
+ icon?: React.ReactNode
427
+ metadata?: Record<string, unknown>
428
+ className?: string
429
+ }
430
+
431
+ export interface FilterOperator {
432
+ value: string
433
+ label: string
434
+ supportsMultiple?: boolean
435
+ }
436
+
437
+ // Custom renderer props interface
438
+ export interface CustomRendererProps<T = unknown> {
439
+ field: FilterFieldConfig<T>
440
+ values: T[]
441
+ onChange: (values: T[]) => void
442
+ operator: string
443
+ }
444
+
445
+ // Grouped field configuration interface
446
+ export interface FilterFieldGroup<T = unknown> {
447
+ group?: string
448
+ fields: FilterFieldConfig<T>[]
449
+ }
450
+
451
+ // Union type for both flat and grouped field configurations
452
+ export type FilterFieldsConfig<T = unknown> =
453
+ | FilterFieldConfig<T>[]
454
+ | FilterFieldGroup<T>[]
455
+
456
+ export interface FilterFieldConfig<T = unknown> {
457
+ key?: string
458
+ label?: string
459
+ icon?: React.ReactNode
460
+ type?: "select" | "multiselect" | "text" | "custom" | "separator"
461
+ // Group-level configuration
462
+ group?: string
463
+ fields?: FilterFieldConfig<T>[]
464
+ // Field-specific options
465
+ options?: FilterOption<T>[]
466
+ operators?: FilterOperator[]
467
+ customRenderer?: (props: CustomRendererProps<T>) => React.ReactNode
468
+ customValueRenderer?: (
469
+ values: T[],
470
+ options: FilterOption<T>[]
471
+ ) => React.ReactNode
472
+ placeholder?: string
473
+ searchable?: boolean
474
+ maxSelections?: number
475
+ min?: number
476
+ max?: number
477
+ step?: number
478
+ prefix?: string | React.ReactNode
479
+ suffix?: string | React.ReactNode
480
+ pattern?: string
481
+ validation?: (
482
+ value: unknown
483
+ ) => boolean | { valid: boolean; message?: string }
484
+ allowCustomValues?: boolean
485
+ className?: string
486
+ menuPopupClassName?: string
487
+ // Grouping options (legacy support)
488
+ groupLabel?: string
489
+ // Boolean field options
490
+ onLabel?: string
491
+ offLabel?: string
492
+ // Input event handlers
493
+ onInputChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
494
+ // Default operator to use when creating a filter for this field
495
+ defaultOperator?: string
496
+ // Controlled values support for this field
497
+ value?: T[]
498
+ onValueChange?: (values: T[]) => void
499
+ }
500
+
501
+ // Helper functions to handle both flat and grouped field configurations
502
+ const isFieldGroup = <T = unknown,>(
503
+ item: FilterFieldConfig<T> | FilterFieldGroup<T>
504
+ ): item is FilterFieldGroup<T> => {
505
+ return "fields" in item && Array.isArray(item.fields)
506
+ }
507
+
508
+ // Helper function to check if a FilterFieldConfig is a group-level configuration
509
+ const isGroupLevelField = <T = unknown,>(
510
+ field: FilterFieldConfig<T>
511
+ ): boolean => {
512
+ return Boolean(field.group && field.fields)
513
+ }
514
+
515
+ const flattenFields = <T = unknown,>(
516
+ fields: FilterFieldsConfig<T>
517
+ ): FilterFieldConfig<T>[] => {
518
+ return fields.reduce<FilterFieldConfig<T>[]>((acc, item) => {
519
+ if (isFieldGroup(item)) {
520
+ return [...acc, ...item.fields]
521
+ }
522
+ // Handle group-level fields (new structure)
523
+ if (isGroupLevelField(item)) {
524
+ return [...acc, ...item.fields!]
525
+ }
526
+ return [...acc, item]
527
+ }, [])
528
+ }
529
+
530
+ const getFieldsMap = <T = unknown,>(
531
+ fields: FilterFieldsConfig<T>
532
+ ): Record<string, FilterFieldConfig<T>> => {
533
+ const flatFields = flattenFields(fields)
534
+ return flatFields.reduce(
535
+ (acc, field) => {
536
+ // Only add fields that have a key (skip group-level configurations)
537
+ if (field.key) {
538
+ acc[field.key] = field
539
+ }
540
+ return acc
541
+ },
542
+ {} as Record<string, FilterFieldConfig<T>>
543
+ )
544
+ }
545
+
546
+ // Helper function to create operators from i18n config
547
+ const createOperatorsFromI18n = (
548
+ i18n: FilterI18nConfig
549
+ ): Record<string, FilterOperator[]> => ({
550
+ select: [
551
+ { value: "is", label: i18n.operators.is },
552
+ { value: "is_not", label: i18n.operators.isNot },
553
+ { value: "empty", label: i18n.operators.empty },
554
+ { value: "not_empty", label: i18n.operators.notEmpty },
555
+ ],
556
+ multiselect: [
557
+ { value: "is_any_of", label: i18n.operators.isAnyOf },
558
+ { value: "is_not_any_of", label: i18n.operators.isNotAnyOf },
559
+ { value: "includes_all", label: i18n.operators.includesAll },
560
+ { value: "excludes_all", label: i18n.operators.excludesAll },
561
+ { value: "empty", label: i18n.operators.empty },
562
+ { value: "not_empty", label: i18n.operators.notEmpty },
563
+ ],
564
+ text: [
565
+ { value: "contains", label: i18n.operators.contains },
566
+ { value: "not_contains", label: i18n.operators.notContains },
567
+ { value: "starts_with", label: i18n.operators.startsWith },
568
+ { value: "ends_with", label: i18n.operators.endsWith },
569
+ { value: "is", label: i18n.operators.isExactly },
570
+ { value: "empty", label: i18n.operators.empty },
571
+ { value: "not_empty", label: i18n.operators.notEmpty },
572
+ ],
573
+ custom: [
574
+ { value: "is", label: i18n.operators.is },
575
+ { value: "after", label: i18n.operators.after },
576
+ { value: "is", label: i18n.operators.is },
577
+ { value: "between", label: i18n.operators.between },
578
+ { value: "empty", label: i18n.operators.empty },
579
+ { value: "not_empty", label: i18n.operators.notEmpty },
580
+ ],
581
+ })
582
+
583
+ // Default operators for different field types (using default i18n)
584
+ export const DEFAULT_OPERATORS: Record<string, FilterOperator[]> =
585
+ createOperatorsFromI18n(DEFAULT_I18N)
586
+
587
+ // Helper function to get operators for a field
588
+ const getOperatorsForField = <T = unknown,>(
589
+ field: FilterFieldConfig<T>,
590
+ values: T[],
591
+ i18n: FilterI18nConfig
592
+ ): FilterOperator[] => {
593
+ if (field.operators) return field.operators
594
+
595
+ const operators = createOperatorsFromI18n(i18n)
596
+
597
+ // Determine field type for operator selection
598
+ let fieldType = field.type || "select"
599
+
600
+ // If it's a select field but has multiple values, treat as multiselect
601
+ if (fieldType === "select" && values.length > 1) {
602
+ fieldType = "multiselect"
603
+ }
604
+
605
+ // If it's a multiselect field or has multiselect operators, use multiselect operators
606
+ if (fieldType === "multiselect" || field.type === "multiselect") {
607
+ return operators.multiselect
608
+ }
609
+
610
+ return operators[fieldType] || operators.select
611
+ }
612
+
613
+ interface FilterOperatorDropdownProps<T = unknown> {
614
+ field: FilterFieldConfig<T>
615
+ operator: string
616
+ values: T[]
617
+ onChange: (operator: string) => void
618
+ }
619
+
620
+ function FilterOperatorDropdown<T = unknown>({
621
+ field,
622
+ operator,
623
+ values,
624
+ onChange,
625
+ }: FilterOperatorDropdownProps<T>) {
626
+ const context = useFilterContext()
627
+ const operators = getOperatorsForField(field, values, context.i18n)
628
+
629
+ // Find the operator label, with fallback to formatted operator name
630
+ const operatorLabel =
631
+ operators.find((op) => op.value === operator)?.label ||
632
+ context.i18n.helpers.formatOperator(operator)
633
+
634
+ return (
635
+ <DropdownMenu>
636
+ <DropdownMenuTrigger asChild>
637
+ <Button
638
+ variant="outline"
639
+ size={context.size}
640
+ className="text-muted-foreground hover:text-foreground"
641
+ >
642
+ {operatorLabel}
643
+ </Button>
644
+ </DropdownMenuTrigger>
645
+ <DropdownMenuContent align="start" className="w-fit min-w-fit">
646
+ {operators.map((op) => (
647
+ <DropdownMenuItem
648
+ key={op.value}
649
+ onClick={() => onChange(op.value)}
650
+ className={cn(
651
+ "data-highlighted:bg-accent data-highlighted:text-accent-foreground flex items-center justify-between"
652
+ )}
653
+ >
654
+ <span>{op.label}</span>
655
+ <CheckIcon className={cn(
656
+ "text-primary ms-auto",
657
+ op.value === operator ? "opacity-100" : "opacity-0"
658
+ )} />
659
+ </DropdownMenuItem>
660
+ ))}
661
+ </DropdownMenuContent>
662
+ </DropdownMenu>
663
+ )
664
+ }
665
+
666
+ interface FilterValueSelectorProps<T = unknown> {
667
+ field: FilterFieldConfig<T>
668
+ values: T[]
669
+ onChange: (values: T[]) => void
670
+ operator: string
671
+ autoFocus?: boolean
672
+ }
673
+
674
+ interface SelectOptionsPopoverProps<T = unknown> {
675
+ field: FilterFieldConfig<T>
676
+ values: T[]
677
+ onChange: (values: T[]) => void
678
+ onClose?: () => void
679
+ inline?: boolean
680
+ }
681
+
682
+ function SelectOptionsPopover<T = unknown>({
683
+ field,
684
+ values,
685
+ onChange,
686
+ onClose,
687
+ inline = false,
688
+ }: SelectOptionsPopoverProps<T>) {
689
+ const [open, setOpen] = useState(false)
690
+ const [searchInput, setSearchInput] = useState("")
691
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
692
+ const inputRef = useRef<HTMLInputElement>(null)
693
+ const context = useFilterContext()
694
+ const baseId = useId()
695
+
696
+ useEffect(() => {
697
+ setHighlightedIndex(-1)
698
+ }, [searchInput, open])
699
+
700
+ useEffect(() => {
701
+ if (highlightedIndex >= 0 && open) {
702
+ const element = document.getElementById(
703
+ `${baseId}-item-${highlightedIndex}`
704
+ )
705
+ element?.scrollIntoView({ block: "nearest" })
706
+ }
707
+ }, [highlightedIndex, open, baseId])
708
+
709
+ const isMultiSelect = field.type === "multiselect" || values.length > 1
710
+ const effectiveValues =
711
+ (field.value !== undefined ? (field.value as T[]) : values) || []
712
+
713
+ const selectedOptions =
714
+ field.options?.filter((opt) => effectiveValues.includes(opt.value)) || []
715
+ const unselectedOptions =
716
+ field.options?.filter((opt) => !effectiveValues.includes(opt.value)) || []
717
+
718
+ // Filter options based on search input
719
+ const filteredSelectedOptions = selectedOptions // Keep all selected visible
720
+ const filteredUnselectedOptions = unselectedOptions.filter((opt) =>
721
+ opt.label.toLowerCase().includes(searchInput.toLowerCase())
722
+ )
723
+
724
+ const allFilteredOptions = useMemo(
725
+ () => [...filteredSelectedOptions, ...filteredUnselectedOptions],
726
+ [filteredSelectedOptions, filteredUnselectedOptions]
727
+ )
728
+
729
+ const handleClose = () => {
730
+ setOpen(false)
731
+ onClose?.()
732
+ }
733
+
734
+ const renderMenuContent = () => (
735
+ <>
736
+ {field.searchable !== false && (
737
+ <>
738
+ <Input
739
+ ref={inputRef}
740
+ role="combobox"
741
+ aria-autocomplete="list"
742
+ aria-expanded={true}
743
+ aria-haspopup="listbox"
744
+ aria-controls={`${baseId}-listbox`}
745
+ aria-activedescendant={
746
+ highlightedIndex >= 0
747
+ ? `${baseId}-item-${highlightedIndex}`
748
+ : undefined
749
+ }
750
+ placeholder={context.i18n.placeholders.searchField(
751
+ field.label || ""
752
+ )}
753
+ className={cn(
754
+ "border-input h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none",
755
+ "focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0"
756
+ )}
757
+ value={searchInput}
758
+ onChange={(e) => setSearchInput(e.target.value)}
759
+ onClick={(e) => e.stopPropagation()}
760
+ onKeyDown={(e) => {
761
+ if (e.key === "ArrowDown") {
762
+ e.preventDefault()
763
+ if (allFilteredOptions.length > 0) {
764
+ setHighlightedIndex((prev) =>
765
+ prev < allFilteredOptions.length - 1 ? prev + 1 : 0
766
+ )
767
+ }
768
+ } else if (e.key === "ArrowUp") {
769
+ e.preventDefault()
770
+ if (allFilteredOptions.length > 0) {
771
+ setHighlightedIndex((prev) =>
772
+ prev > 0 ? prev - 1 : allFilteredOptions.length - 1
773
+ )
774
+ }
775
+ } else if (e.key === "ArrowLeft") {
776
+ e.preventDefault()
777
+ setOpen(false)
778
+ } else if (e.key === "Enter" && highlightedIndex >= 0) {
779
+ e.preventDefault()
780
+ const option = allFilteredOptions[highlightedIndex]
781
+ if (option) {
782
+ const isSelected = effectiveValues.includes(option.value as T)
783
+ const next = isSelected
784
+ ? (effectiveValues.filter((v) => v !== option.value) as T[])
785
+ : isMultiSelect
786
+ ? ([...effectiveValues, option.value] as T[])
787
+ : ([option.value] as T[])
788
+
789
+ if (
790
+ !isSelected &&
791
+ isMultiSelect &&
792
+ field.maxSelections &&
793
+ next.length > field.maxSelections
794
+ ) {
795
+ return
796
+ }
797
+
798
+ if (field.onValueChange) {
799
+ field.onValueChange(next)
800
+ } else {
801
+ onChange(next)
802
+ }
803
+ if (!isMultiSelect) handleClose()
804
+ }
805
+ }
806
+ e.stopPropagation()
807
+ }}
808
+ />
809
+ <DropdownMenuSeparator />
810
+ </>
811
+ )}
812
+ <div className="relative flex max-h-full">
813
+ <div
814
+ className="flex max-h-[min(var(--radix-dropdown-menu-content-available-height),24rem)] w-full scroll-pt-2 scroll-pb-2 flex-col overscroll-contain"
815
+ role="listbox"
816
+ id={`${baseId}-listbox`}
817
+ >
818
+ <ScrollArea className="size-full min-h-0 **:data-[slot=scroll-area-scrollbar]:m-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain">
819
+ {allFilteredOptions.length === 0 && (
820
+ <div className="text-muted-foreground py-2 text-center text-sm">
821
+ {context.i18n.noResultsFound}
822
+ </div>
823
+ )}
824
+
825
+ {/* Selected items */}
826
+ {filteredSelectedOptions.length > 0 && (
827
+ <DropdownMenuGroup className="px-1">
828
+ {filteredSelectedOptions.map((option, index) => {
829
+ const isHighlighted = highlightedIndex === index
830
+ const itemId = `${baseId}-item-${index}`
831
+
832
+ return (
833
+ <DropdownMenuCheckboxItem
834
+ key={String(option.value)}
835
+ id={itemId}
836
+ role="option"
837
+ aria-selected={isHighlighted}
838
+ data-highlighted={isHighlighted || undefined}
839
+ onMouseEnter={() => setHighlightedIndex(index)}
840
+ checked={true}
841
+ className={cn(
842
+ "data-highlighted:bg-accent data-highlighted:text-accent-foreground",
843
+ option.className
844
+ )}
845
+ onSelect={(e) => {
846
+ if (isMultiSelect) e.preventDefault()
847
+ }}
848
+ onCheckedChange={() => {
849
+ const next = effectiveValues.filter(
850
+ (v) => v !== option.value
851
+ ) as T[]
852
+ if (field.onValueChange) {
853
+ field.onValueChange(next)
854
+ } else {
855
+ onChange(next)
856
+ }
857
+ if (!isMultiSelect) handleClose()
858
+ }}
859
+ >
860
+ {option.icon && option.icon}
861
+ <span className="truncate">{option.label}</span>
862
+ </DropdownMenuCheckboxItem>
863
+ )
864
+ })}
865
+ </DropdownMenuGroup>
866
+ )}
867
+
868
+ {/* Separator */}
869
+ {filteredSelectedOptions.length > 0 &&
870
+ filteredUnselectedOptions.length > 0 && (
871
+ <DropdownMenuSeparator className="mx-0" />
872
+ )}
873
+
874
+ {/* Available items */}
875
+ {filteredUnselectedOptions.length > 0 && (
876
+ <DropdownMenuGroup className="px-1">
877
+ {filteredUnselectedOptions.map((option, index) => {
878
+ const overallIndex = index + filteredSelectedOptions.length
879
+ const isHighlighted = highlightedIndex === overallIndex
880
+ const itemId = `${baseId}-item-${overallIndex}`
881
+
882
+ return (
883
+ <DropdownMenuCheckboxItem
884
+ key={String(option.value)}
885
+ id={itemId}
886
+ role="option"
887
+ aria-selected={isHighlighted}
888
+ data-highlighted={isHighlighted || undefined}
889
+ onMouseEnter={() => setHighlightedIndex(overallIndex)}
890
+ checked={false}
891
+ className={cn(
892
+ "data-highlighted:bg-accent data-highlighted:text-accent-foreground",
893
+ option.className
894
+ )}
895
+ onSelect={(e) => {
896
+ if (isMultiSelect) e.preventDefault()
897
+ }}
898
+ onCheckedChange={() => {
899
+ const next = isMultiSelect
900
+ ? ([...effectiveValues, option.value] as T[])
901
+ : ([option.value] as T[])
902
+
903
+ if (
904
+ isMultiSelect &&
905
+ field.maxSelections &&
906
+ next.length > field.maxSelections
907
+ ) {
908
+ return
909
+ }
910
+
911
+ if (field.onValueChange) {
912
+ field.onValueChange(next)
913
+ } else {
914
+ onChange(next)
915
+ }
916
+ if (!isMultiSelect) handleClose()
917
+ }}
918
+ >
919
+ {option.icon && option.icon}
920
+ <span className="truncate">{option.label}</span>
921
+ </DropdownMenuCheckboxItem>
922
+ )
923
+ })}
924
+ </DropdownMenuGroup>
925
+ )}
926
+ </ScrollArea>
927
+ </div>
928
+ </div>
929
+ </>
930
+ )
931
+
932
+ if (inline) {
933
+ return <div className="w-full">{renderMenuContent()}</div>
934
+ }
935
+
936
+ return (
937
+ <DropdownMenu
938
+ open={open}
939
+ onOpenChange={(open) => {
940
+ setOpen(open)
941
+ if (!open) {
942
+ setTimeout(() => setSearchInput(""), 200)
943
+ }
944
+ }}
945
+ >
946
+ <DropdownMenuTrigger asChild>
947
+ <Button variant="outline" size={context.size}>
948
+ <div className="flex items-center gap-1.5">
949
+ {field.customValueRenderer ? (
950
+ field.customValueRenderer(values, field.options || [])
951
+ ) : (
952
+ <>
953
+ {selectedOptions.length > 0 && (
954
+ <div className="flex items-center -space-x-1.5">
955
+ {selectedOptions.slice(0, 3).map((option) => (
956
+ <div key={String(option.value)}>{option.icon}</div>
957
+ ))}
958
+ </div>
959
+ )}
960
+ {selectedOptions.length === 1
961
+ ? selectedOptions[0].label
962
+ : selectedOptions.length > 1
963
+ ? `${selectedOptions.length} ${context.i18n.selectedCount}`
964
+ : context.i18n.select}
965
+ </>
966
+ )}
967
+ </div>
968
+ </Button>
969
+ </DropdownMenuTrigger>
970
+ <DropdownMenuContent
971
+ align="start"
972
+ className={cn("w-[200px] px-0", field.className)}
973
+ >
974
+ {renderMenuContent()}
975
+ </DropdownMenuContent>
976
+ </DropdownMenu>
977
+ )
978
+ }
979
+
980
+ function FilterValueSelector<T = unknown>({
981
+ field,
982
+ values,
983
+ onChange,
984
+ operator,
985
+ autoFocus,
986
+ }: FilterValueSelectorProps<T>) {
987
+ const context = useFilterContext()
988
+
989
+ if (operator === "empty" || operator === "not_empty") {
990
+ return null
991
+ }
992
+
993
+ if (field.customRenderer) {
994
+ return (
995
+ <ButtonGroupText className="hover:bg-accent aria-expanded:bg-accent bg-background dark:bg-input/30 text-start whitespace-nowrap outline-hidden">
996
+ {field.customRenderer({ field, values, onChange, operator })}
997
+ </ButtonGroupText>
998
+ )
999
+ }
1000
+
1001
+ if (field.type === "text") {
1002
+ return (
1003
+ <FilterInput
1004
+ type="text"
1005
+ value={(values[0] as string) || ""}
1006
+ onChange={(e) => onChange([e.target.value] as T[])}
1007
+ placeholder={field.placeholder}
1008
+ pattern={field.pattern}
1009
+ field={field}
1010
+ className={cn("w-36", field.className)}
1011
+ autoFocus={autoFocus}
1012
+ />
1013
+ )
1014
+ }
1015
+
1016
+ if (field.type === "select" || field.type === "multiselect") {
1017
+ return (
1018
+ <SelectOptionsPopover field={field} values={values} onChange={onChange} />
1019
+ )
1020
+ }
1021
+
1022
+ return (
1023
+ <SelectOptionsPopover field={field} values={values} onChange={onChange} />
1024
+ )
1025
+ }
1026
+ export interface Filter<T = unknown> {
1027
+ id: string
1028
+ field: string
1029
+ operator: string
1030
+ values: T[]
1031
+ }
1032
+
1033
+ export interface FilterGroup<T = unknown> {
1034
+ id: string
1035
+ label?: string
1036
+ filters: Filter<T>[]
1037
+ fields: FilterFieldConfig<T>[]
1038
+ }
1039
+
1040
+ interface FiltersContentProps<T = unknown> {
1041
+ filters: Filter<T>[]
1042
+ fields: FilterFieldsConfig<T>
1043
+ onChange: (filters: Filter<T>[]) => void
1044
+ }
1045
+
1046
+ export const FiltersContent = <T = unknown,>({
1047
+ filters,
1048
+ fields,
1049
+ onChange,
1050
+ }: FiltersContentProps<T>) => {
1051
+ const context = useFilterContext()
1052
+ const fieldsMap = useMemo(() => getFieldsMap(fields), [fields])
1053
+
1054
+ const updateFilter = useCallback(
1055
+ (filterId: string, updates: Partial<Filter<T>>) => {
1056
+ onChange(
1057
+ filters.map((filter) => {
1058
+ if (filter.id === filterId) {
1059
+ const updatedFilter = { ...filter, ...updates }
1060
+ if (
1061
+ updates.operator === "empty" ||
1062
+ updates.operator === "not_empty"
1063
+ ) {
1064
+ updatedFilter.values = [] as T[]
1065
+ }
1066
+ return updatedFilter
1067
+ }
1068
+ return filter
1069
+ })
1070
+ )
1071
+ },
1072
+ [filters, onChange]
1073
+ )
1074
+
1075
+ const removeFilter = useCallback(
1076
+ (filterId: string) => {
1077
+ onChange(filters.filter((filter) => filter.id !== filterId))
1078
+ },
1079
+ [filters, onChange]
1080
+ )
1081
+
1082
+ return (
1083
+ <div
1084
+ className={cn(
1085
+ filtersContainerVariants({
1086
+ variant: context.variant,
1087
+ size: context.size,
1088
+ }),
1089
+ context.className
1090
+ )}
1091
+ >
1092
+ {filters.map((filter) => {
1093
+ const field = fieldsMap[filter.field]
1094
+ if (!field) return null
1095
+
1096
+ return (
1097
+ <ButtonGroup key={filter.id}>
1098
+ <ButtonGroupText>
1099
+ {field.icon && field.icon}
1100
+ {field.label}
1101
+ </ButtonGroupText>
1102
+
1103
+ <FilterOperatorDropdown<T>
1104
+ field={field}
1105
+ operator={filter.operator}
1106
+ values={filter.values}
1107
+ onChange={(operator) => updateFilter(filter.id, { operator })}
1108
+ />
1109
+
1110
+ <FilterValueSelector<T>
1111
+ field={field}
1112
+ values={filter.values}
1113
+ onChange={(values) => updateFilter(filter.id, { values })}
1114
+ operator={filter.operator}
1115
+ autoFocus={false}
1116
+ />
1117
+
1118
+ <FilterRemoveButton onClick={() => removeFilter(filter.id)} />
1119
+ </ButtonGroup>
1120
+ )
1121
+ })}
1122
+ </div>
1123
+ )
1124
+ }
1125
+
1126
+ interface FiltersProps<T = unknown> {
1127
+ filters: Filter<T>[]
1128
+ fields: FilterFieldsConfig<T>
1129
+ onChange: (filters: Filter<T>[]) => void
1130
+ className?: string
1131
+ variant?: "solid" | "default"
1132
+ size?: "sm" | "default" | "lg"
1133
+ radius?: "default" | "full"
1134
+ i18n?: Partial<FilterI18nConfig>
1135
+ showSearchInput?: boolean
1136
+ trigger?: React.ReactNode
1137
+ allowMultiple?: boolean
1138
+ menuPopupClassName?: string
1139
+ collapseAddButton?: boolean
1140
+ enableShortcut?: boolean
1141
+ shortcutKey?: string
1142
+ shortcutLabel?: string
1143
+ }
1144
+
1145
+ interface FilterSubmenuContentProps<T = unknown> {
1146
+ field: FilterFieldConfig<T>
1147
+ currentValues: T[]
1148
+ isMultiSelect: boolean
1149
+ onToggle: (value: T, isSelected: boolean) => void
1150
+ i18n: FilterI18nConfig
1151
+ isActive?: boolean
1152
+ onActive?: () => void
1153
+ onBack?: () => void
1154
+ onClose?: () => void
1155
+ }
1156
+
1157
+ function FilterSubmenuContent<T = unknown>({
1158
+ field,
1159
+ currentValues,
1160
+ isMultiSelect,
1161
+ onToggle,
1162
+ i18n,
1163
+ isActive,
1164
+ onActive,
1165
+ onBack,
1166
+ onClose,
1167
+ }: FilterSubmenuContentProps<T>) {
1168
+ const [searchInput, setSearchInput] = useState("")
1169
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
1170
+ const inputRef = useRef<HTMLInputElement>(null)
1171
+ const baseId = useId()
1172
+
1173
+ useEffect(() => {
1174
+ setHighlightedIndex(-1)
1175
+ }, [searchInput])
1176
+
1177
+ useEffect(() => {
1178
+ if (highlightedIndex >= 0 && isActive) {
1179
+ const element = document.getElementById(
1180
+ `${baseId}-item-${highlightedIndex}`
1181
+ )
1182
+ element?.scrollIntoView({ block: "nearest" })
1183
+ }
1184
+ }, [highlightedIndex, isActive, baseId])
1185
+
1186
+ const filteredOptions = useMemo(() => {
1187
+ return (
1188
+ field.options?.filter((option) => {
1189
+ const isSelected = currentValues.includes(option.value)
1190
+ if (isSelected) return true
1191
+ if (!searchInput) return true
1192
+ return option.label.toLowerCase().includes(searchInput.toLowerCase())
1193
+ }) || []
1194
+ )
1195
+ }, [field.options, searchInput, currentValues])
1196
+
1197
+ useEffect(() => {
1198
+ if (isActive && filteredOptions.length > 0) {
1199
+ setHighlightedIndex(0)
1200
+ }
1201
+ }, [isActive, filteredOptions.length])
1202
+
1203
+ return (
1204
+ <div className="flex flex-col" onMouseEnter={onActive}>
1205
+ {field.searchable !== false && (
1206
+ <>
1207
+ <Input
1208
+ ref={inputRef}
1209
+ role="combobox"
1210
+ aria-autocomplete="list"
1211
+ aria-expanded={true}
1212
+ aria-haspopup="listbox"
1213
+ aria-controls={`${baseId}-listbox`}
1214
+ aria-activedescendant={
1215
+ highlightedIndex >= 0
1216
+ ? `${baseId}-item-${highlightedIndex}`
1217
+ : undefined
1218
+ }
1219
+ placeholder={i18n.placeholders.searchField(field.label || "")}
1220
+ className={cn(
1221
+ "h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none",
1222
+ "focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0"
1223
+ )}
1224
+ value={searchInput}
1225
+ onChange={(e) => setSearchInput(e.target.value)}
1226
+ onClick={(e) => e.stopPropagation()}
1227
+ onKeyDown={(e) => {
1228
+ if (e.key === "ArrowDown") {
1229
+ e.preventDefault()
1230
+ if (filteredOptions.length > 0) {
1231
+ setHighlightedIndex((prev) =>
1232
+ prev < filteredOptions.length - 1 ? prev + 1 : 0
1233
+ )
1234
+ }
1235
+ } else if (e.key === "ArrowUp") {
1236
+ e.preventDefault()
1237
+ if (filteredOptions.length > 0) {
1238
+ setHighlightedIndex((prev) =>
1239
+ prev > 0 ? prev - 1 : filteredOptions.length - 1
1240
+ )
1241
+ }
1242
+ } else if (e.key === "ArrowLeft") {
1243
+ e.preventDefault()
1244
+ onBack?.()
1245
+ } else if (e.key === "Enter" && highlightedIndex >= 0) {
1246
+ e.preventDefault()
1247
+ const option = filteredOptions[highlightedIndex]
1248
+ if (option) {
1249
+ onToggle(
1250
+ option.value as T,
1251
+ currentValues.includes(option.value)
1252
+ )
1253
+ if (!isMultiSelect) {
1254
+ onBack?.()
1255
+ }
1256
+ }
1257
+ } else if (e.key === "Escape") {
1258
+ e.preventDefault()
1259
+ onClose?.()
1260
+ }
1261
+ e.stopPropagation()
1262
+ }}
1263
+ />
1264
+ <DropdownMenuSeparator />
1265
+ </>
1266
+ )}
1267
+ <div className="relative flex max-h-full">
1268
+ <div
1269
+ className="flex max-h-[min(var(--radix-dropdown-menu-content-available-height),24rem)] w-full scroll-pt-2 scroll-pb-2 flex-col overscroll-contain outline-hidden"
1270
+ role="listbox"
1271
+ id={`${baseId}-listbox`}
1272
+ tabIndex={field.searchable === false ? 0 : -1}
1273
+ onKeyDown={(e) => {
1274
+ if (field.searchable === false) {
1275
+ if (e.key === "ArrowDown") {
1276
+ e.preventDefault()
1277
+ if (filteredOptions.length > 0) {
1278
+ setHighlightedIndex((prev) =>
1279
+ prev < filteredOptions.length - 1 ? prev + 1 : 0
1280
+ )
1281
+ }
1282
+ } else if (e.key === "ArrowUp") {
1283
+ e.preventDefault()
1284
+ if (filteredOptions.length > 0) {
1285
+ setHighlightedIndex((prev) =>
1286
+ prev > 0 ? prev - 1 : filteredOptions.length - 1
1287
+ )
1288
+ }
1289
+ } else if (e.key === "ArrowLeft") {
1290
+ e.preventDefault()
1291
+ onBack?.()
1292
+ } else if (e.key === "Enter" && highlightedIndex >= 0) {
1293
+ e.preventDefault()
1294
+ const option = filteredOptions[highlightedIndex]
1295
+ if (option) {
1296
+ onToggle(
1297
+ option.value as T,
1298
+ currentValues.includes(option.value)
1299
+ )
1300
+ if (!isMultiSelect) {
1301
+ onBack?.()
1302
+ }
1303
+ }
1304
+ } else if (e.key === "Escape") {
1305
+ e.preventDefault()
1306
+ onClose?.()
1307
+ }
1308
+ e.stopPropagation()
1309
+ }
1310
+ }}
1311
+ >
1312
+ <ScrollArea className="size-full min-h-0 **:data-[slot=scroll-area-scrollbar]:m-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain">
1313
+ {filteredOptions.length === 0 ? (
1314
+ <div className="text-muted-foreground py-2 text-center text-sm">
1315
+ {i18n.noResultsFound}
1316
+ </div>
1317
+ ) : (
1318
+ <DropdownMenuGroup>
1319
+ {filteredOptions.map((option, index) => {
1320
+ const isSelected = currentValues.includes(option.value)
1321
+ const isHighlighted = highlightedIndex === index
1322
+ const itemId = `${baseId}-item-${index}`
1323
+
1324
+ return (
1325
+ <DropdownMenuCheckboxItem
1326
+ key={String(option.value)}
1327
+ id={itemId}
1328
+ role="option"
1329
+ aria-selected={isHighlighted}
1330
+ data-highlighted={isHighlighted || undefined}
1331
+ onMouseEnter={() => setHighlightedIndex(index)}
1332
+ checked={isSelected}
1333
+ className={cn(
1334
+ "data-highlighted:bg-accent data-highlighted:text-accent-foreground",
1335
+ option.className
1336
+ )}
1337
+ onSelect={(e) => {
1338
+ if (isMultiSelect) e.preventDefault()
1339
+ }}
1340
+ onCheckedChange={() =>
1341
+ onToggle(option.value as T, isSelected)
1342
+ }
1343
+ >
1344
+ {option.icon && option.icon}
1345
+ <span className="truncate">{option.label}</span>
1346
+ </DropdownMenuCheckboxItem>
1347
+ )
1348
+ })}
1349
+ </DropdownMenuGroup>
1350
+ )}
1351
+ </ScrollArea>
1352
+ </div>
1353
+ </div>
1354
+ </div>
1355
+ )
1356
+ }
1357
+
1358
+ export function Filters<T = unknown>({
1359
+ filters,
1360
+ fields,
1361
+ onChange,
1362
+ className,
1363
+ variant = "default",
1364
+ size = "default",
1365
+ radius = "default",
1366
+ i18n,
1367
+ showSearchInput = true,
1368
+ trigger,
1369
+ allowMultiple = true,
1370
+ menuPopupClassName,
1371
+ enableShortcut = false,
1372
+ shortcutKey = "f",
1373
+ shortcutLabel = "F",
1374
+ }: FiltersProps<T>) {
1375
+ const [addFilterOpen, setAddFilterOpen] = useState(false)
1376
+ const [menuSearchInput, setMenuSearchInput] = useState("")
1377
+ const [activeMenu, setActiveMenu] = useState<string>("root")
1378
+ const [openSubMenu, setOpenSubMenu] = useState<string | null>(null)
1379
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
1380
+ const [lastAddedFilterId, setLastAddedFilterId] = useState<string | null>(
1381
+ null
1382
+ )
1383
+ const rootInputRef = useRef<HTMLInputElement>(null)
1384
+ const rootId = useId()
1385
+
1386
+ useEffect(() => {
1387
+ if (!enableShortcut) return
1388
+
1389
+ const handleKeyDown = (e: KeyboardEvent) => {
1390
+ if (
1391
+ e.key.toLowerCase() === shortcutKey.toLowerCase() &&
1392
+ !addFilterOpen &&
1393
+ !(
1394
+ document.activeElement instanceof HTMLInputElement ||
1395
+ document.activeElement instanceof HTMLTextAreaElement
1396
+ )
1397
+ ) {
1398
+ e.preventDefault()
1399
+ setAddFilterOpen(true)
1400
+ }
1401
+ }
1402
+
1403
+ window.addEventListener("keydown", handleKeyDown)
1404
+ return () => window.removeEventListener("keydown", handleKeyDown)
1405
+ }, [enableShortcut, shortcutKey, addFilterOpen])
1406
+
1407
+ useEffect(() => {
1408
+ setHighlightedIndex(-1)
1409
+ }, [menuSearchInput])
1410
+
1411
+ useEffect(() => {
1412
+ if (highlightedIndex >= 0 && addFilterOpen) {
1413
+ const element = document.getElementById(
1414
+ `${rootId}-item-${highlightedIndex}`
1415
+ )
1416
+ element?.scrollIntoView({ block: "nearest" })
1417
+ }
1418
+ }, [highlightedIndex, addFilterOpen, rootId])
1419
+
1420
+ useEffect(() => {
1421
+ if (!addFilterOpen) {
1422
+ setOpenSubMenu(null)
1423
+ }
1424
+ }, [addFilterOpen])
1425
+
1426
+ // Track which filter instance is being built in the current Add Filter menu session
1427
+ // Maps fieldKey -> unique filterId created during this open session
1428
+ const [sessionFilterIds, setSessionFilterIds] = useState<
1429
+ Record<string, string>
1430
+ >({})
1431
+
1432
+ useEffect(() => {
1433
+ if (lastAddedFilterId) {
1434
+ const timer = setTimeout(() => {
1435
+ setLastAddedFilterId(null)
1436
+ }, 1000)
1437
+ return () => clearTimeout(timer)
1438
+ }
1439
+ }, [lastAddedFilterId])
1440
+
1441
+ const mergedI18n: FilterI18nConfig = {
1442
+ ...DEFAULT_I18N,
1443
+ ...i18n,
1444
+ operators: { ...DEFAULT_I18N.operators, ...i18n?.operators },
1445
+ placeholders: { ...DEFAULT_I18N.placeholders, ...i18n?.placeholders },
1446
+ validation: { ...DEFAULT_I18N.validation, ...i18n?.validation },
1447
+ }
1448
+
1449
+ const fieldsMap = useMemo(() => getFieldsMap(fields), [fields])
1450
+
1451
+ const updateFilter = useCallback(
1452
+ (filterId: string, updates: Partial<Filter<T>>) => {
1453
+ onChange(
1454
+ filters.map((filter) => {
1455
+ if (filter.id === filterId) {
1456
+ const updatedFilter = { ...filter, ...updates }
1457
+ if (
1458
+ updates.operator === "empty" ||
1459
+ updates.operator === "not_empty"
1460
+ ) {
1461
+ updatedFilter.values = [] as T[]
1462
+ }
1463
+ return updatedFilter
1464
+ }
1465
+ return filter
1466
+ })
1467
+ )
1468
+ },
1469
+ [filters, onChange]
1470
+ )
1471
+
1472
+ const removeFilter = useCallback(
1473
+ (filterId: string) => {
1474
+ onChange(filters.filter((filter) => filter.id !== filterId))
1475
+ },
1476
+ [filters, onChange]
1477
+ )
1478
+
1479
+ const addFilter = useCallback(
1480
+ (fieldKey: string) => {
1481
+ const field = fieldsMap[fieldKey]
1482
+ if (field && field.key) {
1483
+ const defaultOperator =
1484
+ field.defaultOperator ||
1485
+ (field.type === "multiselect" ? "is_any_of" : "is")
1486
+ const defaultValues: unknown[] = field.type === "text" ? [""] : []
1487
+ const newFilter = createFilter<T>(
1488
+ fieldKey,
1489
+ defaultOperator,
1490
+ defaultValues as T[]
1491
+ )
1492
+ setLastAddedFilterId(newFilter.id)
1493
+ onChange([...filters, newFilter])
1494
+ setAddFilterOpen(false)
1495
+ setMenuSearchInput("")
1496
+ }
1497
+ },
1498
+ [fieldsMap, filters, onChange]
1499
+ )
1500
+
1501
+ const selectableFields = useMemo(() => {
1502
+ const flatFields = flattenFields(fields)
1503
+ return flatFields.filter((field) => {
1504
+ if (!field.key || field.type === "separator") return false
1505
+ if (allowMultiple) return true
1506
+ return !filters.some((filter) => filter.field === field.key)
1507
+ })
1508
+ }, [fields, filters, allowMultiple])
1509
+
1510
+ const filteredFields = useMemo(() => {
1511
+ return selectableFields.filter(
1512
+ (f) =>
1513
+ !menuSearchInput ||
1514
+ f.label?.toLowerCase().includes(menuSearchInput.toLowerCase())
1515
+ )
1516
+ }, [selectableFields, menuSearchInput])
1517
+
1518
+ useEffect(() => {
1519
+ if (addFilterOpen && filteredFields.length > 0) {
1520
+ setHighlightedIndex(0)
1521
+ }
1522
+ }, [addFilterOpen, filteredFields.length])
1523
+
1524
+ return (
1525
+ <FilterContext.Provider
1526
+ value={{
1527
+ variant,
1528
+ size,
1529
+ radius,
1530
+ i18n: mergedI18n,
1531
+ className,
1532
+ trigger,
1533
+ allowMultiple,
1534
+ }}
1535
+ >
1536
+ <div
1537
+ className={cn(filtersContainerVariants({ variant, size }), className)}
1538
+ >
1539
+ {selectableFields.length > 0 && (
1540
+ <DropdownMenu
1541
+ open={addFilterOpen}
1542
+ onOpenChange={(open) => {
1543
+ setAddFilterOpen(open)
1544
+ if (!open) {
1545
+ setMenuSearchInput("")
1546
+ setSessionFilterIds({})
1547
+ } else {
1548
+ setActiveMenu("root")
1549
+ }
1550
+ }}
1551
+ >
1552
+ <DropdownMenuTrigger asChild>
1553
+ {trigger || (
1554
+ <Button variant="outline">
1555
+ <PlusIcon
1556
+ />
1557
+ {mergedI18n.addFilter}
1558
+ </Button>
1559
+ )}
1560
+ </DropdownMenuTrigger>
1561
+ <DropdownMenuContent
1562
+ className={cn("w-[220px]", menuPopupClassName)}
1563
+ align="start"
1564
+ >
1565
+ {showSearchInput && (
1566
+ <>
1567
+ <div className="relative">
1568
+ <Input
1569
+ ref={rootInputRef}
1570
+ role="combobox"
1571
+ aria-controls={`${rootId}-listbox`}
1572
+ aria-activedescendant={
1573
+ highlightedIndex >= 0
1574
+ ? `${rootId}-item-${highlightedIndex}`
1575
+ : undefined
1576
+ }
1577
+ placeholder={mergedI18n.searchFields}
1578
+ className={cn(
1579
+ "h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none",
1580
+ "focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0"
1581
+ )}
1582
+ value={menuSearchInput}
1583
+ onChange={(e) => setMenuSearchInput(e.target.value)}
1584
+ onClick={(e) => e.stopPropagation()}
1585
+ onKeyDown={(e) => {
1586
+ if (e.key === "ArrowDown") {
1587
+ e.preventDefault()
1588
+ if (filteredFields.length > 0) {
1589
+ setHighlightedIndex((prev) =>
1590
+ prev < filteredFields.length - 1 ? prev + 1 : 0
1591
+ )
1592
+ }
1593
+ } else if (e.key === "ArrowUp") {
1594
+ e.preventDefault()
1595
+ if (filteredFields.length > 0) {
1596
+ setHighlightedIndex((prev) =>
1597
+ prev > 0 ? prev - 1 : filteredFields.length - 1
1598
+ )
1599
+ }
1600
+ } else if (
1601
+ (e.key === "ArrowRight" || e.key === "ArrowLeft") &&
1602
+ highlightedIndex >= 0
1603
+ ) {
1604
+ const field = filteredFields[highlightedIndex]
1605
+ const hasSubMenu =
1606
+ field &&
1607
+ (field.type === "select" ||
1608
+ field.type === "multiselect") &&
1609
+ field.options?.length
1610
+
1611
+ if (e.key === "ArrowRight" && hasSubMenu) {
1612
+ e.preventDefault()
1613
+ setOpenSubMenu(field.key || null)
1614
+ setActiveMenu(field.key || "root")
1615
+ } else if (e.key === "ArrowLeft") {
1616
+ e.preventDefault()
1617
+ if (openSubMenu) {
1618
+ setOpenSubMenu(null)
1619
+ setActiveMenu("root")
1620
+ }
1621
+ }
1622
+ } else if (e.key === "Enter" && highlightedIndex >= 0) {
1623
+ e.preventDefault()
1624
+ const field = filteredFields[highlightedIndex]
1625
+ if (field.key) {
1626
+ const hasSubMenu =
1627
+ (field.type === "select" ||
1628
+ field.type === "multiselect") &&
1629
+ field.options?.length
1630
+ if (!hasSubMenu) {
1631
+ addFilter(field.key)
1632
+ } else {
1633
+ if (openSubMenu === field.key) {
1634
+ setOpenSubMenu(null)
1635
+ setActiveMenu("root")
1636
+ } else {
1637
+ setOpenSubMenu(field.key)
1638
+ setActiveMenu(field.key)
1639
+ }
1640
+ }
1641
+ }
1642
+ } else if (e.key === "Escape") {
1643
+ setAddFilterOpen(false)
1644
+ }
1645
+ e.stopPropagation()
1646
+ }}
1647
+ />
1648
+ {enableShortcut && shortcutLabel && (
1649
+ <Kbd className="bg-background absolute top-1/2 right-2 -translate-y-1/2 border">
1650
+ {shortcutLabel}
1651
+ </Kbd>
1652
+ )}
1653
+ </div>
1654
+ <DropdownMenuSeparator />
1655
+ </>
1656
+ )}
1657
+
1658
+ <div className="relative flex max-h-full">
1659
+ <div
1660
+ className="flex max-h-[min(var(--radix-dropdown-menu-content-available-height),24rem)] w-full scroll-pt-2 scroll-pb-2 flex-col overscroll-contain"
1661
+ role="listbox"
1662
+ id={`${rootId}-listbox`}
1663
+ >
1664
+ <ScrollArea className="**:data-[slot=scroll-area-scrollbar]:m-0">
1665
+ {(() => {
1666
+ if (filteredFields.length === 0) {
1667
+ return (
1668
+ <div className="text-muted-foreground py-2 text-center text-sm">
1669
+ {mergedI18n.noFieldsFound}
1670
+ </div>
1671
+ )
1672
+ }
1673
+
1674
+ return filteredFields.map((field, index) => {
1675
+ const isHighlighted = highlightedIndex === index
1676
+ const itemId = `${rootId}-item-${index}`
1677
+ const hasSubMenu =
1678
+ (field.type === "select" ||
1679
+ field.type === "multiselect") &&
1680
+ field.options?.length
1681
+
1682
+ if (hasSubMenu) {
1683
+ const isMultiSelect = field.type === "multiselect"
1684
+ const fieldKey = field.key as string
1685
+ const sessionFilterId = sessionFilterIds[fieldKey]
1686
+ const sessionFilter = sessionFilterId
1687
+ ? filters.find((f) => f.id === sessionFilterId)
1688
+ : null
1689
+ const currentValues = sessionFilter?.values || []
1690
+
1691
+ return (
1692
+ <DropdownMenuSub
1693
+ key={fieldKey}
1694
+ open={openSubMenu === fieldKey}
1695
+ onOpenChange={(open) => {
1696
+ if (open) {
1697
+ setOpenSubMenu((prev) =>
1698
+ prev === fieldKey ? prev : fieldKey
1699
+ )
1700
+ } else {
1701
+ if (openSubMenu === fieldKey) {
1702
+ setOpenSubMenu(null)
1703
+ setActiveMenu("root")
1704
+ }
1705
+ }
1706
+ }}
1707
+ >
1708
+ <DropdownMenuSubTrigger
1709
+ id={itemId}
1710
+ role="option"
1711
+ aria-selected={isHighlighted}
1712
+ data-highlighted={isHighlighted || undefined}
1713
+ onMouseEnter={() => setHighlightedIndex(index)}
1714
+ className="data-[state=open]:bg-accent data-[state=open]:text-accent-foreground data-highlighted:bg-accent data-highlighted:text-accent-foreground"
1715
+ >
1716
+ {field.icon}
1717
+ <span>{field.label}</span>
1718
+ </DropdownMenuSubTrigger>
1719
+ <DropdownMenuSubContent className="w-[200px]">
1720
+ <FilterSubmenuContent
1721
+ field={field}
1722
+ currentValues={currentValues}
1723
+ isMultiSelect={isMultiSelect}
1724
+ i18n={mergedI18n}
1725
+ isActive={activeMenu === fieldKey}
1726
+ onActive={() => {
1727
+ if (field.searchable !== false) {
1728
+ setActiveMenu(fieldKey)
1729
+ }
1730
+ }}
1731
+ onBack={() => {
1732
+ setOpenSubMenu(null)
1733
+ setActiveMenu("root")
1734
+ }}
1735
+ onClose={() => setAddFilterOpen(false)}
1736
+ onToggle={(value, isSelected) => {
1737
+ if (isMultiSelect) {
1738
+ const nextValues = isSelected
1739
+ ? (currentValues.filter(
1740
+ (v) => v !== value
1741
+ ) as T[])
1742
+ : ([...currentValues, value] as T[])
1743
+
1744
+ if (sessionFilter) {
1745
+ if (nextValues.length === 0) {
1746
+ onChange(
1747
+ filters.filter(
1748
+ (f) => f.id !== sessionFilter.id
1749
+ )
1750
+ )
1751
+ setSessionFilterIds((prev) => ({
1752
+ ...prev,
1753
+ [fieldKey]: "",
1754
+ }))
1755
+ } else {
1756
+ onChange(
1757
+ filters.map((f) =>
1758
+ f.id === sessionFilter.id
1759
+ ? { ...f, values: nextValues }
1760
+ : f
1761
+ )
1762
+ )
1763
+ }
1764
+ } else {
1765
+ const newFilter = createFilter<T>(
1766
+ fieldKey,
1767
+ field.defaultOperator || "is_any_of",
1768
+ nextValues
1769
+ )
1770
+ onChange([...filters, newFilter])
1771
+ setSessionFilterIds((prev) => ({
1772
+ ...prev,
1773
+ [fieldKey]: newFilter.id,
1774
+ }))
1775
+ }
1776
+ } else {
1777
+ const newFilter = createFilter<T>(
1778
+ fieldKey,
1779
+ field.defaultOperator || "is",
1780
+ [value] as T[]
1781
+ )
1782
+ setLastAddedFilterId(newFilter.id)
1783
+ onChange([...filters, newFilter])
1784
+ setAddFilterOpen(false)
1785
+ }
1786
+ }}
1787
+ />
1788
+ </DropdownMenuSubContent>
1789
+ </DropdownMenuSub>
1790
+ )
1791
+ }
1792
+
1793
+ return (
1794
+ <DropdownMenuItem
1795
+ key={field.key}
1796
+ id={itemId}
1797
+ role="option"
1798
+ aria-selected={isHighlighted}
1799
+ data-highlighted={isHighlighted || undefined}
1800
+ onMouseEnter={() => setHighlightedIndex(index)}
1801
+ onClick={() => field.key && addFilter(field.key)}
1802
+ className="data-highlighted:bg-accent data-highlighted:text-accent-foreground"
1803
+ >
1804
+ {field.icon}
1805
+ <span>{field.label}</span>
1806
+ </DropdownMenuItem>
1807
+ )
1808
+ })
1809
+ })()}
1810
+ </ScrollArea>
1811
+ </div>
1812
+ </div>
1813
+ </DropdownMenuContent>
1814
+ </DropdownMenu>
1815
+ )}
1816
+
1817
+ {filters.map((filter) => {
1818
+ const field = fieldsMap[filter.field]
1819
+ if (!field) return null
1820
+ return (
1821
+ <ButtonGroup key={filter.id}>
1822
+ <ButtonGroupText className="bg-background dark:bg-input/30">
1823
+ {field.icon && field.icon}
1824
+ {field.label}
1825
+ </ButtonGroupText>
1826
+ <FilterOperatorDropdown<T>
1827
+ field={field}
1828
+ operator={filter.operator}
1829
+ values={filter.values}
1830
+ onChange={(operator) => updateFilter(filter.id, { operator })}
1831
+ />
1832
+ <FilterValueSelector<T>
1833
+ field={field}
1834
+ values={filter.values}
1835
+ operator={filter.operator}
1836
+ onChange={(values) => updateFilter(filter.id, { values })}
1837
+ autoFocus={filter.id === lastAddedFilterId}
1838
+ />
1839
+ <FilterRemoveButton onClick={() => removeFilter(filter.id)} />
1840
+ </ButtonGroup>
1841
+ )
1842
+ })}
1843
+ </div>
1844
+ </FilterContext.Provider>
1845
+ )
1846
+ }
1847
+
1848
+ export const createFilter = <T = unknown,>(
1849
+ field: string,
1850
+ operator?: string,
1851
+ values: T[] = []
1852
+ ): Filter<T> => ({
1853
+ id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
1854
+ field,
1855
+ operator: operator || "is",
1856
+ values,
1857
+ })
1858
+
1859
+ export const createFilterGroup = <T = unknown,>(
1860
+ id: string,
1861
+ label: string,
1862
+ fields: FilterFieldConfig<T>[],
1863
+ initialFilters: Filter<T>[] = []
1864
+ ): FilterGroup<T> => ({
1865
+ id,
1866
+ label,
1867
+ filters: initialFilters,
1868
+ fields,
1869
+ })