@suphark/ui 0.25.1 → 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 +61 -0
- package/README.md +4 -3
- package/package.json +5 -4
- package/src/components/app-shell/command-palette.tsx +13 -5
- package/src/components/data-table/data-table-sortable.tsx +5 -4
- package/src/components/data-table/data-table-toolbar.tsx +14 -3
- package/src/components/data-table/use-data-table.ts +38 -5
- package/src/components/design-system/design-system-showcase.tsx +64 -15
- package/src/components/pwa/pull-to-refresh.tsx +11 -4
- package/src/components/pwa/service-worker-updates.ts +26 -3
- package/src/components/shared/action-menu.tsx +25 -10
- package/src/components/shared/auth-parts.tsx +4 -2
- package/src/components/shared/button/base/action-button.tsx +7 -14
- package/src/components/shared/button/base/link-button.tsx +5 -14
- package/src/components/shared/button/confirm-action-button.tsx +9 -13
- package/src/components/shared/chat-thread.tsx +13 -6
- package/src/components/shared/client-redirect.tsx +5 -9
- package/src/components/shared/dialogs-provider.tsx +31 -0
- package/src/components/shared/form/number-input.tsx +36 -14
- package/src/components/shared/form/tags-input.tsx +26 -21
- package/src/components/shared/form/time-picker.tsx +12 -2
- package/src/components/shared/month-stepper.tsx +3 -3
- package/src/components/shared/row-actions.tsx +13 -7
- package/src/components/shared/rule-builder.tsx +10 -1
- package/src/components/shared/settings-layout.tsx +8 -3
- package/src/components/shared/step-form.tsx +33 -13
- package/src/components/ui/input.tsx +1 -1
- package/src/components/ui/native-select.tsx +1 -1
- package/src/components/ui/select.tsx +1 -1
- package/src/components/ui/textarea.tsx +1 -1
- package/src/hooks/use-table-search-params.ts +9 -4
- package/src/lib/action-result.ts +33 -0
- package/src/lib/safe-url.ts +15 -0
- package/src/styles.css +21 -18
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,67 @@
|
|
|
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
|
+
|
|
45
|
+
## [0.26.0] — 2026-10-03
|
|
46
|
+
|
|
47
|
+
ปิดงานค้างจากผลรีวิว + prop ที่ DueBell ขอ — เพิ่มอย่างเดียว
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- `NumberInput`: `inputRef` (ref ของช่องที่พิมพ์ — focus เองได้) และ `autoFocus` (คำขอจาก DueBell)
|
|
52
|
+
- `TimePicker`: `roundToStep` — ปัดนาทีที่พิมพ์ลงตาม `minuteStep` (เช่น step 60 + พิมพ์ 14:30 → 14:00); ค่าเริ่มต้นเก็บตามที่พิมพ์เหมือนเดิม (คำขอจาก DueBell)
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- **ช่องกรอกใช้ขนาดตัวอักษรตามบทบาท `text-body`** (16px มือถือ / 15px จอกว้าง) — `Input`, `Textarea`, `SelectTrigger`, `NativeSelect` (+ `NumberInput`, `TagsInput`): เดิม `text-base md:text-sm` = 14px บนจอกว้าง เล็กกว่าข้อความรอบข้าง 1px และ `text-body` ที่แอปใส่ให้ช่องได้ผลแค่มือถือ (pi-done ต้องเติม `md:text-body` เอง 19 จุด — ลบได้แล้ว); `SelectTrigger`/`NativeSelect` เดิม 14px แม้บนมือถือ (iOS ซูมเมื่อโฟกัส) — แก้ไฟล์ shadcn 4 ไฟล์เป็นข้อยกเว้น (เพิ่มในรายการคืนไฟล์หลัง `shadcn add --overwrite` ใน CLAUDE.md); `className` ของแอปยังแทนได้ (`cn` รู้จักคลาสตามบทบาท)
|
|
57
|
+
- ปุ่ม `ActionButton`/`LinkButton` ที่มี tooltip ไม่สร้าง `TooltipProvider` ต่อปุ่มแล้ว (ตาราง 50 แถว = 100+ provider) — ใช้ `delay={0}` ที่ trigger แทน หน้าตา/จังหวะเหมือนเดิม
|
|
58
|
+
- `DesignSystemShowcase`: แท็บอื่นนอกจาก "ภาพรวม" โหลดแบบ lazy เมื่อเปิดแท็บ (เดิม import ทุกแท็บ ~1 MB) — ลิงก์ข้ามแท็บไปหัวข้อรอแท็บโหลดเสร็จก่อนเลื่อน
|
|
59
|
+
- ลบ dependency `cn` ที่ไม่ได้ใช้ (แอปไม่ต้องติดตั้งเพิ่ม)
|
|
60
|
+
- docs: agent-rules — barrel ที่ client import ห้าม re-export schema ของ zod (ค่า) ให้ export แค่ type (DueBell −18% ต่อหน้า); `ChartConfig` ใช้เฉพาะค่าที่แอปกำหนดเอง (ค่าถูกเขียนลง `<style>` ตรง ๆ)
|
|
61
|
+
- CI: `permissions: contents: read` (+ `pull-requests: read` ให้ gitleaks) — GITHUB_TOKEN อ่านได้อย่างเดียว; pin action ด้วย commit SHA (checkout v4.4.0, gitleaks-action v2.3.9, pnpm/action-setup v4.3.0, setup-node v4.4.0)
|
|
62
|
+
- showcase: `/components/[slug]` โหลด demo แบบ `next/dynamic` ต่อ slug (`src/docs/demos.tsx` + `DemoView`) — เดิมทุกหน้าโหลด demo ทั้ง 129 ตัว (~409 kB gzip ต่อหน้า เช่น `/components/badge` พก recharts)
|
|
63
|
+
- showcase: demo `code-block`/`secret-reveal` ใช้ค่า `example_…` แทน `sk_live_…` (ไม่ให้ secret scanner แจ้งเตือนผิด)
|
|
64
|
+
- showcase: `@suphark/auth-client` ^0.1.7 → **^0.6.1** (แก้ open redirect ใน `safeNextPath` ของ handler `/auth/login|callback|logout` — ทดสอบ `/auth/logout?next=/%09/evil.com` แล้วไม่ออกนอกโดเมน); breaking ระหว่างทาง (ลบ Prisma adapter, เปลี่ยนชื่อ `ACCESS_DENIED`) ไม่กระทบ showcase
|
|
65
|
+
|
|
5
66
|
## [0.25.1] — 2026-10-02
|
|
6
67
|
|
|
7
68
|
แก้ตามผลรีวิว codebase (bug / security / performance) — ไม่เปลี่ยน props
|
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
|
-
|
|
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.
|
|
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",
|
|
@@ -72,7 +74,6 @@
|
|
|
72
74
|
"class-variance-authority": "^0.7.1",
|
|
73
75
|
"clsx": "^2.1.1",
|
|
74
76
|
"cmdk": "^1.1.1",
|
|
75
|
-
"cn": "^0.3.0",
|
|
76
77
|
"date-fns": "^4.4.0",
|
|
77
78
|
"embla-carousel-react": "^8.6.0",
|
|
78
79
|
"input-otp": "^1.5.0",
|
|
@@ -118,7 +119,7 @@
|
|
|
118
119
|
"@vitejs/plugin-react": "^6.1.0",
|
|
119
120
|
"jsdom": "^29.1.1",
|
|
120
121
|
"lucide-react": "^1.45.0",
|
|
121
|
-
"next": "^16.3.
|
|
122
|
+
"next": "^16.3.8",
|
|
122
123
|
"react": "^19.3.0",
|
|
123
124
|
"react-dom": "^19.3.0",
|
|
124
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
|
-
|
|
159
|
-
if ((e.metaKey || e.ctrlKey)
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
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
|
-
|
|
62
|
+
timer.current = setTimeout(() => {
|
|
63
|
+
timer.current = null;
|
|
62
64
|
committed.current = searchDraft;
|
|
63
65
|
table.setGlobalFilter(searchDraft);
|
|
64
66
|
}, 250);
|
|
65
|
-
return () =>
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
222
|
+
// ค่าเริ่มต้นใช้ `row.id` ถ้ามี — tanstack ใช้ลำดับแถว (index) ทำให้แถวที่เลือกไว้กลายเป็นแถวอื่นเมื่อข้อมูลเปลี่ยน
|
|
223
|
+
// (DeleteManyButton ลบผิดรายการ) — ไม่มี id = ใช้ index เหมือนเดิม
|
|
224
|
+
getRowId: getRowId ?? defaultRowId,
|
|
192
225
|
getExpandedRowModel: getExpandedRowModel(),
|
|
193
226
|
filterFromLeafRows: true,
|
|
194
227
|
enableRowSelection,
|
|
@@ -12,14 +12,38 @@ import {
|
|
|
12
12
|
Zap,
|
|
13
13
|
} from "lucide-react";
|
|
14
14
|
import * as React from "react";
|
|
15
|
-
import { ShowcaseDataDisplay } from "./sections/showcase-data-display";
|
|
16
|
-
import { ShowcaseFeedback } from "./sections/showcase-feedback";
|
|
17
|
-
import { ShowcaseForms } from "./sections/showcase-forms";
|
|
18
|
-
import { ShowcaseFoundations } from "./sections/showcase-foundations";
|
|
19
|
-
import { ShowcaseGuidelinesRecipes } from "./sections/showcase-guidelines-recipes";
|
|
20
|
-
import { ShowcaseOverlaysNavigation } from "./sections/showcase-overlays-navigation";
|
|
21
15
|
import { ShowcaseOverview } from "./sections/showcase-overview";
|
|
22
|
-
|
|
16
|
+
|
|
17
|
+
// แท็บอื่นนอกจากภาพรวมโหลดเมื่อเปิดแท็บนั้น — เดิม import ทุกแท็บ (ปฏิทิน, ตาราง, dnd-kit, cmdk …) ~1 MB ทั้งที่เห็นทีละแท็บ
|
|
18
|
+
const ShowcaseDataDisplay = React.lazy(() =>
|
|
19
|
+
import("./sections/showcase-data-display").then((m) => ({ default: m.ShowcaseDataDisplay })),
|
|
20
|
+
);
|
|
21
|
+
const ShowcaseFeedback = React.lazy(() =>
|
|
22
|
+
import("./sections/showcase-feedback").then((m) => ({ default: m.ShowcaseFeedback })),
|
|
23
|
+
);
|
|
24
|
+
const ShowcaseForms = React.lazy(() =>
|
|
25
|
+
import("./sections/showcase-forms").then((m) => ({ default: m.ShowcaseForms })),
|
|
26
|
+
);
|
|
27
|
+
const ShowcaseFoundations = React.lazy(() =>
|
|
28
|
+
import("./sections/showcase-foundations").then((m) => ({ default: m.ShowcaseFoundations })),
|
|
29
|
+
);
|
|
30
|
+
const ShowcaseGuidelinesRecipes = React.lazy(() =>
|
|
31
|
+
import("./sections/showcase-guidelines-recipes").then((m) => ({
|
|
32
|
+
default: m.ShowcaseGuidelinesRecipes,
|
|
33
|
+
})),
|
|
34
|
+
);
|
|
35
|
+
const ShowcaseOverlaysNavigation = React.lazy(() =>
|
|
36
|
+
import("./sections/showcase-overlays-navigation").then((m) => ({
|
|
37
|
+
default: m.ShowcaseOverlaysNavigation,
|
|
38
|
+
})),
|
|
39
|
+
);
|
|
40
|
+
const ShowcasePrimitives = React.lazy(() =>
|
|
41
|
+
import("./sections/showcase-primitives").then((m) => ({ default: m.ShowcasePrimitives })),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
function SectionLoading() {
|
|
45
|
+
return <div className="h-96 animate-pulse rounded-xl bg-muted/40" aria-busy="true" />;
|
|
46
|
+
}
|
|
23
47
|
|
|
24
48
|
export function DesignSystemShowcase({
|
|
25
49
|
links,
|
|
@@ -32,10 +56,14 @@ export function DesignSystemShowcase({
|
|
|
32
56
|
|
|
33
57
|
/** สลับแท็บแล้วเลื่อนไปหัวข้อ — เลื่อนใน effect หลังแท็บใหม่ render แล้ว (ตำแหน่งจึงถูก) ชดเชย header ที่ sticky */
|
|
34
58
|
const [pendingAnchor, setPendingAnchor] = React.useState<string | null>(null);
|
|
59
|
+
const tries = React.useRef(0);
|
|
60
|
+
const [retry, setRetry] = React.useState(0);
|
|
35
61
|
const jumpTo = React.useCallback((tab: string, anchor?: string) => {
|
|
62
|
+
tries.current = 0; // การกระโดดครั้งใหม่ได้เวลารอแท็บ lazy เต็มรอบ
|
|
36
63
|
setActiveTab(tab);
|
|
37
64
|
setPendingAnchor(anchor ?? "__top__");
|
|
38
65
|
}, []);
|
|
66
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: retry ใช้เป็นตัวกระตุ้นให้ลองหาหัวข้อซ้ำระหว่างรอแท็บ lazy
|
|
39
67
|
React.useEffect(() => {
|
|
40
68
|
if (!pendingAnchor) return;
|
|
41
69
|
// หน่วงเล็กน้อยให้ Tabs โฟกัส trigger ใหม่เสร็จก่อน (การโฟกัสเลื่อนหน้าไปที่แถบแท็บ) แล้วค่อยเลื่อนไปหัวข้อ
|
|
@@ -47,15 +75,22 @@ export function DesignSystemShowcase({
|
|
|
47
75
|
}
|
|
48
76
|
const el = document.getElementById(pendingAnchor);
|
|
49
77
|
if (!el) {
|
|
78
|
+
// แท็บที่โหลดแบบ lazy อาจยัง render ไม่เสร็จ — ลองใหม่ได้ถึง ~2 วินาที
|
|
79
|
+
if (tries.current++ < 25) {
|
|
80
|
+
setRetry((r) => r + 1);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
tries.current = 0;
|
|
50
84
|
setPendingAnchor(null);
|
|
51
85
|
return;
|
|
52
86
|
}
|
|
87
|
+
tries.current = 0;
|
|
53
88
|
el.style.scrollMarginTop = "88px"; // ชดเชย header ที่ sticky
|
|
54
89
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
55
90
|
setPendingAnchor(null);
|
|
56
91
|
}, 80);
|
|
57
92
|
return () => window.clearTimeout(timer);
|
|
58
|
-
}, [pendingAnchor]);
|
|
93
|
+
}, [pendingAnchor, retry]);
|
|
59
94
|
|
|
60
95
|
const tabItems = [
|
|
61
96
|
{ value: "overview", label: "ภาพรวม", icon: LayoutGrid },
|
|
@@ -109,31 +144,45 @@ export function DesignSystemShowcase({
|
|
|
109
144
|
</TabsContent>
|
|
110
145
|
|
|
111
146
|
<TabsContent value="foundations" className="mt-0 outline-none">
|
|
112
|
-
<
|
|
147
|
+
<React.Suspense fallback={<SectionLoading />}>
|
|
148
|
+
<ShowcaseFoundations />
|
|
149
|
+
</React.Suspense>
|
|
113
150
|
</TabsContent>
|
|
114
151
|
|
|
115
152
|
<TabsContent value="primitives" className="mt-0 outline-none">
|
|
116
|
-
<
|
|
153
|
+
<React.Suspense fallback={<SectionLoading />}>
|
|
154
|
+
<ShowcasePrimitives />
|
|
155
|
+
</React.Suspense>
|
|
117
156
|
</TabsContent>
|
|
118
157
|
|
|
119
158
|
<TabsContent value="forms" className="mt-0 outline-none">
|
|
120
|
-
<
|
|
159
|
+
<React.Suspense fallback={<SectionLoading />}>
|
|
160
|
+
<ShowcaseForms />
|
|
161
|
+
</React.Suspense>
|
|
121
162
|
</TabsContent>
|
|
122
163
|
|
|
123
164
|
<TabsContent value="data-display" className="mt-0 outline-none">
|
|
124
|
-
<
|
|
165
|
+
<React.Suspense fallback={<SectionLoading />}>
|
|
166
|
+
<ShowcaseDataDisplay />
|
|
167
|
+
</React.Suspense>
|
|
125
168
|
</TabsContent>
|
|
126
169
|
|
|
127
170
|
<TabsContent value="overlays" className="mt-0 outline-none">
|
|
128
|
-
<
|
|
171
|
+
<React.Suspense fallback={<SectionLoading />}>
|
|
172
|
+
<ShowcaseOverlaysNavigation />
|
|
173
|
+
</React.Suspense>
|
|
129
174
|
</TabsContent>
|
|
130
175
|
|
|
131
176
|
<TabsContent value="feedback" className="mt-0 outline-none">
|
|
132
|
-
<
|
|
177
|
+
<React.Suspense fallback={<SectionLoading />}>
|
|
178
|
+
<ShowcaseFeedback />
|
|
179
|
+
</React.Suspense>
|
|
133
180
|
</TabsContent>
|
|
134
181
|
|
|
135
182
|
<TabsContent value="guidelines-recipes" className="mt-0 outline-none">
|
|
136
|
-
<
|
|
183
|
+
<React.Suspense fallback={<SectionLoading />}>
|
|
184
|
+
<ShowcaseGuidelinesRecipes />
|
|
185
|
+
</React.Suspense>
|
|
137
186
|
</TabsContent>
|
|
138
187
|
</Tabs>
|
|
139
188
|
</div>
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ใหม่ทำงานทันที —
|
|
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",
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* ลิงก์ที่มี confirm: ไปหลังยืนยันแล้วเท่านั้น — ไม่ได้ผ่าน `<a href>` (ที่ React กัน javascript: ให้)
|
|
94
|
+
* จึงต้องรับเฉพาะ http(s) และ path ภายในเอง
|
|
95
|
+
*/
|
|
88
96
|
const follow = (item: ActionMenuCommand) => {
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
|
|
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
|
-
|
|
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
|
>
|