@eggspot/ui 0.0.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/eslint.config.js +4 -0
- package/package.json +66 -0
- package/postcss.config.mjs +1 -0
- package/src/components/Button.machine.tsx +50 -0
- package/src/components/Button.tsx +249 -0
- package/src/components/Button.variants.tsx +186 -0
- package/src/components/ButtonGroup.tsx +56 -0
- package/src/components/Calendar.tsx +275 -0
- package/src/components/Calendar.utils.tsx +22 -0
- package/src/components/Checkbox.tsx +199 -0
- package/src/components/ConfirmDialog.tsx +183 -0
- package/src/components/DashboardLayout/DashboardLayout.tsx +348 -0
- package/src/components/DashboardLayout/SidebarNav.tsx +509 -0
- package/src/components/DashboardLayout/index.ts +33 -0
- package/src/components/DataTable/DataTable.tsx +557 -0
- package/src/components/DataTable/DataTableColumnHeader.tsx +122 -0
- package/src/components/DataTable/DataTableDisplaySettings.tsx +265 -0
- package/src/components/DataTable/DataTableFloatingBar.tsx +44 -0
- package/src/components/DataTable/DataTablePagination.tsx +168 -0
- package/src/components/DataTable/DataTableStates.tsx +69 -0
- package/src/components/DataTable/DataTableToolbarContainer.tsx +47 -0
- package/src/components/DataTable/hooks/use-data-table-settings.ts +101 -0
- package/src/components/DataTable/index.ts +7 -0
- package/src/components/DataTable/types/data-table.ts +97 -0
- package/src/components/DatePicker.tsx +213 -0
- package/src/components/DatePicker.utils.tsx +38 -0
- package/src/components/Datefield.tsx +109 -0
- package/src/components/Datefield.utils.ts +10 -0
- package/src/components/Dialog.tsx +167 -0
- package/src/components/Field.tsx +49 -0
- package/src/components/Filter/Filter.store.tsx +122 -0
- package/src/components/Filter/Filter.tsx +11 -0
- package/src/components/Filter/Filter.types.ts +107 -0
- package/src/components/Filter/FilterBar.tsx +38 -0
- package/src/components/Filter/FilterBuilder.tsx +158 -0
- package/src/components/Filter/FilterField/DateModeRowValue.tsx +250 -0
- package/src/components/Filter/FilterField/FilterAsyncSelect.tsx +191 -0
- package/src/components/Filter/FilterField/FilterDateMode.tsx +241 -0
- package/src/components/Filter/FilterField/FilterDateRange.tsx +169 -0
- package/src/components/Filter/FilterField/FilterSelect.tsx +208 -0
- package/src/components/Filter/FilterField/FilterSingleDate.tsx +277 -0
- package/src/components/Filter/FilterField/OptionItem.tsx +112 -0
- package/src/components/Filter/FilterField/index.ts +6 -0
- package/src/components/Filter/FilterRow.tsx +527 -0
- package/src/components/Filter/index.ts +17 -0
- package/src/components/Form.tsx +195 -0
- package/src/components/Heading.tsx +41 -0
- package/src/components/Input.tsx +221 -0
- package/src/components/InputOTP.tsx +78 -0
- package/src/components/Label.tsx +65 -0
- package/src/components/Layout.tsx +129 -0
- package/src/components/ListBox.tsx +97 -0
- package/src/components/Menu.tsx +152 -0
- package/src/components/NativeSelect.tsx +77 -0
- package/src/components/NumberInput.tsx +114 -0
- package/src/components/Popover.tsx +44 -0
- package/src/components/Provider.tsx +22 -0
- package/src/components/RadioGroup.tsx +191 -0
- package/src/components/Resizable.tsx +71 -0
- package/src/components/ScrollArea.tsx +57 -0
- package/src/components/Select.tsx +626 -0
- package/src/components/Select.utils.tsx +64 -0
- package/src/components/Separator.tsx +25 -0
- package/src/components/Sheet.tsx +147 -0
- package/src/components/Sonner.tsx +96 -0
- package/src/components/Spinner.tsx +30 -0
- package/src/components/Switch.tsx +51 -0
- package/src/components/Text.tsx +35 -0
- package/src/components/Tooltip.tsx +58 -0
- package/src/consts/config.ts +2 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/lib/utils.ts +10 -0
- package/tsconfig.json +11 -0
- package/tsconfig.lint.json +8 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useMemo, useRef, useState } from "react"
|
|
4
|
+
import { Popover, PopoverDialog } from "@eggspot/ui/components/Popover"
|
|
5
|
+
import { Separator } from "@eggspot/ui/components/Separator"
|
|
6
|
+
import { Tooltip, TooltipTrigger } from "@eggspot/ui/components/Tooltip"
|
|
7
|
+
import { cn } from "@eggspot/ui/lib/utils"
|
|
8
|
+
import { XIcon } from "lucide-react"
|
|
9
|
+
|
|
10
|
+
import { FilterItemProvider, useFilterContext } from "./Filter.store"
|
|
11
|
+
import type {
|
|
12
|
+
FilterBuilderItem,
|
|
13
|
+
FilterFieldValue,
|
|
14
|
+
SelectOption,
|
|
15
|
+
SerializableSelectOption,
|
|
16
|
+
} from "./Filter.types"
|
|
17
|
+
import { isSelectOptionArray } from "./Filter.types"
|
|
18
|
+
|
|
19
|
+
interface FilterRowProps {
|
|
20
|
+
item: FilterBuilderItem
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function FilterRow({ item }: FilterRowProps) {
|
|
24
|
+
const { value, clearField } = useFilterContext()
|
|
25
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
26
|
+
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
27
|
+
const raw = value[item.field]
|
|
28
|
+
|
|
29
|
+
if (raw == null || (Array.isArray(raw) && raw.length === 0)) return null
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
className={cn(
|
|
34
|
+
"group bg-gray-3 inline-flex h-7 items-center rounded-md border text-xs"
|
|
35
|
+
)}
|
|
36
|
+
>
|
|
37
|
+
<button
|
|
38
|
+
ref={triggerRef}
|
|
39
|
+
type="button"
|
|
40
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
41
|
+
className={cn(
|
|
42
|
+
"flex h-full items-center gap-1.5 pr-1",
|
|
43
|
+
"hover:bg-gray-4 rounded-l-md transition-colors",
|
|
44
|
+
"focus-visible:ring-accent-9 outline-none focus-visible:ring-2 focus-visible:ring-inset",
|
|
45
|
+
isOpen && "bg-gray-4"
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
<div className="flex items-center gap-1.5 pr-2 pl-3">
|
|
49
|
+
{item.icon && <span className="text-gray-12">{item.icon}</span>}
|
|
50
|
+
<span className="text-gray-12 text-xs font-medium">{item.label}</span>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<Separator orientation="vertical" className="bg-gray-6 h-4 w-px" />
|
|
54
|
+
|
|
55
|
+
<div className="flex items-center gap-1 px-2">
|
|
56
|
+
{item.renderRowValue ? (
|
|
57
|
+
item.renderRowValue({ value: raw, field: item.field })
|
|
58
|
+
) : (
|
|
59
|
+
<FilterValueDisplay
|
|
60
|
+
field={item.field}
|
|
61
|
+
value={raw}
|
|
62
|
+
options={item.options}
|
|
63
|
+
renderIcon={item.renderIcon}
|
|
64
|
+
multi={item.multi}
|
|
65
|
+
formatValue={item.formatValue}
|
|
66
|
+
/>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
</button>
|
|
70
|
+
|
|
71
|
+
<Popover
|
|
72
|
+
triggerRef={triggerRef}
|
|
73
|
+
isOpen={isOpen}
|
|
74
|
+
onOpenChange={setIsOpen}
|
|
75
|
+
placement="bottom start"
|
|
76
|
+
className="overflow-hidden rounded-lg"
|
|
77
|
+
>
|
|
78
|
+
<PopoverDialog
|
|
79
|
+
className="bg-gray-2 min-w-[260px] p-0"
|
|
80
|
+
aria-label={`Filter ${item.label}`}
|
|
81
|
+
>
|
|
82
|
+
<FilterItemProvider
|
|
83
|
+
item={item}
|
|
84
|
+
mode="row"
|
|
85
|
+
onClose={() => setIsOpen(false)}
|
|
86
|
+
>
|
|
87
|
+
{item.render("row")}
|
|
88
|
+
</FilterItemProvider>
|
|
89
|
+
</PopoverDialog>
|
|
90
|
+
</Popover>
|
|
91
|
+
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={() => clearField(item.field)}
|
|
95
|
+
className={cn(
|
|
96
|
+
"group mr-1 flex size-5 cursor-pointer items-center justify-center rounded-sm px-[5px]",
|
|
97
|
+
"hover:bg-gray-4 transition-colors",
|
|
98
|
+
"focus-visible:ring-accent-9 focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset"
|
|
99
|
+
)}
|
|
100
|
+
aria-label={`Remove ${item.field} filter`}
|
|
101
|
+
>
|
|
102
|
+
<XIcon className="text-gray-11 group-hover:text-gray-12 size-3.5 transition-colors" />
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface FilterValueDisplayProps {
|
|
109
|
+
field: string
|
|
110
|
+
value: unknown
|
|
111
|
+
options?: SelectOption[]
|
|
112
|
+
renderIcon?: (option: SelectOption, isSelected: boolean) => React.ReactNode
|
|
113
|
+
multi?: boolean
|
|
114
|
+
formatValue?: (value: unknown) => string
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function FilterValueDisplay({
|
|
118
|
+
field,
|
|
119
|
+
value,
|
|
120
|
+
options,
|
|
121
|
+
renderIcon,
|
|
122
|
+
multi,
|
|
123
|
+
formatValue,
|
|
124
|
+
}: FilterValueDisplayProps) {
|
|
125
|
+
const { setFieldValue } = useFilterContext()
|
|
126
|
+
|
|
127
|
+
const isDateRange =
|
|
128
|
+
Array.isArray(value) &&
|
|
129
|
+
value.length === 2 &&
|
|
130
|
+
value[0] instanceof Date &&
|
|
131
|
+
value[1] instanceof Date
|
|
132
|
+
|
|
133
|
+
if (isDateRange && formatValue) {
|
|
134
|
+
return (
|
|
135
|
+
<span className="text-gray-12 inline-flex items-center gap-1.5 text-xs font-medium">
|
|
136
|
+
<span>{formatValue(value)}</span>
|
|
137
|
+
</span>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const storedOptions = isSelectOptionArray(value) ? value : []
|
|
142
|
+
const isStoredFormat = storedOptions.length > 0
|
|
143
|
+
|
|
144
|
+
const primitiveValues: unknown[] =
|
|
145
|
+
!isStoredFormat && Array.isArray(value)
|
|
146
|
+
? value
|
|
147
|
+
: !isStoredFormat && value != null
|
|
148
|
+
? [value]
|
|
149
|
+
: []
|
|
150
|
+
|
|
151
|
+
const getLabel = (item: SerializableSelectOption | unknown): string => {
|
|
152
|
+
if (formatValue) {
|
|
153
|
+
return formatValue(item)
|
|
154
|
+
}
|
|
155
|
+
if (typeof item === "object" && item !== null && "label" in item) {
|
|
156
|
+
return (item as SerializableSelectOption).label
|
|
157
|
+
}
|
|
158
|
+
const str = String(item)
|
|
159
|
+
return options?.find((o) => String(o.value) === str)?.label ?? str
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const getStoredOption = (
|
|
163
|
+
item: SerializableSelectOption | unknown
|
|
164
|
+
): SerializableSelectOption | undefined => {
|
|
165
|
+
if (typeof item === "object" && item !== null && "label" in item) {
|
|
166
|
+
return item as SerializableSelectOption
|
|
167
|
+
}
|
|
168
|
+
const str = String(item)
|
|
169
|
+
const opt = options?.find((o) => String(o.value) === str)
|
|
170
|
+
return opt
|
|
171
|
+
? { value: opt.value, label: opt.label, avatar: opt.avatar }
|
|
172
|
+
: undefined
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const getIcon = (
|
|
176
|
+
item: SerializableSelectOption | unknown
|
|
177
|
+
): SelectOption | undefined => {
|
|
178
|
+
const value =
|
|
179
|
+
typeof item === "object" && item !== null && "value" in item
|
|
180
|
+
? (item as SerializableSelectOption).value
|
|
181
|
+
: String(item)
|
|
182
|
+
return options?.find((o) => String(o.value) === value)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const handleRemoveItem = (itemValue: string) => {
|
|
186
|
+
if (isStoredFormat) {
|
|
187
|
+
const newOptions = storedOptions.filter((opt) => opt.value !== itemValue)
|
|
188
|
+
setFieldValue(field, newOptions.length > 0 ? newOptions : undefined)
|
|
189
|
+
} else {
|
|
190
|
+
const newValues = primitiveValues.filter((v) => String(v) !== itemValue)
|
|
191
|
+
setFieldValue(
|
|
192
|
+
field,
|
|
193
|
+
newValues.length > 0 ? (newValues as FilterFieldValue) : undefined
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const allItems = isStoredFormat ? storedOptions : primitiveValues
|
|
199
|
+
|
|
200
|
+
if (allItems.length === 0) {
|
|
201
|
+
return <span className="text-gray-12 font-medium">None</span>
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!multi) {
|
|
205
|
+
const item = allItems[0]
|
|
206
|
+
const storedOpt = getStoredOption(item)
|
|
207
|
+
const iconOpt = getIcon(item)
|
|
208
|
+
const avatar = storedOpt?.avatar
|
|
209
|
+
const icon = iconOpt?.icon
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<span className="text-gray-12 inline-flex items-center gap-1.5 text-xs font-medium">
|
|
213
|
+
<ItemIcon
|
|
214
|
+
avatar={avatar}
|
|
215
|
+
icon={icon}
|
|
216
|
+
label={getLabel(item)}
|
|
217
|
+
renderIcon={renderIcon}
|
|
218
|
+
iconOption={iconOpt}
|
|
219
|
+
size="md"
|
|
220
|
+
/>
|
|
221
|
+
<span>{getLabel(item)}</span>
|
|
222
|
+
</span>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const visibleItems = allItems.slice(0, 2)
|
|
227
|
+
const remainingItems = allItems.slice(2)
|
|
228
|
+
const hasRemaining = remainingItems.length > 0
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<div className="flex items-center gap-1">
|
|
232
|
+
{visibleItems.map((item, index) => {
|
|
233
|
+
const storedOpt = getStoredOption(item)
|
|
234
|
+
const iconOpt = getIcon(item)
|
|
235
|
+
const itemValue =
|
|
236
|
+
typeof item === "object" && item !== null && "value" in item
|
|
237
|
+
? (item as SerializableSelectOption).value
|
|
238
|
+
: String(item)
|
|
239
|
+
return (
|
|
240
|
+
<FilterBadge
|
|
241
|
+
key={itemValue}
|
|
242
|
+
label={getLabel(item)}
|
|
243
|
+
storedOption={storedOpt}
|
|
244
|
+
iconOption={iconOpt}
|
|
245
|
+
renderIcon={renderIcon}
|
|
246
|
+
onRemove={() => handleRemoveItem(itemValue)}
|
|
247
|
+
className={index === 1 ? "hidden sm:inline-flex" : undefined}
|
|
248
|
+
/>
|
|
249
|
+
)
|
|
250
|
+
})}
|
|
251
|
+
|
|
252
|
+
{hasRemaining && (
|
|
253
|
+
<RemainingBadges
|
|
254
|
+
items={remainingItems}
|
|
255
|
+
extraHiddenCount={visibleItems.length > 1 ? 1 : 0}
|
|
256
|
+
renderIcon={renderIcon}
|
|
257
|
+
onRemove={handleRemoveItem}
|
|
258
|
+
getLabel={getLabel}
|
|
259
|
+
getStoredOption={getStoredOption}
|
|
260
|
+
getIcon={getIcon}
|
|
261
|
+
/>
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
interface FilterBadgeProps {
|
|
268
|
+
label: string
|
|
269
|
+
storedOption?: SerializableSelectOption
|
|
270
|
+
iconOption?: SelectOption
|
|
271
|
+
renderIcon?: (option: SelectOption, isSelected: boolean) => React.ReactNode
|
|
272
|
+
onRemove: () => void
|
|
273
|
+
className?: string
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function FilterBadge({
|
|
277
|
+
label,
|
|
278
|
+
storedOption,
|
|
279
|
+
iconOption,
|
|
280
|
+
renderIcon,
|
|
281
|
+
onRemove,
|
|
282
|
+
className,
|
|
283
|
+
}: FilterBadgeProps) {
|
|
284
|
+
return (
|
|
285
|
+
<span
|
|
286
|
+
className={cn(
|
|
287
|
+
"bg-gray-5 text-gray-12 inline-flex items-center gap-1 rounded py-0.5 pr-0.5 pl-1.5 text-xs font-medium",
|
|
288
|
+
className
|
|
289
|
+
)}
|
|
290
|
+
>
|
|
291
|
+
<ItemIcon
|
|
292
|
+
avatar={storedOption?.avatar}
|
|
293
|
+
icon={iconOption?.icon}
|
|
294
|
+
label={label}
|
|
295
|
+
renderIcon={renderIcon}
|
|
296
|
+
iconOption={iconOption}
|
|
297
|
+
size="sm"
|
|
298
|
+
/>
|
|
299
|
+
<span className="max-w-[80px] truncate">{label}</span>
|
|
300
|
+
<div
|
|
301
|
+
role="button"
|
|
302
|
+
tabIndex={0}
|
|
303
|
+
onClick={(e) => {
|
|
304
|
+
e.stopPropagation()
|
|
305
|
+
onRemove()
|
|
306
|
+
}}
|
|
307
|
+
className={cn(
|
|
308
|
+
"flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm",
|
|
309
|
+
"hover:bg-gray-6 transition-colors",
|
|
310
|
+
"focus-visible:ring-accent-9 focus-visible:ring-1 focus-visible:outline-none"
|
|
311
|
+
)}
|
|
312
|
+
aria-label={`Remove ${label}`}
|
|
313
|
+
>
|
|
314
|
+
<XIcon className="size-2.5" />
|
|
315
|
+
</div>
|
|
316
|
+
</span>
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
interface RemainingBadgesProps {
|
|
321
|
+
items: (SerializableSelectOption | unknown)[]
|
|
322
|
+
extraHiddenCount?: number
|
|
323
|
+
renderIcon?: (option: SelectOption, isSelected: boolean) => React.ReactNode
|
|
324
|
+
onRemove: (value: string) => void
|
|
325
|
+
getLabel: (v: SerializableSelectOption | unknown) => string
|
|
326
|
+
getStoredOption: (
|
|
327
|
+
v: SerializableSelectOption | unknown
|
|
328
|
+
) => SerializableSelectOption | undefined
|
|
329
|
+
getIcon: (v: SerializableSelectOption | unknown) => SelectOption | undefined
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function RemainingBadges({
|
|
333
|
+
items,
|
|
334
|
+
extraHiddenCount = 0,
|
|
335
|
+
renderIcon,
|
|
336
|
+
onRemove,
|
|
337
|
+
getLabel,
|
|
338
|
+
getStoredOption,
|
|
339
|
+
getIcon,
|
|
340
|
+
}: RemainingBadgesProps) {
|
|
341
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
342
|
+
const triggerRef = useRef<HTMLSpanElement>(null)
|
|
343
|
+
|
|
344
|
+
const getItemValue = (item: SerializableSelectOption | unknown): string => {
|
|
345
|
+
if (typeof item === "object" && item !== null && "value" in item) {
|
|
346
|
+
return (item as SerializableSelectOption).value
|
|
347
|
+
}
|
|
348
|
+
return String(item)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const renderedTooltipItems = useMemo(
|
|
352
|
+
() =>
|
|
353
|
+
items.map((item) => {
|
|
354
|
+
const storedOpt = getStoredOption(item)
|
|
355
|
+
const iconOpt = getIcon(item)
|
|
356
|
+
|
|
357
|
+
return (
|
|
358
|
+
<span
|
|
359
|
+
key={getItemValue(item)}
|
|
360
|
+
className="inline-flex items-center gap-0.5 align-middle"
|
|
361
|
+
>
|
|
362
|
+
<ItemIcon
|
|
363
|
+
avatar={storedOpt?.avatar}
|
|
364
|
+
icon={iconOpt?.icon}
|
|
365
|
+
label={getLabel(item)}
|
|
366
|
+
renderIcon={renderIcon}
|
|
367
|
+
iconOption={iconOpt}
|
|
368
|
+
size="sm"
|
|
369
|
+
/>
|
|
370
|
+
<span className="text-gray-12 text-xs">{getLabel(item)}</span>
|
|
371
|
+
</span>
|
|
372
|
+
)
|
|
373
|
+
}),
|
|
374
|
+
[items, getStoredOption, getIcon, getLabel, renderIcon, getItemValue]
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
const toggleOpen = () => setIsOpen((prev) => !prev)
|
|
378
|
+
|
|
379
|
+
const handleClick: React.MouseEventHandler<HTMLSpanElement> = (e) => {
|
|
380
|
+
e.stopPropagation()
|
|
381
|
+
toggleOpen()
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const handleKeyDown: React.KeyboardEventHandler<HTMLSpanElement> = (e) => {
|
|
385
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
386
|
+
e.preventDefault()
|
|
387
|
+
e.stopPropagation()
|
|
388
|
+
toggleOpen()
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<>
|
|
394
|
+
<TooltipTrigger delay={200}>
|
|
395
|
+
<span
|
|
396
|
+
ref={triggerRef}
|
|
397
|
+
role="button"
|
|
398
|
+
tabIndex={0}
|
|
399
|
+
aria-label={`Show ${items.length} more selected values`}
|
|
400
|
+
onClick={handleClick}
|
|
401
|
+
onKeyDown={handleKeyDown}
|
|
402
|
+
className={cn(
|
|
403
|
+
"bg-gray-5 text-gray-12 inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium",
|
|
404
|
+
"hover:bg-gray-6 cursor-pointer transition-colors"
|
|
405
|
+
)}
|
|
406
|
+
>
|
|
407
|
+
<span className="sm:hidden">+{items.length + extraHiddenCount}</span>
|
|
408
|
+
<span className="hidden sm:inline">+{items.length}</span>
|
|
409
|
+
</span>
|
|
410
|
+
|
|
411
|
+
<Tooltip placement="bottom">
|
|
412
|
+
<div className="text-gray-12 max-w-[260px] text-xs">
|
|
413
|
+
{renderedTooltipItems.map((node, index) => (
|
|
414
|
+
<span key={index}>
|
|
415
|
+
{node}
|
|
416
|
+
{index < renderedTooltipItems.length - 1 && (
|
|
417
|
+
<span className="text-gray-11">, </span>
|
|
418
|
+
)}
|
|
419
|
+
</span>
|
|
420
|
+
))}
|
|
421
|
+
</div>
|
|
422
|
+
</Tooltip>
|
|
423
|
+
</TooltipTrigger>
|
|
424
|
+
|
|
425
|
+
<Popover
|
|
426
|
+
triggerRef={triggerRef}
|
|
427
|
+
isOpen={isOpen}
|
|
428
|
+
onOpenChange={setIsOpen}
|
|
429
|
+
placement="bottom start"
|
|
430
|
+
className="overflow-hidden rounded-lg"
|
|
431
|
+
>
|
|
432
|
+
<PopoverDialog className="bg-gray-2 max-w-[260px] min-w-[180px] p-0">
|
|
433
|
+
<div className="flex max-h-[200px] flex-col gap-1 overflow-y-auto p-2">
|
|
434
|
+
{items.map((item) => {
|
|
435
|
+
const storedOpt = getStoredOption(item)
|
|
436
|
+
const iconOpt = getIcon(item)
|
|
437
|
+
const itemValue = getItemValue(item)
|
|
438
|
+
|
|
439
|
+
return (
|
|
440
|
+
<div
|
|
441
|
+
key={itemValue}
|
|
442
|
+
className={cn(
|
|
443
|
+
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5",
|
|
444
|
+
"hover:bg-gray-3 transition-colors"
|
|
445
|
+
)}
|
|
446
|
+
>
|
|
447
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
448
|
+
<ItemIcon
|
|
449
|
+
avatar={storedOpt?.avatar}
|
|
450
|
+
icon={iconOpt?.icon}
|
|
451
|
+
label={getLabel(item)}
|
|
452
|
+
renderIcon={renderIcon}
|
|
453
|
+
iconOption={iconOpt}
|
|
454
|
+
size="md"
|
|
455
|
+
/>
|
|
456
|
+
<span className="text-gray-12 truncate text-xs">
|
|
457
|
+
{getLabel(item)}
|
|
458
|
+
</span>
|
|
459
|
+
</div>
|
|
460
|
+
<div
|
|
461
|
+
role="button"
|
|
462
|
+
tabIndex={0}
|
|
463
|
+
onClick={(e) => {
|
|
464
|
+
e.stopPropagation()
|
|
465
|
+
onRemove(itemValue)
|
|
466
|
+
}}
|
|
467
|
+
className={cn(
|
|
468
|
+
"flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm",
|
|
469
|
+
"hover:bg-gray-4 text-gray-11 hover:text-gray-12 transition-colors",
|
|
470
|
+
"focus-visible:ring-accent-9 focus-visible:ring-1 focus-visible:outline-none"
|
|
471
|
+
)}
|
|
472
|
+
aria-label={`Remove ${getLabel(item)}`}
|
|
473
|
+
>
|
|
474
|
+
<XIcon className="size-3" />
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
)
|
|
478
|
+
})}
|
|
479
|
+
</div>
|
|
480
|
+
</PopoverDialog>
|
|
481
|
+
</Popover>
|
|
482
|
+
</>
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/* Shared icon/avatar display for filter items */
|
|
487
|
+
interface ItemIconProps {
|
|
488
|
+
avatar?: string
|
|
489
|
+
icon?: React.ReactNode
|
|
490
|
+
label: string
|
|
491
|
+
renderIcon?: (option: SelectOption, isSelected: boolean) => React.ReactNode
|
|
492
|
+
iconOption?: SelectOption
|
|
493
|
+
size?: "sm" | "md"
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function ItemIcon({
|
|
497
|
+
avatar,
|
|
498
|
+
icon,
|
|
499
|
+
label,
|
|
500
|
+
renderIcon,
|
|
501
|
+
iconOption,
|
|
502
|
+
size = "md",
|
|
503
|
+
}: ItemIconProps) {
|
|
504
|
+
const sizeClasses = size === "sm" ? "size-3" : "size-4"
|
|
505
|
+
const svgSizeClasses = size === "sm" ? "[&>svg]:size-3" : "[&>svg]:size-3.5"
|
|
506
|
+
|
|
507
|
+
if (renderIcon && iconOption) {
|
|
508
|
+
return <span className="shrink-0">{renderIcon(iconOption, true)}</span>
|
|
509
|
+
}
|
|
510
|
+
if (avatar) {
|
|
511
|
+
return (
|
|
512
|
+
<img
|
|
513
|
+
src={avatar}
|
|
514
|
+
alt={label}
|
|
515
|
+
className={cn(sizeClasses, "shrink-0 rounded-full object-cover")}
|
|
516
|
+
/>
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
if (icon) {
|
|
520
|
+
return (
|
|
521
|
+
<span className={cn("text-gray-11 shrink-0", svgSizeClasses)}>
|
|
522
|
+
{icon}
|
|
523
|
+
</span>
|
|
524
|
+
)
|
|
525
|
+
}
|
|
526
|
+
return null
|
|
527
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { Filter } from "./Filter"
|
|
2
|
+
export { FilterBar } from "./FilterBar"
|
|
3
|
+
export { FilterBuilder } from "./FilterBuilder"
|
|
4
|
+
|
|
5
|
+
export * from "./FilterField"
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
FilterBuilderEntry,
|
|
9
|
+
FilterValue,
|
|
10
|
+
FilterFieldValue,
|
|
11
|
+
FilterProps,
|
|
12
|
+
FilterBuilderItem,
|
|
13
|
+
SelectOption,
|
|
14
|
+
SerializableSelectOption,
|
|
15
|
+
} from "./Filter.types"
|
|
16
|
+
|
|
17
|
+
export { isSelectOptionArray } from "./Filter.types"
|