@suphark/ui 0.6.1 → 0.8.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.8.0] — 2026-09-21
6
+
7
+ รอบ feedback หลังทุกแอปย้ายมา 0.7.0 ครบ 6/6 — สเปกจาก Pi Done (ColorPicker, CommandPalette client-side), Pi Mail (ActionForm), Pi Files (file explorer 4 ตัว) ทำรวดเดียว; minor ทั้งหมด ไม่เปลี่ยน API เดิม
8
+
9
+ ### Added
10
+
11
+ - **`ColorPicker<T>`** + **`ColorSwatch`** (barrel) — กริดเลือกสีจากชุดที่แอปส่ง `colors[{ value, label, swatchClassName? | swatch? (CSS) }]` (แพ็กเกจไม่ bundle palette): ปุ่ม `aria-pressed` + `aria-label`/`title`, ที่เลือก = `ring-2 ring-primary ring-offset-1` + ✓ ขาว, hover scale-105, roving tabindex + ลูกศร ↑↓←→/Home/End (จำนวนคอลัมน์อ่านจาก grid จริง), `columnsClassName` (grid-cols-5), `size` sm|md (size-7/size-8), `language`/`text`; `ColorSwatch` จุดสี size-3.5 สำหรับ trigger/badge (`color` CSS หรือ class) — ไม่มี popover ในตัว แอปครอบเองแบบ IconPicker (สเปกจาก Pi Done แทน metadata-color-picker 9 จุด)
12
+ - **`CommandPalette` โหมด client-side / controlled** (ขอจาก Pi Done): `filter(query) => CommandPaletteSection[]` คำนวณฝั่งแอปทุกครั้งที่ query เปลี่ยน (เรียกแม้ query ว่าง — ใช้แสดง action/ล่าสุด, ไม่ debounce) แสดงก่อนผลจาก `search`; item ใน section รับ `onSelect()` (action ล้วน `href` ไม่บังคับแล้ว; มีทั้งคู่เรียก onSelect ก่อนแล้วไป href) + `shortcut` (ป้ายท้ายรายการ); `open`/`onOpenChange` คุมจากภายนอก, `showTrigger={false}` ซ่อนปุ่มของแพ็กเกจ, `shortcut={null}` ไม่ดักคีย์ลัด (แอปดักเอง); type `CommandPaletteSectionItem`
13
+ - **`ActionForm`** (ข้อสังเกตจาก Pi Mail): `hideSuccessMessage` ซ่อนข้อความสำเร็จเมื่อแอปแจ้งผลเอง (ใน compact ข้อความดันปุ่มตกบรรทัด — ยังแสดง error), `submitDisabled` + `submitProps` (variant/size/className ของปุ่มส่ง); **`FormState<TData>`** — `ok: true` มี `data?: TData` และ `ActionForm<TData>`/`FormDialog<TData>`/`FormAction<TData>` generic → `onSuccess(s)` ได้ `s.data` ไม่ต้อง cast (`TData` ค่าเริ่มต้น `undefined` — โค้ดเดิมไม่ต้องแก้)
14
+ - **`TreeView`** (barrel, สเปกจาก Pi Files) — ต้นไม้ทั่วไป `role=tree` ตาม WAI-ARIA (treeitem มี aria-level/expanded/selected, roving tabindex): `nodes[{ id, label, icon?, hasChildren?, children?, disabled?, badge? }]`, `selectedId`/`onSelect(id, node)`, `expandedIds`/`defaultExpandedIds`/`onExpandedChange` (controlled ได้), `loadChildren(id, node)` โหลดลูก lazy (Spinner ระหว่างโหลด, cache ในตัว, error = ลูกว่าง), `onDrop(targetId, e, node)`/`canDrop` HTML5 dnd ไฮไลต์ node (`data-drop-target`), `renderLabel(node, { expanded, selected, level })`, คีย์บอร์ด ↑↓ เลื่อน → ขยาย/เข้าไปลูกแรก ← ยุบ/กลับแม่ Home/End Enter/Space เลือก, `indent` (12px/ระดับ), `emptyState`, `label`; สีตาม token ของ Sidebar (เลือก = `bg-sidebar-accent`) — ลูกศรยุบ/ขยายไม่ใช่ button (ไม่ซ้อน focusable ใน treeitem)
15
+ - **`Dropzone`** (barrel, สเปกจาก Pi Files) — พื้นที่ลากวางไฟล์: การ์ดเดี่ยว (เส้นประ + ไอคอน + ข้อความ + ปุ่มเลือกไฟล์; คลิกการ์ด/ปุ่ม/Enter เปิด picker) หรือ `overlay` ห่อ `children` แสดงชั้น "วางไฟล์ที่นี่" เฉพาะตอนลากไฟล์เข้าหน้า (ฟัง window + กัน browser เปิดไฟล์เมื่อวางนอกโซน); `onFiles(File[])`, `multiple` (true), `accept` (นามสกุล/กลุ่ม/mime — export `matchesAccept`), `maxBytes`, `onRejected(files, "size" | "type" | "multiple")`, `disabled`, `directory` (`webkitdirectory` + วางโฟลเดอร์ไล่อ่านไฟล์ข้างในผ่าน `webkitGetAsEntry`), `hint` (ค่าเริ่มต้นแสดง maxBytes), `icon`, `language`/`text`; data-attribute `data-active`/`data-disabled`
16
+ - **`UploadList`** (barrel, สเปกจาก Pi Files) — รายการอัปโหลด `items[{ id, name, sizeBytes, progress 0–1, status queued|uploading|done|error|aborted, error?, mime? }]` พร้อม `ProgressBar` ต่อแถว (queued จาง), `FileTypeIcon`, สรุป "x/y เสร็จ"/"เสร็จทั้งหมด" (aria-live), `onCancel`/`onRetry`/`onClearDone`/`onClose`, ยุบ/ขยาย (`defaultCollapsed`), `floating` มุมล่างขวาแบบ Drive (w-80 ไม่เกินจอ), `title`, `language`/`text`; `items` ว่าง = ไม่ render
17
+ - **`FileTypeIcon`** + **`getFileKind(mime, filename)`** (barrel) — ไอคอน lucide ตามชนิดไฟล์ (folder/image/video/audio/pdf/doc/sheet/slides/zip/code/text/default) จาก mime ก่อน นามสกุลสำรอง สีตาม token (pdf destructive, sheet success, folder/slides warning, สื่อ primary), `size` px, `data-kind`; **`formatBytes(bytes, locale)`** (lib/formatters) ฐาน 1024 "1.5 KB"/"12 MB"
18
+ - showcase: หน้า `color-picker` (กริด + ครอบ Popover), `tree-view` (lazy + drop ไฟล์จริง), `dropzone` (การ์ด + overlay), `upload-list` (mock progress + ลอย), `file-type-icon`; `command-palette` เพิ่มแบบ client-side คุม open เอง; `action-form` เพิ่ม hideSuccessMessage/submitDisabled/data (SecretReveal) — 95 หน้า; tests 17 กรณีเพิ่ม (รวม 119)
19
+
20
+ ## [0.7.0] — 2026-09-20
21
+
22
+ component ระดับแอปที่ Pi Auth และ Pi Done เขียนซ้ำกัน — สเปกจาก Pi Auth ออกแบบให้ทั้งสองแอปใช้ร่วม (ไม่พึ่ง i18n/auth ของแอป ทุกข้อความรับเป็น prop หรือ `UiText`)
23
+
24
+ ### Added
25
+
26
+ - **`CommandPalette`** (`@suphark/ui/next`) — ค้นหาแบบ ⌘K/Ctrl+K ทั้งแอป: `pages` (หน้าในเมนูที่แอปกรองสิทธิ์แล้ว — กรองฝั่ง client ตาม label/group/href/keywords) + `search?(query) => Promise<CommandPaletteSection[]>` (server action ของแอป เรียกเมื่อ ≥ `minQuery` ตัว, debounce `debounceMs` 150, เก็บ query คู่ผล ผลล่าสุดเท่านั้นที่แสดง), `compact` (ปุ่มไอคอนใน header) หรือปุ่มเต็มกว้าง + `Kbd` (⌘K/CtrlK ตาม platform หลัง mount) สำหรับ `sidebarHeader`, `shortcut` (ค่าเริ่มต้น k), `text` (fallback `UiText.palette`), `onNavigate` (ค่าเริ่มต้น router.push + ปิด); `Command shouldFilter={false}`, `CommandEmpty` แสดง "กำลังค้นหา…" ขณะ pending; types `CommandPaletteItem`, `CommandPaletteSection`, `CommandPaletteText`
27
+ - **`OrganizationSwitcher`** (`@suphark/ui/next`) — ตัวสลับองค์กร/tenant แบบ Clerk บน `HeaderCrumb` + `DropdownMenu`: `organizations[{ id, name, slug?, logoUrl? }]`, `activeId`, `onSwitch(id)` (server action — component ทำ useTransition + `router.refresh()` ปิดได้ด้วย `refresh={false}`), `placement="header"` (HeaderCrumb ใน layout top) / `"sidebar"` (ปุ่มเต็มกว้าง + SwitcherChevron ใน `sidebarHeader`), `fallbackLabel`/`heading` (ค่าเริ่มต้น `UiText.switcher`), `hint(org)` (ค่าเริ่มต้น `/slug`), `footer` (slot ท้ายเมนู เช่น สร้างองค์กร); องค์กรเดียว = ไม่มีเมนู, active มี ✓ และกดไม่ทำอะไร, pending ปิดปุ่ม, โลโก้ `<img size-5>` หรือ Building2; types `OrganizationOption`, `OrganizationSwitcherProps`
28
+ - `UiText.palette` / `UiText.switcher` (optional, มีค่าเริ่มต้นไทย/อังกฤษ)
29
+ - **`IconPicker`** (barrel, generic `T extends string`) — เลือกไอคอนจากชุดที่แอปส่ง `icons[{ name, icon: LucideIcon, labelTh, labelEn, keywords? }]` (แพ็กเกจไม่ bundle ชุดไอคอน): ค้นหา name/labelTh/labelEn/keywords ผ่าน `useDeferredValue`, preview ไอคอน+ชื่อด้านบน, กริด `SelectionButton` (aria-pressed, title/aria-label ตาม `language` th/en), `accentColor` (inline style สี dynamic ของผู้ใช้), `heightClassName`/`columnsClassName`, `text` ทับข้อความ — สเปกจาก DueBell ให้ Pi Done (metadata icon) ใช้ร่วม
30
+ - **`SelectionButton`** (barrel) — ปุ่ม toggle `aria-pressed` + สีเน้นเมื่อเลือก (Button outline) — chip ประเภท/ตัวกรอง, ช่องใน IconPicker (ยกจาก DueBell)
31
+ - **`CalendarMonthGrid<T>`** (barrel) — ตารางเดือนเลือกวันที่แอปคุมเนื้อหาต่อวัน: `month "YYYY-MM"`, `selectedDay`, `onSelectDay(day, date)`, `itemsByDay`, `renderDay(ctx)` (คืน null = ✓ ของแพ็กเกจเมื่อ complete), `dayState(items, ctx) → { complete?, hasItems? }` → data-attribute `data-selected/today/has-items/complete/weekend` ให้ CSS ของแอปแต่ง, (data-attribute เป็น boolean มี/ไม่มี — CSS ใช้ `[data-selected]`), `weekStartsOn 0|1`, `weekdayLabels` (export `WEEKDAYS_TH`/`WEEKDAYS_EN`), `today` (ไม่ส่ง = browser หลัง mount ผ่าน `useMounted` — SSR ได้ไม่ mismatch), `dayLabel` (aria-label "วันที่ N มี M รายการ"), `legend`, `dayClassName`; ช่องว่างก่อนวันที่ 1 aspect-square; ปุ่มวัน `aria-pressed` — สเปกจาก DueBell + Pi Done
32
+ - **`ProgressBar`** (barrel) — แถบความคืบหน้าตั้ง `tone` (default/success/warning/destructive), `size` sm/md/lg, `label`, `showValue`/`format`, `trackClassName`/`indicatorClassName`/`indicatorStyle` (สี dynamic) จาก props — `Progress` ของ shadcn ต่อท้าย Track+Indicator ให้เสมอ ส่ง children เข้าไปจะได้ 2 แถบ (รายงานจาก DueBell) จึงประกอบจาก `ProgressPrimitive.Root` โดยตรง
33
+ - showcase: หน้า `icon-picker`, `selection-button`, `calendar-month-grid`, `progress-bar`; tests 6 กรณีเพิ่ม (รวม 100)
34
+ - **error รายช่องในฟอร์ม server action** (ขอจาก Pi Mail): `FormState` แบบ `ok: false` รับ `errors?: Record<string, string>`; `ActionForm` และ `FormDialog` รับ `children` เป็นฟังก์ชัน `({ state, pending, errors, fieldError }) => ReactNode` (type `FormRenderContext`) → `<LabeledField error={fieldError("email")}>` ไม่ต้องมี hook ของแอป; `FormMessage` ยังแสดง `error` รวมเหมือนเดิม
35
+ - `UserMenu`: prop `menuLabel` + `UiText.userMenu`/`signOut` (ขอจาก Pi PDF — แอปอังกฤษได้ label ไทย); `ThemeMenuItems`: prop `onThemeChange(theme)` สำหรับบันทึกธีมเป็น preference ของบัญชี + ข้อความจาก `UiText.theme`
36
+ - `LabeledField`: prop `htmlFor` — ส่งมาแล้ว label ไม่ครอบ control (ผูกด้วย id) ให้ tooling ที่ไม่คำนวณ nested label เห็น accessible name (ข้อสังเกตจาก Pi Files)
37
+ - `DataTable`: `showToolbar` / `showPagination` (ค่าเริ่มต้น true) ปิดแถบค้นหา/ตัวกรอง/View และ pagination สำหรับตารางตั้งค่าเล็ก ๆ (ขอจาก Pi Files)
38
+
39
+ ### Fixed
40
+
41
+ - **`AppShell` (layout sidebar) มี `<main>` ซ้อน 2 ชั้น** (รายงานจาก DueBell): `SidebarInset` ของ shadcn เป็น `<main>` อยู่แล้ว → ใส่ `id={APP_MAIN_ID}`/`tabIndex` ที่ SidebarInset และเลิกสร้าง `<main>` ชั้นใน (เป็น `<div>`); layout top ยังมี `<main>` ตัวเดียวเหมือนเดิม
42
+ - showcase: หน้า `command-palette`, `organization-switcher`; `/shell` (sidebarHeader + ยุบเป็นไอคอน) และ `/shell-top` (crumb องค์กร) ใช้สองตัวนี้จริงแทน mock เดิม; tests 5 กรณี (คีย์ลัด toggle + กรอง keywords, search debounce/ผลล่าสุด/push+ปิด, org เดียวไม่มีเมนู, active ไม่เรียก onSwitch + refresh, fallback label)
43
+
5
44
  ## [0.6.1] — 2026-09-20
