@suphark/ui 0.5.1 → 0.6.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +20 -0
  3. package/package.json +5 -1
  4. package/scripts/audit.mjs +218 -0
  5. package/src/components/app-shell/app-brand.tsx +1 -0
  6. package/src/components/app-shell/app-nav.tsx +26 -12
  7. package/src/components/data-table/data-table-faceted-filter.tsx +14 -15
  8. package/src/components/data-table/data-table-row-actions.tsx +2 -1
  9. package/src/components/data-table/use-data-table.ts +7 -11
  10. package/src/components/design-system/sections/showcase-data-display.tsx +2 -2
  11. package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +1 -1
  12. package/src/components/design-system/sections/showcase-overlays-navigation.tsx +2 -2
  13. package/src/components/design-system/sections/showcase-primitives.tsx +1 -1
  14. package/src/components/pwa/pull-to-refresh.tsx +11 -5
  15. package/src/components/shared/action-form.tsx +78 -0
  16. package/src/components/shared/button/floating-save-button.tsx +1 -1
  17. package/src/components/shared/form/form-field-color-select.tsx +1 -1
  18. package/src/components/shared/form/form-field-combobox.tsx +15 -6
  19. package/src/components/shared/form/form-field-date.tsx +1 -1
  20. package/src/components/shared/form/form-field-input.tsx +6 -6
  21. package/src/components/shared/form/form-field-radio-cards.tsx +65 -24
  22. package/src/components/shared/form/labeled-field.tsx +61 -0
  23. package/src/components/shared/form-dialog.tsx +1 -3
  24. package/src/components/shared/form-message.tsx +4 -5
  25. package/src/components/shared/navigation/stepper.tsx +96 -0
  26. package/src/components/shared/notice.tsx +1 -0
  27. package/src/components/shared/number/formatted-number.tsx +39 -102
  28. package/src/components/shared/status-badge.tsx +1 -0
  29. package/src/components/shared/theme/theme-toggle.tsx +11 -8
  30. package/src/components/shared/time/client-date-formatter.tsx +47 -154
  31. package/src/components/ui/relative-date.tsx +1 -10
  32. package/src/hooks/use-mounted.ts +16 -0
  33. package/src/hooks/use-ticker.ts +53 -0
  34. package/src/index.ts +6 -1
  35. package/src/lib/intl-cache.ts +31 -0
  36. package/src/lib/status-colors.ts +1 -0
  37. package/src/next.ts +1 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,66 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.6.1] — 2026-09-20
