@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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.29.0] — 2026-10-05
6
+
7
+ ### Added
8
+
9
+ - `InlineEdit`: คุมโหมดแก้ไขจากข้างนอกได้ — `editing` + `onEditingChange` (controlled), `defaultEditing` และ `onCancel` (กด Esc/✕) สำหรับแอปที่เข้าโหมดแก้ไขด้วย F2 หรือเมนูคลิกขวา "เปลี่ยนชื่อ" (คำขอจาก Pi Files — เดิมต้อง `.click()` ปุ่มดินสอผ่าน DOM)
10
+ - **`@suphark/ui/sortable` — ลากจัดลำดับ 2 ระดับ (กลุ่ม + รายการในกลุ่ม)** entry ใหม่บน dnd-kit (แยกจาก barrel เหมือน `data-table`/`kanban` เพราะ dnd-kit สร้าง context ตอน import) — ยกสมองส่วนลากออกจากแอป ส่วนการเรนเดอร์แถวยังเป็นของแอป (ตาราง/การ์ด/รายการ) ตามสเปกของ Pi Config (`values-table.tsx`)
11
+ - `SortableProvider` — sensors (เมาส์ + คีย์บอร์ด), collision ที่กันลากข้ามชนิด (กลุ่มชนได้แค่กลุ่ม, รายการชนได้แค่รายการกับที่วางของกลุ่ม), ย้ายข้ามกลุ่มระหว่างลาก, commit ตอนปล่อย และ **คืนลำดับเดิมอัตโนมัติเมื่อบันทึกล้มเหลว** (throw หรือ `{ ok: false, error }`) — ไม่มี `onCommitError` = ขึ้น toast ให้; ส่งมา = ส่ง error ดิบให้แอปจัดการเอง ไม่กลืนเงียบ
12
+ - `SortableGroup` (ขอบเขตการเรียงของกลุ่ม), `useSortableRow({ type, id })` → `{ ref, style, handleProps, isDragging }`, `useSortableDropZone(groupId)` (แปะที่ไหนก็ได้ — หัวกลุ่ม หรือแถว placeholder ของกลุ่มที่ไม่มีหัว; กลุ่มที่ย่ออยู่ก็ยังรับการวางได้), `useSortableState()` → `{ dragging, disabled }`, `DragHandle` (ใช้ `Button` ของแพ็กเกจ จึงได้ tooltip จาก `aria-label`; ปิดการลากแล้วที่จับจางลงเอง หรือซ่อนไปเลยด้วย `hideWhenDisabled`)
13
+ - `announcements` + `screenReaderInstructions` ส่งเป็น prop ได้ (callback ได้ `{ id, kind, groupId, index, total }` ให้แอปประกอบข้อความจากชื่อจริง) ไม่ส่ง = แม่แบบจาก `UiText.sortable`
14
+ - helper แบบ pure ใน `lib/sortable-order`: `sortableOrderOf`, `reorderGroups`, `reorderItems`, `moveItemToGroup`, `groupOfItem`, `sameOrder`, `moveWithin`, `sortableOrderToPayload` (คืน `{ groups: string[], items: [{ groupId: string | null, itemIds }] }` — ลำดับกลุ่มกับรายการในกลุ่มครบในก้อนเดียว, `UNGROUPED` → `null`)
15
+ - `UiText.sortable` — `handle`, `instructions`, `started`, `moved`, `dropped`, `cancelled` (`{index}` / `{total}`)
16
+ - showcase: หน้า `/components/sortable-list` — ตาราง 2 ระดับ ลากเรียงกลุ่ม/คีย์/ย้ายข้ามกลุ่ม พร้อมแถว "ลากมาที่นี่เพื่อเอาออกจากกลุ่ม" ที่โผล่เฉพาะตอนลากคีย์
17
+
18
+ ## [0.28.1] — 2026-10-05
19
+
20
+ ### Fixed
21
+
22
+ - **`AppShell` ปุ่มย่อ/ขยายเมนูยังมี "Toggle Sidebar" อังกฤษค้างอยู่ใน `sr-only`** (รายงานจาก DueBell): `SidebarTrigger` ของ shadcn ฝังข้อความนั้นเป็น children ซึ่งแทนด้วย props ไม่ได้ (JSX children ชนะ) — 0.27.1 เปลี่ยนได้แค่ `aria-label` ตอนนี้ `AppShell` เรนเดอร์ปุ่มเองจาก `Button` ของแพ็กเกจ + `useSidebar()` จึงใช้ `UiText.toggleSidebar` ทั้งชื่อและ tooltip และไม่มีข้อความอังกฤษเหลือในปุ่ม (ยังคง `data-sidebar="trigger"` ไว้ให้ selector เดิมใช้ได้)
23
+
24
+ ## [0.28.0] — 2026-10-05
25
+
26
+ ### Added
27
+
28
+ - **`ActivityFeed`** — เส้นเวลา "ใครทำอะไรเมื่อไหร่" จาก array เดียว (audit log ของ Pi Auth, ประวัติงานของ Pi Done, timeline ของเอกสาร): avatar ของผู้ทำหรือไอคอน+โทนสำหรับเหตุการณ์ของระบบ, เวลาสัมพัทธ์ที่คำนวณฝั่ง client หลัง mount (ไม่ทำ hydration mismatch) และช่องใส่ปุ่ม/ป้ายท้ายแถว
29
+ - **`InlineEdit`** — แก้ค่าในที่: คลิกดินสอ → ช่องกรอก (เลือกข้อความเดิมไว้ให้พิมพ์ทับได้ทันที) → Enter/✓ บันทึก, Esc/✕ ยกเลิก พร้อม `validate`, สถานะกำลังบันทึก และ toast เมื่อล้มเหลว (ถือ `{ ok: false, error }` เป็นล้มเหลว, `redirect()` เป็นสำเร็จ) — ใช้กับค่าสั้น ๆ ที่ไม่คุ้มเปิด `FormDialog`
30
+ - **`SelectionBar`** — แถบคำสั่งหมู่ "เลือก N รายการ" + ปุ่มคำสั่ง + ปุ่มล้าง (`floating` กลางล่างจอ หรือ `inline` ในหน้า) ใช้คู่ `DataTable` หรือ `SimpleTable`; ข้อความจาก `UiText.selection`
31
+ - **`SimpleTable` เลือกหลายแถวได้** — `selected` + `onSelectedChange` เปิดช่องติ๊กหน้าแถวและหัวตาราง (เลือกบางส่วนแสดงเป็นขีด), แถวที่เลือกมีพื้นจาง; คลิกช่องติ๊กไม่ไปโดน `onRowClick`
32
+ - `SimpleTabs`: แท็บล้นความกว้างแล้วเลื่อนแนวนอนได้ (ซ่อนแถบเลื่อน) + เงาขอบบอกว่ายังมีแท็บต่อ
33
+ - `UiText` คีย์ใหม่: `edit`, `selection.{selected,clear}`, `table.{rowsPerPage,pageOf,rowsSelected,noResults,deleteTitle,deleteDescription,deleting,deleted,delete}`
34
+
35
+ ### Fixed
36
+
37
+ - **`DataTable` เลิกใช้ข้อความอังกฤษตายตัว** — "Rows per page", "Page X of Y", "N of M row(s) selected", "No results.", เมนู Edit/Delete, กล่องยืนยันลบ และ toast ตอนลบ ทั้งหมดมาจาก `UiText.table` (ค่าเริ่มต้นภาษาไทย) — `resourceName` แทรกผ่าน `{resource}`
38
+
5
39
  ## [0.27.1] — 2026-10-05
