@suphark/ui 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +14 -13
  3. package/scripts/audit.mjs +0 -0
  4. package/src/components/app-shell/app-shell.tsx +15 -9
  5. package/src/components/app-shell/index.ts +1 -0
  6. package/src/components/app-shell/mobile-tab-bar.tsx +104 -0
  7. package/src/components/shared/action-menu.tsx +155 -0
  8. package/src/components/shared/confirm-dialog.tsx +41 -2
  9. package/src/components/shared/count-badge.tsx +41 -0
  10. package/src/components/shared/filter-bar.tsx +108 -0
  11. package/src/components/shared/form/slider-field.tsx +79 -0
  12. package/src/components/shared/form-dialog.tsx +43 -4
  13. package/src/components/shared/month-stepper.tsx +124 -0
  14. package/src/components/shared/save-status.tsx +99 -0
  15. package/src/components/shared/segmented-control.tsx +73 -0
  16. package/src/components/shared/settings-layout.tsx +170 -0
  17. package/src/components/shared/settings-row.tsx +118 -0
  18. package/src/components/shared/setup-checklist.tsx +104 -0
  19. package/src/components/shared/simple-dialog.tsx +172 -0
  20. package/src/components/shared/simple-pagination.tsx +109 -0
  21. package/src/components/shared/simple-table.tsx +93 -0
  22. package/src/components/shared/simple-tabs.tsx +118 -0
  23. package/src/components/shared/simple-tooltip.tsx +87 -0
  24. package/src/components/shared/skip-link.tsx +30 -0
  25. package/src/components/shared/stat.tsx +110 -6
  26. package/src/components/shared/ui-text.tsx +35 -0
  27. package/src/components/shared/user-identity.tsx +61 -0
  28. package/src/components/ui/accordion.tsx +2 -2
  29. package/src/components/ui/alert-dialog.tsx +4 -4
  30. package/src/components/ui/alert.tsx +2 -2
  31. package/src/components/ui/attachment.tsx +9 -9
  32. package/src/components/ui/avatar.tsx +4 -4
  33. package/src/components/ui/badge.tsx +1 -1
  34. package/src/components/ui/breadcrumb.tsx +1 -1
  35. package/src/components/ui/bubble.tsx +123 -0
  36. package/src/components/ui/button-group.tsx +2 -2
  37. package/src/components/ui/button.tsx +6 -6
  38. package/src/components/ui/calendar.tsx +9 -9
  39. package/src/components/ui/card.tsx +3 -3
  40. package/src/components/ui/chart.tsx +2 -2
  41. package/src/components/ui/checkbox.tsx +1 -1
  42. package/src/components/ui/combobox.tsx +6 -6
  43. package/src/components/ui/command.tsx +4 -4
  44. package/src/components/ui/context-menu.tsx +7 -7
  45. package/src/components/ui/dialog.tsx +4 -4
  46. package/src/components/ui/direction.tsx +6 -0
  47. package/src/components/ui/drawer.tsx +10 -10
  48. package/src/components/ui/dropdown-menu.tsx +8 -8
  49. package/src/components/ui/empty.tsx +4 -4
  50. package/src/components/ui/field.tsx +8 -8
  51. package/src/components/ui/hover-card.tsx +1 -2
  52. package/src/components/ui/input-group.tsx +3 -3
  53. package/src/components/ui/input-otp.tsx +1 -1
  54. package/src/components/ui/input.tsx +1 -1
  55. package/src/components/ui/item.tsx +4 -4
  56. package/src/components/ui/kbd.tsx +1 -1
  57. package/src/components/ui/label.tsx +1 -1
  58. package/src/components/ui/marker.tsx +67 -0
  59. package/src/components/ui/menubar.tsx +9 -9
  60. package/src/components/ui/message-scroller.tsx +129 -0
  61. package/src/components/ui/message.tsx +84 -0
  62. package/src/components/ui/native-select.tsx +1 -1
  63. package/src/components/ui/navigation-menu.tsx +6 -6
  64. package/src/components/ui/popover.tsx +1 -1
  65. package/src/components/ui/progress.tsx +2 -3
  66. package/src/components/ui/questionnaire.tsx +314 -0
  67. package/src/components/ui/radio-group.tsx +1 -2
  68. package/src/components/ui/resizable.tsx +1 -1
  69. package/src/components/ui/select.tsx +4 -4
  70. package/src/components/ui/separator.tsx +0 -1
  71. package/src/components/ui/sheet.tsx +3 -3
  72. package/src/components/ui/sidebar.tsx +14 -14
  73. package/src/components/ui/switch.tsx +2 -3
  74. package/src/components/ui/textarea.tsx +1 -1
  75. package/src/components/ui/toast.tsx +221 -0
  76. package/src/components/ui/toggle-group.tsx +2 -2
  77. package/src/components/ui/toggle.tsx +1 -1
  78. package/src/components/ui/tooltip.tsx +0 -1
  79. package/src/hooks/use-confirm.tsx +2 -0
  80. package/src/index.ts +48 -1
  81. package/src/lib/month.ts +60 -0
  82. package/src/styles.css +10 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,53 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.20.0] — 2026-10-01
