@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.53.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 (
@@ -108,7 +108,7 @@ export function DataTableRowActions<TData>({
108
108
  };
109
109
 
110
110
  return (
111
- <div className="flex justify-end">
111
+ <div data-slot="data-table-row-actions" className="flex justify-end">
112
112
  <DropdownMenu>
113
113
  <DropdownMenuTrigger
114
114
  render={
@@ -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
- <Sheet>
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>({ table }: DataTableViewOptionsProps<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={<Button variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex" />}
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 className={cn("flex flex-col gap-4", containerClassName)}>
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
- <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">
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";