@suphark/ui 0.27.1 → 0.28.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 CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.28.0] — 2026-10-05
6
+
7
+ ### Added
8
+
9
+ - **`ActivityFeed`** — เส้นเวลา "ใครทำอะไรเมื่อไหร่" จาก array เดียว (audit log ของ Pi Auth, ประวัติงานของ Pi Done, timeline ของเอกสาร): avatar ของผู้ทำหรือไอคอน+โทนสำหรับเหตุการณ์ของระบบ, เวลาสัมพัทธ์ที่คำนวณฝั่ง client หลัง mount (ไม่ทำ hydration mismatch) และช่องใส่ปุ่ม/ป้ายท้ายแถว
10
+ - **`InlineEdit`** — แก้ค่าในที่: คลิกดินสอ → ช่องกรอก (เลือกข้อความเดิมไว้ให้พิมพ์ทับได้ทันที) → Enter/✓ บันทึก, Esc/✕ ยกเลิก พร้อม `validate`, สถานะกำลังบันทึก และ toast เมื่อล้มเหลว (ถือ `{ ok: false, error }` เป็นล้มเหลว, `redirect()` เป็นสำเร็จ) — ใช้กับค่าสั้น ๆ ที่ไม่คุ้มเปิด `FormDialog`
11
+ - **`SelectionBar`** — แถบคำสั่งหมู่ "เลือก N รายการ" + ปุ่มคำสั่ง + ปุ่มล้าง (`floating` กลางล่างจอ หรือ `inline` ในหน้า) ใช้คู่ `DataTable` หรือ `SimpleTable`; ข้อความจาก `UiText.selection`
12
+ - **`SimpleTable` เลือกหลายแถวได้** — `selected` + `onSelectedChange` เปิดช่องติ๊กหน้าแถวและหัวตาราง (เลือกบางส่วนแสดงเป็นขีด), แถวที่เลือกมีพื้นจาง; คลิกช่องติ๊กไม่ไปโดน `onRowClick`
13
+ - `SimpleTabs`: แท็บล้นความกว้างแล้วเลื่อนแนวนอนได้ (ซ่อนแถบเลื่อน) + เงาขอบบอกว่ายังมีแท็บต่อ
14
+ - `UiText` คีย์ใหม่: `edit`, `selection.{selected,clear}`, `table.{rowsPerPage,pageOf,rowsSelected,noResults,deleteTitle,deleteDescription,deleting,deleted,delete}`
15
+
16
+ ### Fixed
17
+
18
+ - **`DataTable` เลิกใช้ข้อความอังกฤษตายตัว** — "Rows per page", "Page X of Y", "N of M row(s) selected", "No results.", เมนู Edit/Delete, กล่องยืนยันลบ และ toast ตอนลบ ทั้งหมดมาจาก `UiText.table` (ค่าเริ่มต้นภาษาไทย) — `resourceName` แทรกผ่าน `{resource}`
19
+
5
20
  ## [0.27.1] — 2026-10-05
6
21
 
