@suphark/ui 0.26.0 → 0.27.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +31 -3
  3. package/package.json +5 -3
  4. package/src/components/app-shell/command-palette.tsx +14 -6
  5. package/src/components/data-table/data-table-pagination.tsx +7 -5
  6. package/src/components/data-table/data-table-sortable.tsx +5 -4
  7. package/src/components/data-table/data-table-toolbar.tsx +15 -4
  8. package/src/components/data-table/use-data-table.ts +38 -5
  9. package/src/components/design-system/design-system-showcase.tsx +1 -0
  10. package/src/components/pwa/pull-to-refresh.tsx +11 -4
  11. package/src/components/pwa/service-worker-updates.ts +26 -3
  12. package/src/components/shared/action-menu.tsx +26 -11
  13. package/src/components/shared/auth-parts.tsx +5 -3
  14. package/src/components/shared/button/button.tsx +110 -0
  15. package/src/components/shared/button/confirm-action-button.tsx +9 -13
  16. package/src/components/shared/button/index.ts +6 -0
  17. package/src/components/shared/chat-thread.tsx +13 -6
  18. package/src/components/shared/client-redirect.tsx +5 -9
  19. package/src/components/shared/dialogs-provider.tsx +31 -0
  20. package/src/components/shared/form/number-input.tsx +28 -13
  21. package/src/components/shared/form/tags-input.tsx +26 -21
  22. package/src/components/shared/form/time-picker.tsx +2 -2
  23. package/src/components/shared/month-stepper.tsx +4 -4
  24. package/src/components/shared/notice.tsx +1 -1
  25. package/src/components/shared/resource-property-filter.tsx +1 -1
  26. package/src/components/shared/row-actions.tsx +14 -8
  27. package/src/components/shared/rule-builder.tsx +11 -2
  28. package/src/components/shared/secret-reveal.tsx +1 -1
  29. package/src/components/shared/settings-layout.tsx +8 -3
  30. package/src/components/shared/simple-dialog.tsx +1 -1
  31. package/src/components/shared/simple-pagination.tsx +1 -1
  32. package/src/components/shared/simple-tooltip.tsx +11 -6
  33. package/src/components/shared/step-form.tsx +33 -13
  34. package/src/components/shared/theme/theme-switch.tsx +1 -1
  35. package/src/components/shared/theme/theme-toggle.tsx +5 -3
  36. package/src/components/shared/ui-text.tsx +25 -4
  37. package/src/components/shared/upload-list.tsx +1 -1
  38. package/src/hooks/use-table-search-params.ts +9 -4
  39. package/src/index.ts +7 -1
  40. package/src/lib/action-result.ts +33 -0
  41. package/src/lib/safe-url.ts +15 -0
  42. package/src/styles.css +21 -18
