@suphark/ui 0.19.0 → 0.20.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 (74) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/package.json +14 -13
  3. package/scripts/audit.mjs +0 -0
  4. package/src/components/app-shell/app-shell.tsx +12 -0
  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 +157 -0
  8. package/src/components/shared/button/confirm-action-button.tsx +2 -0
  9. package/src/components/shared/filter-bar.tsx +108 -0
  10. package/src/components/shared/month-stepper.tsx +124 -0
  11. package/src/components/shared/row-actions.tsx +2 -0
  12. package/src/components/shared/segmented-control.tsx +73 -0
  13. package/src/components/shared/settings-layout.tsx +170 -0
  14. package/src/components/shared/setup-checklist.tsx +104 -0
  15. package/src/components/shared/simple-dialog.tsx +172 -0
  16. package/src/components/shared/simple-pagination.tsx +109 -0
  17. package/src/components/shared/simple-table.tsx +93 -0
  18. package/src/components/shared/simple-tabs.tsx +118 -0
  19. package/src/components/shared/simple-tooltip.tsx +87 -0
  20. package/src/components/shared/ui-text.tsx +28 -0
  21. package/src/components/ui/accordion.tsx +2 -2
  22. package/src/components/ui/alert-dialog.tsx +4 -4
  23. package/src/components/ui/alert.tsx +2 -2
  24. package/src/components/ui/attachment.tsx +9 -9
  25. package/src/components/ui/avatar.tsx +4 -4
  26. package/src/components/ui/badge.tsx +1 -1
  27. package/src/components/ui/breadcrumb.tsx +1 -1
  28. package/src/components/ui/bubble.tsx +123 -0
  29. package/src/components/ui/button-group.tsx +2 -2
  30. package/src/components/ui/button.tsx +6 -6
  31. package/src/components/ui/calendar.tsx +9 -9
  32. package/src/components/ui/card.tsx +3 -3
  33. package/src/components/ui/chart.tsx +2 -2
  34. package/src/components/ui/checkbox.tsx +1 -1
  35. package/src/components/ui/combobox.tsx +6 -6
  36. package/src/components/ui/command.tsx +4 -4
  37. package/src/components/ui/context-menu.tsx +7 -7
  38. package/src/components/ui/dialog.tsx +4 -4
  39. package/src/components/ui/direction.tsx +6 -0
  40. package/src/components/ui/drawer.tsx +10 -10
  41. package/src/components/ui/dropdown-menu.tsx +8 -8
  42. package/src/components/ui/empty.tsx +4 -4
  43. package/src/components/ui/field.tsx +8 -8
  44. package/src/components/ui/hover-card.tsx +1 -2
  45. package/src/components/ui/input-group.tsx +3 -3
  46. package/src/components/ui/input-otp.tsx +1 -1
  47. package/src/components/ui/input.tsx +1 -1
  48. package/src/components/ui/item.tsx +4 -4
  49. package/src/components/ui/kbd.tsx +1 -1
  50. package/src/components/ui/label.tsx +1 -1
  51. package/src/components/ui/marker.tsx +67 -0
  52. package/src/components/ui/menubar.tsx +9 -9
  53. package/src/components/ui/message-scroller.tsx +129 -0
  54. package/src/components/ui/message.tsx +84 -0
  55. package/src/components/ui/native-select.tsx +1 -1
  56. package/src/components/ui/navigation-menu.tsx +6 -6
  57. package/src/components/ui/popover.tsx +1 -1
  58. package/src/components/ui/progress.tsx +2 -3
  59. package/src/components/ui/questionnaire.tsx +314 -0
  60. package/src/components/ui/radio-group.tsx +1 -2
  61. package/src/components/ui/resizable.tsx +1 -1
  62. package/src/components/ui/select.tsx +4 -4
  63. package/src/components/ui/separator.tsx +0 -1
  64. package/src/components/ui/sheet.tsx +3 -3
  65. package/src/components/ui/sidebar.tsx +14 -14
  66. package/src/components/ui/switch.tsx +2 -3
  67. package/src/components/ui/textarea.tsx +1 -1
  68. package/src/components/ui/toast.tsx +221 -0
  69. package/src/components/ui/toggle-group.tsx +2 -2
  70. package/src/components/ui/toggle.tsx +1 -1
  71. package/src/components/ui/tooltip.tsx +0 -1
  72. package/src/index.ts +41 -0
  73. package/src/lib/month.ts +60 -0
  74. package/src/styles.css +10 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.20.1] — 2026-10-01
