@suphark/ui 0.53.0 → 0.59.1
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 +1125 -1063
- package/package.json +1 -1
- package/src/components/data-table/data-table-column-header.tsx +1 -1
- package/src/components/data-table/data-table-pagination.tsx +70 -0
- package/src/components/data-table/data-table-row-actions.tsx +1 -1
- package/src/components/data-table/data-table-toolbar.tsx +166 -44
- package/src/components/data-table/data-table-view-option.tsx +15 -4
- package/src/components/data-table/data-table.tsx +153 -3
- package/src/components/data-table/index.ts +1 -1
- package/src/components/data-table/types.ts +19 -0
- package/src/components/pwa/pull-to-refresh.tsx +96 -15
- package/src/components/shared/date/accessible-calendar.tsx +40 -0
- package/src/components/shared/date/date-picker.tsx +124 -32
- package/src/components/shared/date/date-range-picker.tsx +8 -5
- package/src/components/shared/form/date-time-picker.tsx +2 -2
- package/src/components/shared/form/labeled-field.tsx +37 -0
- package/src/components/shared/list-section.tsx +85 -0
- package/src/components/shared/notification-center.tsx +13 -5
- package/src/components/shared/progress-bar.tsx +9 -2
- package/src/components/shared/responsive-popover.tsx +174 -0
- package/src/components/shared/row-table.tsx +133 -0
- package/src/components/shared/sidebar-nav-item.tsx +62 -0
- package/src/components/shared/ui-text.tsx +21 -0
- package/src/data-table.ts +1 -0
- package/src/hooks/use-mobile.ts +3 -1
- package/src/index.ts +26 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
|
|
@@ -40,7 +40,7 @@ export function DataTableColumnHeader<TData, TValue>({
|
|
|
40
40
|
) : column.getIsSorted() === "asc" ? (
|
|
41
41
|
<ArrowUp />
|
|
42
42
|
) : (
|
|
43
|
-
<ChevronsUpDown />
|
|
43
|
+
<ChevronsUpDown data-sort-idle="" />
|
|
44
44
|
)}
|
|
45
45
|
{column.getIsFiltered() && (
|
|
46
46
|
<Filter className="ml-1 size-3 fill-primary/10 text-primary" />
|
|
@@ -17,14 +17,84 @@ interface DataTablePaginationProps<TData> {
|
|
|
17
17
|
table: Table<TData>;
|
|
18
18
|
pageSizeOptions?: number[];
|
|
19
19
|
onPageSizeChange?: (pageSize: number) => void;
|
|
20
|
+
/** `quiet` = "1–10 จาก 128 · แถวต่อหน้า · ‹ 1 / 13 ›" (ตัดปุ่มหน้าแรก/สุดท้ายและข้อความจำนวนที่เลือก) */
|
|
21
|
+
variant?: "default" | "quiet";
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export function DataTablePagination<TData>({
|
|
23
25
|
table,
|
|
24
26
|
pageSizeOptions = [10, 20, 30, 40, 50],
|
|
25
27
|
onPageSizeChange,
|
|
28
|
+
variant = "default",
|
|
26
29
|
}: DataTablePaginationProps<TData>) {
|
|
27
30
|
const text = useUiText();
|
|
31
|
+
if (variant === "quiet") {
|
|
32
|
+
const { pageIndex, pageSize } = table.getState().pagination;
|
|
33
|
+
const total = table.options.manualPagination
|
|
34
|
+
? (table.options.rowCount ?? table.getRowModel().rows.length)
|
|
35
|
+
: table.getFilteredRowModel().rows.length;
|
|
36
|
+
const from = total === 0 ? 0 : pageIndex * pageSize + 1;
|
|
37
|
+
const to = Math.min(total, (pageIndex + 1) * pageSize);
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-1 text-muted-foreground text-xs">
|
|
40
|
+
<span className="tabular-nums">
|
|
41
|
+
{(text.table?.rangeOf ?? "{from}–{to} จาก {total}")
|
|
42
|
+
.replace("{from}", String(from))
|
|
43
|
+
.replace("{to}", String(to))
|
|
44
|
+
.replace("{total}", String(total))}
|
|
45
|
+
</span>
|
|
46
|
+
<div className="flex items-center gap-3">
|
|
47
|
+
<div className="flex items-center gap-1.5 max-sm:hidden">
|
|
48
|
+
<span>{text.table?.rowsPerPage ?? "แถวต่อหน้า"}</span>
|
|
49
|
+
<Select
|
|
50
|
+
value={`${pageSize}`}
|
|
51
|
+
onValueChange={(value) => {
|
|
52
|
+
const newSize = Number(value);
|
|
53
|
+
table.setPageSize(newSize);
|
|
54
|
+
onPageSizeChange?.(newSize);
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<SelectTrigger size="sm" className="h-7 w-16 border-transparent bg-muted shadow-none">
|
|
58
|
+
<SelectValue placeholder={pageSize} />
|
|
59
|
+
</SelectTrigger>
|
|
60
|
+
<SelectContent side="top">
|
|
61
|
+
{pageSizeOptions.map((size) => (
|
|
62
|
+
<SelectItem key={size} value={`${size}`}>
|
|
63
|
+
{size}
|
|
64
|
+
</SelectItem>
|
|
65
|
+
))}
|
|
66
|
+
</SelectContent>
|
|
67
|
+
</Select>
|
|
68
|
+
</div>
|
|
69
|
+
<div className="flex items-center gap-0.5">
|
|
70
|
+
<Button
|
|
71
|
+
variant="ghost"
|
|
72
|
+
size="icon-sm"
|
|
73
|
+
aria-label={text.pagination?.previous ?? "ก่อนหน้า"}
|
|
74
|
+
onClick={() => table.previousPage()}
|
|
75
|
+
disabled={!table.getCanPreviousPage()}
|
|
76
|
+
className="pointer-coarse:h-11 pointer-coarse:w-11"
|
|
77
|
+
>
|
|
78
|
+
<ChevronLeft />
|
|
79
|
+
</Button>
|
|
80
|
+
<span className="px-1.5 font-semibold text-foreground tabular-nums">
|
|
81
|
+
{pageIndex + 1} / {Math.max(1, table.getPageCount())}
|
|
82
|
+
</span>
|
|
83
|
+
<Button
|
|
84
|
+
variant="ghost"
|
|
85
|
+
size="icon-sm"
|
|
86
|
+
aria-label={text.pagination?.next ?? "ถัดไป"}
|
|
87
|
+
onClick={() => table.nextPage()}
|
|
88
|
+
disabled={!table.getCanNextPage()}
|
|
89
|
+
className="pointer-coarse:h-11 pointer-coarse:w-11"
|
|
90
|
+
>
|
|
91
|
+
<ChevronRight />
|
|
92
|
+
</Button>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
28
98
|
const isRowSelectionEnabled = table.options.enableRowSelection !== false;
|
|
29
99
|
|
|
30
100
|
return (
|
|
@@ -19,8 +19,9 @@ import {
|
|
|
19
19
|
SheetTitle,
|
|
20
20
|
SheetTrigger,
|
|
21
21
|
} from "@suphark/ui/components/ui/sheet";
|
|
22
|
+
import { useIsMobile } from "@suphark/ui/hooks/use-mobile";
|
|
22
23
|
import type { Table } from "@tanstack/react-table";
|
|
23
|
-
import { Search, SlidersHorizontal, X } from "lucide-react";
|
|
24
|
+
import { Rows3, Rows4, Search, SlidersHorizontal, X } from "lucide-react";
|
|
24
25
|
import type React from "react";
|
|
25
26
|
import { useEffect, useRef, useState } from "react";
|
|
26
27
|
import type { FacetedFilterConfig } from ".";
|
|
@@ -34,6 +35,10 @@ interface DataTableToolbarProps<TData> {
|
|
|
34
35
|
globalFilterPlaceholder?: string;
|
|
35
36
|
toolbarFilters?: React.ReactNode;
|
|
36
37
|
preservedFilterIds?: string[];
|
|
38
|
+
/** `quiet`: ตัวกรองปุ่มเดียว + ชิป + ตัวนับ + สลับความหนาแน่นในบรรทัดเดียว (ดู `DataTableProps.variant`) */
|
|
39
|
+
variant?: "default" | "quiet";
|
|
40
|
+
density?: "comfortable" | "compact";
|
|
41
|
+
onDensityChange?: (density: "comfortable" | "compact") => void;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
export function DataTableToolbar<TData>({
|
|
@@ -43,8 +48,12 @@ export function DataTableToolbar<TData>({
|
|
|
43
48
|
globalFilterPlaceholder,
|
|
44
49
|
toolbarFilters,
|
|
45
50
|
preservedFilterIds = [],
|
|
51
|
+
variant = "default",
|
|
52
|
+
density = "comfortable",
|
|
53
|
+
onDensityChange,
|
|
46
54
|
}: DataTableToolbarProps<TData>) {
|
|
47
55
|
const text = useUiText();
|
|
56
|
+
const isMobile = useIsMobile();
|
|
48
57
|
const globalFilter = table.getState().globalFilter;
|
|
49
58
|
// ค้นหา: เก็บข้อความในช่องเอง แล้วส่งให้ตารางเมื่อหยุดพิมพ์ 250 ms — เดิมกรอง/เรียง/render ทุกแถวทุกตัวอักษร
|
|
50
59
|
// (และถ้าแอปผูก globalFilter กับ URL = router.replace ทุกตัวอักษร)
|
|
@@ -114,6 +123,161 @@ export function DataTableToolbar<TData>({
|
|
|
114
123
|
);
|
|
115
124
|
});
|
|
116
125
|
|
|
126
|
+
const renderSheet = ({
|
|
127
|
+
side,
|
|
128
|
+
triggerVariant,
|
|
129
|
+
triggerClassName,
|
|
130
|
+
}: {
|
|
131
|
+
side: "right" | "bottom";
|
|
132
|
+
triggerVariant: "outline" | "ghost";
|
|
133
|
+
triggerClassName?: string;
|
|
134
|
+
}) => (
|
|
135
|
+
<Sheet>
|
|
136
|
+
<SheetTrigger
|
|
137
|
+
render={<Button variant={triggerVariant} size="sm" className={triggerClassName} />}
|
|
138
|
+
>
|
|
139
|
+
<SlidersHorizontal />
|
|
140
|
+
ตัวกรอง
|
|
141
|
+
{activeFilterCount > 0 && (
|
|
142
|
+
<Badge variant="secondary" className="ml-1 px-1.5 tabular-nums">
|
|
143
|
+
{activeFilterCount}
|
|
144
|
+
</Badge>
|
|
145
|
+
)}
|
|
146
|
+
</SheetTrigger>
|
|
147
|
+
<SheetContent
|
|
148
|
+
side={side}
|
|
149
|
+
showCloseButton={false}
|
|
150
|
+
className={
|
|
151
|
+
side === "bottom"
|
|
152
|
+
? "max-h-[85dvh] rounded-t-2xl"
|
|
153
|
+
: "w-[min(24rem,calc(100vw-1rem))] sm:max-w-md"
|
|
154
|
+
}
|
|
155
|
+
>
|
|
156
|
+
<SheetHeader className="border-b pr-12">
|
|
157
|
+
<SheetTitle>{text.table?.filters ?? "ตัวกรองข้อมูล"}</SheetTitle>
|
|
158
|
+
<SheetDescription>
|
|
159
|
+
{text.table?.filtersDescription ?? "เลือกได้หลายเงื่อนไข ผลลัพธ์จะอัปเดตทันที"}
|
|
160
|
+
</SheetDescription>
|
|
161
|
+
<SheetClose
|
|
162
|
+
render={<Button variant="ghost" size="icon-sm" className="absolute top-3 right-3" />}
|
|
163
|
+
>
|
|
164
|
+
<X />
|
|
165
|
+
<span className="sr-only">{text.table?.closeFilters ?? "ปิดตัวกรอง"}</span>
|
|
166
|
+
</SheetClose>
|
|
167
|
+
</SheetHeader>
|
|
168
|
+
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4">
|
|
169
|
+
{renderFilterControls(true)}
|
|
170
|
+
{toolbarFilters}
|
|
171
|
+
</div>
|
|
172
|
+
{isFiltered && (
|
|
173
|
+
<SheetFooter className="border-t">
|
|
174
|
+
<Button variant="secondary" onClick={resetUserFilters}>
|
|
175
|
+
<X />
|
|
176
|
+
ล้างตัวกรองทั้งหมด
|
|
177
|
+
</Button>
|
|
178
|
+
</SheetFooter>
|
|
179
|
+
)}
|
|
180
|
+
</SheetContent>
|
|
181
|
+
</Sheet>
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
if (variant === "quiet") {
|
|
185
|
+
const total = table.getCoreRowModel().rows.length;
|
|
186
|
+
const shown = table.getFilteredRowModel().rows.length;
|
|
187
|
+
return (
|
|
188
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
189
|
+
{globalFilterPlaceholder && (
|
|
190
|
+
<InputGroup className="h-8 w-full pointer-coarse:h-11 sm:w-[220px]">
|
|
191
|
+
<InputGroupAddon align="inline-start">
|
|
192
|
+
<Search aria-hidden="true" className="size-4" />
|
|
193
|
+
</InputGroupAddon>
|
|
194
|
+
<InputGroupInput
|
|
195
|
+
aria-label={text.table?.search ?? "ค้นหาข้อมูลในตาราง"}
|
|
196
|
+
name="table-search"
|
|
197
|
+
autoComplete="off"
|
|
198
|
+
placeholder={globalFilterPlaceholder}
|
|
199
|
+
value={searchDraft}
|
|
200
|
+
onChange={(event) => setSearchDraft(event.target.value)}
|
|
201
|
+
onKeyDown={(event) => {
|
|
202
|
+
if (event.key === "Enter") commitSearch(searchDraft);
|
|
203
|
+
}}
|
|
204
|
+
/>
|
|
205
|
+
{!!searchDraft && (
|
|
206
|
+
<InputGroupAddon align="inline-end">
|
|
207
|
+
<InputGroupButton
|
|
208
|
+
variant="ghost"
|
|
209
|
+
aria-label={text.table?.clearSearch ?? "ล้างคำค้นหา"}
|
|
210
|
+
onClick={() => commitSearch("")}
|
|
211
|
+
>
|
|
212
|
+
<X aria-hidden="true" className="size-4" />
|
|
213
|
+
</InputGroupButton>
|
|
214
|
+
</InputGroupAddon>
|
|
215
|
+
)}
|
|
216
|
+
</InputGroup>
|
|
217
|
+
)}
|
|
218
|
+
{userColumnFilters.map((filter) => {
|
|
219
|
+
const config = facetedFilters?.find((f) => f.columnId === filter.id);
|
|
220
|
+
if (!config) return null;
|
|
221
|
+
const values = Array.isArray(filter.value) ? filter.value : [filter.value];
|
|
222
|
+
const labels = values
|
|
223
|
+
.map((val) => {
|
|
224
|
+
const option = config.options.find((opt) => String(opt.value) === String(val));
|
|
225
|
+
return option ? option.label : String(val);
|
|
226
|
+
})
|
|
227
|
+
.join(", ");
|
|
228
|
+
const displayed =
|
|
229
|
+
config.compact && values.length > 1 ? `${values.length} รายการ` : labels;
|
|
230
|
+
return (
|
|
231
|
+
<span
|
|
232
|
+
key={filter.id}
|
|
233
|
+
className="inline-flex h-7 items-center gap-1.5 rounded-full border bg-muted ps-2.5 pe-1 text-xs"
|
|
234
|
+
>
|
|
235
|
+
<span className="text-muted-foreground">{config.title}</span>
|
|
236
|
+
<b className="font-semibold">{displayed}</b>
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
aria-label={`${(text.table?.clearFilter ?? "ล้างตัวกรอง {name}").replace(
|
|
240
|
+
"{name}",
|
|
241
|
+
config.title,
|
|
242
|
+
)}: ${displayed}`}
|
|
243
|
+
onClick={() => table.getColumn(filter.id)?.setFilterValue(undefined)}
|
|
244
|
+
className="relative flex size-5 items-center justify-center rounded-full text-muted-foreground outline-none after:absolute after:-inset-3 after:content-[''] hover:bg-foreground/10 focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
245
|
+
>
|
|
246
|
+
<X className="size-3" aria-hidden="true" />
|
|
247
|
+
</button>
|
|
248
|
+
</span>
|
|
249
|
+
);
|
|
250
|
+
})}
|
|
251
|
+
{renderSheet({
|
|
252
|
+
side: isMobile ? "bottom" : "right",
|
|
253
|
+
triggerVariant: "ghost",
|
|
254
|
+
triggerClassName: "text-muted-foreground pointer-coarse:h-11",
|
|
255
|
+
})}
|
|
256
|
+
<div className="ml-auto flex items-center gap-1">
|
|
257
|
+
<span className="me-1 text-muted-foreground text-xs tabular-nums">
|
|
258
|
+
{(text.table?.shownOf ?? "{shown} จาก {total} รายการ")
|
|
259
|
+
.replace("{shown}", String(shown))
|
|
260
|
+
.replace("{total}", String(total))}
|
|
261
|
+
</span>
|
|
262
|
+
{actionsComponent}
|
|
263
|
+
{onDensityChange && (
|
|
264
|
+
<Button
|
|
265
|
+
variant="ghost"
|
|
266
|
+
size="icon-sm"
|
|
267
|
+
aria-label={text.table?.density ?? "ความหนาแน่นของแถว"}
|
|
268
|
+
aria-pressed={density === "compact"}
|
|
269
|
+
onClick={() => onDensityChange(density === "compact" ? "comfortable" : "compact")}
|
|
270
|
+
className="max-sm:hidden"
|
|
271
|
+
>
|
|
272
|
+
{density === "compact" ? <Rows4 /> : <Rows3 />}
|
|
273
|
+
</Button>
|
|
274
|
+
)}
|
|
275
|
+
<DataTableViewOptions table={table} iconOnly />
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
117
281
|
return (
|
|
118
282
|
<div className="flex flex-wrap items-center gap-2">
|
|
119
283
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
|
@@ -152,49 +316,7 @@ export function DataTableToolbar<TData>({
|
|
|
152
316
|
{toolbarFilters}
|
|
153
317
|
</div>
|
|
154
318
|
|
|
155
|
-
|
|
156
|
-
<SheetTrigger render={<Button variant="outline" size="sm" className="xl:hidden" />}>
|
|
157
|
-
<SlidersHorizontal />
|
|
158
|
-
ตัวกรอง
|
|
159
|
-
{activeFilterCount > 0 && (
|
|
160
|
-
<Badge variant="secondary" className="ml-1 px-1.5 tabular-nums">
|
|
161
|
-
{activeFilterCount}
|
|
162
|
-
</Badge>
|
|
163
|
-
)}
|
|
164
|
-
</SheetTrigger>
|
|
165
|
-
<SheetContent
|
|
166
|
-
side="right"
|
|
167
|
-
showCloseButton={false}
|
|
168
|
-
className="w-[min(24rem,calc(100vw-1rem))] sm:max-w-md"
|
|
169
|
-
>
|
|
170
|
-
<SheetHeader className="border-b pr-12">
|
|
171
|
-
<SheetTitle>{text.table?.filters ?? "ตัวกรองข้อมูล"}</SheetTitle>
|
|
172
|
-
<SheetDescription>
|
|
173
|
-
{text.table?.filtersDescription ?? "เลือกได้หลายเงื่อนไข ผลลัพธ์จะอัปเดตทันที"}
|
|
174
|
-
</SheetDescription>
|
|
175
|
-
<SheetClose
|
|
176
|
-
render={
|
|
177
|
-
<Button variant="ghost" size="icon-sm" className="absolute top-3 right-3" />
|
|
178
|
-
}
|
|
179
|
-
>
|
|
180
|
-
<X />
|
|
181
|
-
<span className="sr-only">{text.table?.closeFilters ?? "ปิดตัวกรอง"}</span>
|
|
182
|
-
</SheetClose>
|
|
183
|
-
</SheetHeader>
|
|
184
|
-
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4">
|
|
185
|
-
{renderFilterControls(true)}
|
|
186
|
-
{toolbarFilters}
|
|
187
|
-
</div>
|
|
188
|
-
{isFiltered && (
|
|
189
|
-
<SheetFooter className="border-t">
|
|
190
|
-
<Button variant="secondary" onClick={resetUserFilters}>
|
|
191
|
-
<X />
|
|
192
|
-
ล้างตัวกรองทั้งหมด
|
|
193
|
-
</Button>
|
|
194
|
-
</SheetFooter>
|
|
195
|
-
)}
|
|
196
|
-
</SheetContent>
|
|
197
|
-
</Sheet>
|
|
319
|
+
{renderSheet({ side: "right", triggerVariant: "outline", triggerClassName: "xl:hidden" })}
|
|
198
320
|
|
|
199
321
|
{isFiltered && (
|
|
200
322
|
<Button variant="secondary" size="sm" onClick={resetUserFilters}>
|
|
@@ -15,16 +15,27 @@ import { Settings2 } from "lucide-react";
|
|
|
15
15
|
|
|
16
16
|
interface DataTableViewOptionsProps<TData> {
|
|
17
17
|
table: Table<TData>;
|
|
18
|
+
/** ปุ่มไอคอนล้วน (`variant="quiet"`) */
|
|
19
|
+
iconOnly?: boolean;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
export function DataTableViewOptions<TData>({
|
|
22
|
+
export function DataTableViewOptions<TData>({
|
|
23
|
+
table,
|
|
24
|
+
iconOnly = false,
|
|
25
|
+
}: DataTableViewOptionsProps<TData>) {
|
|
21
26
|
return (
|
|
22
27
|
<DropdownMenu>
|
|
23
28
|
<DropdownMenuTrigger
|
|
24
|
-
render={
|
|
29
|
+
render={
|
|
30
|
+
iconOnly ? (
|
|
31
|
+
<Button variant="ghost" size="icon-sm" aria-label="View" className="hidden lg:flex" />
|
|
32
|
+
) : (
|
|
33
|
+
<Button variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex" />
|
|
34
|
+
)
|
|
35
|
+
}
|
|
25
36
|
>
|
|
26
|
-
<Settings2 className="mr-2 h-4 w-4" />
|
|
27
|
-
View
|
|
37
|
+
<Settings2 className={iconOnly ? undefined : "mr-2 h-4 w-4"} />
|
|
38
|
+
{!iconOnly && "View"}
|
|
28
39
|
</DropdownMenuTrigger>
|
|
29
40
|
<DropdownMenuContent align="end" className="w-[150px]">
|
|
30
41
|
<DropdownMenuGroup>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { SelectionBar } from "@suphark/ui/components/shared/selection-bar";
|
|
3
4
|
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
4
5
|
import {
|
|
5
6
|
TableBody,
|
|
@@ -14,7 +15,7 @@ import { flexRender } from "@tanstack/react-table";
|
|
|
14
15
|
import * as React from "react";
|
|
15
16
|
import { DataTablePagination } from "./data-table-pagination";
|
|
16
17
|
import { DataTableToolbar } from "./data-table-toolbar";
|
|
17
|
-
import type { DataTableProps } from "./types";
|
|
18
|
+
import type { DataTableMobileSlot, DataTableProps } from "./types";
|
|
18
19
|
import { useDataTable } from "./use-data-table";
|
|
19
20
|
|
|
20
21
|
// ส่วนลากเรียง (dnd-kit) โหลดเฉพาะตารางที่ส่ง onReorder
|
|
@@ -80,8 +81,13 @@ export function DataTable<TData, TValue>({
|
|
|
80
81
|
preservedFilterIds,
|
|
81
82
|
onReorder,
|
|
82
83
|
renderCustomView,
|
|
84
|
+
variant = "default",
|
|
85
|
+
defaultDensity = "comfortable",
|
|
86
|
+
selectionActions,
|
|
83
87
|
}: DataTableProps<TData, TValue>) {
|
|
84
88
|
const text = useUiText();
|
|
89
|
+
const quiet = variant === "quiet";
|
|
90
|
+
const [density, setDensity] = React.useState<"comfortable" | "compact">(defaultDensity);
|
|
85
91
|
const { table } = useDataTable({
|
|
86
92
|
columns,
|
|
87
93
|
data,
|
|
@@ -231,10 +237,21 @@ export function DataTable<TData, TValue>({
|
|
|
231
237
|
</UiTable>
|
|
232
238
|
);
|
|
233
239
|
|
|
240
|
+
const hasMobileSlots = table
|
|
241
|
+
.getAllLeafColumns()
|
|
242
|
+
.some((column) => (column.columnDef.meta as { mobile?: string } | undefined)?.mobile);
|
|
243
|
+
const mobileList =
|
|
244
|
+
quiet && hasMobileSlots && !isLoading && table.getRowModel().rows.length > 0 ? (
|
|
245
|
+
<QuietMobileList table={table} />
|
|
246
|
+
) : null;
|
|
247
|
+
|
|
234
248
|
const customView = renderCustomView?.(table);
|
|
235
249
|
|
|
236
250
|
return (
|
|
237
|
-
<div
|
|
251
|
+
<div
|
|
252
|
+
data-variant={quiet ? "quiet" : undefined}
|
|
253
|
+
className={cn("flex flex-col gap-4", containerClassName)}
|
|
254
|
+
>
|
|
238
255
|
{showToolbar && (
|
|
239
256
|
<DataTableToolbar
|
|
240
257
|
table={table}
|
|
@@ -243,6 +260,9 @@ export function DataTable<TData, TValue>({
|
|
|
243
260
|
globalFilterPlaceholder={globalFilterPlaceholder}
|
|
244
261
|
toolbarFilters={toolbarFilters?.(table)}
|
|
245
262
|
preservedFilterIds={preservedFilterIds}
|
|
263
|
+
variant={variant}
|
|
264
|
+
density={density}
|
|
265
|
+
onDensityChange={setDensity}
|
|
246
266
|
/>
|
|
247
267
|
)}
|
|
248
268
|
{customView ? (
|
|
@@ -253,12 +273,21 @@ export function DataTable<TData, TValue>({
|
|
|
253
273
|
table={table}
|
|
254
274
|
pageSizeOptions={pageSizeOptions}
|
|
255
275
|
onPageSizeChange={onPageSizeChange}
|
|
276
|
+
variant={variant}
|
|
256
277
|
/>
|
|
257
278
|
)}
|
|
258
279
|
</>
|
|
259
280
|
) : (
|
|
260
281
|
<>
|
|
261
|
-
|
|
282
|
+
{quiet && mobileList}
|
|
283
|
+
<div
|
|
284
|
+
data-density={quiet ? density : undefined}
|
|
285
|
+
className={cn(
|
|
286
|
+
"overflow-x-auto rounded-lg border bg-card shadow-sm max-sm:-mx-2 max-sm:rounded-none max-sm:border-x-0",
|
|
287
|
+
quiet && QUIET_TABLE,
|
|
288
|
+
quiet && mobileList && "max-sm:hidden",
|
|
289
|
+
)}
|
|
290
|
+
>
|
|
262
291
|
{onReorder ? (
|
|
263
292
|
// ระหว่างโหลดส่วนลากเรียง แสดงตารางปกติไปก่อน (ลากไม่ได้ชั่วคราว)
|
|
264
293
|
<React.Suspense fallback={renderTable(false)}>
|
|
@@ -287,11 +316,132 @@ export function DataTable<TData, TValue>({
|
|
|
287
316
|
table={table}
|
|
288
317
|
pageSizeOptions={pageSizeOptions}
|
|
289
318
|
onPageSizeChange={onPageSizeChange}
|
|
319
|
+
variant={variant}
|
|
290
320
|
/>
|
|
291
321
|
</div>
|
|
292
322
|
)}
|
|
293
323
|
</>
|
|
294
324
|
)}
|
|
325
|
+
{quiet &&
|
|
326
|
+
enableRowSelection !== false &&
|
|
327
|
+
table.getFilteredSelectedRowModel().rows.length > 0 && (
|
|
328
|
+
<SelectionBar
|
|
329
|
+
variant="pill"
|
|
330
|
+
count={table.getFilteredSelectedRowModel().rows.length}
|
|
331
|
+
onClear={() => table.resetRowSelection()}
|
|
332
|
+
>
|
|
333
|
+
{selectionActions?.(table)}
|
|
334
|
+
</SelectionBar>
|
|
335
|
+
)}
|
|
295
336
|
</div>
|
|
296
337
|
);
|
|
297
338
|
}
|
|
339
|
+
|
|
340
|
+
/** `variant="quiet"`: สไตล์ตารางทั้งก้อนผ่านตัวเลือก CSS (ไม่แตะ markup ของแถว/เซลล์ของแอป) */
|
|
341
|
+
const QUIET_TABLE = [
|
|
342
|
+
"rounded-xl shadow-none",
|
|
343
|
+
// หัวตาราง: เรียบ ลูกศรเรียงโผล่เมื่อชี้ (คอลัมน์ที่เรียงอยู่แสดงตลอด)
|
|
344
|
+
"[&_thead]:bg-muted/40 [&_th]:h-9 [&_th]:font-semibold [&_th]:text-label [&_th]:text-muted-foreground",
|
|
345
|
+
"[&_[data-sort-idle]]:opacity-0 [&_th:hover_[data-sort-idle]]:opacity-60",
|
|
346
|
+
// แถว: พื้นตอนชี้ · แถวที่เลือกมีแถบสีซ้าย
|
|
347
|
+
"[&_tbody_tr]:hover:bg-muted/40",
|
|
348
|
+
"[&_tbody_tr[data-state=selected]]:bg-primary/[0.07] [&_tbody_tr[data-state=selected]]:shadow-[inset_3px_0_0_var(--primary)]",
|
|
349
|
+
// ช่องเลือก/ปุ่มท้ายแถว: ซ่อนจนกว่าจะชี้/โฟกัส/เลือก (อุปกรณ์ที่ชี้ไม่ได้เห็นตลอด)
|
|
350
|
+
"[@media(hover:hover)]:[&_tbody_tr:not(:hover):not(:focus-within):not([data-state=selected])_[role=checkbox]]:opacity-0",
|
|
351
|
+
"[@media(hover:hover)]:[&_tbody_tr:not(:hover):not(:focus-within)_[data-slot=data-table-row-actions]]:opacity-0",
|
|
352
|
+
"[&_[role=checkbox]]:transition-opacity [&_[data-slot=data-table-row-actions]]:transition-opacity",
|
|
353
|
+
// ความหนาแน่น
|
|
354
|
+
"data-[density=comfortable]:[&_td]:py-2.5 data-[density=compact]:[&_td]:py-1",
|
|
355
|
+
].join(" ");
|
|
356
|
+
|
|
357
|
+
const SLOT_ORDER: DataTableMobileSlot[] = ["title", "end", "sub", "status", "meta"];
|
|
358
|
+
|
|
359
|
+
/** `variant="quiet"` บนมือถือ: รายการแถวละ 3 บรรทัดจากคอลัมน์ที่ใส่ `meta.mobile` */
|
|
360
|
+
function QuietMobileList<TData>({
|
|
361
|
+
table,
|
|
362
|
+
}: {
|
|
363
|
+
table: import("@tanstack/react-table").Table<TData>;
|
|
364
|
+
}) {
|
|
365
|
+
const slotOf = (column: { columnDef: { meta?: unknown } }) =>
|
|
366
|
+
(column.columnDef.meta as { mobile?: DataTableMobileSlot } | undefined)?.mobile;
|
|
367
|
+
const rows = table.getRowModel().rows;
|
|
368
|
+
return (
|
|
369
|
+
<ul className="divide-y overflow-hidden rounded-xl border bg-card sm:hidden">
|
|
370
|
+
{rows.map((row) => {
|
|
371
|
+
const cells = row.getVisibleCells();
|
|
372
|
+
const pick = (slot: DataTableMobileSlot) =>
|
|
373
|
+
cells.filter((cell) => slotOf(cell.column) === slot);
|
|
374
|
+
const select = cells.find((cell) => cell.column.id === "select");
|
|
375
|
+
return (
|
|
376
|
+
<li
|
|
377
|
+
key={row.id}
|
|
378
|
+
data-state={row.getIsSelected() ? "selected" : undefined}
|
|
379
|
+
className="flex items-start gap-3 px-4 py-2.5 data-[state=selected]:bg-primary/[0.07] data-[state=selected]:shadow-[inset_3px_0_0_var(--primary)]"
|
|
380
|
+
>
|
|
381
|
+
{select && (
|
|
382
|
+
// ขยายพื้นที่แตะเป็น 44px (ช่องติ๊กจริงเล็ก 16px); แตะส่วนที่เกินช่องก็สลับการเลือก
|
|
383
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: คีย์บอร์ดใช้ช่องติ๊กข้างใน
|
|
384
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: เป็นแค่พื้นที่แตะเสริม
|
|
385
|
+
<div
|
|
386
|
+
className="-m-3.5 -mt-3 shrink-0 p-3.5"
|
|
387
|
+
onClick={(event) => {
|
|
388
|
+
if (event.target === event.currentTarget) row.toggleSelected();
|
|
389
|
+
}}
|
|
390
|
+
>
|
|
391
|
+
{flexRender(select.column.columnDef.cell, select.getContext())}
|
|
392
|
+
</div>
|
|
393
|
+
)}
|
|
394
|
+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
395
|
+
{SLOT_ORDER.map((slot) => {
|
|
396
|
+
const found = pick(slot);
|
|
397
|
+
if (slot === "meta" || found.length === 0) return null;
|
|
398
|
+
const content = found.map((cell) => (
|
|
399
|
+
<span key={cell.id}>
|
|
400
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
401
|
+
</span>
|
|
402
|
+
));
|
|
403
|
+
if (slot === "title") {
|
|
404
|
+
const end = pick("end");
|
|
405
|
+
return (
|
|
406
|
+
<div
|
|
407
|
+
key={slot}
|
|
408
|
+
className="flex items-baseline justify-between gap-2 font-semibold text-sm"
|
|
409
|
+
>
|
|
410
|
+
<span className="min-w-0 truncate">{content}</span>
|
|
411
|
+
<span className="shrink-0 tabular-nums">
|
|
412
|
+
{end.map((cell) => (
|
|
413
|
+
<span key={cell.id}>
|
|
414
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
415
|
+
</span>
|
|
416
|
+
))}
|
|
417
|
+
</span>
|
|
418
|
+
</div>
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
if (slot === "end") return null;
|
|
422
|
+
if (slot === "sub") {
|
|
423
|
+
return (
|
|
424
|
+
<div key={slot} className="truncate text-muted-foreground text-sm">
|
|
425
|
+
{content}
|
|
426
|
+
</div>
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
const meta = pick("meta");
|
|
430
|
+
return (
|
|
431
|
+
<div key={slot} className="flex items-center gap-1.5 text-xs">
|
|
432
|
+
{content}
|
|
433
|
+
{meta.map((cell) => (
|
|
434
|
+
<span key={cell.id} className="text-muted-foreground">
|
|
435
|
+
· {flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
436
|
+
</span>
|
|
437
|
+
))}
|
|
438
|
+
</div>
|
|
439
|
+
);
|
|
440
|
+
})}
|
|
441
|
+
</div>
|
|
442
|
+
</li>
|
|
443
|
+
);
|
|
444
|
+
})}
|
|
445
|
+
</ul>
|
|
446
|
+
);
|
|
447
|
+
}
|
|
@@ -5,5 +5,5 @@ export { DataTableFacetedFilter } from "./data-table-faceted-filter";
|
|
|
5
5
|
export { DataTablePagination } from "./data-table-pagination";
|
|
6
6
|
export { DataTableToolbar } from "./data-table-toolbar";
|
|
7
7
|
export { DataTableViewOptions } from "./data-table-view-option";
|
|
8
|
-
export type { DataTableProps, FacetedFilterConfig } from "./types";
|
|
8
|
+
export type { DataTableMobileSlot, DataTableProps, FacetedFilterConfig } from "./types";
|
|
9
9
|
export { useDataTable } from "./use-data-table";
|
|
@@ -107,4 +107,23 @@ export interface DataTableProps<TData, TValue> {
|
|
|
107
107
|
pageSizeOptions?: number[];
|
|
108
108
|
onReorder?: (newData: TData[]) => void;
|
|
109
109
|
renderCustomView?: (table: Table<TData>) => React.ReactNode;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `default` = เหมือนเดิม · `quiet` = "Calm toolbar, dense rows": ตัวกรองรวมเป็นปุ่มเดียว + ชิปที่ลบได้ในบรรทัดเดียวกับช่องค้นหา,
|
|
113
|
+
* ตัวนับ "N จาก M รายการ" + สลับความหนาแน่น, หัวตารางเรียบ (ลูกศรเรียงโผล่เมื่อชี้), ช่องเลือก/ปุ่มท้ายแถวโผล่เมื่อชี้,
|
|
114
|
+
* `StatusBadge` ในตารางเป็นจุดสี, แถวที่เลือกมีแถบสีซ้าย, แถบเลือกหมู่เป็นแคปซูล, เลขหน้าย่อ ·
|
|
115
|
+
* จอ < sm: ถ้ามีคอลัมน์ที่ใส่ `meta.mobile` จะแสดงเป็นรายการแถวละ 3 บรรทัดแทนตาราง (ดู `DataTableMobileSlot`)
|
|
116
|
+
*/
|
|
117
|
+
variant?: "default" | "quiet";
|
|
118
|
+
/** `quiet`: ความหนาแน่นเริ่มต้น (ผู้ใช้สลับได้ที่แถบเครื่องมือ) — ค่าเริ่มต้น comfortable */
|
|
119
|
+
defaultDensity?: "comfortable" | "compact";
|
|
120
|
+
/** `quiet`: ปุ่มคำสั่งในแคปซูลเลือกหมู่ (ส่งออก/ลบ …) — ไม่ส่ง = แคปซูลมีแค่จำนวนที่เลือกและปุ่มล้าง */
|
|
121
|
+
selectionActions?: (table: Table<TData>) => React.ReactNode;
|
|
110
122
|
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `variant="quiet"` บนจอ < sm: วางที่ `columnDef.meta = { mobile: "title" }` — ชื่อบรรทัดแรกซ้าย · `end` ยอด/ตัวเลข บรรทัดแรกขวา ·
|
|
126
|
+
* `sub` บรรทัดรอง · `status` บรรทัดสถานะ (ต่อท้ายด้วย `meta`) · `meta` ข้อความเล็กต่อท้ายสถานะ · `hidden` ไม่แสดงบนมือถือ
|
|
127
|
+
* ไม่ประกาศ slot ใดเลย = แสดงตารางเลื่อนแนวนอนเหมือนเดิม
|
|
128
|
+
*/
|
|
129
|
+
export type DataTableMobileSlot = "title" | "end" | "sub" | "status" | "meta" | "hidden";
|