@suphark/ui 0.57.0 → 0.60.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 CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.60.0] — 2026-10-24
6
+
7
+ ### Added
8
+ - `DataTable variant="quiet"` บนมือถือ (ตาม mockup รอบ 2): **กดค้างที่แถว (500 ms) เข้าโหมดเลือกหมู่** — ช่องเลือก (คอลัมน์ `select`) ซ่อนจากสายตาจนกว่าจะเลือก (คีย์บอร์ด/โปรแกรมอ่านจอยังโฟกัสได้ตลอด), หัว "เลือก N รายการ · เลือกทั้งหมด" (`role="status"`), แตะแถวสลับการเลือก, เอาออกหมด = ออกจากโหมด, สั่นเบา ๆ ถ้าอุปกรณ์รองรับ · บรรทัดบอกใบ้ "กดค้างที่แถวเพื่อเลือกหมู่" แสดงครั้งแรกครั้งเดียว (หายเองใน 6 วินาที จำใน localStorage)
9
+ - `DataTable variant="quiet"` แผ่นตัวกรอง: ตัวกรองแบบรายการติ๊ก (`DataTableFilterSections`) หัวข้อเปิดไว้ แถวสูง 44px มีจำนวนต่อค่า (ใช้ `getFacetedUniqueValues` ของแอปถ้าส่งมา ไม่งั้นนับจากแถวที่ผ่านตัวกรองคอลัมน์อื่น) + ปุ่มล่าง "ล้าง / ดู N รายการ" แทนปุ่ม popover ต่อคอลัมน์
10
+ - `DataTable densityStorageKey` (`quiet`): จำความหนาแน่นที่ผู้ใช้เลือกใน localStorage — ไม่ส่ง = ไม่จำ; localStorage ใช้ไม่ได้ก็ทำงานต่อ
11
+ - `UiText.table.selectedCount` / `selectedPill` / `selectAll` / `longPressHint` / `clearFilters` / `showResults`
12
+
13
+ ### Changed
14
+ - แคปซูลเลือกหมู่ของ `quiet` แสดง "N รายการ" (สั้นลง เพราะหัวรายการมือถือบอก "เลือก N รายการ" แล้ว)
15
+
16
+ ### Fixed
17
+ - `SelectionBar` แบบลอย (`floating`/`pill`): กว้างตามเนื้อหา (`w-max`) — เดิมถูกบีบเหลือครึ่งจอจากการยึด `left-1/2` ทำให้ปุ่มตัดคำและทับกันบนมือถือ · `pill` เป็นบรรทัดเดียวเสมอ ปุ่มที่แอปส่งมาใช้สีตัวอักษรบนพื้นสีหลัก และสูง 44px บนจอสัมผัส · ปุ่มปิดแผ่นตัวกรองสูง 44px บนจอสัมผัส
18
+
19
+ ## [0.59.1] — 2026-10-24
20
+
21
+ ### Fixed
22
+ - `DataTable variant="quiet"` บนจอสัมผัส (`pointer-coarse`): ปุ่ม "ตัวกรอง", ช่องค้นหา, ปุ่มก่อนหน้า/ถัดไป สูง 44px · พื้นที่แตะช่องเลือกของรายการมือถือขยายเป็น 44px (แตะส่วนที่เกินช่องก็สลับการเลือก) · ปุ่มลบชิปตัวกรองมีพื้นที่แตะกว้างขึ้น · วัด contrast ข้อความรอง/สถานะแล้ว ≥ 4.5:1 ทั้งโหมดสว่างและมืด (ไม่ต้องแก้)
23
+
24
+ ## [0.59.0] — 2026-10-24
25
+
26
+ ### Added
27
+ - `DataTable variant="quiet"` (opt-in, ค่าเริ่มต้น `default` เหมือนเดิม — ตาม mockup "Calm toolbar, dense rows"): แถบเครื่องมือบรรทัดเดียว — ช่องค้นหา + ชิปตัวกรองที่ลบได้ + ปุ่ม "ตัวกรอง" ปุ่มเดียว (เปิดแผ่นตัวกรอง: ขวาบนเดสก์ท็อป · ล่างบนมือถือ) + ตัวนับ "N จาก M รายการ" + สลับความหนาแน่น (`defaultDensity`) + ไอคอนคอลัมน์ · หัวตารางเรียบ (ลูกศรเรียงของคอลัมน์ที่ไม่ได้เรียงโผล่เมื่อชี้) · ช่องเลือก (`[role=checkbox]`) และปุ่มท้ายแถว (`DataTableRowActions`) โผล่เมื่อชี้/โฟกัส/เลือก (จอสัมผัสเห็นตลอด) · `StatusBadge` ในตารางเป็นจุดสี · แถวที่เลือกมีแถบสีซ้าย · เลือกแล้วขึ้นแคปซูล `SelectionBar variant="pill"` (`selectionActions` ใส่ปุ่มคำสั่ง) แทนข้อความ "เลือก N จาก M แถว" · เลขหน้าย่อ "1–10 จาก 128 · ‹ 1 / 13 ›"
28
+ - มือถือ (< sm) ของ `variant="quiet"`: คอลัมน์ที่ใส่ `columnDef.meta = { mobile: "title" | "end" | "sub" | "status" | "meta" | "hidden" }` (type `DataTableMobileSlot`) แสดงเป็นรายการแถวละ 3 บรรทัด (ชื่อ + ยอด / รอง / สถานะ · meta) แทนตารางเลื่อนแนวนอน · ไม่ใส่ slot ใดเลย = ตารางเหมือนเดิม · คอลัมน์ `select` แสดงที่ซ้ายของรายการ
29
+ - `DataTablePagination variant="quiet"`, `DataTableViewOptions iconOnly`, `UiText.table.rangeOf` / `shownOf` / `density`
30
+ - `NotificationCenter headerAction` (ขอโดย pi-done): ปุ่มคำสั่งของทั้งกล่อง เช่น "อ่านทั้งหมดแล้ว" ที่หัว ข้างปุ่ม "ดูทั้งหมด" — ไม่ต้องยืม `viewAll`
31
+ - `SwitchField labelClassName` (ขอโดย pi-done): ทับสไตล์ข้อความป้าย (ค่าเริ่มต้น `text-sm`)
32
+
33
+ ### Fixed
34
+ - `useIsMobile` ไม่พังเมื่อไม่มี `window.matchMedia` (jsdom/เบราว์เซอร์เก่า) — ใช้ค่าตอน mount อย่างเดียว (กระทบ `DataTableToolbar` ที่เรียก hook นี้ แอปที่เทสต์ DataTable ใน jsdom ไม่ต้อง mock matchMedia)
35
+ - showcase: `/components/data-table` ตัวอย่างใหม่ (สลับ default/quiet, เลือกแถว, ใบสั่งซื้อสมมติ)
36
+
37
+ ## [0.58.0] — 2026-10-24
38
+
39
+ ### Added
40
+ - `PullToRefresh variant="circle"` (`@suphark/ui/pwa`, ขอโดย pi-done — เจ้าของเลือกแบบวงกลม): วงกลม 40px ไอคอนล้วน (ลูกศร → หมุนตอนรีเฟรช) กลางแนวนอน พักที่ 16px ใต้ขอบบนของ root · เริ่มทำงานที่ 64px สูงสุด 104px แรงต้าน 0.5 · `role="status"` + ข้อความ `sr-only` จาก `text` (ปกติ `PULL_TO_REFRESH_TEXT_*`) · ค่าเริ่มต้นยัง `variant="pill"` (แคปซูลมีข้อความ) เหมือนเดิม
41
+
5
42
  ## [0.57.0] — 2026-10-24
