@suphark/ui 0.6.1 → 0.7.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,30 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.7.0] — 2026-09-20
6
+
7
+ component ระดับแอปที่ Pi Auth และ Pi Done เขียนซ้ำกัน — สเปกจาก Pi Auth ออกแบบให้ทั้งสองแอปใช้ร่วม (ไม่พึ่ง i18n/auth ของแอป ทุกข้อความรับเป็น prop หรือ `UiText`)
8
+
9
+ ### Added
10
+
11
+ - **`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`
12
+ - **`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`
13
+ - `UiText.palette` / `UiText.switcher` (optional, มีค่าเริ่มต้นไทย/อังกฤษ)
14
+ - **`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) ใช้ร่วม
15
+ - **`SelectionButton`** (barrel) — ปุ่ม toggle `aria-pressed` + สีเน้นเมื่อเลือก (Button outline) — chip ประเภท/ตัวกรอง, ช่องใน IconPicker (ยกจาก DueBell)
16
+ - **`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
17
+ - **`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` โดยตรง
18
+ - showcase: หน้า `icon-picker`, `selection-button`, `calendar-month-grid`, `progress-bar`; tests 6 กรณีเพิ่ม (รวม 100)
19
+ - **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` รวมเหมือนเดิม
20
+ - `UserMenu`: prop `menuLabel` + `UiText.userMenu`/`signOut` (ขอจาก Pi PDF — แอปอังกฤษได้ label ไทย); `ThemeMenuItems`: prop `onThemeChange(theme)` สำหรับบันทึกธีมเป็น preference ของบัญชี + ข้อความจาก `UiText.theme`
21
+ - `LabeledField`: prop `htmlFor` — ส่งมาแล้ว label ไม่ครอบ control (ผูกด้วย id) ให้ tooling ที่ไม่คำนวณ nested label เห็น accessible name (ข้อสังเกตจาก Pi Files)
22
+ - `DataTable`: `showToolbar` / `showPagination` (ค่าเริ่มต้น true) ปิดแถบค้นหา/ตัวกรอง/View และ pagination สำหรับตารางตั้งค่าเล็ก ๆ (ขอจาก Pi Files)
23
+
24
+ ### Fixed
25
+
26
+ - **`AppShell` (layout sidebar) มี `<main>` ซ้อน 2 ชั้น** (รายงานจาก DueBell): `SidebarInset` ของ shadcn เป็น `<main>` อยู่แล้ว → ใส่ `id={APP_MAIN_ID}`/`tabIndex` ที่ SidebarInset และเลิกสร้าง `<main>` ชั้นใน (เป็น `<div>`); layout top ยังมี `<main>` ตัวเดียวเหมือนเดิม
27
+ - showcase: หน้า `command-palette`, `organization-switcher`; `/shell` (sidebarHeader + ยุบเป็นไอคอน) และ `/shell-top` (crumb องค์กร) ใช้สองตัวนี้จริงแทน mock เดิม; tests 5 กรณี (คีย์ลัด toggle + กรอง keywords, search debounce/ผลล่าสุด/push+ปิด, org เดียวไม่มีเมนู, active ไม่เรียก onSwitch + refresh, fallback label)
28
+
5
29
  ## [0.6.1] — 2026-09-20
6
30
 
7
31
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.6.1",
3
+ "version": "0.7.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,256 @@
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, 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 CommandPaletteSection {
33
+ key: string;
34
+ heading: string;
35
+ items: Array<{ id: string; href: string; label: string; description?: string; icon?: ReactNode }>;
36
+ }
37
+
38
+ export interface CommandPaletteText {
39
+ placeholder: string;
40
+ title: string;
41
+ description: string;
42
+ empty: string;
43
+ searching: string;
44
+ /** หัวกลุ่ม "หน้า" */
45
+ pages: string;
46
+ }
47
+
48
+ const DEFAULT_TEXT: CommandPaletteText = {
49
+ placeholder: "ค้นหาหรือสั่งงาน…",
50
+ title: "ค้นหา",
51
+ description: "พิมพ์เพื่อค้นหาหน้าและข้อมูล",
52
+ empty: "ไม่พบผลลัพธ์",
53
+ searching: "กำลังค้นหา…",
54
+ pages: "หน้า",
55
+ };
56
+
57
+ /** ⌘ บน Mac, Ctrl ที่อื่น — อ่านฝั่ง client หลัง mount เท่านั้น (กัน hydration mismatch) */
58
+ function useModKeyLabel(mounted: boolean): string {
59
+ return useMemo(() => {
60
+ if (!mounted) return "Ctrl";
61
+ const p = `${navigator.platform ?? ""} ${navigator.userAgent ?? ""}`;
62
+ return /mac|iphone|ipad/i.test(p) ? "⌘" : "Ctrl";
63
+ }, [mounted]);
64
+ }
65
+
66
+ /**
67
+ * ค้นหาแบบ ⌘K / Ctrl+K ทั้งแอป: หน้าในเมนู (แอปกรองสิทธิ์มาแล้ว) กรองฝั่ง client + ส่วนจาก server action
68
+ * (`search`) เมื่อพิมพ์ ≥ `minQuery` ตัว (debounce; ผลของ query ล่าสุดเท่านั้นที่แสดง)
69
+ * ปุ่มเรียก: `compact` = ไอคอนใน header, ค่าเริ่มต้น = ปุ่มเต็มกว้าง + Kbd ใน `sidebarHeader` ของ AppShell
70
+ * ข้อความรับเป็น prop `text` (ค่าเริ่มต้นจาก `UiText.palette` หรือไทย) — ไม่พึ่ง i18n ของแอป
71
+ * ยกจาก Pi Auth (features/console/command-palette) ให้ Pi Done/DueBell ใช้ร่วม
72
+ */
73
+ export function CommandPalette({
74
+ pages,
75
+ search,
76
+ compact = false,
77
+ shortcut = "k",
78
+ minQuery = 2,
79
+ debounceMs = 150,
80
+ text: textProp,
81
+ onNavigate,
82
+ className,
83
+ }: {
84
+ pages: CommandPaletteItem[];
85
+ /** server action ของแอป — คืน section ตามลำดับที่ต้องการแสดง */
86
+ search?: (query: string) => Promise<CommandPaletteSection[]>;
87
+ compact?: boolean;
88
+ /** ตัวอักษรคีย์ลัดร่วมกับ ⌘/Ctrl (ค่าเริ่มต้น "k") */
89
+ shortcut?: string;
90
+ minQuery?: number;
91
+ debounceMs?: number;
92
+ text?: Partial<CommandPaletteText>;
93
+ /** ค่าเริ่มต้น router.push + ปิดกล่อง — ครอบเพื่อ prefetch/analytics แล้วเรียกต่อเองได้ */
94
+ onNavigate?: (href: string) => void;
95
+ className?: string;
96
+ }) {
97
+ const ui = useUiText();
98
+ const text: CommandPaletteText = { ...DEFAULT_TEXT, ...ui.palette, ...textProp };
99
+ const router = useRouter();
100
+ const mounted = useMounted();
101
+ const mod = useModKeyLabel(mounted);
102
+ const [open, setOpen] = useState(false);
103
+ const [query, setQuery] = useState("");
104
+ const [result, setResult] = useState<{ query: string; sections: CommandPaletteSection[] } | null>(
105
+ null,
106
+ );
107
+ const [pending, start] = useTransition();
108
+
109
+ // คีย์ลัดจากที่ไหนก็ได้ในแอป
110
+ useEffect(() => {
111
+ const key = shortcut.toLowerCase();
112
+ const onKey = (e: KeyboardEvent) => {
113
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === key) {
114
+ e.preventDefault();
115
+ setOpen((o) => !o);
116
+ }
117
+ };
118
+ window.addEventListener("keydown", onKey);
119
+ return () => window.removeEventListener("keydown", onKey);
120
+ }, [shortcut]);
121
+
122
+ const q = query.trim();
123
+ const shouldSearch = Boolean(search) && q.length >= minQuery;
124
+
125
+ // ค้นหาฝั่ง server หลังหยุดพิมพ์ — เก็บ query คู่กับผล ผลที่ไม่ตรง query ปัจจุบันไม่แสดง
126
+ useEffect(() => {
127
+ if (!search || !shouldSearch) return;
128
+ const handle = setTimeout(() => {
129
+ start(async () => {
130
+ try {
131
+ const sections = await search(q);
132
+ setResult({ query: q, sections });
133
+ } catch {
134
+ setResult({ query: q, sections: [] });
135
+ }
136
+ });
137
+ }, debounceMs);
138
+ return () => clearTimeout(handle);
139
+ }, [q, search, shouldSearch, debounceMs]);
140
+
141
+ const sections = shouldSearch && result?.query === q ? result.sections : [];
142
+ const ql = q.toLowerCase();
143
+ const visiblePages = ql
144
+ ? pages.filter((p) =>
145
+ `${p.label} ${p.group ?? ""} ${p.href} ${(p.keywords ?? []).join(" ")}`
146
+ .toLowerCase()
147
+ .includes(ql),
148
+ )
149
+ : pages;
150
+ const nothing = visiblePages.length === 0 && sections.every((s) => s.items.length === 0);
151
+
152
+ function close() {
153
+ setOpen(false);
154
+ setQuery("");
155
+ }
156
+ function go(href: string) {
157
+ close();
158
+ if (onNavigate) onNavigate(href);
159
+ else router.push(href);
160
+ }
161
+
162
+ const shortcutLabel = `${mod}${shortcut.toUpperCase()}`;
163
+
164
+ return (
165
+ <>
166
+ {compact ? (
167
+ <Button
168
+ type="button"
169
+ variant="ghost"
170
+ size="icon"
171
+ aria-label={text.placeholder}
172
+ title={`${text.placeholder} (${shortcutLabel})`}
173
+ className={cn("text-muted-foreground", className)}
174
+ onClick={() => setOpen(true)}
175
+ >
176
+ <Search />
177
+ </Button>
178
+ ) : (
179
+ <Button
180
+ type="button"
181
+ variant="outline"
182
+ size="sm"
183
+ className={cn(
184
+ "h-8 w-full justify-start gap-2 bg-background px-2 font-normal text-muted-foreground",
185
+ className,
186
+ )}
187
+ onClick={() => setOpen(true)}
188
+ >
189
+ <Search />
190
+ <span className="flex-1 truncate text-left">{text.placeholder}</span>
191
+ <Kbd className="hidden sm:inline-flex">{shortcutLabel}</Kbd>
192
+ </Button>
193
+ )}
194
+ <CommandDialog
195
+ open={open}
196
+ onOpenChange={(o) => (o ? setOpen(true) : close())}
197
+ title={text.title}
198
+ description={text.description}
199
+ >
200
+ <Command shouldFilter={false}>
201
+ <CommandInput
202
+ autoFocus
203
+ placeholder={text.placeholder}
204
+ value={query}
205
+ onValueChange={setQuery}
206
+ />
207
+ <CommandList>
208
+ {nothing && (
209
+ <CommandEmpty>{pending && shouldSearch ? text.searching : text.empty}</CommandEmpty>
210
+ )}
211
+ {visiblePages.length > 0 && (
212
+ <CommandGroup heading={text.pages}>
213
+ {visiblePages.map((p) => (
214
+ <CommandItem key={p.href} value={p.href} onSelect={() => go(p.href)}>
215
+ <span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4">
216
+ {p.icon}
217
+ </span>
218
+ <span className="flex-1 truncate">{p.label}</span>
219
+ {p.group && <CommandShortcut>{p.group}</CommandShortcut>}
220
+ </CommandItem>
221
+ ))}
222
+ </CommandGroup>
223
+ )}
224
+ {sections.map(
225
+ (s) =>
226
+ s.items.length > 0 && (
227
+ <CommandGroup key={s.key} heading={s.heading}>
228
+ {s.items.map((it) => (
229
+ <CommandItem
230
+ key={it.id}
231
+ value={`${s.key}:${it.id}`}
232
+ onSelect={() => go(it.href)}
233
+ >
234
+ <span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4">
235
+ {it.icon}
236
+ </span>
237
+ <span className="flex-1 truncate">
238
+ {it.label}
239
+ {it.description && (
240
+ <span className="text-muted-foreground"> · {it.description}</span>
241
+ )}
242
+ </span>
243
+ </CommandItem>
244
+ ))}
245
+ </CommandGroup>
246
+ ),
247
+ )}
248
+ {pending && shouldSearch && !nothing && (
249
+ <div className="px-3 py-2 text-muted-foreground text-xs">{text.searching}</div>
250
+ )}
251
+ </CommandList>
252
+ </Command>
253
+ </CommandDialog>
254
+ </>
255
+ );
256
+ }
@@ -1,9 +1,20 @@
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 CommandPaletteText,
9
+ } from "./command-palette";
4
10
  export { HeaderBreadcrumbs, type HeaderBreadcrumbsProps } from "./header-breadcrumbs";
5
11
  export { HeaderCrumb, HeaderCrumbs } from "./header-crumb";
6
12
  export { NavLink } from "./nav-link";
13
+ export {
14
+ type OrganizationOption,
15
+ OrganizationSwitcher,
16
+ type OrganizationSwitcherProps,
17
+ } from "./organization-switcher";
7
18
  export { SubNav } from "./sub-nav";
8
19
  export { isTabActive, TopTabs, topLevelTabs } from "./top-tabs";
9
20
  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
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Initials } from "@suphark/ui/components/shared/initials";
4
4
  import { SwitcherChevron } from "@suphark/ui/components/shared/switcher-chevron";
5
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
5
6
  import { Button } from "@suphark/ui/components/ui/button";
6
7
  import {
7
8
  DropdownMenu,
@@ -41,6 +42,8 @@ export interface UserMenuProps {
41
42
  variant?: "sidebar" | "header";
42
43
  align?: "start" | "end";
43
44
  className?: string;
45
+ /** aria-label ของปุ่มเปิดเมนู (ค่าเริ่มต้น `UiText.userMenu` "เมนูผู้ใช้") — ต่อท้ายชื่อผู้ใช้ให้ */
46
+ menuLabel?: string;
44
47
  }
45
48
 
46
49
  /** เมนูผู้ใช้ (ยกจาก supplychain NavUser + Pi Auth UserMenu) */
@@ -48,11 +51,13 @@ export function UserMenu({
48
51
  user,
49
52
  children,
50
53
  onSignOut,
51
- signOutLabel = "ออกจากระบบ",
54
+ signOutLabel,
52
55
  variant = "sidebar",
53
56
  align = variant === "header" ? "end" : "start",
54
57
  className,
58
+ menuLabel,
55
59
  }: UserMenuProps) {
60
+ const text = useUiText();
56
61
  const [pending, start] = useTransition();
57
62
  const description = user.description ?? user.email;
58
63
  return (
@@ -61,7 +66,7 @@ export function UserMenu({
61
66
  render={
62
67
  <Button
63
68
  variant="ghost"
64
- aria-label={`เปิดเมนูผู้ใช้ ${user.name}`}
69
+ aria-label={`${menuLabel ?? text.userMenu ?? "เมนูผู้ใช้"}: ${user.name}`}
65
70
  className={cn(
66
71
  variant === "sidebar"
67
72
  ? "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"
@@ -122,7 +127,7 @@ export function UserMenu({
122
127
  <DropdownMenuSeparator />
123
128
  <DropdownMenuItem disabled={pending} onClick={() => start(() => onSignOut())}>
124
129
  <LogOut aria-hidden="true" />
125
- {signOutLabel}
130
+ {signOutLabel ?? text.signOut ?? "ออกจากระบบ"}
126
131
  </DropdownMenuItem>
127
132
  </>
128
133
  )}
@@ -139,6 +139,8 @@ export function DataTable<TData, TValue>({
139
139
  enableColumnResizing = false,
140
140
  enableRowSelection = true,
141
141
  isLoading,
142
+ showToolbar = true,
143
+ showPagination = true,
142
144
  pageSizeOptions,
143
145
  toolbarFilters,
144
146
  preservedFilterIds,
@@ -347,22 +349,26 @@ export function DataTable<TData, TValue>({
347
349
 
348
350
  return (
349
351
  <div className={cn("flex flex-col gap-4", containerClassName)}>
350
- <DataTableToolbar
351
- table={table}
352
- facetedFilters={facetedFilters}
353
- actionsComponent={actionsComponent?.(table)}
354
- globalFilterPlaceholder={globalFilterPlaceholder}
355
- toolbarFilters={toolbarFilters?.(table)}
356
- preservedFilterIds={preservedFilterIds}
357
- />
352
+ {showToolbar && (
353
+ <DataTableToolbar
354
+ table={table}
355
+ facetedFilters={facetedFilters}
356
+ actionsComponent={actionsComponent?.(table)}
357
+ globalFilterPlaceholder={globalFilterPlaceholder}
358
+ toolbarFilters={toolbarFilters?.(table)}
359
+ preservedFilterIds={preservedFilterIds}
360
+ />
361
+ )}
358
362
  {customView ? (
359
363
  <>
360
364
  {customView}
361
- <DataTablePagination
362
- table={table}
363
- pageSizeOptions={pageSizeOptions}
364
- onPageSizeChange={onPageSizeChange}
365
- />
365
+ {showPagination && (
366
+ <DataTablePagination
367
+ table={table}
368
+ pageSizeOptions={pageSizeOptions}
369
+ onPageSizeChange={onPageSizeChange}
370
+ />
371
+ )}
366
372
  </>
367
373
  ) : (
368
374
  <>
@@ -379,19 +385,21 @@ export function DataTable<TData, TValue>({
379
385
  tableContent
380
386
  )}
381
387
  </div>
382
- <div
383
- className={
384
- stickyPagination
385
- ? "sticky bottom-0 z-20 border-t bg-background py-2 max-sm:-mx-2"
386
- : undefined
387
- }
388
- >
389
- <DataTablePagination
390
- table={table}
391
- pageSizeOptions={pageSizeOptions}
392
- onPageSizeChange={onPageSizeChange}
393
- />
394
- </div>
388
+ {showPagination && (
389
+ <div
390
+ className={
391
+ stickyPagination
392
+ ? "sticky bottom-0 z-20 border-t bg-background py-2 max-sm:-mx-2"
393
+ : undefined
394
+ }
395
+ >
396
+ <DataTablePagination
397
+ table={table}
398
+ pageSizeOptions={pageSizeOptions}
399
+ onPageSizeChange={onPageSizeChange}
400
+ />
401
+ </div>
402
+ )}
395
403
  </>
396
404
  )}
397
405
  </div>
@@ -95,6 +95,10 @@ export interface DataTableProps<TData, TValue> {
95
95
  initialPageSize?: number;
96
96
  meta?: Record<string, unknown>;
97
97
  isLoading?: boolean;
98
+ /** ซ่อนแถบค้นหา/ตัวกรอง/View (ตารางตั้งค่าเล็ก ๆ) */
99
+ showToolbar?: boolean;
100
+ /** ซ่อน pagination (แถวน้อย ไม่ต้องแบ่งหน้า) */
101
+ showPagination?: boolean;
98
102
 
99
103
  // Advanced
100
104
  enableColumnResizing?: boolean;