@moloodev/ui 0.0.0-stage → 0.2.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 (93) hide show
  1. package/package.json +139 -3
  2. package/registry/components.ts +135 -0
  3. package/registry/fonts.css +81 -0
  4. package/registry/suit-OFL.txt +94 -0
  5. package/registry/wanted-sans-OFL.txt +93 -0
  6. package/src/components/ui/accordion.tsx +74 -0
  7. package/src/components/ui/alert-dialog.tsx +187 -0
  8. package/src/components/ui/alert.tsx +83 -0
  9. package/src/components/ui/aspect-ratio.tsx +22 -0
  10. package/src/components/ui/attachment.tsx +207 -0
  11. package/src/components/ui/avatar.tsx +109 -0
  12. package/src/components/ui/badge.tsx +54 -0
  13. package/src/components/ui/breadcrumb.tsx +126 -0
  14. package/src/components/ui/bubble.tsx +128 -0
  15. package/src/components/ui/button-group.tsx +103 -0
  16. package/src/components/ui/button.tsx +68 -0
  17. package/src/components/ui/calendar.tsx +222 -0
  18. package/src/components/ui/card.tsx +103 -0
  19. package/src/components/ui/carousel.tsx +242 -0
  20. package/src/components/ui/chart.tsx +398 -0
  21. package/src/components/ui/checkbox.tsx +28 -0
  22. package/src/components/ui/collapsible.tsx +40 -0
  23. package/src/components/ui/combobox.tsx +299 -0
  24. package/src/components/ui/command.tsx +218 -0
  25. package/src/components/ui/context-menu.tsx +271 -0
  26. package/src/components/ui/data-table.tsx +919 -0
  27. package/src/components/ui/date-picker.tsx +65 -0
  28. package/src/components/ui/dialog.tsx +224 -0
  29. package/src/components/ui/drawer.tsx +231 -0
  30. package/src/components/ui/dropdown-menu.tsx +268 -0
  31. package/src/components/ui/dropzone.tsx +186 -0
  32. package/src/components/ui/empty.tsx +101 -0
  33. package/src/components/ui/field.tsx +238 -0
  34. package/src/components/ui/form.tsx +83 -0
  35. package/src/components/ui/highlighted-source.tsx +200 -0
  36. package/src/components/ui/hover-card.tsx +51 -0
  37. package/src/components/ui/image-field.tsx +796 -0
  38. package/src/components/ui/input-group.tsx +149 -0
  39. package/src/components/ui/input-otp.tsx +86 -0
  40. package/src/components/ui/input-step.ts +19 -0
  41. package/src/components/ui/input.tsx +100 -0
  42. package/src/components/ui/item.tsx +201 -0
  43. package/src/components/ui/kbd.tsx +91 -0
  44. package/src/components/ui/kind-icon-glob.ts +24 -0
  45. package/src/components/ui/kind-icon-load.ts +52 -0
  46. package/src/components/ui/kind-icon-static.ts +46 -0
  47. package/src/components/ui/kind-icon.tsx +84 -0
  48. package/src/components/ui/kind-mark.tsx +65 -0
  49. package/src/components/ui/label.tsx +29 -0
  50. package/src/components/ui/marker.tsx +71 -0
  51. package/src/components/ui/menubar.tsx +277 -0
  52. package/src/components/ui/message-scroller.tsx +137 -0
  53. package/src/components/ui/message.tsx +92 -0
  54. package/src/components/ui/moloo-logo.tsx +45 -0
  55. package/src/components/ui/native-select.tsx +61 -0
  56. package/src/components/ui/navigation-menu.tsx +168 -0
  57. package/src/components/ui/outline-list.tsx +73 -0
  58. package/src/components/ui/pagination.tsx +130 -0
  59. package/src/components/ui/panel.tsx +108 -0
  60. package/src/components/ui/popover.tsx +90 -0
  61. package/src/components/ui/progress.tsx +117 -0
  62. package/src/components/ui/radio-group.tsx +38 -0
  63. package/src/components/ui/resizable.tsx +52 -0
  64. package/src/components/ui/scroll-area.tsx +54 -0
  65. package/src/components/ui/select.tsx +205 -0
  66. package/src/components/ui/separator.tsx +25 -0
  67. package/src/components/ui/sheet.tsx +140 -0
  68. package/src/components/ui/sidebar.tsx +746 -0
  69. package/src/components/ui/skeleton.tsx +13 -0
  70. package/src/components/ui/slider.tsx +52 -0
  71. package/src/components/ui/spinner.tsx +18 -0
  72. package/src/components/ui/switch.tsx +32 -0
  73. package/src/components/ui/table.tsx +116 -0
  74. package/src/components/ui/tabs.tsx +83 -0
  75. package/src/components/ui/tag-list-input.tsx +194 -0
  76. package/src/components/ui/textarea.tsx +18 -0
  77. package/src/components/ui/timeline.tsx +265 -0
  78. package/src/components/ui/toast.tsx +237 -0
  79. package/src/components/ui/toggle-group.tsx +90 -0
  80. package/src/components/ui/toggle.tsx +45 -0
  81. package/src/components/ui/tooltip.tsx +66 -0
  82. package/src/css.d.ts +1 -0
  83. package/src/hooks/use-clipboard.ts +82 -0
  84. package/src/hooks/use-mobile.ts +21 -0
  85. package/src/index.ts +106 -0
  86. package/src/kind-icon.ts +4 -0
  87. package/src/lib/format-relative-time.ts +41 -0
  88. package/src/lib/list-selection.ts +61 -0
  89. package/src/lib/utils.ts +6 -0
  90. package/src/moloo.css +360 -0
  91. package/src/styles.css +36 -0
  92. package/src/typeset.css +518 -0
  93. package/README.md +0 -3
