@suphark/ui 0.59.1 → 0.61.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 +1146 -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-states.tsx +150 -0
- package/src/components/data-table/data-table-toolbar.tsx +40 -10
- package/src/components/data-table/data-table.tsx +261 -78
- package/src/components/data-table/types.ts +13 -0
- package/src/components/shared/selection-bar.tsx +11 -2
- package/src/components/shared/ui-text.tsx +57 -0
|
@@ -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 {
|
|
@@ -14,6 +15,7 @@ import { cn } from "@suphark/ui/lib/utils";
|
|
|
14
15
|
import { flexRender } from "@tanstack/react-table";
|
|
15
16
|
import * as React from "react";
|
|
16
17
|
import { DataTablePagination } from "./data-table-pagination";
|
|
18
|
+
import { DataTableEmptyState, DataTableErrorState, QuietMobileSkeleton } from "./data-table-states";
|
|
17
19
|
import { DataTableToolbar } from "./data-table-toolbar";
|
|
18
20
|
import type { DataTableMobileSlot, DataTableProps } from "./types";
|
|
19
21
|
import { useDataTable } from "./use-data-table";
|
|
@@ -84,10 +86,30 @@ export function DataTable<TData, TValue>({
|
|
|
84
86
|
variant = "default",
|
|
85
87
|
defaultDensity = "comfortable",
|
|
86
88
|
selectionActions,
|
|
89
|
+
densityStorageKey,
|
|
90
|
+
resourceName,
|
|
91
|
+
emptyAction,
|
|
92
|
+
error,
|
|
93
|
+
onRetry,
|
|
87
94
|
}: DataTableProps<TData, TValue>) {
|
|
88
95
|
const text = useUiText();
|
|
89
96
|
const quiet = variant === "quiet";
|
|
90
|
-
const [density,
|
|
97
|
+
const [density, setDensityState] = React.useState<"comfortable" | "compact">(defaultDensity);
|
|
98
|
+
// จำความหนาแน่น: อ่านหลัง mount (กัน hydration mismatch) — localStorage ใช้ไม่ได้ก็ข้าม
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
if (!densityStorageKey) return;
|
|
101
|
+
try {
|
|
102
|
+
const saved = localStorage.getItem(densityStorageKey);
|
|
103
|
+
if (saved === "compact" || saved === "comfortable") setDensityState(saved);
|
|
104
|
+
} catch {}
|
|
105
|
+
}, [densityStorageKey]);
|
|
106
|
+
const setDensity = (next: "comfortable" | "compact") => {
|
|
107
|
+
setDensityState(next);
|
|
108
|
+
if (!densityStorageKey) return;
|
|
109
|
+
try {
|
|
110
|
+
localStorage.setItem(densityStorageKey, next);
|
|
111
|
+
} catch {}
|
|
112
|
+
};
|
|
91
113
|
const { table } = useDataTable({
|
|
92
114
|
columns,
|
|
93
115
|
data,
|
|
@@ -176,7 +198,13 @@ export function DataTable<TData, TValue>({
|
|
|
176
198
|
))}
|
|
177
199
|
</TableHeader>
|
|
178
200
|
<TableBody>
|
|
179
|
-
{
|
|
201
|
+
{error ? (
|
|
202
|
+
<TableRow>
|
|
203
|
+
<TableCell colSpan={table.getVisibleLeafColumns().length}>
|
|
204
|
+
<DataTableErrorState onRetry={onRetry} />
|
|
205
|
+
</TableCell>
|
|
206
|
+
</TableRow>
|
|
207
|
+
) : isLoading ? (
|
|
180
208
|
Array.from({ length: initialPageSize }).map((_, i) => (
|
|
181
209
|
// biome-ignore lint/suspicious/noArrayIndexKey: แถว skeleton ไม่มีตัวตน ใช้ตำแหน่งเป็น key ได้
|
|
182
210
|
<TableRow key={i}>
|
|
@@ -229,7 +257,17 @@ export function DataTable<TData, TValue>({
|
|
|
229
257
|
) : (
|
|
230
258
|
<TableRow>
|
|
231
259
|
<TableCell colSpan={table.getVisibleLeafColumns().length} className="h-24 text-center">
|
|
232
|
-
{emptyStateComponent ||
|
|
260
|
+
{emptyStateComponent ||
|
|
261
|
+
(quiet ? (
|
|
262
|
+
<DataTableEmptyState
|
|
263
|
+
table={table}
|
|
264
|
+
preservedFilterIds={preservedFilterIds}
|
|
265
|
+
resourceName={resourceName}
|
|
266
|
+
emptyAction={emptyAction}
|
|
267
|
+
/>
|
|
268
|
+
) : (
|
|
269
|
+
(text.table?.noResults ?? "ไม่พบข้อมูล")
|
|
270
|
+
))}
|
|
233
271
|
</TableCell>
|
|
234
272
|
</TableRow>
|
|
235
273
|
)}
|
|
@@ -241,8 +279,10 @@ export function DataTable<TData, TValue>({
|
|
|
241
279
|
.getAllLeafColumns()
|
|
242
280
|
.some((column) => (column.columnDef.meta as { mobile?: string } | undefined)?.mobile);
|
|
243
281
|
const mobileList =
|
|
244
|
-
quiet && hasMobileSlots && !isLoading && table.getRowModel().rows.length > 0 ? (
|
|
282
|
+
quiet && hasMobileSlots && !isLoading && !error && table.getRowModel().rows.length > 0 ? (
|
|
245
283
|
<QuietMobileList table={table} />
|
|
284
|
+
) : quiet && hasMobileSlots && isLoading && !error ? (
|
|
285
|
+
<QuietMobileSkeleton rows={initialPageSize} />
|
|
246
286
|
) : null;
|
|
247
287
|
|
|
248
288
|
const customView = renderCustomView?.(table);
|
|
@@ -263,6 +303,7 @@ export function DataTable<TData, TValue>({
|
|
|
263
303
|
variant={variant}
|
|
264
304
|
density={density}
|
|
265
305
|
onDensityChange={setDensity}
|
|
306
|
+
isLoading={isLoading}
|
|
266
307
|
/>
|
|
267
308
|
)}
|
|
268
309
|
{customView ? (
|
|
@@ -304,7 +345,7 @@ export function DataTable<TData, TValue>({
|
|
|
304
345
|
renderTable(false)
|
|
305
346
|
)}
|
|
306
347
|
</div>
|
|
307
|
-
{showPagination && (
|
|
348
|
+
{showPagination && !error && (
|
|
308
349
|
<div
|
|
309
350
|
className={
|
|
310
351
|
stickyPagination
|
|
@@ -327,6 +368,7 @@ export function DataTable<TData, TValue>({
|
|
|
327
368
|
table.getFilteredSelectedRowModel().rows.length > 0 && (
|
|
328
369
|
<SelectionBar
|
|
329
370
|
variant="pill"
|
|
371
|
+
label={text.table?.selectedPill ?? "{count} รายการ"}
|
|
330
372
|
count={table.getFilteredSelectedRowModel().rows.length}
|
|
331
373
|
onClear={() => table.resetRowSelection()}
|
|
332
374
|
>
|
|
@@ -356,92 +398,233 @@ const QUIET_TABLE = [
|
|
|
356
398
|
|
|
357
399
|
const SLOT_ORDER: DataTableMobileSlot[] = ["title", "end", "sub", "status", "meta"];
|
|
358
400
|
|
|
359
|
-
|
|
401
|
+
const LONG_PRESS_MS = 500;
|
|
402
|
+
const HINT_KEY = "ui:data-table:long-press-hint";
|
|
403
|
+
|
|
404
|
+
/** กดค้าง (≥ 500 ms โดยไม่ขยับเกิน 8 px) → เรียก `onLongPress`; `fired` บอกว่า click ถัดไปคือแรงที่ตามหลังการกดค้าง */
|
|
405
|
+
function useLongPress(onLongPress: () => void, enabled: boolean) {
|
|
406
|
+
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
407
|
+
const origin = React.useRef<{ x: number; y: number } | null>(null);
|
|
408
|
+
const fired = React.useRef(false);
|
|
409
|
+
const cancel = () => {
|
|
410
|
+
if (timer.current) clearTimeout(timer.current);
|
|
411
|
+
timer.current = null;
|
|
412
|
+
};
|
|
413
|
+
React.useEffect(() => cancel, []);
|
|
414
|
+
const handlers = {
|
|
415
|
+
onPointerDown: (event: React.PointerEvent) => {
|
|
416
|
+
// ล้างธงของการกดค้างรอบก่อนทุกครั้งที่เริ่มแตะใหม่ (แม้ปิดการกดค้างอยู่)
|
|
417
|
+
fired.current = false;
|
|
418
|
+
cancel();
|
|
419
|
+
if (!enabled) return;
|
|
420
|
+
origin.current = { x: event.clientX, y: event.clientY };
|
|
421
|
+
timer.current = setTimeout(() => {
|
|
422
|
+
timer.current = null;
|
|
423
|
+
fired.current = true;
|
|
424
|
+
onLongPress();
|
|
425
|
+
}, LONG_PRESS_MS);
|
|
426
|
+
},
|
|
427
|
+
onPointerMove: (event: React.PointerEvent) => {
|
|
428
|
+
const start = origin.current;
|
|
429
|
+
if (start && Math.hypot(event.clientX - start.x, event.clientY - start.y) > 8) cancel();
|
|
430
|
+
},
|
|
431
|
+
onPointerUp: cancel,
|
|
432
|
+
onPointerCancel: cancel,
|
|
433
|
+
onPointerLeave: cancel,
|
|
434
|
+
// เมนูบริบทของเบราว์เซอร์มือถือเด้งตอนกดค้าง — กันไว้เฉพาะช่วงที่เพิ่งกดค้างสำเร็จ
|
|
435
|
+
onContextMenu: (event: React.MouseEvent) => {
|
|
436
|
+
if (fired.current) event.preventDefault();
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
return { fired, handlers };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* `variant="quiet"` บนมือถือ: รายการแถวละ 3 บรรทัดจากคอลัมน์ที่ใส่ `meta.mobile`
|
|
444
|
+
* ช่องเลือก (คอลัมน์ `select`) ซ่อนจากสายตาจนกว่าจะเลือก — กดค้างที่แถวเพื่อเข้าโหมดเลือกหมู่,
|
|
445
|
+
* แตะแถวเพื่อสลับ · คีย์บอร์ด/โปรแกรมอ่านจอยังเข้าถึงช่องเลือกได้ตลอด
|
|
446
|
+
*/
|
|
360
447
|
function QuietMobileList<TData>({
|
|
361
448
|
table,
|
|
362
449
|
}: {
|
|
363
450
|
table: import("@tanstack/react-table").Table<TData>;
|
|
364
451
|
}) {
|
|
452
|
+
const text = useUiText();
|
|
365
453
|
const slotOf = (column: { columnDef: { meta?: unknown } }) =>
|
|
366
454
|
(column.columnDef.meta as { mobile?: DataTableMobileSlot } | undefined)?.mobile;
|
|
367
455
|
const rows = table.getRowModel().rows;
|
|
456
|
+
const selectedCount = table.getFilteredSelectedRowModel().rows.length;
|
|
457
|
+
const selecting = selectedCount > 0;
|
|
458
|
+
const hasSelect = table.getAllLeafColumns().some((column) => column.id === "select");
|
|
459
|
+
|
|
460
|
+
// บรรทัดบอกใบ้: แสดงครั้งแรกครั้งเดียว (หายเองใน 6 วินาที หรือเมื่อกดค้างสำเร็จ) แล้วจำไว้
|
|
461
|
+
const [hint, setHint] = React.useState(false);
|
|
462
|
+
const markHintSeen = React.useCallback(() => {
|
|
463
|
+
setHint(false);
|
|
464
|
+
try {
|
|
465
|
+
localStorage.setItem(HINT_KEY, "1");
|
|
466
|
+
} catch {}
|
|
467
|
+
}, []);
|
|
468
|
+
React.useEffect(() => {
|
|
469
|
+
if (!hasSelect) return;
|
|
470
|
+
try {
|
|
471
|
+
if (localStorage.getItem(HINT_KEY)) return;
|
|
472
|
+
} catch {}
|
|
473
|
+
setHint(true);
|
|
474
|
+
const id = setTimeout(markHintSeen, 6000);
|
|
475
|
+
return () => clearTimeout(id);
|
|
476
|
+
}, [hasSelect, markHintSeen]);
|
|
477
|
+
|
|
368
478
|
return (
|
|
369
|
-
<
|
|
370
|
-
{
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
<
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
479
|
+
<div className="flex flex-col gap-2 sm:hidden">
|
|
480
|
+
{selecting && (
|
|
481
|
+
<div
|
|
482
|
+
role="status"
|
|
483
|
+
aria-live="polite"
|
|
484
|
+
className="flex h-11 items-center justify-between px-1 text-sm"
|
|
485
|
+
>
|
|
486
|
+
<span className="font-semibold">
|
|
487
|
+
{(text.table?.selectedCount ?? "เลือก {count} รายการ").replace(
|
|
488
|
+
"{count}",
|
|
489
|
+
String(selectedCount),
|
|
490
|
+
)}
|
|
491
|
+
</span>
|
|
492
|
+
<Button
|
|
493
|
+
variant="ghost"
|
|
494
|
+
size="sm"
|
|
495
|
+
className="text-primary pointer-coarse:h-11"
|
|
496
|
+
onClick={() => table.toggleAllPageRowsSelected(true)}
|
|
380
497
|
>
|
|
381
|
-
{
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
498
|
+
{text.table?.selectAll ?? "เลือกทั้งหมด"}
|
|
499
|
+
</Button>
|
|
500
|
+
</div>
|
|
501
|
+
)}
|
|
502
|
+
<ul className="divide-y overflow-hidden rounded-xl border bg-card">
|
|
503
|
+
{rows.map((row) => (
|
|
504
|
+
<QuietMobileRow
|
|
505
|
+
key={row.id}
|
|
506
|
+
row={row}
|
|
507
|
+
selecting={selecting}
|
|
508
|
+
canLongPress={hasSelect && row.getCanSelect()}
|
|
509
|
+
slotOf={slotOf}
|
|
510
|
+
onLongPress={() => {
|
|
511
|
+
row.toggleSelected(true);
|
|
512
|
+
markHintSeen();
|
|
513
|
+
try {
|
|
514
|
+
navigator.vibrate?.(10);
|
|
515
|
+
} catch {}
|
|
516
|
+
}}
|
|
517
|
+
/>
|
|
518
|
+
))}
|
|
519
|
+
</ul>
|
|
520
|
+
{!selecting && hint && (
|
|
521
|
+
<p className="text-center text-muted-foreground text-xs">
|
|
522
|
+
{text.table?.longPressHint ?? "กดค้างที่แถวเพื่อเลือกหมู่"}
|
|
523
|
+
</p>
|
|
524
|
+
)}
|
|
525
|
+
</div>
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function QuietMobileRow<TData>({
|
|
530
|
+
row,
|
|
531
|
+
selecting,
|
|
532
|
+
canLongPress,
|
|
533
|
+
slotOf,
|
|
534
|
+
onLongPress,
|
|
535
|
+
}: {
|
|
536
|
+
row: import("@tanstack/react-table").Row<TData>;
|
|
537
|
+
selecting: boolean;
|
|
538
|
+
canLongPress: boolean;
|
|
539
|
+
slotOf: (column: { columnDef: { meta?: unknown } }) => DataTableMobileSlot | undefined;
|
|
540
|
+
onLongPress: () => void;
|
|
541
|
+
}) {
|
|
542
|
+
const { fired, handlers } = useLongPress(onLongPress, canLongPress && !selecting);
|
|
543
|
+
const cells = row.getVisibleCells();
|
|
544
|
+
const pick = (slot: DataTableMobileSlot) => cells.filter((cell) => slotOf(cell.column) === slot);
|
|
545
|
+
const select = cells.find((cell) => cell.column.id === "select");
|
|
546
|
+
return (
|
|
547
|
+
<li
|
|
548
|
+
data-state={row.getIsSelected() ? "selected" : undefined}
|
|
549
|
+
{...handlers}
|
|
550
|
+
onClick={(event) => {
|
|
551
|
+
// click ที่ตามหลังการกดค้างสำเร็จ ไม่นับเป็นการแตะ (ไม่งั้นเลือกแล้วถูกยกเลิกทันที)
|
|
552
|
+
if (fired.current) {
|
|
553
|
+
fired.current = false;
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (!selecting || !canLongPress) return;
|
|
557
|
+
if ((event.target as HTMLElement).closest("a,button,input,[role=checkbox]")) return;
|
|
558
|
+
row.toggleSelected();
|
|
559
|
+
}}
|
|
560
|
+
className={cn(
|
|
561
|
+
"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)]",
|
|
562
|
+
selecting && "cursor-pointer",
|
|
563
|
+
)}
|
|
564
|
+
>
|
|
565
|
+
{select && (
|
|
566
|
+
// ขยายพื้นที่แตะเป็น 44px (ช่องติ๊กจริงเล็ก 16px); แตะส่วนที่เกินช่องก็สลับการเลือก
|
|
567
|
+
// ยังไม่เลือก = ซ่อนจากสายตาแต่ยังโฟกัสด้วยคีย์บอร์ดได้ (sr-only + แสดงตอนโฟกัส)
|
|
568
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: คีย์บอร์ดใช้ช่องติ๊กข้างใน
|
|
569
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: เป็นแค่พื้นที่แตะเสริม
|
|
570
|
+
<div
|
|
571
|
+
className={cn(
|
|
572
|
+
"-m-3.5 -mt-3 shrink-0 p-3.5",
|
|
573
|
+
!selecting && "sr-only focus-within:not-sr-only",
|
|
574
|
+
)}
|
|
575
|
+
onClick={(event) => {
|
|
576
|
+
if (event.target === event.currentTarget) row.toggleSelected();
|
|
577
|
+
}}
|
|
578
|
+
>
|
|
579
|
+
{flexRender(select.column.columnDef.cell, select.getContext())}
|
|
580
|
+
</div>
|
|
581
|
+
)}
|
|
582
|
+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
583
|
+
{SLOT_ORDER.map((slot) => {
|
|
584
|
+
const found = pick(slot);
|
|
585
|
+
if (slot === "meta" || found.length === 0) return null;
|
|
586
|
+
const content = found.map((cell) => (
|
|
587
|
+
<span key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</span>
|
|
588
|
+
));
|
|
589
|
+
if (slot === "title") {
|
|
590
|
+
const end = pick("end");
|
|
591
|
+
return (
|
|
385
592
|
<div
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
if (event.target === event.currentTarget) row.toggleSelected();
|
|
389
|
-
}}
|
|
593
|
+
key={slot}
|
|
594
|
+
className="flex items-baseline justify-between gap-2 font-semibold text-sm"
|
|
390
595
|
>
|
|
391
|
-
{
|
|
596
|
+
<span className="min-w-0 truncate">{content}</span>
|
|
597
|
+
<span className="shrink-0 tabular-nums">
|
|
598
|
+
{end.map((cell) => (
|
|
599
|
+
<span key={cell.id}>
|
|
600
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
601
|
+
</span>
|
|
602
|
+
))}
|
|
603
|
+
</span>
|
|
392
604
|
</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
|
-
})}
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
if (slot === "end") return null;
|
|
608
|
+
if (slot === "sub") {
|
|
609
|
+
return (
|
|
610
|
+
<div key={slot} className="truncate text-muted-foreground text-sm">
|
|
611
|
+
{content}
|
|
612
|
+
</div>
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
const meta = pick("meta");
|
|
616
|
+
return (
|
|
617
|
+
<div key={slot} className="flex items-center gap-1.5 text-xs">
|
|
618
|
+
{content}
|
|
619
|
+
{meta.map((cell) => (
|
|
620
|
+
<span key={cell.id} className="text-muted-foreground">
|
|
621
|
+
· {flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
622
|
+
</span>
|
|
623
|
+
))}
|
|
441
624
|
</div>
|
|
442
|
-
|
|
443
|
-
)
|
|
444
|
-
|
|
445
|
-
</
|
|
625
|
+
);
|
|
626
|
+
})}
|
|
627
|
+
</div>
|
|
628
|
+
</li>
|
|
446
629
|
);
|
|
447
630
|
}
|
|
@@ -117,6 +117,19 @@ 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;
|
|
125
|
+
/** ชื่อชนิดข้อมูลสำหรับสถานะว่าง เช่น "ใบสั่งซื้อ" → "ยังไม่มีใบสั่งซื้อ" (`quiet`) */
|
|
126
|
+
resourceName?: string;
|
|
127
|
+
/** `quiet`: ปุ่ม/ลิงก์ในสถานะ "ว่างจริง" (ไม่มีข้อมูลและไม่มีตัวกรอง) เช่น ปุ่ม "สร้างใบสั่งซื้อ" — ไม่ส่ง = ไม่มีปุ่ม */
|
|
128
|
+
emptyAction?: React.ReactNode;
|
|
129
|
+
/** โหลดไม่สำเร็จ: แสดงสถานะผิดพลาดแทนตาราง (ทั้งสอง variant) — ข้อความกลาง ๆ ไม่แสดง error ดิบ */
|
|
130
|
+
error?: boolean;
|
|
131
|
+
/** ปุ่ม "ลองใหม่" ของสถานะผิดพลาด — ไม่ส่ง = ไม่มีปุ่ม */
|
|
132
|
+
onRetry?: () => void;
|
|
120
133
|
/** `quiet`: ปุ่มคำสั่งในแคปซูลเลือกหมู่ (ส่งออก/ลบ …) — ไม่ส่ง = แคปซูลมีแค่จำนวนที่เลือกและปุ่มล้าง */
|
|
121
134
|
selectionActions?: (table: Table<TData>) => React.ReactNode;
|
|
122
135
|
}
|
|
@@ -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,27 @@ export interface UiText {
|
|
|
85
85
|
rangeOf: string;
|
|
86
86
|
shownOf: string;
|
|
87
87
|
density: string;
|
|
88
|
+
/** `variant="quiet"` มือถือ — `{count}` */
|
|
89
|
+
selectedCount: string;
|
|
90
|
+
/** สถานะตาราง: `emptyTitleOf` ใช้ `{resource}`, `noMatchQuery` ใช้ `{query}` */
|
|
91
|
+
loading: string;
|
|
92
|
+
emptyTitle: string;
|
|
93
|
+
emptyTitleOf: string;
|
|
94
|
+
emptyHint: string;
|
|
95
|
+
noMatch: string;
|
|
96
|
+
noMatchQuery: string;
|
|
97
|
+
noMatchHint: string;
|
|
98
|
+
clearAllFilters: string;
|
|
99
|
+
clearQuery: string;
|
|
100
|
+
errorTitle: string;
|
|
101
|
+
errorHint: string;
|
|
102
|
+
retry: string;
|
|
103
|
+
/** ข้อความในแคปซูลเลือกหมู่ (สั้นกว่า `selectedCount` เพราะหัวรายการมือถือบอก "เลือก N รายการ" อยู่แล้ว) */
|
|
104
|
+
selectedPill: string;
|
|
105
|
+
selectAll: string;
|
|
106
|
+
longPressHint: string;
|
|
107
|
+
clearFilters: string;
|
|
108
|
+
showResults: string;
|
|
88
109
|
rowsSelected: string;
|
|
89
110
|
noResults: string;
|
|
90
111
|
deleteTitle: string;
|
|
@@ -323,6 +344,24 @@ export const UI_TEXT_TH: UiText = {
|
|
|
323
344
|
rangeOf: "{from}–{to} จาก {total}",
|
|
324
345
|
shownOf: "{shown} จาก {total} รายการ",
|
|
325
346
|
density: "ความหนาแน่นของแถว",
|
|
347
|
+
selectedCount: "เลือก {count} รายการ",
|
|
348
|
+
loading: "กำลังโหลด…",
|
|
349
|
+
emptyTitle: "ยังไม่มีรายการ",
|
|
350
|
+
emptyTitleOf: "ยังไม่มี{resource}",
|
|
351
|
+
emptyHint: "เมื่อสร้างแล้วจะแสดงที่นี่",
|
|
352
|
+
noMatch: "ไม่พบรายการที่ตรงกัน",
|
|
353
|
+
noMatchQuery: 'ไม่พบรายการที่ตรงกับ "{query}"',
|
|
354
|
+
noMatchHint: "ลองคำค้นอื่น หรือเอาตัวกรองออกบางตัว",
|
|
355
|
+
clearAllFilters: "ล้างตัวกรองทั้งหมด",
|
|
356
|
+
clearQuery: "ล้างคำค้น",
|
|
357
|
+
errorTitle: "โหลดรายการไม่สำเร็จ",
|
|
358
|
+
errorHint: "การเชื่อมต่อขัดข้อง ลองใหม่อีกครั้ง",
|
|
359
|
+
retry: "ลองใหม่",
|
|
360
|
+
selectedPill: "{count} รายการ",
|
|
361
|
+
selectAll: "เลือกทั้งหมด",
|
|
362
|
+
longPressHint: "กดค้างที่แถวเพื่อเลือกหมู่",
|
|
363
|
+
clearFilters: "ล้าง",
|
|
364
|
+
showResults: "ดู {count} รายการ",
|
|
326
365
|
rowsSelected: "เลือก {selected} จาก {total} แถว",
|
|
327
366
|
noResults: "ไม่พบข้อมูล",
|
|
328
367
|
deleteTitle: "ยืนยันการลบ",
|
|
@@ -540,6 +579,24 @@ export const UI_TEXT_EN: UiText = {
|
|
|
540
579
|
rangeOf: "{from}–{to} of {total}",
|
|
541
580
|
shownOf: "{shown} of {total} items",
|
|
542
581
|
density: "Row density",
|
|
582
|
+
selectedCount: "{count} selected",
|
|
583
|
+
loading: "Loading…",
|
|
584
|
+
emptyTitle: "Nothing here yet",
|
|
585
|
+
emptyTitleOf: "No {resource} yet",
|
|
586
|
+
emptyHint: "Items will show up here once created.",
|
|
587
|
+
noMatch: "No matching items",
|
|
588
|
+
noMatchQuery: 'No items match "{query}"',
|
|
589
|
+
noMatchHint: "Try another search, or remove some filters.",
|
|
590
|
+
clearAllFilters: "Clear all filters",
|
|
591
|
+
clearQuery: "Clear search",
|
|
592
|
+
errorTitle: "Couldn't load the list",
|
|
593
|
+
errorHint: "The connection dropped. Try again.",
|
|
594
|
+
retry: "Try again",
|
|
595
|
+
selectedPill: "{count} items",
|
|
596
|
+
selectAll: "Select all",
|
|
597
|
+
longPressHint: "Press and hold a row to select several",
|
|
598
|
+
clearFilters: "Clear",
|
|
599
|
+
showResults: "Show {count} items",
|
|
543
600
|
rowsSelected: "{selected} of {total} row(s) selected",
|
|
544
601
|
noResults: "No results",
|
|
545
602
|
deleteTitle: "Are you sure?",
|