@suphark/ui 0.26.0 → 0.26.1

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,46 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.26.1] — 2026-10-03
6
+
7
+ แก้ตามผล audit 2026-10-03 (`docs/reports/audit-2026-10-03.md`) — ไม่เปลี่ยน props
8
+
9
+ ### Fixed
10
+
11
+ - **`RuleBuilder`**: แก้ค่าในเงื่อนไขแล้ว element ถูกสร้างใหม่ทุกครั้ง (key ผูกกับ object) → ช่องที่กำลังพิมพ์หลุดโฟกัสทุกตัวอักษร — object ใหม่ที่มาแทนใช้ key เดิม
12
+ - **`RowActions` / `ConfirmActionButton` / `ActionMenu` / `StepForm` / `ChatThread`**: server action ที่ `redirect()`/`notFound()` เคยขึ้น toast "NEXT_REDIRECT" — ถือว่าสำเร็จ (Next นำทางให้แล้ว); `ActionMenu`/`StepForm`/`ChatThread` ถือ `{ ok: false, error }` เป็นล้มเหลวด้วย (ตัวช่วยใหม่ใน `lib/action-result.ts`: `errorMessage`, `failedResult`, `isNavigationError` — `@suphark/ui/next` ยัง export สองตัวแรกจาก confirm-action-button เหมือนเดิม)
13
+ - **`RowActions` + `confirm`**: เคยปิด dialog ก่อนรัน action — ตอนนี้ปุ่มยืนยันหมุนระหว่างรอ ล้มเหลวคง dialog ไว้ให้ลองใหม่ สำเร็จค่อยปิด (ตามที่ 0.25.1 ตั้งใจ)
14
+ - **`useDialogs().confirm`** รองรับ `requireText` (เดิมข้ามการพิมพ์ยืนยันไปเงียบ ๆ)
15
+ - **`DataTable` / `useDataTable`**: เลือกแถวตาม `row.id` เป็นค่าเริ่มต้น (ไม่มี id = index เหมือนเดิม) — เดิมใช้ลำดับแถว ข้อมูลเปลี่ยน/แทรกแถวแล้วแถวที่เลือกกลายเป็นแถวอื่น (`DeleteManyButton` ลบผิดรายการ); การลากเรียงเทียบ id แบบ string
16
+ - **`useDataTable` `columnFilters` (regression 0.25.1)**: เทียบด้วยค่าแต่ตั้ง state เป็นค่าจริงจาก prop — เดิม `JSON.parse` ทำให้ `Date` กลายเป็น string; ค่าที่ stringify ไม่ได้ (BigInt/วงอ้างอิง) ไม่ throw ตอน render
17
+ - **`DataTable` ช่องค้นหา**: Enter ไม่ส่งซ้ำอีกรอบหลัง 250 ms; ปุ่มล้างตัวกรองล้างข้อความที่พิมพ์ค้างด้วย
18
+ - **`NumberInput` / `TagsInput` ใน `<label>` ที่ครอบ** (`LabeledField` ไม่ใส่ `htmlFor`): คลิกข้อความ label เคยกดปุ่ม "ลด"/ลบป้ายแรก — ช่องพิมพ์อยู่ก่อนปุ่มใน DOM แล้ววางตำแหน่งด้วย CSS `order`
19
+ - `NumberInput`: ทั้งช่องเคยจางเหมือนปิดใช้งานเมื่อค่าเท่ากับ `min`/`max` (`has-disabled` จับปุ่ม −/+ ที่ปิด) — จางเฉพาะเมื่อช่องปิดจริง; `step` ที่เปลี่ยนหลัง mount ไม่ทำให้ทศนิยมถูกปฏิเสธอีก; `size="sm"` 16px บนมือถือ (iOS ไม่ซูม)
20
+ - `TagsInput`: ค่าที่ถูกปฏิเสธค้างในช่อง = ช่อง invalid (ฟอร์มไม่ส่งรายการที่ขาดค่านั้นไปเงียบ ๆ)
21
+ - `TimePicker`: ปุ่ม "ตอนนี้" ปัดตาม `roundToStep`
22
+ - **`StepForm`**: ดับเบิลคลิก "ถัดไป" ไม่ข้ามขั้นกลาง; `onSubmit` ที่จบเร็ว/sync ไม่ถูกส่งซ้ำ; ตัวควบคุม Base UI ที่ไม่ผ่าน (Select/Checkbox/Switch) โฟกัสตัวมันเอง ไม่ใช่ช่องอื่น
23
+ - **`CommandPalette`**: เช็ก Ctrl/⌘ ก่อนอ่าน `e.key` (keydown จาก autofill ไม่มี key ไม่ throw); ไม่เปิดด้วย AltGr (ctrl+alt); แป้นละตินเทียบ `e.key` (Dvorak ฯลฯ) แป้นไทยใช้ `e.code`; 2 ตัวในหน้าเดียวเปิดทีละตัว
24
+ - `ChatThread`: ข้อความที่พิมพ์ระหว่างรอส่งไม่ถูกล้างทิ้ง
25
+ - `SettingsLayout`: มือถือกด "กลับ" ขณะหัวข้อที่เปิดถูกกรองด้วยคำค้น โฟกัสไปช่องค้นหาแทนหลุดไป body
26
+ - **service worker (PWA)**: reload หลังมีเวอร์ชันใหม่เคยเกิดทุก `focusout` (ย้ายช่อง/กดบันทึก = reload กลางฟอร์ม) — รอนิ่ง 2 วินาทีหลังออกจากช่อง ยกเลิกเมื่อมีการคลิก/พิมพ์/โฟกัสใหม่
27
+ - `PullToRefresh`: ฟัง scroll แบบ capture ที่ document (root ที่ mount ทีหลัง/หน้าเลื่อนทั้ง document ใช้ได้)
28
+ - `ProviderButtons`: ลิงก์ที่ปิดอยู่ดูปิดจริงและถอด `href` (กันคลิกกลาง); `aria-busy` ทั้งสองแบบ
29
+ - `useTableSearchParams`: ค่าที่รอส่งไม่ค้างเมื่อ URL ไม่เปลี่ยน; key `__proto__` จาก URL ไม่ทับ prototype
30
+ - `MonthStepper`: JSDoc กลับมาแสดงใน editor; `DesignSystemShowcase` กระโดดข้ามแท็บรอบใหม่ได้เวลารอเต็ม
31
+ - CSS: scrollbar ทั้งหน้า (`*{scrollbar-width}`) ย้ายเข้า `@layer base` — เดิมทับ `no-scrollbar`/className ของแอปทุกที่ (เช่น คอลัมน์ TimePicker)
32
+
33
+ ### Security
34
+
35
+ - **`ActionMenu` href + confirm**: นำทางผ่าน `location.assign`/`window.open` ซึ่งไม่กัน `javascript:` แบบ `<a href>` — รับเฉพาะ http(s)/path ภายใน (`lib/safe-url.ts` `safeNavigableUrl` ใช้ร่วมกับ `ClientRedirect`); ลิงก์แท็บใหม่ที่ไม่มี onClick เปิดทันทีในคลิก (ไม่โดน popup blocker)
36
+ - `check:barrel` จับ `export *` ทุกรูปแบบ (alias `@suphark/ui/...`, แพ็กเกจ, quote เดี่ยว, comment ท้ายบรรทัด) และรันใน `verify` ของแพ็กเกจ + `prepublishOnly` (publish ไม่ผ่าน CI ก็ยังถูกตรวจ); README สอนขั้นตอนที่ถูก (`fix:barrel`)
37
+ - showcase: demo ไม่มีค่าที่หน้าตาเหมือนคีย์จริง (`pk_live_`) แล้ว; หน้า time-picker บอกรูปแบบค่า DateTimePicker ที่มี offset
38
+ - tests 12 กรณี (`fixes-0-26-1.test.tsx`, RuleBuilder, service worker)
39
+
40
+ - showcase (security): `next` ^16.3.5 → ^16.3.8 — 16.3.5 มี advisory **critical** GHSA-vcvr-r3jv-pc5j (RCE ใน `next/og` ImageResponse; showcase ไม่ได้ใช้ `next/og` จึงไม่ถูกโจมตีได้จริง แต่ `pnpm audit` แจ้ง) — `pnpm audit --prod` เหลือ 0; devDependency `next` ของแพ็กเกจ bump ตาม (peer ยัง `>=15`)
41
+ - showcase (security): `@suphark/auth-client` ^0.6.1 → **^0.6.2** (แก้ dot segment ต้นทาง) — ลบตัวกรอง `?next=` ชั่วคราวใน `/auth/[...pi]` ออก; ทดสอบ `/auth/logout` 7 กรณี (`/.//`, `/..//`, `/%2e%2e//`, `/./../..//`, tab, `//`, `/brand`) ด้วย server จริงบนเครื่องแล้ว — ปลายทางไม่ออกนอกโดเมน
42
+ - showcase: `@suphark/config-client` ^0.3.0 → ^0.9.2 (CLI `pi-config pull` ตอน build บน Vercel — ข้าม 0.9.0 ที่ pull ค่าหลายบรรทัด CRLF ไม่ได้; 0.9.x บังคับ `PI_CONFIG_URL` เป็น https, ไม่ตาม redirect, timeout 30s; 0.9.2 แก้ crash exit 127 บน Windows) — ทดสอบ pull ด้วย token จาก env (แบบ Vercel) ได้ 6 คีย์; diff/--check บน Windows exit 0/1 ถูกต้อง 10 รอบไม่ crash
43
+ - showcase (security): `safeNextPath` ปฏิเสธผลที่ normalize แล้วเป็น `//host` — dot segment `/.//evil.com`, `/..//evil.com`, `/%2e%2e//evil.com` เคยผ่าน (origin ตอน parse ยังเดิม) แล้ว redirect ไป evil.com; `/auth/[...pi]` กรอง `?next=` ด้วยตัวนี้ก่อนส่งให้ handler ของ `@suphark/auth-client` 0.6.1 ที่มีช่องเดียวกัน (แก้ต้นทางใน 0.6.2 — ลบตัวกรองได้หลัง bump) — production ก่อน deploy นี้: `curl /auth/logout?next=/.//evil.com` → 303 https://evil.com/ (รายงานโดย pi-auth และ pi-config)
44
+
5
45
  ## [0.26.0] — 2026-10-03
