@suphark/ui 0.27.1 → 0.29.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.
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
5
+ import { cn } from "@suphark/ui/lib/utils";
6
+ import { XIcon } from "lucide-react";
7
+ import type { ReactNode } from "react";
8
+
9
+ export interface SelectionAction {
10
+ label: string;
11
+ onClick: () => void;
12
+ icon?: ReactNode;
13
+ destructive?: boolean;
14
+ disabled?: boolean;
15
+ }
16
+
17
+ export interface SelectionBarProps {
18
+ /** จำนวนที่เลือก — 0 = ไม่แสดงอะไรเลย */
19
+ count: number;
20
+ /** ล้างการเลือก (เช่น `table.resetRowSelection`) */
21
+ onClear: () => void;
22
+ /** ปุ่มคำสั่งหมู่ — ใส่เป็น array หรือจะส่ง `children` เป็น element เองก็ได้ */
23
+ actions?: ReadonlyArray<SelectionAction>;
24
+ children?: ReactNode;
25
+ /** ข้อความนับ ใช้ `{count}` เช่น "เลือก {count} รายการ" (ไม่ส่ง = `UiText.selection.selected`) */
26
+ label?: string;
27
+ /** floating = แถบลอยกลางล่างจอ (ค่าเริ่มต้น) · inline = แถบในหน้า เหนือตาราง */
28
+ variant?: "floating" | "inline";
29
+ className?: string;
30
+ }
31
+
32
+ /**
33
+ * แถบคำสั่งหมู่ที่โผล่เมื่อเลือกหลายแถว — "เลือก N รายการ" + ปุ่มคำสั่ง + ปุ่มล้าง
34
+ * ใช้คู่ `DataTable` (`table.getFilteredSelectedRowModel().rows`) หรือ `SimpleTable` ที่แอปคุม state เอง
35
+ * คำสั่งที่ย้อนกลับไม่ได้ให้ส่ง element ของ `ConfirmActionButton`/`DeleteManyButton` ผ่าน `children`
36
+ *
37
+ * <SelectionBar count={selected.length} onClear={() => table.resetRowSelection()}
38
+ * actions={[{ label: "ส่งออก", onClick: exportRows }, { label: "ลบ", destructive: true, onClick: removeRows }]} />
39
+ */
40
+ export function SelectionBar({
41
+ count,
42
+ onClear,
43
+ actions,
44
+ children,
45
+ label,
46
+ variant = "floating",
47
+ className,
48
+ }: SelectionBarProps) {
49
+ const text = useUiText();
50
+ if (count <= 0) return null;
51
+
52
+ const template = label ?? text.selection?.selected ?? "เลือก {count} รายการ";
53
+ const message = template.replace("{count}", String(count));
54
+
55
+ return (
56
+ <div
57
+ data-slot="selection-bar"
58
+ // อ่านออกเสียงจำนวนที่เลือกให้ screen reader โดยไม่ขัดจังหวะ
59
+ aria-live="polite"
60
+ className={cn(
61
+ "flex items-center gap-2 rounded-xl border bg-card px-3 py-2 shadow-lg",
62
+ variant === "floating" &&
63
+ "-translate-x-1/2 fixed bottom-[calc(1rem+env(safe-area-inset-bottom))] left-1/2 z-40 max-w-[calc(100vw-2rem)]",
64
+ className,
65
+ )}
66
+ >
67
+ <span className="shrink-0 font-medium text-sm tabular-nums">{message}</span>
68
+ <div className="flex min-w-0 flex-wrap items-center gap-1.5">
69
+ {actions?.map((action) => (
70
+ <Button
71
+ key={action.label}
72
+ size="sm"
73
+ variant={action.destructive ? "destructive" : "outline"}
74
+ disabled={action.disabled}
75
+ onClick={action.onClick}
76
+ >
77
+ {action.icon}
78
+ {action.label}
79
+ </Button>
80
+ ))}
81
+ {children}
82
+ </div>
83
+ <Button
84
+ variant="ghost"
85
+ size="icon-sm"
86
+ aria-label={text.selection?.clear ?? "ล้างการเลือก"}
87
+ onClick={onClear}
88
+ className="ml-auto shrink-0"
89
+ >
90
+ <XIcon />
91
+ </Button>
92
+ </div>
93
+ );
94
+ }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { Checkbox } from "@suphark/ui/components/ui/checkbox";
3
4
  import {
4
5
  Table,
5
6
  TableBody,
@@ -38,6 +39,10 @@ export function SimpleTable<T>({
38
39
  getRowKey,
39
40
  empty = "ไม่มีข้อมูล",
40
41
  onRowClick,
42
+ selected,
43
+ onSelectedChange,
44
+ selectLabel = "เลือกแถว",
45
+ selectAllLabel = "เลือกทั้งหมด",
41
46
  caption,
42
47
  className,
43
48
  }: {
@@ -45,6 +50,13 @@ export function SimpleTable<T>({
45
50
  rows: ReadonlyArray<T>;
46
51
  getRowKey: (row: T, index: number) => string | number;
47
52
  empty?: ReactNode;
53
+ /** คีย์ของแถวที่เลือก — ส่งคู่กับ `onSelectedChange` เพื่อเปิดช่องติ๊กหน้าแถว (ใช้คู่ `SelectionBar`) */
54
+ selected?: ReadonlyArray<string | number>;
55
+ onSelectedChange?: (selected: Array<string | number>) => void;
56
+ /** aria-label ของช่องติ๊กแต่ละแถว (ค่าเริ่มต้น "เลือกแถว") */
57
+ selectLabel?: string;
58
+ /** aria-label ของช่องติ๊กบนหัวตาราง (ค่าเริ่มต้น "เลือกทั้งหมด") */
59
+ selectAllLabel?: string;
48
60
  /** คลิกทั้งแถว (เช่นเปิดรายละเอียด) — ปุ่มในแถวควร stopPropagation เอง */
49
61
  onRowClick?: (row: T) => void;
50
62
  /** คำอธิบายตารางสำหรับ screen reader (sr-only) */
@@ -53,12 +65,37 @@ export function SimpleTable<T>({
53
65
  }) {
54
66
  const alignClass = (a?: "left" | "center" | "right") =>
55
67
  a === "right" ? "text-right tabular-nums" : a === "center" ? "text-center" : undefined;
68
+
69
+ const selectable = Boolean(onSelectedChange);
70
+ const selectedKeys = new Set(selected ?? []);
71
+ const allKeys = rows.map((row, i) => getRowKey(row, i));
72
+ const allSelected = allKeys.length > 0 && allKeys.every((k) => selectedKeys.has(k));
73
+ const someSelected = !allSelected && allKeys.some((k) => selectedKeys.has(k));
74
+
75
+ function toggleRow(key: string | number, checked: boolean) {
76
+ const next = new Set(selectedKeys);
77
+ if (checked) next.add(key);
78
+ else next.delete(key);
79
+ onSelectedChange?.([...next]);
80
+ }
56
81
  return (
57
82
  <div className={cn("overflow-hidden rounded-xl border bg-card", className)}>
58
83
  <Table>
59
84
  {caption && <caption className="sr-only">{caption}</caption>}
60
85
  <TableHeader>
61
86
  <TableRow>
87
+ {selectable && (
88
+ <TableHead className="w-10">
89
+ <Checkbox
90
+ aria-label={selectAllLabel}
91
+ checked={allSelected}
92
+ indeterminate={someSelected}
93
+ // ครึ่งหนึ่ง: ซ่อนเครื่องหมายถูก แสดงขีดแทน (ไฟล์ shadcn ไม่มีไอคอนขีด)
94
+ className="data-indeterminate:border-primary data-indeterminate:bg-primary data-indeterminate:text-primary-foreground data-indeterminate:before:absolute data-indeterminate:before:h-0.5 data-indeterminate:before:w-2 data-indeterminate:before:rounded-full data-indeterminate:before:bg-current [&[data-indeterminate]_svg]:hidden"
95
+ onCheckedChange={(checked) => onSelectedChange?.(checked ? allKeys : [])}
96
+ />
97
+ </TableHead>
98
+ )}
62
99
  {columns.map((c) => (
63
100
  <TableHead key={c.key} className={cn(alignClass(c.align), c.className)}>
64
101
  {c.header}
@@ -68,14 +105,26 @@ export function SimpleTable<T>({
68
105
  </TableHeader>
69
106
  <TableBody>
70
107
  {rows.length === 0 ? (
71
- <EmptyRow colSpan={columns.length}>{empty}</EmptyRow>
108
+ <EmptyRow colSpan={columns.length + (selectable ? 1 : 0)}>{empty}</EmptyRow>
72
109
  ) : (
73
110
  rows.map((row, i) => (
74
111
  <TableRow
75
112
  key={getRowKey(row, i)}
76
113
  onClick={onRowClick ? () => onRowClick(row) : undefined}
77
- className={cn(onRowClick && "cursor-pointer")}
114
+ data-selected={selectedKeys.has(getRowKey(row, i)) || undefined}
115
+ className={cn(onRowClick && "cursor-pointer", "data-selected:bg-muted/50")}
78
116
  >
117
+ {selectable && (
118
+ <TableCell className="w-10">
119
+ <Checkbox
120
+ aria-label={selectLabel}
121
+ checked={selectedKeys.has(getRowKey(row, i))}
122
+ onCheckedChange={(checked) => toggleRow(getRowKey(row, i), checked === true)}
123
+ // คลิกช่องติ๊กไม่ให้ไปโดน onRowClick ของทั้งแถว
124
+ onClick={(event) => event.stopPropagation()}
125
+ />
126
+ </TableCell>
127
+ )}
79
128
  {columns.map((c) => (
80
129
  <TableCell key={c.key} className={cn(alignClass(c.align), c.className)}>
81
130
  {c.cell
@@ -8,7 +8,7 @@ import {
8
8
  } from "@suphark/ui/components/ui/accordion";
9
9
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@suphark/ui/components/ui/tabs";
10
10
  import { cn } from "@suphark/ui/lib/utils";
11
- import type { ReactNode } from "react";
11
+ import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
12
12
 
13
13
  export interface SimpleTabItem {
14
14
  value: string;
@@ -49,6 +49,26 @@ export function SimpleTabs({
49
49
  className?: string;
50
50
  listClassName?: string;
51
51
  }) {
52
+ const listRef = useRef<HTMLDivElement>(null);
53
+ const [edges, setEdges] = useState({ start: false, end: false });
54
+
55
+ const updateEdges = useCallback(() => {
56
+ const el = listRef.current;
57
+ if (!el) return;
58
+ const max = el.scrollWidth - el.clientWidth;
59
+ // 4px กันเศษจากการปัดทศนิยม
60
+ setEdges({ start: el.scrollLeft > 4, end: el.scrollLeft < max - 4 });
61
+ }, []);
62
+
63
+ useEffect(() => {
64
+ updateEdges();
65
+ const el = listRef.current;
66
+ if (!el || typeof ResizeObserver === "undefined") return;
67
+ const observer = new ResizeObserver(updateEdges);
68
+ observer.observe(el);
69
+ return () => observer.disconnect();
70
+ }, [updateEdges]);
71
+
52
72
  return (
53
73
  <Tabs
54
74
  value={value}
@@ -57,18 +77,39 @@ export function SimpleTabs({
57
77
  onValueChange={(v) => onValueChange?.(String(v))}
58
78
  className={className}
59
79
  >
60
- <TabsList
61
- variant={variant}
62
- className={cn(fullWidth && "w-full", "max-w-full overflow-x-auto", listClassName)}
63
- >
64
- {items.map((item) => (
65
- <TabsTrigger key={item.value} value={item.value} disabled={item.disabled}>
66
- {item.icon}
67
- {item.label}
68
- {item.badge}
69
- </TabsTrigger>
70
- ))}
71
- </TabsList>
80
+ {/* แท็บเยอะเกินความกว้าง = เลื่อนแนวนอนได้ + เงาบอกว่ายังมีต่อ (ซ่อนแถบเลื่อน) */}
81
+ <div className="relative">
82
+ <TabsList
83
+ ref={listRef}
84
+ onScroll={updateEdges}
85
+ variant={variant}
86
+ className={cn(
87
+ fullWidth && "w-full",
88
+ "no-scrollbar max-w-full overflow-x-auto scroll-smooth",
89
+ listClassName,
90
+ )}
91
+ >
92
+ {items.map((item) => (
93
+ <TabsTrigger key={item.value} value={item.value} disabled={item.disabled}>
94
+ {item.icon}
95
+ {item.label}
96
+ {item.badge}
97
+ </TabsTrigger>
98
+ ))}
99
+ </TabsList>
100
+ {edges.start && (
101
+ <span
102
+ aria-hidden="true"
103
+ className="pointer-events-none absolute inset-y-0 left-0 w-6 rounded-l-lg bg-gradient-to-r from-background to-transparent"
104
+ />
105
+ )}
106
+ {edges.end && (
107
+ <span
108
+ aria-hidden="true"
109
+ className="pointer-events-none absolute inset-y-0 right-0 w-6 rounded-r-lg bg-gradient-to-l from-background to-transparent"
110
+ />
111
+ )}
112
+ </div>
72
113
  {items.map((item) => (
73
114
  <TabsContent key={item.value} value={item.value}>
74
115
  {item.content}
@@ -20,6 +20,33 @@ export interface UiText {
20
20
  confirmTitle?: string;
21
21
  /** RowActions/ConfirmActionButton: action โยน error (optional — ค่าเริ่มต้น "ดำเนินการไม่สำเร็จ") */
22
22
  actionFailed?: string;
23
+ /** InlineEdit: ปุ่มดินสอ (optional — "แก้ไข") */
24
+ edit?: string;
25
+ /** SortableList (optional) — ที่จับลาก + คำอธิบายสำหรับ screen reader */
26
+ sortable?: Partial<{
27
+ handle: string;
28
+ instructions: string;
29
+ /** ใช้ `{index}` / `{total}` */
30
+ started: string;
31
+ moved: string;
32
+ dropped: string;
33
+ cancelled: string;
34
+ }>;
35
+ /** SelectionBar (optional) — `selected` ใช้ `{count}` */
36
+ selection?: Partial<{ selected: string; clear: string }>;
37
+ /** DataTable (optional) — `rowsSelected` ใช้ `{selected}`/`{total}`, `pageOf` ใช้ `{page}`/`{pages}` */
38
+ table?: Partial<{
39
+ rowsPerPage: string;
40
+ pageOf: string;
41
+ rowsSelected: string;
42
+ noResults: string;
43
+ deleteTitle: string;
44
+ /** ใช้ `{name}` และ `{resource}` (ชื่อชนิดข้อมูลที่ส่งมาทาง `resourceName`) */
45
+ deleteDescription: string;
46
+ deleting: string;
47
+ deleted: string;
48
+ delete: string;
49
+ }>;
23
50
  /** DialogsProvider: ปุ่มปิดของ alert (optional — ค่าเริ่มต้น "ตกลง") */
24
51
  ok?: string;
25
52
  /** DialogsProvider: หัวกล่อง alert (optional — ค่าเริ่มต้น "แจ้งให้ทราบ") */
@@ -135,6 +162,27 @@ export const UI_TEXT_TH: UiText = {
135
162
  confirm: "ยืนยัน",
136
163
  confirmTitle: "ยืนยันการทำรายการ",
137
164
  actionFailed: "ดำเนินการไม่สำเร็จ",
165
+ edit: "แก้ไข",
166
+ selection: { selected: "เลือก {count} รายการ", clear: "ล้างการเลือก" },
167
+ sortable: {
168
+ handle: "ลากเพื่อจัดลำดับ",
169
+ instructions: "กด Space เพื่อเริ่มลาก ใช้ปุ่มลูกศรเลื่อน แล้วกด Space อีกครั้งเพื่อวาง (Esc = ยกเลิก)",
170
+ started: "เริ่มลากรายการที่ {index} จาก {total}",
171
+ moved: "ย้ายไปตำแหน่งที่ {index} จาก {total}",
172
+ dropped: "วางที่ตำแหน่งที่ {index} จาก {total}",
173
+ cancelled: "ยกเลิกการลาก",
174
+ },
175
+ table: {
176
+ rowsPerPage: "แถวต่อหน้า",
177
+ pageOf: "หน้า {page} จาก {pages}",
178
+ rowsSelected: "เลือก {selected} จาก {total} แถว",
179
+ noResults: "ไม่พบข้อมูล",
180
+ deleteTitle: "ยืนยันการลบ",
181
+ deleteDescription: "ลบ{resource} “{name}” แล้วกู้คืนไม่ได้",
182
+ deleting: "กำลังลบ{resource} “{name}”…",
183
+ deleted: "ลบ{resource} “{name}” แล้ว",
184
+ delete: "ลบ",
185
+ },
138
186
  ok: "ตกลง",
139
187
  alertTitle: "แจ้งให้ทราบ",
140
188
  promptTitle: "กรอกข้อมูล",
@@ -233,6 +281,28 @@ export const UI_TEXT_EN: UiText = {
233
281
  confirm: "Confirm",
234
282
  confirmTitle: "Please confirm",
235
283
  actionFailed: "Action failed",
284
+ edit: "Edit",
285
+ selection: { selected: "{count} selected", clear: "Clear selection" },
286
+ sortable: {
287
+ handle: "Drag to reorder",
288
+ instructions:
289
+ "Press Space to start dragging, use arrow keys to move, Space again to drop (Esc to cancel)",
290
+ started: "Picked up item {index} of {total}",
291
+ moved: "Moved to position {index} of {total}",
292
+ dropped: "Dropped at position {index} of {total}",
293
+ cancelled: "Dragging cancelled",
294
+ },
295
+ table: {
296
+ rowsPerPage: "Rows per page",
297
+ pageOf: "Page {page} of {pages}",
298
+ rowsSelected: "{selected} of {total} row(s) selected",
299
+ noResults: "No results",
300
+ deleteTitle: "Are you sure?",
301
+ deleteDescription: "Deleting{resource} “{name}” cannot be undone",
302
+ deleting: "Deleting{resource} “{name}”…",
303
+ deleted: "“{name}”{resource} deleted",
304
+ delete: "Delete",
305
+ },
236
306
  ok: "OK",
237
307
  alertTitle: "Notice",
238
308
  promptTitle: "Input required",