6
40
 
7
41
  ### Added
package/README.md CHANGED
@@ -197,6 +197,32 @@ webview/automation บางตัวปิด `window.confirm` ทิ้ง (
197
197
 
198
198
  ต้องการ `alert` / `prompt` / `choose` (หลายปุ่ม) / `checklist` ด้วย หรือไม่อยาก render `confirmDialog` ทุกที่: ครอบ `<DialogsProvider>` ที่ root แล้ว `const dialogs = useDialogs()` — `await dialogs.prompt({ description: "ชื่อใหม่", defaultValue })` คืน string หรือ null; หัว/ปุ่มค่าเริ่มต้นจาก `UiText.ok/alertTitle/promptTitle/chooseTitle/checklistTitle`
199
199
 
200
+ ### ลากจัดลำดับ (`@suphark/ui/sortable`)
201
+
202
+ ลากเรียงกลุ่ม เรียงรายการในกลุ่ม และย้ายรายการข้ามกลุ่ม ในตาราง/รายการเดียวกัน — แพ็กเกจดูแล sensors, collision, การย้ายระหว่างลาก และ **คืนลำดับเดิมให้เองเมื่อบันทึกไม่สำเร็จ** ส่วนการเรนเดอร์แถวยังเป็นของแอป
203
+
204
+ ```tsx
205
+ const [order, setOrder] = useState(() =>
206
+ sortableOrderOf(groups, items, { groupId: (g) => g.id, itemId: (i) => i.id, itemGroupId: (i) => i.groupId }),
207
+ );
208
+
209
+ <SortableProvider order={order} onOrderChange={setOrder} onCommit={(next) => saveOrder(sortableOrderToPayload(next))}>
210
+ <SortableGroup itemIds={order.items[groupId] ?? []}>
211
+ {/* แต่ละแถว: const row = useSortableRow({ type: "item", id }); */}
212
+ <TableRow ref={row.ref} style={row.style}>
213
+ <TableCell><DragHandle label={`ลาก ${item.name}`} {...row.handleProps} /></TableCell>
214
+ …
215
+ </TableRow>
216
+ </SortableGroup>
217
+ </SortableProvider>
218
+ ```
219
+
220
+ - กลุ่มที่ไม่มีหัวกลุ่ม (เช่น "ยังไม่จัดกลุ่ม") ใช้ `useSortableDropZone(groupId)` แปะกับแถว placeholder ของตัวเอง และดูว่าควรแสดงเมื่อไหร่จาก `useSortableState().dragging`
221
+ - `onCommit` ล้มเหลว (throw หรือ `{ ok: false, error }`) = คืนลำดับเดิม + toast; ถ้าอยากจัดการ error เองใส่ `onCommitError`
222
+ - `sortableOrderToPayload(order)` คืน `{ groups, items }` — `groups` คือลำดับของกลุ่ม, `items` คือรายการในแต่ละกลุ่มเรียงตามนั้น (ของที่ยังไม่มีกลุ่มมาก่อนด้วย `groupId: null`)
223
+ - `SortableProvider disabled` (เช่นระหว่างกรอง ซึ่งลำดับที่เห็นไม่ใช่ลำดับจริง) ทำให้ `DragHandle` จางลงและกดไม่ได้ — ไม่อยากให้เห็นเลยใส่ `hideWhenDisabled`
224
+ - ข้อความ screen reader: ใส่ `announcements`/`screenReaderInstructions` เป็น prop (callback ได้ `{ id, kind, groupId, index, total }`) หรือปล่อยให้ใช้แม่แบบจาก `UiText.sortable`
225
+
200
226
  ### PWA (`@suphark/ui/pwa`)
201
227
 
202
228
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.27.1",
3
+ "version": "0.29.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 เอง",
@@ -44,6 +44,7 @@
44
44
  "./data-table": "./src/data-table.ts",
45
45
  "./pwa": "./src/pwa.ts",
46
46
  "./kanban": "./src/kanban.ts",
47
+ "./sortable": "./src/sortable.ts",
47
48
  "./styles.css": "./src/styles.css",
48
49
  "./components/shared/button": "./src/components/shared/button/index.ts",
49
50
  "./components/shared/theme": "./src/components/shared/theme/index.ts",
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { SimpleTooltip } from "@suphark/ui/components/shared/simple-tooltip";
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
4
  import { SkipLink } from "@suphark/ui/components/shared/skip-link";
5
5
  import { useUiText } from "@suphark/ui/components/shared/ui-text";
6
6
  import {
@@ -18,11 +18,10 @@ import {
18
18
  SidebarInset,
19
19
  SidebarProvider,
20
20
  SidebarRail,
21
- SidebarTrigger,
22
21
  useSidebar,
23
22
  } from "@suphark/ui/components/ui/sidebar";
24
23
  import { cn } from "@suphark/ui/lib/utils";
25
- import { Search, X } from "lucide-react";
24
+ import { PanelLeftIcon, Search, X } from "lucide-react";
26
25
  import Link from "next/link";
27
26
  import { usePathname } from "next/navigation";
28
27
  import {
@@ -78,7 +77,7 @@ export interface AppShellProps {
78
77
  /** ท้าย sidebar — ปกติคือ `<UserMenu variant="sidebar" />` */
79
78
  sidebarFooter?: ReactNode;
80
79
  /**
81
- * header ของเนื้อหา: `false` = ไม่มี header, `true`/ไม่ส่ง = SidebarTrigger + breadcrumb อัตโนมัติ
80
+ * header ของเนื้อหา: `false` = ไม่มี header, `true`/ไม่ส่ง = ปุ่มย่อ/ขยายเมนู + breadcrumb อัตโนมัติ
82
81
  * ส่ง object เพื่อกำหนดเอง
83
82
  */
84
83
  header?:
@@ -207,9 +206,7 @@ function SidebarShell({
207
206
  )}
208
207
  >
209
208
  <div className="flex h-12 min-w-0 flex-1 items-center gap-2 md:h-full">
210
- <SimpleTooltip content={toggleSidebarLabel}>
211
- <SidebarTrigger className="-ml-1" aria-label={toggleSidebarLabel} />
212
- </SimpleTooltip>
209
+ <SidebarToggle label={toggleSidebarLabel} />
213
210
  {breadcrumbs && (
214
211
  <>
215
212
  <Separator orientation="vertical" className="mr-2 hidden h-4 md:block" />
@@ -357,6 +354,28 @@ function TopShell({
357
354
  );
358
355
  }
359
356
 
357
+ /**
358
+ * ปุ่มย่อ/ขยายเมนูในหัวเรื่อง — เขียนเองแทน `SidebarTrigger` ของ shadcn เพราะตัวนั้นฝัง
359
+ * `<span className="sr-only">Toggle Sidebar</span>` เป็นภาษาอังกฤษไว้ใน children (แทนไม่ได้
360
+ * เพราะ JSX children ชนะ props) ตัวนี้ใช้ `UiText` และได้ tooltip จาก `Button` ของแพ็กเกจ
361
+ * คง `data-sidebar="trigger"` ไว้ให้แอปที่ยิง selector เดิมยังใช้ได้
362
+ */
363
+ function SidebarToggle({ label }: { label: string }) {
364
+ const { toggleSidebar } = useSidebar();
365
+ return (
366
+ <Button
367
+ data-sidebar="trigger"
368
+ variant="ghost"
369
+ size="icon-sm"
370
+ aria-label={label}
371
+ className="-ml-1"
372
+ onClick={toggleSidebar}
373
+ >
374
+ <PanelLeftIcon />
375
+ </Button>
376
+ );
377
+ }
378
+
360
379
  /** แผง sidebar — แยกเพื่อใช้ useSidebar (ล้างคำค้นเมื่อยุบ ไม่ให้เมนูถูกกรองทั้งที่มองไม่เห็นช่องค้นหา) */
361
380
  function ShellSidebar({
362
381
  brand,
@@ -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,209 @@
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
+ /**
24
+ * คุมโหมดแก้ไขจากข้างนอก (เช่นเข้าโหมดแก้ไขด้วย F2 หรือเมนูคลิกขวา "เปลี่ยนชื่อ")
25
+ * ส่ง `editing` คู่กับ `onEditingChange` = controlled; ไม่ส่ง = component คุมเอง
26
+ */
27
+ editing?: boolean;
28
+ onEditingChange?: (editing: boolean) => void;
29
+ /** เริ่มต้นอยู่ในโหมดแก้ไขเลย (uncontrolled — เช่นแถวที่เพิ่งสร้าง) */
30
+ defaultEditing?: boolean;
31
+ /** กด Esc / ปุ่ม ✕ (ไม่ถูกเรียกเมื่อบันทึกสำเร็จ) */
32
+ onCancel?: () => void;
33
+ /** ค่าเดิมกับค่าใหม่เท่ากัน = ปิดโหมดแก้ไขโดยไม่เรียก `onSave` (ค่าเริ่มต้น true) */
34
+ skipWhenUnchanged?: boolean;
35
+ disabled?: boolean;
36
+ /** sm = ใช้ในแถวตาราง */
37
+ size?: "sm" | "default";
38
+ className?: string;
39
+ }
40
+
41
+ /**
42
+ * แก้ค่าในที่ — คลิกข้อความ (หรือปุ่มดินสอ) → ช่องกรอก → Enter/✓ บันทึก, Esc/✕ ยกเลิก
43
+ * ใช้กับค่าสั้น ๆ ที่ไม่คุ้มเปิด dialog (ชื่อโปรเจกต์, ชื่อเล่นของคีย์, หมายเหตุหนึ่งบรรทัด)
44
+ * ล้มเหลว = ขึ้น toast และคงโหมดแก้ไขไว้ให้ลองใหม่ · ค่ายาว/หลายช่องใช้ `FormDialog`
45
+ *
46
+ * <InlineEdit label="ชื่อโปรเจกต์" value={p.name} onSave={(name) => renameProject(p.id, name)} />
47
+ */
48
+ export function InlineEdit({
49
+ value,
50
+ onSave,
51
+ label,
52
+ placeholder,
53
+ emptyLabel = "—",
54
+ validate,
55
+ editing: editingProp,
56
+ onEditingChange,
57
+ defaultEditing = false,
58
+ onCancel,
59
+ skipWhenUnchanged = true,
60
+ disabled = false,
61
+ size = "default",
62
+ className,
63
+ }: InlineEditProps) {
64
+ const text = useUiText();
65
+ const [uncontrolledEditing, setUncontrolledEditing] = useState(defaultEditing);
66
+ const editing = editingProp ?? uncontrolledEditing;
67
+ const [draft, setDraft] = useState(value);
68
+ const [pending, setPending] = useState(false);
69
+ const inputRef = useRef<HTMLInputElement>(null);
70
+ // เลือกข้อความทั้งหมดครั้งแรกที่เข้าโหมดแก้ไข — พิมพ์ทับได้ทันที (โฟกัสรอบหลังไม่เลือกซ้ำ)
71
+ const selectedOnOpen = useRef(false);
72
+
73
+ // เข้าโหมดแก้ไขจากข้างนอก (F2/เมนู) ก็ต้องเริ่มจากค่าปัจจุบันเหมือนกดปุ่มดินสอ
74
+ const wasEditing = useRef(editing);
75
+ if (editing && !wasEditing.current) {
76
+ setDraft(value);
77
+ selectedOnOpen.current = false;
78
+ }
79
+ wasEditing.current = editing;
80
+
81
+ function setEditing(next: boolean) {
82
+ if (editingProp === undefined) setUncontrolledEditing(next);
83
+ onEditingChange?.(next);
84
+ }
85
+
86
+ function start() {
87
+ setDraft(value);
88
+ selectedOnOpen.current = false;
89
+ setEditing(true);
90
+ }
91
+
92
+ /** ยกเลิก (Esc / ปุ่ม ✕) — ต่างจากบันทึกสำเร็จตรงที่บอกแอปด้วย */
93
+ function cancel() {
94
+ setEditing(false);
95
+ onCancel?.();
96
+ }
97
+
98
+ async function commit() {
99
+ const next = draft.trim();
100
+ if (skipWhenUnchanged && next === value.trim()) {
101
+ setEditing(false);
102
+ return;
103
+ }
104
+ const invalid = validate?.(next);
105
+ if (invalid) {
106
+ toast.error(invalid);
107
+ inputRef.current?.focus();
108
+ return;
109
+ }
110
+ setPending(true);
111
+ try {
112
+ const result = await onSave(next);
113
+ // server action ที่คืน { ok: false, error } ถือว่าล้มเหลวเหมือน throw
114
+ if (result && typeof result === "object" && "ok" in result && result.ok === false) {
115
+ const message = "error" in result ? String(result.error) : undefined;
116
+ toast.error(message || (text.actionFailed ?? "ดำเนินการไม่สำเร็จ"));
117
+ return;
118
+ }
119
+ setEditing(false);
120
+ } catch (err) {
121
+ // action ที่ redirect() = สำเร็จ (Next นำทางให้แล้ว)
122
+ if (isNavigationError(err)) {
123
+ setEditing(false);
124
+ return;
125
+ }
126
+ toast.error(errorMessage(err, text.actionFailed ?? "ดำเนินการไม่สำเร็จ"));
127
+ } finally {
128
+ setPending(false);
129
+ }
130
+ }
131
+
132
+ const compact = size === "sm";
133
+
134
+ if (!editing) {
135
+ return (
136
+ <span
137
+ data-slot="inline-edit"
138
+ className={cn("group/inline inline-flex min-w-0 items-center gap-1", className)}
139
+ >
140
+ <span
141
+ className={cn("truncate", compact ? "text-sm" : "", !value && "text-muted-foreground")}
142
+ >
143
+ {value || emptyLabel}
144
+ </span>
145
+ <Button
146
+ variant="ghost"
147
+ size={compact ? "icon-xs" : "icon-sm"}
148
+ aria-label={`${text.edit ?? "แก้ไข"}: ${label}`}
149
+ disabled={disabled}
150
+ onClick={start}
151
+ className="opacity-0 transition-opacity focus-visible:opacity-100 group-hover/inline:opacity-100"
152
+ >
153
+ <PencilIcon />
154
+ </Button>
155
+ </span>
156
+ );
157
+ }
158
+
159
+ return (
160
+ <span
161
+ data-slot="inline-edit"
162
+ className={cn("inline-flex min-w-0 items-center gap-1", className)}
163
+ >
164
+ <Input
165
+ ref={inputRef}
166
+ // biome-ignore lint/a11y/noAutofocus: เพิ่งกดปุ่มแก้ไข — โฟกัสต่อเนื่องคือสิ่งที่ผู้ใช้คาดหวัง
167
+ autoFocus
168
+ aria-label={label}
169
+ value={draft}
170
+ placeholder={placeholder}
171
+ disabled={pending}
172
+ className={cn("min-w-0", compact && "h-7")}
173
+ onFocus={(e) => {
174
+ if (selectedOnOpen.current) return;
175
+ selectedOnOpen.current = true;
176
+ e.currentTarget.select();
177
+ }}
178
+ onChange={(e) => setDraft(e.target.value)}
179
+ onKeyDown={(e) => {
180
+ if (e.key === "Enter") {
181
+ e.preventDefault();
182
+ void commit();
183
+ } else if (e.key === "Escape") {
184
+ e.preventDefault();
185
+ cancel();
186
+ }
187
+ }}
188
+ />
189
+ <Button
190
+ variant="ghost"
191
+ size={compact ? "icon-xs" : "icon-sm"}
192
+ aria-label={text.save}
193
+ disabled={pending}
194
+ onClick={() => void commit()}
195
+ >
196
+ {pending ? <Loader2Icon className="animate-spin" /> : <CheckIcon />}
197
+ </Button>
198
+ <Button
199
+ variant="ghost"
200
+ size={compact ? "icon-xs" : "icon-sm"}
201
+ aria-label={text.cancel}
202
+ disabled={pending}
203
+ onClick={cancel}
204
+ >
205
+ <XIcon />
206
+ </Button>
207
+ </span>
208
+ );
209
+ }