@suphark/ui 0.1.1 → 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.
- package/CHANGELOG.md +11 -0
- package/README.md +5 -1
- package/package.json +13 -2
- package/src/components/data-table/data-table-column-header.tsx +71 -0
- package/src/components/data-table/data-table-date-range-filter.tsx +25 -0
- package/src/components/data-table/data-table-faceted-filter.tsx +181 -0
- package/src/components/data-table/data-table-pagination.tsx +114 -0
- package/src/components/data-table/data-table-row-actions.tsx +166 -0
- package/src/components/data-table/data-table-toolbar.tsx +207 -0
- package/src/components/data-table/data-table-view-option.tsx +59 -0
- package/src/components/data-table/data-table.tsx +399 -0
- package/src/components/data-table/index.ts +9 -0
- package/src/components/data-table/types.ts +106 -0
- package/src/components/data-table/use-data-table.ts +229 -0
- package/src/components/design-system/sections/showcase-data-display.tsx +71 -0
- package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +8 -8
- package/src/components/shared/button/delete-many-button.tsx +85 -0
- package/src/components/shared/resource-property-filter.tsx +399 -0
- package/src/data-table.ts +21 -0
- package/src/hooks/use-table-search-params.ts +147 -0
- package/src/next.ts +5 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Badge } from "@suphark/ui/components/ui/badge";
|
|
4
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
5
|
+
import {
|
|
6
|
+
InputGroup,
|
|
7
|
+
InputGroupAddon,
|
|
8
|
+
InputGroupButton,
|
|
9
|
+
InputGroupInput,
|
|
10
|
+
} from "@suphark/ui/components/ui/input-group";
|
|
11
|
+
import {
|
|
12
|
+
Sheet,
|
|
13
|
+
SheetClose,
|
|
14
|
+
SheetContent,
|
|
15
|
+
SheetDescription,
|
|
16
|
+
SheetFooter,
|
|
17
|
+
SheetHeader,
|
|
18
|
+
SheetTitle,
|
|
19
|
+
SheetTrigger,
|
|
20
|
+
} from "@suphark/ui/components/ui/sheet";
|
|
21
|
+
import type { Table } from "@tanstack/react-table";
|
|
22
|
+
import { Search, SlidersHorizontal, X } from "lucide-react";
|
|
23
|
+
import type React from "react";
|
|
24
|
+
import type { FacetedFilterConfig } from ".";
|
|
25
|
+
import { DataTableFacetedFilter } from "./data-table-faceted-filter";
|
|
26
|
+
import { DataTableViewOptions } from "./data-table-view-option";
|
|
27
|
+
|
|
28
|
+
interface DataTableToolbarProps<TData> {
|
|
29
|
+
table: Table<TData>;
|
|
30
|
+
facetedFilters?: FacetedFilterConfig[];
|
|
31
|
+
actionsComponent?: React.ReactNode;
|
|
32
|
+
globalFilterPlaceholder?: string;
|
|
33
|
+
toolbarFilters?: React.ReactNode;
|
|
34
|
+
preservedFilterIds?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function DataTableToolbar<TData>({
|
|
38
|
+
table,
|
|
39
|
+
facetedFilters,
|
|
40
|
+
actionsComponent,
|
|
41
|
+
globalFilterPlaceholder,
|
|
42
|
+
toolbarFilters,
|
|
43
|
+
preservedFilterIds = [],
|
|
44
|
+
}: DataTableToolbarProps<TData>) {
|
|
45
|
+
const globalFilter = table.getState().globalFilter;
|
|
46
|
+
const preservedFilterIdSet = new Set(preservedFilterIds);
|
|
47
|
+
const userColumnFilters = table
|
|
48
|
+
.getState()
|
|
49
|
+
.columnFilters.filter((filter) => !preservedFilterIdSet.has(filter.id));
|
|
50
|
+
const activeFilterCount = userColumnFilters.length + (globalFilter ? 1 : 0);
|
|
51
|
+
|
|
52
|
+
const isFiltered = activeFilterCount > 0;
|
|
53
|
+
|
|
54
|
+
const resetUserFilters = () => {
|
|
55
|
+
table.setColumnFilters((filters) =>
|
|
56
|
+
filters.filter((filter) => preservedFilterIdSet.has(filter.id)),
|
|
57
|
+
);
|
|
58
|
+
table.setGlobalFilter("");
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const renderFilterControls = (fullWidth = false) =>
|
|
62
|
+
facetedFilters?.map((filter: FacetedFilterConfig) => {
|
|
63
|
+
const column = table.getColumn(filter.columnId);
|
|
64
|
+
if (!column) return null;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<DataTableFacetedFilter
|
|
68
|
+
key={filter.columnId}
|
|
69
|
+
column={column}
|
|
70
|
+
title={filter.title}
|
|
71
|
+
options={filter.options}
|
|
72
|
+
compact={filter.compact}
|
|
73
|
+
fullWidth={fullWidth}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
80
|
+
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
|
81
|
+
{globalFilterPlaceholder && (
|
|
82
|
+
<InputGroup className="h-8 w-full sm:w-[240px] lg:w-[320px]">
|
|
83
|
+
<InputGroupAddon align="inline-start">
|
|
84
|
+
<Search aria-hidden="true" className="size-4" />
|
|
85
|
+
</InputGroupAddon>
|
|
86
|
+
<InputGroupInput
|
|
87
|
+
aria-label="ค้นหาข้อมูลในตาราง"
|
|
88
|
+
name="table-search"
|
|
89
|
+
autoComplete="off"
|
|
90
|
+
placeholder={globalFilterPlaceholder}
|
|
91
|
+
value={(globalFilter as string) ?? ""}
|
|
92
|
+
onChange={(event) => table.setGlobalFilter(String(event.target.value))}
|
|
93
|
+
/>
|
|
94
|
+
{!!globalFilter && (
|
|
95
|
+
<InputGroupAddon align="inline-end">
|
|
96
|
+
<InputGroupButton
|
|
97
|
+
variant="ghost"
|
|
98
|
+
aria-label="ล้างคำค้นหา"
|
|
99
|
+
onClick={() => table.setGlobalFilter("")}
|
|
100
|
+
>
|
|
101
|
+
<X aria-hidden="true" className="size-4" />
|
|
102
|
+
</InputGroupButton>
|
|
103
|
+
</InputGroupAddon>
|
|
104
|
+
)}
|
|
105
|
+
</InputGroup>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
<div className="hidden xl:contents">
|
|
109
|
+
{renderFilterControls()}
|
|
110
|
+
{toolbarFilters}
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<Sheet>
|
|
114
|
+
<SheetTrigger render={<Button variant="outline" size="sm" className="xl:hidden" />}>
|
|
115
|
+
<SlidersHorizontal />
|
|
116
|
+
ตัวกรอง
|
|
117
|
+
{activeFilterCount > 0 && (
|
|
118
|
+
<Badge variant="secondary" className="ml-1 px-1.5 tabular-nums">
|
|
119
|
+
{activeFilterCount}
|
|
120
|
+
</Badge>
|
|
121
|
+
)}
|
|
122
|
+
</SheetTrigger>
|
|
123
|
+
<SheetContent
|
|
124
|
+
side="right"
|
|
125
|
+
showCloseButton={false}
|
|
126
|
+
className="w-[min(24rem,calc(100vw-1rem))] sm:max-w-md"
|
|
127
|
+
>
|
|
128
|
+
<SheetHeader className="border-b pr-12">
|
|
129
|
+
<SheetTitle>ตัวกรองข้อมูล</SheetTitle>
|
|
130
|
+
<SheetDescription>เลือกได้หลายเงื่อนไข ผลลัพธ์จะอัปเดตทันที</SheetDescription>
|
|
131
|
+
<SheetClose
|
|
132
|
+
render={
|
|
133
|
+
<Button variant="ghost" size="icon-sm" className="absolute top-3 right-3" />
|
|
134
|
+
}
|
|
135
|
+
>
|
|
136
|
+
<X />
|
|
137
|
+
<span className="sr-only">ปิดตัวกรอง</span>
|
|
138
|
+
</SheetClose>
|
|
139
|
+
</SheetHeader>
|
|
140
|
+
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4">
|
|
141
|
+
{renderFilterControls(true)}
|
|
142
|
+
{toolbarFilters}
|
|
143
|
+
</div>
|
|
144
|
+
{isFiltered && (
|
|
145
|
+
<SheetFooter className="border-t">
|
|
146
|
+
<Button variant="secondary" onClick={resetUserFilters}>
|
|
147
|
+
<X />
|
|
148
|
+
ล้างตัวกรองทั้งหมด
|
|
149
|
+
</Button>
|
|
150
|
+
</SheetFooter>
|
|
151
|
+
)}
|
|
152
|
+
</SheetContent>
|
|
153
|
+
</Sheet>
|
|
154
|
+
|
|
155
|
+
{isFiltered && (
|
|
156
|
+
<Button variant="secondary" size="sm" onClick={resetUserFilters}>
|
|
157
|
+
ล้างตัวกรอง
|
|
158
|
+
<X />
|
|
159
|
+
</Button>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div className="ml-auto flex flex-wrap items-center justify-end gap-2 self-start">
|
|
164
|
+
{actionsComponent}
|
|
165
|
+
<DataTableViewOptions table={table} />
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{isFiltered && (
|
|
169
|
+
<div className="flex w-full flex-wrap gap-2 pt-1">
|
|
170
|
+
{userColumnFilters.map((filter) => {
|
|
171
|
+
const config = facetedFilters?.find((f) => f.columnId === filter.id);
|
|
172
|
+
if (!config) return null;
|
|
173
|
+
|
|
174
|
+
const values = Array.isArray(filter.value) ? filter.value : [filter.value];
|
|
175
|
+
const labels = values
|
|
176
|
+
.map((val) => {
|
|
177
|
+
const option = config.options.find((opt) => String(opt.value) === String(val));
|
|
178
|
+
return option ? option.label : String(val);
|
|
179
|
+
})
|
|
180
|
+
.join(", ");
|
|
181
|
+
const displayedLabels =
|
|
182
|
+
config.compact && values.length > 1 ? `${values.length} รายการ` : labels;
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<Badge
|
|
186
|
+
key={filter.id}
|
|
187
|
+
variant="secondary"
|
|
188
|
+
className="flex items-center gap-1.5 rounded-md px-2 py-1 font-normal"
|
|
189
|
+
>
|
|
190
|
+
<span className="text-muted-foreground">{config.title}:</span>
|
|
191
|
+
<span className="font-medium">{displayedLabels}</span>
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
aria-label={`ล้างตัวกรอง ${config.title}: ${displayedLabels}`}
|
|
195
|
+
onClick={() => table.getColumn(filter.id)?.setFilterValue(undefined)}
|
|
196
|
+
className="ml-1 rounded-full outline-none hover:bg-muted-foreground/20"
|
|
197
|
+
>
|
|
198
|
+
<X className="size-3" />
|
|
199
|
+
</button>
|
|
200
|
+
</Badge>
|
|
201
|
+
);
|
|
202
|
+
})}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuCheckboxItem,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuGroup,
|
|
9
|
+
DropdownMenuLabel,
|
|
10
|
+
DropdownMenuSeparator,
|
|
11
|
+
DropdownMenuTrigger,
|
|
12
|
+
} from "@suphark/ui/components/ui/dropdown-menu";
|
|
13
|
+
import type { Table } from "@tanstack/react-table";
|
|
14
|
+
import { Settings2 } from "lucide-react";
|
|
15
|
+
|
|
16
|
+
interface DataTableViewOptionsProps<TData> {
|
|
17
|
+
table: Table<TData>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function DataTableViewOptions<TData>({ table }: DataTableViewOptionsProps<TData>) {
|
|
21
|
+
return (
|
|
22
|
+
<DropdownMenu>
|
|
23
|
+
<DropdownMenuTrigger
|
|
24
|
+
render={<Button variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex" />}
|
|
25
|
+
>
|
|
26
|
+
<Settings2 className="mr-2 h-4 w-4" />
|
|
27
|
+
View
|
|
28
|
+
</DropdownMenuTrigger>
|
|
29
|
+
<DropdownMenuContent align="end" className="w-[150px]">
|
|
30
|
+
<DropdownMenuGroup>
|
|
31
|
+
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
|
32
|
+
</DropdownMenuGroup>
|
|
33
|
+
<DropdownMenuSeparator />
|
|
34
|
+
{table
|
|
35
|
+
.getAllColumns()
|
|
36
|
+
.filter(
|
|
37
|
+
(column) =>
|
|
38
|
+
typeof column.accessorFn !== "undefined" &&
|
|
39
|
+
column.getCanHide() &&
|
|
40
|
+
column.columnDef.enableHiding !== false,
|
|
41
|
+
)
|
|
42
|
+
.map((column) => {
|
|
43
|
+
return (
|
|
44
|
+
<DropdownMenuCheckboxItem
|
|
45
|
+
key={column.id}
|
|
46
|
+
className="capitalize"
|
|
47
|
+
checked={column.getIsVisible()}
|
|
48
|
+
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
|
49
|
+
// Base UI ปิดเมนูหลังคลิกเป็นค่าเริ่มต้น — ติ๊กเลือกคอลัมน์ต้องเปิดค้างไว้
|
|
50
|
+
closeOnClick={false}
|
|
51
|
+
>
|
|
52
|
+
{column.id.replace(/([A-Z])/g, " $1")}
|
|
53
|
+
</DropdownMenuCheckboxItem>
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
56
|
+
</DropdownMenuContent>
|
|
57
|
+
</DropdownMenu>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -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";
|