6
46
 
7
47
  ปิดงานค้างจากผลรีวิว + prop ที่ DueBell ขอ — เพิ่มอย่างเดียว
package/README.md CHANGED
@@ -218,7 +218,7 @@ npx suphark-ui-audit --strict # warning นับเป็น error (ใช้
218
218
  ```bash
219
219
  pnpm install
220
220
  pnpm dev # apps/showcase → http://localhost:3200/design-system
221
- pnpm verify # typecheck + biome + lint:base-ui + check:typography + vitest
221
+ pnpm verify # typecheck + biome + lint:base-ui + check:typography + check:changelog + check:barrel + audit + vitest
222
222
  pnpm --filter @suphark/ui test
223
223
  ```
224
224
 
@@ -228,7 +228,7 @@ pnpm --filter @suphark/ui test
228
228
  cd packages/ui && pnpm dlx shadcn@latest add <name>
229
229
  ```
230
230
 
231
- แล้วเพิ่ม `export * from "./components/ui/<name>"` ใน `src/index.ts`
231
+ แล้วเพิ่ม `export * from "./components/ui/<name>"` ใน `src/index.ts` **แล้วรัน `pnpm fix:barrel` ทันที** (แปลงเป็นรายชื่อ export) — ห้ามมี `export *` ค้างใน `src`: server component ที่ import barrel แบบ `export *` ทำให้ทุกหน้าโหลด client component ทั้งแพ็กเกจ (`pnpm check:barrel` ตรวจใน verify, CI และก่อน publish); CLI เขียน `import { cn } from "cn"` ให้เปลี่ยนเป็น `@suphark/ui/lib/utils`
232
232
 
233
233
  ## Versioning
234
234
 
@@ -237,8 +237,9 @@ semver — patch = หน้าตา/บั๊ก, minor = component/prop ใ
237
237
  ## Publish
238
238
 
239
239
  ```bash
240
+ pnpm verify # จาก root ก่อนทุกครั้ง (หลัง commit/push แล้ว)
240
241
  cd packages/ui
