@suphark/ui 0.4.3 → 0.5.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 (42) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +25 -0
  3. package/package.json +3 -1
  4. package/src/components/app-shell/app-nav.tsx +1 -1
  5. package/src/components/app-shell/app-shell.tsx +31 -5
  6. package/src/components/app-shell/header-breadcrumbs.tsx +2 -1
  7. package/src/components/app-shell/header-crumb.tsx +1 -1
  8. package/src/components/app-shell/index.ts +1 -1
  9. package/src/components/app-shell/sub-nav.tsx +3 -3
  10. package/src/components/app-shell/top-tabs.tsx +1 -1
  11. package/src/components/app-shell/user-menu.tsx +1 -1
  12. package/src/components/data-table/data-table-column-header.tsx +1 -1
  13. package/src/components/data-table/data-table-row-actions.tsx +1 -3
  14. package/src/components/data-table/use-data-table.ts +4 -1
  15. package/src/components/design-system/design-system-showcase.tsx +1 -1
  16. package/src/components/pwa/install-prompt-store.ts +26 -0
  17. package/src/components/pwa/pull-to-refresh.tsx +147 -0
  18. package/src/components/pwa/pwa-install-card.tsx +165 -0
  19. package/src/components/pwa/pwa-service-worker-registration.tsx +63 -0
  20. package/src/components/pwa/service-worker-updates.ts +76 -0
  21. package/src/components/shared/button/confirm-action-button.tsx +15 -1
  22. package/src/components/shared/button/delete-many-button.tsx +7 -3
  23. package/src/components/shared/button/refresh-button.tsx +2 -1
  24. package/src/components/shared/combobox.tsx +2 -1
  25. package/src/components/shared/confirm-dialog.tsx +1 -0
  26. package/src/components/shared/dialogs-provider.tsx +270 -0
  27. package/src/components/shared/form/form-field-combobox.tsx +1 -1
  28. package/src/components/shared/form/form-field-input.tsx +8 -2
  29. package/src/components/shared/form/form-field-radio-cards.tsx +1 -1
  30. package/src/components/shared/form/form-field-select.tsx +9 -5
  31. package/src/components/shared/layout/error-state.tsx +16 -5
  32. package/src/components/shared/link/transition-link.tsx +2 -0
  33. package/src/components/shared/row-actions.tsx +12 -2
  34. package/src/components/shared/ui-text.tsx +24 -0
  35. package/src/components/ui/relative-date.tsx +39 -9
  36. package/src/hooks/use-tracked-state.ts +35 -0
  37. package/src/index.ts +12 -0
  38. package/src/kanban.ts +9 -0
  39. package/src/lib/formatters.ts +5 -3
  40. package/src/lib/kanban-collision.ts +118 -0
  41. package/src/pwa.ts +24 -0
  42. package/src/styles.css +12 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,52 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.5.1] — 2026-09-20
