@suphark/ui 0.28.0 → 0.30.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,37 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.30.0] — 2026-10-05
6
+
7
+ ### Added
8
+
9
+ - **ข้อความที่ผู้ใช้เห็นในแพ็กเกจเปลี่ยนภาษาได้ครบแล้ว** — เก็บข้อความไทยที่ฝังตายใน JSX อีก 34 จุดเข้า `UiText`: `DataTableToolbar` (ค้นหา/ล้าง/ตัวกรอง/คำอธิบาย/ปิด), `DataTableFacetedFilter` + `FormFieldCombobox` ("ไม่พบข้อมูล"), `MultiSelectCombobox` (ล้างทั้งหมด/ลบ/เพิ่ม), `ResourcePropertyFilter` (ทั้งแผง 14 จุด), `AppShell`/`SubNav`/`TopTabs` (เมนูหลัก/เมนูย่อย/เลือกหน้า/ไม่พบ/ล้างคำค้นหา), `ErrorState` (เซสชันหมดอายุ) — คีย์ใหม่: `UiText.combobox`, `UiText.filter`, `UiText.nav`, `UiText.sessionExpired` และ `UiText.table.{search,clearSearch,filters,filtersDescription,closeFilters,clearFilter}`
10
+ - **เทสต์กันข้อความฝังตาย** `no-hardcoded-text` (อยู่ใน `pnpm test`/CI): ไล่ JSX ของ `shared`/`app-shell`/`data-table`/`sortable` หาข้อความไทยที่ไม่มีทาง override — ยกเว้นได้แต่ต้องเขียนเหตุผลใน ALLOW; มีชุด "ปลูกการละเมิด" พิสูจน์ว่าตัวตรวจจับของจริงได้และไม่จับของที่ถูกต้อง (คอมเมนต์/JSDoc/`?? "…"`/พจนานุกรมของ component) — ไอเดียจาก Pi Config ที่เจอว่าเทสต์แบบนี้ล้มเหลวเงียบได้ง่าย
11
+ - `SegmentedControl`: `tone` ต่อตัวเลือก (`success` / `warning` / `destructive` / `info`) — ลงสีเฉพาะตัวที่ถูกเลือก ใช้กับตัวกรองที่ "ประเภท" มีความหมายทางสี เช่นรายรับ/รายจ่าย (คำขอจาก DueBell ที่ต้องใช้ไอคอนแทนไปก่อน)
12
+
13
+ ### Fixed
14
+
15
+ - `MonthStepper`: ปุ่มกลางตอนอยู่เดือนปัจจุบันเคยเป็น `<Button disabled>` ซึ่ง screen reader อ่านว่า "ปุ่ม ปิดใช้งาน" ทั้งที่มันคือป้ายชื่อเดือน — ตอนนี้เรนเดอร์เป็นป้ายธรรมดาเมื่อไม่มีอะไรให้กด (รายงานจาก DueBell) และตอนกดได้ใช้ tooltip ของ `Button` แทน `title` (เลิก tooltip ซ้อนของเบราว์เซอร์)
16
+
17
+ ## [0.29.0] — 2026-10-05
18
+
19
+ ### Added
20
+
21
+ - `InlineEdit`: คุมโหมดแก้ไขจากข้างนอกได้ — `editing` + `onEditingChange` (controlled), `defaultEditing` และ `onCancel` (กด Esc/✕) สำหรับแอปที่เข้าโหมดแก้ไขด้วย F2 หรือเมนูคลิกขวา "เปลี่ยนชื่อ" (คำขอจาก Pi Files — เดิมต้อง `.click()` ปุ่มดินสอผ่าน DOM)
22
+ - **`@suphark/ui/sortable` — ลากจัดลำดับ 2 ระดับ (กลุ่ม + รายการในกลุ่ม)** entry ใหม่บน dnd-kit (แยกจาก barrel เหมือน `data-table`/`kanban` เพราะ dnd-kit สร้าง context ตอน import) — ยกสมองส่วนลากออกจากแอป ส่วนการเรนเดอร์แถวยังเป็นของแอป (ตาราง/การ์ด/รายการ) ตามสเปกของ Pi Config (`values-table.tsx`)
23
+ - `SortableProvider` — sensors (เมาส์ + คีย์บอร์ด), collision ที่กันลากข้ามชนิด (กลุ่มชนได้แค่กลุ่ม, รายการชนได้แค่รายการกับที่วางของกลุ่ม), ย้ายข้ามกลุ่มระหว่างลาก, commit ตอนปล่อย และ **คืนลำดับเดิมอัตโนมัติเมื่อบันทึกล้มเหลว** (throw หรือ `{ ok: false, error }`) — ไม่มี `onCommitError` = ขึ้น toast ให้; ส่งมา = ส่ง error ดิบให้แอปจัดการเอง ไม่กลืนเงียบ
24
+ - `SortableGroup` (ขอบเขตการเรียงของกลุ่ม), `useSortableRow({ type, id })` → `{ ref, style, handleProps, isDragging }`, `useSortableDropZone(groupId)` (แปะที่ไหนก็ได้ — หัวกลุ่ม หรือแถว placeholder ของกลุ่มที่ไม่มีหัว; กลุ่มที่ย่ออยู่ก็ยังรับการวางได้), `useSortableState()` → `{ dragging, disabled }`, `DragHandle` (ใช้ `Button` ของแพ็กเกจ จึงได้ tooltip จาก `aria-label`; ปิดการลากแล้วที่จับจางลงเอง หรือซ่อนไปเลยด้วย `hideWhenDisabled`)
25
+ - `announcements` + `screenReaderInstructions` ส่งเป็น prop ได้ (callback ได้ `{ id, kind, groupId, index, total }` ให้แอปประกอบข้อความจากชื่อจริง) ไม่ส่ง = แม่แบบจาก `UiText.sortable`
26
+ - helper แบบ pure ใน `lib/sortable-order`: `sortableOrderOf`, `reorderGroups`, `reorderItems`, `moveItemToGroup`, `groupOfItem`, `sameOrder`, `moveWithin`, `sortableOrderToPayload` (คืน `{ groups: string[], items: [{ groupId: string | null, itemIds }] }` — ลำดับกลุ่มกับรายการในกลุ่มครบในก้อนเดียว, `UNGROUPED` → `null`)
27
+ - `UiText.sortable` — `handle`, `instructions`, `started`, `moved`, `dropped`, `cancelled` (`{index}` / `{total}`)
28
+ - showcase: หน้า `/components/sortable-list` — ตาราง 2 ระดับ ลากเรียงกลุ่ม/คีย์/ย้ายข้ามกลุ่ม พร้อมแถว "ลากมาที่นี่เพื่อเอาออกจากกลุ่ม" ที่โผล่เฉพาะตอนลากคีย์
29
+
30
+ ## [0.28.1] — 2026-10-05
31
+
32
+ ### Fixed
33
+
34
+ - **`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 เดิมใช้ได้)
35
+
5
36
  ## [0.28.0] — 2026-10-05
6
37
 
7
38
  ### 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.28.0",
3
+ "version": "0.30.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",
@@ -0,0 +1,3 @@
1
+ /** ชนิดของ `hardcoded-text.mjs` (สคริปต์เป็น .mjs เพราะ CI/เทสต์เรียกได้ทั้งคู่) */
2
+ export declare function stripComments(source: string): string[];
3
+ export declare function findHardcodedThai(source: string): Array<{ line: number; text: string }>;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * หา "ข้อความที่ผู้ใช้เห็น" ซึ่งฝังภาษาไทยตายไว้ใน JSX ของแพ็กเกจ
3
+ * ข้อความแบบนั้นแอปสองภาษาแปลไม่ได้เลย — ต้องผ่าน `UiText` (`text.x ?? "ไทย"`) หรือ prop
4
+ *
5
+ * ตรวจเฉพาะ **ข้อความใน JSX** สองแบบ (จุดที่ override ไม่ได้จริง ๆ):
6
+ * 1. attribute ที่ผู้ใช้/screen reader เห็น: `aria-label` `placeholder` `title` `alt` ฯลฯ
7
+ * 2. ข้อความที่เป็นลูกของ element: `<CommandEmpty>ไม่พบข้อมูล</CommandEmpty>`
8
+ * ไม่ตรวจ: คอมเมนต์ (รวม JSDoc ตัวอย่างโค้ด), ค่าเริ่มต้นของ prop, พจนานุกรมของ component
9
+ * (`const TEXT = { th, en }`) และ `text.x ?? "ไทย"` — ทั้งหมดนั้นแอปเปลี่ยนได้อยู่แล้ว
10
+ *
11
+ * export เป็นฟังก์ชันล้วนเพื่อให้เทสต์ "ปลูกไฟล์ละเมิด" พิสูจน์ได้ว่าจับได้จริงทั้งสองทาง
12
+ * (เทสต์ประเภทนี้ล้มเหลวเงียบง่ายมาก — ผ่านเพราะไม่ได้ตรวจอะไร)
13
+ */
14
+
15
+ const THAI = "[\\u0E00-\\u0E7F]";
16
+ const VISIBLE_ATTR = new RegExp(
17
+ `\\s(?:aria-label|aria-description|aria-roledescription|placeholder|title|alt|label)=\\{?["'\`][^"'\`]*${THAI}`,
18
+ );
19
+ const JSX_CHILD = new RegExp(`>\\s*[^<>{}\\n]*${THAI}`);
20
+ const THAI_ANY = new RegExp(THAI);
21
+
22
+ /** ตัดคอมเมนต์ออกทั้งไฟล์ (`//`, `/* *\/` ทั้งบรรทัดเดียวและหลายบรรทัด) โดยคงจำนวนบรรทัดไว้ */
23
+ export function stripComments(source) {
24
+ const out = [];
25
+ let inBlock = false;
26
+ for (const line of source.split("\n")) {
27
+ let rest = line;
28
+ let kept = "";
29
+ while (rest !== "") {
30
+ if (inBlock) {
31
+ const end = rest.indexOf("*/");
32
+ if (end === -1) {
33
+ rest = "";
34
+ break;
35
+ }
36
+ inBlock = false;
37
+ rest = rest.slice(end + 2);
38
+ continue;
39
+ }
40
+ const block = rest.indexOf("/*");
41
+ const lineComment = rest.indexOf("//");
42
+ const blockOutside = block !== -1 && balanced(rest.slice(0, block));
43
+ const lineOutside = lineComment !== -1 && balanced(rest.slice(0, lineComment));
44
+ if (lineOutside && (!blockOutside || lineComment < block)) {
45
+ kept += rest.slice(0, lineComment);
46
+ rest = "";
47
+ break;
48
+ }
49
+ if (!blockOutside) {
50
+ kept += rest;
51
+ rest = "";
52
+ break;
53
+ }
54
+ kept += rest.slice(0, block);
55
+ rest = rest.slice(block + 2);
56
+ inBlock = true;
57
+ }
58
+ out.push(kept);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** quote เปิด-ปิดครบไหม (ใช้ตัดสินว่า `//` หรือ `/*` อยู่นอก string เช่น "https://…") */
64
+ function balanced(text) {
65
+ let quote = null;
66
+ for (let i = 0; i < text.length; i += 1) {
67
+ const ch = text[i];
68
+ if (ch === "\\") {
69
+ i += 1;
70
+ continue;
71
+ }
72
+ if (quote) {
73
+ if (ch === quote) quote = null;
74
+ } else if (ch === '"' || ch === "'" || ch === "`") {
75
+ quote = ch;
76
+ }
77
+ }
78
+ return quote === null;
79
+ }
80
+
81
+ /** บรรทัดนี้มีทางให้แอปเปลี่ยนข้อความอยู่แล้วไหม */
82
+ function hasEscapeHatch(line) {
83
+ // `text.x ?? "ไทย"` / `text?.x?.y ?? "ไทย"` (ค่าเริ่มต้นของ UiText)
84
+ if (/\?\?\s*["'`]/.test(line)) return true;
85
+ return false;
86
+ }
87
+
88
+ /** คืนรายการละเมิดของไฟล์เดียว: `{ line, text }` */
89
+ export function findHardcodedThai(source) {
90
+ const lines = stripComments(source);
91
+ const found = [];
92
+ lines.forEach((line, index) => {
93
+ if (!THAI_ANY.test(line)) return;
94
+ if (hasEscapeHatch(line)) return;
95
+ // บรรทัดที่ไม่มีแท็กเลยไม่ใช่ JSX (เช่น object ของข้อความ หรือ `throw new Error(...)`)
96
+ const looksJsx = line.includes("<");
97
+ if (!VISIBLE_ATTR.test(line) && !(looksJsx && JSX_CHILD.test(line))) return;
98
+ found.push({ line: index + 1, text: line.trim() });
99
+ });
100
+ return found;
101
+ }
@@ -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,
@@ -417,7 +436,7 @@ function ShellSidebar({
417
436
  <InputGroupAddon align="inline-end">
418
437
  <InputGroupButton
419
438
  onClick={() => setQuery("")}
420
- aria-label="ล้างคำค้นหาเมนู"
439
+ aria-label={text.nav?.clearSearch ?? "ล้างคำค้นหาเมนู"}
421
440
  className="size-6 p-0 hover:bg-transparent"
422
441
  >
423
442
  <X aria-hidden="true" />
@@ -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
  InputGroup,
5
6
  InputGroupAddon,
@@ -30,6 +31,7 @@ export function SubNav({
30
31
  searchPlaceholder?: string;
31
32
  className?: string;
32
33
  }) {
34
+ const text = useUiText();
33
35
  const pathname = usePathname();
34
36
  const router = useRouter();
35
37
  const [query, setQuery] = useState("");
@@ -66,9 +68,9 @@ export function SubNav({
66
68
  className="h-9 rounded-md border bg-background px-2 text-foreground text-sm"
67
69
  value={current?.href ?? ""}
68
70
  onChange={(e) => e.target.value && router.push(e.target.value)}
69
- aria-label={typeof title === "string" ? title : "เมนูย่อย"}
71
+ aria-label={typeof title === "string" ? title : (text.nav?.sub ?? "เมนูย่อย")}
70
72
  >
71
- {!current && <option value="">เลือกหน้า…</option>}
73
+ {!current && <option value="">{text.nav?.choosePage ?? "เลือกหน้า…"}</option>}
72
74
  {sections.map((s, si) => (
73
75
  // biome-ignore lint/suspicious/noArrayIndexKey: กลุ่มคงที่
74
76
  <optgroup key={si} label={s.title ?? ""}>
@@ -84,7 +86,7 @@ export function SubNav({
84
86
 
85
87
  {/* เดสก์ท็อป */}
86
88
  <nav
87
- aria-label={typeof title === "string" ? title : "เมนูย่อย"}
89
+ aria-label={typeof title === "string" ? title : (text.nav?.sub ?? "เมนูย่อย")}
88
90
  className={cn(
89
91
  "sticky top-28 hidden w-56 shrink-0 flex-col gap-3 self-start lg:flex",
90
92
  className,
@@ -142,7 +144,9 @@ export function SubNav({
142
144
  </div>
143
145
  ))}
144
146
  {sections.length === 0 && (
145
- <p className="px-2 text-muted-foreground text-xs">ไม่พบ “{query}”</p>
147
+ <p className="px-2 text-muted-foreground text-xs">
148
+ {(text.nav?.noResults ?? "ไม่พบ “{query}”").replace("{query}", query)}
149
+ </p>
146
150
  )}
147
151
  </nav>
148
152
  </>
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
3
4
  import { cn } from "@suphark/ui/lib/utils";
4
5
  import Link from "next/link";
5
6
  import { usePathname } from "next/navigation";
@@ -34,11 +35,15 @@ export function isTabActive(pathname: string, tab: AppNavItem): boolean {
34
35
 
35
36
  /** แท็บหลักแนวนอนใต้ header (แบบ Clerk: Overview · Users · …) — active มีเส้นใต้; จอแคบเลื่อนแนวนอน */
36
37
  export function TopTabs({ nav, className }: { nav: AppNavGroup[]; className?: string }) {
38
+ const text = useUiText();
37
39
  const pathname = usePathname();
38
40
  const tabs = topLevelTabs(nav);
39
41
  if (tabs.length === 0) return null;
40
42
  return (
41
- <nav aria-label="เมนูหลัก" className={cn("no-scrollbar -mb-px flex overflow-x-auto", className)}>
43
+ <nav
44
+ aria-label={text.nav?.main ?? "เมนูหลัก"}
45
+ className={cn("no-scrollbar -mb-px flex overflow-x-auto", className)}
46
+ >
42
47
  {tabs.map((tab) => {
43
48
  const active = isTabActive(pathname, tab);
44
49
  return (
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@suphark/ui/components/shared/button/button";
4
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
4
5
  import { Badge } from "@suphark/ui/components/ui/badge";
5
6
  import {
6
7
  Command,
@@ -37,6 +38,7 @@ export function DataTableFacetedFilter<TData, TValue>({
37
38
  compact = false,
38
39
  fullWidth = false,
39
40
  }: DataTableFacetedFilterProps<TData, TValue>) {
41
+ const text = useUiText();
40
42
  const facets = column?.getFacetedUniqueValues();
41
43
  const filterValue = column?.getFilterValue();
42
44
  // Set + ลำดับ memo จากค่า filter จริง (เดิมสร้าง Set ใหม่ทุก render ทำให้ memo ไม่เคย hit)
@@ -109,7 +111,7 @@ export function DataTableFacetedFilter<TData, TValue>({
109
111
  <Command>
110
112
  {!compact ? <CommandInput placeholder={title} /> : null}
111
113
  <CommandList>
112
- <CommandEmpty>ไม่พบข้อมูล</CommandEmpty>
114
+ <CommandEmpty>{text.combobox?.empty ?? "ไม่พบข้อมูล"}</CommandEmpty>
113
115
  <CommandGroup>
114
116
  {sortedOptions.map((option) => {
115
117
  const valueAsString = String(option.value);
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@suphark/ui/components/shared/button/button";
4
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
4
5
  import { Badge } from "@suphark/ui/components/ui/badge";
5
6
  import {
6
7
  InputGroup,
@@ -43,6 +44,7 @@ export function DataTableToolbar<TData>({
43
44
  toolbarFilters,
44
45
  preservedFilterIds = [],
45
46
  }: DataTableToolbarProps<TData>) {
47
+ const text = useUiText();
46
48
  const globalFilter = table.getState().globalFilter;
47
49
  // ค้นหา: เก็บข้อความในช่องเอง แล้วส่งให้ตารางเมื่อหยุดพิมพ์ 250 ms — เดิมกรอง/เรียง/render ทุกแถวทุกตัวอักษร
48
50
  // (และถ้าแอปผูก globalFilter กับ URL = router.replace ทุกตัวอักษร)
@@ -121,7 +123,7 @@ export function DataTableToolbar<TData>({
121
123
  <Search aria-hidden="true" className="size-4" />
122
124
  </InputGroupAddon>
123
125
  <InputGroupInput
124
- aria-label="ค้นหาข้อมูลในตาราง"
126
+ aria-label={text.table?.search ?? "ค้นหาข้อมูลในตาราง"}
125
127
  name="table-search"
126
128
  autoComplete="off"
127
129
  placeholder={globalFilterPlaceholder}
@@ -135,7 +137,7 @@ export function DataTableToolbar<TData>({
135
137
  <InputGroupAddon align="inline-end">
136
138
  <InputGroupButton
137
139
  variant="ghost"
138
- aria-label="ล้างคำค้นหา"
140
+ aria-label={text.table?.clearSearch ?? "ล้างคำค้นหา"}
139
141
  onClick={() => commitSearch("")}
140
142
  >
141
143
  <X aria-hidden="true" className="size-4" />
@@ -166,15 +168,17 @@ export function DataTableToolbar<TData>({
166
168
  className="w-[min(24rem,calc(100vw-1rem))] sm:max-w-md"
167
169
  >
168
170
  <SheetHeader className="border-b pr-12">
169
- <SheetTitle>ตัวกรองข้อมูล</SheetTitle>
170
- <SheetDescription>เลือกได้หลายเงื่อนไข ผลลัพธ์จะอัปเดตทันที</SheetDescription>
171
+ <SheetTitle>{text.table?.filters ?? "ตัวกรองข้อมูล"}</SheetTitle>
172
+ <SheetDescription>
173
+ {text.table?.filtersDescription ?? "เลือกได้หลายเงื่อนไข ผลลัพธ์จะอัปเดตทันที"}
174
+ </SheetDescription>
171
175
  <SheetClose
172
176
  render={
173
177
  <Button variant="ghost" size="icon-sm" className="absolute top-3 right-3" />
174
178
  }
175
179
  >
176
180
  <X />
177
- <span className="sr-only">ปิดตัวกรอง</span>
181
+ <span className="sr-only">{text.table?.closeFilters ?? "ปิดตัวกรอง"}</span>
178
182
  </SheetClose>
179
183
  </SheetHeader>
180
184
  <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4">
@@ -231,7 +235,10 @@ export function DataTableToolbar<TData>({
231
235
  <span className="font-medium">{displayedLabels}</span>
232
236
  <button
233
237
  type="button"
234
- aria-label={`ล้างตัวกรอง ${config.title}: ${displayedLabels}`}
238
+ aria-label={`${(text.table?.clearFilter ?? "ล้างตัวกรอง {name}").replace(
239
+ "{name}",
240
+ config.title,
241
+ )}: ${displayedLabels}`}
235
242
  onClick={() => table.getColumn(filter.id)?.setFilterValue(undefined)}
236
243
  className="ml-1 rounded-full outline-none hover:bg-muted-foreground/20"
237
244
  >
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Button } from "@suphark/ui/components/shared/button/button";
4
4
  import { SwitcherChevron } from "@suphark/ui/components/shared/switcher-chevron";
5
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
5
6
  import {
6
7
  Command,
7
8
  CommandEmpty,
@@ -68,6 +69,7 @@ export function FormFieldCombobox<T extends FieldValues>({
68
69
  autoFocus,
69
70
  onSelect,
70
71
  }: FormFieldComboboxProps<T>) {
72
+ const text = useUiText();
71
73
  const cacheKey = fetcherKey ?? fetcher;
72
74
  const [options, setOptions] = useState<{ label: string; value: string; subLabel?: string }[]>(
73
75
  () => cacheGet(cacheKey) || [],
@@ -133,9 +135,14 @@ export function FormFieldCombobox<T extends FieldValues>({
133
135
  </PopoverTrigger>
134
136
  <PopoverContent className="w-[--anchor-width] p-0">
135
137
  <Command>
136
- <CommandInput placeholder={`ค้นหา ${placeholder.toLowerCase()}...`} />
138
+ <CommandInput
139
+ placeholder={(text.combobox?.search ?? "ค้นหา {label}…").replace(
140
+ "{label}",
141
+ placeholder.toLowerCase(),
142
+ )}
143
+ />
137
144
  <CommandList>
138
- <CommandEmpty>ไม่พบข้อมูล</CommandEmpty>
145
+ <CommandEmpty>{text.combobox?.empty ?? "ไม่พบข้อมูล"}</CommandEmpty>
139
146
  <CommandGroup>
140
147
  {options.map((option) => (
141
148
  <CommandItem
@@ -20,6 +20,16 @@ export interface InlineEditProps {
20
20
  emptyLabel?: ReactNode;
21
21
  /** ตรวจก่อนบันทึก — คืนข้อความ = ไม่ผ่าน */
22
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;
23
33
  /** ค่าเดิมกับค่าใหม่เท่ากัน = ปิดโหมดแก้ไขโดยไม่เรียก `onSave` (ค่าเริ่มต้น true) */
24
34
  skipWhenUnchanged?: boolean;
25
35
  disabled?: boolean;
@@ -42,25 +52,49 @@ export function InlineEdit({
42
52
  placeholder,
43
53
  emptyLabel = "—",
44
54
  validate,
55
+ editing: editingProp,
56
+ onEditingChange,
57
+ defaultEditing = false,
58
+ onCancel,
45
59
  skipWhenUnchanged = true,
46
60
  disabled = false,
47
61
  size = "default",
48
62
  className,
49
63
  }: InlineEditProps) {
50
64
  const text = useUiText();
51
- const [editing, setEditing] = useState(false);
65
+ const [uncontrolledEditing, setUncontrolledEditing] = useState(defaultEditing);
66
+ const editing = editingProp ?? uncontrolledEditing;
52
67
  const [draft, setDraft] = useState(value);
53
68
  const [pending, setPending] = useState(false);
54
69
  const inputRef = useRef<HTMLInputElement>(null);
55
70
  // เลือกข้อความทั้งหมดครั้งแรกที่เข้าโหมดแก้ไข — พิมพ์ทับได้ทันที (โฟกัสรอบหลังไม่เลือกซ้ำ)
56
71
  const selectedOnOpen = useRef(false);
57
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
+
58
86
  function start() {
59
87
  setDraft(value);
60
88
  selectedOnOpen.current = false;
61
89
  setEditing(true);
62
90
  }
63
91
 
92
+ /** ยกเลิก (Esc / ปุ่ม ✕) — ต่างจากบันทึกสำเร็จตรงที่บอกแอปด้วย */
93
+ function cancel() {
94
+ setEditing(false);
95
+ onCancel?.();
96
+ }
97
+
64
98
  async function commit() {
65
99
  const next = draft.trim();
66
100
  if (skipWhenUnchanged && next === value.trim()) {
@@ -148,7 +182,7 @@ export function InlineEdit({
148
182
  void commit();
149
183
  } else if (e.key === "Escape") {
150
184
  e.preventDefault();
151
- setEditing(false);
185
+ cancel();
152
186
  }
153
187
  }}
154
188
  />
@@ -166,7 +200,7 @@ export function InlineEdit({
166
200
  size={compact ? "icon-xs" : "icon-sm"}
167
201
  aria-label={text.cancel}
168
202
  disabled={pending}
169
- onClick={() => setEditing(false)}
203
+ onClick={cancel}
170
204
  >
171
205
  <XIcon />
172
206
  </Button>
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@suphark/ui/components/shared/button/button";
4
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
4
5
  import { buttonVariants } from "@suphark/ui/components/ui/button";
5
6
  import { Card, CardContent, CardHeader, CardTitle } from "@suphark/ui/components/ui/card";
6
7
  import { cn } from "@suphark/ui/lib/utils";
@@ -55,6 +56,7 @@ export function ErrorState({
55
56
  signInHref = "/sign-in",
56
57
  className,
57
58
  }: ErrorStateProps) {
59
+ const text = useUiText();
58
60
  const [sessionExpired, setSessionExpired] = useState(false);
59
61
 
60
62
  useEffect(() => {
@@ -100,19 +102,21 @@ export function ErrorState({
100
102
  <LogIn className="h-10 w-10 text-primary" />
101
103
  </div>
102
104
  </div>
103
- <CardTitle className="text-2xl">เซสชันหมดอายุ</CardTitle>
105
+ <CardTitle className="text-2xl">
106
+ {text.sessionExpired?.title ?? "เซสชันหมดอายุ"}
107
+ </CardTitle>
104
108
  </CardHeader>
105
109
  <CardContent className="space-y-4 text-center">
106
110
  <p className="text-muted-foreground">
107
- คุณถูกออกจากระบบระหว่างใช้งาน ข้อมูลที่ยังไม่ได้บันทึกจะหายไป กรุณาเข้าสู่ระบบอีกครั้ง
108
- แล้วระบบจะพากลับมาหน้านี้
111
+ {text.sessionExpired?.description ??
112
+ "คุณถูกออกจากระบบระหว่างใช้งาน ข้อมูลที่ยังไม่ได้บันทึกจะหายไป กรุณาเข้าสู่ระบบอีกครั้ง แล้วระบบจะพากลับมาหน้านี้"}
109
113
  </p>
110
114
  <div className="flex justify-center pt-2">
111
115
  {/* full page load เพื่อให้ proxy/เซสชันเริ่มใหม่สะอาด ๆ */}
112
116
  {/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
113
117
  <a href={signInUrl} className={cn(buttonVariants())}>
114
118
  <LogIn className="mr-2 h-4 w-4" />
115
- เข้าสู่ระบบ
119
+ {text.sessionExpired?.signIn ?? "เข้าสู่ระบบ"}
116
120
  </a>
117
121
  </div>
118
122
  </CardContent>