6
+
7
+ ### Fixed
8
+
9
+ - **`confirm.requireText` ถูกทิ้งเงียบ ๆ** (รายงานจาก Pi Config): `RowActions`, `ConfirmActionButton` และ `ActionMenu` ส่งให้ `ConfirmDialog` แค่ title/description/label/destructive — ใส่ `confirm: { requireText: slug }` แล้วลบได้ด้วยคลิกเดียวทั้งที่ `ConfirmSpec` ประกาศว่ารองรับ → ส่ง `requireText` + `requireTextLabel` ต่อครบทั้งสามที่; tests 2 กรณี (รวม 185)
10
+
11
+ ## [0.20.0] — 2026-10-01
12
+
13
+ ขั้น "layout" ของแผนรวม UI ทุกแอป (ดู `todo/todo.md`) — เพิ่มอย่างเดียว ไม่มี prop เดิมเปลี่ยนความหมาย
14
+
15
+ ### Added
16
+
17
+ - **`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 เว้นที่ใต้เนื้อหาให้เอง
18
+ - utility **`pb-safe`** (safe-area ล่างของ iPhone) และ **`pb-tab-bar`** (เว้นที่ให้พ้นแถบล่าง) ใน `styles.css`
19
+ - **`SettingsLayout`** — หน้าตั้งค่าหลายหัวข้อ (ยกจาก DueBell master-detail + PDF ค้นหาได้): จอกว้างสารบัญซ้าย sticky + เนื้อหา, มือถือรายการหัวข้อ → หน้าหัวข้อ + ปุ่ม "กลับ" (ย้ายโฟกัสไปหัวข้อให้ screen reader), `searchable` ค้น label/description/`keywords` (ไทย+อังกฤษ), `badge` ท้ายรายการ, คุมเองด้วย `value`/`onValueChange` หรือ `initialSection`
20
+ - **`FilterBar`** — แถบค้นหา + select ตัวกรองเหนือรายการ client-side (Pi Auth เขียนซ้ำ 14 หน้า): ปุ่ม "ล้างตัวกรอง" โผล่เมื่อมีค่าค้าง (เทียบกับ `allValue` ของแต่ละตัว) และตั้งกลับให้เองถ้าไม่ส่ง `onClear`; ตัวกรองแบบอื่นใส่เป็น `children` + `active`
21
+ - **`SetupChecklist`** — รายการ "ต้องตั้งอะไรให้ครบ" (ยกจาก Pi Auth `SetupSteps`): ✓ เมื่อทำแล้ว, ขั้นบังคับถัดไปถูกเน้น (`aria-current="step"`), ขั้น `optional` ไม่นับ, ครบแล้วซ่อนทั้งกล่อง (`hideWhenComplete`); `action` รับ `{ current, done }` ให้แอปส่ง `LinkButton` เอง (barrel ไม่พึ่ง next)
22
+ - **`MonthStepper`** + **`useMonthCursor`** — เลือกเดือน ‹ เดือน › กดชื่อเดือนกลับเดือนนี้, `min`/`max`, `locale` (`th-TH` = พ.ศ., `th-TH-u-ca-gregory` = ค.ศ.), `format` long|short, `formatLabel` (ยกจาก DueBell `MonthSelector`)
23
+ - **`lib/month.ts`** (barrel): `MonthKey` (`"YYYY-MM"` แบบเดียวกับ `CalendarMonthGrid`), `parseMonthKey`, `isMonthKey`, `toMonthKey`, `currentMonthKey`, `shiftMonth` (ข้ามปีให้), `compareMonth`, `formatMonthKey` — แทน `CycleMonth` ของ DueBell
24
+ - `UiText` key ใหม่ (optional ทั้งหมด มีค่าไทย/อังกฤษใน `UI_TEXT_TH`/`UI_TEXT_EN`): `month`, `setup`, `filters`, `settings`, `mobileTabs`
25
+ - **ชุด component สำเร็จรูป (Simple\*)** — ทิศทางของแพ็กเกจ: แอปเขียน component เดียว + props แทนการประกอบ primitive หลายชิ้นซ้อนกันแบบ shadcn (ดู `docs/agent-rules.md` "ใช้ตัวสำเร็จรูปก่อน"):
26
+ - `SimpleDialog` (แทน Dialog 7 ชิ้น) / `SimpleSheet` (+ `side`) — trigger, title, description, children, `footer` (ReactNode หรือ `(close) => …`), `size`, controlled ได้; **ปุ่ม X อ่าน `UiText.close`** แก้ปัญหา "Close" แปลภาษาไม่ได้โดยไม่แตะไฟล์ shadcn
27
+ - `SimpleTabs` (items: value/label/content/icon/badge/disabled, `variant` default|line, `fullWidth`) / `SimpleAccordion` (`multiple`, `initialOpen`)
28
+ - `SimpleTooltip` (ห่อ element เดียว, content ว่าง = ไม่มี tooltip) / `SimplePopover` (trigger + title/description + เนื้อหา)
29
+ - `ActionMenu` — เมนูคำสั่งจาก array: icon, shortcut, separator, label, href/external, destructive, **`confirm` เปิด `ConfirmDialog` ก่อนเรียก onClick**; trigger ไม่ส่ง = ปุ่ม …
30
+ - `SimplePagination` + `paginationRange` — page/pageCount/onPageChange, จำนวนปุ่มคงที่, มือถือเหลือ "x / y"
31
+ - `SimpleTable<T>` — columns (key/header/cell/align/className) + rows + getRowKey + empty + onRowClick; ค่าจาก key ตรง ๆ ถ้าไม่ส่ง cell
32
+ - `SegmentedControl<T>` — เลือกทีละหนึ่งและต้องมีค่าเสมอ (กดตัวเดิมไม่หลุด), `count`, `icon`, `size`, `fullWidth`
33
+ - `UiText` key ใหม่: `close`, `pagination`
34
+ - **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`
35
+ - ไฟล์เดิมได้ class/ลำดับจาก upstream (ส่วนใหญ่เรียง class ใหม่) + sidebar deps ของ useEffect/useMemo ครบขึ้น
36
+ - **คงของที่แก้ไว้เอง** (ติดมาจาก supplychain ตั้งแต่ 0.1.0 — upstream ยังไม่มี): `scroll-area.tsx` (`data-[orientation=…]` ของ Base UI — ไม่งั้นแถบเลื่อนกว้าง 0 มองไม่เห็นทั้งแอป), `slider.tsx`, `tabs.tsx` (orientation แบบเดียวกัน), `table.tsx` (prop `containerClassName`)
37
+ - CLI รุ่นนี้เขียน `import { cn } from "cn"` — เปลี่ยนกลับเป็น `@suphark/ui/lib/utils` ทุกไฟล์ เพราะ `cn` ของแพ็กเกจรู้จักคลาสตามบทบาท (`text-body` ฯลฯ, 0.13.1) ส่วน `cn` ทั่วไปจะลบทิ้งเงียบ ๆ
38
+ - `hooks/use-mobile.ts` (CLI เขียนทับ) เติม `"use client"` คืน; biome ปิด `noDoubleEquals` เฉพาะ `components/ui/**` (`field.tsx` ของ upstream ใช้ `==`)
39
+ - 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)
40
+
5
41
  ## [0.19.0] — 2026-09-30
