@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.59.1",
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={<Button variant="ghost" size="icon-sm" className="absolute top-3 right-3" />}
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
- {renderFilterControls(true)}
176
+ {variant === "quiet" && facetedFilters ? (
177
+ <DataTableFilterSections table={table} filters={facetedFilters} />
178
+ ) : (
179
+ renderFilterControls(true)
180
+ )}
170
181
  {toolbarFilters}
171
182
  </div>
172
- {isFiltered && (
173
- <SheetFooter className="border-t">
174
- <Button variant="secondary" onClick={resetUserFilters}>
175
- <X />
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, setDensity] = React.useState<"comfortable" | "compact">(defaultDensity);
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
- /** `variant="quiet"` บนมือถือ: รายการแถวละ 3 บรรทัดจากคอลัมน์ที่ใส่ `meta.mobile` */
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
- <ul className="divide-y overflow-hidden rounded-xl border bg-card sm:hidden">
370
- {rows.map((row) => {
371
- const cells = row.getVisibleCells();
372
- const pick = (slot: DataTableMobileSlot) =>
373
- cells.filter((cell) => slotOf(cell.column) === slot);
374
- const select = cells.find((cell) => cell.column.id === "select");
375
- return (
376
- <li
377
- key={row.id}
378
- data-state={row.getIsSelected() ? "selected" : undefined}
379
- className="flex items-start gap-3 px-4 py-2.5 data-[state=selected]:bg-primary/[0.07] data-[state=selected]:shadow-[inset_3px_0_0_var(--primary)]"
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
- {select && (
382
- // ขยายพื้นที่แตะเป็น 44px (ช่องติ๊กจริงเล็ก 16px); แตะส่วนที่เกินช่องก็สลับการเลือก
383
- // biome-ignore lint/a11y/useKeyWithClickEvents: คีย์บอร์ดใช้ช่องติ๊กข้างใน
384
- // biome-ignore lint/a11y/noStaticElementInteractions: เป็นแค่พื้นที่แตะเสริม
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
- className="-m-3.5 -mt-3 shrink-0 p-3.5"
387
- onClick={(event) => {
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
- {flexRender(select.column.columnDef.cell, select.getContext())}
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
- <div className="flex min-w-0 flex-1 flex-col gap-0.5">
395
- {SLOT_ORDER.map((slot) => {
396
- const found = pick(slot);
397
- if (slot === "meta" || found.length === 0) return null;
398
- const content = found.map((cell) => (
399
- <span key={cell.id}>
400
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
401
- </span>
402
- ));
403
- if (slot === "title") {
404
- const end = pick("end");
405
- return (
406
- <div
407
- key={slot}
408
- className="flex items-baseline justify-between gap-2 font-semibold text-sm"
409
- >
410
- <span className="min-w-0 truncate">{content}</span>
411
- <span className="shrink-0 tabular-nums">
412
- {end.map((cell) => (
413
- <span key={cell.id}>
414
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
415
- </span>
416
- ))}
417
- </span>
418
- </div>
419
- );
420
- }
421
- if (slot === "end") return null;
422
- if (slot === "sub") {
423
- return (
424
- <div key={slot} className="truncate text-muted-foreground text-sm">
425
- {content}
426
- </div>
427
- );
428
- }
429
- const meta = pick("meta");
430
- return (
431
- <div key={slot} className="flex items-center gap-1.5 text-xs">
432
- {content}
433
- {meta.map((cell) => (
434
- <span key={cell.id} className="text-muted-foreground">
435
- · {flexRender(cell.column.columnDef.cell, cell.getContext())}
436
- </span>
437
- ))}
438
- </div>
439
- );
440
- })}
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
- </li>
443
- );
444
- })}
445
- </ul>
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 className="flex min-w-0 flex-wrap items-center gap-1.5">
75
+ <div
76
+ className={cn(
77
+ "flex min-w-0 items-center gap-1.5",
78
+ // pill: บรรทัดเดียวเสมอ ปุ่มไม่ตัดคำ และปุ่มที่แอปส่งมาใช้สีตัวอักษรบนพื้นสีหลัก
79
+ pill
80
+ ? "[&_button]:whitespace-nowrap [&_button]:text-primary-foreground [&_button]:hover:bg-primary-foreground/15 pointer-coarse:[&_button]:h-11"
81
+ : "flex-wrap",
82
+ )}
83
+ >
76
84
  {actions?.map((action) => (
77
85
  <Button
78
86
  key={action.label}
@@ -98,6 +106,7 @@ export function SelectionBar({
98
106
  onClick={onClear}
99
107
  className={cn(
100
108
  "ml-auto shrink-0",
109
+ pill && "pointer-coarse:h-11 pointer-coarse:w-11",
101
110
  pill && "rounded-full hover:bg-primary-foreground/15 hover:text-primary-foreground",
102
111
  )}
103
112
  >
@@ -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?",