@moloodev/ui 0.0.0-stage → 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/package.json +139 -3
- package/registry/components.ts +135 -0
- package/registry/fonts.css +81 -0
- package/registry/suit-OFL.txt +94 -0
- package/registry/wanted-sans-OFL.txt +93 -0
- package/src/components/ui/accordion.tsx +74 -0
- package/src/components/ui/alert-dialog.tsx +187 -0
- package/src/components/ui/alert.tsx +83 -0
- package/src/components/ui/aspect-ratio.tsx +22 -0
- package/src/components/ui/attachment.tsx +207 -0
- package/src/components/ui/avatar.tsx +109 -0
- package/src/components/ui/badge.tsx +54 -0
- package/src/components/ui/breadcrumb.tsx +126 -0
- package/src/components/ui/bubble.tsx +128 -0
- package/src/components/ui/button-group.tsx +103 -0
- package/src/components/ui/button.tsx +68 -0
- package/src/components/ui/calendar.tsx +222 -0
- package/src/components/ui/card.tsx +103 -0
- package/src/components/ui/carousel.tsx +242 -0
- package/src/components/ui/chart.tsx +398 -0
- package/src/components/ui/checkbox.tsx +28 -0
- package/src/components/ui/collapsible.tsx +40 -0
- package/src/components/ui/combobox.tsx +299 -0
- package/src/components/ui/command.tsx +218 -0
- package/src/components/ui/context-menu.tsx +271 -0
- package/src/components/ui/data-table.tsx +919 -0
- package/src/components/ui/date-picker.tsx +65 -0
- package/src/components/ui/dialog.tsx +224 -0
- package/src/components/ui/drawer.tsx +231 -0
- package/src/components/ui/dropdown-menu.tsx +268 -0
- package/src/components/ui/dropzone.tsx +186 -0
- package/src/components/ui/empty.tsx +101 -0
- package/src/components/ui/field.tsx +238 -0
- package/src/components/ui/form.tsx +83 -0
- package/src/components/ui/highlighted-source.tsx +200 -0
- package/src/components/ui/hover-card.tsx +51 -0
- package/src/components/ui/image-field.tsx +796 -0
- package/src/components/ui/input-group.tsx +149 -0
- package/src/components/ui/input-otp.tsx +86 -0
- package/src/components/ui/input-step.ts +19 -0
- package/src/components/ui/input.tsx +100 -0
- package/src/components/ui/item.tsx +201 -0
- package/src/components/ui/kbd.tsx +91 -0
- package/src/components/ui/kind-icon-glob.ts +24 -0
- package/src/components/ui/kind-icon-load.ts +52 -0
- package/src/components/ui/kind-icon-static.ts +46 -0
- package/src/components/ui/kind-icon.tsx +84 -0
- package/src/components/ui/kind-mark.tsx +65 -0
- package/src/components/ui/label.tsx +29 -0
- package/src/components/ui/marker.tsx +71 -0
- package/src/components/ui/menubar.tsx +277 -0
- package/src/components/ui/message-scroller.tsx +137 -0
- package/src/components/ui/message.tsx +92 -0
- package/src/components/ui/moloo-logo.tsx +45 -0
- package/src/components/ui/native-select.tsx +61 -0
- package/src/components/ui/navigation-menu.tsx +168 -0
- package/src/components/ui/outline-list.tsx +73 -0
- package/src/components/ui/pagination.tsx +130 -0
- package/src/components/ui/panel.tsx +108 -0
- package/src/components/ui/popover.tsx +90 -0
- package/src/components/ui/progress.tsx +117 -0
- package/src/components/ui/radio-group.tsx +38 -0
- package/src/components/ui/resizable.tsx +52 -0
- package/src/components/ui/scroll-area.tsx +54 -0
- package/src/components/ui/select.tsx +205 -0
- package/src/components/ui/separator.tsx +25 -0
- package/src/components/ui/sheet.tsx +140 -0
- package/src/components/ui/sidebar.tsx +746 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/slider.tsx +52 -0
- package/src/components/ui/spinner.tsx +18 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/table.tsx +116 -0
- package/src/components/ui/tabs.tsx +83 -0
- package/src/components/ui/tag-list-input.tsx +194 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/timeline.tsx +265 -0
- package/src/components/ui/toast.tsx +237 -0
- package/src/components/ui/toggle-group.tsx +90 -0
- package/src/components/ui/toggle.tsx +45 -0
- package/src/components/ui/tooltip.tsx +66 -0
- package/src/css.d.ts +1 -0
- package/src/hooks/use-clipboard.ts +82 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/index.ts +106 -0
- package/src/kind-icon.ts +4 -0
- package/src/lib/format-relative-time.ts +41 -0
- package/src/lib/list-selection.ts +61 -0
- package/src/lib/utils.ts +6 -0
- package/src/moloo.css +360 -0
- package/src/styles.css +36 -0
- package/src/typeset.css +518 -0
- 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
|
+
}
|