6
+
7
+ ### Added
8
+
9
+ - **`npx suphark-ui-audit`** (`bin` ของแพ็กเกจ, `scripts/audit.mjs`) — ตรวจโค้ดแอปปลายทางตามกติกา design system ในคำสั่งเดียว: error = `window-dialog` (window.confirm/alert/prompt), `radix-state` (`data-[state=…]` ที่ Base UI ไม่มี), `arbitrary-text` (`text-[Npx]`), `base-ui-props` (สคริปต์เดิม); warning = `raw-palette` (bg-red-500 …), `important` (utility ลงท้าย `!` — เฉพาะ `--important` สำหรับโค้ดแพ็กเกจ); `--strict` ให้ warning เป็น error; ยกเว้น `// ui-audit-ok: เหตุผล` / `/* ui-audit-ok-file: เหตุผล */`; ข้าม `components/ui/**`, tests, generated; README + `docs/agent-rules.md` มีวิธีใช้ — repo นี้รัน `pnpm audit:ui` ใน `verify` และ CI (0 error 0 warning)
10
+ - `FormFieldRadioCards`: prop `aria-label` ส่งต่อไป RadioGroup เมื่อไม่แสดง `label` (ข้อสังเกตจาก Pi Auth หลังย้ายมาใช้ — ของเดิมมี aria-label)
11
+
12
+ ### Changed
13
+
14
+ - ล้าง raw palette ในแพ็กเกจเป็น semantic token: labs badge ใน `AppNav` → `text-warning-muted-foreground`, `FloatingSaveButton` สำเร็จ → `bg-success`, `FormMessage` สำเร็จ → `text-success`, ดอกจัน/ข้อความ error ใน `FormField*` → `text-destructive`, ไอคอน `ThemeToggle` → `text-warning`/`text-primary`/`text-muted-foreground`, ตัวอย่างใน design-system; ที่ยังใช้ palette โดยตั้งใจและจดไว้ (`ui-audit-ok-file`): `Notice`/`StatusBadge` (โทน 50/900 คู่ dark — รอตัดสินใจ token ระดับ tone), `lib/status-colors.ts` (ชุดสีให้ผู้ใช้เลือก)
15
+
16
+ ## [0.6.0] — 2026-09-20
17
+
18
+ component สำเร็จรูปสำหรับฟอร์ม server action ที่ Pi Auth ประกอบเองอยู่ (DueBell/Mail/Files ใช้ได้เหมือนกัน) — ไม่เปลี่ยน API เดิม; **รวม 0.5.2 ที่ยังไม่ได้ publish**
19
+
20
+ ### Added
21
+
22
+ - **`LabeledField`** + **`CheckboxField`** (barrel) — ช่องฟอร์มสำหรับ server action ธรรมดา (ไม่ใช่ react-hook-form): label ครอบ control + `hint` หรือ `error` (ตั้ง `data-invalid`) ประกอบจาก shadcn `Field/FieldLabel/FieldDescription/FieldError`; `CheckboxField` = Checkbox + label บรรทัดเดียว ใส่ `name` แล้วส่ง "on" ใน FormData — ยกจาก Pi Auth (42 ไฟล์) คง API เดิมเป๊ะ; ใช้จาก server component ได้ (ชื่อ `LabeledField` เพราะ `Field` ชนกับ shadcn และ `FormField` ชนกับ react-hook-form)
23
+ - **`ActionForm`** (barrel — ไม่พึ่ง next) — ฟอร์ม inline คู่กับ `FormDialog`: `useActionState` + `FormMessage` + ปุ่มส่ง (ป้ายค่าเริ่มต้น `UiText.save`), ไม่ใช้ `action=` บน `<form>` จึงคงค่าที่พิมพ์เมื่อ action คืน error; props `action`, `title?`, `submitLabel?`, `compact?` (ไม่มี Card, ปุ่ม sm, จัดแนวนอน wrap สำหรับแถวตาราง), `className?`, `onSuccess?(state)`; export type **`FormAction`** จาก barrel (FormDialog ใช้ชนิดเดียวกัน)
24
+ - **`Stepper`** (`@suphark/ui/next` — ใช้ next/link) — แถบขั้นตอน wizard ทุกขั้นกดเข้าได้: `steps[{ key, href, label, status: done|todo|problem, statusLabel? }]`, `current`, `ariaLabel?`; แนวนอนเลื่อนได้บนจอแคบ แนวตั้งบน lg; วงกลม เลข/✓/⚠ + `StatusBadge` ใต้ชื่อ; `aria-current="step"`; server component ได้; types `StepperStep`, `StepStatus`
25
+ - **`FormFieldRadioCards`** (backward-compatible): ใช้ **โดยไม่มี `control`** ในฟอร์ม server action ได้ — `name` ส่งต่อไป `RadioGroup` (มี hidden input ใน FormData) + `defaultValue` (uncontrolled) หรือ `value`+`onValueChange` (controlled); **`layout="row"`** = การ์ดแนวนอน ปุ่มวิทยุจริงซ้าย ไอคอน+ชื่อบรรทัดบน คำอธิบายบรรทัดล่าง (`grid sm:grid-cols-2`, checked = `border-primary bg-primary/5`); layout `grid` เดิม จุดเลือกมุมขวาบนตอนนี้ตามสถานะจริงของปุ่มวิทยุ (CSS `peer-data-checked`) จึงถูกทั้ง controlled/uncontrolled
26
+ - showcase: หน้า `labeled-field`, `action-form`, `stepper`, `form-field-radio-cards`; tests 8 กรณี (labeled-field, action-form ส่ง FormData/คงค่า/onSuccess/compact, stepper, radio-cards FormData + row)
27
+
28
+ ## [0.5.2] — 2026-09-20 (ไม่ได้ publish แยก — รวมใน 0.6.0)
29
+
30
+ รอบรีวิวประสิทธิภาพ + บั๊กที่เหลือ — ไม่เปลี่ยน props
31
+
32
+ ### Fixed
33
+
34
+ - **`PullToRefresh`** (`@suphark/ui/pwa`): effect ผูก `pullDistance`/`isRefreshing` เป็น deps → ถอด/ใส่ listener 4 ตัวบน `window` ใหม่ทุก `touchmove` และ `touchend` หลุดได้ระหว่างนั้น (ดึงแล้วไม่รีเฟรช/ป้ายค้าง) — อ่านค่าผ่าน ref ลงทะเบียนครั้งเดียว
35
+ - **`ThemeToggle`** เมื่อส่ง `persistedTheme`: effect ผูก `theme` ด้วย → ผู้ใช้กดเปลี่ยนแล้วถูก set กลับเป็นค่าที่บันทึกไว้ทันที (server action ยังไม่กลับ) — sync เฉพาะเมื่อ `persistedTheme` เปลี่ยน (รวมครั้งแรก)
36
+ - **`DataTableRowActions`**: `EditDialog` ถูก mount ทุกแถวตลอดเวลา (50 แถว = 50 Dialog+ฟอร์ม และ `initialData` stale) — mount เฉพาะตอนเปิด
37
+ - **`useDataTable`**: `onGlobalFilterChange` อ่านค่าเก่าจาก closure (พิมพ์เร็วใน tick เดียวหายตัวอักษร) → อ่านจาก `table.getState()`; `getPaginationRowModel` ไม่ใส่เมื่อ `manualPagination` (server แบ่งหน้าแล้ว ไม่ตัดซ้ำ); ตัด `useMemo` ที่ไม่มีผล
38
+ - **`FormFieldCombobox`**: cache ตัวเลือก key ด้วย function identity ใน `Map` module-level → inline fetcher ทำให้โตไม่จำกัด (memory leak) และไม่เคย hit — key ที่เป็นฟังก์ชันเก็บใน `WeakMap` (ส่ง `fetcherKey` ยังดีที่สุด)
39
+
40
+ ### Changed (ประสิทธิภาพ)
41
+
42
+ - **`ClientDateFormatter`**: ทุก instance เคยมี `setInterval` ของตัวเอง + render เพิ่ม 1 รอบตอน mount → ใช้ **`useTicker()`** นาฬิกากลางแชร์ timer เดียวต่อ interval + `useMounted()` (ไม่มี render เพิ่ม) + cache `Intl.DateTimeFormat`
43
+ - **`FormattedNumber`**: จาก state + 2 effects (3 renders/เซลล์) และ `new Intl.NumberFormat` ทุกครั้ง → `useMounted()` + `useMemo` + cache formatter ต่อ locale+options (ตารางหลายร้อยเซลล์เห็นผล)
44
+ - **`DataTableFacetedFilter`**: `selectedValues` Set ใหม่ทุก render ทำให้ `useMemo` เรียง options ไม่เคย hit → memo จาก `filterValue` จริง
45
+ - **`AppNav`**: กลุ่มเมนูที่กางได้เปลี่ยนจาก remount ทั้ง subtree ด้วย `key` เมื่อลูก active/ค้นหา → Collapsible แบบ controlled (`NavCollapsible`) เปิดอัตโนมัติเมื่อสถานะเปลี่ยน ผู้ใช้กาง/หุบเองได้ ไม่ mount ใหม่ มี animation
46
+ - **`RelativeDate`** ใช้ `useMounted()` กลาง
47
+
48
+ ### Added
49
+
50
+ - **`useMounted()`** (barrel) — true เฉพาะฝั่ง client หลัง hydrate ผ่าน `useSyncExternalStore` (ไม่มี render เพิ่ม) — ใช้กับค่าที่ server/browser ต่างกัน (เวลา, โซนเวลา, localStorage)
51
+ - **`useTicker(intervalMs)`** (barrel) — ตัวนับที่เพิ่มทุก interval, ทุก instance แชร์ `setInterval` เดียวต่อ interval (เริ่ม/หยุดตามผู้ใช้ตัวแรก/สุดท้าย); 0 = ไม่ tick
52
+ - **`getNumberFormat` / `getDateTimeFormat`** (`@suphark/ui/lib/intl-cache`, barrel) — Intl formatter ที่ cache ต่อ locale+options
53
+ - tests: `use-ticker` (แชร์ timer/หยุดเมื่อถอดหมด), `intl-cache`
54
+
55
+ ### ไม่ได้แก้ตามที่ประเมินไว้ (0.5.2)
56
+
57
+ - `DataTableToolbar` render ชุดตัวกรอง 2 ครั้ง (desktop + Sheet มือถือ): ตรวจแล้ว `SheetContent` ของ Base UI ไม่ mount ลูกเมื่อปิด ชุดใน Sheet จึงมีเฉพาะตอนเปิด — ต้นทุนจริงมีแค่ชุด desktop ที่ `hidden` บนมือถือ (ปุ่ม + trigger ไม่มี popup) ไม่คุ้มเสี่ยง hydration flash จากการ gate ด้วย `useIsMobile()`
58
+
59
+ ### Added (showcase/เครื่องมือ)
60
+
61
+
62
+ - showcase: หน้า **`/changelog`** render `packages/ui/CHANGELOG.md` ตอน build (parser เล็ก ๆ ไม่เพิ่ม dep: รุ่น/หมวด Added-Changed-Fixed/รายการ/inline code-bold-link) + ลิงก์ในเมนูบน
63
+ - **`pnpm check:changelog`** (`scripts/check-changelog.mjs`) — ทุกการเปลี่ยนแปลงใน `packages/ui/src`, `package.json`, `README.md`, `apps/showcase/src` ต้องมีบรรทัดใน CHANGELOG; ตรวจ working tree ก่อน commit (อยู่ใน `pnpm verify`) และช่วง commit ใน CI (`CHANGELOG_RANGE`)
64
+
5
65
  ## [0.5.1] — 2026-09-20