package/CHANGELOG.md CHANGED
@@ -2,6 +2,61 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.27.0] — 2026-10-05
6
+
7
+ ### Added
8
+
9
+ - **`Button`: tooltip อัตโนมัติสำหรับปุ่มไอคอน** — มาตรฐานใหม่ของทุกโครงการ (เจ้าของสั่ง 2026-10-05): ไอคอนที่ไม่มีข้อความกำกับต้องบอกได้ว่าคืออะไรเมื่อวางเมาส์ ถ้า `Button` มี `aria-label` และ children ไม่มีข้อความ (ไอคอน/`sr-only` ไม่นับ) จะห่อ `Tooltip` ให้เองจาก `aria-label` — ทำที่ `Button` ชั้นในสุดที่เดียว **call site ทุกที่ของทุกแอปได้ทันทีโดยไม่ต้องแก้โค้ด** รวมถึงปุ่มที่ส่งเข้า `render=`/`trigger` ของ `FormDialog`/`SimpleDialog`/`DropdownMenu`/`RowActions` (เคสที่ห่อ `SimpleTooltip` เองไม่ได้เพราะ trigger ส่ง ref/props ให้)
10
+ - props ใหม่: `tooltip` (ทับข้อความ, `false` = ปิด), `tooltipSide`, `tooltipDelay` — ไม่ต้องมี `TooltipProvider` ใน tree (ใส่ได้ถ้าอยากคุม delay ร่วมกัน)
11
+ - `SimpleTooltip` ปิด tooltip อัตโนมัติของ `Button` ที่ห่ออยู่ให้แล้ว (ไม่ขึ้นสองชั้น) — แอปที่ห่อ tooltip ด้วยกลไกอื่นใช้ `ButtonTooltipSuppress` (export ใหม่) หรือ `tooltip={false}`
12
+ - barrel export `Button` จาก `components/shared/button/button.tsx` (ครอบ `components/ui/button.tsx` ที่ยังเป็นไฟล์ shadcn ดิบ ไม่ถูกแก้มือ); `buttonVariants` เหมือนเดิม; props อื่นและหน้าตาไม่เปลี่ยน
13
+ - `DataTablePagination`: ปุ่มเลื่อนหน้าทั้ง 4 ใช้ `aria-label` จาก `UiText` (เดิมเป็นข้อความอังกฤษตายตัวใน `sr-only`); `ThemeToggle` ใช้ `aria-label` จาก `UiText.theme.label` แทน `title` (เลิก tooltip ซ้อนของเบราว์เซอร์)
14
+ - README หัวข้อ "ปุ่มไอคอน — tooltip มาให้เอง (มาตรฐานทุกโครงการ)" + แถวใหม่ใน `docs/agent-rules.md`
15
+ - ใช้ข้อความใน `<span className="sr-only">` (รูปแบบของ shadcn) เป็น tooltip ได้เมื่อไม่มี `aria-label`
16
+ - **ปุ่มไอคอนในแพ็กเกจเองได้ tooltip ครบ**: `RowActions`, `ActionMenu`, `SimpleDialog`/`SimpleSheet` (ปุ่มปิด), `SimplePagination`, `DataTablePagination`, `DataTableToolbar`, `CommandPalette`, `ThemeToggle`/`ThemeSwitch`, `Notice`, `UploadList`, `SecretReveal`, `TimePicker`, `MonthStepper`, `RuleBuilder`, `ResourcePropertyFilter`, `AuthParts` (17 ไฟล์เปลี่ยนมา import `Button` ตัวที่มี tooltip)
17
+ - `UiText.pagination` เพิ่ม `first` / `last` (หน้าแรก/หน้าสุดท้าย)
18
+ - showcase: หน้า `/components/button` เพิ่มแถวปุ่มไอคอน (auto / `tooltip="…"` / `tooltip={false}` / trigger ของ dialog / ห่อ `SimpleTooltip`)
19
+
20
+ ## [0.26.1] — 2026-10-03
21
+
22
+ แก้ตามผล audit 2026-10-03 (`docs/reports/audit-2026-10-03.md`) — ไม่เปลี่ยน props
23
+
24
+ ### Fixed
25
+
26
+ - **`RuleBuilder`**: แก้ค่าในเงื่อนไขแล้ว element ถูกสร้างใหม่ทุกครั้ง (key ผูกกับ object) → ช่องที่กำลังพิมพ์หลุดโฟกัสทุกตัวอักษร — object ใหม่ที่มาแทนใช้ key เดิม
27
+ - **`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 เหมือนเดิม)
28
+ - **`RowActions` + `confirm`**: เคยปิด dialog ก่อนรัน action — ตอนนี้ปุ่มยืนยันหมุนระหว่างรอ ล้มเหลวคง dialog ไว้ให้ลองใหม่ สำเร็จค่อยปิด (ตามที่ 0.25.1 ตั้งใจ)
29
+ - **`useDialogs().confirm`** รองรับ `requireText` (เดิมข้ามการพิมพ์ยืนยันไปเงียบ ๆ)
30
+ - **`DataTable` / `useDataTable`**: เลือกแถวตาม `row.id` เป็นค่าเริ่มต้น (ไม่มี id = index เหมือนเดิม) — เดิมใช้ลำดับแถว ข้อมูลเปลี่ยน/แทรกแถวแล้วแถวที่เลือกกลายเป็นแถวอื่น (`DeleteManyButton` ลบผิดรายการ); การลากเรียงเทียบ id แบบ string
31
+ - **`useDataTable` `columnFilters` (regression 0.25.1)**: เทียบด้วยค่าแต่ตั้ง state เป็นค่าจริงจาก prop — เดิม `JSON.parse` ทำให้ `Date` กลายเป็น string; ค่าที่ stringify ไม่ได้ (BigInt/วงอ้างอิง) ไม่ throw ตอน render
32
+ - **`DataTable` ช่องค้นหา**: Enter ไม่ส่งซ้ำอีกรอบหลัง 250 ms; ปุ่มล้างตัวกรองล้างข้อความที่พิมพ์ค้างด้วย
33
+ - **`NumberInput` / `TagsInput` ใน `<label>` ที่ครอบ** (`LabeledField` ไม่ใส่ `htmlFor`): คลิกข้อความ label เคยกดปุ่ม "ลด"/ลบป้ายแรก — ช่องพิมพ์อยู่ก่อนปุ่มใน DOM แล้ววางตำแหน่งด้วย CSS `order`
34
+ - `NumberInput`: ทั้งช่องเคยจางเหมือนปิดใช้งานเมื่อค่าเท่ากับ `min`/`max` (`has-disabled` จับปุ่ม −/+ ที่ปิด) — จางเฉพาะเมื่อช่องปิดจริง; `step` ที่เปลี่ยนหลัง mount ไม่ทำให้ทศนิยมถูกปฏิเสธอีก; `size="sm"` 16px บนมือถือ (iOS ไม่ซูม)
35
+ - `TagsInput`: ค่าที่ถูกปฏิเสธค้างในช่อง = ช่อง invalid (ฟอร์มไม่ส่งรายการที่ขาดค่านั้นไปเงียบ ๆ)
36
+ - `TimePicker`: ปุ่ม "ตอนนี้" ปัดตาม `roundToStep`
37
+ - **`StepForm`**: ดับเบิลคลิก "ถัดไป" ไม่ข้ามขั้นกลาง; `onSubmit` ที่จบเร็ว/sync ไม่ถูกส่งซ้ำ; ตัวควบคุม Base UI ที่ไม่ผ่าน (Select/Checkbox/Switch) โฟกัสตัวมันเอง ไม่ใช่ช่องอื่น
38
+ - **`CommandPalette`**: เช็ก Ctrl/⌘ ก่อนอ่าน `e.key` (keydown จาก autofill ไม่มี key ไม่ throw); ไม่เปิดด้วย AltGr (ctrl+alt); แป้นละตินเทียบ `e.key` (Dvorak ฯลฯ) แป้นไทยใช้ `e.code`; 2 ตัวในหน้าเดียวเปิดทีละตัว
39
+ - `ChatThread`: ข้อความที่พิมพ์ระหว่างรอส่งไม่ถูกล้างทิ้ง
40
+ - `SettingsLayout`: มือถือกด "กลับ" ขณะหัวข้อที่เปิดถูกกรองด้วยคำค้น โฟกัสไปช่องค้นหาแทนหลุดไป body
41
+ - **service worker (PWA)**: reload หลังมีเวอร์ชันใหม่เคยเกิดทุก `focusout` (ย้ายช่อง/กดบันทึก = reload กลางฟอร์ม) — รอนิ่ง 2 วินาทีหลังออกจากช่อง ยกเลิกเมื่อมีการคลิก/พิมพ์/โฟกัสใหม่
42
+ - `PullToRefresh`: ฟัง scroll แบบ capture ที่ document (root ที่ mount ทีหลัง/หน้าเลื่อนทั้ง document ใช้ได้)
43
+ - `ProviderButtons`: ลิงก์ที่ปิดอยู่ดูปิดจริงและถอด `href` (กันคลิกกลาง); `aria-busy` ทั้งสองแบบ
44
+ - `useTableSearchParams`: ค่าที่รอส่งไม่ค้างเมื่อ URL ไม่เปลี่ยน; key `__proto__` จาก URL ไม่ทับ prototype
45
+ - `MonthStepper`: JSDoc กลับมาแสดงใน editor; `DesignSystemShowcase` กระโดดข้ามแท็บรอบใหม่ได้เวลารอเต็ม
46
+ - CSS: scrollbar ทั้งหน้า (`*{scrollbar-width}`) ย้ายเข้า `@layer base` — เดิมทับ `no-scrollbar`/className ของแอปทุกที่ (เช่น คอลัมน์ TimePicker)
47
+
48
+ ### Security
49
+
50
+ - **`ActionMenu` href + confirm**: นำทางผ่าน `location.assign`/`window.open` ซึ่งไม่กัน `javascript:` แบบ `<a href>` — รับเฉพาะ http(s)/path ภายใน (`lib/safe-url.ts` `safeNavigableUrl` ใช้ร่วมกับ `ClientRedirect`); ลิงก์แท็บใหม่ที่ไม่มี onClick เปิดทันทีในคลิก (ไม่โดน popup blocker)
51
+ - `check:barrel` จับ `export *` ทุกรูปแบบ (alias `@suphark/ui/...`, แพ็กเกจ, quote เดี่ยว, comment ท้ายบรรทัด) และรันใน `verify` ของแพ็กเกจ + `prepublishOnly` (publish ไม่ผ่าน CI ก็ยังถูกตรวจ); README สอนขั้นตอนที่ถูก (`fix:barrel`)
52
+ - showcase: demo ไม่มีค่าที่หน้าตาเหมือนคีย์จริง (`pk_live_`) แล้ว; หน้า time-picker บอกรูปแบบค่า DateTimePicker ที่มี offset
53
+ - tests 12 กรณี (`fixes-0-26-1.test.tsx`, RuleBuilder, service worker)
54
+
55
+ - 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`)
56
+ - showcase (security): `@suphark/auth-client` ^0.6.1 → **^0.6.2** (แก้ dot segment ต้นทาง) — ลบตัวกรอง `?next=` ชั่วคราวใน `/auth/[...pi]` ออก; ทดสอบ `/auth/logout` 7 กรณี (`/.//`, `/..//`, `/%2e%2e//`, `/./../..//`, tab, `//`, `/brand`) ด้วย server จริงบนเครื่องแล้ว — ปลายทางไม่ออกนอกโดเมน
57
+ - 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
58
+ - 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)
59
+
5
60
  ## [0.26.0] — 2026-10-03
