@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 +37 -0
- package/package.json +1 -1
- package/src/components/data-table/data-table-column-header.tsx +1 -1
- package/src/components/data-table/data-table-filter-sections.tsx +108 -0
- 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 +191 -44
- package/src/components/data-table/data-table-view-option.tsx +15 -4
- package/src/components/data-table/data-table.tsx +312 -3
- package/src/components/data-table/index.ts +1 -1
- package/src/components/data-table/types.ts +24 -0
- package/src/components/pwa/pull-to-refresh.tsx +52 -7
- package/src/components/shared/form/labeled-field.tsx +4 -1
- package/src/components/shared/notification-center.tsx +13 -5
- package/src/components/shared/selection-bar.tsx +11 -2
- package/src/components/shared/ui-text.tsx +30 -0
- package/src/data-table.ts +1 -0
- package/src/hooks/use-mobile.ts +3 -1
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.
|
|
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 (
|
|
@@ -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
|
-
|
|
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>({
|
|
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,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
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
const
|
|
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(
|
|
141
|
+
setPullDistance(Math.min(dims.max, deltaY * dims.resistance));
|
|
129
142
|
};
|
|
130
143
|
|
|
131
144
|
const handleTouchEnd = async () => {
|
|
132
|
-
const shouldRefresh = pullDistanceRef.current >=
|
|
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 /
|
|
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
|
-
<
|
|
191
|
-
{
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
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
package/src/hooks/use-mobile.ts
CHANGED
|
@@ -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
|
|