6
45
 
7
46
  ### Added
package/README.md CHANGED
@@ -60,6 +60,7 @@ import { ThemeProvider, Toaster } from "@suphark/ui";
60
60
  ```ts
61
61
  // ทุกอย่างที่ไม่พึ่ง Next — primitives, shared, theme, hooks, cn
62
62
  import { Button, Card, FormFieldInput, StatusBadge, ThemeToggle, ConfirmDialog, useConfirm, cn } from "@suphark/ui";
63
+ // file explorer / picker: TreeView, Dropzone, UploadList, FileTypeIcon, ColorPicker, IconPicker, CalendarMonthGrid ก็อยู่ barrel
63
64
 
64
65
  // ของที่ใช้ next/navigation หรือ next/link + โครงแอป
65
66
  import { AppShell, UserMenu, filterNav, FormDialog, RowActions, LinkButton, RefreshButton, PageShell } from "@suphark/ui/next";
@@ -82,12 +83,12 @@ import { ChartContainer } from "@suphark/ui/components/ui/chart";
82
83
  | โฟลเดอร์ | คืออะไร | กติกา |
83
84
  |---|---|---|
84
85
  | `src/components/ui/*` | shadcn/ui ต้นฉบับ 59 ตัว (+ `__tests__`) | **ห้ามแก้มือ** — เพิ่ม/อัปเดตด้วย `pnpm dlx shadcn@latest add <name>` ใน `packages/ui` (`components.json` ตั้ง alias ไว้แล้ว) |
85
- | `src/components/shared/*` | ของที่เราประกอบเอง: ปุ่มมาตรฐาน (`button/`), form-field บน react-hook-form (`form/`), layout, theme, combobox, และชุดจาก Pi Auth (FormDialog, NameIdFields, StatusBadge, RowMenu, …) | แก้ได้ แต่คง props เดิมให้มากที่สุด (minor = เพิ่ม, major = เปลี่ยน) |
86
+ | `src/components/shared/*` | ของที่เราประกอบเอง: ปุ่มมาตรฐาน (`button/`), form-field บน react-hook-form (`form/`), layout, theme, combobox, ชุดจาก Pi Auth (FormDialog, NameIdFields, StatusBadge, RowMenu, …), picker (IconPicker/ColorPicker/CalendarMonthGrid), file explorer (TreeView/Dropzone/UploadList/FileTypeIcon) | แก้ได้ แต่คง props เดิมให้มากที่สุด (minor = เพิ่ม, major = เปลี่ยน) |
86
87
  | `src/components/data-table/*` | DataTable บน tanstack (toolbar, faceted filter, pagination, view options, row actions, ลากเรียงแถว) | entry `@suphark/ui/data-table` |
87
88
  | `src/components/pwa/*` | PWA: ลงทะเบียน/อัปเดต service worker, การ์ดชวนติดตั้ง, ดึงลงเพื่อรีเฟรช | entry `@suphark/ui/pwa` |
88
89
  | `src/components/app-shell/*` | AppShell (sidebar + header + เนื้อหา), AppNav, AppBrand, UserMenu, HeaderBreadcrumbs, NavLink, filterNav/searchNav | export ผ่าน `@suphark/ui/next` (ใช้ next/link, next/navigation) |
89
90
  | `src/components/design-system/*` | หน้า showcase (render ใน `apps/showcase`) | ไม่อยู่ใน barrel |
90
- | `src/lib/*`, `src/hooks/*` | `cn`, formatter วันที่ไทย, สีสถานะ, permission context, `useIsMobile`, `useCopyToClipboard` | |
91
+ | `src/lib/*`, `src/hooks/*` | `cn`, formatter วันที่ไทย + `formatBytes`, สีสถานะ, permission context, `useIsMobile`, `useCopyToClipboard`, `useMounted`, `useTicker` | |
91
92
  | `src/styles.css` | token (slate + success/warning/sidebar/chart), radius, ฟอนต์, scrollbar, dark variant | |
92
93
  | `scripts/*.mjs` | audit บังคับ: Base UI props (`default*`/`onSelect`), typography scale | แอปปลายทางรันได้จาก `node_modules/@suphark/ui/scripts` |
93
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.6.1",
3
+ "version": "0.8.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 เอง",
@@ -114,7 +114,7 @@ export function AppShell(props: AppShellProps) {
114
114
  return <SidebarShell {...props} />;
115
115
  }
116
116
 
117
- /** id ของ <main> ที่ AppShell สร้าง — ใช้อ้างจาก skip link / anchor */
117
+ /** id ของ <main> ของ AppShell (sidebar: SidebarInset, top: main ในเนื้อหา) — ใช้อ้างจาก skip link / anchor */
118
118
  export const APP_MAIN_ID = "app-main";
