@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.
- package/.claude/ralph-loop.local.md +9 -0
- package/README.md +172 -0
- package/bin/init.js +269 -0
- package/bun.lock +401 -0
- package/components.json +28 -0
- package/package.json +74 -0
- package/scripts/publish-npm.sh +202 -0
- package/src/AppShell.tsx +847 -0
- package/src/components/PageHeader.tsx +160 -0
- package/src/components/data-table/README.md +447 -0
- package/src/components/data-table/data-table-preferences.tsx +184 -0
- package/src/components/data-table/data-table-toolbar.tsx +118 -0
- package/src/components/data-table/data-table.tsx +37 -0
- package/src/components/data-table/index.ts +32 -0
- package/src/components/global-header/AllServicesButton.tsx +127 -0
- package/src/components/global-header/CategoriesButton.tsx +120 -0
- package/src/components/global-header/GlobalHeader.tsx +59 -0
- package/src/components/global-header/GlobalHeaderSearch.tsx +57 -0
- package/src/components/global-header/HeaderUtilities.tsx +243 -0
- package/src/components/global-header/ServicesMenu.tsx +246 -0
- package/src/components/layout/AppBreadcrumb.tsx +70 -0
- package/src/components/layout/AppFlashbar.tsx +95 -0
- package/src/components/layout/AppLayout.tsx +271 -0
- package/src/components/layout/AppNavigation.tsx +313 -0
- package/src/components/layout/AppSidebar.tsx +229 -0
- package/src/components/patterns/index.ts +14 -0
- package/src/components/patterns/p-alert-5.tsx +19 -0
- package/src/components/patterns/p-autocomplete-5.tsx +89 -0
- package/src/components/patterns/p-breadcrumb-1.tsx +28 -0
- package/src/components/patterns/p-button-42.tsx +37 -0
- package/src/components/patterns/p-button-51.tsx +14 -0
- package/src/components/patterns/p-button-6.tsx +5 -0
- package/src/components/patterns/p-calendar-1.tsx +18 -0
- package/src/components/patterns/p-card-1.tsx +33 -0
- package/src/components/patterns/p-card-2.tsx +26 -0
- package/src/components/patterns/p-card-5.tsx +31 -0
- package/src/components/patterns/p-collapsible-7.tsx +121 -0
- package/src/components/patterns/p-command-6.tsx +113 -0
- package/src/components/patterns/p-dialog-1.tsx +56 -0
- package/src/components/patterns/p-dropdown-menu-1.tsx +38 -0
- package/src/components/patterns/p-dropdown-menu-11.tsx +122 -0
- package/src/components/patterns/p-dropdown-menu-14.tsx +165 -0
- package/src/components/patterns/p-dropdown-menu-9.tsx +108 -0
- package/src/components/patterns/p-empty-2.tsx +34 -0
- package/src/components/patterns/p-file-upload-1.tsx +72 -0
- package/src/components/patterns/p-filters-1.tsx +666 -0
- package/src/components/patterns/p-frame-2.tsx +26 -0
- package/src/components/patterns/p-tabs-2.tsx +129 -0
- package/src/components/reui/alert.tsx +92 -0
- package/src/components/reui/autocomplete.tsx +343 -0
- package/src/components/reui/badge.tsx +87 -0
- package/src/components/reui/data-grid/data-grid-column-filter.tsx +165 -0
- package/src/components/reui/data-grid/data-grid-column-header.tsx +339 -0
- package/src/components/reui/data-grid/data-grid-column-visibility.tsx +55 -0
- package/src/components/reui/data-grid/data-grid-pagination.tsx +224 -0
- package/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx +260 -0
- package/src/components/reui/data-grid/data-grid-table-dnd.tsx +253 -0
- package/src/components/reui/data-grid/data-grid-table.tsx +639 -0
- package/src/components/reui/data-grid/data-grid.tsx +209 -0
- package/src/components/reui/date-selector.tsx +1330 -0
- package/src/components/reui/filters.tsx +1869 -0
- package/src/components/reui/frame.tsx +134 -0
- package/src/components/reui/index.ts +17 -0
- package/src/components/reui/timeline.tsx +219 -0
- package/src/components/search/Autocomplete.tsx +183 -0
- package/src/components/search/AutocompleteClient.tsx +293 -0
- package/src/components/search/GlobalSearch.tsx +187 -0
- package/src/components/section-drawer/deal-drawer-content.tsx +891 -0
- package/src/components/section-drawer/index.ts +19 -0
- package/src/components/section-drawer/section-drawer.css +665 -0
- package/src/components/section-drawer/section-drawer.tsx +467 -0
- package/src/components/sectioned-list-board/README.md +78 -0
- package/src/components/sectioned-list-board/board-card-content.tsx +340 -0
- package/src/components/sectioned-list-board/date-range-filter.tsx +249 -0
- package/src/components/sectioned-list-board/index.ts +19 -0
- package/src/components/sectioned-list-board/sectioned-list-board.css +564 -0
- package/src/components/sectioned-list-board/sectioned-list-board.tsx +731 -0
- package/src/components/sectioned-list-board/sortable-card.tsx +314 -0
- package/src/components/sectioned-list-board/sortable-section.tsx +319 -0
- package/src/components/sectioned-list-board/types.ts +216 -0
- package/src/components/sectioned-list-table/README.md +80 -0
- package/src/components/sectioned-list-table/index.ts +14 -0
- package/src/components/sectioned-list-table/sectioned-list-table.css +534 -0
- package/src/components/sectioned-list-table/sectioned-list-table.tsx +740 -0
- package/src/components/sectioned-list-table/sortable-column-header.tsx +120 -0
- package/src/components/sectioned-list-table/sortable-row.tsx +420 -0
- package/src/components/sectioned-list-table/sortable-section.tsx +251 -0
- package/src/components/sectioned-list-table/table-cell-content.tsx +129 -0
- package/src/components/sectioned-list-table/types.ts +120 -0
- package/src/components/sectioned-list-table/use-column-preferences.ts +103 -0
- package/src/components/ui/actions-dropdown.tsx +109 -0
- package/src/components/ui/assignee-selector.tsx +209 -0
- package/src/components/ui/avatar.tsx +107 -0
- package/src/components/ui/breadcrumb.tsx +109 -0
- package/src/components/ui/button-group.tsx +83 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/calendar.tsx +220 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/chart.tsx +376 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/collapsible.tsx +33 -0
- package/src/components/ui/command.tsx +182 -0
- package/src/components/ui/context-menu.tsx +250 -0
- package/src/components/ui/create-button-group.tsx +128 -0
- package/src/components/ui/dialog.tsx +156 -0
- package/src/components/ui/drawer.tsx +133 -0
- package/src/components/ui/dropdown-menu.tsx +255 -0
- package/src/components/ui/empty.tsx +104 -0
- package/src/components/ui/field.tsx +248 -0
- package/src/components/ui/form.tsx +165 -0
- package/src/components/ui/index.ts +37 -0
- package/src/components/ui/input-group.tsx +168 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/kbd.tsx +28 -0
- package/src/components/ui/label.tsx +22 -0
- package/src/components/ui/navigation-menu.tsx +168 -0
- package/src/components/ui/page-header.tsx +80 -0
- package/src/components/ui/popover.tsx +87 -0
- package/src/components/ui/scroll-area.tsx +56 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/sheet.tsx +141 -0
- package/src/components/ui/sidebar.tsx +726 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/sonner.tsx +38 -0
- package/src/components/ui/switch.tsx +33 -0
- package/src/components/ui/tabs.tsx +91 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/toggle-group.tsx +83 -0
- package/src/components/ui/toggle.tsx +45 -0
- package/src/components/ui/tooltip.tsx +57 -0
- package/src/hooks/use-copy-to-clipboard.ts +37 -0
- package/src/hooks/use-file-upload.ts +415 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/index.ts +95 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles.css +1859 -0
- package/src/urls.ts +83 -0
- package/src/vite.d.ts +22 -0
- package/src/vite.js +241 -0
- package/tsconfig.base.json +18 -0
- 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
|
+
})
|