241
- pnpm publish --access public
242
+ npm publish --access public # Windows: npm.cmd — prepublishOnly รัน check:barrel + typecheck ให้อีกรอบ
242
243
  ```
243
244
 
244
245
  (ผู้ใช้ `npm login` เอง; ไม่มีขั้น build เพราะส่ง source)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
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 เอง",
@@ -61,7 +61,9 @@
61
61
  "test:watch": "vitest",
62
62
  "lint:base-ui": "node scripts/audit-base-ui-props.mjs",
63
63
  "check:typography": "node scripts/audit-tailwind-typography.mjs",
64
- "verify": "pnpm typecheck && pnpm lint:base-ui && pnpm check:typography && pnpm test"
64
+ "verify": "pnpm typecheck && pnpm check:barrel && pnpm lint:base-ui && pnpm check:typography && pnpm test",
65
+ "check:barrel": "node ../../scripts/barrel-exports.mjs",
66
+ "prepublishOnly": "node ../../scripts/barrel-exports.mjs && tsc --noEmit"
65
67
  },
66
68
  "dependencies": {
67
69
  "@dnd-kit/core": "^6.3.1",
@@ -117,7 +119,7 @@
117
119
  "@vitejs/plugin-react": "^6.1.0",
118
120
  "jsdom": "^29.1.1",
119
121
  "lucide-react": "^1.45.0",
120
- "next": "^16.3.5",
122
+ "next": "^16.3.8",
121
123
  "react": "^19.3.0",
122
124
  "react-dom": "^19.3.0",
123
125
  "react-hook-form": "^7.86.0",
@@ -155,11 +155,19 @@ export function CommandPalette({
155
155
  ? `Digit${key}`
156
156
  : null;
157
157
  const onKey = (e: KeyboardEvent) => {
158
- const matches = e.key.toLowerCase() === key || (code !== null && e.code === code);
159
- if ((e.metaKey || e.ctrlKey) && matches) {
160
- e.preventDefault();
161
- setOpenRef.current(!openRef.current);
162
- }
158
+ // ต้องมี Ctrl/⌘ และไม่ใช่ AltGr (Windows ส่ง AltGr เป็น ctrl+alt — พิมพ์อักษรพิเศษในบางภาษา)
159
+ if (!(e.metaKey || e.ctrlKey) || e.altKey) return;
160
+ // palette ตัวอื่น (เช่น 2 ตัวในหน้าเดียว) จัดการปุ่มนี้ไปแล้ว — เปิดได้ทีละตัว
161
+ if (e.defaultPrevented) return;
162
+ const pressed = typeof e.key === "string" ? e.key.toLowerCase() : ""; // autofill ส่ง keydown ที่ไม่มี key
163
+ // อักษรละติน/ตัวเลขเทียบตาม e.key (เคารพแป้นอย่าง Dvorak) — แป้นที่ไม่ใช่ละติน (ไทย "า") ใช้ตำแหน่งปุ่ม e.code
164
+ const latin = /^[a-z0-9]$/.test(pressed);
165
+ const matches = latin
166
+ ? pressed === key
167
+ : pressed === key || (code !== null && e.code === code);
168
+ if (!matches) return;
169
+ e.preventDefault();
170
+ setOpenRef.current(!openRef.current);
163
171
  };
164
172
  window.addEventListener("keydown", onKey);
165
173
  return () => window.removeEventListener("keydown", onKey);
@@ -135,10 +135,11 @@ export function SortableTableProvider<TData>({
135
135
 
136
136
  const { active, over } = event;
137
137
  if (over && active.id !== over.id) {
138
- const idOf = (item: TData) =>
139
- getRowId ? getRowId(item) : (item as { id?: string | number }).id;
140
- const oldIndex = data.findIndex((item) => idOf(item) === active.id);
141
- const newIndex = data.findIndex((item) => idOf(item) === over.id);
138
+ // id ของแถวใน tanstack เป็น string เสมอ (getRowId / row.id / index) — เทียบแบบ string
139
+ const idOf = (item: TData, index: number) =>
140
+ String(getRowId ? getRowId(item) : ((item as { id?: string | number }).id ?? index));
141
+ const oldIndex = data.findIndex((item, i) => idOf(item, i) === String(active.id));
142
+ const newIndex = data.findIndex((item, i) => idOf(item, i) === String(over.id));
142
143
 
143
144
  if (oldIndex !== -1 && newIndex !== -1) {
144
145
  onReorder(arrayMove(data, oldIndex, newIndex));
@@ -56,16 +56,25 @@ export function DataTableToolbar<TData>({
56
56
  setSearchDraft(external);
57
57
  }
58
58
  }, [globalFilter]);
59
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
59
60
  useEffect(() => {
60
61
  if (searchDraft === committed.current) return;
61
- const timer = setTimeout(() => {
62
+ timer.current = setTimeout(() => {
63
+ timer.current = null;
62
64
  committed.current = searchDraft;
63
65
  table.setGlobalFilter(searchDraft);
64
66
  }, 250);
65
- return () => clearTimeout(timer);
67
+ return () => {
68
+ if (timer.current) clearTimeout(timer.current);
69
+ timer.current = null;
70
+ };
66
71
  }, [searchDraft, table]);
72
+ /** ส่งทันที (Enter / ปุ่มล้าง / ล้างตัวกรอง) — ยกเลิกตัวนับเวลาที่ค้าง ไม่ให้ส่งซ้ำอีกรอบ */
67
73
  const commitSearch = (value: string) => {
74
+ if (timer.current) clearTimeout(timer.current);
75
+ timer.current = null;
68
76
  setSearchDraft(value);
77
+ if (committed.current === value) return;
69
78
  committed.current = value;
70
79
  table.setGlobalFilter(value);
71
80
  };
@@ -73,7 +82,7 @@ export function DataTableToolbar<TData>({
73
82
  const userColumnFilters = table
74
83
  .getState()
75
84
  .columnFilters.filter((filter) => !preservedFilterIdSet.has(filter.id));
76
- const activeFilterCount = userColumnFilters.length + (globalFilter ? 1 : 0);
85
+ const activeFilterCount = userColumnFilters.length + (globalFilter || searchDraft ? 1 : 0);
77
86
 
78
87
  const isFiltered = activeFilterCount > 0;
79
88
 
@@ -81,6 +90,8 @@ export function DataTableToolbar<TData>({
81
90
  table.setColumnFilters((filters) =>
82
91
  filters.filter((filter) => preservedFilterIdSet.has(filter.id)),
83
92
  );
93
+ // ล้างทั้งข้อความที่พิมพ์ค้าง (ยังไม่ถึง 250 ms) และค่าที่ส่งแล้ว
94
+ commitSearch("");
84
95
  table.setGlobalFilter("");
85
96
  };
86
97
 
@@ -30,6 +30,33 @@ function getNestedValue<TData>(obj: TData, path: string): unknown {
30
30
  return current;
31
31
  }
32
32
 
33
+ let refSeq = 0;
34
+ const refKeys = new WeakMap<object, string>();
35
+ /** key สำหรับเทียบค่า: JSON ถ้าได้ ไม่งั้นใช้ตัวอ้างอิงของ object */
36
+ function stableKey(value: unknown): string | undefined {
37
+ if (value === undefined) return undefined;
38
+ try {
39
+ return JSON.stringify(value);
40
+ } catch {
41
+ if (value && typeof value === "object") {
42
+ let k = refKeys.get(value);
43
+ if (!k) {
44
+ k = `ref:${++refSeq}`;
45
+ refKeys.set(value, k);
46
+ }
47
+ return k;
48
+ }
49
+ return String(value);
50
+ }
51
+ }
52
+
53
+ function defaultRowId<T>(row: T, index: number, parent?: { id: string }): string {
54
+ const id = (row as { id?: unknown } | null)?.id;
55
+ if (typeof id === "string" || typeof id === "number")
56
+ return parent ? `${parent.id}.${id}` : String(id);
57
+ return parent ? `${parent.id}.${index}` : String(index);
58
+ }
59
+
33
60
  export function useDataTable<TData, TValue>({
34
61
  columns,
35
62
  data,
@@ -68,12 +95,16 @@ export function useDataTable<TData, TValue>({
68
95
 
69
96
  // Sync with prop if provided — เทียบด้วยค่าเหมือน sorting/pagination: ผู้เรียกมักส่ง array literal ใหม่ทุก render
70
97
  // ถ้าผูกกับตัวอ้างอิง effect จะรันทุก render → render ซ้ำ + ล้าง memo ของ row model + ทับตัวกรองที่ผู้ใช้เพิ่งเลือก
71
- const columnFiltersKey = columnFiltersProp ? JSON.stringify(columnFiltersProp) : undefined;
98
+ // ใช้ key แค่ตัดสินว่า "เปลี่ยนจริงไหม" แต่ตั้ง state เป็นค่าจริงจาก prop (Date/Set ฯลฯ ไม่กลายเป็น string)
99
+ // ค่าที่ stringify ไม่ได้ (BigInt, วงอ้างอิง) ถอยไปเทียบด้วยตัวอ้างอิงแทน throw ตอน render
100
+ const columnFiltersKey = stableKey(columnFiltersProp);
101
+ const columnFiltersPropRef = React.useRef(columnFiltersProp);
102
+ columnFiltersPropRef.current = columnFiltersProp;
72
103
  React.useEffect(() => {
73
104
  if (columnFiltersKey === undefined) return;
74
- setColumnFilters((current) =>
75
- JSON.stringify(current) === columnFiltersKey ? current : JSON.parse(columnFiltersKey),
76
- );
105
+ const next = columnFiltersPropRef.current;
106
+ if (next)
107
+ setColumnFilters((current) => (stableKey(current) === columnFiltersKey ? current : next));
77
108
  }, [columnFiltersKey]);
78
109
  const [sorting, setSorting] = React.useState<SortingState>(
79
110
  () => sortingProp ?? (initialState?.sorting as SortingState | undefined) ?? [],
@@ -188,7 +219,9 @@ export function useDataTable<TData, TValue>({
188
219
  },
189
220
  meta,
190
221
  getSubRows: getSubRows,
191
- getRowId: getRowId,
222
+ // ค่าเริ่มต้นใช้ `row.id` ถ้ามี — tanstack ใช้ลำดับแถว (index) ทำให้แถวที่เลือกไว้กลายเป็นแถวอื่นเมื่อข้อมูลเปลี่ยน
223
+ // (DeleteManyButton ลบผิดรายการ) — ไม่มี id = ใช้ index เหมือนเดิม
224
+ getRowId: getRowId ?? defaultRowId,
192
225
  getExpandedRowModel: getExpandedRowModel(),
193
226
  filterFromLeafRows: true,
194
227
  enableRowSelection,
@@ -59,6 +59,7 @@ export function DesignSystemShowcase({
59
59
  const tries = React.useRef(0);
60
60
  const [retry, setRetry] = React.useState(0);
61
61
  const jumpTo = React.useCallback((tab: string, anchor?: string) => {
62
+ tries.current = 0; // การกระโดดครั้งใหม่ได้เวลารอแท็บ lazy เต็มรอบ
62
63
  setActiveTab(tab);
63
64
  setPendingAnchor(anchor ?? "__top__");
64
65
  }, []);
@@ -56,7 +56,11 @@ export function PullToRefresh({
56
56
  const isRefreshing = () => isRefreshingRef.current;
57
57
  const isMobileViewport = () => window.innerWidth < MOBILE_BREAKPOINT;
58
58
  const getRoot = () => document.querySelector<HTMLElement>(rootSelector);
59
- const isRootAtTop = (root: HTMLElement) => root.scrollTop <= 0;
59
+ // root เป็น <html>/<body> = หน้าเลื่อนทั้ง document → ดูตำแหน่งของ window
60
+ const isRootAtTop = (root: HTMLElement) =>
61
+ root === document.documentElement || root === document.body
62
+ ? window.scrollY <= 0
63
+ : root.scrollTop <= 0;
60
64
 
61
65
  const resetPull = () => {
62
66
  startRef.current = null;
@@ -65,6 +69,8 @@ export function PullToRefresh({
65
69
  };
66
70
 
67
71
  const handleTouchStart = (event: TouchEvent) => {
72
+ // root ที่ mount หลัง PullToRefresh และไม่เคย scroll: ตรวจตอนเริ่มแตะด้วย (ครั้งถัดไปดึงได้แน่นอน)
73
+ syncMoveListener();
68
74
  const touch = event.touches[0];
69
75
  if (!isMobileViewport() || isRefreshing() || event.touches.length !== 1 || !touch) {
70
76
  resetPull();
@@ -127,16 +133,17 @@ export function PullToRefresh({
127
133
  moveAttached = false;
128
134
  }
129
135
  };
130
- const root = getRoot();
131
136
  syncMoveListener();
132
- root?.addEventListener("scroll", syncMoveListener, { passive: true });
137
+ // ฟัง scroll แบบ capture ที่ document: ได้ทั้ง root ที่ mount ทีหลัง/ถูกแทนที่ และหน้าที่เลื่อนทั้ง document
138
+ // (หา root ใหม่ทุกครั้งใน syncMoveListener — เดิมผูกกับ root ที่เจอตอน mount ครั้งเดียว)
139
+ document.addEventListener("scroll", syncMoveListener, { capture: true, passive: true });
133
140
  window.addEventListener("resize", syncMoveListener, { passive: true });
134
141
 
135
142
  window.addEventListener("touchstart", handleTouchStart, { passive: true });
136
143
  window.addEventListener("touchend", handleTouchEnd, { passive: true });
137
144
  window.addEventListener("touchcancel", resetPull, { passive: true });
138
145
  return () => {
139
- root?.removeEventListener("scroll", syncMoveListener);
146
+ document.removeEventListener("scroll", syncMoveListener, { capture: true });
140
147
  window.removeEventListener("resize", syncMoveListener);
141
148
  window.removeEventListener("touchstart", handleTouchStart);
142
149
  window.removeEventListener("touchmove", handleTouchMove);
@@ -1,4 +1,6 @@
1
1
  const UPDATE_CHECK_INTERVAL_MS = 15 * 60_000;
2
+ /** หลังออกจากช่องกรอก รอให้นิ่งก่อน reload — ระหว่างย้ายช่อง/กดปุ่มบันทึก activeElement เป็น body ชั่วขณะ */
3
+ const IDLE_RELOAD_MS = 2000;
2
4
 
3
5
  const isEditableElement = (element: Element | null) => {
4
6
  if (!element) return false;
@@ -16,7 +18,7 @@ export const canReloadNow = (doc: Document) =>
16
18
  *
17
19
  * - ขอให้ browser เช็ค script ใหม่ตอนโหลด, ทุกครั้งที่กลับมาที่แท็บ, และทุก 15 นาที
18
20
  * - worker ฝั่งแอปต้อง `skipWaiting()` + `clients.claim()` (ดู README) → ตัวใหม่คุมหน้าเมื่อติดตั้งเสร็จ
19
- * แล้ว `controllerchange` จะ reload หน้าให้ bundle ใหม่ทำงานทันที — เลื่อน reload เฉพาะขณะผู้ใช้กำลังพิมพ์
21
+ * แล้ว `controllerchange` จะ reload หน้าให้ bundle ใหม่ทำงานทันที — ขณะผู้ใช้กำลังพิมพ์เลื่อนไปจนออกจากช่องและนิ่ง 2 วินาที
20
22
  * และไม่ reload ตอนติดตั้งครั้งแรก
21
23
  *
22
24
  * คืนฟังก์ชัน cleanup
@@ -42,6 +44,22 @@ export function watchServiceWorkerUpdates(
42
44
  if (canReloadNow(doc)) reload();
43
45
  };
44
46
 
47
+ // ออกจากช่องกรอก: ไม่ reload ทันที (focusout เกิดก่อน focus ไปช่องถัดไป และก่อน click ของปุ่มบันทึก
48
+ // → reload กลางการกรอก/ทิ้งการบันทึก) — รอ 2 วินาทีที่ไม่มีการกด/พิมพ์/โฟกัสใหม่ แล้วค่อยลอง
49
+ let idleTimer: number | null = null;
50
+ const cancelIdleReload = () => {
51
+ if (idleTimer !== null) win.clearTimeout(idleTimer);
52
+ idleTimer = null;
53
+ };
54
+ const scheduleIdleReload = () => {
55
+ cancelIdleReload();
56
+ if (!reloadPending) return;
57
+ idleTimer = win.setTimeout(() => {
58
+ idleTimer = null;
59
+ tryReload();
60
+ }, IDLE_RELOAD_MS);
61
+ };
62
+
45
63
  const handleControllerChange = () => {
46
64
  // ติดตั้งครั้งแรก: หน้านี้รัน bundle ปัจจุบันอยู่แล้ว
47
65
  if (!hadController) return;
@@ -63,14 +81,19 @@ export function watchServiceWorkerUpdates(
63
81
 
64
82
  container.addEventListener("controllerchange", handleControllerChange);
65
83
  doc.addEventListener("visibilitychange", handleVisibilityChange);
66
- doc.addEventListener("focusout", tryReload);
84
+ doc.addEventListener("focusout", scheduleIdleReload);
85
+ // ผู้ใช้ยังทำอะไรอยู่ = เลื่อนออกไป (focusout ครั้งถัดไปจะนับใหม่)
86
+ const activity = ["focusin", "pointerdown", "keydown"] as const;
87
+ for (const type of activity) doc.addEventListener(type, cancelIdleReload, true);
67
88
  const interval = win.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS);
68
89
  checkForUpdate();
69
90
 
70
91
  return () => {
71
92
  container.removeEventListener("controllerchange", handleControllerChange);
72
93
  doc.removeEventListener("visibilitychange", handleVisibilityChange);
73
- doc.removeEventListener("focusout", tryReload);
94
+ doc.removeEventListener("focusout", scheduleIdleReload);
95
+ for (const type of activity) doc.removeEventListener(type, cancelIdleReload, true);
96
+ cancelIdleReload();
74
97
  win.clearInterval(interval);
75
98
  };
76
99
  }
@@ -11,6 +11,8 @@ import {
11
11
  DropdownMenuShortcut,
12
12
  DropdownMenuTrigger,
13
13
  } from "@suphark/ui/components/ui/dropdown-menu";
14
+ import { errorMessage, failedResult, isNavigationError } from "@suphark/ui/lib/action-result";
15
+ import { safeNavigableUrl } from "@suphark/ui/lib/safe-url";
14
16
  import { MoreHorizontal } from "lucide-react";
15
17
  import { type ReactElement, type ReactNode, useState } from "react";
16
18
  import { toast } from "sonner";
@@ -70,25 +72,32 @@ export function ActionMenu({
70
72
  if (!fn) return true;
71
73
  setPending(true);
72
74
  try {
73
- await fn();
75
+ // คืน { ok: false, error } (FormState) ก็ถือว่าล้มเหลวเหมือน throw
76
+ const failed = failedResult(await fn(), text.actionFailed ?? "ดำเนินการไม่สำเร็จ");
77
+ if (failed) {
78
+ toast.error(failed);
79
+ return false;
80
+ }
74
81
  return true;
75
82
  } catch (err) {
76
- toast.error(
77
- err instanceof Error && err.message
78
- ? err.message
79
- : (text.actionFailed ?? "ดำเนินการไม่สำเร็จ"),
80
- );
83
+ // server action ที่ redirect() = สำเร็จ (Next นำทางให้แล้ว) — ไม่แสดง "NEXT_REDIRECT"
84
+ if (isNavigationError(err)) return true;
85
+ toast.error(errorMessage(err, text.actionFailed ?? "ดำเนินการไม่สำเร็จ"));
81
86
  return false;
82
87
  } finally {
83
88
  setPending(false);
84
89
  }
85
90
  };
86
91
 
87
- /** ลิงก์ที่มี confirm: ไปหลังยืนยันแล้วเท่านั้น */
92
+ /**
93
+ * ลิงก์ที่มี confirm: ไปหลังยืนยันแล้วเท่านั้น — ไม่ได้ผ่าน `<a href>` (ที่ React กัน javascript: ให้)
94
+ * จึงต้องรับเฉพาะ http(s) และ path ภายในเอง
95
+ */
88
96
  const follow = (item: ActionMenuCommand) => {
89
- if (!item.href) return;
90
- if (item.external) window.open(item.href, "_blank", "noopener,noreferrer");
91
- else window.location.assign(item.href);
97
+ const url = safeNavigableUrl(item.href);
98
+ if (!url) return;
99
+ if (item.external) window.open(url, "_blank", "noopener,noreferrer");
100
+ else window.location.assign(url);
92
101
  };
93
102
 
94
103
  const spec = confirming?.confirm ? normalizeConfirm(confirming.confirm) : null;
@@ -164,6 +173,12 @@ export function ActionMenu({
164
173
  destructive={spec.destructive ?? confirming.destructive}
165
174
  pending={pending}
166
175
  onConfirm={async () => {
176
+ // ลิงก์แท็บใหม่ที่ไม่มี onClick: เปิดทันทีใน event คลิก (หลัง await เบราว์เซอร์บล็อก popup)
177
+ if (confirming.external && !confirming.onClick) {
178
+ setConfirming(null);
179
+ follow(confirming);
180
+ return;
181
+ }
167
182
  // ล้มเหลว = คง dialog ไว้ให้ลองใหม่ (toast แจ้งแล้วใน run)
168
183
  if (!(await run(confirming.onClick))) return;
169
184
  setConfirming(null);
@@ -104,11 +104,12 @@ export function ProviderButtons({
104
104
  <Button
105
105
  key={p.id}
106
106
  variant="outline"
107
- className={buttonClass}
107
+ // ลิงก์ที่ปิดอยู่: Button ใช้ :disabled ที่ <a> ไม่มี → ทำให้ดูปิดเอง และถอด href (กันคลิกกลาง/เปิดแท็บใหม่)
108
+ className={cn(buttonClass, (disabled || busy) && "pointer-events-none opacity-50")}
108
109
  disabled={disabled || busy}
109
110
  aria-busy={busy || undefined}
110
111
  nativeButton={false}
111
- render={<a href={p.href} {...a11y} />}
112
+ render={<a href={disabled || busy ? undefined : p.href} {...a11y} />}
112
113
  >
113
114
  {content}
114
115
  </Button>
@@ -119,6 +120,7 @@ export function ProviderButtons({
119
120
  variant="outline"
120
121
  className={buttonClass}
121
122
  disabled={disabled || busy}
123
+ aria-busy={busy || undefined}
122
124
  {...a11y}
123
125
  onClick={p.onClick}
124
126
  >
@@ -10,23 +10,13 @@ import {
10
10
  normalizeConfirm,
11
11
  } from "@suphark/ui/components/shared/confirm-dialog";
12
12
  import { useUiText } from "@suphark/ui/components/shared/ui-text";
13
+ import { errorMessage, failedResult, isNavigationError } from "@suphark/ui/lib/action-result";
13
14
  import { useRouter } from "next/navigation";
14
15
  import { useState, useTransition } from "react";
15
16
  import { toast } from "sonner";
16
17
 
17
- /** ข้อความจาก error ที่แสดงให้ผู้ใช้ได้ (Error.message ที่ไม่ว่าง ไม่งั้นใช้ fallback) */
18
- export function errorMessage(err: unknown, fallback: string): string {
19
- return err instanceof Error && err.message ? err.message : fallback;
20
- }
21
-
22
- /** action คืน `FormState` แบบ `{ ok: false, error }` (ไม่ได้ throw) = ล้มเหลว → ข้อความ error; อย่างอื่น = null */
23
- export function failedResult(result: unknown, fallback: string): string | null {
24
- if (result && typeof result === "object" && "ok" in result && result.ok === false) {
25
- const error = "error" in result ? result.error : undefined;
26
- return typeof error === "string" && error ? error : fallback;
27
- }
28
- return null;
29
- }
18
+ // ตัวช่วยตีความผล action ย้ายไป lib/action-result (ไม่พึ่ง next — ใช้ได้จาก barrel) — คง export เดิมไว้
19
+ export { errorMessage, failedResult } from "@suphark/ui/lib/action-result";
30
20
 
31
21
  /**
32
22
  * ปุ่มเดี่ยวที่ถามยืนยัน (ConfirmDialog) ก่อนเรียก server action — ใช้แทน `<form action>` + window.confirm
@@ -92,6 +82,12 @@ export function ConfirmActionButton({
92
82
  return;
93
83
  }
94
84
  } catch (err) {
85
+ // action redirect() = สำเร็จ (Next นำทางให้แล้ว) — ปิด dialog เงียบ ๆ ไม่แสดง "NEXT_REDIRECT"
86
+ if (isNavigationError(err)) {
87
+ onDone?.();
88
+ setOpen(false);
89
+ return;
90
+ }
95
91
  toast.error(errorMessage(err, fallback));
96
92
  return;
97
93
  }
@@ -24,6 +24,7 @@ import {
24
24
  MessageScrollerViewport,
25
25
  } from "@suphark/ui/components/ui/message-scroller";
26
26
  import { Spinner } from "@suphark/ui/components/ui/spinner";
27
+ import { errorMessage, failedResult, isNavigationError } from "@suphark/ui/lib/action-result";
27
28
  import { cn } from "@suphark/ui/lib/utils";
28
29
  import { ArrowDown, SendHorizontal } from "lucide-react";
29
30
  import { type ReactNode, useState } from "react";
@@ -156,15 +157,21 @@ function ChatComposer({
156
157
 
157
158
  const send = async () => {
158
159
  if (!canSend) return;
160
+ const sent = value;
159
161
  setPending(true);
162
+ // ล้างช่องเฉพาะเมื่อผู้ใช้ยังไม่ได้พิมพ์ต่อระหว่างรอส่ง (ไม่ทิ้งข้อความใหม่)
163
+ const clearSent = () => setValue((current) => (current === sent ? "" : current));
160
164
  try {
161
- await onSend(value.trim());
162
- setValue("");
165
+ const failed = failedResult(await onSend(sent.trim()), failedText ?? "ดำเนินการไม่สำเร็จ");
166
+ if (failed) toast.error(failed);
167
+ else clearSent();
163
168
  } catch (err) {
164
- // ส่งไม่สำเร็จ: คงข้อความไว้ในช่องให้ลองใหม่ + แจ้งผู้ใช้ (เดิมเงียบ — unhandled rejection)
165
- toast.error(
166
- err instanceof Error && err.message ? err.message : (failedText ?? "ดำเนินการไม่สำเร็จ"),
167
- );
169
+ if (isNavigationError(err)) {
170
+ clearSent();
171
+ return;
172
+ }
173
+ // ส่งไม่สำเร็จ: คงข้อความไว้ในช่องให้ลองใหม่ + แจ้งผู้ใช้
174
+ toast.error(errorMessage(err, failedText ?? "ดำเนินการไม่สำเร็จ"));
168
175
  } finally {
169
176
  setPending(false);
170
177
  }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { safeNavigableUrl } from "@suphark/ui/lib/safe-url";
3
4
  import { useEffect } from "react";
4
5
 
5
6
  /**
@@ -9,17 +10,12 @@ import { useEffect } from "react";
9
10
  */
10
11
  export function ClientRedirect({ to }: { to: string }) {
11
12
  useEffect(() => {
12
- let url: URL;
13
- try {
14
- url = new URL(to, window.location.href);
15
- } catch {
13
+ const url = safeNavigableUrl(to);
14
+ if (!url) {
15
+ console.error("ClientRedirect: ปฏิเสธปลายทางที่ไม่ใช่ http(s)");
16
16
  return;
17
17
  }
18
- if (url.protocol !== "http:" && url.protocol !== "https:") {
19
- console.error(`ClientRedirect: ปฏิเสธปลายทางที่ไม่ใช่ http(s): ${url.protocol}`);
20
- return;
21
- }
22
- window.location.replace(url.href);
18
+ window.location.replace(url);
23
19
  }, [to]);
24
20
 
25
21
  return null;
@@ -12,11 +12,13 @@ import {
12
12
  } from "@suphark/ui/components/ui/alert-dialog";
13
13
  import { Checkbox } from "@suphark/ui/components/ui/checkbox";
14
14
  import { Input } from "@suphark/ui/components/ui/input";
15
+ import { Label } from "@suphark/ui/components/ui/label";
15
16
  import {
16
17
  createContext,
17
18
  type ReactNode,
18
19
  useCallback,
19
20
  useContext,
21
+ useId,
20
22
  useMemo,
21
23
  useRef,
22
24
  useState,
@@ -104,6 +106,7 @@ export function DialogsProvider({ children }: { children: ReactNode }) {
104
106
  setRequest(next);
105
107
  }, []);
106
108
 
109
+ const typeToConfirmId = useId();
107
110
  const settle = useCallback((value: Result) => {
108
111
  resolveRef.current?.(value);
109
112
  resolveRef.current = null;
@@ -120,6 +123,7 @@ export function DialogsProvider({ children }: { children: ReactNode }) {
120
123
  }),
121
124
  confirm: (spec) =>
122
125
  new Promise((resolve) => {
126
+ setPromptValue(""); // ช่องพิมพ์ยืนยัน (requireText) เริ่มว่างทุกครั้ง
123
127
  open({ ...normalizeConfirm(spec), mode: "confirm" }, (value) => resolve(value === true));
124
128
  }),
125
129
  prompt: (spec) =>
@@ -196,6 +200,28 @@ export function DialogsProvider({ children }: { children: ReactNode }) {
196
200
  />
197
201
  )}
198
202
 
203
+ {request.mode === "confirm" && request.requireText && (
204
+ // พิมพ์ข้อความยืนยันก่อนกด (เหมือน ConfirmDialog — เดิม useDialogs().confirm ข้าม requireText ไปเงียบ ๆ)
205
+ <div className="flex flex-col gap-1.5">
206
+ <Label htmlFor={typeToConfirmId} className="font-normal text-sm">
207
+ {request.requireTextLabel ?? (
208
+ <>
209
+ {text.confirmTypeToConfirm ?? "พิมพ์"}{" "}
210
+ <span className="font-medium">{request.requireText}</span>{" "}
211
+ {text.confirmTypeToConfirmSuffix ?? "เพื่อยืนยัน"}
212
+ </>
213
+ )}
214
+ </Label>
215
+ <Input
216
+ id={typeToConfirmId}
217
+ value={promptValue}
218
+ onChange={(event) => setPromptValue(event.target.value)}
219
+ autoComplete="off"
220
+ spellCheck={false}
221
+ />
222
+ </div>
223
+ )}
224
+
199
225
  {request.mode === "checklist" && (
200
226
  <div className="flex flex-col gap-2">
201
227
  {request.options?.map((option) => {
@@ -247,6 +273,11 @@ export function DialogsProvider({ children }: { children: ReactNode }) {
247
273
  ) : (
248
274
  <AlertDialogAction
249
275
  variant={request.destructive ? "destructive" : "default"}
276
+ disabled={
277
+ request.mode === "confirm" &&
278
+ Boolean(request.requireText) &&
279
+ promptValue.trim() !== request.requireText
280
+ }
250
281
  onClick={() => settle(confirmResult())}
251
282
  >
252
283
  {request.confirmLabel ?? confirmLabelByMode[request.mode]}
@@ -3,7 +3,7 @@
3
3
  import { NumberField } from "@base-ui/react/number-field";
4
4
  import { cn } from "@suphark/ui/lib/utils";
5
5
  import { MinusIcon, PlusIcon } from "lucide-react";
6
- import type { ReactNode, Ref } from "react";
6
+ import { type ReactNode, type Ref, useCallback, useEffect, useRef } from "react";
7
7
  import { useUiText } from "../ui-text";
8
8
 
9
9
  /**
@@ -14,9 +14,11 @@ import { useUiText } from "../ui-text";
14
14
  function relaxHiddenStep(el: HTMLInputElement | null) {
15
15
  if (el) el.step = "any";
16
16
  }
17
+ // หมายเหตุ: เมื่อ prop step เปลี่ยน React เขียน attribute step ใหม่ → effect ใน NumberInput ตั้ง "any" ซ้ำ
17
18
 
18
19
  const SIZE = {
19
- sm: { group: "h-7", button: "w-7 [&_svg]:size-3.5", text: "text-sm" },
20
+ // มือถือ ≥16px (iOS ไม่ซูมตอนโฟกัส) — จอกว้างค่อยเล็กลง
21
+ sm: { group: "h-7", button: "w-7 [&_svg]:size-3.5", text: "text-base md:text-sm" },
20
22
  default: { group: "h-8", button: "w-8 [&_svg]:size-4", text: "text-body" },
21
23
  } as const;
22
24
 
@@ -91,6 +93,16 @@ export function NumberInput({
91
93
  autoFocus?: boolean;
92
94
  }) {
93
95
  const text = useUiText().number;
96
+ const hiddenEl = useRef<HTMLInputElement | null>(null);
97
+ const hiddenRef = useCallback((el: HTMLInputElement | null) => {
98
+ hiddenEl.current = el;
99
+ relaxHiddenStep(el);
100
+ }, []);
101
+ // step เปลี่ยนหลัง mount → React เขียน attribute step ใหม่ → ตั้ง "any" ซ้ำ
102
+ // biome-ignore lint/correctness/useExhaustiveDependencies: step เป็นตัวกระตุ้น — React เขียน attribute step ใหม่ทุกครั้งที่ prop เปลี่ยน
103
+ useEffect(() => {
104
+ relaxHiddenStep(hiddenEl.current);
105
+ }, [step]);
94
106
  const s = SIZE[size];
95
107
  const buttonClass = cn(
96
108
  "flex shrink-0 items-center justify-center text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:bg-muted disabled:pointer-events-none disabled:opacity-50",
@@ -113,25 +125,16 @@ export function NumberInput({
113
125
  required={required}
114
126
  disabled={disabled}
115
127
  readOnly={readOnly}
116
- inputRef={relaxHiddenStep}
128
+ inputRef={hiddenRef}
117
129
  className={cn("w-full", className)}
118
130
  >
119
131
  <NumberField.Group
120
132
  data-slot="number-input"
121
133
  className={cn(
122
- "flex w-full min-w-0 items-stretch overflow-hidden rounded-lg border border-input bg-transparent transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-disabled:opacity-50 dark:bg-input/30",
134
+ "flex w-full min-w-0 items-stretch overflow-hidden rounded-lg border border-input bg-transparent transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-[input:disabled]:opacity-50 dark:bg-input/30",
123
135
  s.group,
124
136
  )}
125
137
  >
126
- {!hideButtons && (
127
- <NumberField.Decrement
128
- aria-label={text?.decrement ?? "ลด"}
129
- className={cn(buttonClass, "border-r border-input")}
130
- >
131
- <MinusIcon />
132
- </NumberField.Decrement>
133
- )}
134
- {prefix && <span className={cn(affix, !hideButtons && "pr-0")}>{prefix}</span>}
135
138
  <NumberField.Input
136
139
  ref={inputRef}
137
140
  autoFocus={autoFocus}
@@ -146,6 +149,18 @@ export function NumberInput({
146
149
  inputClassName,
147
150
  )}
148
151
  />
152
+ {/* ปุ่มลด/prefix อยู่หลัง input ใน DOM (label ที่ครอบช่องนี้คลิกแล้วเลือก input ไม่ใช่ปุ่มลด) แต่แสดงด้านซ้ายด้วย order */}
153
+ {!hideButtons && (
154
+ <NumberField.Decrement
155
+ aria-label={text?.decrement ?? "ลด"}
156
+ className={cn(buttonClass, "order-first border-r border-input")}
157
+ >
158
+ <MinusIcon />
159
+ </NumberField.Decrement>
160
+ )}
161
+ {prefix && (
162
+ <span className={cn(affix, "order-first", !hideButtons && "pr-0")}>{prefix}</span>
163
+ )}
149
164
  {suffix && <span className={cn(affix, !hideButtons && "pl-0")}>{suffix}</span>}
150
165
  {!hideButtons && (
151
166
  <NumberField.Increment
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { cn } from "@suphark/ui/lib/utils";
4
4
  import { XIcon } from "lucide-react";
5
- import { type ClipboardEvent, type KeyboardEvent, useId, useRef, useState } from "react";
5
+ import { type ClipboardEvent, type KeyboardEvent, useEffect, useId, useRef, useState } from "react";
6
6
  import { useUiText } from "../ui-text";
7
7
 
8
8
  /**
@@ -59,6 +59,10 @@ export function TagsInput({
59
59
  const [error, setError] = useState<string | null>(null);
60
60
  const inputRef = useRef<HTMLInputElement>(null);
61
61
  const errorId = useId();
62
+ // ค่าที่ถูกปฏิเสธยังค้างในช่อง = ช่อง invalid จริง → ฟอร์มไม่ส่งรายการที่ขาดค่านี้ไปเงียบ ๆ
63
+ useEffect(() => {
64
+ inputRef.current?.setCustomValidity(error && draft.trim() ? error : "");
65
+ }, [error, draft]);
62
66
  const full = max != null && tags.length >= max;
63
67
 
64
68
  const commit = (next: string[]) => {
@@ -144,6 +148,27 @@ export function TagsInput({
144
148
  disabled && "pointer-events-none opacity-50",
145
149
  )}
146
150
  >
151
+ {/* ช่องพิมพ์อยู่ก่อนป้ายใน DOM (label ที่ครอบอยู่คลิกแล้วเลือกช่องพิมพ์ ไม่ใช่ปุ่มลบป้ายแรก) แต่แสดงท้ายด้วย order-last */}
152
+ <input
153
+ ref={inputRef}
154
+ id={id}
155
+ value={draft}
156
+ disabled={disabled || full}
157
+ placeholder={full ? undefined : (placeholder ?? text?.placeholder ?? "พิมพ์แล้วกด Enter")}
158
+ aria-label={ariaLabel}
159
+ aria-describedby={describedBy}
160
+ aria-invalid={error || ariaInvalid ? true : undefined}
161
+ onChange={(e) => {
162
+ setDraft(e.currentTarget.value);
163
+ if (error) setError(null);
164
+ }}
165
+ onKeyDown={onKeyDown}
166
+ onPaste={onPaste}
167
+ onBlur={() => {
168
+ if (draft.trim()) setDraft(add([draft]));
169
+ }}
170
+ className="order-last h-6 min-w-24 flex-1 bg-transparent px-1 text-body outline-none placeholder:text-muted-foreground"
171
+ />
147
172
  {tags.map((tag, i) => (
148
173
  <span
149
174
  // biome-ignore lint/suspicious/noArrayIndexKey: allowDuplicates ทำให้ค่าซ้ำได้ ใช้ตำแหน่งร่วม
@@ -166,26 +191,6 @@ export function TagsInput({
166
191
  {name && <input type="hidden" name={name} value={tag} />}
167
192
  </span>
168
193
  ))}
169
- <input
170
- ref={inputRef}
171
- id={id}
172
- value={draft}
173
- disabled={disabled || full}
174
- placeholder={full ? undefined : (placeholder ?? text?.placeholder ?? "พิมพ์แล้วกด Enter")}
175
- aria-label={ariaLabel}
176
- aria-describedby={describedBy}
177
- aria-invalid={error || ariaInvalid ? true : undefined}
178
- onChange={(e) => {
179
- setDraft(e.currentTarget.value);
180
- if (error) setError(null);
181
- }}
182
- onKeyDown={onKeyDown}
183
- onPaste={onPaste}
184
- onBlur={() => {
185
- if (draft.trim()) setDraft(add([draft]));
186
- }}
187
- className="h-6 min-w-24 flex-1 bg-transparent px-1 text-body outline-none placeholder:text-muted-foreground"
188
- />
189
194
  </div>
190
195
  {error && (
191
196
  <p id={errorId} role="alert" className="text-destructive text-xs">
@@ -308,7 +308,7 @@ export function TimePicker({
308
308
  size="xs"
309
309
  variant="ghost"
310
310
  onClick={() => {
311
- setTime(nowTime());
311
+ setTime(roundTime(nowTime(), roundToStep ? minuteStep : 1));
312
312
  setDraft(null);
313
313
  }}
314
314
  >
@@ -31,6 +31,9 @@ export interface MonthStepperProps {
31
31
  className?: string;
32
32
  }
33
33
 
34
+ /** "เดือนนี้" ของ MonthStepper/useMonthCursor คิดตามเวลาไทย — แอปของเราใช้ในไทย และต้องได้ค่าเดียวกันทั้ง server และ browser */
35
+ const APP_TIME_ZONE = "Asia/Bangkok";
36
+
34
37
  /**
35
38
  * เลือกเดือนแบบ ‹ เดือน › — กดชื่อเดือนเพื่อกลับเดือนนี้ (ยกจาก DueBell `MonthSelector`)
36
39
  * ใช้คู่กับ `CalendarMonthGrid month` ได้เลย (รูปแบบ `"YYYY-MM"` เดียวกัน)
@@ -38,9 +41,6 @@ export interface MonthStepperProps {
38
41
  * const { month, setMonth } = useMonthCursor();
39
42
  * <MonthStepper value={month} onValueChange={setMonth} />
40
43
  */
41
- /** "เดือนนี้" ของ MonthStepper/useMonthCursor คิดตามเวลาไทย — แอปของเราใช้ในไทย และต้องได้ค่าเดียวกันทั้ง server และ browser */
42
- const APP_TIME_ZONE = "Asia/Bangkok";
43
-
44
44
  export function MonthStepper({
45
45
  value,
46
46
  onValueChange,
@@ -8,12 +8,12 @@ import {
8
8
  DropdownMenuSeparator,
9
9
  DropdownMenuTrigger,
10
10
  } from "@suphark/ui/components/ui/dropdown-menu";
11
+ import { errorMessage, failedResult, isNavigationError } from "@suphark/ui/lib/action-result";
11
12
  import { MoreHorizontalIcon } from "lucide-react";
12
13
  import Link from "next/link";
13
14
  import { useRouter } from "next/navigation";
14
15
  import { useState, useTransition } from "react";
15
16
  import { toast } from "sonner";
16
- import { errorMessage, failedResult } from "./button/confirm-action-button";
17
17
  import { ConfirmDialog, type ConfirmSpec, normalizeConfirm } from "./confirm-dialog";
18
18
  import { useUiText } from "./ui-text";
19
19
 
@@ -44,12 +44,15 @@ export function RowActions({ items, label }: { items: RowAction[]; label?: strin
44
44
  const [open, setOpen] = useState(false);
45
45
  const [confirming, setConfirming] = useState<RowActionItem | null>(null);
46
46
 
47
- function run(it: RowActionItem) {
47
+ /** `done` = เรียกเมื่อสำเร็จ (ใช้ปิด ConfirmDialog — ล้มเหลวคง dialog ไว้ให้ลองใหม่) */
48
+ function run(it: RowActionItem, done?: () => void) {
48
49
  if (it.href) {
50
+ done?.();
49
51
  router.push(it.href);
50
52
  return;
51
53
  }
52
54
  if (it.onSelect) {
55
+ done?.();
53
56
  it.onSelect();
54
57
  return;
55
58
  }
@@ -67,9 +70,15 @@ export function RowActions({ items, label }: { items: RowAction[]; label?: strin
67
70
  return;
68
71
  }
69
72
  } catch (err) {
73
+ // action redirect() = สำเร็จ (Next นำทางให้แล้ว) — ไม่แสดง "NEXT_REDIRECT"
74
+ if (isNavigationError(err)) {
75
+ done?.();
76
+ return;
77
+ }
70
78
  toast.error(errorMessage(err, fallback));
71
79
  return;
72
80
  }
81
+ done?.();
73
82
  router.refresh();
74
83
  });
75
84
  }
@@ -126,11 +135,8 @@ export function RowActions({ items, label }: { items: RowAction[]; label?: strin
126
135
  requireText={spec.requireText}
127
136
  requireTextLabel={spec.requireTextLabel}
128
137
  destructive={spec.destructive ?? confirming.destructive}
129
- onConfirm={() => {
130
- const it = confirming;
131
- setConfirming(null);
132
- run(it);
133
- }}
138
+ pending={pending}
139
+ onConfirm={() => run(confirming, () => setConfirming(null))}
134
140
  />
135
141
  )}
136
142
  </>
@@ -160,6 +160,12 @@ function keyOf(item: object): string {
160
160
  return k;
161
161
  }
162
162
 
163
+ /** object ใหม่ที่มาแทนของเดิม (แก้ค่าในเงื่อนไข) ใช้ key เดิม — ไม่งั้นช่องที่กำลังพิมพ์ re-mount แล้วหลุดโฟกัสทุกตัวอักษร */
164
+ function keepKey<T extends object>(prev: object, next: T): T {
165
+ if (prev !== next && !keys.has(next)) keys.set(next, keyOf(prev));
166
+ return next;
167
+ }
168
+
163
169
  export interface RuleBuilderProps<F extends string = string> {
164
170
  value: RuleCondition<F>;
165
171
  /** ส่งต้นไม้ใหม่ทั้งก้อน (immutable) */
@@ -317,7 +323,10 @@ function ConditionNode<F extends string>({
317
323
  <ConditionNode
318
324
  value={item}
319
325
  onChange={(next) =>
320
- onChange({ ...value, items: value.items.map((x, j) => (j === i ? next : x)) })
326
+ onChange({
327
+ ...value,
328
+ items: value.items.map((x, j) => (j === i ? keepKey(x, next) : x)),
329
+ })
321
330
  }
322
331
  ctx={ctx}
323
332
  depth={depth + 1}
@@ -82,9 +82,14 @@ export function SettingsLayout({
82
82
  /** มือถือ: กลับไปรายการ — ปุ่ม "กลับ" ถูกซ่อน จึงคืนโฟกัสให้หัวข้อที่เพิ่งเปิด (ไม่งั้นโฟกัสหลุดไป body) */
83
83
  const back = (id: string) => {
84
84
  setMobileDetail(false);
85
- requestAnimationFrame(() =>
86
- navRef.current?.querySelector<HTMLElement>(`[data-section-id="${CSS.escape(id)}"]`)?.focus(),
87
- );
85
+ requestAnimationFrame(() => {
86
+ const nav = navRef.current;
87
+ // หัวข้อที่เปิดอยู่ถูกกรองออกจากรายการด้วยคำค้น → โฟกัสช่องค้นหา/หัวข้อแรกแทน (ไม่ปล่อยหลุดไป body)
88
+ const target =
89
+ nav?.querySelector<HTMLElement>(`[data-section-id="${CSS.escape(id)}"]`) ??
90
+ nav?.querySelector<HTMLElement>("input, [data-section-id]");
91
+ target?.focus();
92
+ });
88
93
  };
89
94
 
90
95
  if (!current) return null;
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Button } from "@suphark/ui/components/ui/button";
4
4
  import { Spinner } from "@suphark/ui/components/ui/spinner";
5
+ import { errorMessage, failedResult, isNavigationError } from "@suphark/ui/lib/action-result";
5
6
  import { cn } from "@suphark/ui/lib/utils";
6
7
  import { CheckIcon } from "lucide-react";
7
8
  import { type FormEvent, type ReactNode, useEffect, useRef, useState } from "react";
@@ -19,6 +20,21 @@ export interface StepFormStep {
19
20
 
20
21
  const CONTROLS = "input, select, textarea";
21
22
 
23
+ const FOCUSABLE = 'input, select, textarea, button, [tabindex]:not([tabindex="-1"])';
24
+
25
+ /** สิ่งที่โฟกัสได้ (มองเห็น ไม่ใช่ตัวแทนที่ซ่อน) ตัวก่อนหน้าที่ใกล้ `el` ที่สุดในกล่อง */
26
+ function precedingFocusable(section: HTMLElement, el: Element): HTMLElement | undefined {
27
+ const all = [...section.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
28
+ (c) =>
29
+ c !== el &&
30
+ c.getAttribute("aria-hidden") !== "true" &&
31
+ (c as HTMLInputElement).type !== "hidden" &&
32
+ !(c as HTMLButtonElement).disabled &&
33
+ c.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING,
34
+ );
35
+ return all[all.length - 1];
36
+ }
37
+
22
38
  /**
23
39
  * ตรวจ constraint ของ input ในกล่อง (required, pattern, min…) — แสดง bubble ของเบราว์เซอร์ที่ช่องแรกที่ผิด
24
40
  * input ที่ซ่อนไว้ (ตัวแทนของ Base UI เช่น NumberField, aria-hidden) แสดง bubble ไม่ได้ → คืนข้อความให้แสดงใต้ฟอร์มแทน
@@ -27,16 +43,13 @@ const CONTROLS = "input, select, textarea";
27
43
  function reportSection(section: HTMLElement | null): true | false | string {
28
44
  if (!section) return true;
29
45
  const controls = [...section.querySelectorAll<HTMLInputElement>(CONTROLS)];
30
- for (const [i, el] of controls.entries()) {
46
+ for (const el of controls) {
31
47
  if (el.type === "hidden" || el.disabled) continue;
32
48
  if (!el.checkValidity()) {
33
49
  if (el.getAttribute("aria-hidden") === "true") {
34
- // input ซ่อนของ Base UI อยู่ถัดจากช่องที่มองเห็นของมัน → โฟกัสช่องที่มองเห็นตัวก่อนหน้าที่ใกล้ที่สุด
35
- controls
36
- .slice(0, i)
37
- .reverse()
38
- .find((c) => c.getAttribute("aria-hidden") !== "true" && c.type !== "hidden")
39
- ?.focus();
50
+ // input ซ่อนของ Base UI (NumberField/Select/Checkbox/Switch/Radio) อยู่ถัดจากตัวควบคุมที่มองเห็นของมัน
51
+ // → โฟกัสสิ่งที่โฟกัสได้ตัวก่อนหน้าที่ใกล้ที่สุด (ปุ่ม combobox/checkbox ก็นับ ไม่ใช่แค่ input)
52
+ precedingFocusable(section, el)?.focus();
40
53
  return el.validationMessage || "ค่าไม่ถูกต้อง";
41
54
  }
42
55
  el.reportValidity();
@@ -92,6 +105,8 @@ export function StepForm({
92
105
  const busy = useRef(false);
93
106
  /** เวลาที่เปลี่ยนขั้นล่าสุด — คลิกที่สองของดับเบิลคลิก "ถัดไป" ตกที่ปุ่ม "ส่ง" ตำแหน่งเดิม */
94
107
  const movedAt = useRef(0);
108
+ /** เวลาที่ส่งเสร็จล่าสุด — กันคลิกซ้ำเมื่อ onSubmit จบเร็ว */
109
+ const submittedAt = useRef(0);
95
110
  const last = steps.length - 1;
96
111
  const step = steps[current];
97
112
 
@@ -132,8 +147,10 @@ export function StepForm({
132
147
  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
133
148
  e.preventDefault();
134
149
  if (busy.current) return;
135
- // ปุ่ม "ถัดไป" กลายเป็น "ส่ง" ที่ตำแหน่งเดิม — คลิกที่มาเร็วเกินหลังเปลี่ยนขั้นไม่นับเป็นการส่ง (ผู้ใช้ยังไม่ได้เห็นหน้าตรวจทาน)
136
- if (current === last && Date.now() - movedAt.current < 500) return;
150
+ // คลิกที่สองของดับเบิลคลิก: หลังเปลี่ยนขั้น (ปุ่มเดิมกลายเป็น "ถัดไป"/"ส่ง" ของขั้นใหม่ — ข้ามขั้นหรือข้ามหน้าตรวจทาน)
151
+ // และหลังส่งเสร็จ (onSubmit ที่ sync/เร็ว ปล่อย busy ก่อนคลิกที่สองมาถึง) ไม่นับ
152
+ const now = Date.now();
153
+ if (now - movedAt.current < 500 || now - submittedAt.current < 500) return;
137
154
  busy.current = true;
138
155
  try {
139
156
  await submitStep();
@@ -174,13 +191,16 @@ export function StepForm({
174
191
  }
175
192
  if (!formRef.current) return;
176
193
  setPending(true);
194
+ const fallback = ui.actionFailed ?? "ดำเนินการไม่สำเร็จ";
177
195
  try {
178
- await onSubmit(new FormData(formRef.current));
196
+ // server action ที่คืน { ok: false, error } (FormState) = ล้มเหลว เหมือน throw
197
+ const failed = failedResult(await onSubmit(new FormData(formRef.current)), fallback);
198
+ if (failed) setError(failed);
179
199
  } catch (err) {
180
- setError(
181
- err instanceof Error && err.message ? err.message : (ui.actionFailed ?? "ดำเนินการไม่สำเร็จ"),
182
- );
200
+ // redirect() ใน server action = สำเร็จ (Next นำทางให้แล้ว)
201
+ if (!isNavigationError(err)) setError(errorMessage(err, fallback));
183
202
  } finally {
203
+ submittedAt.current = Date.now();
184
204
  setPending(false);
185
205
  }
186
206
  };
@@ -45,7 +45,8 @@ export function useTableSearchParams(defaults?: { pageSize?: number }) {
45
45
  const [isPending, startTransition] = useTransition();
46
46
 
47
47
  const state = useMemo<TableSearchState>(() => {
48
- const filters: Record<string, string[]> = {};
48
+ // ไม่มี prototype: key จาก URL อย่าง "__proto__"/"constructor" เป็นแค่ตัวกรองชื่อหนึ่ง ไม่ไปทับ prototype
49
+ const filters: Record<string, string[]> = Object.create(null);
49
50
  for (const key of new Set(searchParams.keys())) {
50
51
  if (RESERVED.has(key)) continue;
51
52
  const values = searchParams
@@ -73,16 +74,20 @@ export function useTableSearchParams(defaults?: { pageSize?: number }) {
73
74
 
74
75
  // setter หลายตัวในจังหวะเดียว (เช่น setFilter แล้ว setSort) ต้องต่อจากค่าที่เพิ่งตั้ง ไม่ใช่ searchParams ตอน render
75
76
  // (router.replace ยังไม่ทันเปลี่ยน URL → ตัวหลังเขียนทับตัวแรก) — ล้างเมื่อ URL เปลี่ยนจริงแล้ว
76
- const latest = useRef<string | null>(null);
77
+ // จำ URL ที่เราเพิ่งสั่ง (value) คู่กับ URL ตอนสั่ง (base) — ใช้ต่อเฉพาะเมื่อ URL ยังเป็น base เดิม
78
+ // (เดิมล้างตามตัวอ้างอิงของ searchParams ซึ่ง Next คืนตัวเดิมเมื่อ URL ไม่เปลี่ยน → ค่าเก่าค้าง)
79
+ const latest = useRef<{ base: string; value: string } | null>(null);
77
80
  useEffect(() => {
78
81
  latest.current = null;
79
82
  }, [searchParams]);
80
83
 
81
84
  const push = useCallback(
82
85
  (mutate: (params: URLSearchParams) => void) => {
83
- const params = new URLSearchParams(latest.current ?? searchParams.toString());
86
+ const base = searchParams.toString();
87
+ const pending = latest.current?.base === base ? latest.current.value : base;
88
+ const params = new URLSearchParams(pending);
84
89
  mutate(params);
85
- latest.current = params.toString();
90
+ latest.current = { base, value: params.toString() };
86
91
  startTransition(() => {
87
92
  router.replace(`${pathname}?${params.toString()}`, { scroll: false });
88
93
  });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * ตีความผลของ action (server action / async handler) ให้เหมือนกันทุก component — ไม่พึ่ง next
3
+ */
4
+
5
+ /** ข้อความจาก error ที่ throw (ไม่มี = fallback) */
6
+ export function errorMessage(err: unknown, fallback: string): string {
7
+ return err instanceof Error && err.message ? err.message : fallback;
8
+ }
9
+
10
+ /** action คืน `FormState` แบบ `{ ok: false, error }` (ไม่ได้ throw) = ล้มเหลว → ข้อความ error; อย่างอื่น = null */
11
+ export function failedResult(result: unknown, fallback: string): string | null {
12
+ if (result && typeof result === "object" && "ok" in result && result.ok === false) {
13
+ const error = "error" in result ? result.error : undefined;
14
+ return typeof error === "string" && error ? error : fallback;
15
+ }
16
+ return null;
17
+ }
18
+
19
+ /**
20
+ * error ที่ Next ใช้สั่งนำทาง (`redirect()`, `notFound()`, `forbidden()`/`unauthorized()`) — ไม่ใช่ความล้มเหลว
21
+ * server action ที่ redirect ทำให้ promise ฝั่ง client reject ด้วย error นี้ขณะที่ router นำทางให้แล้ว
22
+ * → ห้ามแสดงเป็น toast "NEXT_REDIRECT" (ดูจาก `digest` จึงไม่ต้อง import next)
23
+ */
24
+ export function isNavigationError(err: unknown): boolean {
25
+ if (!err || typeof err !== "object" || !("digest" in err)) return false;
26
+ const digest = (err as { digest: unknown }).digest;
27
+ return (
28
+ typeof digest === "string" &&
29
+ (digest.startsWith("NEXT_REDIRECT") ||
30
+ digest === "NEXT_NOT_FOUND" ||
31
+ digest.startsWith("NEXT_HTTP_ERROR_FALLBACK"))
32
+ );
33
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * URL ที่นำทางด้วย script ได้อย่างปลอดภัย (`location.assign/replace`, `window.open`)
3
+ * — ช่องทางเหล่านี้ไม่มีตัวกัน `javascript:` แบบ `<a href>` ของ React หรือ router ของ Next
4
+ * รับเฉพาะ http(s) และ path ภายใน (relative); คืน URL ที่ resolve แล้ว หรือ null ถ้าไม่ปลอดภัย
5
+ * ไม่ได้กัน open redirect ไปโดเมนอื่น — ถ้าค่ามาจาก URL/ผู้ใช้ แอปต้องเช็กปลายทางเอง
6
+ */
7
+ export function safeNavigableUrl(href: string | null | undefined): string | null {
8
+ if (!href || typeof window === "undefined") return null;
9
+ try {
10
+ const url = new URL(href, window.location.href);
11
+ return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
package/src/styles.css CHANGED
@@ -297,25 +297,28 @@
297
297
  scrollbar-color: var(--scrollbar-thumb) transparent;
298
298
  }
299
299
 
300
- /* Global scrollbar styling */
301
- ::-webkit-scrollbar {
302
- width: 6px;
303
- height: 6px;
304
- }
305
- ::-webkit-scrollbar-track {
306
- background: transparent;
307
- }
308
- ::-webkit-scrollbar-thumb {
309
- background-color: var(--scrollbar-thumb);
310
- border-radius: 9999px;
311
- }
312
- ::-webkit-scrollbar-thumb:hover {
313
- background-color: var(--scrollbar-thumb-hover);
314
- }
300
+ /* Global scrollbar styling — อยู่ใน @layer base ให้ utility (no-scrollbar, scrollbar-thin, className ของแอป) ชนะได้
301
+ (เดิมไม่มี layer → `*{scrollbar-width:thin}` ทับ no-scrollbar ทุกที่ เช่นคอลัมน์ TimePicker แถบแท็บ) */
302
+ @layer base {
303
+ ::-webkit-scrollbar {
304
+ width: 6px;
305
+ height: 6px;
306
+ }
307
+ ::-webkit-scrollbar-track {
308
+ background: transparent;
309
+ }
310
+ ::-webkit-scrollbar-thumb {
311
+ background-color: var(--scrollbar-thumb);
312
+ border-radius: 9999px;
313
+ }
314
+ ::-webkit-scrollbar-thumb:hover {
315
+ background-color: var(--scrollbar-thumb-hover);
316
+ }
315
317
 
316
- * {
317
- scrollbar-width: thin;
318
- scrollbar-color: var(--scrollbar-thumb) transparent;
318
+ * {
319
+ scrollbar-width: thin;
320
+ scrollbar-color: var(--scrollbar-thumb) transparent;
321
+ }
319
322
  }
320
323
 
321
324
  html,