6
+
7
+ ขั้น "layout" ของแผนรวม UI ทุกแอป (ดู `todo/todo.md`) — เพิ่มอย่างเดียว ไม่มี prop เดิมเปลี่ยนความหมาย
8
+
9
+ ### Added
10
+
11
+ - **`MobileTabBar`** (`/next`) + **`AppShell mobileTabs`** — แถบเมนูล่างของมือถือ 3–5 แท็บ (ยกจาก DueBell `MobileNav`): ซ่อนตั้งแต่ `md`, แท็บสูง ≥ 56px, `badge` เป็น `CountBadge` บนไอคอน (รวมใน aria-label), `onClick` แทนการไปหน้า, ไฮไลต์ตาม path (`/` ตรงตัว, อื่น ๆ prefix หรือ `exact`); `icon` เป็น **element** (`<Home />`) จึงส่งจาก server layout ได้เหมือน `AppNav`; `AppShell` ทั้ง layout sidebar/top เว้นที่ใต้เนื้อหาให้เอง
12
+ - utility **`pb-safe`** (safe-area ล่างของ iPhone) และ **`pb-tab-bar`** (เว้นที่ให้พ้นแถบล่าง) ใน `styles.css`
13
+ - **`SettingsLayout`** — หน้าตั้งค่าหลายหัวข้อ (ยกจาก DueBell master-detail + PDF ค้นหาได้): จอกว้างสารบัญซ้าย sticky + เนื้อหา, มือถือรายการหัวข้อ → หน้าหัวข้อ + ปุ่ม "กลับ" (ย้ายโฟกัสไปหัวข้อให้ screen reader), `searchable` ค้น label/description/`keywords` (ไทย+อังกฤษ), `badge` ท้ายรายการ, คุมเองด้วย `value`/`onValueChange` หรือ `initialSection`
14
+ - **`FilterBar`** — แถบค้นหา + select ตัวกรองเหนือรายการ client-side (Pi Auth เขียนซ้ำ 14 หน้า): ปุ่ม "ล้างตัวกรอง" โผล่เมื่อมีค่าค้าง (เทียบกับ `allValue` ของแต่ละตัว) และตั้งกลับให้เองถ้าไม่ส่ง `onClear`; ตัวกรองแบบอื่นใส่เป็น `children` + `active`
15
+ - **`SetupChecklist`** — รายการ "ต้องตั้งอะไรให้ครบ" (ยกจาก Pi Auth `SetupSteps`): ✓ เมื่อทำแล้ว, ขั้นบังคับถัดไปถูกเน้น (`aria-current="step"`), ขั้น `optional` ไม่นับ, ครบแล้วซ่อนทั้งกล่อง (`hideWhenComplete`); `action` รับ `{ current, done }` ให้แอปส่ง `LinkButton` เอง (barrel ไม่พึ่ง next)
16
+ - **`MonthStepper`** + **`useMonthCursor`** — เลือกเดือน ‹ เดือน › กดชื่อเดือนกลับเดือนนี้, `min`/`max`, `locale` (`th-TH` = พ.ศ., `th-TH-u-ca-gregory` = ค.ศ.), `format` long|short, `formatLabel` (ยกจาก DueBell `MonthSelector`)
17
+ - **`lib/month.ts`** (barrel): `MonthKey` (`"YYYY-MM"` แบบเดียวกับ `CalendarMonthGrid`), `parseMonthKey`, `isMonthKey`, `toMonthKey`, `currentMonthKey`, `shiftMonth` (ข้ามปีให้), `compareMonth`, `formatMonthKey` — แทน `CycleMonth` ของ DueBell
18
+ - `UiText` key ใหม่ (optional ทั้งหมด มีค่าไทย/อังกฤษใน `UI_TEXT_TH`/`UI_TEXT_EN`): `month`, `setup`, `filters`, `settings`, `mobileTabs`
19
+ - **ชุด component สำเร็จรูป (Simple\*)** — ทิศทางของแพ็กเกจ: แอปเขียน component เดียว + props แทนการประกอบ primitive หลายชิ้นซ้อนกันแบบ shadcn (ดู `docs/agent-rules.md` "ใช้ตัวสำเร็จรูปก่อน"):
20
+ - `SimpleDialog` (แทน Dialog 7 ชิ้น) / `SimpleSheet` (+ `side`) — trigger, title, description, children, `footer` (ReactNode หรือ `(close) => …`), `size`, controlled ได้; **ปุ่ม X อ่าน `UiText.close`** แก้ปัญหา "Close" แปลภาษาไม่ได้โดยไม่แตะไฟล์ shadcn
21
+ - `SimpleTabs` (items: value/label/content/icon/badge/disabled, `variant` default|line, `fullWidth`) / `SimpleAccordion` (`multiple`, `initialOpen`)
22
+ - `SimpleTooltip` (ห่อ element เดียว, content ว่าง = ไม่มี tooltip) / `SimplePopover` (trigger + title/description + เนื้อหา)
23
+ - `ActionMenu` — เมนูคำสั่งจาก array: icon, shortcut, separator, label, href/external, destructive, **`confirm` เปิด `ConfirmDialog` ก่อนเรียก onClick**; trigger ไม่ส่ง = ปุ่ม …
24
+ - `SimplePagination` + `paginationRange` — page/pageCount/onPageChange, จำนวนปุ่มคงที่, มือถือเหลือ "x / y"
25
+ - `SimpleTable<T>` — columns (key/header/cell/align/className) + rows + getRowKey + empty + onRowClick; ค่าจาก key ตรง ๆ ถ้าไม่ส่ง cell
26
+ - `SegmentedControl<T>` — เลือกทีละหนึ่งและต้องมีค่าเสมอ (กดตัวเดิมไม่หลุด), `count`, `icon`, `size`, `fullWidth`
27
+ - `UiText` key ใหม่: `close`, `pagination`
28
+ - **shadcn/ui base-nova ล่าสุดทั้งชุด** (`shadcn add --all --overwrite`, CLI 4.21.0): primitive ใหม่ `bubble`, `marker`, `message`, `message-scroller`, `questionnaire`, `direction` (export ใน barrel) และ `toast` (Base UI — **ไม่อยู่ใน barrel** เพราะชื่อ `toast`/`Toaster` ชนกับ sonner ที่แพ็กเกจใช้; import จาก `@suphark/ui/components/ui/toast`); dep ใหม่ `@shadcn/react`
29
+ - ไฟล์เดิมได้ class/ลำดับจาก upstream (ส่วนใหญ่เรียง class ใหม่) + sidebar deps ของ useEffect/useMemo ครบขึ้น
30
+ - **คงของที่แก้ไว้เอง** (ติดมาจาก supplychain ตั้งแต่ 0.1.0 — upstream ยังไม่มี): `scroll-area.tsx` (`data-[orientation=…]` ของ Base UI — ไม่งั้นแถบเลื่อนกว้าง 0 มองไม่เห็นทั้งแอป), `slider.tsx`, `tabs.tsx` (orientation แบบเดียวกัน), `table.tsx` (prop `containerClassName`)
31
+ - CLI รุ่นนี้เขียน `import { cn } from "cn"` — เปลี่ยนกลับเป็น `@suphark/ui/lib/utils` ทุกไฟล์ เพราะ `cn` ของแพ็กเกจรู้จักคลาสตามบทบาท (`text-body` ฯลฯ, 0.13.1) ส่วน `cn` ทั่วไปจะลบทิ้งเงียบ ๆ
32
+ - `hooks/use-mobile.ts` (CLI เขียนทับ) เติม `"use client"` คืน; biome ปิด `noDoubleEquals` เฉพาะ `components/ui/**` (`field.tsx` ของ upstream ใช้ `==`)
33
+ - showcase: หน้า `/components/{month-stepper,filter-bar,setup-checklist,settings-layout,mobile-tab-bar,simple-dialog,simple-sheet,simple-tabs,simple-accordion,simple-tooltip,simple-popover,action-menu,simple-pagination,simple-table,segmented-control}` (118 หน้า) + `/shell` มี `mobileTabs` ให้ลองบนจอมือถือ; tests 20 กรณีเพิ่ม (รวม 183)
34
+
35
+ ## [0.19.0] — 2026-09-30
36
+
37
+ ปิดรายการ "component เล็กที่ใช้ร่วม" ของแผนรวม UI ทุกแอป (ดู `todo/todo.md`) — เพิ่มอย่างเดียว ไม่มี prop เดิมเปลี่ยนความหมาย
38
+
39
+ ### Added
40
+
41
+ - **`Stat` เพิ่ม `icon` / `badge` / `delta` / `tone`** + **`StatGrid`** (Pi Auth มี 17 การ์ดแบบนี้, DueBell มี DeltaBadge เอง): `delta` = `{ value, direction: "up"|"down"|"flat", tone?, hint? }` เลือกไอคอนเทรนด์และสีให้ (ขึ้น = success, ลง = destructive — ทับด้วย `tone` ได้เพราะบางตัวชี้วัด "ลง" คือดี), `icon` มีกรอบพื้นตามโทน, `tone` เปลี่ยนสีตัวเลข (`default|success|warning|destructive|info`); `StatGrid` คอลัมน์ 2|3|4 (มือถือ 1 → sm 2)
42
+ - **`SettingsRow`** / **`SwitchRow`** / **`DangerZone`** (barrel) — แถวตั้งค่า: ชื่อ+คำอธิบายซ้าย control ขวา (จอแคบตกลงมา), `htmlFor` ผูกชื่อกับ control, `tone="destructive"`; `SwitchRow` = แถว + `Switch` ที่ผูก id ให้แล้ว (คลิกชื่อแล้วสลับ) รับ props ของ `Switch` ทั้งหมด; `DangerZone` = กรอบ `border-destructive/40` + พื้น subtle + เส้นคั่นแถว (Pi Auth/Pi PDF เขียน layout นี้ซ้ำ)
43
+ - **`ConfirmDialog` / `useConfirm` `requireText`** — ต้องพิมพ์ข้อความให้ตรงก่อนปุ่มยืนยันจะกดได้ (ลบของที่กู้ไม่ได้ แบบ GitHub); `requireTextLabel` ทับข้อความเหนือช่อง, ค่าเริ่มต้นจาก `UiText.confirmTypeToConfirm`/`…Suffix` (key ใหม่); ปิดกล่องแล้วล้างสิ่งที่พิมพ์เสมอ (ยืนยันรายการถัดไปทันทีไม่ได้) — ใช้ผ่าน `confirm({ requireText: app.name, destructive: true })` ได้เลย
44
+ - **`UserIdentity`** (barrel) — avatar (`Initials` สีพื้นคงที่ต่อชื่อ) + ชื่อ + อีเมล + `badge`/`hint`, `size` sm|default, `hideAvatar`; **ไม่มีชื่อ = ใช้อีเมลเป็นบรรทัดหลัก** (ผู้ใช้ที่ยังไม่ตั้งชื่อ) — Pi Auth เขียนซ้ำ ~15 จุด
45
+ - **`CountBadge`** (barrel) — ป้ายจำนวนท้ายเมนู/แท็บ: เกิน `max` เป็น `99+`, `0` ซ่อนอัตโนมัติ (`showZero` บังคับแสดง), `tone` muted|primary|destructive
46
+ - **`SaveStatus`** (barrel, ยกจาก Pi PDF) — สถานะบันทึกอัตโนมัติบรรทัดเดียว: `saving` (spinner) / `saved` (✓ เขียว) / `error` (`role="alert"`, ข้อความของแอปหรือ `UiText.actionFailed`) / `idle` + `savedAt` = เวลาสัมพัทธ์ผ่าน `ClientDateFormatter` (อัปเดตเองหลัง mount ไม่ทำ hydration mismatch); `language`/`text`
47
+ - **`SliderField`** (barrel, ยกจาก Pi PDF) — ช่องเลื่อน + ป้ายชื่อ + ค่าปัจจุบันมุมขวา: `value`/`onValueChange` เป็น number เดี่ยว, `unit` หรือ `formatValue`, `description`, `size` sm|default, `disabled`
48
+ - **`SkipLink`** (barrel) — ลิงก์ "ข้ามไปเนื้อหา" สำหรับหน้าที่ไม่ได้ใช้ `AppShell` (Pi PDF มีของตัวเอง); `AppShell` เปลี่ยนมาใช้ตัวเดียวกันนี้ภายใน
49
+ - **`FormDialog` `revealSuccess`** — แบบ **ไม่ต้องส่งฟังก์ชัน** จึงใช้จาก **server component** ได้ (รายงานจาก Pi Auth: `renderSuccess` เป็นฟังก์ชัน ข้าม server/client boundary ไม่ได้ → `Functions cannot be passed directly to Client Components`): server action คืน `{ ok: true, data: { values: { "Signing secret": "whsec_…" } } }` แล้ว dialog แสดง `SecretReveal` ให้เอง + ปุ่ม "เสร็จแล้ว" (ไม่ปิดเอง); `successTitle` (ค่าเริ่มต้น `UiText.showOnce`), `successDescription` แทน description ของฟอร์ม — เคส "สร้างแล้วโชว์ secret ครั้งเดียว" (webhook secret / SCIM token / service account key / client secret) ซ้ำกันทุกแอป; `renderSuccess` เดิมยังใช้ได้จาก client component
50
+ - showcase: หน้า `/components/settings-row`, `/components/user-identity` + หน้า `stat` ใหม่ (ไอคอน/delta/StatGrid) — 104 หน้า; tests 9 กรณีเพิ่ม (รวม 163)
51
+
5
52
  ## [0.18.0] — 2026-09-30