6
42
 
7
43
  ปิดรายการ "component เล็กที่ใช้ร่วม" ของแผนรวม UI ทุกแอป (ดู `todo/todo.md`) — เพิ่มอย่างเดียว ไม่มี prop เดิมเปลี่ยนความหมาย
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.19.0",
3
+ "version": "0.20.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 เอง",
@@ -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
@@ -35,6 +35,7 @@ import { AppBrand, type AppBrandProps } from "./app-brand";
35
35
  import { AppNav, type AppNavPinProps } from "./app-nav";
36
36
  import { HeaderBreadcrumbs, type HeaderBreadcrumbsProps } from "./header-breadcrumbs";
37
37
  import { HeaderCrumbs } from "./header-crumb";
38
+ import { MobileTabBar, type MobileTabItem } from "./mobile-tab-bar";
38
39
  import { SubNav } from "./sub-nav";
39
40
  import { isTabActive, TopTabs, topLevelTabs } from "./top-tabs";
40
41
  import { type AppNavGroup, searchNav } from "./types";
@@ -102,6 +103,11 @@ export interface AppShellProps {
102
103
  contentClassName?: string;
103
104
  /** จำกัดความกว้างเนื้อหา เช่น `max-w-5xl` (Pi Auth) — ไม่ส่ง = เต็มจอ (supplychain) */
104
105
  contentMaxWidth?: string;
106
+ /**
107
+ * แถบเมนูล่างบนมือถือ (< md) 3–5 แท็บ — เว้นที่ใต้เนื้อหาให้เอง; เดสก์ท็อปยังใช้ sidebar/แท็บบนตามเดิม
108
+ * (ยกจาก DueBell MobileNav)
109
+ */
110
+ mobileTabs?: ReadonlyArray<MobileTabItem>;
105
111
  children: ReactNode;
106
112
  }
