@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 +24 -0
- package/package.json +1 -1
- package/src/components/app-shell/app-shell.tsx +6 -7
- package/src/components/app-shell/command-palette.tsx +256 -0
- package/src/components/app-shell/index.ts +11 -0
- package/src/components/app-shell/organization-switcher.tsx +150 -0
- package/src/components/app-shell/user-menu.tsx +8 -3
- package/src/components/data-table/data-table.tsx +34 -26
- package/src/components/data-table/types.ts +4 -0
- package/src/components/shared/action-form.tsx +12 -3
- package/src/components/shared/calendar-month-grid.tsx +182 -0
- package/src/components/shared/form/labeled-field.tsx +21 -0
- package/src/components/shared/form-dialog.tsx +12 -3
- package/src/components/shared/form-message.tsx +25 -1
- package/src/components/shared/icon-picker.tsx +192 -0
- package/src/components/shared/progress-bar.tsx +83 -0
- package/src/components/shared/selection-button.tsx +29 -0
- package/src/components/shared/theme/theme-menu-items.tsx +31 -14
- package/src/components/shared/ui-text.tsx +41 -0
- package/src/index.ts +22 -1
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.
|
|
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>
|
|
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
|
-
<
|
|
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
|
-
<
|
|
220
|
-
id={APP_MAIN_ID}
|
|
221
|
-
tabIndex={-1}
|
|
220
|
+
<div
|
|
222
221
|
className={cn(
|
|
223
|
-
"flex flex-1 flex-col gap-4 p-4
|
|
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
|
-
</
|
|
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={
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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;
|