6
53
 
7
54
  > 0.17.0 ถูก publish ตอนที่ `LanguageSwitch` ยังไม่เข้า (commit ถัดไป) — ของชิ้นนี้จึงออกเป็น 0.18.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
@@ -54,11 +54,21 @@
54
54
  "./hooks/*": "./src/hooks/*.ts",
55
55
  "./package.json": "./package.json"
56
56
  },
57
+ "scripts": {
58
+ "typecheck": "tsc --noEmit",
59
+ "audit": "node scripts/audit.mjs src --important",
60
+ "test": "vitest run",
61
+ "test:watch": "vitest",
62
+ "lint:base-ui": "node scripts/audit-base-ui-props.mjs",
63
+ "check:typography": "node scripts/audit-tailwind-typography.mjs",
64
+ "verify": "pnpm typecheck && pnpm lint:base-ui && pnpm check:typography && pnpm test"
65
+ },
57
66
  "dependencies": {
58
67
  "@dnd-kit/core": "^6.3.1",
59
68
  "@dnd-kit/sortable": "^10.0.0",
60
69
  "@dnd-kit/utilities": "^3.2.2",
61
70
  "@fontsource/noto-sans-thai-looped": "^5.3.0",
71
+ "@shadcn/react": "^0.3.1",
62
72
  "class-variance-authority": "^0.7.1",
63
73
  "clsx": "^2.1.1",
64
74
  "cmdk": "^1.1.1",
@@ -67,7 +77,7 @@
67
77
  "embla-carousel-react": "^8.6.0",
68
78
  "input-otp": "^1.5.0",
69
79
  "next-themes": "^0.4.6",
70
- "react-day-picker": "^10.0.1",
80
+ "react-day-picker": "^10.0.2",
71
81
  "react-number-format": "^5.4.5",
72
82
  "react-resizable-panels": "^4.12.3",
73
83
  "sonner": "^2.0.8",
@@ -112,18 +122,9 @@
112
122
  "react": "^19.3.0",
113
123
  "react-dom": "^19.3.0",
114
124
  "react-hook-form": "^7.86.0",
115
- "recharts": "^3.10.1",
125
+ "recharts": "^3.8.0",
116
126
  "tailwindcss": "^4.3.3",
117
127
  "typescript": "^6.0.3",
118
128
  "vitest": "^4.1.11"
119
- },
120
- "scripts": {
121
- "typecheck": "tsc --noEmit",
122
- "audit": "node scripts/audit.mjs src --important",
123
- "test": "vitest run",
124
- "test:watch": "vitest",
125
- "lint:base-ui": "node scripts/audit-base-ui-props.mjs",
126
- "check:typography": "node scripts/audit-tailwind-typography.mjs",
127
- "verify": "pnpm typecheck && pnpm lint:base-ui && pnpm check:typography && pnpm test"
128
129
  }
