@suphark/ui 0.1.0 → 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 (30) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +5 -1
  3. package/package.json +16 -2
  4. package/src/components/data-table/data-table-column-header.tsx +71 -0
  5. package/src/components/data-table/data-table-date-range-filter.tsx +25 -0
  6. package/src/components/data-table/data-table-faceted-filter.tsx +181 -0
  7. package/src/components/data-table/data-table-pagination.tsx +114 -0
  8. package/src/components/data-table/data-table-row-actions.tsx +166 -0
  9. package/src/components/data-table/data-table-toolbar.tsx +207 -0
  10. package/src/components/data-table/data-table-view-option.tsx +59 -0
  11. package/src/components/data-table/data-table.tsx +399 -0
  12. package/src/components/data-table/index.ts +9 -0
  13. package/src/components/data-table/types.ts +106 -0
  14. package/src/components/data-table/use-data-table.ts +229 -0
  15. package/src/components/design-system/sections/showcase-data-display.tsx +71 -0
  16. package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +8 -8
  17. package/src/components/shared/button/delete-many-button.tsx +85 -0
  18. package/src/components/shared/resource-property-filter.tsx +399 -0
  19. package/src/components/ui/native-select.tsx +54 -0
  20. package/src/data-table.ts +21 -0
  21. package/src/hooks/use-table-search-params.ts +147 -0
  22. package/src/index.ts +1 -0
  23. package/src/next.ts +5 -0
  24. package/src/components/shared/__tests__/multi-select-combobox.test.tsx +0 -180
  25. package/src/components/shared/__tests__/name-id-fields.test.ts +0 -24
  26. package/src/components/shared/form/__tests__/simple-select.test.tsx +0 -72
  27. package/src/components/ui/__tests__/combobox.test.tsx +0 -40
  28. package/src/components/ui/__tests__/date-picker.test.tsx +0 -53
  29. package/src/components/ui/__tests__/slider.test.tsx +0 -18
  30. package/src/components/ui/__tests__/tabs.test.tsx +0 -33