107
113
 
@@ -147,6 +153,7 @@ function SidebarShell({
147
153
  defaultOpen = true,
148
154
  contentClassName,
149
155
  contentMaxWidth,
156
+ mobileTabs,
150
157
  children,
151
158
  }: AppShellProps) {
152
159
  const style = brandColor
@@ -224,12 +231,14 @@ function SidebarShell({
224
231
  <div
225
232
  className={cn(
226
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",
227
235
  contentMaxWidth && cn("mx-auto w-full", contentMaxWidth),
228
236
  contentClassName,
229
237
  )}
230
238
  >
231
239
  {children}
232
240
  </div>
241
+ {mobileTabs?.length ? <MobileTabBar items={mobileTabs} /> : null}
233
242
  </SidebarInset>
234
243
  </SidebarProvider>
235
244
  );
@@ -248,6 +257,7 @@ function TopShell({
248
257
  brandForeground,
249
258
  contentClassName,
250
259
  contentMaxWidth,
260
+ mobileTabs,
251
261
  children,
252
262
  }: AppShellProps) {
253
263
  const pathname = usePathname();
@@ -312,6 +322,7 @@ function TopShell({
312
322
  <div
313
323
  className={cn(
314
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",
315
326
  contentMaxWidth && cn("mx-auto", contentMaxWidth),
316
327
  )}
317
328
  >
@@ -335,6 +346,7 @@ function TopShell({
335
346
  {children}
336
347
  </main>
337
348
  </div>
349
+ {mobileTabs?.length ? <MobileTabBar items={mobileTabs} /> : null}
338
350
  </div>
339
351
  );
340
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,157 @@
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
+ requireText={spec.requireText}
146
+ requireTextLabel={spec.requireTextLabel}
147
+ destructive={spec.destructive ?? confirming.destructive}
148
+ pending={pending}
149
+ onConfirm={async () => {
150
+ await run(confirming.onClick);
151
+ setConfirming(null);
152
+ }}
153
+ />
154
+ )}
155
+ </>
156
+ );
157
+ }
@@ -66,6 +66,8 @@ export function ConfirmActionButton({
66
66
  description={spec.description}
67
67
  confirmLabel={spec.confirmLabel}
68
68
  cancelLabel={spec.cancelLabel}
69
+ requireText={spec.requireText}
70
+ requireTextLabel={spec.requireTextLabel}
69
71
  destructive={destructive}
70
72
  pending={pending}
71
73
  onConfirm={() => {
@@ -0,0 +1,108 @@
1
+ "use client";
2
+
3
+ import { Button } from "@suphark/ui/components/ui/button";
4
+ import { NativeSelect, NativeSelectOption } from "@suphark/ui/components/ui/native-select";
5
+ import { cn } from "@suphark/ui/lib/utils";
6
+ import { XIcon } from "lucide-react";
7
+ import type { ReactNode } from "react";
8
+ import { SearchInput } from "./form/search-input";
9
+ import { useUiText } from "./ui-text";
10
+
11
+ export interface FilterBarFilter {
12
+ id: string;
13
+ /** aria-label ของ select (ไม่มี label ที่มองเห็น) */
14
+ label: string;
15
+ value: string;
16
+ onValueChange: (value: string) => void;
17
+ options: ReadonlyArray<{ value: string; label: string }>;
18
+ /** ค่าที่หมายถึง "ทั้งหมด" (ไม่กรอง) — ใช้ตัดสินว่ามีตัวกรองค้างไหม และค่าที่ปุ่มล้างตั้งกลับ (ค่าเริ่มต้น "") */
19
+ allValue?: string;
20
+ className?: string;
21
+ }
22
+
23
+ /**
24
+ * แถบค้นหา + ตัวกรองเหนือตาราง/รายการแบบ client-side (ยกจาก Pi Auth — มี 14 หน้าเขียนซ้ำ):
25
+ * ช่องค้นหาซ้าย · select ตัวกรองขวา · ปุ่ม "ล้างตัวกรอง" โผล่เมื่อมีอะไรค้าง
26
+ * ตารางที่ใช้ tanstack ให้ใช้ toolbar ของ `@suphark/ui/data-table` แทน
27
+ *
28
+ * <FilterBar
29
+ * search={{ value: q, onValueChange: setQ, placeholder: "ค้นหาชื่อหรืออีเมล" }}
30
+ * filters={[{ id: "role", label: "บทบาท", value: role, onValueChange: setRole, allValue: "ALL",
31
+ * options: [{ value: "ALL", label: "ทุกบทบาท" }, { value: "admin", label: "ผู้ดูแล" }] }]}
32
+ * />
33
+ */
34
+ export function FilterBar({
35
+ search,
36
+ filters = [],
37
+ onClear,
38
+ active,
39
+ children,
40
+ className,
41
+ }: {
42
+ search?: { value: string; onValueChange: (value: string) => void; placeholder?: string };
43
+ filters?: FilterBarFilter[];
44
+ /** ไม่ส่ง = ล้างช่องค้นหา + ตั้งทุกตัวกรองกลับ `allValue` ให้เอง */
45
+ onClear?: () => void;
46
+ /** บังคับสถานะ "มีตัวกรองค้าง" (เช่นมีตัวกรองใน children) — ไม่ส่ง = คำนวณจาก search/filters */
47
+ active?: boolean;
48
+ /** ตัวกรองแบบอื่นที่แอปประกอบเอง (DateRangePicker, ToggleGroup …) วางต่อจาก select */
49
+ children?: ReactNode;
50
+ className?: string;
51
+ }) {
52
+ const text = useUiText();
53
+ const isActive =
54
+ active ?? ((search?.value ?? "") !== "" || filters.some((f) => f.value !== (f.allValue ?? "")));
55
+
56
+ const clear = () => {
57
+ if (onClear) return onClear();
58
+ search?.onValueChange("");
59
+ for (const f of filters) f.onValueChange(f.allValue ?? "");
60
+ };
61
+
62
+ return (
63
+ <div
64
+ data-slot="filter-bar"
65
+ role="search"
66
+ aria-label={text.filters?.label ?? "ตัวกรอง"}
67
+ className={cn(
68
+ "flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between",
69
+ className,
70
+ )}
71
+ >
72
+ {search && (
73
+ <SearchInput
74
+ value={search.value}
75
+ onValueChange={search.onValueChange}
76
+ placeholder={search.placeholder}
77
+ groupClassName="lg:max-w-sm flex-1"
78
+ />
79
+ )}
80
+ {(filters.length > 0 || children || isActive) && (
81
+ <div className="flex flex-wrap items-center gap-2">
82
+ {filters.map((f) => (
83
+ <NativeSelect
84
+ key={f.id}
85
+ aria-label={f.label}
86
+ value={f.value}
87
+ onChange={(e) => f.onValueChange(e.target.value)}
88
+ className={cn("w-auto max-w-48", f.className)}
89
+ >
90
+ {f.options.map((o) => (
91
+ <NativeSelectOption key={o.value} value={o.value}>
92
+ {o.label}
93
+ </NativeSelectOption>
94
+ ))}
95
+ </NativeSelect>
96
+ ))}
97
+ {children}
98
+ {isActive && (
99
+ <Button type="button" variant="ghost" size="sm" onClick={clear}>
100
+ <XIcon />
101
+ {text.filters?.clear ?? "ล้างตัวกรอง"}
102
+ </Button>
103
+ )}
104
+ </div>
105
+ )}
106
+ </div>
107
+ );
108
+ }