6
+
7
+ ### Fixed
8
+
9
+ - **`RelativeDate` hydration mismatch (React #418) บน production** — เดิม render "วันนี้/เมื่อวาน" บน server ด้วย "ตอนนี้" ของ server (UTC และ module ที่โหลดไว้ก่อน) ซึ่งต่างจาก browser ได้ทั้งโซนเวลาและข้ามวัน → บน Vercel หน้า `/shell` (ตารางมี RelativeDate) hydrate พังทั้งหน้าและ React render ใหม่ทั้ง tree (เห็น theme script ซ้ำ 2 ตัว) — ตอนนี้คำนวณเฉพาะฝั่ง client หลัง mount (`useSyncExternalStore` guard แบบเดียวกับ `ClientDateFormatter`) ก่อน mount แสดงไอคอน + ช่องว่างขนาดเดิม
10
+ - `RelativeDate`: เอาข้อความเฉพาะ supplychain ("วันที่อ้างอิงราคาจริง") ออกจาก tooltip → prop ใหม่ `titlePrefix` (ค่าเริ่มต้น "วันที่") และ `emptyText`; สีเป็น semantic token (`text-muted-foreground`) แทน `text-slate-*` ตามกฎ design system (className/iconClassName ทับได้เหมือนเดิม)
11
+ - showcase: launch config `showcase-prod` (:3202, `next start` หลัง build) สำหรับตรวจ production build ในเครื่อง
12
+
13
+ ## [0.5.0] — 2026-09-19
14
+
15
+ ยกของที่พิสูจน์แล้วจาก Pi Done (todo/kanban) เข้าแพ็กเกจ — ไม่เปลี่ยน props เดิม
16
+
17
+ ### Added
18
+
19
+ - **`DialogsProvider` + `useDialogs()`** (`@suphark/ui`): กล่องโต้ตอบแบบ promise ทั้งแอปบน AlertDialog — `alert / confirm / prompt / choose / checklist` ครอบครั้งเดียวที่ root แล้วเรียกได้ทุกที่โดยไม่ต้อง render อะไรในผู้เรียก (ต่างจาก `useConfirm()` ที่ยังอยู่เหมือนเดิม); เปิดคำขอใหม่ขณะกล่องเปิด → คำขอเดิม resolve เป็นค่ายกเลิก; `UiText` เพิ่ม optional `ok`, `alertTitle`, `promptTitle`, `chooseTitle`, `checklistTitle` (มีค่าใน `UI_TEXT_TH`/`UI_TEXT_EN`)
20
+ - **`createKanbanCollisionDetection()`** (`@suphark/ui/kanban` — entry แยกเพราะ @dnd-kit/core สร้าง context ตอน import ใช้จาก server component ไม่ได้): collision detection ของ dnd-kit สำหรับบอร์ดหลายคอลัมน์ — pointer ในช่องว่างระหว่างการ์ด/ใต้การ์ดสุดท้ายแปลงเป็น "การ์ดที่ใกล้ที่สุดในคอลัมน์" (ไม่นับการ์ดที่ลาก) แทนการรายงานคอลัมน์ซึ่งทำให้ dnd-kit ล้าง transform (การ์ดเด้งกลับ, drop ไปท้ายคอลัมน์); คอลัมน์ว่างรายงาน droppable ของรายการ; รองรับ droppable อื่นของคอลัมน์ผ่าน `resolveColumnId` (เช่น handle ลากเรียงคอลัมน์)
21
+ - **`useTrackedState()`** (`@suphark/ui`): `useState` + `getLatest()` ที่อัปเดต synchronous — mutation ติดกันใน tick เดียว (drag-drop, automation, bulk) อ่านค่าล่าสุดแทน closure ตอน render
22
+ - **`@suphark/ui/pwa`** (entry ใหม่): `PwaServiceWorkerRegistration` (ลงทะเบียน SW + เก็บ `beforeinstallprompt` + `watchServiceWorkerUpdates`: เช็คอัปเดตตอนโหลด/กลับมาที่แท็บ/ทุก 15 นาที แล้ว reload ครั้งเดียวเมื่อ worker ใหม่คุมหน้า เลื่อนขณะพิมพ์ ไม่ reload ตอนติดตั้งครั้งแรก), `PwaInstallCard` (`appName`, `text` ค่าเริ่มต้นไทย + `PWA_INSTALL_CARD_TEXT_EN`), `PullToRefresh` (มือถือ, `router.refresh()`, `PULL_TO_REFRESH_TEXT_EN`), install-prompt store; `PullToRefresh` ใช้ next/navigation จึงอยู่ entry นี้ไม่ใช่ barrel หลัก
23
+ - showcase: หน้า `dialogs`, `kanban-board` (บอร์ด 3 คอลัมน์ลากได้จริง), `pwa-install-card`
24
+
25
+ ## [0.4.4] — 2026-09-19
26
+
27
+ รอบแก้บั๊กจาก code review ทั้ง codebase + audit Web Interface Guidelines — ไม่เปลี่ยน props เดิม
28
+
29
+ ### Fixed
30
+
31
+ - **`DeleteManyButton`**: `AlertDialogAction` ของ Base UI เป็นปุ่มธรรมดา (ไม่ใช่ Close) → ลบสำเร็จแล้วกล่องค้างเปิด กด Delete ซ้ำได้ด้วย ids ที่ลบไปแล้ว — คุม `open` เอง ปิดหลังสำเร็จ + `table.resetRowSelection()`; ปิดไม่ได้ระหว่าง pending
32
+ - **`useDataTable`**: effect sync `initialPageSize` ทับ `pagination.pageSize` ที่ควบคุมจาก prop/URL ตอน mount (URL=50 แต่ตารางกลายเป็น 25 และตัดแถวที่ server ส่งมา) — ข้ามเมื่อ `pagination` ถูกส่งมา (controlled)
33
+ - **`FormFieldSelect`**: `onValueChange` ไม่ถูกเรียกเลยเมื่อไม่ส่ง `variant` (ค่าเริ่มต้น select)
34
+ - **`FormFieldInput isNumeric`**: แปลงเป็น number ทันทีทำให้ศูนย์ท้ายทศนิยมหายขณะพิมพ์ ("1.0" → "1" พิมพ์ 1.05 ไม่ได้) — คงเป็น string จนทศนิยมจบ
35
+ - **`ErrorState`**: hardcode `/api/auth/get-session` + `/sign-in` ของ supplychain → แอปอื่นได้ 404 แล้วถูกตีความว่า "เซสชันหมดอายุ" พร้อมลิงก์ที่ไม่มี — ตรวจเซสชันเฉพาะเมื่อส่ง prop ใหม่ `sessionCheckUrl` (และ `signInHref` ค่าเริ่มต้น `/sign-in`); response ที่ไม่ใช่ 401/ok ไม่ถือว่าหมดอายุ (supplychain ส่ง `sessionCheckUrl="/api/auth/get-session"` เมื่อย้ายมาใช้)
36
+ - **`formatRelativeThaiDate`**: `Math.abs` ทำให้วันพรุ่งนี้เป็น "เมื่อวาน" — ใช้ `differenceInCalendarDays` คงเครื่องหมาย เพิ่ม "พรุ่งนี้"; ไกลกว่านั้น date-fns บอกทิศทางเอง
37
+ - **data attribute ของ Radix ที่หลงมาบน Base UI** (ไม่เคยทำงาน): `AppNav` ลูกศรกลุ่มเมนูไม่หมุน (`data-[state=open]` → `data-open`), `FormFieldRadioCards` การ์ดที่เลือกไม่ไฮไลต์ (`peer-data-[state=checked]` → `peer-data-checked`), trigger เปิดเมนูไม่มีพื้นหลัง (`UserMenu`, `DataTableColumnHeader`, `DataTableRowActions`, `HeaderCrumb` → `data-popup-open`), แท็บ `DesignSystemShowcase` ไม่ไฮไลต์ (`data-[state=active]` → `data-active`)
38
+ - **`ConfirmActionButton` / `RowActions`**: action โยน error → เด้ง error boundary ทั้งหน้าและ dialog ค้าง — จับแล้ว `toast.error(message)` (ข้อความจาก `UiText.actionFailed` optional) คง dialog ไว้ให้ลองใหม่
39
+ - **`HeaderBreadcrumbs hideIds`**: regex `[0-9a-z]{16,}` ซ่อน segment คำธรรมดายาว ≥16 (เช่น `organizationmembers`) — ต้องมีตัวเลขปนจึงถือเป็น id
40
+ - **`RowActions` รายการ `href`** render เป็น `<Link>` จริง (Ctrl/⌘+click, คลิกกลาง, เปิดแท็บใหม่ได้) ยกเว้นมี `confirm`; **`TransitionLink`** ไม่ดัก modifier keys/คลิกกลางแล้ว
41
+ - **`RefreshButton`**: `{...props}` ทับ `isLoading` ที่รวม pending ของ `router.refresh()` เมื่อผู้เรียกส่ง `isLoading={false}`
42
+ - **cmdk value ชนกันเมื่อ label ซ้ำ** (`Combobox`, `FormFieldCombobox`, `FormFieldSelect variant="combobox"`, `FormFieldInput variant="autocomplete"`): ต่อ `value` ท้าย label ให้ระบุรายการได้ (ค้นด้วยรหัสได้ด้วย)
43
+
44
+ ### Added (Web Interface Guidelines)
45
+
46
+ - **`AppShell`**: กล่องเนื้อหาเป็น `<main id="app-main" tabIndex={-1}>` (export `APP_MAIN_ID`) + skip link "ข้ามไปเนื้อหา" (มองเห็นเมื่อกด Tab ตัวแรก) ทั้ง layout sidebar และ top
47
+ - `styles.css`: `@media (prefers-reduced-motion: reduce)` ตัด animation/transition ทุก component (tw-animate, animate-spin, dialog zoom)
48
+ - `SubNav`/`TopTabs`: `focus-visible:ring-2` ให้เท่ากับ HeaderCrumb; `<select>` ของ SubNav ใส่ `text-foreground` (Windows dark mode); `ConfirmDialog` ปุ่มยืนยัน `aria-busy` ตอน pending; ข้อความ "ไม่พบ" ใช้ curly quotes
49
+ - showcase: `/login` กัน `next=//host` (protocol-relative) ; `LayoutSwitch` ป้ายเป็น `sr-only` บนจอแคบ; demo `form-fields` เพิ่มช่อง isNumeric + `onValueChange`
50
+
5
51
  ## [0.4.3] — 2026-09-19
6
52
 
7
53
  ### Added
package/README.md CHANGED
@@ -67,6 +67,12 @@ import { AppShell, UserMenu, filterNav, FormDialog, RowActions, LinkButton, Refr
67
67
  // ตารางข้อมูล (ต้องติดตั้ง @tanstack/react-table เอง)
68
68
  import { DataTable, DataTableColumnHeader, useDataTable } from "@suphark/ui/data-table";
69
69
 
70
+ // PWA (PullToRefresh ใช้ next/navigation)
71
+ import { PwaServiceWorkerRegistration, PwaInstallCard, PullToRefresh } from "@suphark/ui/pwa";
72
+
73
+ // บอร์ด Kanban บน dnd-kit (แยก entry เหมือน data-table)
74
+ import { createKanbanCollisionDetection } from "@suphark/ui/kanban";
75
+
70
76
  // import ตรงรายไฟล์ก็ได้ (เช่น chart ที่ต้องมี recharts)
71
77
  import { ChartContainer } from "@suphark/ui/components/ui/chart";
72
78
  ```
@@ -78,6 +84,7 @@ import { ChartContainer } from "@suphark/ui/components/ui/chart";
78
84
  | `src/components/ui/*` | shadcn/ui ต้นฉบับ 59 ตัว (+ `__tests__`) | **ห้ามแก้มือ** — เพิ่ม/อัปเดตด้วย `pnpm dlx shadcn@latest add <name>` ใน `packages/ui` (`components.json` ตั้ง alias ไว้แล้ว) |
79
85
  | `src/components/shared/*` | ของที่เราประกอบเอง: ปุ่มมาตรฐาน (`button/`), form-field บน react-hook-form (`form/`), layout, theme, combobox, และชุดจาก Pi Auth (FormDialog, NameIdFields, StatusBadge, RowMenu, …) | แก้ได้ แต่คง props เดิมให้มากที่สุด (minor = เพิ่ม, major = เปลี่ยน) |
80
86
  | `src/components/data-table/*` | DataTable บน tanstack (toolbar, faceted filter, pagination, view options, row actions, ลากเรียงแถว) | entry `@suphark/ui/data-table` |
87
+ | `src/components/pwa/*` | PWA: ลงทะเบียน/อัปเดต service worker, การ์ดชวนติดตั้ง, ดึงลงเพื่อรีเฟรช | entry `@suphark/ui/pwa` |
81
88
  | `src/components/app-shell/*` | AppShell (sidebar + header + เนื้อหา), AppNav, AppBrand, UserMenu, HeaderBreadcrumbs, NavLink, filterNav/searchNav | export ผ่าน `@suphark/ui/next` (ใช้ next/link, next/navigation) |
82
89
  | `src/components/design-system/*` | หน้า showcase (render ใน `apps/showcase`) | ไม่อยู่ใน barrel |
83
90
  | `src/lib/*`, `src/hooks/*` | `cn`, formatter วันที่ไทย, สีสถานะ, permission context, `useIsMobile`, `useCopyToClipboard` | |
@@ -157,6 +164,24 @@ webview/automation บางตัวปิด `window.confirm` ทิ้ง (
157
164
 
158
165
  ข้อความค่าเริ่มต้น (หัว/ปุ่ม) มาจาก `UiText.confirmTitle` / `UiText.confirm`
159
166
 
167
+ ต้องการ `alert` / `prompt` / `choose` (หลายปุ่ม) / `checklist` ด้วย หรือไม่อยาก render `confirmDialog` ทุกที่: ครอบ `<DialogsProvider>` ที่ root แล้ว `const dialogs = useDialogs()` — `await dialogs.prompt({ description: "ชื่อใหม่", defaultValue })` คืน string หรือ null; หัว/ปุ่มค่าเริ่มต้นจาก `UiText.ok/alertTitle/promptTitle/chooseTitle/checklistTitle`
168
+
169
+ ### PWA (`@suphark/ui/pwa`)
170
+
171
+ ```tsx
172
+ // layout ("use client" หรือ component ลูก) — ครั้งเดียว
173
+ <PwaServiceWorkerRegistration url="/sw.js" />
174
+ // ที่ไหนก็ได้
175
+ <PwaInstallCard appName="Pi Done" text={PWA_INSTALL_CARD_TEXT_EN} />
176
+ <PullToRefresh /> // + data-pull-to-refresh-root บน element ที่เลื่อนได้
177
+ ```
178
+
179
+ `PwaServiceWorkerRegistration` เช็คอัปเดตตอนโหลด / กลับมาที่แท็บ / ทุก 15 นาที และ reload หน้าครั้งเดียวเมื่อ worker ใหม่คุมหน้า (เลื่อนขณะผู้ใช้กำลังพิมพ์, ไม่ reload ตอนติดตั้งครั้งแรก) — ฝั่ง worker ของแอปต้อง: ฝัง deployment id ใน script (เช่น `process.env.VERCEL_DEPLOYMENT_ID`) ให้ byte เปลี่ยนทุก deploy, `self.skipWaiting()` ใน `install` โดยไม่ขึ้นกับผล precache, `self.clients.claim()` ใน `activate`, และเสิร์ฟ script ด้วย `Cache-Control: no-cache`
180
+
181
+ ### Kanban (dnd-kit)
182
+
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
+
160
185
  ### สิทธิ์ (ActionButton/LinkButton `permission` prop)
161
186
 
162
187
  แพ็กเกจไม่รู้จักระบบสิทธิ์ของแอป — ครอบด้วย `UiPermissionProvider` แล้วส่งฟังก์ชัน `check(requirement, options)` ของแอปเข้ามา ถ้าไม่ครอบ = อนุญาตทุกอย่าง
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.4.3",
3
+ "version": "0.5.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 เอง",
@@ -39,6 +39,8 @@
39
39
  ".": "./src/index.ts",
40
40
  "./next": "./src/next.ts",
41
41
  "./data-table": "./src/data-table.ts",
42
+ "./pwa": "./src/pwa.ts",
43
+ "./kanban": "./src/kanban.ts",
42
44
  "./styles.css": "./src/styles.css",
43
45
  "./components/shared/button": "./src/components/shared/button/index.ts",
44
46
  "./components/shared/theme": "./src/components/shared/theme/index.ts",
@@ -192,7 +192,7 @@ export function AppNavGroupMenu({
192
192
  >
193
193
  {item.icon}
194
194
  <MenuTitle title={item.title} isLabs={item.isLabs} />
195
- <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
195
+ <ChevronRight className="ml-auto transition-transform duration-200 group-data-open/collapsible:rotate-90" />
196
196
  </CollapsibleTrigger>
197
197
  <CollapsibleContent suppressHydrationWarning>
198
198
  <SidebarMenuSub>
@@ -114,6 +114,21 @@ export function AppShell(props: AppShellProps) {
114
114
  return <SidebarShell {...props} />;
115
115
  }
116
116
 
117
+ /** id ของ <main> ที่ AppShell สร้าง — ใช้อ้างจาก skip link / anchor */
118
+ export const APP_MAIN_ID = "app-main";
119
+
120
+ /** ลิงก์ "ข้ามไปเนื้อหา" สำหรับคีย์บอร์ด/screen reader — มองไม่เห็นจนกด Tab ตัวแรก */
121
+ function SkipToMain() {
122
+ return (
123
+ <a
124
+ href={`#${APP_MAIN_ID}`}
125
+ className="sr-only z-[60] rounded-md bg-background px-3 py-2 text-sm font-medium ring-2 ring-ring focus:not-sr-only focus:fixed focus:top-2 focus:left-2"
126
+ >
127
+ ข้ามไปเนื้อหา
128
+ </a>
129
+ );
130
+ }
131
+
117
132
  function SidebarShell({
118
133
  brand,
119
134
  nav,
@@ -152,6 +167,7 @@ function SidebarShell({
152
167
  return (
153
168
  // base-ui-ok: defaultOpen อ่านจาก cookie ครั้งเดียวตอน mount — หลังจากนั้น Sidebar จัดการ state เอง (ตั้งใจให้ uncontrolled)
154
169
  <SidebarProvider defaultOpen={defaultOpen} style={style}>
170
+ <SkipToMain />
155
171
  <ShellSidebar
156
172
  brand={brand}
157
173
  nav={nav}
@@ -200,15 +216,17 @@ function SidebarShell({
200
216
  </header>
201
217
  </div>
202
218
  )}
203
- <div
219
+ <main
220
+ id={APP_MAIN_ID}
221
+ tabIndex={-1}
204
222
  className={cn(
205
- "flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6 print:overflow-visible",
223
+ "flex flex-1 flex-col gap-4 p-4 outline-none md:gap-6 md:p-6 print:overflow-visible",
206
224
  contentMaxWidth && cn("mx-auto w-full", contentMaxWidth),
207
225
  contentClassName,
208
226
  )}
209
227
  >
210
228
  {children}
211
- </div>
229
+ </main>
212
230
  </SidebarInset>
213
231
  </SidebarProvider>
214
232
  );
@@ -262,6 +280,7 @@ function TopShell({
262
280
 
263
281
  return (
264
282
  <div style={style} className="flex min-h-svh flex-col bg-background">
283
+ <SkipToMain />
265
284
  {header !== false && (
266
285
  <div className="sticky top-0 z-40 bg-background print:static">
267
286
  {headerOpts.banner}
@@ -301,10 +320,17 @@ function TopShell({
301
320
  searchPlaceholder={searchPlaceholder}
302
321
  />
303
322
  )}
304
- <div className={cn("flex min-w-0 flex-1 flex-col gap-4 md:gap-6", contentClassName)}>
323
+ <main
324
+ id={APP_MAIN_ID}
325
+ tabIndex={-1}
326
+ className={cn(
327
+ "flex min-w-0 flex-1 flex-col gap-4 outline-none md:gap-6",
328
+ contentClassName,
329
+ )}
330
+ >
305
331
  {breadcrumbs}
306
332
  {children}
307
- </div>
333
+ </main>
308
334
  </div>
309
335
  </div>
310
336
  );
@@ -30,7 +30,8 @@ export interface HeaderBreadcrumbsProps {
30
30
  hideIds?: boolean;
31
31
  }
32
32
 
33
- const ID_LIKE = /^(\d+|[0-9a-f]{8}-[0-9a-f-]{27}|c[a-z0-9]{20,}|[0-9a-z]{16,})$/i;
33
+ // ตัวเลขล้วน / uuid / cuid / ตัวอักษร+ตัวเลขปน ≥16 ตัว (ต้องมีตัวเลขอย่างน้อย 1 — คำธรรมดายาว ๆ ไม่ใช่ id)
34
+ const ID_LIKE = /^(\d+|[0-9a-f]{8}-[0-9a-f-]{27}|c[a-z0-9]{20,}|(?=[a-z]*\d)[0-9a-z]{16,})$/i;
34
35
 
35
36
  function labelOf(segment: string, href: string, labels?: HeaderBreadcrumbsProps["labels"]) {
36
37
  const known = typeof labels === "function" ? labels(segment, href) : labels?.[segment];
@@ -34,7 +34,7 @@ export function HeaderCrumb({
34
34
  <button
35
35
  type="button"
36
36
  className={cn(
37
- "inline-flex h-8 max-w-56 items-center gap-1.5 rounded-md px-2 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-[popup-open]:bg-accent data-[state=open]:bg-accent [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0",
37
+ "inline-flex h-8 max-w-56 items-center gap-1.5 rounded-md px-2 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-popup-open:bg-accent [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0",
38
38
  className,
39
39
  )}
40
40
  {...props}
@@ -1,6 +1,6 @@
1
1
  export { AppBrand, type AppBrandProps } from "./app-brand";
2
2
  export { AppNav, AppNavGroupMenu, type AppNavPinProps } from "./app-nav";
3
- export { AppShell, type AppShellProps } from "./app-shell";
3
+ export { APP_MAIN_ID, AppShell, type AppShellProps } from "./app-shell";
4
4
  export { HeaderBreadcrumbs, type HeaderBreadcrumbsProps } from "./header-breadcrumbs";
5
5
  export { HeaderCrumb, HeaderCrumbs } from "./header-crumb";
6
6
  export { NavLink } from "./nav-link";
@@ -63,7 +63,7 @@ export function SubNav({
63
63
  <label className="flex flex-col gap-1 lg:hidden">
64
64
  {title && <span className="text-muted-foreground text-xs">{title}</span>}
65
65
  <select
66
- className="h-9 rounded-md border bg-background px-2 text-sm"
66
+ className="h-9 rounded-md border bg-background px-2 text-foreground text-sm"
67
67
  value={current?.href ?? ""}
68
68
  onChange={(e) => e.target.value && router.push(e.target.value)}
69
69
  aria-label={typeof title === "string" ? title : "เมนูย่อย"}
@@ -120,7 +120,7 @@ export function SubNav({
120
120
  href={i.href}
121
121
  aria-current={active ? "page" : undefined}
122
122
  className={cn(
123
- "flex items-center gap-2 rounded-md px-2 py-1.5 text-sm no-underline transition-colors [&_svg:not([class*='size-'])]:size-4",
123
+ "flex items-center gap-2 rounded-md px-2 py-1.5 text-sm no-underline outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 [&_svg:not([class*='size-'])]:size-4",
124
124
  active
125
125
  ? "bg-accent font-medium text-accent-foreground"
126
126
  : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
@@ -139,7 +139,7 @@ export function SubNav({
139
139
  </div>
140
140
  ))}
141
141
  {sections.length === 0 && (
142
- <p className="px-2 text-muted-foreground text-xs">ไม่พบ "{query}"</p>
142
+ <p className="px-2 text-muted-foreground text-xs">ไม่พบ “{query}”</p>
143
143
  )}
144
144
  </nav>
145
145
  </>
@@ -47,7 +47,7 @@ export function TopTabs({ nav, className }: { nav: AppNavGroup[]; className?: st
47
47
  href={tab.href}
48
48
  aria-current={active ? "page" : undefined}
49
49
  className={cn(
50
- "flex shrink-0 items-center gap-1.5 border-b-2 px-3 py-2.5 text-sm no-underline transition-colors [&_svg:not([class*='size-'])]:size-4",
50
+ "flex shrink-0 items-center gap-1.5 rounded-t-md border-b-2 px-3 py-2.5 text-sm no-underline outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 [&_svg:not([class*='size-'])]:size-4",
51
51
  active
52
52
  ? "border-foreground font-medium text-foreground"
53
53
  : "border-transparent text-muted-foreground hover:border-border hover:text-foreground",
@@ -65,7 +65,7 @@ export function UserMenu({
65
65
  className={cn(
66
66
  variant === "sidebar"
67
67
  ? "h-auto w-full justify-start gap-2.5 px-2 py-2 text-left group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:p-0"
68
- : "h-9 min-w-0 gap-2 px-1.5 data-[state=open]:bg-accent sm:px-2",
68
+ : "h-9 min-w-0 gap-2 px-1.5 data-popup-open:bg-accent sm:px-2",
69
69
  className,
70
70
  )}
71
71
  />
@@ -31,7 +31,7 @@ export function DataTableColumnHeader<TData, TValue>({
31
31
  <DropdownMenu>
32
32
  <DropdownMenuTrigger
33
33
  render={
34
- <Button variant="ghost" size="sm" className="-ml-3 h-8 data-[state=open]:bg-accent" />
34
+ <Button variant="ghost" size="sm" className="-ml-3 h-8 data-popup-open:bg-accent" />
35
35
  }
36
36
  >
37
37
  <span>{title}</span>
@@ -105,9 +105,7 @@ export function DataTableRowActions<TData>({
105
105
  <div className="flex justify-end">
106
106
  <DropdownMenu>
107
107
  <DropdownMenuTrigger
108
- render={
109
- <Button variant="ghost" className="flex h-8 w-8 p-0 data-[state=open]:bg-muted" />
110
- }
108
+ render={<Button variant="ghost" className="flex h-8 w-8 p-0 data-popup-open:bg-muted" />}
111
109
  >
112
110
  <Ellipsis className="h-4 w-4" />
113
111
  <span className="sr-only">Open menu</span>
@@ -115,7 +115,10 @@ export function useDataTable<TData, TValue>({
115
115
  }, [pageIndexProp, pageSizeProp]);
116
116
 
117
117
  // Sync initialPageSize when it loads asynchronously (e.g. from user preferences)
118
+ // เฉพาะตอน pagination ไม่ได้ถูกควบคุมจาก prop — ไม่งั้นจะทับ pageSize จาก URL ตอน mount
119
+ const paginationControlled = paginationProp !== undefined;
118
120
  React.useEffect(() => {
121
+ if (paginationControlled) return;
119
122
  setPagination((prev) => {
120
123
  // Only update if the size actually changed
121
124
  if (prev.pageSize !== initialPageSize) {
@@ -123,7 +126,7 @@ export function useDataTable<TData, TValue>({
123
126
  }
124
127
  return prev;
125
128
  });
126
- }, [initialPageSize]);
129
+ }, [initialPageSize, paginationControlled]);
127
130
 
128
131
  const finalGlobalFilterFn = React.useMemo(() => {
129
132
  if (globalFilterFn) {
@@ -87,7 +87,7 @@ export function DesignSystemShowcase({
87
87
  <TabsTrigger
88
88
  key={tab.value}
89
89
  value={tab.value}
90
- className="gap-2 px-3 py-2 text-sm data-[state=active]:bg-card data-[state=active]:shadow-xs"
90
+ className="gap-2 px-3 py-2 text-sm data-active:bg-card data-active:shadow-xs"
91
91
  >
92
92
  <Icon className="size-4" />
93
93
  {tab.label}
@@ -0,0 +1,26 @@
1
+ /** เก็บ `beforeinstallprompt` ไว้ใช้ทีหลัง (browser ยิงครั้งเดียวตอนโหลด ก่อน UI ที่จะแสดงปุ่มติดตั้งจะ mount) */
2
+ export type BeforeInstallPromptEvent = Event & {
3
+ prompt: () => Promise<void>;
4
+ userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
5
+ };
6
+
7
+ let installPrompt: BeforeInstallPromptEvent | null = null;
8
+ const listeners = new Set<(event: BeforeInstallPromptEvent | null) => void>();
9
+
10
+ export function getInstallPrompt() {
11
+ return installPrompt;
12
+ }
13
+
14
+ export function setInstallPrompt(event: BeforeInstallPromptEvent | null) {
15
+ installPrompt = event;
16
+ for (const listener of listeners) listener(installPrompt);
17
+ }
18
+
19
+ export function subscribeToInstallPrompt(
20
+ listener: (event: BeforeInstallPromptEvent | null) => void,
21
+ ) {
22
+ listeners.add(listener);
23
+ return () => {
24
+ listeners.delete(listener);
25
+ };
26
+ }
@@ -0,0 +1,147 @@
1
+ "use client";
2
+
3
+ import { cn } from "@suphark/ui/lib/utils";
4
+ import { Loader2, RefreshCw } from "lucide-react";
5
+ import { useRouter } from "next/navigation";
6
+ import { useEffect, useRef, useState } from "react";
7
+
8
+ const ACTIVATION_DISTANCE = 72;
9
+ const MAX_PULL_DISTANCE = 112;
10
+ const MOBILE_BREAKPOINT = 768;
11
+
12
+ export interface PullToRefreshText {
13
+ pull: string;
14
+ release: string;
15
+ refreshing: string;
16
+ }
17
+
18
+ export const PULL_TO_REFRESH_TEXT_TH: PullToRefreshText = {
19
+ pull: "ดึงลงเพื่อรีเฟรช",
20
+ release: "ปล่อยเพื่อรีเฟรช",
21
+ refreshing: "กำลังรีเฟรช",
22
+ };
23
+
24
+ export const PULL_TO_REFRESH_TEXT_EN: PullToRefreshText = {
25
+ pull: "Pull to refresh",
26
+ release: "Release to refresh",
27
+ refreshing: "Refreshing",
28
+ };
29
+
30
+ /**
31
+ * ดึงลงเพื่อรีเฟรช (มือถือ, < 768px) — เรียก `router.refresh()` เมื่อดึงเกินระยะ
32
+ * ใส่ `data-pull-to-refresh-root` บน element ที่เลื่อนได้ของหน้า (ดึงได้เมื่อเลื่อนอยู่บนสุดเท่านั้น)
33
+ *
34
+ * <main data-pull-to-refresh-root className="overflow-y-auto">…</main>
35
+ * <PullToRefresh />
36
+ */
37
+ export function PullToRefresh({
38
+ text = PULL_TO_REFRESH_TEXT_TH,
39
+ rootSelector = "[data-pull-to-refresh-root]",
40
+ }: {
41
+ text?: PullToRefreshText;
42
+ rootSelector?: string;
43
+ }) {
44
+ const router = useRouter();
45
+ const startRef = useRef<{ x: number; y: number } | null>(null);
46
+ const isPullingRef = useRef(false);
47
+ const [pullDistance, setPullDistance] = useState(0);
48
+ const [isRefreshing, setIsRefreshing] = useState(false);
49
+
50
+ useEffect(() => {
51
+ const isMobileViewport = () => window.innerWidth < MOBILE_BREAKPOINT;
52
+ const getRoot = () => document.querySelector<HTMLElement>(rootSelector);
53
+ const isRootAtTop = (root: HTMLElement) => root.scrollTop <= 0;
54
+
55
+ const resetPull = () => {
56
+ startRef.current = null;
57
+ isPullingRef.current = false;
58
+ setPullDistance(0);
59
+ };
60
+
61
+ const handleTouchStart = (event: TouchEvent) => {
62
+ const touch = event.touches[0];
63
+ if (!isMobileViewport() || isRefreshing || event.touches.length !== 1 || !touch) {
64
+ resetPull();
65
+ return;
66
+ }
67
+ const root = getRoot();
68
+ if (!root || !isRootAtTop(root)) {
69
+ resetPull();
70
+ return;
71
+ }
72
+ startRef.current = { x: touch.clientX, y: touch.clientY };
73
+ isPullingRef.current = false;
74
+ };
75
+
76
+ const handleTouchMove = (event: TouchEvent) => {
77
+ const start = startRef.current;
78
+ const touch = event.touches[0];
79
+ if (!start || isRefreshing || event.touches.length !== 1 || !touch) return;
80
+
81
+ const root = getRoot();
82
+ if (!root || !isRootAtTop(root)) {
83
+ resetPull();
84
+ return;
85
+ }
86
+
87
+ const deltaX = touch.clientX - start.x;
88
+ const deltaY = touch.clientY - start.y;
89
+ if (deltaY <= 0) {
90
+ resetPull();
91
+ return;
92
+ }
93
+ if (!isPullingRef.current) {
94
+ if (Math.abs(deltaX) > deltaY || deltaY < 10) return;
95
+ isPullingRef.current = true;
96
+ }
97
+ event.preventDefault();
98
+ setPullDistance(Math.min(MAX_PULL_DISTANCE, deltaY * 0.62));
99
+ };
100
+
101
+ const handleTouchEnd = () => {
102
+ const shouldRefresh = pullDistance >= ACTIVATION_DISTANCE;
103
+ resetPull();
104
+ if (!shouldRefresh || isRefreshing) return;
105
+ setIsRefreshing(true);
106
+ router.refresh();
107
+ window.setTimeout(() => setIsRefreshing(false), 700);
108
+ };
109
+
110
+ window.addEventListener("touchstart", handleTouchStart, { passive: true });
111
+ window.addEventListener("touchmove", handleTouchMove, { passive: false });
112
+ window.addEventListener("touchend", handleTouchEnd, { passive: true });
113
+ window.addEventListener("touchcancel", resetPull, { passive: true });
114
+ return () => {
115
+ window.removeEventListener("touchstart", handleTouchStart);
116
+ window.removeEventListener("touchmove", handleTouchMove);
117
+ window.removeEventListener("touchend", handleTouchEnd);
118
+ window.removeEventListener("touchcancel", resetPull);
119
+ };
120
+ }, [isRefreshing, pullDistance, router, rootSelector]);
121
+
122
+ const isVisible = pullDistance > 0 || isRefreshing;
123
+ const progress = Math.min(1, pullDistance / ACTIVATION_DISTANCE);
124
+ const label = isRefreshing ? text.refreshing : progress >= 1 ? text.release : text.pull;
125
+
126
+ return (
127
+ <div
128
+ aria-live="polite"
129
+ className={cn(
130
+ "pointer-events-none fixed top-3 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-full border bg-card/95 px-3.5 py-2 text-xs font-medium text-foreground shadow-lg backdrop-blur-md transition-all duration-150 md:hidden",
131
+ isVisible ? "translate-y-0 opacity-100" : "-translate-y-8 opacity-0",
132
+ )}
133
+ style={{ transform: `translate(-50%, ${isRefreshing ? 0 : progress * 18}px)` }}
134
+ >
135
+ {isRefreshing ? (
136
+ <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
137
+ ) : (
138
+ <RefreshCw
139
+ className="size-3.5 transition-transform"
140
+ style={{ transform: `rotate(${progress * 180}deg)` }}
141
+ aria-hidden="true"
142
+ />
143
+ )}
144
+ <span>{label}</span>
145
+ </div>
146
+ );
147
+ }