6
43
 
7
44
  ยกจาก pi-done (รอบสองของคำขอย้าย component ที่แอปทำเอง)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.57.0",
3
+ "version": "0.60.0",
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" />
@@ -0,0 +1,108 @@
1
+ "use client";
2
+
3
+ import { Checkbox } from "@suphark/ui/components/ui/checkbox";
4
+ import { cn } from "@suphark/ui/lib/utils";
5
+ import type { Table } from "@tanstack/react-table";
6
+ import { ChevronDown } from "lucide-react";
7
+ import * as React from "react";
8
+ import type { FacetedFilterConfig } from "./types";
9
+
10
+ /**
11
+ * `variant="quiet"`: ตัวกรองแบบรายการติ๊กในแผ่นตัวกรอง — หัวข้อเปิดไว้ตั้งแต่แรก แถวสูง 44px
12
+ * แต่ละค่ามีจำนวนแถวกำกับ (แทนปุ่ม popover ต่อคอลัมน์ของตัวกรองปกติ)
13
+ */
14
+ export function DataTableFilterSections<TData>({
15
+ table,
16
+ filters,
17
+ }: {
18
+ table: Table<TData>;
19
+ filters: FacetedFilterConfig[];
20
+ }) {
21
+ return (
22
+ <div className="flex flex-col">
23
+ {filters.map((filter) => (
24
+ <FilterSection key={filter.columnId} table={table} filter={filter} />
25
+ ))}
26
+ </div>
27
+ );
28
+ }
29
+
30
+ function FilterSection<TData>({
31
+ table,
32
+ filter,
33
+ }: {
34
+ table: Table<TData>;
35
+ filter: FacetedFilterConfig;
36
+ }) {
37
+ const [open, setOpen] = React.useState(true);
38
+ const id = React.useId();
39
+ const column = table.getColumn(filter.columnId);
40
+ if (!column) return null;
41
+ // จำนวนต่อค่า: ใช้ที่แอปส่งมา (`getFacetedUniqueValues`) ถ้ามี ไม่งั้นนับเองจากแถวที่ผ่านตัวกรองคอลัมน์อื่น
42
+ const provided = column.getFacetedUniqueValues();
43
+ const counted = new Map<unknown, number>();
44
+ if (!provided || provided.size === 0) {
45
+ for (const row of column.getFacetedRowModel().rows) {
46
+ const key = String(row.getValue(filter.columnId));
47
+ counted.set(key, (counted.get(key) ?? 0) + 1);
48
+ }
49
+ }
50
+ const facets = provided && provided.size > 0 ? provided : counted;
51
+ const current = column.getFilterValue();
52
+ const selected = new Set(Array.isArray(current) ? current.map(String) : []);
53
+
54
+ const toggle = (value: string) => {
55
+ const next = new Set(selected);
56
+ if (next.has(value)) next.delete(value);
57
+ else next.add(value);
58
+ column.setFilterValue(next.size ? Array.from(next) : undefined);
59
+ };
60
+
61
+ return (
62
+ <section className="border-b last:border-b-0">
63
+ <button
64
+ type="button"
65
+ aria-expanded={open}
66
+ aria-controls={id}
67
+ onClick={() => setOpen((value) => !value)}
68
+ className="flex h-11 w-full items-center justify-between text-muted-foreground text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
69
+ >
70
+ <span>
71
+ {filter.title}
72
+ {selected.size > 0 && (
73
+ <span className="ms-1.5 font-semibold text-foreground tabular-nums">
74
+ {selected.size}
75
+ </span>
76
+ )}
77
+ </span>
78
+ <ChevronDown
79
+ aria-hidden="true"
80
+ className={cn("size-4 transition-transform", open && "rotate-180")}
81
+ />
82
+ </button>
83
+ <div id={id} hidden={!open}>
84
+ {filter.options.map((option) => {
85
+ const value = String(option.value);
86
+ const count = facets?.get(option.value) ?? facets?.get(value);
87
+ return (
88
+ <label
89
+ key={value}
90
+ className="flex h-11 cursor-pointer items-center gap-3 border-t text-sm first:border-t-0"
91
+ >
92
+ <Checkbox
93
+ aria-label={option.label}
94
+ checked={selected.has(value)}
95
+ onCheckedChange={() => toggle(value)}
96
+ />
97
+ {option.icon && <option.icon className="size-4 text-muted-foreground" />}
98
+ <span className="min-w-0 flex-1 truncate">{option.label}</span>
99
+ {count !== undefined && (
100
+ <span className="text-muted-foreground text-xs tabular-nums">{count}</span>
101
+ )}
102
+ </label>
103
+ );
104
+ })}
105
+ </div>
106
+ </section>
107
+ );
108
+ }
@@ -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,12 +19,14 @@ 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 ".";
27
28
  import { DataTableFacetedFilter } from "./data-table-faceted-filter";
29
+ import { DataTableFilterSections } from "./data-table-filter-sections";
28
30
  import { DataTableViewOptions } from "./data-table-view-option";
29
31
 