7
22
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.27.1",
3
+ "version": "0.28.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 เอง",
@@ -36,13 +36,16 @@ export function DataTablePagination<TData>({
36
36
  >
37
37
  {isRowSelectionEnabled && (
38
38
  <div className="flex-1 text-muted-foreground text-sm max-sm:text-center">
39
- {table.getFilteredSelectedRowModel().rows.length} of{" "}
40
- {table.getFilteredRowModel().rows.length} row(s) selected.
39
+ {(text.table?.rowsSelected ?? "เลือก {selected} จาก {total} แถว")
40
+ .replace("{selected}", String(table.getFilteredSelectedRowModel().rows.length))
41
+ .replace("{total}", String(table.getFilteredRowModel().rows.length))}
41
42
  </div>
42
43
  )}
43
44
  <div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 lg:gap-8">
44
45
  <div className="flex items-center gap-2">
45
- <p className="font-medium text-sm max-sm:hidden">Rows per page</p>
46
+ <p className="font-medium text-sm max-sm:hidden">
47
+ {text.table?.rowsPerPage ?? "แถวต่อหน้า"}
48
+ </p>
46
49
  <Select
47
50
  value={`${table.getState().pagination.pageSize}`}
48
51
  onValueChange={(value) => {
@@ -66,7 +69,9 @@ export function DataTablePagination<TData>({
66
69
  </Select>
67
70
  </div>
68
71
  <div className="flex w-[100px] items-center justify-center font-medium text-sm">
69
- Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
72
+ {(text.table?.pageOf ?? "หน้า {page} จาก {pages}")
73
+ .replace("{page}", String(table.getState().pagination.pageIndex + 1))
74
+ .replace("{pages}", String(table.getPageCount()))}
70
75
  </div>
71
76
  <div className="flex items-center gap-2">
72
77
  <Button
@@ -76,6 +76,10 @@ export function DataTableRowActions<TData>({
76
76
  idKey = "id" as keyof TData,
77
77
  }: DataTableRowActionsProps<TData>) {
78
78
  const text = useUiText();
79
+ // ชื่อชนิดข้อมูล (เช่น "โปรเจกต์") แทรกในข้อความลบ — ไม่ส่งมา = ข้อความไม่มีคำนี้
80
+ const resource = resourceName ? ` ${resourceName}` : "";
81
+ const fill = (template: string, name: string) =>
82
+ template.replace("{resource}", resource).replace("{name}", name);
79
83
  const item = row.original;
80
84
  const itemRecord = item as Record<string, unknown>;
81
85
  const itemName = String(
@@ -96,8 +100,8 @@ export function DataTableRowActions<TData>({
96
100
  });
97
101
 
98
102
  toast.promise(promise, {
99
- loading: `Deleting ${resourceName.toLowerCase()} "${itemName}"...`,
100
- success: `${resourceName} "${itemName}" deleted successfully.`,
103
+ loading: fill(text.table?.deleting ?? "กำลังลบ{resource} “{name}”…", itemName),
104
+ success: fill(text.table?.deleted ?? "ลบ{resource} “{name}” แล้ว", itemName),
101
105
  error: (err: Error) => err.message,
102
106
  });
103
107
  });
@@ -125,7 +129,7 @@ export function DataTableRowActions<TData>({
125
129
  {EditDialog && (
126
130
  <DropdownMenuItem onClick={() => setIsEditDialogOpen(true)}>
127
131
  <Pen className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
128
- Edit
132
+ {text.edit ?? "แก้ไข"}
129
133
  </DropdownMenuItem>
130
134
  )}
131
135
 
@@ -137,7 +141,7 @@ export function DataTableRowActions<TData>({
137
141
  onClick={() => setIsDeleteDialogOpen(true)}
138
142
  >
139
143
  <Trash className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
140
- Delete
144
+ {text.table?.delete ?? "ลบ"}
141
145
  </DropdownMenuItem>
142
146
  )}
143
147
  </DropdownMenuGroup>
@@ -153,15 +157,16 @@ export function DataTableRowActions<TData>({
153
157
  <AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
154
158
  <AlertDialogContent>
155
159
  <AlertDialogHeader>
156
- <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
160
+ <AlertDialogTitle>{text.table?.deleteTitle ?? "ยืนยันการลบ"}</AlertDialogTitle>
157
161
  <AlertDialogDescription>
158
- This action cannot be undone. This will permanently delete the{" "}
159
- {resourceName.toLowerCase()} &ldquo;{itemName}&rdquo; and remove it from our
160
- servers.
162
+ {fill(
163
+ text.table?.deleteDescription ?? "ลบ{resource} “{name}” แล้วกู้คืนไม่ได้",
164
+ itemName,
165
+ )}
161
166
  </AlertDialogDescription>
162
167
  </AlertDialogHeader>
163
168
  <AlertDialogFooter>
164
- <AlertDialogCancel>Cancel</AlertDialogCancel>
169
+ <AlertDialogCancel>{text.cancel}</AlertDialogCancel>
165
170
  <DeleteButton onClick={handleDelete} isLoading={isPending} />
166
171
  </AlertDialogFooter>
167
172
  </AlertDialogContent>
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
3
4
  import {
4
5
  TableBody,
5
6
  TableCell,
@@ -80,6 +81,7 @@ export function DataTable<TData, TValue>({
80
81
  onReorder,
81
82
  renderCustomView,
82
83
  }: DataTableProps<TData, TValue>) {
84
+ const text = useUiText();
83
85
  const { table } = useDataTable({
84
86
  columns,
85
87
  data,
@@ -221,7 +223,7 @@ export function DataTable<TData, TValue>({
221
223
  ) : (
222
224
  <TableRow>
223
225
  <TableCell colSpan={table.getVisibleLeafColumns().length} className="h-24 text-center">
224
- {emptyStateComponent || "No results."}
226
+ {emptyStateComponent || (text.table?.noResults ?? "ไม่พบข้อมูล")}
225
227
  </TableCell>
226
228
  </TableRow>
227
229
  )}
@@ -0,0 +1,114 @@
1
+ "use client";
2
+
3
+ import { Initials } from "@suphark/ui/components/shared/initials";
4
+ import { ClientDateFormatter } from "@suphark/ui/components/shared/time/client-date-formatter";
5
+ import { cn } from "@suphark/ui/lib/utils";
6
+ import type { ReactNode } from "react";
7
+
8
+ export type ActivityTone = "default" | "info" | "success" | "warning" | "destructive";
9
+
10
+ export interface ActivityItem {
11
+ id: string;
12
+ /** สิ่งที่เกิดขึ้น เช่น "แก้ไขสิทธิ์ของสมชาย" */
13
+ title: ReactNode;
14
+ /** รายละเอียดเพิ่ม (ก่อน → หลัง, เหตุผล, IP) */
15
+ description?: ReactNode;
16
+ /** ชื่อผู้ทำ — ไม่ส่ง = เหตุการณ์ของระบบ */
17
+ actor?: string | null;
18
+ /** รูปผู้ทำ (ไม่มี = ตัวย่อชื่อ) */
19
+ actorSrc?: string | null;
20
+ at: Date | string | number;
21
+ /** ไอคอนแทน avatar (เหตุการณ์ของระบบ เช่น `<KeyIcon />`) */
22
+ icon?: ReactNode;
23
+ tone?: ActivityTone;
24
+ /** มุมขวาของแถว เช่น `<StatusBadge>` หรือปุ่มดูรายละเอียด */
25
+ action?: ReactNode;
26
+ }
27
+
28
+ const DOT_TONE: Record<ActivityTone, string> = {
29
+ default: "bg-muted text-muted-foreground",
30
+ info: "bg-info-subtle text-info-subtle-foreground",
31
+ success: "bg-success-subtle text-success-subtle-foreground",
32
+ warning: "bg-warning-subtle text-warning-subtle-foreground",
33
+ destructive: "bg-destructive-subtle text-destructive-subtle-foreground",
34
+ };
35
+
36
+ /**
37
+ * เส้นเวลา "ใครทำอะไรเมื่อไหร่" จาก array เดียว — audit log (Pi Auth), ประวัติงาน (Pi Done), timeline ของเอกสาร
38
+ * เวลาคำนวณฝั่ง client หลัง mount (ไม่ทำ hydration mismatch) และอัปเดตเองทุกนาที
39
+ * รายการที่มี `icon` ใช้ไอคอนแทน avatar; ไม่มีทั้งคู่ = ตัวย่อชื่อผู้ทำ
40
+ *
41
+ * <ActivityFeed items={[{ id: "1", title: "เปลี่ยนสิทธิ์เป็นผู้ดูแล", actor: "สมชาย ใจดี", at: row.createdAt }]} />
42
+ */
43
+ export function ActivityFeed({
44
+ items,
45
+ relative = true,
46
+ emptyLabel,
47
+ className,
48
+ }: {
49
+ items: ReadonlyArray<ActivityItem>;
50
+ /** true = "5 นาทีที่แล้ว" (ค่าเริ่มต้น) · false = วันที่/เวลาเต็ม */
51
+ relative?: boolean;
52
+ /** ไม่มีรายการ (ค่าเริ่มต้น "ยังไม่มีรายการ") */
53
+ emptyLabel?: ReactNode;
54
+ className?: string;
55
+ }) {
56
+ if (items.length === 0) {
57
+ return (
58
+ <p className={cn("py-6 text-center text-muted-foreground text-sm", className)}>
59
+ {emptyLabel ?? "ยังไม่มีรายการ"}
60
+ </p>
61
+ );
62
+ }
63
+
64
+ return (
65
+ <ol data-slot="activity-feed" className={cn("flex flex-col", className)}>
66
+ {items.map((item, index) => {
67
+ const tone = item.tone ?? "default";
68
+ const last = index === items.length - 1;
69
+ return (
70
+ <li key={item.id} className="flex gap-3">
71
+ {/* เส้นแนวตั้ง + จุด/avatar */}
72
+ <div className="flex flex-col items-center">
73
+ {item.icon ? (
74
+ <span
75
+ className={cn(
76
+ "flex size-7 shrink-0 items-center justify-center rounded-full [&_svg]:size-3.5",
77
+ DOT_TONE[tone],
78
+ )}
79
+ >
80
+ {item.icon}
81
+ </span>
82
+ ) : (
83
+ <Initials name={item.actor ?? ""} src={item.actorSrc} className="size-7" />
84
+ )}
85
+ {!last && <span className="w-px flex-1 bg-border" aria-hidden="true" />}
86
+ </div>
87
+ <div className={cn("flex min-w-0 flex-1 gap-3", last ? "pb-0" : "pb-4")}>
88
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
89
+ <p className="text-sm leading-snug">
90
+ {item.actor && <span className="font-medium">{item.actor} </span>}
91
+ {item.title}
92
+ </p>
93
+ {item.description && (
94
+ <div className="text-muted-foreground text-xs">{item.description}</div>
95
+ )}
96
+ <ClientDateFormatter
97
+ date={item.at}
98
+ formatType={relative ? "relative" : "standard"}
99
+ options={
100
+ relative
101
+ ? undefined
102
+ : { day: "numeric", month: "short", year: "numeric", timeStyle: "short" }
103
+ }
104
+ className="text-muted-foreground text-xs"
105
+ />
106
+ </div>
107
+ {item.action && <div className="shrink-0">{item.action}</div>}
108
+ </div>
109
+ </li>
110
+ );
111
+ })}
112
+ </ol>
113
+ );
114
+ }
@@ -0,0 +1,175 @@
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 { Input } from "@suphark/ui/components/ui/input";
6
+ import { errorMessage, isNavigationError } from "@suphark/ui/lib/action-result";
7
+ import { cn } from "@suphark/ui/lib/utils";
8
+ import { CheckIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react";
9
+ import { type ReactNode, useRef, useState } from "react";
10
+ import { toast } from "sonner";
11
+
12
+ export interface InlineEditProps {
13
+ value: string;
14
+ /** บันทึกค่าใหม่ — throw หรือคืน `{ ok: false, error }` = ล้มเหลว (คงโหมดแก้ไขไว้ให้ลองใหม่) */
15
+ onSave: (value: string) => void | Promise<unknown>;
16
+ /** ป้ายสำหรับ screen reader ของปุ่มแก้ไข/ช่องกรอก เช่น "ชื่อโปรเจกต์" */
17
+ label: string;
18
+ placeholder?: string;
19
+ /** แสดงแทนค่าว่าง (ค่าเริ่มต้น "—") */
20
+ emptyLabel?: ReactNode;
21
+ /** ตรวจก่อนบันทึก — คืนข้อความ = ไม่ผ่าน */
22
+ validate?: (value: string) => string | null | undefined;
23
+ /** ค่าเดิมกับค่าใหม่เท่ากัน = ปิดโหมดแก้ไขโดยไม่เรียก `onSave` (ค่าเริ่มต้น true) */
24
+ skipWhenUnchanged?: boolean;
25
+ disabled?: boolean;
26
+ /** sm = ใช้ในแถวตาราง */
27
+ size?: "sm" | "default";
28
+ className?: string;
29
+ }
30
+
31
+ /**
32
+ * แก้ค่าในที่ — คลิกข้อความ (หรือปุ่มดินสอ) → ช่องกรอก → Enter/✓ บันทึก, Esc/✕ ยกเลิก
33
+ * ใช้กับค่าสั้น ๆ ที่ไม่คุ้มเปิด dialog (ชื่อโปรเจกต์, ชื่อเล่นของคีย์, หมายเหตุหนึ่งบรรทัด)
34
+ * ล้มเหลว = ขึ้น toast และคงโหมดแก้ไขไว้ให้ลองใหม่ · ค่ายาว/หลายช่องใช้ `FormDialog`
35
+ *
36
+ * <InlineEdit label="ชื่อโปรเจกต์" value={p.name} onSave={(name) => renameProject(p.id, name)} />
37
+ */
38
+ export function InlineEdit({
39
+ value,
40
+ onSave,
41
+ label,
42
+ placeholder,
43
+ emptyLabel = "—",
44
+ validate,
45
+ skipWhenUnchanged = true,
46
+ disabled = false,
47
+ size = "default",
48
+ className,
49
+ }: InlineEditProps) {
50
+ const text = useUiText();
51
+ const [editing, setEditing] = useState(false);
52
+ const [draft, setDraft] = useState(value);
53
+ const [pending, setPending] = useState(false);
54
+ const inputRef = useRef<HTMLInputElement>(null);
55
+ // เลือกข้อความทั้งหมดครั้งแรกที่เข้าโหมดแก้ไข — พิมพ์ทับได้ทันที (โฟกัสรอบหลังไม่เลือกซ้ำ)
56
+ const selectedOnOpen = useRef(false);
57
+
58
+ function start() {
59
+ setDraft(value);
60
+ selectedOnOpen.current = false;
61
+ setEditing(true);
62
+ }
63
+
64
+ async function commit() {
65
+ const next = draft.trim();
66
+ if (skipWhenUnchanged && next === value.trim()) {
67
+ setEditing(false);
68
+ return;
69
+ }
70
+ const invalid = validate?.(next);
71
+ if (invalid) {
72
+ toast.error(invalid);
73
+ inputRef.current?.focus();
74
+ return;
75
+ }
76
+ setPending(true);
77
+ try {
78
+ const result = await onSave(next);
79
+ // server action ที่คืน { ok: false, error } ถือว่าล้มเหลวเหมือน throw
80
+ if (result && typeof result === "object" && "ok" in result && result.ok === false) {
81
+ const message = "error" in result ? String(result.error) : undefined;
82
+ toast.error(message || (text.actionFailed ?? "ดำเนินการไม่สำเร็จ"));
83
+ return;
84
+ }
85
+ setEditing(false);
86
+ } catch (err) {
87
+ // action ที่ redirect() = สำเร็จ (Next นำทางให้แล้ว)
88
+ if (isNavigationError(err)) {
89
+ setEditing(false);
90
+ return;
91
+ }
92
+ toast.error(errorMessage(err, text.actionFailed ?? "ดำเนินการไม่สำเร็จ"));
93
+ } finally {
94
+ setPending(false);
95
+ }
96
+ }
97
+
98
+ const compact = size === "sm";
99
+
100
+ if (!editing) {
101
+ return (
102
+ <span
103
+ data-slot="inline-edit"
104
+ className={cn("group/inline inline-flex min-w-0 items-center gap-1", className)}
105
+ >
106
+ <span
107
+ className={cn("truncate", compact ? "text-sm" : "", !value && "text-muted-foreground")}
108
+ >
109
+ {value || emptyLabel}
110
+ </span>
111
+ <Button
112
+ variant="ghost"
113
+ size={compact ? "icon-xs" : "icon-sm"}
114
+ aria-label={`${text.edit ?? "แก้ไข"}: ${label}`}
115
+ disabled={disabled}
116
+ onClick={start}
117
+ className="opacity-0 transition-opacity focus-visible:opacity-100 group-hover/inline:opacity-100"
118
+ >
119
+ <PencilIcon />
120
+ </Button>
121
+ </span>
122
+ );
123
+ }
124
+
125
+ return (
126
+ <span
127
+ data-slot="inline-edit"
128
+ className={cn("inline-flex min-w-0 items-center gap-1", className)}
129
+ >
130
+ <Input
131
+ ref={inputRef}
132
+ // biome-ignore lint/a11y/noAutofocus: เพิ่งกดปุ่มแก้ไข — โฟกัสต่อเนื่องคือสิ่งที่ผู้ใช้คาดหวัง
133
+ autoFocus
134
+ aria-label={label}
135
+ value={draft}
136
+ placeholder={placeholder}
137
+ disabled={pending}
138
+ className={cn("min-w-0", compact && "h-7")}
139
+ onFocus={(e) => {
140
+ if (selectedOnOpen.current) return;
141
+ selectedOnOpen.current = true;
142
+ e.currentTarget.select();
143
+ }}
144
+ onChange={(e) => setDraft(e.target.value)}
145
+ onKeyDown={(e) => {
146
+ if (e.key === "Enter") {
147
+ e.preventDefault();
148
+ void commit();
149
+ } else if (e.key === "Escape") {
150
+ e.preventDefault();
151
+ setEditing(false);
152
+ }
153
+ }}
154
+ />
155
+ <Button
156
+ variant="ghost"
157
+ size={compact ? "icon-xs" : "icon-sm"}
158
+ aria-label={text.save}
159
+ disabled={pending}
160
+ onClick={() => void commit()}
161
+ >
162
+ {pending ? <Loader2Icon className="animate-spin" /> : <CheckIcon />}
163
+ </Button>
164
+ <Button
165
+ variant="ghost"
166
+ size={compact ? "icon-xs" : "icon-sm"}
167
+ aria-label={text.cancel}
168
+ disabled={pending}
169
+ onClick={() => setEditing(false)}
170
+ >
171
+ <XIcon />
172
+ </Button>
173
+ </span>
174
+ );
175
+ }
@@ -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,23 @@ 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
+ /** SelectionBar (optional) — `selected` ใช้ `{count}` */
26
+ selection?: Partial<{ selected: string; clear: string }>;
27
+ /** DataTable (optional) — `rowsSelected` ใช้ `{selected}`/`{total}`, `pageOf` ใช้ `{page}`/`{pages}` */
28
+ table?: Partial<{
29
+ rowsPerPage: string;
30
+ pageOf: string;
31
+ rowsSelected: string;
32
+ noResults: string;
33
+ deleteTitle: string;
34
+ /** ใช้ `{name}` และ `{resource}` (ชื่อชนิดข้อมูลที่ส่งมาทาง `resourceName`) */
35
+ deleteDescription: string;
36
+ deleting: string;
37
+ deleted: string;
38
+ delete: string;
39
+ }>;
23
40
  /** DialogsProvider: ปุ่มปิดของ alert (optional — ค่าเริ่มต้น "ตกลง") */
24
41
  ok?: string;
25
42
  /** DialogsProvider: หัวกล่อง alert (optional — ค่าเริ่มต้น "แจ้งให้ทราบ") */
@@ -135,6 +152,19 @@ export const UI_TEXT_TH: UiText = {
135
152
  confirm: "ยืนยัน",
136
153
  confirmTitle: "ยืนยันการทำรายการ",
137
154
  actionFailed: "ดำเนินการไม่สำเร็จ",
155
+ edit: "แก้ไข",
156
+ selection: { selected: "เลือก {count} รายการ", clear: "ล้างการเลือก" },
157
+ table: {
158
+ rowsPerPage: "แถวต่อหน้า",
159
+ pageOf: "หน้า {page} จาก {pages}",
160
+ rowsSelected: "เลือก {selected} จาก {total} แถว",
161
+ noResults: "ไม่พบข้อมูล",
162
+ deleteTitle: "ยืนยันการลบ",
163
+ deleteDescription: "ลบ{resource} “{name}” แล้วกู้คืนไม่ได้",
164
+ deleting: "กำลังลบ{resource} “{name}”…",
165
+ deleted: "ลบ{resource} “{name}” แล้ว",
166
+ delete: "ลบ",
167
+ },
138
168
  ok: "ตกลง",
139
169
  alertTitle: "แจ้งให้ทราบ",
140
170
  promptTitle: "กรอกข้อมูล",
@@ -233,6 +263,19 @@ export const UI_TEXT_EN: UiText = {
233
263
  confirm: "Confirm",
234
264
  confirmTitle: "Please confirm",
235
265
  actionFailed: "Action failed",
266
+ edit: "Edit",
267
+ selection: { selected: "{count} selected", clear: "Clear selection" },
268
+ table: {
269
+ rowsPerPage: "Rows per page",
270
+ pageOf: "Page {page} of {pages}",
271
+ rowsSelected: "{selected} of {total} row(s) selected",
272
+ noResults: "No results",
273
+ deleteTitle: "Are you sure?",
274
+ deleteDescription: "Deleting{resource} “{name}” cannot be undone",
275
+ deleting: "Deleting{resource} “{name}”…",
276
+ deleted: "“{name}”{resource} deleted",
277
+ delete: "Delete",
278
+ },
236
279
  ok: "OK",
237
280
  alertTitle: "Notice",
238
281
  promptTitle: "Input required",
package/src/index.ts CHANGED
@@ -19,6 +19,11 @@ export {
19
19
  type ActionMenuCommand,
20
20
  type ActionMenuItem,
21
21
  } from "./components/shared/action-menu";
22
+ export {
23
+ ActivityFeed,
24
+ type ActivityItem,
25
+ type ActivityTone,
26
+ } from "./components/shared/activity-feed";
22
27
  export { AuthLayout, type AuthLayoutProps } from "./components/shared/auth-layout";
23
28
  export {
24
29
  AuthDivider,
@@ -144,6 +149,7 @@ export {
144
149
  } from "./components/shared/icon-picker";
145
150
  export { InfoHint } from "./components/shared/info-hint";
146
151
  export { Initials, initialsOf } from "./components/shared/initials";
152
+ export { InlineEdit, type InlineEditProps } from "./components/shared/inline-edit";
147
153
  export {
148
154
  LanguageMenuItems,
149
155
  type LanguageOption,
@@ -192,6 +198,11 @@ export { type SaveState, SaveStatus, type SaveStatusText } from "./components/sh
192
198
  export { SecretReveal } from "./components/shared/secret-reveal";
193
199
  export { SectionCard } from "./components/shared/section-card";
194
200
  export { SegmentedControl, type SegmentedOption } from "./components/shared/segmented-control";
201
+ export {
202
+ type SelectionAction,
203
+ SelectionBar,
204
+ type SelectionBarProps,
205
+ } from "./components/shared/selection-bar";
195
206
  export { SelectionButton } from "./components/shared/selection-button";
196
207
  export { SettingsLayout, type SettingsSection } from "./components/shared/settings-layout";
197
208
  export { DangerZone, SettingsRow, SwitchRow } from "./components/shared/settings-row";