@datum-cloud/datum-ui 1.0.0 → 1.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.
Files changed (28) hide show
  1. package/dist/components/features/data-table/core/filter-engine.d.ts +5 -2
  2. package/dist/components/features/data-table/core/filter-engine.d.ts.map +1 -1
  3. package/dist/components/features/data-table/index.d.ts +4 -0
  4. package/dist/components/features/data-table/index.d.ts.map +1 -1
  5. package/dist/components/features/grouped-table/components/grouped-skeleton.d.ts +7 -0
  6. package/dist/components/features/grouped-table/components/grouped-skeleton.d.ts.map +1 -0
  7. package/dist/components/features/grouped-table/components/grouped-toolbar.d.ts +9 -0
  8. package/dist/components/features/grouped-table/components/grouped-toolbar.d.ts.map +1 -0
  9. package/dist/components/features/grouped-table/grouped-table.d.ts +3 -0
  10. package/dist/components/features/grouped-table/grouped-table.d.ts.map +1 -0
  11. package/dist/components/features/grouped-table/index.d.ts +3 -0
  12. package/dist/components/features/grouped-table/index.d.ts.map +1 -0
  13. package/dist/components/features/grouped-table/lib/bucket-rows.d.ts +14 -0
  14. package/dist/components/features/grouped-table/lib/bucket-rows.d.ts.map +1 -0
  15. package/dist/components/features/grouped-table/lib/compose-columns.d.ts +11 -0
  16. package/dist/components/features/grouped-table/lib/compose-columns.d.ts.map +1 -0
  17. package/dist/components/features/grouped-table/lib/sort-rows.d.ts +7 -0
  18. package/dist/components/features/grouped-table/lib/sort-rows.d.ts.map +1 -0
  19. package/dist/components/features/grouped-table/lib/use-controllable-state.d.ts +8 -0
  20. package/dist/components/features/grouped-table/lib/use-controllable-state.d.ts.map +1 -0
  21. package/dist/components/features/grouped-table/types.d.ts +57 -0
  22. package/dist/components/features/grouped-table/types.d.ts.map +1 -0
  23. package/dist/components/features/grouped-table/use-grouped-expansion.d.ts +10 -0
  24. package/dist/components/features/grouped-table/use-grouped-expansion.d.ts.map +1 -0
  25. package/dist/data-table/index.mjs +2 -1588
  26. package/dist/data-table-BTIxzB7O.mjs +1588 -0
  27. package/dist/grouped-table/index.mjs +339 -0
  28. package/package.json +8 -3