@@ -0,0 +1,919 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import {
5
+ IconArrowsSort,
6
+ IconCheck,
7
+ IconCopy,
8
+ IconDots,
9
+ IconFilter,
10
+ IconMinus,
11
+ IconSearch,
12
+ IconSortAscending,
13
+ IconSortDescending,
14
+ } from "@tabler/icons-react"
15
+ import {
16
+ columnFilteringFeature,
17
+ createFilteredRowModel,
18
+ createPaginatedRowModel,
19
+ createSortedRowModel,
20
+ createTableHook,
21
+ filterFn_arrHas,
22
+ metaHelper,
23
+ rowPaginationFeature,
24
+ rowSelectionFeature,
25
+ rowSortingFeature,
26
+ sortFn_alphanumeric,
27
+ sortFn_datetime,
28
+ sortFn_text,
29
+ Subscribe,
30
+ tableFeatures,
31
+ type ColumnFiltersState,
32
+ type PaginationState,
33
+ type RowData,
34
+ type RowSelectionState,
35
+ type SortingState,
36
+ type TableState,
37
+ } from "@tanstack/react-table"
38
+
39
+ import { Badge } from "./badge"
40
+ import { Button } from "./button"
41
+ import { Checkbox } from "./checkbox"
42
+ import {
43
+ DropdownMenu,
44
+ DropdownMenuContent,
45
+ DropdownMenuTrigger,
46
+ } from "./dropdown-menu"
47
+ import {
48
+ InputGroup,
49
+ InputGroupAddon,
50
+ InputGroupInput,
51
+ } from "./input-group"
52
+ import {
53
+ Popover,
54
+ PopoverContent,
55
+ PopoverTrigger,
56
+ } from "./popover"
57
+ import { Skeleton as UiSkeleton } from "./skeleton"
58
+ import {
59
+ Table,
60
+ TableBody,
61
+ TableCell,
62
+ TableHead,
63
+ TableHeader,
64
+ TableRow,
65
+ } from "./table"
66
+ import { toast } from "./toast"
67
+ import { cn } from "../../lib/utils"
68
+
69
+ export type EnumFilterOption = {
70
+ value: string
71
+ label: string
72
+ }
73
+
74
+ export type EnumFilterMeta = {
75
+ options: readonly EnumFilterOption[]
76
+ }
77
+
78
+ export type ColumnMetaWithEnumFilter = {
79
+ enumFilter?: EnumFilterMeta
80
+ }
81
+
82
+ export type ColumnMetaWithCopyable = {
83
+ copyable?: boolean
84
+ }
85
+
86
+ type DataTableColumnMeta = ColumnMetaWithEnumFilter & ColumnMetaWithCopyable
87
+
88
+ export function compareValues(av: unknown, bv: unknown): number {
89
+ if (av == null && bv == null) return 0
90
+ if (av == null) return -1
91
+ if (bv == null) return 1
92
+
93
+ if (typeof av === "number" && typeof bv === "number") {
94
+ return av - bv
95
+ }
96
+
97
+ if (av instanceof Date || bv instanceof Date) {
98
+ const at = av instanceof Date ? av.getTime() : new Date(String(av)).getTime()
99
+ const bt = bv instanceof Date ? bv.getTime() : new Date(String(bv)).getTime()
100
+ if (!Number.isNaN(at) && !Number.isNaN(bt)) return at - bt
101
+ }
102
+
103
+ return String(av).localeCompare(String(bv), undefined, {
104
+ sensitivity: "base",
105
+ numeric: true,
106
+ })
107
+ }
108
+
109
+ type AccessorMap<TItem> = Record<string, (item: TItem) => unknown>
110
+
111
+ export function compareBySorting<TItem>(
112
+ sorting: SortingState,
113
+ accessors: AccessorMap<TItem>,
114
+ ): (a: TItem, b: TItem) => number {
115
+ return (a, b) => {
116
+ for (const sort of sorting) {
117
+ const get = accessors[sort.id]
118
+ if (!get) continue
119
+ const cmp = compareValues(get(a), get(b))
120
+ if (cmp !== 0) return sort.desc ? -cmp : cmp
121
+ }
122
+ return 0
123
+ }
124
+ }
125
+
126
+ export function sortBySortingState<TItem>(
127
+ items: readonly TItem[],
128
+ sorting: SortingState,
129
+ getValue: (item: TItem, columnId: string) => unknown,
130
+ ): TItem[] {
131
+ if (!sorting.length || items.length < 2) {
132
+ return items.slice()
133
+ }
134
+
135
+ return items.slice().sort((a, b) => {
136
+ for (const sort of sorting) {
137
+ const cmp = compareValues(getValue(a, sort.id), getValue(b, sort.id))
138
+ if (cmp !== 0) return sort.desc ? -cmp : cmp
139
+ }
140
+ return 0
141
+ })
142
+ }
143
+
144
+ export function isEnumFilterNarrowed(
145
+ selected: readonly string[] | undefined,
146
+ options: readonly EnumFilterOption[],
147
+ ) {
148
+ if (!selected) return false
149
+ if (selected.length === 0) return true
150
+ return selected.length < options.length
151
+ }
152
+
153
+ export function allEnumFilterValues(options: readonly EnumFilterOption[]) {
154
+ return options.map((option) => option.value)
155
+ }
156
+
157
+ export function toggleEnumFilterValue(
158
+ selected: readonly string[],
159
+ value: string,
160
+ checked: boolean,
161
+ ) {
162
+ if (checked) {
163
+ return selected.includes(value) ? [...selected] : [...selected, value]
164
+ }
165
+ return selected.filter((entry) => entry !== value)
166
+ }
167
+
168
+ export type SearchAcrossFieldsInput<TItem, TField extends string> = {
169
+ query: string
170
+ fields: readonly TField[]
171
+ fetchField: (args: { field: TField; query: string }) => Promise<TItem[]>
172
+ getId: (item: TItem) => string
173
+ pagination: PaginationState
174
+ compare?: (a: TItem, b: TItem) => number
175
+ }
176
+
177
+ export type SearchAcrossFieldsResult<TItem> = {
178
+ items: TItem[]
179
+ total: number
180
+ }
181
+
182
+ export async function searchAcrossFields<TItem, TField extends string>(
183
+ input: SearchAcrossFieldsInput<TItem, TField>,
184
+ ): Promise<SearchAcrossFieldsResult<TItem>> {
185
+ const query = input.query.trim()
186
+ if (!query) {
187
+ throw new Error("searchAcrossFields requires a non-empty query")
188
+ }
189
+
190
+ const batches = await Promise.all(
191
+ input.fields.map((field) => input.fetchField({ field, query })),
192
+ )
193
+
194
+ const byId = new Map<string, TItem>()
195
+ for (const batch of batches) {
196
+ for (const item of batch) {
197
+ byId.set(input.getId(item), item)
198
+ }
199
+ }
200
+
201
+ const items = [...byId.values()]
202
+ if (input.compare) {
203
+ items.sort(input.compare)
204
+ }
205
+
206
+ const total = items.length
207
+ const start = input.pagination.pageIndex * input.pagination.pageSize
208
+
209
+ return {
210
+ items: items.slice(start, start + input.pagination.pageSize),
211
+ total,
212
+ }
213
+ }
214
+
215
+ function useCopy(copiedLabel: string, copyErrorLabel: string) {
216
+ const [copied, setCopied] = React.useState(false)
217
+ const timer = React.useRef<number | null>(null)
218
+
219
+ React.useEffect(
220
+ () => () => {
221
+ if (timer.current != null) window.clearTimeout(timer.current)
222
+ },
223
+ [],
224
+ )
225
+
226
+ async function copy(text: string) {
227
+ try {
228
+ await navigator.clipboard.writeText(text)
229
+ setCopied(true)
230
+ if (timer.current != null) window.clearTimeout(timer.current)
231
+ timer.current = window.setTimeout(() => setCopied(false), 2000)
232
+ toast.add({ title: copiedLabel, type: "success" })
233
+ } catch {
234
+ toast.add({ title: copyErrorLabel, type: "error" })
235
+ }
236
+ }
237
+
238
+ return { copy, copied }
239
+ }
240
+
241
+ function Text({
242
+ copyable,
243
+ copyLabel = "Copy",
244
+ copiedLabel = "Copied to the clipboard.",
245
+ copyErrorLabel = "Could not copy.",
246
+ }: {
247
+ copyable?: boolean
248
+ copyLabel?: string
249
+ copiedLabel?: string
250
+ copyErrorLabel?: string
251
+ } = {}) {
252
+ const cell = useCellContext()
253
+ const enabled = copyable ?? cell.column.columnDef.meta?.copyable ?? false
254
+ const raw = cell.getValue()
255
+ const text = raw == null ? "" : String(raw)
256
+ const empty = raw == null || text.trim() === ""
257
+ const { copy, copied } = useCopy(copiedLabel, copyErrorLabel)
258
+
259
+ if (!enabled) {
260
+ return <span>{empty ? "—" : text}</span>
261
+ }
262
+
263
+ return (
264
+ <div className="flex min-w-0 items-center gap-0.5">
265
+ <span className="min-w-0 truncate">{empty ? "—" : text}</span>
266
+ {empty ? null : (
267
+ <Button
268
+ type="button"
269
+ variant="ghost"
270
+ size="icon-xs"
271
+ className="shrink-0 text-muted-foreground hover:text-foreground"
272
+ aria-label={copyLabel}
273
+ onClick={(event) => {
274
+ event.stopPropagation()
275
+ void copy(text)
276
+ }}
277
+ >
278
+ {copied ? (
279
+ <IconCheck className="size-3" aria-hidden />
280
+ ) : (
281
+ <IconCopy className="size-3" aria-hidden />
282
+ )}
283
+ </Button>
284
+ )}
285
+ </div>
286
+ )
287
+ }
288
+
289
+ function BadgeCell({
290
+ variant = "secondary",
291
+ }: {
292
+ variant?: React.ComponentProps<typeof Badge>["variant"]
293
+ } = {}) {
294
+ const cell = useCellContext()
295
+ const value = cell.getValue()
296
+ if (value == null || value === "") return <span>—</span>
297
+ return <Badge variant={variant}>{String(value)}</Badge>
298
+ }
299
+
300
+ function formatDate(value: unknown, locale?: string) {
301
+ if (value == null || value === "") return "—"
302
+ const date = value instanceof Date ? value : new Date(String(value))
303
+ if (Number.isNaN(date.getTime())) return "—"
304
+ return date.toLocaleString(locale)
305
+ }
306
+
307
+ function DateTime({ locale }: { locale?: string } = {}) {
308
+ const cell = useCellContext()
309
+ return (
310
+ <span className="text-muted-foreground tabular-nums">
311
+ {formatDate(cell.getValue(), locale)}
312
+ </span>
313
+ )
314
+ }
315
+
316
+ function RowActions({
317
+ children,
318
+ label = "Row actions",
319
+ }: {
320
+ children: (row: unknown) => React.ReactNode
321
+ label?: string
322
+ }) {
323
+ const cell = useCellContext()
324
+ return (
325
+ <DropdownMenu>
326
+ <DropdownMenuTrigger
327
+ render={
328
+ <Button
329
+ type="button"
330
+ variant="ghost"
331
+ size="icon-sm"
332
+ aria-label={label}
333
+ />
334
+ }
335
+ >
336
+ <IconDots className="size-4" />
337
+ </DropdownMenuTrigger>
338
+ <DropdownMenuContent align="end" className="min-w-40">
339
+ {children(cell.row.original)}
340
+ </DropdownMenuContent>
341
+ </DropdownMenu>
342
+ )
343
+ }
344
+
345
+ function SelectCheckbox({
346
+ checked,
347
+ indeterminate = false,
348
+ label,
349
+ onCheckedChange,
350
+ }: {
351
+ checked: boolean
352
+ indeterminate?: boolean
353
+ label: string
354
+ onCheckedChange: () => void
355
+ }) {
356
+ return (
357
+ <span className="relative inline-flex">
358
+ <Checkbox
359
+ checked={checked}
360
+ indeterminate={indeterminate}
361
+ aria-label={label}
362
+ onCheckedChange={onCheckedChange}
363
+ className="data-indeterminate:[&_svg]:invisible"
364
+ />
365
+ {indeterminate ? (
366
+ <IconMinus
367
+ aria-hidden
368
+ className="pointer-events-none absolute top-1/2 left-1/2 size-3 -translate-x-1/2 -translate-y-1/2 text-primary-foreground"
369
+ />
370
+ ) : null}
371
+ </span>
372
+ )
373
+ }
374
+
375
+ function SelectCell({
376
+ label = "Select row",
377
+ }: {
378
+ label?: string
379
+ } = {}) {
380
+ const cell = useCellContext()
381
+ const { row, table } = cell
382
+
383
+ return (
384
+ <Subscribe
385
+ source={table.atoms.rowSelection}
386
+ selector={(selection: RowSelectionState) => Boolean(selection[row.id])}
387
+ >
388
+ {(checked) => (
389
+ <SelectCheckbox
390
+ checked={checked}
391
+ label={label}
392
+ onCheckedChange={() => {
393
+ row.toggleSelected()
394
+ }}
395
+ />
396
+ )}
397
+ </Subscribe>
398
+ )
399
+ }
400
+
401
+ function Sortable() {
402
+ const header = useHeaderContext()
403
+ const { column, table } = header
404
+
405
+ return (
406
+ <Subscribe
407
+ source={table.atoms.sorting}
408
+ selector={(sorting: SortingState) => {
409
+ const columnSort = sorting.find((entry) => entry.id === column.id)
410
+ if (!columnSort) return false as const
411
+ return columnSort.desc ? ("desc" as const) : ("asc" as const)
412
+ }}
413
+ >
414
+ {(sorted) => {
415
+ if (sorted === "asc") {
416
+ return (
417
+ <IconSortAscending className="size-3.5 text-foreground" aria-hidden />
418
+ )
419
+ }
420
+ if (sorted === "desc") {
421
+ return (
422
+ <IconSortDescending className="size-3.5 text-foreground" aria-hidden />
423
+ )
424
+ }
425
+ return (
426
+ <IconArrowsSort
427
+ className="size-3.5 text-muted-foreground opacity-50"
428
+ aria-hidden
429
+ />
430
+ )
431
+ }}
432
+ </Subscribe>
433
+ )
434
+ }
435
+
436
+ function readEnumOptions(column: {
437
+ columnDef: { meta?: ColumnMetaWithEnumFilter }
438
+ }): readonly EnumFilterOption[] {
439
+ return column.columnDef.meta?.enumFilter?.options ?? []
440
+ }
441
+
442
+ function EnumFilter({ label = "Filter" }: { label?: string } = {}) {
443
+ const header = useHeaderContext()
444
+ const { column, table } = header
445
+ const options = readEnumOptions(column)
446
+
447
+ if (options.length === 0) return null
448
+
449
+ return (
450
+ <Subscribe
451
+ source={table.atoms.columnFilters}
452
+ selector={(filters: ColumnFiltersState) => {
453
+ const entry = filters.find((filter) => filter.id === column.id)
454
+ const value = entry?.value
455
+ if (!Array.isArray(value)) return undefined
456
+ return value.filter((item): item is string => typeof item === "string")
457
+ }}
458
+ >
459
+ {(selected) => {
460
+ const values = selected ?? options.map((option) => option.value)
461
+ const narrowed = isEnumFilterNarrowed(values, options)
462
+
463
+ return (
464
+ <Popover>
465
+ <PopoverTrigger
466
+ render={
467
+ <button
468
+ type="button"
469
+ className="moloo-focus inline-flex size-5 items-center justify-center rounded-sm hover:text-foreground"
470
+ aria-label={label}
471
+ />
472
+ }
473
+ >
474
+ <IconFilter
475
+ className={cn(
476
+ "size-3.5",
477
+ narrowed
478
+ ? "text-foreground"
479
+ : "text-muted-foreground opacity-50",
480
+ )}
481
+ aria-hidden
482
+ />
483
+ </PopoverTrigger>
484
+ <PopoverContent align="start" className="w-36 gap-0.5 p-1.5">
485
+ <ul className="flex flex-col gap-0.5">
486
+ {options.map((option) => {
487
+ const checked = values.includes(option.value)
488
+ const id = `enum-filter-${column.id}-${option.value}`
489
+ return (
490
+ <li key={option.value}>
491
+ <label
492
+ htmlFor={id}
493
+ className="flex cursor-pointer items-center gap-1.5 rounded-sm px-1 py-1 hover:bg-muted/60"
494
+ >
495
+ <Checkbox
496
+ id={id}
497
+ checked={checked}
498
+ className="size-3 rounded-[3px] [&_svg]:size-2.5"
499
+ onCheckedChange={(next) => {
500
+ column.setFilterValue(
501
+ toggleEnumFilterValue(values, option.value, next === true),
502
+ )
503
+ }}
504
+ />
505
+ <span className="text-xs leading-none font-medium">
506
+ {option.label}
507
+ </span>
508
+ </label>
509
+ </li>
510
+ )
511
+ })}
512
+ </ul>
513
+ </PopoverContent>
514
+ </Popover>
515
+ )
516
+ }}
517
+ </Subscribe>
518
+ )
519
+ }
520
+
521
+ function SelectHeader({
522
+ label = "Select all rows on this page",
523
+ }: {
524
+ label?: string
525
+ } = {}) {
526
+ const header = useHeaderContext()
527
+ const { table } = header
528
+
529
+ return (
530
+ <Subscribe
531
+ source={table.atoms.rowSelection}
532
+ selector={() => ({
533
+ all: table.getIsAllPageRowsSelected(),
534
+ some: table.getIsSomePageRowsSelected(),
535
+ })}
536
+ >
537
+ {({ all, some }) => (
538
+ <SelectCheckbox
539
+ checked={all}
540
+ indeterminate={some && !all}
541
+ label={label}
542
+ onCheckedChange={() => {
543
+ table.toggleAllPageRowsSelected(!all)
544
+ }}
545
+ />
546
+ )}
547
+ </Subscribe>
548
+ )
549
+ }
550
+
551
+ const features = tableFeatures({
552
+ columnMeta: metaHelper<DataTableColumnMeta>(),
553
+ columnFilteringFeature,
554
+ rowPaginationFeature,
555
+ rowSelectionFeature,
556
+ rowSortingFeature,
557
+ filteredRowModel: createFilteredRowModel(),
558
+ sortedRowModel: createSortedRowModel(),
559
+ paginatedRowModel: createPaginatedRowModel(),
560
+ filterFns: {
561
+ arrHas: filterFn_arrHas,
562
+ },
563
+ sortFns: {
564
+ alphanumeric: sortFn_alphanumeric,
565
+ datetime: sortFn_datetime,
566
+ text: sortFn_text,
567
+ },
568
+ })
569
+
570
+ const {
571
+ useAppTable,
572
+ createAppColumnHelper,
573
+ useTableContext,
574
+ useCellContext,
575
+ useHeaderContext,
576
+ appFeatures,
577
+ } = createTableHook({
578
+ features,
579
+ enableSortingRemoval: false,
580
+ cellComponents: {
581
+ Text,
582
+ Badge: BadgeCell,
583
+ DateTime,
584
+ RowActions,
585
+ Select: SelectCell,
586
+ },
587
+ headerComponents: {
588
+ Sortable,
589
+ EnumFilter,
590
+ Select: SelectHeader,
591
+ },
592
+ })
593
+
594
+ export const useTable = useAppTable
595
+ export const createColumnHelper = createAppColumnHelper
596
+ export { useTableContext, useCellContext, useHeaderContext, appFeatures, features }
597
+
598
+ export type Table<
599
+ TData extends RowData,
600
+ TSelected = TableState<typeof features>,
601
+ > = ReturnType<typeof useTable<TData, TSelected>>
602
+
603
+ type ViewState = { sorting?: SortingState }
604
+
605
+ type RootProps<TData extends RowData, TSelected> = {
606
+ table: Table<TData, TSelected>
607
+ toolbar?: React.ReactNode
608
+ footer?: React.ReactNode
609
+ loading?: boolean
610
+ loadingFallback?: React.ReactNode
611
+ empty?: React.ReactNode
612
+ selector?: (state: TableState<typeof features>) => ViewState
613
+ }
614
+
615
+ function reorderManualSortPage<TData extends RowData, TSelected>(
616
+ table: Table<TData, TSelected>,
617
+ sorting: SortingState | undefined,
618
+ ) {
619
+ const rows = table.getRowModel().rows
620
+ if (!table.options.manualSorting || !sorting?.length) {
621
+ return rows
622
+ }
623
+
624
+ return sortBySortingState(rows, sorting, (row, columnId) => {
625
+ try {
626
+ return row.getValue(columnId)
627
+ } catch {
628
+ return undefined
629
+ }
630
+ })
631
+ }
632
+
633
+ function columnAriaSort(
634
+ sorting: SortingState | undefined,
635
+ columnId: string,
636
+ ): "ascending" | "descending" | "none" | undefined {
637
+ if (!sorting) return undefined
638
+ const entry = sorting.find((item) => item.id === columnId)
639
+ if (!entry) return "none"
640
+ return entry.desc ? "descending" : "ascending"
641
+ }
642
+
643
+ export function Root<TData extends RowData, TSelected>({
644
+ table,
645
+ toolbar,
646
+ footer,
647
+ loading,
648
+ loadingFallback,
649
+ empty = "No results.",
650
+ selector = (state) => state,
651
+ }: RootProps<TData, TSelected>) {
652
+ return (
653
+ <table.AppTable selector={selector}>
654
+ {(state) => {
655
+ const rows = reorderManualSortPage(table, state.sorting)
656
+ const showEmpty = !loading && rows.length === 0
657
+
658
+ return (
659
+ <div data-slot="data-table" className="flex w-full flex-col gap-3">
660
+ {toolbar}
661
+ <div className="overflow-hidden rounded-md border-2 border-border">
662
+ <Table>
663
+ <TableHeader>
664
+ {table.getHeaderGroups().map((headerGroup) => (
665
+ <TableRow key={headerGroup.id}>
666
+ {headerGroup.headers.map((header) => (
667
+ <table.AppHeader key={header.id} header={header}>
668
+ {(bound) => (
669
+ <TableHead
670
+ aria-sort={
671
+ header.column.getCanSort()
672
+ ? columnAriaSort(state.sorting, header.column.id)
673
+ : undefined
674
+ }
675
+ >
676
+ {header.isPlaceholder ? null : <bound.FlexRender />}
677
+ </TableHead>
678
+ )}
679
+ </table.AppHeader>
680
+ ))}
681
+ </TableRow>
682
+ ))}
683
+ </TableHeader>
684
+ <TableBody>
685
+ {loading && loadingFallback ? (
686
+ loadingFallback
687
+ ) : showEmpty ? (
688
+ <TableRow>
689
+ <TableCell
690
+ colSpan={table.getAllColumns().length}
691
+ className="h-24 text-center text-muted-foreground"
692
+ data-slot="data-table-empty"
693
+ >
694
+ {empty}
695
+ </TableCell>
696
+ </TableRow>
697
+ ) : (
698
+ rows.map((row) => (
699
+ <TableRow
700
+ key={row.id}
701
+ data-state={row.getIsSelected() ? "selected" : undefined}
702
+ >
703
+ {row.getAllCells().map((cell) => (
704
+ <table.AppCell key={cell.id} cell={cell}>
705
+ {(bound) => (
706
+ <TableCell>
707
+ <bound.FlexRender />
708
+ </TableCell>
709
+ )}
710
+ </table.AppCell>
711
+ ))}
712
+ </TableRow>
713
+ ))
714
+ )}
715
+ </TableBody>
716
+ </Table>
717
+ </div>
718
+ {footer}
719
+ </div>
720
+ )
721
+ }}
722
+ </table.AppTable>
723
+ )
724
+ }
725
+
726
+ type ToolbarBulk = {
727
+ count: number
728
+ actions?: React.ReactNode
729
+ label?: (count: number) => string
730
+ }
731
+
732
+ type ToolbarProps = {
733
+ search?: string
734
+ onSearchChange?: (value: string) => void
735
+ searchPlaceholder?: string
736
+ primaryAction?: React.ReactNode
737
+ children?: React.ReactNode
738
+ bulk?: ToolbarBulk
739
+ }
740
+
741
+ export function Toolbar({
742
+ search = "",
743
+ onSearchChange,
744
+ searchPlaceholder = "Search",
745
+ primaryAction,
746
+ children,
747
+ bulk,
748
+ }: ToolbarProps) {
749
+ if (bulk && bulk.count > 0) {
750
+ return (
751
+ <BulkBar count={bulk.count} actions={bulk.actions} label={bulk.label} />
752
+ )
753
+ }
754
+
755
+ return (
756
+ <div
757
+ data-slot="data-table-toolbar"
758
+ className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
759
+ >
760
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
761
+ {onSearchChange ? (
762
+ <InputGroup className="max-w-sm">
763
+ <InputGroupAddon>
764
+ <IconSearch className="size-4" />
765
+ </InputGroupAddon>
766
+ <InputGroupInput
767
+ value={search}
768
+ onChange={(event) => onSearchChange(event.target.value)}
769
+ placeholder={searchPlaceholder}
770
+ aria-label={searchPlaceholder}
771
+ />
772
+ </InputGroup>
773
+ ) : null}
774
+ {children}
775
+ </div>
776
+ {primaryAction ? (
777
+ <div className="flex shrink-0 items-center gap-2">{primaryAction}</div>
778
+ ) : null}
779
+ </div>
780
+ )
781
+ }
782
+
783
+ type BulkBarProps = {
784
+ count: number
785
+ actions?: React.ReactNode
786
+ label?: (count: number) => string
787
+ }
788
+
789
+ export function BulkBar({
790
+ count,
791
+ actions,
792
+ label = (n) => `${n} selected`,
793
+ }: BulkBarProps) {
794
+ if (count <= 0) return null
795
+
796
+ return (
797
+ <div
798
+ data-slot="data-table-bulk-bar"
799
+ className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
800
+ >
801
+ <p className="label">{label(count)}</p>
802
+ {actions ? (
803
+ <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>
804
+ ) : null}
805
+ </div>
806
+ )
807
+ }
808
+
809
+ const paginationLabels = {
810
+ empty: "No results",
811
+ range: (from: number, to: number, count: number) => `${from}–${to} / ${count}`,
812
+ first: "First",
813
+ previous: "Previous",
814
+ next: "Next",
815
+ page: (page: number, pages: number) => `${page} / ${pages}`,
816
+ }
817
+
818
+ type PaginationLabels = typeof paginationLabels
819
+
820
+ type PaginationProps<
821
+ TData extends RowData,
822
+ TSelected extends { pagination: PaginationState },
823
+ > = {
824
+ table: Table<TData, TSelected>
825
+ labels?: Partial<PaginationLabels>
826
+ }
827
+
828
+ export function Pagination<
829
+ TData extends RowData,
830
+ TSelected extends { pagination: PaginationState },
831
+ >({ table, labels }: PaginationProps<TData, TSelected>) {
832
+ const copy = { ...paginationLabels, ...labels }
833
+ const pagination = table.state.pagination ?? table.atoms.pagination.get()
834
+ const { pageIndex, pageSize } = pagination
835
+ const pageCount = table.getPageCount()
836
+ const rowCount = table.getRowCount()
837
+ const from = rowCount === 0 ? 0 : pageIndex * pageSize + 1
838
+ const to = Math.min(rowCount, (pageIndex + 1) * pageSize)
839
+
840
+ return (
841
+ <div
842
+ data-slot="data-table-pagination"
843
+ className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"
844
+ >
845
+ <p className="label text-muted-foreground">
846
+ {rowCount === 0 ? copy.empty : copy.range(from, to, rowCount)}
847
+ </p>
848
+ <div className="flex items-center gap-2">
849
+ <Button
850
+ type="button"
851
+ variant="outline"
852
+ size="sm"
853
+ onClick={() => table.firstPage()}
854
+ disabled={!table.getCanPreviousPage()}
855
+ >
856
+ {copy.first}
857
+ </Button>
858
+ <Button
859
+ type="button"
860
+ variant="outline"
861
+ size="sm"
862
+ onClick={() => table.previousPage()}
863
+ disabled={!table.getCanPreviousPage()}
864
+ >
865
+ {copy.previous}
866
+ </Button>
867
+ <span className="label text-muted-foreground tabular-nums">
868
+ {copy.page(pageCount === 0 ? 0 : pageIndex + 1, Math.max(pageCount, 1))}
869
+ </span>
870
+ <Button
871
+ type="button"
872
+ variant="outline"
873
+ size="sm"
874
+ onClick={() => table.nextPage()}
875
+ disabled={!table.getCanNextPage()}
876
+ >
877
+ {copy.next}
878
+ </Button>
879
+ </div>
880
+ </div>
881
+ )
882
+ }
883
+
884
+ export function Skeleton({
885
+ columns,
886
+ rows = 3,
887
+ }: {
888
+ columns: number
889
+ rows?: number
890
+ }) {
891
+ return (
892
+ <>
893
+ {Array.from({ length: rows }, (_, row) => (
894
+ <TableRow key={row} aria-hidden>
895
+ {Array.from({ length: columns }, (_, col) => (
896
+ <TableCell
897
+ key={col}
898
+ className={col === columns - 1 ? "text-right" : undefined}
899
+ >
900
+ <UiSkeleton
901
+ className={cn(
902
+ "h-4",
903
+ col === columns - 1 ? "ml-auto w-14" : "w-[70%] max-w-40",
904
+ )}
905
+ />
906
+ </TableCell>
907
+ ))}
908
+ </TableRow>
909
+ ))}
910
+ </>
911
+ )
912
+ }
913
+
914
+ export type {
915
+ ColumnFiltersState,
916
+ PaginationState,
917
+ RowSelectionState,
918
+ SortingState,
919
+ }