6
66
 
7
67
  ### Fixed
package/README.md CHANGED
@@ -182,6 +182,26 @@ webview/automation บางตัวปิด `window.confirm` ทิ้ง (
182
182
 
183
183
  `createKanbanCollisionDetection({ columnItems, resolveColumnId })` (จาก `@suphark/ui/kanban`) ใช้เป็น `collisionDetection` ของ `DndContext` เมื่อมีหลายคอลัมน์ (SortableContext ต่อคอลัมน์) — ตัวอย่างบอร์ดเต็มที่ ui.suphark.com/components/kanban-board; wrapper ของการ์ด**ห้าม** `overflow-hidden` (dnd-kit ใส่ transform ที่ node ที่ `setNodeRef` ถ้าถูกครอบด้วย overflow-hidden การ์ดที่ขยับหลบจะถูก clip)
184
184
 
185
+ ### ตรวจกติกาในแอป — `npx suphark-ui-audit`
186
+
187
+ แพ็กเกจมี `bin` ให้รันในแอปปลายทาง (ตรวจ `src` ค่าเริ่มต้น; ข้าม `components/ui/**`, tests, generated):
188
+
189
+ ```bash
190
+ npx suphark-ui-audit # หรือใส่ใน package.json: "audit:ui": "suphark-ui-audit src"
191
+ npx suphark-ui-audit --strict # warning นับเป็น error (ใช้ใน CI เมื่อล้างหมดแล้ว)
192
+ ```
193
+
194
+ | กฎ | ระดับ | เจอ → ใช้แทน |
195
+ | --- | --- | --- |
196
+ | `window-dialog` | error | `window.confirm/alert/prompt(` → `ConfirmDialog` / `useConfirm()` / `useDialogs()` |
197
+ | `radix-state` | error | `data-[state=open\|checked\|active…]` → `data-open` / `data-checked` / `data-active` / `data-popup-open` (Base UI ไม่มี data-state) |
198
+ | `arbitrary-text` | error | `text-[Npx]` → `text-xs … text-3xl` |
199
+ | `base-ui-props` | error | `default*` ที่ค่าเปลี่ยนได้ / `onSelect` บนเมนู (ดู `scripts/audit-base-ui-props.mjs`) |
200
+ | `raw-palette` | warning | `bg-red-500`, `text-slate-600` … → `bg-destructive`, `text-muted-foreground`, `bg-success`, `bg-warning`, `text-primary` |
201
+ | `important` | warning (เฉพาะ `--important`) | utility ลงท้าย `!` ในโค้ดแพ็กเกจ |
202
+
203
+ ยกเว้นรายบรรทัด `// ui-audit-ok: <เหตุผล>` (บรรทัดเดียวกันหรือบรรทัดก่อน) — ยกเว้นทั้งไฟล์ `/* ui-audit-ok-file: <เหตุผล> */`
204
+
185
205
  ### สิทธิ์ (ActionButton/LinkButton `permission` prop)
186
206
 
187
207
  แพ็กเกจไม่รู้จักระบบสิทธิ์ของแอป — ครอบด้วย `UiPermissionProvider` แล้วส่งฟังก์ชัน `check(requirement, options)` ของแอปเข้ามา ถ้าไม่ครอบ = อนุญาตทุกอย่าง
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.5.1",
3
+ "version": "0.6.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 เอง",
@@ -22,6 +22,9 @@
22
22
  "sideEffects": [
23
23
  "*.css"
24
24
  ],
25
+ "bin": {
26
+ "suphark-ui-audit": "scripts/audit.mjs"
27
+ },
25
28
  "files": [
26
29
  "src",
27
30
  "!src/**/__tests__",
@@ -116,6 +119,7 @@
116
119
  },
117
120
  "scripts": {
118
121
  "typecheck": "tsc --noEmit",
122
+ "audit": "node scripts/audit.mjs src --important",
119
123
  "test": "vitest run",
120
124
  "test:watch": "vitest",
121
125
  "lint:base-ui": "node scripts/audit-base-ui-props.mjs",
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * suphark-ui-audit — ตรวจโค้ดแอปปลายทางตามกติกา design system (docs/agent-rules.md) ในคำสั่งเดียว
4
+ *
5
+ * npx suphark-ui-audit # ตรวจ src (ค่าเริ่มต้น)
6
+ * npx suphark-ui-audit src apps/web # หลายโฟลเดอร์
7
+ * npx suphark-ui-audit --strict # warning นับเป็น error ด้วย (ใช้ใน CI เมื่อล้างหมดแล้ว)
8
+ * npx suphark-ui-audit --no-palette # ปิดกฎ raw palette ชั่วคราว
9
+ * npx suphark-ui-audit --important # เปิดกฎ utility ลงท้าย ! (ค่าเริ่มต้นตรวจเฉพาะโค้ดของแพ็กเกจเอง)
10
+ *
11
+ * กฎ (error = พังบน Base UI / ผิดกติกาชัดเจน, warning = ควรแก้แต่ไม่บล็อก):
12
+ * window-dialog error window.confirm/alert/prompt → ConfirmDialog / useConfirm() / useDialogs()
13
+ * radix-state error data-[state=open|checked|active|…] ไม่มีบน Base UI → data-open / data-checked / data-active / data-popup-open
14
+ * arbitrary-text error text-[Npx] → ใช้ scale (text-xs … text-3xl)
15
+ * important warning utility ที่ลงท้าย ! (size-3.5!) — เฉพาะโค้ดแพ็กเกจ (--important): Tailwind ของแอปที่ scan ผ่าน @source อาจไม่ generate
16
+ * raw-palette warning bg-red-500 / text-slate-600 … → semantic token (bg-destructive, text-muted-foreground, bg-success …)
17
+ * base-ui-props error default* ที่ค่าเปลี่ยนได้ / onSelect บน Base UI (สคริปต์ audit-base-ui-props)
18
+ *
19
+ * ยกเว้นเป็นบรรทัด: ใส่ `// ui-audit-ok: <เหตุผล>` ท้ายบรรทัดหรือบรรทัดก่อนหน้า
20
+ * ยกเว้นทั้งไฟล์: `/* ui-audit-ok-file: <เหตุผล> *\/` ที่หัวไฟล์
21
+ * ข้ามอัตโนมัติ: node_modules, .next, dist, build, generated, __tests__, *.test.*, components/ui/** (ไฟล์ shadcn)
22
+ */
23
+ import { spawnSync } from "node:child_process";
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ const args = process.argv.slice(2);
29
+ const flags = new Set(args.filter((a) => a.startsWith("--")));
30
+ const roots = args.filter((a) => !a.startsWith("--"));
31
+ if (roots.length === 0) roots.push("src");
32
+ const STRICT = flags.has("--strict");
33
+ const PALETTE = !flags.has("--no-palette");
34
+ const SKIP_BASE_UI = flags.has("--no-base-ui");
35
+ const IMPORTANT = flags.has("--important");
36
+
37
+ const SKIP_DIRS = new Set([
38
+ "node_modules",
39
+ ".next",
40
+ "dist",
41
+ "build",
42
+ "generated",
43
+ "__tests__",
44
+ ".git",
45
+ ]);
46
+ const LINE_MARKER = "ui-audit-ok:";
47
+ const FILE_MARKER = "ui-audit-ok-file:";
48
+
49
+ const PALETTE_COLORS =
50
+ "red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone";
51
+ const RULES = [
52
+ {
53
+ id: "window-dialog",
54
+ level: "error",
55
+ regex: /\bwindow\.(confirm|alert|prompt)\s*\(/g,
56
+ message:
57
+ "ใช้ ConfirmDialog / useConfirm() / useDialogs() ของ @suphark/ui แทน window dialog (webview บางตัวปิดทิ้ง)",
58
+ },
59
+ {
60
+ id: "radix-state",
61
+ level: "error",
62
+ regex:
63
+ /data-\[state=(?:open|closed|checked|unchecked|active|inactive|on|off|visible|hidden|delayed-open|instant-open)\]/g,
64
+ message:
65
+ "Base UI ไม่ใส่ data-state — ใช้ data-open / data-closed / data-checked / data-active / data-popup-open",
66
+ },
67
+ {
68
+ id: "arbitrary-text",
69
+ level: "error",
70
+ regex: /\btext-\[\d+(?:px|rem|em)\]/g,
71
+ message: "ห้าม text-[Npx] — ใช้ typography scale (text-xs … text-3xl) ตาม docs/design-system.md",
72
+ },
73
+ {
74
+ id: "important",
75
+ level: "warning",
76
+ regex: /(?<=["'`\s])[a-z][\w:/[\]().%=,-]*!(?=["'`\s])/g,
77
+ message:
78
+ "utility ที่ลงท้าย ! — แอปที่ scan แพ็กเกจผ่าน @source อาจไม่ generate class นี้ ปรับ specificity แทน",
79
+ },
80
+ {
81
+ id: "raw-palette",
82
+ level: "warning",
83
+ regex: new RegExp(
84
+ `(?<![\\w-])(?:[a-z-]+:)*(?:bg|text|border|ring|fill|stroke|from|to|via|outline|decoration|divide|shadow|accent|caret|placeholder)-(?:${PALETTE_COLORS})-(?:50|100|200|300|400|500|600|700|800|900|950)(?:/\\d+)?(?![\\w-])`,
85
+ "g",
86
+ ),
87
+ message:
88
+ "raw palette → semantic token (bg-muted, text-muted-foreground, bg-destructive, bg-success, bg-warning, text-primary …)",
89
+ },
90
+ ];
91
+
92
+ /** ปิดส่วนที่ไม่ใช่โค้ด (comment, <code>/<pre>, template ตัวอย่างในเอกสาร) ด้วยช่องว่างเพื่อคง offset */
93
+ function mask(content) {
94
+ return content
95
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
96
+ .replace(/(^|[^:\\])\/\/.*$/gm, (m, p1) => p1 + " ".repeat(m.length - p1.length))
97
+ .replace(/<code[\s\S]*?<\/code>/g, (m) => m.replace(/[^\n]/g, " "))
98
+ .replace(/<pre[\s\S]*?<\/pre>/g, (m) => m.replace(/[^\n]/g, " "));
99
+ }
100
+
101
+ function walk(dir, out) {
102
+ let entries;
103
+ try {
104
+ entries = fs.readdirSync(dir, { withFileTypes: true });
105
+ } catch {
106
+ return out;
107
+ }
108
+ for (const e of entries) {
109
+ const full = path.join(dir, e.name);
110
+ if (e.isDirectory()) {
111
+ if (SKIP_DIRS.has(e.name)) continue;
112
+ walk(full, out);
113
+ } else if (/\.(tsx?|jsx?|mdx?)$/.test(e.name) && !/\.test\.[jt]sx?$/.test(e.name)) {
114
+ // ไฟล์ shadcn ของแอป (สำเนา CLI) ไม่ตรวจ — ควรใช้ของแพ็กเกจแทนอยู่แล้ว
115
+ if (/[\\/]components[\\/]ui[\\/]/.test(full)) continue;
116
+ out.push(full);
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+
122
+ const files = roots.flatMap((r) => walk(path.resolve(process.cwd(), r), []));
123
+ const findings = [];
124
+ let skippedFiles = 0;
125
+
126
+ for (const file of files) {
127
+ const raw = fs.readFileSync(file, "utf8");
128
+ if (raw.includes(FILE_MARKER)) {
129
+ skippedFiles++;
130
+ continue;
131
+ }
132
+ const lines = raw.split("\n");
133
+ const masked = mask(raw).split("\n");
134
+ for (const rule of RULES) {
135
+ if (rule.id === "raw-palette" && !PALETTE) continue;
136
+ if (rule.id === "important" && !IMPORTANT) continue;
137
+ for (let i = 0; i < masked.length; i++) {
138
+ const line = masked[i] ?? "";
139
+ rule.regex.lastIndex = 0;
140
+ let m = rule.regex.exec(line);
141
+ if (!m) continue;
142
+ const rawLine = lines[i] ?? "";
143
+ const prev = lines[i - 1] ?? "";
144
+ if (
145
+ rawLine.includes(LINE_MARKER) ||
146
+ (prev.trim().startsWith("//") && prev.includes(LINE_MARKER))
147
+ )
148
+ continue;
149
+ const hits = [];
150
+ while (m) {
151
+ hits.push(m[0]);
152
+ m = rule.regex.exec(line);
153
+ }
154
+ findings.push({
155
+ file: path.relative(process.cwd(), file),
156
+ line: i + 1,
157
+ rule: rule.id,
158
+ level: STRICT ? "error" : rule.level,
159
+ hits: [...new Set(hits)],
160
+ message: rule.message,
161
+ });
162
+ }
163
+ }
164
+ }
165
+
166
+ // base-ui props audit ของแพ็กเกจ (รันเป็นโปรเซสลูกเพื่อคง UI_DIR ชี้ที่ shadcn ของแพ็กเกจ)
167
+ let baseUiExit = 0;
168
+ let baseUiOutput = "";
169
+ if (!SKIP_BASE_UI) {
170
+ const here = path.dirname(fileURLToPath(import.meta.url));
171
+ const uiDir = path.resolve(here, "..", "src", "components", "ui");
172
+ const r = spawnSync(process.execPath, [path.join(here, "audit-base-ui-props.mjs"), ...roots], {
173
+ encoding: "utf8",
174
+ env: { ...process.env, UI_DIR: uiDir },
175
+ });
176
+ baseUiExit = r.status ?? 1;
177
+ baseUiOutput = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
178
+ }
179
+
180
+ // ---------- รายงาน ----------
181
+ const byRule = new Map();
182
+ for (const f of findings) byRule.set(f.rule, (byRule.get(f.rule) ?? 0) + 1);
183
+ const errors = findings.filter((f) => f.level === "error");
184
+ const warnings = findings.filter((f) => f.level === "warning");
185
+
186
+ findings.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file.localeCompare(b.file)));
187
+ let lastFile = "";
188
+ for (const f of findings) {
189
+ if (f.file !== lastFile) {
190
+ console.log(`\n${f.file}`);
191
+ lastFile = f.file;
192
+ }
193
+ const tag = f.level === "error" ? "✗" : "△";
194
+ console.log(` ${tag} :${f.line} ${f.rule} ${f.hits.join(", ")}`);
195
+ }
196
+ if (baseUiOutput && baseUiExit !== 0) console.log(`\n${baseUiOutput}`);
197
+
198
+ console.log(
199
+ `\nsuphark-ui-audit — ตรวจ ${files.length} ไฟล์ใน ${roots.join(", ")}${skippedFiles ? ` (ข้าม ${skippedFiles} ไฟล์ที่ทำเครื่องหมาย ${FILE_MARKER})` : ""}`,
200
+ );
201
+ for (const rule of RULES) {
202
+ const n = byRule.get(rule.id) ?? 0;
203
+ if (rule.id === "raw-palette" && !PALETTE) continue;
204
+ if (rule.id === "important" && !IMPORTANT) continue;
205
+ console.log(
206
+ ` ${n === 0 ? "✓" : rule.level === "error" || STRICT ? "✗" : "△"} ${rule.id.padEnd(15)} ${n}${n ? ` — ${rule.message}` : ""}`,
207
+ );
208
+ }
209
+ console.log(
210
+ ` ${baseUiExit === 0 ? "✓" : "✗"} base-ui-props ${baseUiExit === 0 ? "ผ่าน" : "พบปัญหา (ดูข้างบน)"}`,
211
+ );
212
+ console.log(`\n${errors.length} error, ${warnings.length} warning${STRICT ? " (--strict)" : ""}`);
213
+ if (errors.length || warnings.length) {
214
+ console.log(
215
+ `ยกเว้นรายบรรทัด: // ${LINE_MARKER} <เหตุผล> ยกเว้นทั้งไฟล์: /* ${FILE_MARKER} <เหตุผล> */`,
216
+ );
217
+ }
218
+ process.exit(errors.length > 0 || baseUiExit !== 0 ? 1 : 0);
@@ -65,6 +65,7 @@ export function AppBrand({
65
65
  <SidebarMenuItem className={cn("flex items-center", className)}>
66
66
  <SidebarMenuButton
67
67
  size="lg"
68
+ // ui-audit-ok: class เดียวกับ size=lg ของ sidebar.tsx (shadcn) จึงถูก generate แน่นอน
68
69
  className="min-w-0 flex-1 group-data-[collapsible=icon]:p-0!"
69
70
  render={<Link href={href} onClick={closeMobile} />}
70
71
  >
@@ -32,6 +32,7 @@ import { cn } from "@suphark/ui/lib/utils";
32
32
  import { ChevronRight, FlaskConical, Pin } from "lucide-react";
33
33
  import Link from "next/link";
34
34
  import { usePathname } from "next/navigation";
35
+ import { type ReactNode, useEffect, useState } from "react";
35
36
  import { type AppNavGroup, type AppNavItem, isNavActive } from "./types";
36
37
 
37
38
  /** ปุ่ม pin — แสดงเมื่อแอปส่ง `pinnedHrefs` + `onTogglePin` (supplychain เก็บใน user preferences) */
@@ -48,12 +49,10 @@ function MenuTitle({ title, isLabs }: { title: string; isLabs?: boolean }) {
48
49
  <TooltipTrigger
49
50
  render={<span className="flex w-full items-center gap-1.5 overflow-hidden" />}
50
51
  >
51
- <span
52
- className={cn("truncate", isLabs && "font-medium text-amber-600 dark:text-amber-500")}
53
- >
52
+ <span className={cn("truncate", isLabs && "font-medium text-warning-muted-foreground")}>
54
53
  {title}
55
54
  </span>
56
- {isLabs && <FlaskConical className="h-3 w-3 shrink-0 text-amber-600 dark:text-amber-500" />}
55
+ {isLabs && <FlaskConical className="h-3 w-3 shrink-0 text-warning-muted-foreground" />}
57
56
  </TooltipTrigger>
58
57
  <TooltipContent side="right" align="center" className="max-w-xs break-words">
59
58
  {title}
@@ -179,14 +178,8 @@ export function AppNavGroupMenu({
179
178
  }
180
179
 
181
180
  // กางปกติ — เปิดค้างเมื่อลูกตัวใดตัวหนึ่ง active หรือกำลังค้นหา
182
- const open = forceOpen || childActive;
183
181
  return (
184
- <Collapsible
185
- key={`${item.title}-${open}`}
186
- render={<SidebarMenuItem />}
187
- defaultOpen={open}
188
- className="group/collapsible"
189
- >
182
+ <NavCollapsible key={item.title} autoOpen={forceOpen || childActive}>
190
183
  <CollapsibleTrigger
191
184
  render={<SidebarMenuButton tooltip={item.title} suppressHydrationWarning />}
192
185
  >
@@ -227,7 +220,7 @@ export function AppNavGroupMenu({
227
220
  })}
228
221
  </SidebarMenuSub>
229
222
  </CollapsibleContent>
230
- </Collapsible>
223
+ </NavCollapsible>
231
224
  );
232
225
  })}
233
226
  </SidebarMenu>
@@ -235,6 +228,27 @@ export function AppNavGroupMenu({
235
228
  );
236
229
  }
237
230
 
231
+ /**
232
+ * กลุ่มย่อยที่กางได้: เปิดอัตโนมัติเมื่อ `autoOpen` เปลี่ยนเป็น true/false (ลูก active หรือกำลังค้นหา)
233
+ * แต่ผู้ใช้ยังกาง/หุบเองได้ระหว่างนั้น — ควบคุม state เอง (เดิม remount ทั้ง subtree ด้วย key ทุกครั้งที่ค่าเปลี่ยน)
234
+ */
235
+ function NavCollapsible({ autoOpen, children }: { autoOpen: boolean; children: ReactNode }) {
236
+ const [open, setOpen] = useState(autoOpen);
237
+ useEffect(() => {
238
+ setOpen(autoOpen);
239
+ }, [autoOpen]);
240
+ return (
241
+ <Collapsible
242
+ render={<SidebarMenuItem />}
243
+ open={open}
244
+ onOpenChange={setOpen}
245
+ className="group/collapsible"
246
+ >
247
+ {children}
248
+ </Collapsible>
249
+ );
250
+ }
251
+
238
252
  /** ทุกกลุ่มเมนู */
239
253
  export function AppNav({
240
254
  nav,
@@ -39,19 +39,20 @@ export function DataTableFacetedFilter<TData, TValue>({
39
39
  }: DataTableFacetedFilterProps<TData, TValue>) {
40
40
  const facets = column?.getFacetedUniqueValues();
41
41
  const filterValue = column?.getFilterValue();
42
- const selectedValues = new Set(Array.isArray(filterValue) ? filterValue.map(String) : []);
43
-
44
- const areAllSelected = options.length > 0 && selectedValues.size === options.length;
45
-
46
- const sortedOptions = React.useMemo(() => {
47
- return [...options].sort((a, b) => {
48
- const aSelected = selectedValues.has(String(a.value));
49
- const bSelected = selectedValues.has(String(b.value));
42
+ // Set + ลำดับ memo จากค่า filter จริง (เดิมสร้าง Set ใหม่ทุก render ทำให้ memo ไม่เคย hit)
43
+ const { selectedValues, sortedOptions } = React.useMemo(() => {
44
+ const selected = new Set(Array.isArray(filterValue) ? filterValue.map(String) : []);
45
+ const sorted = [...options].sort((a, b) => {
46
+ const aSelected = selected.has(String(a.value));
47
+ const bSelected = selected.has(String(b.value));
50
48
  if (aSelected && !bSelected) return -1;
51
49
  if (!aSelected && bSelected) return 1;
52
50
  return 0;
53
51
  });
54
- }, [options, selectedValues]);
52
+ return { selectedValues: selected, sortedOptions: sorted };
53
+ }, [options, filterValue]);
54
+
55
+ const areAllSelected = options.length > 0 && selectedValues.size === options.length;
55
56
 
56
57
  return (
57
58
  <Popover>
@@ -117,12 +118,10 @@ export function DataTableFacetedFilter<TData, TValue>({
117
118
  <CommandItem
118
119
  key={valueAsString}
119
120
  onSelect={() => {
120
- if (isSelected) {
121
- selectedValues.delete(valueAsString);
122
- } else {
123
- selectedValues.add(valueAsString);
124
- }
125
- const filterValues = Array.from(selectedValues);
121
+ const next = new Set(selectedValues);
122
+ if (isSelected) next.delete(valueAsString);
123
+ else next.add(valueAsString);
124
+ const filterValues = Array.from(next);
126
125
  column?.setFilterValue(filterValues.length ? filterValues : undefined);
127
126
  }}
128
127
  >
@@ -137,7 +137,8 @@ export function DataTableRowActions<TData>({
137
137
  </DropdownMenuContent>
138
138
  </DropdownMenu>
139
139
 
140
- {EditDialog && (
140
+ {/* mount เฉพาะตอนเปิด — ไม่งั้นทุกแถว mount Dialog+ฟอร์มค้างไว้ (50 แถว = 50 ฟอร์ม) และ initialData stale */}
141
+ {EditDialog && isEditDialogOpen && (
141
142
  <EditDialog initialData={item} open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} />
142
143
  )}
143
144
 
@@ -154,12 +154,9 @@ export function useDataTable<TData, TValue>({
154
154
  return undefined;
155
155
  }, [globalFilterFn, globalFilterKeys]);
156
156
 
157
- const memoizedData = React.useMemo(() => data, [data]);
158
- const memoizedColumns = React.useMemo(() => columns, [columns]);
159
-
160
157
  const table = useReactTable({
161
- data: memoizedData,
162
- columns: memoizedColumns,
158
+ data,
159
+ columns,
163
160
  pageCount: pageCount ?? (manualPagination ? -1 : undefined),
164
161
  rowCount,
165
162
  manualPagination,
@@ -205,7 +202,9 @@ export function useDataTable<TData, TValue>({
205
202
  onGlobalFilterChange: (updater) => {
206
203
  setGlobalFilter(updater);
207
204
  if (onGlobalFilterChange) {
208
- const next = typeof updater === "function" ? updater(globalFilter) : updater;
205
+ // อ่านค่าปัจจุบันจาก table ไม่ใช่ closure ของ render ก่อน (พิมพ์เร็วสองตัวอักษรใน tick เดียวจะหายตัว)
206
+ const current = table.getState().globalFilter as string;
207
+ const next = typeof updater === "function" ? updater(current) : updater;
209
208
  onGlobalFilterChange((next as string) ?? "");
210
209
  }
211
210
  },
@@ -218,15 +217,12 @@ export function useDataTable<TData, TValue>({
218
217
  globalFilterFn: finalGlobalFilterFn,
219
218
  getCoreRowModel: getCoreRowModel(),
220
219
  getFilteredRowModel: getFilteredRowModel(),
221
- getPaginationRowModel: getPaginationRowModel(),
220
+ // server แบ่งหน้าให้แล้ว (manualPagination) → ไม่ต้องตัดซ้ำฝั่ง client
221
+ getPaginationRowModel: manualPagination ? undefined : getPaginationRowModel(),
222
222
  getSortedRowModel: getSortedRowModel(),
223
223
  getFacetedRowModel: getFacetedRowModel(),
224
224
  getFacetedUniqueValues: getFacetedUniqueValuesProp,
225
225
  });
226
226
 
227
- // NOTE: If manualPagination is true, getPaginationRowModel might not be needed?
228
- // TanStack docs say: if manualPagination is true, you don't need getPaginationRowModel IF you only provide the current page data.
229
- // But usage of getPaginationRowModel is safe even with manualPagination as it just slices.
230
-
231
227
  return { table, initialColumnFilters };
232
228
  }
@@ -414,7 +414,7 @@ export function ShowcaseDataDisplay() {
414
414
 
415
415
  <Item variant="outline" className="p-3">
416
416
  <ItemMedia variant="icon">
417
- <AlertTriangle className="size-4 text-amber-500" />
417
+ <AlertTriangle className="size-4 text-warning" />
418
418
  </ItemMedia>
419
419
  <ItemContent>
420
420
  <ItemTitle className="text-xs">หนังสือค้ำประกันผลงาน (Performance Bond)</ItemTitle>
@@ -444,7 +444,7 @@ export function ShowcaseDataDisplay() {
444
444
  <AttachmentGroup>
445
445
  <Attachment state="done" size="default">
446
446
  <AttachmentMedia variant="icon">
447
- <FileSpreadsheet className="size-4 text-emerald-600" />
447
+ <FileSpreadsheet className="size-4 text-success" />
448
448
  </AttachmentMedia>
449
449
  <AttachmentContent>
450
450
  <AttachmentTitle>BOQ_Facade_Rev2.xlsx</AttachmentTitle>
@@ -296,7 +296,7 @@ export function ApprovePackageButton({ id }: { id: string }) {
296
296
  <span className="font-bold">✕ หลีกเลี่ยง:</span>
297
297
  <span className="line-through">{item.bad}</span>
298
298
  </div>
299
- <div className="flex items-start gap-2 text-emerald-600 font-medium">
299
+ <div className="flex items-start gap-2 text-success font-medium">
300
300
  <span className="font-bold">✓ แนะนำ:</span>
301
301
  <span>{item.good}</span>
302
302
  </div>
@@ -252,11 +252,11 @@ export function ShowcaseOverlaysNavigation() {
252
252
  <div className="p-4 flex flex-col gap-2 text-xs">
253
253
  <div className="flex justify-between border-b pb-2">
254
254
  <span>ส่งมอบตรงเวลา (On-Time Delivery)</span>
255
- <span className="font-semibold text-emerald-600">96.5%</span>
255
+ <span className="font-semibold text-success">96.5%</span>
256
256
  </div>
257
257
  <div className="flex justify-between border-b pb-2">
258
258
  <span>มาตรฐานคุณภาพวัสดุ (Quality Pass Rate)</span>
259
- <span className="font-semibold text-emerald-600">98.2%</span>
259
+ <span className="font-semibold text-success">98.2%</span>
260
260
  </div>
261
261
  </div>
262
262
  <DrawerFooter>
@@ -91,7 +91,7 @@ export function ShowcasePrimitives() {
91
91
  label: "Pending Vendor / รอผู้ขาย",
92
92
  variant: "outline",
93
93
  icon: AlertCircle,
94
- className: "border-amber-500/40 text-amber-600 bg-amber-50/50 dark:bg-amber-950/20",
94
+ className: "border-warning/40 text-warning-muted-foreground bg-warning/10",
95
95
  },
96
96
  ];
97
97