@@ -0,0 +1,399 @@
1
+ "use client";
2
+
3
+ import {
4
+ closestCenter,
5
+ DndContext,
6
+ type DragEndEvent,
7
+ KeyboardSensor,
8
+ PointerSensor,
9
+ useSensor,
10
+ useSensors,
11
+ } from "@dnd-kit/core";
12
+ import {
13
+ arrayMove,
14
+ SortableContext,
15
+ sortableKeyboardCoordinates,
16
+ useSortable,
17
+ verticalListSortingStrategy,
18
+ } from "@dnd-kit/sortable";
19
+ import { CSS } from "@dnd-kit/utilities";
20
+ import {
21
+ TableBody,
22
+ TableCell,
23
+ TableHead,
24
+ TableHeader,
25
+ TableRow,
26
+ Table as UiTable,
27
+ } from "@suphark/ui/components/ui/table";
28
+ import { cn } from "@suphark/ui/lib/utils";
29
+ import { flexRender, type Row } from "@tanstack/react-table";
30
+ import type { CSSProperties } from "react";
31
+ import * as React from "react";
32
+ import { toast } from "sonner";
33
+ import { DataTablePagination } from "./data-table-pagination";
34
+ import { DataTableToolbar } from "./data-table-toolbar";
35
+ import type { DataTableProps } from "./types";
36
+ import { useDataTable } from "./use-data-table";
37
+
38
+ interface DraggableRowProps<TData> {
39
+ row: Row<TData>;
40
+ enableColumnResizing?: boolean;
41
+ getRowClassName?: (row: Row<TData>) => string;
42
+ stickyEdgeColumns?: boolean;
43
+ }
44
+
45
+ function DraggableRow<TData>({
46
+ row,
47
+ enableColumnResizing,
48
+ getRowClassName,
49
+ stickyEdgeColumns,
50
+ }: DraggableRowProps<TData>) {
51
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
52
+ id: row.id,
53
+ });
54
+
55
+ const style: CSSProperties = {
56
+ transform: CSS.Transform.toString(transform),
57
+ transition,
58
+ opacity: isDragging ? 0.8 : 1,
59
+ zIndex: isDragging ? 1 : 0,
60
+ position: "relative",
61
+ };
62
+
63
+ return (
64
+ <TableRow
65
+ ref={setNodeRef}
66
+ style={style}
67
+ data-state={row.getIsSelected() && "selected"}
68
+ className={cn(stickyEdgeColumns && "group/row", getRowClassName?.(row))}
69
+ >
70
+ {row.getVisibleCells().map((cell) => {
71
+ const isDndColumn = cell.column.id === "dnd-handle";
72
+ return (
73
+ <TableCell
74
+ key={cell.id}
75
+ {...(isDndColumn ? { ...attributes, ...listeners } : {})}
76
+ style={{
77
+ width: enableColumnResizing ? cell.column.getSize() : undefined,
78
+ }}
79
+ className={cn(
80
+ stickyEdgeColumns &&
81
+ cell.column.id === "select" &&
82
+ "sticky left-0 z-10 bg-card group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted",
83
+ stickyEdgeColumns &&
84
+ cell.column.id === "actions" &&
85
+ "sticky right-0 z-10 bg-card group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted",
86
+ )}
87
+ >
88
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
89
+ </TableCell>
90
+ );
91
+ })}
92
+ </TableRow>
93
+ );
94
+ }
95
+
96
+ /**
97
+ * DataTable
98
+ * @initialState {{ expanded: true }} -> (ขยายทั้งหมด)
99
+ * @initialState {{ expanded: {} }} -> (หุบทั้งหมด)
100
+ */
101
+ export function DataTable<TData, TValue>({
102
+ columns,
103
+ data,
104
+ className,
105
+ containerClassName,
106
+ tableContainerClassName,
107
+ stickyHeader,
108
+ stickyPagination,
109
+ stickyEdgeColumns,
110
+ emptyStateComponent,
111
+ facetedFilters,
112
+ actionsComponent,
113
+ globalFilterPlaceholder,
114
+ initialVisibility = {},
115
+ globalFilterFn,
116
+ globalFilterKeys,
117
+ getRowClassName,
118
+ getFacetedUniqueValues: getFacetedUniqueValuesProp,
119
+ initialColumnFilters = [],
120
+ columnFilters,
121
+ getSubRows,
122
+ getRowId,
123
+ initialPageSize = 10,
124
+ meta,
125
+ initialState,
126
+ pageCount,
127
+ rowCount,
128
+ pagination,
129
+ sorting,
130
+ globalFilter,
131
+ onGlobalFilterChange,
132
+ manualPagination,
133
+ manualSorting,
134
+ manualFiltering,
135
+ onPaginationChange,
136
+ onPageSizeChange,
137
+ onSortingChange,
138
+ onColumnFiltersChange,
139
+ enableColumnResizing = false,
140
+ enableRowSelection = true,
141
+ isLoading,
142
+ pageSizeOptions,
143
+ toolbarFilters,
144
+ preservedFilterIds,
145
+ onReorder,
146
+ renderCustomView,
147
+ }: DataTableProps<TData, TValue>) {
148
+ const { table } = useDataTable({
149
+ columns,
150
+ data,
151
+ className,
152
+ containerClassName,
153
+ stickyHeader,
154
+ emptyStateComponent,
155
+ initialVisibility,
156
+ globalFilterFn,
157
+ globalFilterKeys,
158
+ getFacetedUniqueValues: getFacetedUniqueValuesProp,
159
+ initialColumnFilters,
160
+ columnFilters,
161
+ getSubRows,
162
+ getRowId,
163
+ initialPageSize,
164
+ meta,
165
+ initialState,
166
+ pageCount,
167
+ rowCount,
168
+ pagination,
169
+ sorting,
170
+ globalFilter,
171
+ onGlobalFilterChange,
172
+ manualPagination,
173
+ manualSorting,
174
+ manualFiltering,
175
+ onPaginationChange,
176
+ onSortingChange,
177
+ onColumnFiltersChange,
178
+ enableColumnResizing,
179
+ enableRowSelection,
180
+ });
181
+
182
+ const sensors = useSensors(
183
+ useSensor(PointerSensor, {
184
+ activationConstraint: {
185
+ distance: 5,
186
+ },
187
+ }),
188
+ useSensor(KeyboardSensor, {
189
+ coordinateGetter: sortableKeyboardCoordinates,
190
+ }),
191
+ );
192
+
193
+ function handleDragEnd(event: DragEndEvent) {
194
+ if (!onReorder) return;
195
+
196
+ const state = table.getState();
197
+ const isFiltered = state.columnFilters.length > 0 || state.globalFilter;
198
+ const isSorted = state.sorting.length > 0;
199
+
200
+ if (isFiltered || isSorted) {
201
+ toast.warning("Cannot reorder while filtering or sorting is active.");
202
+ return;
203
+ }
204
+
205
+ const { active, over } = event;
206
+ if (over && active.id !== over.id) {
207
+ const oldIndex = data.findIndex((item) => {
208
+ const id = getRowId ? getRowId(item) : (item as { id?: string | number }).id;
209
+ return id === active.id;
210
+ });
211
+ const newIndex = data.findIndex((item) => {
212
+ const id = getRowId ? getRowId(item) : (item as { id?: string | number }).id;
213
+ return id === over.id;
214
+ });
215
+
216
+ if (oldIndex !== -1 && newIndex !== -1) {
217
+ const newData = arrayMove(data, oldIndex, newIndex);
218
+ onReorder(newData);
219
+ }
220
+ }
221
+ }
222
+
223
+ const tableRows = table.getRowModel().rows;
224
+ const rowIds = React.useMemo(() => tableRows.map((row) => row.id), [tableRows]);
225
+
226
+ const tableContent = (
227
+ <UiTable
228
+ className={cn(
229
+ "w-full border-collapse",
230
+ enableColumnResizing ? "table-fixed" : "table-auto",
231
+ className,
232
+ )}
233
+ // กล่องชั้นในสุดคือ scroll container ที่ sticky header ยึด จึงต้องกำหนดความสูง
234
+ // ที่กล่องนี้ ไม่ใช่กล่องชั้นนอก
235
+ containerClassName={tableContainerClassName}
236
+ style={enableColumnResizing ? { width: table.getTotalSize() } : {}}
237
+ >
238
+ <TableHeader
239
+ className={stickyHeader ? "sticky top-0 z-10 bg-background shadow-sm" : undefined}
240
+ >
241
+ {table.getHeaderGroups().map((headerGroup) => (
242
+ <TableRow key={headerGroup.id}>
243
+ {headerGroup.headers.map((header) => {
244
+ return (
245
+ <TableHead
246
+ key={header.id}
247
+ colSpan={header.colSpan}
248
+ style={{
249
+ width: enableColumnResizing ? header.getSize() : undefined,
250
+ }}
251
+ className={cn(
252
+ "group relative whitespace-nowrap",
253
+ stickyEdgeColumns &&
254
+ header.column.id === "select" &&
255
+ "sticky left-0 z-20 bg-background",
256
+ stickyEdgeColumns &&
257
+ header.column.id === "actions" &&
258
+ "sticky right-0 z-20 bg-background",
259
+ )}
260
+ >
261
+ {header.isPlaceholder
262
+ ? null
263
+ : flexRender(header.column.columnDef.header, header.getContext())}
264
+ {header.column.getCanResize() && (
265
+ <div
266
+ onMouseDown={header.getResizeHandler()}
267
+ onTouchStart={header.getResizeHandler()}
268
+ className={`absolute top-0 right-0 h-full w-1 cursor-col-resize touch-none select-none bg-border opacity-0 hover:bg-primary group-hover:opacity-100 ${
269
+ header.column.getIsResizing() ? "bg-primary opacity-100" : ""
270
+ }`}
271
+ />
272
+ )}
273
+ </TableHead>
274
+ );
275
+ })}
276
+ </TableRow>
277
+ ))}
278
+ </TableHeader>
279
+ <TableBody>
280
+ {isLoading ? (
281
+ Array.from({ length: initialPageSize }).map((_, i) => (
282
+ // biome-ignore lint/suspicious/noArrayIndexKey: แถว skeleton ไม่มีตัวตน ใช้ตำแหน่งเป็น key ได้
283
+ <TableRow key={i}>
284
+ {columns.map((_col, j) => (
285
+ // biome-ignore lint/suspicious/noArrayIndexKey: เซลล์ skeleton เช่นกัน
286
+ <TableCell key={j}>
287
+ <div className="h-4 w-full animate-pulse rounded bg-muted" />
288
+ </TableCell>
289
+ ))}
290
+ </TableRow>
291
+ ))
292
+ ) : table.getRowModel().rows?.length ? (
293
+ onReorder ? (
294
+ <SortableContext items={rowIds} strategy={verticalListSortingStrategy}>
295
+ {table.getRowModel().rows.map((row) => (
296
+ <DraggableRow
297
+ key={row.id}
298
+ row={row}
299
+ enableColumnResizing={enableColumnResizing}
300
+ getRowClassName={getRowClassName}
301
+ stickyEdgeColumns={stickyEdgeColumns}
302
+ />
303
+ ))}
304
+ </SortableContext>
305
+ ) : (
306
+ table.getRowModel().rows.map((row) => (
307
+ <TableRow
308
+ key={row.id}
309
+ data-state={row.getIsSelected() && "selected"}
310
+ className={cn(stickyEdgeColumns && "group/row", getRowClassName?.(row))}
311
+ >
312
+ {row.getVisibleCells().map((cell) => {
313
+ return (
314
+ <TableCell
315
+ key={cell.id}
316
+ style={{
317
+ width: enableColumnResizing ? cell.column.getSize() : undefined,
318
+ }}
319
+ className={cn(
320
+ stickyEdgeColumns &&
321
+ cell.column.id === "select" &&
322
+ "sticky left-0 z-10 bg-card group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted",
323
+ stickyEdgeColumns &&
324
+ cell.column.id === "actions" &&
325
+ "sticky right-0 z-10 bg-card group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted",
326
+ )}
327
+ >
328
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
329
+ </TableCell>
330
+ );
331
+ })}
332
+ </TableRow>
333
+ ))
334
+ )
335
+ ) : (
336
+ <TableRow>
337
+ <TableCell colSpan={table.getVisibleLeafColumns().length} className="h-24 text-center">
338
+ {emptyStateComponent || "No results."}
339
+ </TableCell>
340
+ </TableRow>
341
+ )}
342
+ </TableBody>
343
+ </UiTable>
344
+ );
345
+
346
+ const customView = renderCustomView?.(table);
347
+
348
+ return (
349
+ <div className={cn("flex flex-col gap-4", containerClassName)}>
350
+ <DataTableToolbar
351
+ table={table}
352
+ facetedFilters={facetedFilters}
353
+ actionsComponent={actionsComponent?.(table)}
354
+ globalFilterPlaceholder={globalFilterPlaceholder}
355
+ toolbarFilters={toolbarFilters?.(table)}
356
+ preservedFilterIds={preservedFilterIds}
357
+ />
358
+ {customView ? (
359
+ <>
360
+ {customView}
361
+ <DataTablePagination
362
+ table={table}
363
+ pageSizeOptions={pageSizeOptions}
364
+ onPageSizeChange={onPageSizeChange}
365
+ />
366
+ </>
367
+ ) : (
368
+ <>
369
+ <div className="overflow-x-auto rounded-lg border bg-card shadow-sm max-sm:-mx-2 max-sm:rounded-none max-sm:border-x-0">
370
+ {onReorder ? (
371
+ <DndContext
372
+ sensors={sensors}
373
+ collisionDetection={closestCenter}
374
+ onDragEnd={handleDragEnd}
375
+ >
376
+ {tableContent}
377
+ </DndContext>
378
+ ) : (
379
+ tableContent
380
+ )}
381
+ </div>
382
+ <div
383
+ className={
384
+ stickyPagination
385
+ ? "sticky bottom-0 z-20 border-t bg-background py-2 max-sm:-mx-2"
386
+ : undefined
387
+ }
388
+ >
389
+ <DataTablePagination
390
+ table={table}
391
+ pageSizeOptions={pageSizeOptions}
392
+ onPageSizeChange={onPageSizeChange}
393
+ />
394
+ </div>
395
+ </>
396
+ )}
397
+ </div>
398
+ );
399
+ }
@@ -0,0 +1,9 @@
1
+ export * from "./data-table";
2
+ export * from "./data-table-column-header";
3
+ export * from "./data-table-date-range-filter";
4
+ export * from "./data-table-faceted-filter";
5
+ export * from "./data-table-pagination";
6
+ export * from "./data-table-toolbar";
7
+ export * from "./data-table-view-option";
8
+ export * from "./types";
9
+ export * from "./use-data-table";
@@ -0,0 +1,106 @@
1
+ import type {
2
+ ColumnDef,
3
+ ColumnFiltersState,
4
+ FilterFn,
5
+ OnChangeFn,
6
+ PaginationState,
7
+ Row,
8
+ SortingState,
9
+ Table,
10
+ TableState,
11
+ VisibilityState,
12
+ } from "@tanstack/react-table";
13
+ import type React from "react";
14
+
15
+ export type FacetedFilterConfig = {
16
+ columnId: string;
17
+ title: string;
18
+ compact?: boolean;
19
+ options: {
20
+ label: string;
21
+ value: string | number | boolean;
22
+ icon?: React.ComponentType<{ className?: string }>;
23
+ }[];
24
+ };
25
+
26
+ export interface DataTableProps<TData, TValue> {
27
+ columns: ColumnDef<TData, TValue>[];
28
+ data: TData[];
29
+
30
+ // Styling Hooks
31
+ className?: string; // Appended to table wrap
32
+ containerClassName?: string; // Appended to outer wrapper
33
+ /**
34
+ * Appended to the scrollable box that wraps the table itself.
35
+ *
36
+ * Needed for `stickyHeader`: the box already scrolls horizontally, and CSS
37
+ * forces the vertical axis to `auto` alongside it, so it — not the page — is
38
+ * what a sticky header anchors to. Give it a height (e.g. `max-h-[70vh]`) or
39
+ * the header has nothing to stick within and scrolls away with the page.
40
+ */
41
+ tableContainerClassName?: string;
42
+ /** ตรึงแถบเปลี่ยนหน้าไว้ล่างสุดของจอเสมอ ใช้คู่กับ `stickyHeader` */
43
+ stickyPagination?: boolean;
44
+
45
+ // Accessibility & UI
46
+ stickyHeader?: boolean;
47
+ stickyEdgeColumns?: boolean;
48
+ emptyStateComponent?: React.ReactNode;
49
+
50
+ // Filters & Toolbar
51
+ facetedFilters?: FacetedFilterConfig[];
52
+ actionsComponent?: (table: Table<TData>) => React.ReactNode;
53
+ globalFilterKeys?: string[];
54
+ globalFilterFn?: FilterFn<TData>;
55
+ globalFilterPlaceholder?: string;
56
+ toolbarFilters?: (table: Table<TData>) => React.ReactNode;
57
+ preservedFilterIds?: string[];
58
+
59
+ // State & Visibility
60
+ initialVisibility?: VisibilityState;
61
+ initialColumnFilters?: ColumnFiltersState;
62
+ columnFilters?: ColumnFiltersState;
63
+ initialState?: Partial<TableState>;
64
+
65
+ // Pagination & Sorting (Server-side support)
66
+ pageCount?: number;
67
+ rowCount?: number;
68
+ /**
69
+ * ควบคุม pagination จากภายนอก (ใช้คู่กับ manualPagination)
70
+ * จำเป็นเมื่อ state จริงอยู่ที่อื่น เช่นใน URL — ไม่งั้นเลขหน้าใน UI จะไม่ตรงกับข้อมูลที่ server ส่งมา
71
+ */
72
+ pagination?: PaginationState;
73
+ /** ควบคุม sorting จากภายนอก (ใช้คู่กับ manualSorting) */
74
+ sorting?: SortingState;
75
+ /** ควบคุมคำค้นจากภายนอก (ใช้คู่กับ manualFiltering) */
76
+ globalFilter?: string;
77
+ /**
78
+ * เรียกเมื่อผู้ใช้พิมพ์ในช่องค้นหา
79
+ * ต้องส่งมาด้วยเมื่อเปิด manualFiltering ไม่งั้นช่องค้นหาจะไม่ทำอะไรเลย
80
+ * เพราะ TanStack ถูกสั่งไม่ให้กรองเองแล้ว
81
+ */
82
+ onGlobalFilterChange?: (value: string) => void;
83
+ manualPagination?: boolean;
84
+ manualSorting?: boolean;
85
+ manualFiltering?: boolean;
86
+ onPaginationChange?: OnChangeFn<PaginationState>;
87
+ onPageSizeChange?: (pageSize: number) => void;
88
+ onSortingChange?: OnChangeFn<SortingState>;
89
+ onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>;
90
+
91
+ // Customization
92
+ getRowClassName?: (row: Row<TData>) => string;
93
+ getSubRows?: (originalRow: TData) => TData[] | undefined;
94
+ getRowId?: (originalRow: TData) => string;
95
+ initialPageSize?: number;
96
+ meta?: Record<string, unknown>;
97
+ isLoading?: boolean;
98
+
99
+ // Advanced
100
+ enableColumnResizing?: boolean;
101
+ enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
102
+ getFacetedUniqueValues?: (table: Table<TData>, columnId: string) => () => Map<unknown, number>;
103
+ pageSizeOptions?: number[];
104
+ onReorder?: (newData: TData[]) => void;
105
+ renderCustomView?: (table: Table<TData>) => React.ReactNode;
106
+ }