@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.
Files changed (74) hide show
  1. package/eslint.config.js +4 -0
  2. package/package.json +66 -0
  3. package/postcss.config.mjs +1 -0
  4. package/src/components/Button.machine.tsx +50 -0
  5. package/src/components/Button.tsx +249 -0
  6. package/src/components/Button.variants.tsx +186 -0
  7. package/src/components/ButtonGroup.tsx +56 -0
  8. package/src/components/Calendar.tsx +275 -0
  9. package/src/components/Calendar.utils.tsx +22 -0
  10. package/src/components/Checkbox.tsx +199 -0
  11. package/src/components/ConfirmDialog.tsx +183 -0
  12. package/src/components/DashboardLayout/DashboardLayout.tsx +348 -0
  13. package/src/components/DashboardLayout/SidebarNav.tsx +509 -0
  14. package/src/components/DashboardLayout/index.ts +33 -0
  15. package/src/components/DataTable/DataTable.tsx +557 -0
  16. package/src/components/DataTable/DataTableColumnHeader.tsx +122 -0
  17. package/src/components/DataTable/DataTableDisplaySettings.tsx +265 -0
  18. package/src/components/DataTable/DataTableFloatingBar.tsx +44 -0
  19. package/src/components/DataTable/DataTablePagination.tsx +168 -0
  20. package/src/components/DataTable/DataTableStates.tsx +69 -0
  21. package/src/components/DataTable/DataTableToolbarContainer.tsx +47 -0
  22. package/src/components/DataTable/hooks/use-data-table-settings.ts +101 -0
  23. package/src/components/DataTable/index.ts +7 -0
  24. package/src/components/DataTable/types/data-table.ts +97 -0
  25. package/src/components/DatePicker.tsx +213 -0
  26. package/src/components/DatePicker.utils.tsx +38 -0
  27. package/src/components/Datefield.tsx +109 -0
  28. package/src/components/Datefield.utils.ts +10 -0
  29. package/src/components/Dialog.tsx +167 -0
  30. package/src/components/Field.tsx +49 -0
  31. package/src/components/Filter/Filter.store.tsx +122 -0
  32. package/src/components/Filter/Filter.tsx +11 -0
  33. package/src/components/Filter/Filter.types.ts +107 -0
  34. package/src/components/Filter/FilterBar.tsx +38 -0
  35. package/src/components/Filter/FilterBuilder.tsx +158 -0
  36. package/src/components/Filter/FilterField/DateModeRowValue.tsx +250 -0
  37. package/src/components/Filter/FilterField/FilterAsyncSelect.tsx +191 -0
  38. package/src/components/Filter/FilterField/FilterDateMode.tsx +241 -0
  39. package/src/components/Filter/FilterField/FilterDateRange.tsx +169 -0
  40. package/src/components/Filter/FilterField/FilterSelect.tsx +208 -0
  41. package/src/components/Filter/FilterField/FilterSingleDate.tsx +277 -0
  42. package/src/components/Filter/FilterField/OptionItem.tsx +112 -0
  43. package/src/components/Filter/FilterField/index.ts +6 -0
  44. package/src/components/Filter/FilterRow.tsx +527 -0
  45. package/src/components/Filter/index.ts +17 -0
  46. package/src/components/Form.tsx +195 -0
  47. package/src/components/Heading.tsx +41 -0
  48. package/src/components/Input.tsx +221 -0
  49. package/src/components/InputOTP.tsx +78 -0
  50. package/src/components/Label.tsx +65 -0
  51. package/src/components/Layout.tsx +129 -0
  52. package/src/components/ListBox.tsx +97 -0
  53. package/src/components/Menu.tsx +152 -0
  54. package/src/components/NativeSelect.tsx +77 -0
  55. package/src/components/NumberInput.tsx +114 -0
  56. package/src/components/Popover.tsx +44 -0
  57. package/src/components/Provider.tsx +22 -0
  58. package/src/components/RadioGroup.tsx +191 -0
  59. package/src/components/Resizable.tsx +71 -0
  60. package/src/components/ScrollArea.tsx +57 -0
  61. package/src/components/Select.tsx +626 -0
  62. package/src/components/Select.utils.tsx +64 -0
  63. package/src/components/Separator.tsx +25 -0
  64. package/src/components/Sheet.tsx +147 -0
  65. package/src/components/Sonner.tsx +96 -0
  66. package/src/components/Spinner.tsx +30 -0
  67. package/src/components/Switch.tsx +51 -0
  68. package/src/components/Text.tsx +35 -0
  69. package/src/components/Tooltip.tsx +58 -0
  70. package/src/consts/config.ts +2 -0
  71. package/src/hooks/.gitkeep +0 -0
  72. package/src/lib/utils.ts +10 -0
  73. package/tsconfig.json +11 -0
  74. 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"