@@ -0,0 +1,339 @@
1
+ import { t as cn } from "../cn-dlASUkDY.mjs";
2
+ import { t as Icon } from "../icon-wrapper-DKfJlJd0.mjs";
3
+ import { n as CollapsibleContent, r as CollapsibleTrigger, t as Collapsible } from "../collapsible-BeCdkxTJ.mjs";
4
+ import { t as Input } from "../input-DBzgl-pN.mjs";
5
+ import { t as Skeleton } from "../skeleton-CGU89HPB.mjs";
6
+ import { c as TableRow, i as TableCell, n as TableBody, o as TableHead, s as TableHeader } from "../table-BR3mwU8X.mjs";
7
+ import { f as rowMatchesSearch, h as createSelectionColumn, m as DataTableColumnHeader, p as DataTableRowActions } from "../data-table-BTIxzB7O.mjs";
8
+ import { ChevronRight } from "lucide-react";
9
+ import { useCallback, useEffect, useMemo, useState } from "react";
10
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
11
+ import { flexRender, getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table";
12
+ //#region src/components/features/grouped-table/components/grouped-skeleton.tsx
13
+ function GroupedSkeleton({ columns = 4, groups = 2, rowsPerGroup = 3 }) {
14
+ return /* @__PURE__ */ jsx("div", {
15
+ "data-slot": "gt-skeleton",
16
+ children: Array.from({ length: groups }, (_, g) => /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("div", {
17
+ "data-slot": "gt-skeleton-band",
18
+ className: "flex items-center gap-2 bg-muted/40 px-3 py-2 [&:not(:first-child)]:border-t",
19
+ children: [/* @__PURE__ */ jsx(Skeleton, { className: "size-4 rounded" }), /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-32" })]
20
+ }), Array.from({ length: rowsPerGroup }, (_, r) => /* @__PURE__ */ jsx("div", {
21
+ "data-slot": "gt-skeleton-row",
22
+ className: "flex items-center gap-3 px-3 py-2 [&:not(:last-child)]:border-b",
23
+ children: Array.from({ length: columns }, (_, c) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, c))
24
+ }, r))] }, g))
25
+ });
26
+ }
27
+ //#endregion
28
+ //#region src/components/features/grouped-table/components/grouped-toolbar.tsx
29
+ const DEFAULT_DEBOUNCE_MS = 300;
30
+ function GroupedToolbar({ search, onSearchChange, placeholder = "Search...", debounceMs = DEFAULT_DEBOUNCE_MS, className }) {
31
+ const [value, setValue] = useState(search);
32
+ useEffect(() => {
33
+ setValue(search);
34
+ }, [search]);
35
+ useEffect(() => {
36
+ const timer = setTimeout(() => {
37
+ if (value !== search) onSearchChange(value);
38
+ }, debounceMs);
39
+ return () => clearTimeout(timer);
40
+ }, [
41
+ value,
42
+ debounceMs,
43
+ search,
44
+ onSearchChange
45
+ ]);
46
+ return /* @__PURE__ */ jsx("div", {
47
+ className: "pb-3",
48
+ "data-slot": "gt-toolbar",
49
+ children: /* @__PURE__ */ jsx(Input, {
50
+ placeholder,
51
+ value,
52
+ onChange: (e) => setValue(e.target.value),
53
+ "aria-label": placeholder,
54
+ className,
55
+ "data-slot": "gt-search"
56
+ })
57
+ });
58
+ }
59
+ //#endregion
60
+ //#region src/components/features/grouped-table/lib/bucket-rows.ts
61
+ /**
62
+ * Build per-group buckets of `filteredRows`, preserving the filtered order.
63
+ * `coreRows` are the unfiltered rows in group-concatenated order; we use their
64
+ * positions to learn which row id belongs to which group, so bucketing is correct
65
+ * regardless of a custom getRowId.
66
+ */
67
+ function bucketRows(groups, coreRows, filteredRows) {
68
+ const idToGroup = /* @__PURE__ */ new Map();
69
+ let offset = 0;
70
+ for (const group of groups) {
71
+ for (let i = 0; i < group.rows.length; i++) {
72
+ const core = coreRows[offset + i];
73
+ if (core) idToGroup.set(core.id, group.id);
74
+ }
75
+ offset += group.rows.length;
76
+ }
77
+ const buckets = new Map(groups.map((g) => [g.id, []]));
78
+ for (const row of filteredRows) {
79
+ const groupId = idToGroup.get(row.id);
80
+ if (groupId) buckets.get(groupId).push(row);
81
+ }
82
+ return buckets;
83
+ }
84
+ //#endregion
85
+ //#region src/components/features/grouped-table/lib/compose-columns.tsx
86
+ /** Wrap plain string headers in the sortable header; leave all other columns untouched. */
87
+ function withSortableHeaders(columns) {
88
+ return columns.map((col) => {
89
+ if (typeof col.header !== "string") return col;
90
+ const title = col.header;
91
+ return {
92
+ ...col,
93
+ enableSorting: col.enableSorting ?? true,
94
+ header: ({ column }) => /* @__PURE__ */ jsx(DataTableColumnHeader, {
95
+ column,
96
+ title
97
+ })
98
+ };
99
+ });
100
+ }
101
+ function composeColumns(columns, options) {
102
+ const { enableRowSelection, enableSorting, rowActions, rowActionsSheetTitle } = options;
103
+ let cols = columns;
104
+ if (enableSorting) cols = withSortableHeaders(cols);
105
+ if (enableRowSelection) cols = [createSelectionColumn(typeof enableRowSelection === "object" ? enableRowSelection : {}), ...cols];
106
+ if (rowActions) cols = [...cols, {
107
+ id: "actions",
108
+ size: 44,
109
+ enableSorting: false,
110
+ cell: ({ row }) => /* @__PURE__ */ jsx(DataTableRowActions, {
111
+ row,
112
+ actions: rowActions(row.original),
113
+ sheetTitle: rowActionsSheetTitle
114
+ })
115
+ }];
116
+ return cols;
117
+ }
118
+ //#endregion
119
+ //#region src/components/features/grouped-table/lib/sort-rows.ts
120
+ function compare(a, b) {
121
+ if (a == null && b == null) return 0;
122
+ if (a == null) return -1;
123
+ if (b == null) return 1;
124
+ if (typeof a === "number" && typeof b === "number") return a - b;
125
+ return String(a).localeCompare(String(b));
126
+ }
127
+ /**
128
+ * Sort a single group's rows by the active sort (first entry; v1 is single-sort).
129
+ * Returns the same reference when there is no sort, so React can skip re-renders.
130
+ */
131
+ function sortRows(rows, sorting) {
132
+ if (sorting.length === 0) return rows;
133
+ const { id, desc } = sorting[0];
134
+ const sorted = [...rows].sort((ra, rb) => compare(ra.getValue(id), rb.getValue(id)));
135
+ if (desc) sorted.reverse();
136
+ return sorted;
137
+ }
138
+ //#endregion
139
+ //#region src/components/features/grouped-table/lib/use-controllable-state.ts
140
+ /**
141
+ * Uncontrolled-by-default state with an optional controlled override.
142
+ * When `controlled` is undefined the hook owns the value; otherwise the prop wins.
143
+ * `onChange` always fires. Accepts TanStack-style updater functions.
144
+ */
145
+ function useControllableState(controlled, defaultValue, onChange) {
146
+ const [internal, setInternal] = useState(defaultValue);
147
+ const isControlled = controlled !== void 0;
148
+ const value = isControlled ? controlled : internal;
149
+ return [value, useCallback((next) => {
150
+ const resolved = typeof next === "function" ? next(value) : next;
151
+ if (!isControlled) setInternal(resolved);
152
+ onChange?.(resolved);
153
+ }, [
154
+ isControlled,
155
+ onChange,
156
+ value
157
+ ])];
158
+ }
159
+ //#endregion
160
+ //#region src/components/features/grouped-table/use-grouped-expansion.ts
161
+ function defaultFor(group, def) {
162
+ const fallback = def === "all" ? true : def === "none" ? false : Array.isArray(def) ? def.includes(group.id) : true;
163
+ return group.defaultOpen ?? fallback;
164
+ }
165
+ function useGroupedExpansion(groups, opts) {
166
+ const { defaultExpanded = "all", expanded, onExpandedChange } = opts;
167
+ const controlled = expanded !== void 0;
168
+ const [overrides, setOverrides] = useState({});
169
+ const isOpen = useCallback((id) => {
170
+ if (controlled) return expanded.includes(id);
171
+ if (id in overrides) return overrides[id];
172
+ const g = groups.find((x) => x.id === id);
173
+ return g ? defaultFor(g, defaultExpanded) : false;
174
+ }, [
175
+ controlled,
176
+ expanded,
177
+ overrides,
178
+ groups,
179
+ defaultExpanded
180
+ ]);
181
+ return {
182
+ isOpen,
183
+ toggle: useCallback((id) => {
184
+ const now = isOpen(id);
185
+ if (!controlled) setOverrides((o) => ({
186
+ ...o,
187
+ [id]: !now
188
+ }));
189
+ const next = groups.filter((g) => g.id === id ? !now : isOpen(g.id)).map((g) => g.id);
190
+ onExpandedChange?.(next);
191
+ }, [
192
+ controlled,
193
+ groups,
194
+ isOpen,
195
+ onExpandedChange
196
+ ])
197
+ };
198
+ }
199
+ //#endregion
200
+ //#region src/components/features/grouped-table/grouped-table.tsx
201
+ /** Floor width for unsized (flex) columns so they keep their share instead of collapsing on narrow viewports. */
202
+ const MIN_FLEX_COLUMN_WIDTH = 120;
203
+ function columnWidth(col) {
204
+ return typeof col.size === "number" ? `${col.size}px` : "auto";
205
+ }
206
+ /** Minimum width the table track needs so every column keeps a usable size; below this the area scrolls horizontally. */
207
+ function trackMinWidth(resolvedColumns) {
208
+ return resolvedColumns.reduce((total, col) => total + (typeof col.size === "number" ? col.size : MIN_FLEX_COLUMN_WIDTH), 0);
209
+ }
210
+ function renderColGroup(resolvedColumns) {
211
+ return /* @__PURE__ */ jsx("colgroup", { children: resolvedColumns.map((col, i) => /* @__PURE__ */ jsx("col", { style: { width: columnWidth(col) } }, `col-${i}`)) });
212
+ }
213
+ function GroupedTable(props) {
214
+ const { columns, groups, defaultExpanded, expanded, onExpandedChange, getRowId, enableRowSelection, rowSelection: rowSelectionProp, onRowSelectionChange, rowActions, rowActionsSheetTitle, enableSorting, sorting: sortingProp, onSortingChange, enableSearch, searchPlaceholder, searchableColumns, searchFn, search: searchProp, onSearchChange, searchDebounceMs, isLoading, empty, className } = props;
215
+ const [sorting, setSorting] = useControllableState(sortingProp, [], onSortingChange);
216
+ const [rowSelection, setRowSelection] = useControllableState(rowSelectionProp, {}, onRowSelectionChange);
217
+ const [search, setSearch] = useControllableState(searchProp, "", onSearchChange);
218
+ const isSearching = search.trim().length > 0;
219
+ const { isOpen, toggle } = useGroupedExpansion(groups, {
220
+ defaultExpanded,
221
+ expanded,
222
+ onExpandedChange
223
+ });
224
+ const resolvedColumns = useMemo(() => composeColumns(columns, {
225
+ enableRowSelection,
226
+ enableSorting,
227
+ rowActions,
228
+ rowActionsSheetTitle
229
+ }), [
230
+ columns,
231
+ enableRowSelection,
232
+ enableSorting,
233
+ rowActions,
234
+ rowActionsSheetTitle
235
+ ]);
236
+ const minWidth = useMemo(() => trackMinWidth(resolvedColumns), [resolvedColumns]);
237
+ const flatData = useMemo(() => groups.flatMap((g) => g.rows), [groups]);
238
+ const table = useReactTable({
239
+ data: flatData,
240
+ columns: resolvedColumns,
241
+ state: {
242
+ sorting,
243
+ rowSelection,
244
+ globalFilter: search
245
+ },
246
+ onSortingChange: setSorting,
247
+ onRowSelectionChange: setRowSelection,
248
+ onGlobalFilterChange: setSearch,
249
+ enableRowSelection: Boolean(enableRowSelection),
250
+ manualSorting: true,
251
+ enableMultiSort: false,
252
+ globalFilterFn: (row, _columnId, value) => rowMatchesSearch(row.original, String(value ?? ""), {
253
+ searchFn,
254
+ searchableColumns
255
+ }),
256
+ getRowId,
257
+ getCoreRowModel: getCoreRowModel(),
258
+ getFilteredRowModel: getFilteredRowModel()
259
+ });
260
+ const headerGroups = table.getHeaderGroups();
261
+ const coreRows = table.getCoreRowModel().rows;
262
+ const filteredRows = table.getRowModel().rows;
263
+ const buckets = useMemo(() => bucketRows(groups, coreRows, filteredRows), [
264
+ groups,
265
+ coreRows,
266
+ filteredRows
267
+ ]);
268
+ const slices = useMemo(() => groups.map((g) => ({
269
+ id: g.id,
270
+ title: g.title,
271
+ meta: g.meta,
272
+ rows: sortRows(buckets.get(g.id) ?? [], sorting)
273
+ })), [
274
+ groups,
275
+ buckets,
276
+ sorting
277
+ ]);
278
+ const visibleSlices = isSearching ? slices.filter((s) => s.rows.length > 0) : slices;
279
+ const renderShell = (body, scrollable) => /* @__PURE__ */ jsxs("div", {
280
+ className: cn("w-full", className),
281
+ children: [enableSearch && /* @__PURE__ */ jsx(GroupedToolbar, {
282
+ search,
283
+ onSearchChange: setSearch,
284
+ placeholder: searchPlaceholder,
285
+ debounceMs: searchDebounceMs
286
+ }), /* @__PURE__ */ jsx("div", {
287
+ className: cn("w-full rounded-md border", scrollable ? "overflow-x-auto" : "overflow-hidden"),
288
+ children: scrollable ? /* @__PURE__ */ jsx("div", {
289
+ style: { minWidth },
290
+ children: body
291
+ }) : body
292
+ })]
293
+ });
294
+ if (isLoading) return renderShell(/* @__PURE__ */ jsx(GroupedSkeleton, { columns: resolvedColumns.length }), false);
295
+ if (flatData.length === 0 || isSearching && visibleSlices.length === 0) return renderShell(empty ?? null, false);
296
+ return renderShell(/* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("table", {
297
+ className: "w-full table-fixed text-sm",
298
+ children: [renderColGroup(resolvedColumns), /* @__PURE__ */ jsx(TableHeader, { children: headerGroups.map((hg) => /* @__PURE__ */ jsx(TableRow, { children: hg.headers.map((header) => /* @__PURE__ */ jsx(TableHead, {
299
+ scope: "col",
300
+ children: header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())
301
+ }, header.id)) }, hg.id)) })]
302
+ }), visibleSlices.map((slice, i) => {
303
+ const open = isSearching ? true : isOpen(slice.id);
304
+ return /* @__PURE__ */ jsxs(Collapsible, {
305
+ open,
306
+ onOpenChange: () => toggle(slice.id),
307
+ children: [/* @__PURE__ */ jsxs(CollapsibleTrigger, {
308
+ className: cn("flex w-full items-center gap-2 bg-muted/40 px-3 py-2 text-left text-sm font-semibold hover:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", i > 0 && "border-t"),
309
+ children: [
310
+ /* @__PURE__ */ jsx(Icon, {
311
+ icon: ChevronRight,
312
+ "aria-hidden": true,
313
+ className: cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")
314
+ }),
315
+ /* @__PURE__ */ jsx("span", { children: slice.title }),
316
+ slice.meta != null && /* @__PURE__ */ jsx("span", {
317
+ className: "ml-auto flex items-center gap-2 font-medium text-muted-foreground",
318
+ children: slice.meta
319
+ })
320
+ ]
321
+ }), /* @__PURE__ */ jsx(CollapsibleContent, {
322
+ className: "overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
323
+ children: /* @__PURE__ */ jsxs("table", {
324
+ className: "w-full table-fixed border-t text-sm",
325
+ "aria-label": typeof slice.title === "string" ? slice.title : void 0,
326
+ children: [renderColGroup(resolvedColumns), /* @__PURE__ */ jsx(TableBody, { children: slice.rows.map((row) => /* @__PURE__ */ jsx(TableRow, {
327
+ "data-state": row.getIsSelected() ? "selected" : void 0,
328
+ children: row.getVisibleCells().map((cell) => /* @__PURE__ */ jsx(TableCell, {
329
+ className: "truncate",
330
+ children: flexRender(cell.column.columnDef.cell, cell.getContext())
331
+ }, cell.id))
332
+ }, row.id)) })]
333
+ })
334
+ })]
335
+ }, slice.id);
336
+ })] }), true);
337
+ }
338
+ //#endregion
339
+ export { GroupedTable };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@datum-cloud/datum-ui",
3
3
  "type": "module",
4
- "version": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "url": "https://github.com/datum-cloud/datum-ui"
@@ -193,6 +193,11 @@
193
193
  "types": "./dist/components/base/table/index.d.ts",
194
194
  "default": "./dist/table/index.mjs"
195
195
  },
196
+ "./grouped-table": {
197
+ "source": "./src/components/features/grouped-table/index.ts",
198
+ "types": "./dist/components/features/grouped-table/index.d.ts",
199
+ "default": "./dist/grouped-table/index.mjs"
200
+ },
196
201
  "./tabs": {
197
202
  "source": "./src/components/base/tabs/index.ts",
198
203
  "types": "./dist/components/base/tabs/index.d.ts",
@@ -625,8 +630,8 @@
625
630
  "typescript": "^6.0.3",
626
631
  "vitest": "^4.1.8",
627
632
  "zod": "^4.4.3",
628
- "@repo/config": "0.0.0",
629
- "@repo/shadcn": "0.0.0"
633
+ "@repo/shadcn": "0.0.0",
634
+ "@repo/config": "0.0.0"
630
635
  },
631
636
  "publishConfig": {
632
637
  "access": "public"