119
119
 
120
120
  /** ลิงก์ "ข้ามไปเนื้อหา" สำหรับคีย์บอร์ด/screen reader — มองไม่เห็นจนกด Tab ตัวแรก */
@@ -180,7 +180,8 @@ function SidebarShell({
180
180
  collapsible={collapsible}
181
181
  />
182
182
 
183
- <SidebarInset>
183
+ {/* SidebarInset ของ shadcn เป็น <main> อยู่แล้ว → ใส่ id/tabIndex ให้ skip link ที่นี่ ไม่สร้าง <main> ซ้อน */}
184
+ <SidebarInset id={APP_MAIN_ID} tabIndex={-1} className="outline-none">
184
185
  {showHeader && (
185
186
  <div className="sticky top-0 z-40 shrink-0 bg-background print:static">
186
187
  {headerOpts.banner}
@@ -216,17 +217,15 @@ function SidebarShell({
216
217
  </header>
217
218
  </div>
218
219
  )}
219
- <main
220
- id={APP_MAIN_ID}
221
- tabIndex={-1}
220
+ <div
222
221
  className={cn(
223
- "flex flex-1 flex-col gap-4 p-4 outline-none md:gap-6 md:p-6 print:overflow-visible",
222
+ "flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6 print:overflow-visible",
224
223
  contentMaxWidth && cn("mx-auto w-full", contentMaxWidth),
225
224
  contentClassName,
226
225
  )}
227
226
  >
228
227
  {children}
229
- </main>
228
+ </div>
230
229
  </SidebarInset>
231
230
  </SidebarProvider>
232
231
  );
@@ -0,0 +1,306 @@
1
+ "use client";
2
+
3
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
4
+ import { Button } from "@suphark/ui/components/ui/button";
5
+ import {
6
+ Command,
7
+ CommandDialog,
8
+ CommandEmpty,
9
+ CommandGroup,
10
+ CommandInput,
11
+ CommandItem,
12
+ CommandList,
13
+ CommandShortcut,
14
+ } from "@suphark/ui/components/ui/command";
15
+ import { Kbd } from "@suphark/ui/components/ui/kbd";
16
+ import { useMounted } from "@suphark/ui/hooks/use-mounted";
17
+ import { cn } from "@suphark/ui/lib/utils";
18
+ import { Search } from "lucide-react";
19
+ import { useRouter } from "next/navigation";
20
+ import { type ReactNode, useEffect, useMemo, useRef, useState, useTransition } from "react";
21
+
22
+ export interface CommandPaletteItem {
23
+ href: string;
24
+ label: string;
25
+ /** ชื่อกลุ่ม (แสดงเป็น shortcut ท้ายรายการ และใช้ค้นหา) */
26
+ group?: string;
27
+ icon?: ReactNode;
28
+ /** คำค้นเพิ่มเติมนอกจาก label/group/href */
29
+ keywords?: string[];
30
+ }
31
+
32
+ export interface CommandPaletteSectionItem {
33
+ id: string;
34
+ /** ไปหน้า (router.push / `onNavigate`) — ไม่ส่งถ้าเป็น action ล้วน */
35
+ href?: string;
36
+ /** action ฝั่ง client (สร้างงาน, สลับ sidebar …) — เรียกหลังปิดกล่อง; ถ้ามีทั้งคู่ เรียก onSelect ก่อนแล้วไป href */
37
+ onSelect?: () => void;
38
+ label: string;
39
+ description?: string;
40
+ icon?: ReactNode;
41
+ /** ป้ายคีย์ลัดท้ายรายการ (แสดงอย่างเดียว เช่น "G H") */
42
+ shortcut?: string;
43
+ }
44
+
45
+ export interface CommandPaletteSection {
46
+ key: string;
47
+ heading: string;
48
+ items: CommandPaletteSectionItem[];
49
+ }
50
+
51
+ export interface CommandPaletteText {
52
+ placeholder: string;
53
+ title: string;
54
+ description: string;
55
+ empty: string;
56
+ searching: string;
57
+ /** หัวกลุ่ม "หน้า" */
58
+ pages: string;
59
+ }
60
+
61
+ const DEFAULT_TEXT: CommandPaletteText = {
62
+ placeholder: "ค้นหาหรือสั่งงาน…",
63
+ title: "ค้นหา",
64
+ description: "พิมพ์เพื่อค้นหาหน้าและข้อมูล",
65
+ empty: "ไม่พบผลลัพธ์",
66
+ searching: "กำลังค้นหา…",
67
+ pages: "หน้า",
68
+ };
69
+
70
+ /** ⌘ บน Mac, Ctrl ที่อื่น — อ่านฝั่ง client หลัง mount เท่านั้น (กัน hydration mismatch) */
71
+ function useModKeyLabel(mounted: boolean): string {
72
+ return useMemo(() => {
73
+ if (!mounted) return "Ctrl";
74
+ const p = `${navigator.platform ?? ""} ${navigator.userAgent ?? ""}`;
75
+ return /mac|iphone|ipad/i.test(p) ? "⌘" : "Ctrl";
76
+ }, [mounted]);
77
+ }
78
+
79
+ /**
80
+ * ค้นหาแบบ ⌘K / Ctrl+K ทั้งแอป: หน้าในเมนู (แอปกรองสิทธิ์มาแล้ว) กรองฝั่ง client + ส่วนจาก server action
81
+ * (`search`) เมื่อพิมพ์ ≥ `minQuery` ตัว (debounce; ผลของ query ล่าสุดเท่านั้นที่แสดง) + ส่วนที่แอปคำนวณเองฝั่ง client
82
+ * (`filter(query)` — ข้อมูลใน memory/offline-first, รายการ action ที่มี `onSelect`) แสดงก่อนผลจาก server
83
+ * เปิด/ปิดคุมจากภายนอกได้ (`open`/`onOpenChange`) และซ่อนปุ่มเรียกของแพ็กเกจ (`showTrigger={false}`) เมื่อแอปมีปุ่ม/คีย์ลัดเอง
84
+ * ปุ่มเรียก: `compact` = ไอคอนใน header, ค่าเริ่มต้น = ปุ่มเต็มกว้าง + Kbd ใน `sidebarHeader` ของ AppShell
85
+ * ข้อความรับเป็น prop `text` (ค่าเริ่มต้นจาก `UiText.palette` หรือไทย) — ไม่พึ่ง i18n ของแอป
86
+ * ยกจาก Pi Auth (features/console/command-palette) ให้ Pi Done/DueBell ใช้ร่วม
87
+ */
88
+ export function CommandPalette({
89
+ pages,
90
+ search,
91
+ filter,
92
+ compact = false,
93
+ showTrigger = true,
94
+ open: controlledOpen,
95
+ onOpenChange,
96
+ shortcut = "k",
97
+ minQuery = 2,
98
+ debounceMs = 150,
99
+ text: textProp,
100
+ onNavigate,
101
+ className,
102
+ }: {
103
+ pages: CommandPaletteItem[];
104
+ /** server action ของแอป — คืน section ตามลำดับที่ต้องการแสดง */
105
+ search?: (query: string) => Promise<CommandPaletteSection[]>;
106
+ /**
107
+ * section ที่แอปคำนวณเองฝั่ง client ทุกครั้งที่ query เปลี่ยน (query ว่างก็เรียก — ใช้แสดงรายการ action/ล่าสุด);
108
+ * ไม่ debounce เพราะไม่มี IO — ให้ memo ฝั่งแอปถ้าชุดข้อมูลใหญ่
109
+ */
110
+ filter?: (query: string) => CommandPaletteSection[];
111
+ compact?: boolean;
112
+ /** ซ่อนปุ่มเรียกของแพ็กเกจ (แอปเปิดเองผ่าน `open` หรือคีย์ลัด) */
113
+ showTrigger?: boolean;
114
+ /** คุมการเปิดจากภายนอก (ใช้คู่ `onOpenChange`) — ไม่ส่ง = คุมเองในตัว */
115
+ open?: boolean;
116
+ onOpenChange?: (open: boolean) => void;
117
+ /** ตัวอักษรคีย์ลัดร่วมกับ ⌘/Ctrl (ค่าเริ่มต้น "k"); `null` = ไม่ดักคีย์ลัด (แอปดักเอง) */
118
+ shortcut?: string | null;
119
+ minQuery?: number;
120
+ debounceMs?: number;
121
+ text?: Partial<CommandPaletteText>;
122
+ /** ค่าเริ่มต้น router.push + ปิดกล่อง — ครอบเพื่อ prefetch/analytics แล้วเรียกต่อเองได้ */
123
+ onNavigate?: (href: string) => void;
124
+ className?: string;
125
+ }) {
126
+ const ui = useUiText();
127
+ const text: CommandPaletteText = { ...DEFAULT_TEXT, ...ui.palette, ...textProp };
128
+ const router = useRouter();
129
+ const mounted = useMounted();
130
+ const mod = useModKeyLabel(mounted);
131
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
132
+ const open = controlledOpen ?? uncontrolledOpen;
133
+ const setOpen = (next: boolean) => {
134
+ if (controlledOpen === undefined) setUncontrolledOpen(next);
135
+ onOpenChange?.(next);
136
+ };
137
+ const [query, setQuery] = useState("");
138
+ const [result, setResult] = useState<{ query: string; sections: CommandPaletteSection[] } | null>(
139
+ null,
140
+ );
141
+ const [pending, start] = useTransition();
142
+
143
+ // คีย์ลัดจากที่ไหนก็ได้ในแอป
144
+ const openRef = useRef(open);
145
+ openRef.current = open;
146
+ const setOpenRef = useRef(setOpen);
147
+ setOpenRef.current = setOpen;
148
+ useEffect(() => {
149
+ if (!shortcut) return;
150
+ const key = shortcut.toLowerCase();
151
+ const onKey = (e: KeyboardEvent) => {
152
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === key) {
153
+ e.preventDefault();
154
+ setOpenRef.current(!openRef.current);
155
+ }
156
+ };
157
+ window.addEventListener("keydown", onKey);
158
+ return () => window.removeEventListener("keydown", onKey);
159
+ }, [shortcut]);
160
+
161
+ const q = query.trim();
162
+ const shouldSearch = Boolean(search) && q.length >= minQuery;
163
+
164
+ // ค้นหาฝั่ง server หลังหยุดพิมพ์ — เก็บ query คู่กับผล ผลที่ไม่ตรง query ปัจจุบันไม่แสดง
165
+ useEffect(() => {
166
+ if (!search || !shouldSearch) return;
167
+ const handle = setTimeout(() => {
168
+ start(async () => {
169
+ try {
170
+ const sections = await search(q);
171
+ setResult({ query: q, sections });
172
+ } catch {
173
+ setResult({ query: q, sections: [] });
174
+ }
175
+ });
176
+ }, debounceMs);
177
+ return () => clearTimeout(handle);
178
+ }, [q, search, shouldSearch, debounceMs]);
179
+
180
+ const localSections = filter ? filter(q) : [];
181
+ const remoteSections = shouldSearch && result?.query === q ? result.sections : [];
182
+ const sections = [...localSections, ...remoteSections];
183
+ const ql = q.toLowerCase();
184
+ const visiblePages = ql
185
+ ? pages.filter((p) =>
186
+ `${p.label} ${p.group ?? ""} ${p.href} ${(p.keywords ?? []).join(" ")}`
187
+ .toLowerCase()
188
+ .includes(ql),
189
+ )
190
+ : pages;
191
+ const nothing = visiblePages.length === 0 && sections.every((s) => s.items.length === 0);
192
+
193
+ function close() {
194
+ setOpen(false);
195
+ setQuery("");
196
+ }
197
+ function go(href: string) {
198
+ close();
199
+ if (onNavigate) onNavigate(href);
200
+ else router.push(href);
201
+ }
202
+ function pick(item: CommandPaletteSectionItem) {
203
+ close();
204
+ item.onSelect?.();
205
+ if (item.href) {
206
+ if (onNavigate) onNavigate(item.href);
207
+ else router.push(item.href);
208
+ }
209
+ }
210
+
211
+ const shortcutLabel = `${mod}${(shortcut ?? "k").toUpperCase()}`;
212
+
213
+ return (
214
+ <>
215
+ {!showTrigger ? null : compact ? (
216
+ <Button
217
+ type="button"
218
+ variant="ghost"
219
+ size="icon"
220
+ aria-label={text.placeholder}
221
+ title={shortcut ? `${text.placeholder} (${shortcutLabel})` : text.placeholder}
222
+ className={cn("text-muted-foreground", className)}
223
+ onClick={() => setOpen(true)}
224
+ >
225
+ <Search />
226
+ </Button>
227
+ ) : (
228
+ <Button
229
+ type="button"
230
+ variant="outline"
231
+ size="sm"
232
+ className={cn(
233
+ "h-8 w-full justify-start gap-2 bg-background px-2 font-normal text-muted-foreground",
234
+ className,
235
+ )}
236
+ onClick={() => setOpen(true)}
237
+ >
238
+ <Search />
239
+ <span className="flex-1 truncate text-left">{text.placeholder}</span>
240
+ {shortcut && <Kbd className="hidden sm:inline-flex">{shortcutLabel}</Kbd>}
241
+ </Button>
242
+ )}
243
+ <CommandDialog
244
+ open={open}
245
+ onOpenChange={(o) => (o ? setOpen(true) : close())}
246
+ title={text.title}
247
+ description={text.description}
248
+ >
249
+ <Command shouldFilter={false}>
250
+ <CommandInput
251
+ autoFocus
252
+ placeholder={text.placeholder}
253
+ value={query}
254
+ onValueChange={setQuery}
255
+ />
256
+ <CommandList>
257
+ {nothing && (
258
+ <CommandEmpty>{pending && shouldSearch ? text.searching : text.empty}</CommandEmpty>
259
+ )}
260
+ {visiblePages.length > 0 && (
261
+ <CommandGroup heading={text.pages}>
262
+ {visiblePages.map((p) => (
263
+ <CommandItem key={p.href} value={p.href} onSelect={() => go(p.href)}>
264
+ <span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4">
265
+ {p.icon}
266
+ </span>
267
+ <span className="flex-1 truncate">{p.label}</span>
268
+ {p.group && <CommandShortcut>{p.group}</CommandShortcut>}
269
+ </CommandItem>
270
+ ))}
271
+ </CommandGroup>
272
+ )}
273
+ {sections.map(
274
+ (s) =>
275
+ s.items.length > 0 && (
276
+ <CommandGroup key={s.key} heading={s.heading}>
277
+ {s.items.map((it) => (
278
+ <CommandItem
279
+ key={it.id}
280
+ value={`${s.key}:${it.id}`}
281
+ onSelect={() => pick(it)}
282
+ >
283
+ <span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4">
284
+ {it.icon}
285
+ </span>
286
+ <span className="flex-1 truncate">
287
+ {it.label}
288
+ {it.description && (
289
+ <span className="text-muted-foreground"> · {it.description}</span>
290
+ )}
291
+ </span>
292
+ {it.shortcut && <CommandShortcut>{it.shortcut}</CommandShortcut>}
293
+ </CommandItem>
294
+ ))}
295
+ </CommandGroup>
296
+ ),
297
+ )}
298
+ {pending && shouldSearch && !nothing && (
299
+ <div className="px-3 py-2 text-muted-foreground text-xs">{text.searching}</div>
300
+ )}
301
+ </CommandList>
302
+ </Command>
303
+ </CommandDialog>
304
+ </>
305
+ );
306
+ }
@@ -1,9 +1,21 @@
1
1
  export { AppBrand, type AppBrandProps } from "./app-brand";
2
2
  export { AppNav, AppNavGroupMenu, type AppNavPinProps } from "./app-nav";
3
3
  export { APP_MAIN_ID, AppShell, type AppShellProps } from "./app-shell";
4
+ export {
5
+ CommandPalette,
6
+ type CommandPaletteItem,
7
+ type CommandPaletteSection,
8
+ type CommandPaletteSectionItem,
9
+ type CommandPaletteText,
10
+ } from "./command-palette";
4
11
  export { HeaderBreadcrumbs, type HeaderBreadcrumbsProps } from "./header-breadcrumbs";
5
12
  export { HeaderCrumb, HeaderCrumbs } from "./header-crumb";
6
13
  export { NavLink } from "./nav-link";
14
+ export {
15
+ type OrganizationOption,
16
+ OrganizationSwitcher,
17
+ type OrganizationSwitcherProps,
18
+ } from "./organization-switcher";
7
19
  export { SubNav } from "./sub-nav";
8
20
  export { isTabActive, TopTabs, topLevelTabs } from "./top-tabs";
9
21
  export {
@@ -0,0 +1,150 @@
1
+ "use client";
2
+
3
+ import { SwitcherChevron } from "@suphark/ui/components/shared/switcher-chevron";
4
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
5
+ import { Button } from "@suphark/ui/components/ui/button";
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuGroup,
10
+ DropdownMenuItem,
11
+ DropdownMenuLabel,
12
+ DropdownMenuSeparator,
13
+ DropdownMenuTrigger,
14
+ } from "@suphark/ui/components/ui/dropdown-menu";
15
+ import { cn } from "@suphark/ui/lib/utils";
16
+ import { Building2, Check } from "lucide-react";
17
+ import { useRouter } from "next/navigation";
18
+ import { type ReactNode, useTransition } from "react";
19
+ import { HeaderCrumb } from "./header-crumb";
20
+
21
+ export interface OrganizationOption {
22
+ id: string;
23
+ name: string;
24
+ slug?: string;
25
+ logoUrl?: string | null;
26
+ }
27
+
28
+ export interface OrganizationSwitcherProps {
29
+ organizations: OrganizationOption[];
30
+ activeId: string | null;
31
+ /** แอปเรียก server action สลับองค์กร — component ทำ useTransition + router.refresh() หลัง resolve */
32
+ onSwitch: (id: string) => Promise<void> | void;
33
+ /** refresh หน้าหลังสลับ (ค่าเริ่มต้น true) */
34
+ refresh?: boolean;
35
+ /** ไม่มี active — ค่าเริ่มต้น `UiText.switcher.choose` */
36
+ fallbackLabel?: string;
37
+ /** หัว dropdown — ค่าเริ่มต้น `UiText.switcher.organization` */
38
+ heading?: string;
39
+ /** ข้อความจางท้ายชื่อ (ค่าเริ่มต้น `/slug` ถ้ามี) */
40
+ hint?: (org: OrganizationOption) => ReactNode;
41
+ /** slot ท้ายเมนู เช่น "สร้างองค์กร…", "จัดการองค์กร" */
42
+ footer?: ReactNode;
43
+ /** `header` = HeaderCrumb ใน header (layout top); `sidebar` = ปุ่มเต็มกว้างใน `sidebarHeader` ของ AppShell */
44
+ placement?: "header" | "sidebar";
45
+ className?: string;
46
+ }
47
+
48
+ function OrgLogo({ org }: { org: OrganizationOption | undefined }) {
49
+ if (org?.logoUrl)
50
+ return <img src={org.logoUrl} alt="" className="size-5 rounded object-contain" />;
51
+ return <Building2 className="size-3.5 text-muted-foreground" aria-hidden="true" />;
52
+ }
53
+
54
+ /**
55
+ * ตัวสลับองค์กร/tenant มาตรฐาน (แบบ Clerk OrganizationSwitcher) — ยกจาก Pi Auth TenantSwitcher ให้ Pi Done/DueBell ใช้ร่วม
56
+ * องค์กรเดียว = แสดงชื่อ ไม่มี dropdown; รายการที่ active มี ✓ และกดแล้วไม่ทำอะไร; ระหว่าง pending ปุ่ม disabled
57
+ */
58
+ export function OrganizationSwitcher({
59
+ organizations,
60
+ activeId,
61
+ onSwitch,
62
+ refresh = true,
63
+ fallbackLabel,
64
+ heading,
65
+ hint = (o) => (o.slug ? `/${o.slug}` : undefined),
66
+ footer,
67
+ placement = "header",
68
+ className,
69
+ }: OrganizationSwitcherProps) {
70
+ const text = useUiText();
71
+ const router = useRouter();
72
+ const [pending, start] = useTransition();
73
+ const active = organizations.find((o) => o.id === activeId);
74
+ const label = active ? active.name : (fallbackLabel ?? text.switcher?.choose ?? "เลือกองค์กร");
75
+ const single = organizations.length <= 1 && !footer;
76
+
77
+ const trigger =
78
+ placement === "sidebar" ? (
79
+ <Button
80
+ type="button"
81
+ variant="outline"
82
+ size="sm"
83
+ disabled={pending}
84
+ aria-label={heading ?? text.switcher?.organization ?? "องค์กร"}
85
+ className={cn("h-8 w-full justify-start gap-2 bg-background px-2 font-normal", className)}
86
+ >
87
+ <OrgLogo org={active} />
88
+ <span className="truncate">{label}</span>
89
+ {!single && <SwitcherChevron className="ml-auto" />}
90
+ </Button>
91
+ ) : (
92
+ <HeaderCrumb
93
+ icon={<OrgLogo org={active} />}
94
+ label={label}
95
+ hint={active ? hint(active) : undefined}
96
+ chevron={!single}
97
+ disabled={pending}
98
+ className={className}
99
+ />
100
+ );
101
+
102
+ if (single) return trigger;
103
+
104
+ function pick(id: string) {
105
+ if (id === activeId) return;
106
+ start(async () => {
107
+ await onSwitch(id);
108
+ if (refresh) router.refresh();
109
+ });
110
+ }
111
+
112
+ return (
113
+ <DropdownMenu>
114
+ <DropdownMenuTrigger render={trigger} />
115
+ <DropdownMenuContent align="start" className="w-56">
116
+ <DropdownMenuGroup>
117
+ <DropdownMenuLabel className="text-muted-foreground">
118
+ {heading ?? text.switcher?.organization ?? "องค์กร"}
119
+ </DropdownMenuLabel>
120
+ {organizations.map((o) => {
121
+ const isActive = o.id === activeId;
122
+ return (
123
+ <DropdownMenuItem
124
+ key={o.id}
125
+ onClick={() => pick(o.id)}
126
+ aria-current={isActive ? "true" : undefined}
127
+ className="gap-2"
128
+ >
129
+ <OrgLogo org={o} />
130
+ <span className="flex min-w-0 flex-1 flex-col">
131
+ <span className="truncate">{o.name}</span>
132
+ {hint(o) && (
133
+ <span className="truncate text-muted-foreground text-xs">{hint(o)}</span>
134
+ )}
135
+ </span>
136
+ {isActive && <Check className="size-4 shrink-0" aria-hidden="true" />}
137
+ </DropdownMenuItem>
138
+ );
139
+ })}
140
+ </DropdownMenuGroup>
141
+ {footer && (
142
+ <>
143
+ <DropdownMenuSeparator />
144
+ {footer}
145
+ </>
146
+ )}
147
+ </DropdownMenuContent>
148
+ </DropdownMenu>
149
+ );
150
+ }