30
32
  interface DataTableToolbarProps<TData> {
@@ -34,6 +36,10 @@ interface DataTableToolbarProps<TData> {
34
36
  globalFilterPlaceholder?: string;
35
37
  toolbarFilters?: React.ReactNode;
36
38
  preservedFilterIds?: string[];
39
+ /** `quiet`: ตัวกรองปุ่มเดียว + ชิป + ตัวนับ + สลับความหนาแน่นในบรรทัดเดียว (ดู `DataTableProps.variant`) */
40
+ variant?: "default" | "quiet";
41
+ density?: "comfortable" | "compact";
42
+ onDensityChange?: (density: "comfortable" | "compact") => void;
37
43
  }
38
44
 
39
45
  export function DataTableToolbar<TData>({
@@ -43,8 +49,12 @@ export function DataTableToolbar<TData>({
43
49
  globalFilterPlaceholder,
44
50
  toolbarFilters,
45
51
  preservedFilterIds = [],
52
+ variant = "default",
53
+ density = "comfortable",
54
+ onDensityChange,
46
55
  }: DataTableToolbarProps<TData>) {
47
56
  const text = useUiText();
57
+ const isMobile = useIsMobile();
48
58
  const globalFilter = table.getState().globalFilter;
49
59
  // ค้นหา: เก็บข้อความในช่องเอง แล้วส่งให้ตารางเมื่อหยุดพิมพ์ 250 ms — เดิมกรอง/เรียง/render ทุกแถวทุกตัวอักษร
50
60
  // (และถ้าแอปผูก globalFilter กับ URL = router.replace ทุกตัวอักษร)
@@ -114,6 +124,185 @@ export function DataTableToolbar<TData>({
114
124
  );
115
125
  });
116
126
 
127
+ const renderSheet = ({
128
+ side,
129
+ triggerVariant,
130
+ triggerClassName,
131
+ }: {
132
+ side: "right" | "bottom";
133
+ triggerVariant: "outline" | "ghost";
134
+ triggerClassName?: string;
135
+ }) => (
136
+ <Sheet>
137
+ <SheetTrigger
138
+ render={<Button variant={triggerVariant} size="sm" className={triggerClassName} />}
139
+ >
140
+ <SlidersHorizontal />
141
+ ตัวกรอง
142
+ {activeFilterCount > 0 && (
143
+ <Badge variant="secondary" className="ml-1 px-1.5 tabular-nums">
144
+ {activeFilterCount}
145
+ </Badge>
146
+ )}
147
+ </SheetTrigger>
148
+ <SheetContent
149
+ side={side}
150
+ showCloseButton={false}
151
+ className={
152
+ side === "bottom"
153
+ ? "max-h-[85dvh] rounded-t-2xl"
154
+ : "w-[min(24rem,calc(100vw-1rem))] sm:max-w-md"
155
+ }
156
+ >
157
+ <SheetHeader className="border-b pr-12">
158
+ <SheetTitle>{text.table?.filters ?? "ตัวกรองข้อมูล"}</SheetTitle>
159
+ <SheetDescription>
160
+ {text.table?.filtersDescription ?? "เลือกได้หลายเงื่อนไข ผลลัพธ์จะอัปเดตทันที"}
161
+ </SheetDescription>
162
+ <SheetClose
163
+ render={
164
+ <Button
165
+ variant="ghost"
166
+ size="icon-sm"
167
+ className="absolute top-3 right-3 pointer-coarse:h-11 pointer-coarse:w-11"
168
+ />
169
+ }
170
+ >
171
+ <X />
172
+ <span className="sr-only">{text.table?.closeFilters ?? "ปิดตัวกรอง"}</span>
173
+ </SheetClose>
174
+ </SheetHeader>
175
+ <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4">
176
+ {variant === "quiet" && facetedFilters ? (
177
+ <DataTableFilterSections table={table} filters={facetedFilters} />
178
+ ) : (
179
+ renderFilterControls(true)
180
+ )}
181
+ {toolbarFilters}
182
+ </div>
183
+ {variant === "quiet" ? (
184
+ <SheetFooter className="grid grid-cols-2 gap-2 border-t">
185
+ <Button variant="outline" className="pointer-coarse:h-11" onClick={resetUserFilters}>
186
+ {text.table?.clearFilters ?? "ล้าง"}
187
+ </Button>
188
+ <SheetClose render={<Button className="pointer-coarse:h-11" />}>
189
+ {(text.table?.showResults ?? "ดู {count} รายการ").replace(
190
+ "{count}",
191
+ String(table.getFilteredRowModel().rows.length),
192
+ )}
193
+ </SheetClose>
194
+ </SheetFooter>
195
+ ) : (
196
+ isFiltered && (
197
+ <SheetFooter className="border-t">
198
+ <Button variant="secondary" onClick={resetUserFilters}>
199
+ <X />
200
+ ล้างตัวกรองทั้งหมด
201
+ </Button>
202
+ </SheetFooter>
203
+ )
204
+ )}
205
+ </SheetContent>
206
+ </Sheet>
207
+ );
208
+
209
+ if (variant === "quiet") {
210
+ const total = table.getCoreRowModel().rows.length;
211
+ const shown = table.getFilteredRowModel().rows.length;
212
+ return (
213
+ <div className="flex flex-wrap items-center gap-2">
214
+ {globalFilterPlaceholder && (
215
+ <InputGroup className="h-8 w-full pointer-coarse:h-11 sm:w-[220px]">
216
+ <InputGroupAddon align="inline-start">
217
+ <Search aria-hidden="true" className="size-4" />
218
+ </InputGroupAddon>
219
+ <InputGroupInput
220
+ aria-label={text.table?.search ?? "ค้นหาข้อมูลในตาราง"}
221
+ name="table-search"
222
+ autoComplete="off"
223
+ placeholder={globalFilterPlaceholder}
224
+ value={searchDraft}
225
+ onChange={(event) => setSearchDraft(event.target.value)}
226
+ onKeyDown={(event) => {
227
+ if (event.key === "Enter") commitSearch(searchDraft);
228
+ }}
229
+ />
230
+ {!!searchDraft && (
231
+ <InputGroupAddon align="inline-end">
232
+ <InputGroupButton
233
+ variant="ghost"
234
+ aria-label={text.table?.clearSearch ?? "ล้างคำค้นหา"}
235
+ onClick={() => commitSearch("")}
236
+ >
237
+ <X aria-hidden="true" className="size-4" />
238
+ </InputGroupButton>
239
+ </InputGroupAddon>
240
+ )}
241
+ </InputGroup>
242
+ )}
243
+ {userColumnFilters.map((filter) => {
244
+ const config = facetedFilters?.find((f) => f.columnId === filter.id);
245
+ if (!config) return null;
246
+ const values = Array.isArray(filter.value) ? filter.value : [filter.value];
247
+ const labels = values
248
+ .map((val) => {
249
+ const option = config.options.find((opt) => String(opt.value) === String(val));
250
+ return option ? option.label : String(val);
251
+ })
252
+ .join(", ");
253
+ const displayed =
254
+ config.compact && values.length > 1 ? `${values.length} รายการ` : labels;
255
+ return (
256
+ <span
257
+ key={filter.id}
258
+ className="inline-flex h-7 items-center gap-1.5 rounded-full border bg-muted ps-2.5 pe-1 text-xs"
259
+ >
260
+ <span className="text-muted-foreground">{config.title}</span>
261
+ <b className="font-semibold">{displayed}</b>
262
+ <button
263
+ type="button"
264
+ aria-label={`${(text.table?.clearFilter ?? "ล้างตัวกรอง {name}").replace(
265
+ "{name}",
266
+ config.title,
267
+ )}: ${displayed}`}
268
+ onClick={() => table.getColumn(filter.id)?.setFilterValue(undefined)}
269
+ 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"
270
+ >
271
+ <X className="size-3" aria-hidden="true" />
272
+ </button>
273
+ </span>
274
+ );
275
+ })}
276
+ {renderSheet({
277
+ side: isMobile ? "bottom" : "right",
278
+ triggerVariant: "ghost",
279
+ triggerClassName: "text-muted-foreground pointer-coarse:h-11",
280
+ })}
281
+ <div className="ml-auto flex items-center gap-1">
282
+ <span className="me-1 text-muted-foreground text-xs tabular-nums">
283
+ {(text.table?.shownOf ?? "{shown} จาก {total} รายการ")
284
+ .replace("{shown}", String(shown))
285
+ .replace("{total}", String(total))}
286
+ </span>
287
+ {actionsComponent}
288
+ {onDensityChange && (
289
+ <Button
290
+ variant="ghost"
291
+ size="icon-sm"
292
+ aria-label={text.table?.density ?? "ความหนาแน่นของแถว"}
293
+ aria-pressed={density === "compact"}
294
+ onClick={() => onDensityChange(density === "compact" ? "comfortable" : "compact")}
295
+ className="max-sm:hidden"
296
+ >
297
+ {density === "compact" ? <Rows4 /> : <Rows3 />}
298
+ </Button>
299
+ )}
300
+ <DataTableViewOptions table={table} iconOnly />
301
+ </div>
302
+ </div>
303
+ );
304
+ }
305
+
117
306
  return (
118
307
  <div className="flex flex-wrap items-center gap-2">
119
308
  <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
@@ -152,49 +341,7 @@ export function DataTableToolbar<TData>({
152
341
  {toolbarFilters}
153
342
  </div>
154
343
 
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>
344
+ {renderSheet({ side: "right", triggerVariant: "outline", triggerClassName: "xl:hidden" })}
198
345
 
199
346
  {isFiltered && (
200
347
  <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,7 @@
1
1
  "use client";
2
2
 
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
+ import { SelectionBar } from "@suphark/ui/components/shared/selection-bar";
3
5
  import { useUiText } from "@suphark/ui/components/shared/ui-text";
4
6
  import {
5
7
  TableBody,
@@ -14,7 +16,7 @@ import { flexRender } from "@tanstack/react-table";
14
16
  import * as React from "react";
15
17
  import { DataTablePagination } from "./data-table-pagination";
16
18
  import { DataTableToolbar } from "./data-table-toolbar";
17
- import type { DataTableProps } from "./types";
19
+ import type { DataTableMobileSlot, DataTableProps } from "./types";
18
20
  import { useDataTable } from "./use-data-table";
19
21
 
20
22
  // ส่วนลากเรียง (dnd-kit) โหลดเฉพาะตารางที่ส่ง onReorder
@@ -80,8 +82,29 @@ export function DataTable<TData, TValue>({
80
82
  preservedFilterIds,
81
83
  onReorder,
82
84
  renderCustomView,
85
+ variant = "default",
86
+ defaultDensity = "comfortable",
87
+ selectionActions,
88
+ densityStorageKey,
83
89
  }: DataTableProps<TData, TValue>) {
84
90
  const text = useUiText();
91
+ const quiet = variant === "quiet";
92
+ const [density, setDensityState] = React.useState<"comfortable" | "compact">(defaultDensity);
93
+ // จำความหนาแน่น: อ่านหลัง mount (กัน hydration mismatch) — localStorage ใช้ไม่ได้ก็ข้าม
94
+ React.useEffect(() => {
95
+ if (!densityStorageKey) return;
96
+ try {
97
+ const saved = localStorage.getItem(densityStorageKey);
98
+ if (saved === "compact" || saved === "comfortable") setDensityState(saved);
99
+ } catch {}
100
+ }, [densityStorageKey]);
101
+ const setDensity = (next: "comfortable" | "compact") => {
102
+ setDensityState(next);
103
+ if (!densityStorageKey) return;
104
+ try {
105
+ localStorage.setItem(densityStorageKey, next);
106
+ } catch {}
107
+ };
85
108
  const { table } = useDataTable({
86
109
  columns,
87
110
  data,
@@ -231,10 +254,21 @@ export function DataTable<TData, TValue>({
231
254
  </UiTable>
232
255
  );
233
256
 
257
+ const hasMobileSlots = table
258
+ .getAllLeafColumns()
259
+ .some((column) => (column.columnDef.meta as { mobile?: string } | undefined)?.mobile);
260
+ const mobileList =
261
+ quiet && hasMobileSlots && !isLoading && table.getRowModel().rows.length > 0 ? (
262
+ <QuietMobileList table={table} />
263
+ ) : null;
264
+
234
265
  const customView = renderCustomView?.(table);
235
266
 
236
267
  return (
237
- <div className={cn("flex flex-col gap-4", containerClassName)}>
268
+ <div
269
+ data-variant={quiet ? "quiet" : undefined}
270
+ className={cn("flex flex-col gap-4", containerClassName)}
271
+ >
238
272
  {showToolbar && (
239
273
  <DataTableToolbar
240
274
  table={table}
@@ -243,6 +277,9 @@ export function DataTable<TData, TValue>({
243
277
  globalFilterPlaceholder={globalFilterPlaceholder}
244
278
  toolbarFilters={toolbarFilters?.(table)}
245
279
  preservedFilterIds={preservedFilterIds}
280
+ variant={variant}
281
+ density={density}
282
+ onDensityChange={setDensity}
246
283
  />
247
284
  )}
248
285
  {customView ? (
@@ -253,12 +290,21 @@ export function DataTable<TData, TValue>({
253
290
  table={table}
254
291
  pageSizeOptions={pageSizeOptions}
255
292
  onPageSizeChange={onPageSizeChange}
293
+ variant={variant}
256
294
  />
257
295
  )}
258
296
  </>
259
297
  ) : (
260
298
  <>
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">
299
+ {quiet && mobileList}
300
+ <div
301
+ data-density={quiet ? density : undefined}
302
+ className={cn(
303
+ "overflow-x-auto rounded-lg border bg-card shadow-sm max-sm:-mx-2 max-sm:rounded-none max-sm:border-x-0",
304
+ quiet && QUIET_TABLE,
305
+ quiet && mobileList && "max-sm:hidden",
306
+ )}
307
+ >
262
308
  {onReorder ? (
263
309
  // ระหว่างโหลดส่วนลากเรียง แสดงตารางปกติไปก่อน (ลากไม่ได้ชั่วคราว)
264
310
  <React.Suspense fallback={renderTable(false)}>
@@ -287,11 +333,274 @@ export function DataTable<TData, TValue>({
287
333
  table={table}
288
334
  pageSizeOptions={pageSizeOptions}
289
335
  onPageSizeChange={onPageSizeChange}
336
+ variant={variant}
290
337
  />
291
338
  </div>
292
339
  )}
293
340
  </>
294
341
  )}
342
+ {quiet &&
343
+ enableRowSelection !== false &&
344
+ table.getFilteredSelectedRowModel().rows.length > 0 && (
345
+ <SelectionBar
346
+ variant="pill"
347
+ label={text.table?.selectedPill ?? "{count} รายการ"}
348
+ count={table.getFilteredSelectedRowModel().rows.length}
349
+ onClear={() => table.resetRowSelection()}
350
+ >
351
+ {selectionActions?.(table)}
352
+ </SelectionBar>
353
+ )}
354
+ </div>
355
+ );
356
+ }
357
+
358
+ /** `variant="quiet"`: สไตล์ตารางทั้งก้อนผ่านตัวเลือก CSS (ไม่แตะ markup ของแถว/เซลล์ของแอป) */
359
+ const QUIET_TABLE = [
360
+ "rounded-xl shadow-none",
361
+ // หัวตาราง: เรียบ ลูกศรเรียงโผล่เมื่อชี้ (คอลัมน์ที่เรียงอยู่แสดงตลอด)
362
+ "[&_thead]:bg-muted/40 [&_th]:h-9 [&_th]:font-semibold [&_th]:text-label [&_th]:text-muted-foreground",
363
+ "[&_[data-sort-idle]]:opacity-0 [&_th:hover_[data-sort-idle]]:opacity-60",
364
+ // แถว: พื้นตอนชี้ · แถวที่เลือกมีแถบสีซ้าย
365
+ "[&_tbody_tr]:hover:bg-muted/40",
366
+ "[&_tbody_tr[data-state=selected]]:bg-primary/[0.07] [&_tbody_tr[data-state=selected]]:shadow-[inset_3px_0_0_var(--primary)]",
367
+ // ช่องเลือก/ปุ่มท้ายแถว: ซ่อนจนกว่าจะชี้/โฟกัส/เลือก (อุปกรณ์ที่ชี้ไม่ได้เห็นตลอด)
368
+ "[@media(hover:hover)]:[&_tbody_tr:not(:hover):not(:focus-within):not([data-state=selected])_[role=checkbox]]:opacity-0",
369
+ "[@media(hover:hover)]:[&_tbody_tr:not(:hover):not(:focus-within)_[data-slot=data-table-row-actions]]:opacity-0",
370
+ "[&_[role=checkbox]]:transition-opacity [&_[data-slot=data-table-row-actions]]:transition-opacity",
371
+ // ความหนาแน่น
372
+ "data-[density=comfortable]:[&_td]:py-2.5 data-[density=compact]:[&_td]:py-1",
373
+ ].join(" ");
374
+
375
+ const SLOT_ORDER: DataTableMobileSlot[] = ["title", "end", "sub", "status", "meta"];
376
+
377
+ const LONG_PRESS_MS = 500;
378
+ const HINT_KEY = "ui:data-table:long-press-hint";
379
+
380
+ /** กดค้าง (≥ 500 ms โดยไม่ขยับเกิน 8 px) → เรียก `onLongPress`; `fired` บอกว่า click ถัดไปคือแรงที่ตามหลังการกดค้าง */
381
+ function useLongPress(onLongPress: () => void, enabled: boolean) {
382
+ const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
383
+ const origin = React.useRef<{ x: number; y: number } | null>(null);
384
+ const fired = React.useRef(false);
385
+ const cancel = () => {
386
+ if (timer.current) clearTimeout(timer.current);
387
+ timer.current = null;
388
+ };
389
+ React.useEffect(() => cancel, []);
390
+ const handlers = {
391
+ onPointerDown: (event: React.PointerEvent) => {
392
+ // ล้างธงของการกดค้างรอบก่อนทุกครั้งที่เริ่มแตะใหม่ (แม้ปิดการกดค้างอยู่)
393
+ fired.current = false;
394
+ cancel();
395
+ if (!enabled) return;
396
+ origin.current = { x: event.clientX, y: event.clientY };
397
+ timer.current = setTimeout(() => {
398
+ timer.current = null;
399
+ fired.current = true;
400
+ onLongPress();
401
+ }, LONG_PRESS_MS);
402
+ },
403
+ onPointerMove: (event: React.PointerEvent) => {
404
+ const start = origin.current;
405
+ if (start && Math.hypot(event.clientX - start.x, event.clientY - start.y) > 8) cancel();
406
+ },
407
+ onPointerUp: cancel,
408
+ onPointerCancel: cancel,
409
+ onPointerLeave: cancel,
410
+ // เมนูบริบทของเบราว์เซอร์มือถือเด้งตอนกดค้าง — กันไว้เฉพาะช่วงที่เพิ่งกดค้างสำเร็จ
411
+ onContextMenu: (event: React.MouseEvent) => {
412
+ if (fired.current) event.preventDefault();
413
+ },
414
+ };
415
+ return { fired, handlers };
416
+ }
417
+
418
+ /**
419
+ * `variant="quiet"` บนมือถือ: รายการแถวละ 3 บรรทัดจากคอลัมน์ที่ใส่ `meta.mobile`
420
+ * ช่องเลือก (คอลัมน์ `select`) ซ่อนจากสายตาจนกว่าจะเลือก — กดค้างที่แถวเพื่อเข้าโหมดเลือกหมู่,
421
+ * แตะแถวเพื่อสลับ · คีย์บอร์ด/โปรแกรมอ่านจอยังเข้าถึงช่องเลือกได้ตลอด
422
+ */
423
+ function QuietMobileList<TData>({
424
+ table,
425
+ }: {
426
+ table: import("@tanstack/react-table").Table<TData>;
427
+ }) {
428
+ const text = useUiText();
429
+ const slotOf = (column: { columnDef: { meta?: unknown } }) =>
430
+ (column.columnDef.meta as { mobile?: DataTableMobileSlot } | undefined)?.mobile;
431
+ const rows = table.getRowModel().rows;
432
+ const selectedCount = table.getFilteredSelectedRowModel().rows.length;
433
+ const selecting = selectedCount > 0;
434
+ const hasSelect = table.getAllLeafColumns().some((column) => column.id === "select");
435
+
436
+ // บรรทัดบอกใบ้: แสดงครั้งแรกครั้งเดียว (หายเองใน 6 วินาที หรือเมื่อกดค้างสำเร็จ) แล้วจำไว้
437
+ const [hint, setHint] = React.useState(false);
438
+ const markHintSeen = React.useCallback(() => {
439
+ setHint(false);
440
+ try {
441
+ localStorage.setItem(HINT_KEY, "1");
442
+ } catch {}
443
+ }, []);
444
+ React.useEffect(() => {
445
+ if (!hasSelect) return;
446
+ try {
447
+ if (localStorage.getItem(HINT_KEY)) return;
448
+ } catch {}
449
+ setHint(true);
450
+ const id = setTimeout(markHintSeen, 6000);
451
+ return () => clearTimeout(id);
452
+ }, [hasSelect, markHintSeen]);
453
+
454
+ return (
455
+ <div className="flex flex-col gap-2 sm:hidden">
456
+ {selecting && (
457
+ <div
458
+ role="status"
459
+ aria-live="polite"
460
+ className="flex h-11 items-center justify-between px-1 text-sm"
461
+ >
462
+ <span className="font-semibold">
463
+ {(text.table?.selectedCount ?? "เลือก {count} รายการ").replace(
464
+ "{count}",
465
+ String(selectedCount),
466
+ )}
467
+ </span>
468
+ <Button
469
+ variant="ghost"
470
+ size="sm"
471
+ className="text-primary pointer-coarse:h-11"
472
+ onClick={() => table.toggleAllPageRowsSelected(true)}
473
+ >
474
+ {text.table?.selectAll ?? "เลือกทั้งหมด"}
475
+ </Button>
476
+ </div>
477
+ )}
478
+ <ul className="divide-y overflow-hidden rounded-xl border bg-card">
479
+ {rows.map((row) => (
480
+ <QuietMobileRow
481
+ key={row.id}
482
+ row={row}
483
+ selecting={selecting}
484
+ canLongPress={hasSelect && row.getCanSelect()}
485
+ slotOf={slotOf}
486
+ onLongPress={() => {
487
+ row.toggleSelected(true);
488
+ markHintSeen();
489
+ try {
490
+ navigator.vibrate?.(10);
491
+ } catch {}
492
+ }}
493
+ />
494
+ ))}
495
+ </ul>
496
+ {!selecting && hint && (
497
+ <p className="text-center text-muted-foreground text-xs">
498
+ {text.table?.longPressHint ?? "กดค้างที่แถวเพื่อเลือกหมู่"}
499
+ </p>
500
+ )}
295
501
  </div>
296
502
  );
297
503
  }
504
+
505
+ function QuietMobileRow<TData>({
506
+ row,
507
+ selecting,
508
+ canLongPress,
509
+ slotOf,
510
+ onLongPress,
511
+ }: {
512
+ row: import("@tanstack/react-table").Row<TData>;
513
+ selecting: boolean;
514
+ canLongPress: boolean;
515
+ slotOf: (column: { columnDef: { meta?: unknown } }) => DataTableMobileSlot | undefined;
516
+ onLongPress: () => void;
517
+ }) {
518
+ const { fired, handlers } = useLongPress(onLongPress, canLongPress && !selecting);
519
+ const cells = row.getVisibleCells();
520
+ const pick = (slot: DataTableMobileSlot) => cells.filter((cell) => slotOf(cell.column) === slot);
521
+ const select = cells.find((cell) => cell.column.id === "select");
522
+ return (
523
+ <li
524
+ data-state={row.getIsSelected() ? "selected" : undefined}
525
+ {...handlers}
526
+ onClick={(event) => {
527
+ // click ที่ตามหลังการกดค้างสำเร็จ ไม่นับเป็นการแตะ (ไม่งั้นเลือกแล้วถูกยกเลิกทันที)
528
+ if (fired.current) {
529
+ fired.current = false;
530
+ return;
531
+ }
532
+ if (!selecting || !canLongPress) return;
533
+ if ((event.target as HTMLElement).closest("a,button,input,[role=checkbox]")) return;
534
+ row.toggleSelected();
535
+ }}
536
+ className={cn(
537
+ "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)]",
538
+ selecting && "cursor-pointer",
539
+ )}
540
+ >
541
+ {select && (
542
+ // ขยายพื้นที่แตะเป็น 44px (ช่องติ๊กจริงเล็ก 16px); แตะส่วนที่เกินช่องก็สลับการเลือก
543
+ // ยังไม่เลือก = ซ่อนจากสายตาแต่ยังโฟกัสด้วยคีย์บอร์ดได้ (sr-only + แสดงตอนโฟกัส)
544
+ // biome-ignore lint/a11y/useKeyWithClickEvents: คีย์บอร์ดใช้ช่องติ๊กข้างใน
545
+ // biome-ignore lint/a11y/noStaticElementInteractions: เป็นแค่พื้นที่แตะเสริม
546
+ <div
547
+ className={cn(
548
+ "-m-3.5 -mt-3 shrink-0 p-3.5",
549
+ !selecting && "sr-only focus-within:not-sr-only",
550
+ )}
551
+ onClick={(event) => {
552
+ if (event.target === event.currentTarget) row.toggleSelected();
553
+ }}
554
+ >
555
+ {flexRender(select.column.columnDef.cell, select.getContext())}
556
+ </div>
557
+ )}
558
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
559
+ {SLOT_ORDER.map((slot) => {
560
+ const found = pick(slot);
561
+ if (slot === "meta" || found.length === 0) return null;
562
+ const content = found.map((cell) => (
563
+ <span key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</span>
564
+ ));
565
+ if (slot === "title") {
566
+ const end = pick("end");
567
+ return (
568
+ <div
569
+ key={slot}
570
+ className="flex items-baseline justify-between gap-2 font-semibold text-sm"
571
+ >
572
+ <span className="min-w-0 truncate">{content}</span>
573
+ <span className="shrink-0 tabular-nums">
574
+ {end.map((cell) => (
575
+ <span key={cell.id}>
576
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
577
+ </span>
578
+ ))}
579
+ </span>
580
+ </div>
581
+ );
582
+ }
583
+ if (slot === "end") return null;
584
+ if (slot === "sub") {
585
+ return (
586
+ <div key={slot} className="truncate text-muted-foreground text-sm">
587
+ {content}
588
+ </div>
589
+ );
590
+ }
591
+ const meta = pick("meta");
592
+ return (
593
+ <div key={slot} className="flex items-center gap-1.5 text-xs">
594
+ {content}
595
+ {meta.map((cell) => (
596
+ <span key={cell.id} className="text-muted-foreground">
597
+ · {flexRender(cell.column.columnDef.cell, cell.getContext())}
598
+ </span>
599
+ ))}
600
+ </div>
601
+ );
602
+ })}
603
+ </div>
604
+ </li>
605
+ );
606
+ }
@@ -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,28 @@ 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
+ /**
121
+ * `quiet`: จำความหนาแน่นที่ผู้ใช้เลือกไว้ใน localStorage ด้วยคีย์นี้ (เช่น `"po-table-density"`) —
122
+ * ไม่ส่ง = ไม่จำ (กลับเป็น `defaultDensity` ทุกครั้ง) · localStorage ใช้ไม่ได้ก็ทำงานต่อโดยไม่จำ
123
+ */
124
+ densityStorageKey?: string;
125
+ /** `quiet`: ปุ่มคำสั่งในแคปซูลเลือกหมู่ (ส่งออก/ลบ …) — ไม่ส่ง = แคปซูลมีแค่จำนวนที่เลือกและปุ่มล้าง */
126
+ selectionActions?: (table: Table<TData>) => React.ReactNode;
110
127
  }
128
+
129
+ /**
130
+ * `variant="quiet"` บนจอ < sm: วางที่ `columnDef.meta = { mobile: "title" }` — ชื่อบรรทัดแรกซ้าย · `end` ยอด/ตัวเลข บรรทัดแรกขวา ·
131
+ * `sub` บรรทัดรอง · `status` บรรทัดสถานะ (ต่อท้ายด้วย `meta`) · `meta` ข้อความเล็กต่อท้ายสถานะ · `hidden` ไม่แสดงบนมือถือ
132
+ * ไม่ประกาศ slot ใดเลย = แสดงตารางเลื่อนแนวนอนเหมือนเดิม
133
+ */
134
+ export type DataTableMobileSlot = "title" | "end" | "sub" | "status" | "meta" | "hidden";
@@ -1,12 +1,18 @@
1
1
  "use client";
2
2
 
3
3
  import { cn } from "@suphark/ui/lib/utils";
4
- import { Loader2, RefreshCw } from "lucide-react";
4
+ import { ArrowDown, Loader2, RefreshCw } from "lucide-react";
5
5
  import { useRouter } from "next/navigation";
6
6
  import { useEffect, useRef, useState } from "react";
7
7
 
8
- const ACTIVATION_DISTANCE = 72;
9
- const MAX_PULL_DISTANCE = 112;
8
+ /** ระยะเริ่มทำงาน / สูงสุด / แรงต้าน (px) ต่อแบบ — circle ดึงสั้นกว่า หนักมือกว่า */
9
+ const DIMS = {
10
+ pill: { activation: 72, max: 112, resistance: 0.62 },
11
+ circle: { activation: 64, max: 104, resistance: 0.5 },
12
+ } as const;
13
+ /** circle: ขนาดวงและที่พักใต้ขอบบนของ root ตอนกำลังรีเฟรช */
14
+ const CIRCLE_SIZE = 40;
15
+ const CIRCLE_REST = 16;
10
16
  const MOBILE_BREAKPOINT = 768;
11
17
  /** หมุนอย่างน้อยเท่านี้ — refresh ที่เสร็จทันทีจะไม่กะพริบหายไปเลย */
12
18
  const MIN_SPIN_MS = 500;
@@ -43,11 +49,17 @@ export function PullToRefresh({
43
49
  text = PULL_TO_REFRESH_TEXT_TH,
44
50
  rootSelector = "[data-pull-to-refresh-root]",
45
51
  onRefresh,
52
+ variant = "pill",
46
53
  }: {
47
54
  text?: PullToRefreshText;
48
55
  rootSelector?: string;
49
56
  /** รีเฟรชเอง (คืน promise = หมุนจนเสร็จ) — ไม่ส่ง = `router.refresh()` แล้วหมุน 700ms */
50
57
  onRefresh?: () => Promise<unknown> | void;
58
+ /**
59
+ * `pill` (ค่าเริ่มต้น) = แคปซูลมีข้อความ · `circle` = วงกลม 40px ไอคอนล้วน (ลูกศร → หมุน) อยู่กลาง พักที่ 16px ใต้ขอบบนของ root
60
+ * ข้อความยังอยู่เป็น `sr-only` + `role="status"` สำหรับ screen reader
61
+ */
62
+ variant?: "pill" | "circle";
51
63
  }) {
52
64
  const router = useRouter();
53
65
  const onRefreshRef = useRef(onRefresh);
@@ -64,6 +76,7 @@ export function PullToRefresh({
64
76
  isRefreshingRef.current = isRefreshing;
65
77
 
66
78
  useEffect(() => {
79
+ const dims = DIMS[variant];
67
80
  const isRefreshing = () => isRefreshingRef.current;
68
81
  const isMobileViewport = () => window.innerWidth < MOBILE_BREAKPOINT;
69
82
  const getRoot = () => document.querySelector<HTMLElement>(rootSelector);
@@ -125,11 +138,11 @@ export function PullToRefresh({
125
138
  isPullingRef.current = true;
126
139
  }
127
140
  event.preventDefault();
128
- setPullDistance(Math.min(MAX_PULL_DISTANCE, deltaY * 0.62));
141
+ setPullDistance(Math.min(dims.max, deltaY * dims.resistance));
129
142
  };
130
143
 
131
144
  const handleTouchEnd = async () => {
132
- const shouldRefresh = pullDistanceRef.current >= ACTIVATION_DISTANCE;
145
+ const shouldRefresh = pullDistanceRef.current >= dims.activation;
133
146
  resetPull();
134
147
  if (!shouldRefresh || isRefreshing()) return;
135
148
  isRefreshingRef.current = true;
@@ -182,12 +195,44 @@ export function PullToRefresh({
182
195
  window.removeEventListener("touchend", handleTouchEnd);
183
196
  window.removeEventListener("touchcancel", resetPull);
184
197
  };
185
- }, [router, rootSelector]);
198
+ }, [router, rootSelector, variant]);
186
199
 
187
200
  const isVisible = pullDistance > 0 || isRefreshing;
188
- const progress = Math.min(1, pullDistance / ACTIVATION_DISTANCE);
201
+ const progress = Math.min(1, pullDistance / DIMS[variant].activation);
189
202
  const label = isRefreshing ? text.refreshing : progress >= 1 ? text.release : text.pull;
190
203
 
204
+ if (variant === "circle") {
205
+ const armed = !isRefreshing && progress >= 1;
206
+ return (
207
+ <div
208
+ role="status"
209
+ aria-live="polite"
210
+ className={cn(
211
+ "pointer-events-none fixed left-1/2 z-50 flex size-10 items-center justify-center rounded-full border bg-background text-muted-foreground shadow-md md:hidden",
212
+ pullDistance === 0 && "transition-[transform,opacity] duration-200 ease-out",
213
+ armed && "text-primary",
214
+ )}
215
+ style={{
216
+ top: rootTop - CIRCLE_SIZE,
217
+ marginLeft: -CIRCLE_SIZE / 2,
218
+ opacity: isRefreshing ? 1 : pullDistance > 0 ? Math.max(0.35, progress) : 0,
219
+ transform: `translateY(${isRefreshing ? CIRCLE_REST + CIRCLE_SIZE : pullDistance}px) scale(${isVisible ? 0.7 + 0.3 * progress : 0.6})`,
220
+ }}
221
+ >
222
+ {isRefreshing ? (
223
+ <Loader2 className="size-5 animate-spin text-primary" aria-hidden="true" />
224
+ ) : (
225
+ <ArrowDown
226
+ className="size-5 transition-transform duration-150"
227
+ style={{ transform: `rotate(${armed ? 180 : 0}deg)` }}
228
+ aria-hidden="true"
229
+ />
230
+ )}
231
+ <span className="sr-only">{isVisible ? label : ""}</span>
232
+ </div>
233
+ );
234
+ }
235
+
191
236
  return (
192
237
  <div
193
238
  aria-live="polite"
@@ -109,11 +109,14 @@ export function SwitchField({
109
109
  label,
110
110
  hint,
111
111
  className,
112
+ labelClassName,
112
113
  ...props
113
114
  }: ComponentProps<typeof Switch> & {
114
115
  label: ReactNode;
115
116
  /** คำอธิบายใต้ label (เยื้องตรงกับ label) */
116
117
  hint?: ReactNode;
118
+ /** ทับสไตล์ข้อความป้าย (ค่าเริ่มต้น `text-sm`) เช่น `text-caption font-semibold` */
119
+ labelClassName?: string;
117
120
  }) {
118
121
  return (
119
122
  <Label
@@ -125,7 +128,7 @@ export function SwitchField({
125
128
  >
126
129
  <Switch className={cn(hint && "mt-0.5")} {...props} />
127
130
  <span className="flex flex-col gap-0.5">
128
- <span className="text-sm">{label}</span>
131
+ <span className={cn("text-sm", labelClassName)}>{label}</span>
129
132
  {hint && <span className="text-muted-foreground text-xs">{hint}</span>}
130
133
  </span>
131
134
  </Label>
@@ -122,6 +122,8 @@ export interface NotificationCenterProps {
122
122
  description?: ReactNode;
123
123
  /** ไม่มีรายการ — ส่ง element เองได้ทั้งก้อน */
124
124
  empty?: ReactNode;
125
+ /** ปุ่มคำสั่งของทั้งกล่องที่หัว เช่น "อ่านทั้งหมดแล้ว" (ซ้ายของ "ดูทั้งหมด") — ไม่ต้องยืม `viewAll` มาใช้ผิดความหมาย */
126
+ headerAction?: ReactNode;
125
127
  /** ลิงก์/ปุ่ม "ดูทั้งหมด" มุมขวาบน */
126
128
  viewAll?: { label?: ReactNode; onSelect: () => void };
127
129
  /** กำลังทำคำสั่งลัดอยู่ — ปิดปุ่มทั้งหมดกันกดซ้ำ */
@@ -159,6 +161,7 @@ export function NotificationCenter({
159
161
  description,
160
162
  empty,
161
163
  viewAll,
164
+ headerAction,
162
165
  busy = false,
163
166
  variant = "card",
164
167
  renderItem,
@@ -186,11 +189,16 @@ export function NotificationCenter({
186
189
  </div>
187
190
  )}
188
191
  </div>
189
- {viewAll && (
190
- <Button variant="ghost" size="sm" onClick={viewAll.onSelect} className="shrink-0">
191
- {viewAll.label ?? text.notifications?.viewAll ?? "ดูทั้งหมด"}
192
- <ChevronRightIcon />
193
- </Button>
192
+ {(headerAction || viewAll) && (
193
+ <div className="flex shrink-0 items-center gap-1">
194
+ {headerAction}
195
+ {viewAll && (
196
+ <Button variant="ghost" size="sm" onClick={viewAll.onSelect} className="shrink-0">
197
+ {viewAll.label ?? text.notifications?.viewAll ?? "ดูทั้งหมด"}
198
+ <ChevronRightIcon />
199
+ </Button>
200
+ )}
201
+ </div>
194
202
  )}
195
203
  </div>
196
204
 
@@ -67,12 +67,20 @@ export function SelectionBar({
67
67
  ? "h-11 rounded-full bg-primary py-0 ps-4 pe-2 text-primary-foreground"
68
68
  : "rounded-xl border bg-card",
69
69
  variant !== "inline" &&
70
- "-translate-x-1/2 fixed bottom-[calc(1rem+env(safe-area-inset-bottom))] left-1/2 z-40 max-w-[calc(100vw-2rem)]",
70
+ "-translate-x-1/2 fixed bottom-[calc(1rem+env(safe-area-inset-bottom))] left-1/2 z-40 w-max max-w-[calc(100vw-2rem)]",
71
71
  className,
72
72
  )}
73
73
  >
74
74
  <span className="shrink-0 font-medium text-sm tabular-nums">{message}</span>
75
- <div className="flex min-w-0 flex-wrap items-center gap-1.5">
75
+ <div
76
+ className={cn(
77
+ "flex min-w-0 items-center gap-1.5",
78
+ // pill: บรรทัดเดียวเสมอ ปุ่มไม่ตัดคำ และปุ่มที่แอปส่งมาใช้สีตัวอักษรบนพื้นสีหลัก
79
+ pill
80
+ ? "[&_button]:whitespace-nowrap [&_button]:text-primary-foreground [&_button]:hover:bg-primary-foreground/15 pointer-coarse:[&_button]:h-11"
81
+ : "flex-wrap",
82
+ )}
83
+ >
76
84
  {actions?.map((action) => (
77
85
  <Button
78
86
  key={action.label}
@@ -98,6 +106,7 @@ export function SelectionBar({
98
106
  onClick={onClear}
99
107
  className={cn(
100
108
  "ml-auto shrink-0",
109
+ pill && "pointer-coarse:h-11 pointer-coarse:w-11",
101
110
  pill && "rounded-full hover:bg-primary-foreground/15 hover:text-primary-foreground",
102
111
  )}
103
112
  >
@@ -81,6 +81,18 @@ export interface UiText {
81
81
  table?: Partial<{
82
82
  rowsPerPage: string;
83
83
  pageOf: string;
84
+ /** `variant="quiet"` — `{from}`/`{to}`/`{total}` และ `{shown}`/`{total}` */
85
+ rangeOf: string;
86
+ shownOf: string;
87
+ density: string;
88
+ /** `variant="quiet"` มือถือ — `{count}` */
89
+ selectedCount: string;
90
+ /** ข้อความในแคปซูลเลือกหมู่ (สั้นกว่า `selectedCount` เพราะหัวรายการมือถือบอก "เลือก N รายการ" อยู่แล้ว) */
91
+ selectedPill: string;
92
+ selectAll: string;
93
+ longPressHint: string;
94
+ clearFilters: string;
95
+ showResults: string;
84
96
  rowsSelected: string;
85
97
  noResults: string;
86
98
  deleteTitle: string;
@@ -316,6 +328,15 @@ export const UI_TEXT_TH: UiText = {
316
328
  table: {
317
329
  rowsPerPage: "แถวต่อหน้า",
318
330
  pageOf: "หน้า {page} จาก {pages}",
331
+ rangeOf: "{from}–{to} จาก {total}",
332
+ shownOf: "{shown} จาก {total} รายการ",
333
+ density: "ความหนาแน่นของแถว",
334
+ selectedCount: "เลือก {count} รายการ",
335
+ selectedPill: "{count} รายการ",
336
+ selectAll: "เลือกทั้งหมด",
337
+ longPressHint: "กดค้างที่แถวเพื่อเลือกหมู่",
338
+ clearFilters: "ล้าง",
339
+ showResults: "ดู {count} รายการ",
319
340
  rowsSelected: "เลือก {selected} จาก {total} แถว",
320
341
  noResults: "ไม่พบข้อมูล",
321
342
  deleteTitle: "ยืนยันการลบ",
@@ -530,6 +551,15 @@ export const UI_TEXT_EN: UiText = {
530
551
  table: {
531
552
  rowsPerPage: "Rows per page",
532
553
  pageOf: "Page {page} of {pages}",
554
+ rangeOf: "{from}–{to} of {total}",
555
+ shownOf: "{shown} of {total} items",
556
+ density: "Row density",
557
+ selectedCount: "{count} selected",
558
+ selectedPill: "{count} items",
559
+ selectAll: "Select all",
560
+ longPressHint: "Press and hold a row to select several",
561
+ clearFilters: "Clear",
562
+ showResults: "Show {count} items",
533
563
  rowsSelected: "{selected} of {total} row(s) selected",
534
564
  noResults: "No results",
535
565
  deleteTitle: "Are you sure?",
package/src/data-table.ts CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  DataTableColumnHeader,
13
13
  DataTableDateRangeFilter,
14
14
  DataTableFacetedFilter,
15
+ type DataTableMobileSlot,
15
16
  DataTablePagination,
16
17
  type DataTableProps,
17
18
  DataTableToolbar,
@@ -8,12 +8,14 @@ export function useIsMobile() {
8
8
  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
9
9
 
10
10
  React.useEffect(() => {
11
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
12
+ // เบราว์เซอร์เก่า/jsdom ที่ไม่มี matchMedia: ใช้ค่าตอน mount อย่างเดียว (ไม่ติดตามการปรับขนาด) แทนที่จะพังทั้งหน้า
13
+ if (typeof window.matchMedia !== "function") return;
11
14
  const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
12
15
  const onChange = () => {
13
16
  setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
14
17
  };
15
18
  mql.addEventListener("change", onChange);
16
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
17
19
  return () => mql.removeEventListener("change", onChange);
18
20
  }, []);
19
21