@suphark/ui 0.59.1 → 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 +1139 -1125
- package/package.json +1 -1
- package/src/components/data-table/data-table-filter-sections.tsx +108 -0
- package/src/components/data-table/data-table-toolbar.tsx +32 -7
- package/src/components/data-table/data-table.tsx +233 -74
- package/src/components/data-table/types.ts +5 -0
- package/src/components/shared/selection-bar.tsx +11 -2
- package/src/components/shared/ui-text.tsx +20 -0
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 เอง",
|
|
@@ -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
|
+
}
|
|
@@ -26,6 +26,7 @@ import type React from "react";
|
|
|
26
26
|
import { useEffect, useRef, useState } from "react";
|
|
27
27
|
import type { FacetedFilterConfig } from ".";
|
|
28
28
|
import { DataTableFacetedFilter } from "./data-table-faceted-filter";
|
|
29
|
+
import { DataTableFilterSections } from "./data-table-filter-sections";
|
|
29
30
|
import { DataTableViewOptions } from "./data-table-view-option";
|
|
30
31
|
|
|
31
32
|
interface DataTableToolbarProps<TData> {
|
|
@@ -159,23 +160,47 @@ export function DataTableToolbar<TData>({
|
|
|
159
160
|
{text.table?.filtersDescription ?? "เลือกได้หลายเงื่อนไข ผลลัพธ์จะอัปเดตทันที"}
|
|
160
161
|
</SheetDescription>
|
|
161
162
|
<SheetClose
|
|
162
|
-
render={
|
|
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
|
+
}
|
|
163
170
|
>
|
|
164
171
|
<X />
|
|
165
172
|
<span className="sr-only">{text.table?.closeFilters ?? "ปิดตัวกรอง"}</span>
|
|
166
173
|
</SheetClose>
|
|
167
174
|
</SheetHeader>
|
|
168
175
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4">
|
|
169
|
-
{
|
|
176
|
+
{variant === "quiet" && facetedFilters ? (
|
|
177
|
+
<DataTableFilterSections table={table} filters={facetedFilters} />
|
|
178
|
+
) : (
|
|
179
|
+
renderFilterControls(true)
|
|
180
|
+
)}
|
|
170
181
|
{toolbarFilters}
|
|
171
182
|
</div>
|
|
172
|
-
{
|
|
173
|
-
<SheetFooter className="border-t">
|
|
174
|
-
<Button variant="
|
|
175
|
-
|
|
176
|
-
ล้างตัวกรองทั้งหมด
|
|
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 ?? "ล้าง"}
|
|
177
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>
|
|
178
194
|
</SheetFooter>
|
|
195
|
+
) : (
|
|
196
|
+
isFiltered && (
|
|
197
|
+
<SheetFooter className="border-t">
|
|
198
|
+
<Button variant="secondary" onClick={resetUserFilters}>
|
|
199
|
+
<X />
|
|
200
|
+
ล้างตัวกรองทั้งหมด
|
|
201
|
+
</Button>
|
|
202
|
+
</SheetFooter>
|
|
203
|
+
)
|
|
179
204
|
)}
|
|
180
205
|
</SheetContent>
|
|
181
206
|
</Sheet>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
3
4
|
import { SelectionBar } from "@suphark/ui/components/shared/selection-bar";
|
|
4
5
|
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
5
6
|
import {
|
|
@@ -84,10 +85,26 @@ export function DataTable<TData, TValue>({
|
|
|
84
85
|
variant = "default",
|
|
85
86
|
defaultDensity = "comfortable",
|
|
86
87
|
selectionActions,
|
|
88
|
+
densityStorageKey,
|
|
87
89
|
}: DataTableProps<TData, TValue>) {
|
|
88
90
|
const text = useUiText();
|
|
89
91
|
const quiet = variant === "quiet";
|
|
90
|
-
const [density,
|
|
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
|
+
};
|
|
91
108
|
const { table } = useDataTable({
|
|
92
109
|
columns,
|
|
93
110
|
data,
|
|
@@ -327,6 +344,7 @@ export function DataTable<TData, TValue>({
|
|
|
327
344
|
table.getFilteredSelectedRowModel().rows.length > 0 && (
|
|
328
345
|
<SelectionBar
|
|
329
346
|
variant="pill"
|
|
347
|
+
label={text.table?.selectedPill ?? "{count} รายการ"}
|
|
330
348
|
count={table.getFilteredSelectedRowModel().rows.length}
|
|
331
349
|
onClear={() => table.resetRowSelection()}
|
|
332
350
|
>
|
|
@@ -356,92 +374,233 @@ const QUIET_TABLE = [
|
|
|
356
374
|
|
|
357
375
|
const SLOT_ORDER: DataTableMobileSlot[] = ["title", "end", "sub", "status", "meta"];
|
|
358
376
|
|
|
359
|
-
|
|
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
|
+
*/
|
|
360
423
|
function QuietMobileList<TData>({
|
|
361
424
|
table,
|
|
362
425
|
}: {
|
|
363
426
|
table: import("@tanstack/react-table").Table<TData>;
|
|
364
427
|
}) {
|
|
428
|
+
const text = useUiText();
|
|
365
429
|
const slotOf = (column: { columnDef: { meta?: unknown } }) =>
|
|
366
430
|
(column.columnDef.meta as { mobile?: DataTableMobileSlot } | undefined)?.mobile;
|
|
367
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
|
+
|
|
368
454
|
return (
|
|
369
|
-
<
|
|
370
|
-
{
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
<
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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)}
|
|
380
473
|
>
|
|
381
|
-
{
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
+
)}
|
|
501
|
+
</div>
|
|
502
|
+
);
|
|
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 (
|
|
385
568
|
<div
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
if (event.target === event.currentTarget) row.toggleSelected();
|
|
389
|
-
}}
|
|
569
|
+
key={slot}
|
|
570
|
+
className="flex items-baseline justify-between gap-2 font-semibold text-sm"
|
|
390
571
|
>
|
|
391
|
-
{
|
|
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>
|
|
392
580
|
</div>
|
|
393
|
-
)
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
{end.map((cell) => (
|
|
413
|
-
<span key={cell.id}>
|
|
414
|
-
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
415
|
-
</span>
|
|
416
|
-
))}
|
|
417
|
-
</span>
|
|
418
|
-
</div>
|
|
419
|
-
);
|
|
420
|
-
}
|
|
421
|
-
if (slot === "end") return null;
|
|
422
|
-
if (slot === "sub") {
|
|
423
|
-
return (
|
|
424
|
-
<div key={slot} className="truncate text-muted-foreground text-sm">
|
|
425
|
-
{content}
|
|
426
|
-
</div>
|
|
427
|
-
);
|
|
428
|
-
}
|
|
429
|
-
const meta = pick("meta");
|
|
430
|
-
return (
|
|
431
|
-
<div key={slot} className="flex items-center gap-1.5 text-xs">
|
|
432
|
-
{content}
|
|
433
|
-
{meta.map((cell) => (
|
|
434
|
-
<span key={cell.id} className="text-muted-foreground">
|
|
435
|
-
· {flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
436
|
-
</span>
|
|
437
|
-
))}
|
|
438
|
-
</div>
|
|
439
|
-
);
|
|
440
|
-
})}
|
|
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
|
+
))}
|
|
441
600
|
</div>
|
|
442
|
-
|
|
443
|
-
)
|
|
444
|
-
|
|
445
|
-
</
|
|
601
|
+
);
|
|
602
|
+
})}
|
|
603
|
+
</div>
|
|
604
|
+
</li>
|
|
446
605
|
);
|
|
447
606
|
}
|
|
@@ -117,6 +117,11 @@ export interface DataTableProps<TData, TValue> {
|
|
|
117
117
|
variant?: "default" | "quiet";
|
|
118
118
|
/** `quiet`: ความหนาแน่นเริ่มต้น (ผู้ใช้สลับได้ที่แถบเครื่องมือ) — ค่าเริ่มต้น comfortable */
|
|
119
119
|
defaultDensity?: "comfortable" | "compact";
|
|
120
|
+
/**
|
|
121
|
+
* `quiet`: จำความหนาแน่นที่ผู้ใช้เลือกไว้ใน localStorage ด้วยคีย์นี้ (เช่น `"po-table-density"`) —
|
|
122
|
+
* ไม่ส่ง = ไม่จำ (กลับเป็น `defaultDensity` ทุกครั้ง) · localStorage ใช้ไม่ได้ก็ทำงานต่อโดยไม่จำ
|
|
123
|
+
*/
|
|
124
|
+
densityStorageKey?: string;
|
|
120
125
|
/** `quiet`: ปุ่มคำสั่งในแคปซูลเลือกหมู่ (ส่งออก/ลบ …) — ไม่ส่ง = แคปซูลมีแค่จำนวนที่เลือกและปุ่มล้าง */
|
|
121
126
|
selectionActions?: (table: Table<TData>) => React.ReactNode;
|
|
122
127
|
}
|
|
@@ -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
|
>
|
|
@@ -85,6 +85,14 @@ export interface UiText {
|
|
|
85
85
|
rangeOf: string;
|
|
86
86
|
shownOf: string;
|
|
87
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;
|
|
88
96
|
rowsSelected: string;
|
|
89
97
|
noResults: string;
|
|
90
98
|
deleteTitle: string;
|
|
@@ -323,6 +331,12 @@ export const UI_TEXT_TH: UiText = {
|
|
|
323
331
|
rangeOf: "{from}–{to} จาก {total}",
|
|
324
332
|
shownOf: "{shown} จาก {total} รายการ",
|
|
325
333
|
density: "ความหนาแน่นของแถว",
|
|
334
|
+
selectedCount: "เลือก {count} รายการ",
|
|
335
|
+
selectedPill: "{count} รายการ",
|
|
336
|
+
selectAll: "เลือกทั้งหมด",
|
|
337
|
+
longPressHint: "กดค้างที่แถวเพื่อเลือกหมู่",
|
|
338
|
+
clearFilters: "ล้าง",
|
|
339
|
+
showResults: "ดู {count} รายการ",
|
|
326
340
|
rowsSelected: "เลือก {selected} จาก {total} แถว",
|
|
327
341
|
noResults: "ไม่พบข้อมูล",
|
|
328
342
|
deleteTitle: "ยืนยันการลบ",
|
|
@@ -540,6 +554,12 @@ export const UI_TEXT_EN: UiText = {
|
|
|
540
554
|
rangeOf: "{from}–{to} of {total}",
|
|
541
555
|
shownOf: "{shown} of {total} items",
|
|
542
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",
|
|
543
563
|
rowsSelected: "{selected} of {total} row(s) selected",
|
|
544
564
|
noResults: "No results",
|
|
545
565
|
deleteTitle: "Are you sure?",
|