6
61
 
7
62
  ปิดงานค้างจากผลรีวิว + prop ที่ DueBell ขอ — เพิ่มอย่างเดียว
package/README.md CHANGED
@@ -154,6 +154,33 @@ export default async function Layout({ children }) {
154
154
  <div style={{ "--brand": tenant.color, "--brand-foreground": "oklch(0.985 0 0)" } as React.CSSProperties}>…</div>
155
155
  ```
156
156
 
157
+ ### ปุ่มไอคอน — tooltip มาให้เอง (มาตรฐานทุกโครงการ)
158
+
159
+ ไอคอนที่ไม่มีข้อความกำกับ ต้องบอกได้ว่าคืออะไรเมื่อวางเมาส์ — `Button` ของแพ็กเกจ **ห่อ `Tooltip` ให้เองเมื่อมี `aria-label` และ children ไม่มีข้อความ** (ไอคอนหรือ `<span className="sr-only">` ไม่นับเป็นข้อความ) ทุก call site จึงได้ทันทีโดยไม่ต้องแก้อะไร รวมถึงปุ่มที่เป็น trigger ของ dialog/menu เพราะปุ่มอยู่ชั้นในสุดของ `render=`
160
+
161
+ ```tsx
162
+ <Button variant="ghost" size="icon-sm" aria-label="ดู client secret"><KeySquareIcon /></Button>
163
+ // → hover/focus แล้วขึ้น "ดู client secret" โดยไม่ต้องห่อ SimpleTooltip
164
+
165
+ <FormDialog trigger={<Button size="icon-sm" aria-label="แก้ไข"><PencilIcon /></Button>} … />
166
+ // → ได้ tooltip ด้วย (ไม่ต้องมี prop ใหม่ที่ FormDialog/RowActions)
167
+ ```
168
+
169
+ | ต้องการ | เขียน |
170
+ | --- | --- |
171
+ | ข้อความอื่นที่ไม่ใช่ `aria-label` (หรือปุ่มที่มีข้อความแล้วแต่อยากอธิบายเพิ่ม) | `tooltip="ตรวจสอบอีกครั้ง"` |
172
+ | ปิด (เช่นปุ่มในเมนูที่มีข้อความอยู่แล้ว) | `tooltip={false}` |
173
+ | เปลี่ยนด้าน / หน่วงเวลา | `tooltipSide="bottom"`, `tooltipDelay={0}` |
174
+
175
+ - **ไม่ต้องมี `TooltipProvider`** — Base UI ไม่บังคับ; ใส่ที่ root layout ได้ถ้าอยากคุม delay ร่วมกันทุก tooltip (ค่าเริ่มต้นของ Base UI = 600ms, `TooltipProvider` ของแพ็กเกจตั้ง 0)
176
+ - ปุ่มที่ถูกห่อ `SimpleTooltip` อยู่แล้ว **ไม่ขึ้นสองชั้น** (`SimpleTooltip` ปิดตัวอัตโนมัติให้ด้วย `ButtonTooltipSuppress`) — ถ้าแอปห่อ tooltip ด้วยกลไกอื่นเอง ครอบ `<ButtonTooltipSuppress>` หรือใส่ `tooltip={false}`
177
+ - จอสัมผัสไม่มี hover — `aria-label` ยังทำหน้าที่เดิมให้ screen reader; ข้อความที่ "ต้องอ่านได้ทุกคน" อย่าเก็บไว้ใน tooltip เท่านั้น
178
+ - ไม่มี `aria-label` แต่ใช้รูปแบบ shadcn `<span className="sr-only">ข้อความ</span>` ข้างไอคอน ก็ใช้ข้อความนั้นเป็น tooltip ให้เหมือนกัน
179
+ - ปุ่มที่ `disabled` ไม่ขึ้น tooltip (ปุ่มที่ปิดอยู่ไม่รับ pointer ตามสไตล์ของ shadcn) — ถ้าต้องอธิบายว่าทำไมกดไม่ได้ ให้ห่อ `SimpleTooltip` ที่ `<span>` ครอบปุ่มแทน
180
+ - มี `title="…"` อยู่เดิมให้เอาออก ไม่งั้นจะได้ tooltip ซ้อนกับของเบราว์เซอร์
181
+ - `<button>` ดิบ (ไม่ผ่าน `Button`) ไม่ได้ของนี้ — อีกเหตุผลที่ห้ามเขียนปุ่มเอง
182
+ - ปุ่มไอคอนของแพ็กเกจเอง (`RowActions`, `ActionMenu`, `SimpleDialog` ปุ่มปิด, `DataTable` แบ่งหน้า, `ThemeToggle`, `Notice`, `UploadList`, `TimePicker`, `MonthStepper`, `SecretReveal`, `CommandPalette` …) ได้ tooltip ไปพร้อมกันตั้งแต่ 0.27.0
183
+
157
184
  ### ยืนยันก่อนทำ — ห้ามใช้ `window.confirm`
158
185
 
159
186
  webview/automation บางตัวปิด `window.confirm` ทิ้ง (กดแล้วไม่เกิดอะไร) และไม่เข้า design system — ใช้ 3 ตัวนี้แทน
@@ -218,7 +245,7 @@ npx suphark-ui-audit --strict # warning นับเป็น error (ใช้
218
245
  ```bash
219
246
  pnpm install
220
247
  pnpm dev # apps/showcase → http://localhost:3200/design-system
221
- pnpm verify # typecheck + biome + lint:base-ui + check:typography + vitest
248
+ pnpm verify # typecheck + biome + lint:base-ui + check:typography + check:changelog + check:barrel + audit + vitest
222
249
  pnpm --filter @suphark/ui test
223
250
  ```
224
251
 
@@ -228,7 +255,7 @@ pnpm --filter @suphark/ui test
228
255
  cd packages/ui && pnpm dlx shadcn@latest add <name>
229
256
  ```
230
257
 
231
- แล้วเพิ่ม `export * from "./components/ui/<name>"` ใน `src/index.ts`
258
+ แล้วเพิ่ม `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
259
 
233
260
  ## Versioning
234
261
 
@@ -237,8 +264,9 @@ semver — patch = หน้าตา/บั๊ก, minor = component/prop ใ
237
264
  ## Publish
238
265
 
239
266
  ```bash
267
+ pnpm verify # จาก root ก่อนทุกครั้ง (หลัง commit/push แล้ว)
240
268
  cd packages/ui
241
- pnpm publish --access public
269
+ npm publish --access public # Windows: npm.cmd — prepublishOnly รัน check:barrel + typecheck ให้อีกรอบ
242
270
  ```
243
271
 
244
272
  (ผู้ใช้ `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.27.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 เอง",
@@ -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",
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
3
4
  import { useUiText } from "@suphark/ui/components/shared/ui-text";
4
- import { Button } from "@suphark/ui/components/ui/button";
5
5
  import {
6
6
  Command,
7
7
  CommandDialog,
@@ -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);
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { Button } from "@suphark/ui/components/ui/button";
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
4
5
  import {
5
6
  Select,
6
7
  SelectContent,
@@ -23,6 +24,7 @@ export function DataTablePagination<TData>({
23
24
  pageSizeOptions = [10, 20, 30, 40, 50],
24
25
  onPageSizeChange,
25
26
  }: DataTablePaginationProps<TData>) {
27
+ const text = useUiText();
26
28
  const isRowSelectionEnabled = table.options.enableRowSelection !== false;
27
29
 
28
30
  return (
@@ -71,40 +73,40 @@ export function DataTablePagination<TData>({
71
73
  variant="outline"
72
74
  size="icon"
73
75
  className="hidden size-8 lg:flex"
76
+ aria-label={text.pagination?.first ?? "หน้าแรก"}
74
77
  onClick={() => table.setPageIndex(0)}
75
78
  disabled={!table.getCanPreviousPage()}
76
79
  >
77
- <span className="sr-only">Go to first page</span>
78
80
  <ChevronsLeft />
79
81
  </Button>
80
82
  <Button
81
83
  variant="outline"
82
84
  size="icon"
83
85
  className="size-8"
86
+ aria-label={text.pagination?.previous ?? "ก่อนหน้า"}
84
87
  onClick={() => table.previousPage()}
85
88
  disabled={!table.getCanPreviousPage()}
86
89
  >
87
- <span className="sr-only">Go to previous page</span>
88
90
  <ChevronLeft />
89
91
  </Button>
90
92
  <Button
91
93
  variant="outline"
92
94
  size="icon"
93
95
  className="size-8"
96
+ aria-label={text.pagination?.next ?? "ถัดไป"}
94
97
  onClick={() => table.nextPage()}
95
98
  disabled={!table.getCanNextPage()}
96
99
  >
97
- <span className="sr-only">Go to next page</span>
98
100
  <ChevronRight />
99
101
  </Button>
100
102
  <Button
101
103
  variant="outline"
102
104
  size="icon"
103
105
  className="hidden size-8 lg:flex"
106
+ aria-label={text.pagination?.last ?? "หน้าสุดท้าย"}
104
107
  onClick={() => table.setPageIndex(table.getPageCount() - 1)}
105
108
  disabled={!table.getCanNextPage()}
106
109
  >
107
- <span className="sr-only">Go to last page</span>
108
110
  <ChevronsRight />
109
111
  </Button>
110
112
  </div>
@@ -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));
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
3
4
  import { Badge } from "@suphark/ui/components/ui/badge";
4
- import { Button } from "@suphark/ui/components/ui/button";
5
5
  import {
6
6
  InputGroup,
7
7
  InputGroupAddon,
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button } from "@suphark/ui/components/ui/button";
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
4
  import {
5
5
  DropdownMenu,
6
6
  DropdownMenuContent,
@@ -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);
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button } from "@suphark/ui/components/ui/button";
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
4
  import {
5
5
  InputGroup,
6
6
  InputGroupAddon,
@@ -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
  >