129
- }
130
+ }
package/scripts/audit.mjs CHANGED
File without changes
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { SkipLink } from "@suphark/ui/components/shared/skip-link";
3
4
  import {
4
5
  InputGroup,
5
6
  InputGroupAddon,
@@ -34,6 +35,7 @@ import { AppBrand, type AppBrandProps } from "./app-brand";
34
35
  import { AppNav, type AppNavPinProps } from "./app-nav";
35
36
  import { HeaderBreadcrumbs, type HeaderBreadcrumbsProps } from "./header-breadcrumbs";
36
37
  import { HeaderCrumbs } from "./header-crumb";
38
+ import { MobileTabBar, type MobileTabItem } from "./mobile-tab-bar";
37
39
  import { SubNav } from "./sub-nav";
38
40
  import { isTabActive, TopTabs, topLevelTabs } from "./top-tabs";
39
41
  import { type AppNavGroup, searchNav } from "./types";
@@ -101,6 +103,11 @@ export interface AppShellProps {
101
103
  contentClassName?: string;
102
104
  /** จำกัดความกว้างเนื้อหา เช่น `max-w-5xl` (Pi Auth) — ไม่ส่ง = เต็มจอ (supplychain) */
103
105
  contentMaxWidth?: string;
106
+ /**
107
+ * แถบเมนูล่างบนมือถือ (< md) 3–5 แท็บ — เว้นที่ใต้เนื้อหาให้เอง; เดสก์ท็อปยังใช้ sidebar/แท็บบนตามเดิม
108
+ * (ยกจาก DueBell MobileNav)
109
+ */
110
+ mobileTabs?: ReadonlyArray<MobileTabItem>;
104
111
  children: ReactNode;
105
112
  }
106
113
 
@@ -123,16 +130,9 @@ export function AppShell(props: AppShellProps) {
123
130
  /** id ของ <main> ของ AppShell (sidebar: SidebarInset, top: main ในเนื้อหา) — ใช้อ้างจาก skip link / anchor */
124
131
  export const APP_MAIN_ID = "app-main";
125
132
 
126
- /** ลิงก์ "ข้ามไปเนื้อหา" สำหรับคีย์บอร์ด/screen reader — มองไม่เห็นจนกด Tab ตัวแรก */
133
+ /** ลิงก์ "ข้ามไปเนื้อหา" ของ AppShell — ใช้ `SkipLink` ตัวเดียวกับที่ export ให้แอปใช้นอก AppShell */
127
134
  function SkipToMain() {
128
- return (
129
- <a
130
- href={`#${APP_MAIN_ID}`}
131
- 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"
132
- >
133
- ข้ามไปเนื้อหา
134
- </a>
135
- );
135
+ return <SkipLink href={`#${APP_MAIN_ID}`} />;
136
136
  }
137
137
 
138
138
  function SidebarShell({
@@ -153,6 +153,7 @@ function SidebarShell({
153
153
  defaultOpen = true,
154
154
  contentClassName,
155
155
  contentMaxWidth,
156
+ mobileTabs,
156
157
  children,
157
158
  }: AppShellProps) {
158
159
  const style = brandColor
@@ -230,12 +231,14 @@ function SidebarShell({
230
231
  <div
231
232
  className={cn(
232
233
  "flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6 print:overflow-visible",
234
+ mobileTabs?.length && "pb-tab-bar md:pb-6",
233
235
  contentMaxWidth && cn("mx-auto w-full", contentMaxWidth),
234
236
  contentClassName,
235
237
  )}
236
238
  >
237
239
  {children}
238
240
  </div>
241
+ {mobileTabs?.length ? <MobileTabBar items={mobileTabs} /> : null}
239
242
  </SidebarInset>
240
243
  </SidebarProvider>
241
244
  );
@@ -254,6 +257,7 @@ function TopShell({
254
257
  brandForeground,
255
258
  contentClassName,
256
259
  contentMaxWidth,
260
+ mobileTabs,
257
261
  children,
258
262
  }: AppShellProps) {
259
263
  const pathname = usePathname();
@@ -318,6 +322,7 @@ function TopShell({
318
322
  <div
319
323
  className={cn(
320
324
  "flex w-full flex-1 flex-col gap-4 p-4 md:flex-row md:gap-8 md:p-6",
325
+ mobileTabs?.length && "pb-tab-bar md:pb-6",
321
326
  contentMaxWidth && cn("mx-auto", contentMaxWidth),
322
327
  )}
323
328
  >
@@ -341,6 +346,7 @@ function TopShell({
341
346
  {children}
342
347
  </main>
343
348
  </div>
349
+ {mobileTabs?.length ? <MobileTabBar items={mobileTabs} /> : null}
344
350
  </div>
345
351
  );
346
352
  }
@@ -10,6 +10,7 @@ export {
10
10
  } from "./command-palette";
11
11
  export { HeaderBreadcrumbs, type HeaderBreadcrumbsProps } from "./header-breadcrumbs";
12
12
  export { HeaderCrumb, HeaderCrumbs } from "./header-crumb";
13
+ export { MobileTabBar, type MobileTabItem } from "./mobile-tab-bar";
13
14
  export { NavLink } from "./nav-link";
14
15
  export {
15
16
  type OrganizationOption,
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import { CountBadge } from "@suphark/ui/components/shared/count-badge";
4
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
5
+ import { cn } from "@suphark/ui/lib/utils";
6
+ import Link from "next/link";
7
+ import { usePathname } from "next/navigation";
8
+ import type { ReactNode } from "react";
9
+ import { isNavActive } from "./types";
10
+
11
+ export interface MobileTabItem {
12
+ href: string;
13
+ label: string;
14
+ /** element เช่น `<Home />` (ไม่ใช่ component) — ส่งจาก server layout ได้เหมือน AppNav */
15
+ icon: ReactNode;
16
+ /** ตัวเลขบนไอคอน (เช่นแจ้งเตือนค้าง) — 0/undefined ไม่แสดง */
17
+ badge?: number;
18
+ /** ไฮไลต์เฉพาะ path ตรงตัว (ค่าเริ่มต้น: href "/" ตรงตัว, อื่น ๆ prefix) */
19
+ exact?: boolean;
20
+ /** กดแล้วทำ action แทนการไปหน้า (เช่นเปิด sheet แจ้งเตือน) */
21
+ onClick?: () => void;
22
+ }
23
+
24
+ /**
25
+ * แถบเมนูล่างสำหรับมือถือ (ยกจาก DueBell `MobileNav`) — ซ่อนตั้งแต่ md, เผื่อ safe-area ของ iPhone,
26
+ * แท็บละ ≥ 44px ตามเกณฑ์จอสัมผัส; ใช้ผ่าน `AppShell mobileTabs` (เว้นที่ใต้เนื้อหาให้เอง)
27
+ * หรือวางเองแล้วใส่ `pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-0` ให้เนื้อหา
28
+ * ควรมี 3–5 แท็บ — เกินนั้นให้ใช้เมนู sidebar
29
+ */
30
+ export function MobileTabBar({
31
+ items,
32
+ className,
33
+ "aria-label": ariaLabel,
34
+ }: {
35
+ items: ReadonlyArray<MobileTabItem>;
36
+ className?: string;
37
+ "aria-label"?: string;
38
+ }) {
39
+ const pathname = usePathname();
40
+ const text = useUiText();
41
+ return (
42
+ <nav
43
+ data-slot="mobile-tab-bar"
44
+ aria-label={ariaLabel ?? text.mobileTabs ?? "เมนูหลัก"}
45
+ className={cn(
46
+ "fixed inset-x-0 bottom-0 z-40 border-t bg-background/95 pb-safe backdrop-blur md:hidden print:hidden",
47
+ className,
48
+ )}
49
+ >
50
+ <ul className="mx-auto flex max-w-lg items-stretch justify-around px-1">
51
+ {items.map((item) => {
52
+ const active = isNavActive(pathname, item.href, item.exact ?? item.href === "/");
53
+ const content: ReactNode = (
54
+ <>
55
+ <span className="relative">
56
+ <span
57
+ aria-hidden
58
+ className={cn("flex [&_svg]:size-5", active && "[&_svg]:stroke-[2.5]")}
59
+ >
60
+ {item.icon}
61
+ </span>
62
+ {item.badge ? (
63
+ <CountBadge
64
+ value={item.badge}
65
+ tone="destructive"
66
+ className="absolute -top-1.5 -right-3 min-w-4 px-1"
67
+ />
68
+ ) : null}
69
+ </span>
70
+ <span className="text-label leading-tight">{item.label}</span>
71
+ </>
72
+ );
73
+ const itemClass = cn(
74
+ "flex min-h-14 w-full min-w-14 flex-col items-center justify-center gap-0.5 rounded-lg px-2 py-1.5 no-underline outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
75
+ active ? "font-semibold text-primary" : "text-muted-foreground hover:text-foreground",
76
+ );
77
+ return (
78
+ <li key={item.href} className="flex flex-1">
79
+ {item.onClick ? (
80
+ <button
81
+ type="button"
82
+ onClick={item.onClick}
83
+ className={itemClass}
84
+ aria-label={item.badge ? `${item.label} (${item.badge})` : undefined}
85
+ >
86
+ {content}
87
+ </button>
88
+ ) : (
89
+ <Link
90
+ href={item.href}
91
+ className={itemClass}
92
+ aria-current={active ? "page" : undefined}
93
+ aria-label={item.badge ? `${item.label} (${item.badge})` : undefined}
94
+ >
95
+ {content}
96
+ </Link>
97
+ )}
98
+ </li>
99
+ );
100
+ })}
101
+ </ul>
102
+ </nav>
103
+ );
104
+ }
@@ -0,0 +1,155 @@
1
+ "use client";
2
+
3
+ import { Button } from "@suphark/ui/components/ui/button";
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuGroup,
8
+ DropdownMenuItem,
9
+ DropdownMenuLabel,
10
+ DropdownMenuSeparator,
11
+ DropdownMenuShortcut,
12
+ DropdownMenuTrigger,
13
+ } from "@suphark/ui/components/ui/dropdown-menu";
14
+ import { MoreHorizontal } from "lucide-react";
15
+ import { type ReactElement, type ReactNode, useState } from "react";
16
+ import { ConfirmDialog, type ConfirmSpec, normalizeConfirm } from "./confirm-dialog";
17
+ import { useUiText } from "./ui-text";
18
+
19
+ export interface ActionMenuCommand {
20
+ type?: "item";
21
+ label: ReactNode;
22
+ icon?: ReactNode;
23
+ onClick?: () => unknown;
24
+ /** ลิงก์ธรรมดา (`<a>`) — ลิงก์ภายในของ Next ที่ต้อง prefetch ใช้ onClick + router.push */
25
+ href?: string;
26
+ /** เปิดแท็บใหม่ (ใช้กับ href) */
27
+ external?: boolean;
28
+ shortcut?: string;
29
+ destructive?: boolean;
30
+ disabled?: boolean;
31
+ /** ถามยืนยันก่อน onClick (string = คำถาม หรือ object กำหนดหัว/ปุ่มเอง) */
32
+ confirm?: ConfirmSpec;
33
+ }
34
+
35
+ export type ActionMenuItem =
36
+ | ActionMenuCommand
37
+ | { type: "separator" }
38
+ | { type: "label"; label: ReactNode };
39
+
40
+ /**
41
+ * เมนูคำสั่งสำเร็จรูปจาก array (แทน DropdownMenu/Trigger/Content/Item/Separator/Label/Shortcut)
42
+ * trigger ไม่ส่ง = ปุ่มไอคอน "…" (aria-label จาก `label` หรือ UiText.actions)
43
+ * แถวในตาราง/รายการที่เรียก server action + refresh ใช้ `RowActions` (`@suphark/ui/next`)
44
+ *
45
+ * <ActionMenu items={[
46
+ * { label: "แก้ไข", icon: <Pencil />, onClick: edit },
47
+ * { type: "separator" },
48
+ * { label: "ลบ", icon: <Trash2 />, destructive: true, confirm: "ลบรายการนี้?", onClick: remove },
49
+ * ]} />
50
+ */
51
+ export function ActionMenu({
52
+ items,
53
+ trigger,
54
+ label,
55
+ align = "end",
56
+ }: {
57
+ items: ReadonlyArray<ActionMenuItem>;
58
+ trigger?: ReactElement;
59
+ /** aria-label ของปุ่ม … (ค่าเริ่มต้น UiText.actions) */
60
+ label?: string;
61
+ align?: "start" | "center" | "end";
62
+ }) {
63
+ const text = useUiText();
64
+ const [confirming, setConfirming] = useState<ActionMenuCommand | null>(null);
65
+ const [pending, setPending] = useState(false);
66
+
67
+ const run = async (fn?: () => unknown) => {
68
+ if (!fn) return;
69
+ setPending(true);
70
+ try {
71
+ await fn();
72
+ } finally {
73
+ setPending(false);
74
+ }
75
+ };
76
+
77
+ const spec = confirming?.confirm ? normalizeConfirm(confirming.confirm) : null;
78
+
79
+ return (
80
+ <>
81
+ <DropdownMenu>
82
+ <DropdownMenuTrigger
83
+ render={
84
+ trigger ?? (
85
+ <Button variant="ghost" size="icon-sm" aria-label={label ?? text.actions}>
86
+ <MoreHorizontal />
87
+ </Button>
88
+ )
89
+ }
90
+ />
91
+ <DropdownMenuContent align={align} className="min-w-40">
92
+ <DropdownMenuGroup>
93
+ {items.map((item, i) => {
94
+ if (item.type === "separator") {
95
+ // biome-ignore lint/suspicious/noArrayIndexKey: ตัวคั่นไม่มี id — ลำดับคงที่ตาม items
96
+ return <DropdownMenuSeparator key={`sep-${i}`} />;
97
+ }
98
+ if (item.type === "label") {
99
+ // biome-ignore lint/suspicious/noArrayIndexKey: หัวกลุ่มไม่มี id — ลำดับคงที่ตาม items
100
+ return <DropdownMenuLabel key={`label-${i}`}>{item.label}</DropdownMenuLabel>;
101
+ }
102
+ const content = (
103
+ <>
104
+ {item.icon}
105
+ {item.label}
106
+ {item.shortcut && <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>}
107
+ </>
108
+ );
109
+ return (
110
+ <DropdownMenuItem
111
+ // biome-ignore lint/suspicious/noArrayIndexKey: label เป็น ReactNode ใช้เป็น key ไม่ได้
112
+ key={`item-${i}`}
113
+ variant={item.destructive ? "destructive" : "default"}
114
+ disabled={item.disabled}
115
+ render={
116
+ item.href ? (
117
+ <a
118
+ href={item.href}
119
+ target={item.external ? "_blank" : undefined}
120
+ rel={item.external ? "noreferrer" : undefined}
121
+ />
122
+ ) : undefined
123
+ }
124
+ onClick={() => {
125
+ if (item.href) return;
126
+ if (item.confirm) setConfirming(item);
127
+ else void run(item.onClick);
128
+ }}
129
+ >
130
+ {content}
131
+ </DropdownMenuItem>
132
+ );
133
+ })}
134
+ </DropdownMenuGroup>
135
+ </DropdownMenuContent>
136
+ </DropdownMenu>
137
+ {spec && confirming && (
138
+ <ConfirmDialog
139
+ open
140
+ onOpenChange={(o) => !o && setConfirming(null)}
141
+ title={spec.title}
142
+ description={spec.description}
143
+ confirmLabel={spec.confirmLabel}
144
+ cancelLabel={spec.cancelLabel}
145
+ destructive={spec.destructive ?? confirming.destructive}
146
+ pending={pending}
147
+ onConfirm={async () => {
148
+ await run(confirming.onClick);
149
+ setConfirming(null);
150
+ }}
151
+ />
152
+ )}
153
+ </>
154
+ );
155
+ }
@@ -10,8 +10,10 @@ import {
10
10
  AlertDialogHeader,
11
11
  AlertDialogTitle,
12
12
  } from "@suphark/ui/components/ui/alert-dialog";
13
+ import { Input } from "@suphark/ui/components/ui/input";
14
+ import { Label } from "@suphark/ui/components/ui/label";
13
15
  import { Loader2 } from "lucide-react";
14
- import type { ReactNode } from "react";
16
+ import { type ReactNode, useEffect, useId, useState } from "react";
15
17
  import { useUiText } from "./ui-text";
16
18
 
17
19
  /** สเปกการยืนยัน — string = description อย่างเดียว (title/ปุ่มใช้ค่าเริ่มต้นจาก UiText) */
@@ -24,6 +26,10 @@ export type ConfirmSpec =
24
26
  cancelLabel?: string;
25
27
  /** ปุ่มยืนยันสีแดง (RowActions ใช้ `destructive` ของรายการเป็นค่าเริ่มต้นอยู่แล้ว) */
26
28
  destructive?: boolean;
29
+ /** ต้องพิมพ์ข้อความนี้ให้ตรงก่อนกดยืนยันได้ (ลบของที่กู้คืนไม่ได้ — แบบ GitHub) */
30
+ requireText?: string;
31
+ /** คำอธิบายเหนือช่องพิมพ์ (ค่าเริ่มต้น "พิมพ์ \"X\" เพื่อยืนยัน") */
32
+ requireTextLabel?: ReactNode;
27
33
  };
28
34
 
29
35
  /** แปลง ConfirmSpec เป็น object เสมอ */
@@ -45,6 +51,8 @@ export function ConfirmDialog({
45
51
  cancelLabel,
46
52
  destructive = false,
47
53
  pending = false,
54
+ requireText,
55
+ requireTextLabel,
48
56
  onConfirm,
49
57
  }: {
50
58
  open: boolean;
@@ -56,9 +64,19 @@ export function ConfirmDialog({
56
64
  /** ปุ่มยืนยันสีแดง (ลบ/เพิกถอน) */
57
65
  destructive?: boolean;
58
66
  pending?: boolean;
67
+ /** ต้องพิมพ์ข้อความนี้ให้ตรงก่อนปุ่มยืนยันจะกดได้ (ชื่อแอป/โปรเจกต์ที่กำลังจะลบ) */
68
+ requireText?: string;
69
+ requireTextLabel?: ReactNode;
59
70
  onConfirm: () => void;
60
71
  }) {
61
72
  const text = useUiText();
73
+ const inputId = useId();
74
+ const [typed, setTyped] = useState("");
75
+ // เปิดใหม่ทุกครั้ง = ล้างสิ่งที่พิมพ์ไว้ (ไม่ให้ยืนยันรายการถัดไปได้ทันที)
76
+ useEffect(() => {
77
+ if (!open) setTyped("");
78
+ }, [open]);
79
+ const blocked = Boolean(requireText) && typed.trim() !== requireText;
62
80
  return (
63
81
  <AlertDialog open={open} onOpenChange={(o) => !pending && onOpenChange(o)}>
64
82
  <AlertDialogContent>
@@ -66,11 +84,32 @@ export function ConfirmDialog({
66
84
  <AlertDialogTitle>{title ?? text.confirmTitle ?? "ยืนยันการทำรายการ"}</AlertDialogTitle>
67
85
  <AlertDialogDescription>{description}</AlertDialogDescription>
68
86
  </AlertDialogHeader>
87
+ {requireText && (
88
+ <div className="flex flex-col gap-1.5">
89
+ <Label htmlFor={inputId} className="font-normal text-sm">
90
+ {requireTextLabel ?? (
91
+ <>
92
+ {text.confirmTypeToConfirm ?? "พิมพ์"}{" "}
93
+ <span className="font-medium">{requireText}</span>{" "}
94
+ {text.confirmTypeToConfirmSuffix ?? "เพื่อยืนยัน"}
95
+ </>
96
+ )}
97
+ </Label>
98
+ <Input
99
+ id={inputId}
100
+ value={typed}
101
+ onChange={(e) => setTyped(e.target.value)}
102
+ autoComplete="off"
103
+ spellCheck={false}
104
+ disabled={pending}
105
+ />
106
+ </div>
107
+ )}
69
108
  <AlertDialogFooter>
70
109
  <AlertDialogCancel disabled={pending}>{cancelLabel ?? text.cancel}</AlertDialogCancel>
71
110
  <AlertDialogAction
72
111
  variant={destructive ? "destructive" : "default"}
73
- disabled={pending}
112
+ disabled={pending || blocked}
74
113
  aria-busy={pending || undefined}
75
114
  onClick={onConfirm}
76
115
  >
@@ -0,0 +1,41 @@
1
+ import { cn } from "@suphark/ui/lib/utils";
2
+ import type { ComponentProps } from "react";
3
+
4
+ /**
5
+ * ป้ายจำนวนเล็ก ๆ ท้ายชื่อเมนู/แท็บ/หัวข้อ — เกิน `max` แสดง "99+"; 0 ซ่อนอัตโนมัติ (`showZero` เพื่อบังคับแสดง)
6
+ *
7
+ * ผู้ใช้ <CountBadge value={members.length} />
8
+ */
9
+ export function CountBadge({
10
+ value,
11
+ max = 99,
12
+ showZero = false,
13
+ tone = "muted",
14
+ className,
15
+ ...props
16
+ }: Omit<ComponentProps<"span">, "children"> & {
17
+ value: number | null | undefined;
18
+ max?: number;
19
+ showZero?: boolean;
20
+ /** muted = พื้นเทา (ค่าเริ่มต้น), primary/destructive = เน้น (เช่นจำนวนที่ยังไม่อ่าน) */
21
+ tone?: "muted" | "primary" | "destructive";
22
+ }) {
23
+ if (value == null || (!showZero && value <= 0)) return null;
24
+ return (
25
+ <span
26
+ data-slot="count-badge"
27
+ className={cn(
28
+ "inline-flex min-w-5 shrink-0 items-center justify-center rounded-full px-1.5 font-medium text-label tabular-nums",
29
+ tone === "primary"
30
+ ? "bg-primary text-primary-foreground"
31
+ : tone === "destructive"
32
+ ? "bg-destructive text-white"
33
+ : "bg-muted text-muted-foreground",
34
+ className,
35
+ )}
36
+ {...props}
37
+ >
38
+ {value > max ? `${max}+` : value}
39
+ </span>
40
+ );
41
+ }