@suphark/ui 0.2.0 → 0.3.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,17 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.3.0] — 2026-09-18
6
+
7
+ ### Added
8
+
9
+ - **AppShell** (`@suphark/ui/next`) — โครงแอปกลางแทน ConsoleShell ของ Pi Auth และ main layout ของ supplychain: shadcn Sidebar (ยุบเป็นไอคอน/offcanvas/none, Sheet บนมือถือ, ⌘B, cookie `sidebar_state`), โลโก้ `AppBrand` (name/logo/subtitle/version), ค้นหาเมนู, เมนู 2 ชั้น `AppNav` (ไฮไลต์ตาม pathname, กางอัตโนมัติเมื่อลูก active, dropdown เมื่อยุบ, badge, labs, pin ผ่าน `pin` prop), slot `sidebarHeader`/`sidebarFooter`, header (SidebarTrigger + `HeaderBreadcrumbs` อัตโนมัติ + `center`/`actions`/`banner`), `brandColor` ต่อ tenant, `contentMaxWidth`
10
+ - `UserMenu` (variant `sidebar` | `header`): หัวผู้ใช้ + children (ใส่ DropdownMenuItem/ThemeMenuItems เอง) + `onSignOut`
11
+ - `HeaderBreadcrumbs` (labels/skip/hideIds/homeHref), `NavLink` (จาก Pi Auth)
12
+ - โมเดลเมนู `AppNavGroup`/`AppNavItem` (icon เป็น ReactNode — ประกาศใน server component ได้) + helpers `filterNav` (ตัดตามสิทธิ์ทุกชั้น), `searchNav`, `flattenNav`, `isNavActive` (+tests รวม 40)
13
+ - barrel หลัก export `toast` จาก sonner (แอปไม่ต้องติดตั้ง sonner เอง)
14
+ - showcase: `/shell` ตัวอย่าง layout ที่ใช้ AppShell + UserMenu + ThemeMenuItems + filterNav + cookie defaultOpen
15
+
5
16
  ## [0.2.0] — 2026-09-18
6
17
 
7
18
  ### Added
package/README.md CHANGED
@@ -55,8 +55,8 @@ import { ThemeProvider, Toaster } from "@suphark/ui";
55
55
  // ทุกอย่างที่ไม่พึ่ง Next — primitives, shared, theme, hooks, cn
56
56
  import { Button, Card, FormFieldInput, StatusBadge, ThemeToggle, cn } from "@suphark/ui";
57
57
 
58
- // ของที่ใช้ next/navigation หรือ next/link
59
- import { FormDialog, RowActions, LinkButton, RefreshButton, PageShell } from "@suphark/ui/next";
58
+ // ของที่ใช้ next/navigation หรือ next/link + โครงแอป
59
+ import { AppShell, UserMenu, filterNav, FormDialog, RowActions, LinkButton, RefreshButton, PageShell } from "@suphark/ui/next";
60
60
 
61
61
  // ตารางข้อมูล (ต้องติดตั้ง @tanstack/react-table เอง)
62
62
  import { DataTable, DataTableColumnHeader, useDataTable } from "@suphark/ui/data-table";
@@ -72,11 +72,49 @@ import { ChartContainer } from "@suphark/ui/components/ui/chart";
72
72
  | `src/components/ui/*` | shadcn/ui ต้นฉบับ 59 ตัว (+ `__tests__`) | **ห้ามแก้มือ** — เพิ่ม/อัปเดตด้วย `pnpm dlx shadcn@latest add <name>` ใน `packages/ui` (`components.json` ตั้ง alias ไว้แล้ว) |
73
73
  | `src/components/shared/*` | ของที่เราประกอบเอง: ปุ่มมาตรฐาน (`button/`), form-field บน react-hook-form (`form/`), layout, theme, combobox, และชุดจาก Pi Auth (FormDialog, NameIdFields, StatusBadge, RowMenu, …) | แก้ได้ แต่คง props เดิมให้มากที่สุด (minor = เพิ่ม, major = เปลี่ยน) |
74
74
  | `src/components/data-table/*` | DataTable บน tanstack (toolbar, faceted filter, pagination, view options, row actions, ลากเรียงแถว) | entry `@suphark/ui/data-table` |
75
+ | `src/components/app-shell/*` | AppShell (sidebar + header + เนื้อหา), AppNav, AppBrand, UserMenu, HeaderBreadcrumbs, NavLink, filterNav/searchNav | export ผ่าน `@suphark/ui/next` (ใช้ next/link, next/navigation) |
75
76
  | `src/components/design-system/*` | หน้า showcase (render ใน `apps/showcase`) | ไม่อยู่ใน barrel |
76
77
  | `src/lib/*`, `src/hooks/*` | `cn`, formatter วันที่ไทย, สีสถานะ, permission context, `useIsMobile`, `useCopyToClipboard` | |
77
78
  | `src/styles.css` | token (slate + success/warning/sidebar/chart), radius, ฟอนต์, scrollbar, dark variant | |
78
79
  | `scripts/*.mjs` | audit บังคับ: Base UI props (`default*`/`onSelect`), typography scale | แอปปลายทางรันได้จาก `node_modules/@suphark/ui/scripts` |
79
80
 
81
+ ### โครงแอป (AppShell)
82
+
83
+ ```tsx
84
+ // app/(app)/layout.tsx — server component ได้ (icon เป็น element)
85
+ import { ThemeMenuItems } from "@suphark/ui";
86
+ import { AppShell, type AppNavGroup, filterNav, UserMenu } from "@suphark/ui/next";
87
+ import { cookies } from "next/headers";
88
+
89
+ const NAV: AppNavGroup[] = [
90
+ { title: "งาน", items: [
91
+ { title: "แดชบอร์ด", href: "/", icon: <LayoutDashboard />, exact: true },
92
+ { title: "จัดซื้อ", icon: <ShoppingCart />, items: [{ title: "แพ็กเกจ", href: "/packages", permission: "package:read" }] },
93
+ ]},
94
+ ];
95
+
96
+ export default async function Layout({ children }) {
97
+ const defaultOpen = (await cookies()).get("sidebar_state")?.value !== "false";
98
+ return (
99
+ <AppShell
100
+ brand={{ name: "Pi Auth", subtitle: tenant.name, version: "v1.2.0", versionHref: "/change-log" }}
101
+ nav={filterNav(NAV, can)}
102
+ searchable
103
+ defaultOpen={defaultOpen}
104
+ brandColor={tenant.primaryColor}
105
+ header={{ breadcrumbs: { skip: ["console"] }, actions: <NotificationBell /> }}
106
+ sidebarFooter={<UserMenu user={{ name, email, image }} onSignOut={signOut}><ThemeMenuItems /></UserMenu>}
107
+ >
108
+ {children}
109
+ </AppShell>
110
+ );
111
+ }
112
+ ```
113
+
114
+ - `header={false}` ไม่มี header · `collapsible="offcanvas"` ซ่อนทั้งแผง (supplychain) · `contentMaxWidth="max-w-5xl"` จำกัดความกว้าง (Pi Auth)
115
+ - `pin={{ pinnedHrefs, onTogglePin }}` เปิดปุ่มปักหมุดเมนู (แอปเก็บ state เอง) · `header.center` วาง pinned menus
116
+ - ตัวอย่างรันได้ที่ `apps/showcase/src/app/shell`
117
+
80
118
  ### สีแบรนด์ต่อ tenant/แอป (`--brand`)
81
119
 
82
120
  ตั้ง `--brand` / `--brand-foreground` inline บน wrapper (หรือใส่ `data-brand`) แล้ว `primary`/`ring`/`sidebar-primary` ข้างในตามไปด้วย — ค่าเริ่มต้นไม่ตั้ง `--brand` (primary = slate) ดูตัวอย่างที่ `apps/showcase/src/app/brand`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.2.0",
3
+ "version": "0.3.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 เอง",
@@ -43,6 +43,7 @@
43
43
  "./components/shared/button": "./src/components/shared/button/index.ts",
44
44
  "./components/shared/theme": "./src/components/shared/theme/index.ts",
45
45
  "./components/data-table": "./src/components/data-table/index.ts",
46
+ "./components/app-shell": "./src/components/app-shell/index.ts",
46
47
  "./components/*": "./src/components/*.tsx",
47
48
  "./lib/*": "./src/lib/*.ts",
48
49
  "./hooks/*": "./src/hooks/*.ts",
@@ -0,0 +1,81 @@
1
+ "use client";
2
+
3
+ import {
4
+ SidebarMenu,
5
+ SidebarMenuButton,
6
+ SidebarMenuItem,
7
+ useSidebar,
8
+ } from "@suphark/ui/components/ui/sidebar";
9
+ import { cn } from "@suphark/ui/lib/utils";
10
+ import Link from "next/link";
11
+ import type { ReactNode } from "react";
12
+
13
+ export interface AppBrandProps {
14
+ /** ชื่อแอป/องค์กร */
15
+ name: ReactNode;
16
+ /** โลโก้ (ไอคอน lucide, <img>, หรือตัวอักษร) — ถ้าไม่ส่งจะใช้ตัวอักษรแรกของ name */
17
+ logo?: ReactNode;
18
+ /** ข้อความบรรทัดล่าง เช่น ชื่อ tenant / แผน */
19
+ subtitle?: ReactNode;
20
+ /** ลิงก์เมื่อกดโลโก้ (ค่าเริ่มต้น "/") */
21
+ href?: string;
22
+ /** ป้ายเล็กด้านขวา เช่น เวอร์ชัน `v1.4.0` — กดแล้วไป `versionHref` (เช่น /change-log) */
23
+ version?: ReactNode;
24
+ versionHref?: string;
25
+ className?: string;
26
+ }
27
+
28
+ /**
29
+ * โลโก้ + ชื่อแอปมุมบน sidebar (ยกจาก supplychain NavLogo + Pi Auth ConsoleShell header)
30
+ * ยุบเป็นไอคอนแล้วเหลือแค่โลโก้
31
+ */
32
+ export function AppBrand({
33
+ name,
34
+ logo,
35
+ subtitle,
36
+ href = "/",
37
+ version,
38
+ versionHref,
39
+ className,
40
+ }: AppBrandProps) {
41
+ const { setOpenMobile } = useSidebar();
42
+ const closeMobile = () => setOpenMobile(false);
43
+ const mark = (
44
+ <div className="flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-sidebar-primary font-bold text-sidebar-primary-foreground text-sm [&_img]:size-full [&_img]:object-contain [&_svg]:size-4">
45
+ {logo ?? (typeof name === "string" ? name.charAt(0).toUpperCase() : "•")}
46
+ </div>
47
+ );
48
+ const versionNode =
49
+ version !== undefined &&
50
+ (versionHref ? (
51
+ <Link
52
+ href={versionHref}
53
+ onClick={closeMobile}
54
+ className="shrink-0 rounded-md px-2 py-1 text-muted-foreground text-xs no-underline transition-colors hover:bg-sidebar-accent hover:text-primary group-data-[collapsible=icon]:hidden"
55
+ >
56
+ {version}
57
+ </Link>
58
+ ) : (
59
+ <span className="shrink-0 px-2 text-muted-foreground text-xs group-data-[collapsible=icon]:hidden">
60
+ {version}
61
+ </span>
62
+ ));
63
+ return (
64
+ <SidebarMenu>
65
+ <SidebarMenuItem className={cn("flex items-center", className)}>
66
+ <SidebarMenuButton
67
+ size="lg"
68
+ className="min-w-0 flex-1 group-data-[collapsible=icon]:p-0!"
69
+ render={<Link href={href} onClick={closeMobile} />}
70
+ >
71
+ {mark}
72
+ <div className="grid flex-1 overflow-hidden text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
73
+ <span className="truncate font-medium text-sidebar-foreground">{name}</span>
74
+ {subtitle && <span className="truncate text-muted-foreground text-xs">{subtitle}</span>}
75
+ </div>
76
+ </SidebarMenuButton>
77
+ {versionNode}
78
+ </SidebarMenuItem>
79
+ </SidebarMenu>
80
+ );
81
+ }
@@ -0,0 +1,260 @@
1
+ "use client";
2
+
3
+ import {
4
+ Collapsible,
5
+ CollapsibleContent,
6
+ CollapsibleTrigger,
7
+ } from "@suphark/ui/components/ui/collapsible";
8
+ import {
9
+ DropdownMenu,
10
+ DropdownMenuContent,
11
+ DropdownMenuGroup,
12
+ DropdownMenuItem,
13
+ DropdownMenuLabel,
14
+ DropdownMenuSeparator,
15
+ DropdownMenuTrigger,
16
+ } from "@suphark/ui/components/ui/dropdown-menu";
17
+ import {
18
+ SidebarGroup,
19
+ SidebarGroupLabel,
20
+ SidebarMenu,
21
+ SidebarMenuAction,
22
+ SidebarMenuBadge,
23
+ SidebarMenuButton,
24
+ SidebarMenuItem,
25
+ SidebarMenuSub,
26
+ SidebarMenuSubButton,
27
+ SidebarMenuSubItem,
28
+ useSidebar,
29
+ } from "@suphark/ui/components/ui/sidebar";
30
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@suphark/ui/components/ui/tooltip";
31
+ import { cn } from "@suphark/ui/lib/utils";
32
+ import { ChevronRight, FlaskConical, Pin } from "lucide-react";
33
+ import Link from "next/link";
34
+ import { usePathname } from "next/navigation";
35
+ import { type AppNavGroup, type AppNavItem, isNavActive } from "./types";
36
+
37
+ /** ปุ่ม pin — แสดงเมื่อแอปส่ง `pinnedHrefs` + `onTogglePin` (supplychain เก็บใน user preferences) */
38
+ export interface AppNavPinProps {
39
+ pinnedHrefs?: string[];
40
+ onTogglePin?: (href: string) => void;
41
+ pinLabel?: string;
42
+ unpinLabel?: string;
43
+ }
44
+
45
+ function MenuTitle({ title, isLabs }: { title: string; isLabs?: boolean }) {
46
+ return (
47
+ <Tooltip>
48
+ <TooltipTrigger
49
+ render={<span className="flex w-full items-center gap-1.5 overflow-hidden" />}
50
+ >
51
+ <span
52
+ className={cn("truncate", isLabs && "font-medium text-amber-600 dark:text-amber-500")}
53
+ >
54
+ {title}
55
+ </span>
56
+ {isLabs && <FlaskConical className="h-3 w-3 shrink-0 text-amber-600 dark:text-amber-500" />}
57
+ </TooltipTrigger>
58
+ <TooltipContent side="right" align="center" className="max-w-xs break-words">
59
+ {title}
60
+ </TooltipContent>
61
+ </Tooltip>
62
+ );
63
+ }
64
+
65
+ function PinAction({
66
+ href,
67
+ pin,
68
+ className,
69
+ }: {
70
+ href: string;
71
+ pin: AppNavPinProps;
72
+ className?: string;
73
+ }) {
74
+ if (!pin.onTogglePin) return null;
75
+ const isPinned = pin.pinnedHrefs?.includes(href) ?? false;
76
+ const label = isPinned ? (pin.unpinLabel ?? "เลิกปักหมุด") : (pin.pinLabel ?? "ปักหมุดที่ header");
77
+ return (
78
+ <SidebarMenuAction
79
+ showOnHover
80
+ onClick={() => pin.onTogglePin?.(href)}
81
+ title={label}
82
+ className={className}
83
+ >
84
+ <Pin className={cn("size-3.5", isPinned && "fill-current")} />
85
+ <span className="sr-only">{label}</span>
86
+ </SidebarMenuAction>
87
+ );
88
+ }
89
+
90
+ /**
91
+ * กลุ่มเมนูใน sidebar (ยกจาก supplychain NavMain): 2 ชั้น, กางได้, ยุบเป็นไอคอนแล้วเป็น dropdown,
92
+ * ไฮไลต์ตาม pathname (prefix match), ป้าย badge, ฟีเจอร์ labs, และ pin (ถ้าแอปส่งมา)
93
+ */
94
+ export function AppNavGroupMenu({
95
+ group,
96
+ pin = {},
97
+ forceOpen = false,
98
+ }: {
99
+ group: AppNavGroup;
100
+ pin?: AppNavPinProps;
101
+ /** กางทุกกลุ่มย่อย (ใช้ตอนค้นหาเมนู) */
102
+ forceOpen?: boolean;
103
+ }) {
104
+ const { state, isMobile, setOpenMobile } = useSidebar();
105
+ const pathname = usePathname();
106
+ const isCollapsed = state === "collapsed" && !isMobile;
107
+ // บนมือถือ sidebar เป็น Sheet — กดลิงก์แล้วปิดให้
108
+ const closeMobile = () => setOpenMobile(false);
109
+
110
+ const renderLeaf = (item: AppNavItem & { href: string }) => {
111
+ const active = isNavActive(pathname, item.href, item.exact);
112
+ return (
113
+ <SidebarMenuItem key={item.href}>
114
+ <SidebarMenuButton
115
+ isActive={active}
116
+ tooltip={item.title}
117
+ render={<Link href={item.href} onClick={closeMobile} />}
118
+ >
119
+ {item.icon}
120
+ <MenuTitle title={item.title} isLabs={item.isLabs} />
121
+ </SidebarMenuButton>
122
+ {item.badge !== undefined && <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>}
123
+ <PinAction href={item.href} pin={pin} />
124
+ </SidebarMenuItem>
125
+ );
126
+ };
127
+
128
+ return (
129
+ <SidebarGroup>
130
+ {group.title && <SidebarGroupLabel>{group.title}</SidebarGroupLabel>}
131
+ <SidebarMenu>
132
+ {group.items.map((item) => {
133
+ const children = item.items ?? [];
134
+ const hasChildren = children.length > 0;
135
+
136
+ // ใบ (ไม่มีลูก)
137
+ if (!hasChildren) {
138
+ if (!item.href) return null;
139
+ return renderLeaf({ ...item, href: item.href });
140
+ }
141
+
142
+ const childActive = children.some((c) => isNavActive(pathname, c.href, c.exact));
143
+
144
+ // ยุบเป็นไอคอน + มีลูก → dropdown ด้านขวา
145
+ if (isCollapsed) {
146
+ return (
147
+ <SidebarMenuItem key={item.title}>
148
+ <DropdownMenu>
149
+ <DropdownMenuTrigger
150
+ render={<SidebarMenuButton tooltip={item.title} isActive={childActive} />}
151
+ >
152
+ {item.icon}
153
+ <MenuTitle title={item.title} isLabs={item.isLabs} />
154
+ <ChevronRight className="ml-auto" />
155
+ </DropdownMenuTrigger>
156
+ <DropdownMenuContent side="right" align="start" sideOffset={4}>
157
+ <DropdownMenuGroup>
158
+ <DropdownMenuLabel>{item.title}</DropdownMenuLabel>
159
+ <DropdownMenuSeparator />
160
+ {children.map((sub) => (
161
+ <DropdownMenuItem
162
+ key={sub.href ?? sub.title}
163
+ render={
164
+ <Link
165
+ href={sub.href ?? "#"}
166
+ className="flex cursor-pointer items-center gap-2"
167
+ />
168
+ }
169
+ >
170
+ {sub.icon}
171
+ <MenuTitle title={sub.title} isLabs={sub.isLabs} />
172
+ </DropdownMenuItem>
173
+ ))}
174
+ </DropdownMenuGroup>
175
+ </DropdownMenuContent>
176
+ </DropdownMenu>
177
+ </SidebarMenuItem>
178
+ );
179
+ }
180
+
181
+ // กางปกติ — เปิดค้างเมื่อลูกตัวใดตัวหนึ่ง active หรือกำลังค้นหา
182
+ const open = forceOpen || childActive;
183
+ return (
184
+ <Collapsible
185
+ key={`${item.title}-${open}`}
186
+ render={<SidebarMenuItem />}
187
+ defaultOpen={open}
188
+ className="group/collapsible"
189
+ >
190
+ <CollapsibleTrigger
191
+ render={<SidebarMenuButton tooltip={item.title} suppressHydrationWarning />}
192
+ >
193
+ {item.icon}
194
+ <MenuTitle title={item.title} isLabs={item.isLabs} />
195
+ <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
196
+ </CollapsibleTrigger>
197
+ <CollapsibleContent suppressHydrationWarning>
198
+ <SidebarMenuSub>
199
+ {children.map((sub) => {
200
+ if (!sub.href) return null;
201
+ const active = isNavActive(pathname, sub.href, sub.exact);
202
+ return (
203
+ <SidebarMenuSubItem key={sub.href}>
204
+ <SidebarMenuSubButton
205
+ isActive={active}
206
+ render={
207
+ <Link
208
+ href={sub.href}
209
+ className="flex items-center gap-2"
210
+ onClick={closeMobile}
211
+ />
212
+ }
213
+ >
214
+ {sub.icon}
215
+ <MenuTitle title={sub.title} isLabs={sub.isLabs} />
216
+ </SidebarMenuSubButton>
217
+ {sub.badge !== undefined && (
218
+ <SidebarMenuBadge>{sub.badge}</SidebarMenuBadge>
219
+ )}
220
+ <PinAction
221
+ href={sub.href}
222
+ pin={pin}
223
+ className="top-1 right-2 opacity-0 transition-opacity group-hover/menu-sub-item:opacity-100"
224
+ />
225
+ </SidebarMenuSubItem>
226
+ );
227
+ })}
228
+ </SidebarMenuSub>
229
+ </CollapsibleContent>
230
+ </Collapsible>
231
+ );
232
+ })}
233
+ </SidebarMenu>
234
+ </SidebarGroup>
235
+ );
236
+ }
237
+
238
+ /** ทุกกลุ่มเมนู */
239
+ export function AppNav({
240
+ nav,
241
+ pin,
242
+ forceOpen,
243
+ }: {
244
+ nav: AppNavGroup[];
245
+ pin?: AppNavPinProps;
246
+ forceOpen?: boolean;
247
+ }) {
248
+ return (
249
+ <>
250
+ {nav.map((group, i) => (
251
+ <AppNavGroupMenu
252
+ key={group.title ?? `group-${i}`}
253
+ group={group}
254
+ pin={pin}
255
+ forceOpen={forceOpen}
256
+ />
257
+ ))}
258
+ </>
259
+ );
260
+ }
@@ -0,0 +1,270 @@
1
+ "use client";
2
+
3
+ import {
4
+ InputGroup,
5
+ InputGroupAddon,
6
+ InputGroupButton,
7
+ InputGroupInput,
8
+ } from "@suphark/ui/components/ui/input-group";
9
+ import { Separator } from "@suphark/ui/components/ui/separator";
10
+ import {
11
+ Sidebar,
12
+ SidebarContent,
13
+ SidebarFooter,
14
+ SidebarHeader,
15
+ SidebarInset,
16
+ SidebarProvider,
17
+ SidebarRail,
18
+ SidebarTrigger,
19
+ useSidebar,
20
+ } from "@suphark/ui/components/ui/sidebar";
21
+ import { cn } from "@suphark/ui/lib/utils";
22
+ import { Search, X } from "lucide-react";
23
+ import {
24
+ type ComponentProps,
25
+ type CSSProperties,
26
+ type ReactNode,
27
+ useEffect,
28
+ useMemo,
29
+ useState,
30
+ } from "react";
31
+ import { AppBrand, type AppBrandProps } from "./app-brand";
32
+ import { AppNav, type AppNavPinProps } from "./app-nav";
33
+ import { HeaderBreadcrumbs, type HeaderBreadcrumbsProps } from "./header-breadcrumbs";
34
+ import { type AppNavGroup, searchNav } from "./types";
35
+
36
+ export interface AppShellProps {
37
+ /** โลโก้/ชื่อแอปมุมบน sidebar */
38
+ brand: AppBrandProps;
39
+ /** เมนู (ตัดตามสิทธิ์ด้วย `filterNav` ก่อนส่งเข้ามา) */
40
+ nav: AppNavGroup[];
41
+ /** ช่องค้นหาเมนูใต้โลโก้ (supplychain) */
42
+ searchable?: boolean;
43
+ searchPlaceholder?: string;
44
+ /** ปุ่ม pin เมนูขึ้น header (แอปเก็บ state เอง) */
45
+ pin?: AppNavPinProps;
46
+ /** ส่วนเพิ่มใน sidebar ใต้โลโก้ เช่น TenantSwitcher / CommandPalette */
47
+ sidebarHeader?: ReactNode;
48
+ /** ท้าย sidebar — ปกติคือ `<UserMenu variant="sidebar" />` */
49
+ sidebarFooter?: ReactNode;
50
+ /**
51
+ * header ของเนื้อหา: `false` = ไม่มี header, `true`/ไม่ส่ง = SidebarTrigger + breadcrumb อัตโนมัติ
52
+ * ส่ง object เพื่อกำหนดเอง
53
+ */
54
+ header?:
55
+ | boolean
56
+ | {
57
+ /** breadcrumb: `false` ปิด, object = ตั้งค่า HeaderBreadcrumbs, ReactNode = ใส่เอง */
58
+ breadcrumbs?: false | HeaderBreadcrumbsProps | ReactNode;
59
+ /** กลางบรรทัด (เช่น pinned menus) */
60
+ center?: ReactNode;
61
+ /** ขวาสุด: notification, feedback, `<UserMenu variant="header" />` … */
62
+ actions?: ReactNode;
63
+ /** แถบเหนือ header เช่น impersonation banner */
64
+ banner?: ReactNode;
65
+ className?: string;
66
+ };
67
+ /** สีแบรนด์ต่อ tenant (ตั้ง --brand บน wrapper → primary/ring/sidebar-primary ตาม) */
68
+ brandColor?: string | null;
69
+ brandForeground?: string | null;
70
+ /** `icon` (ค่าเริ่มต้น) ยุบเหลือไอคอน · `offcanvas` ซ่อนทั้งแผง · `none` ตรึงตลอด */
71
+ collapsible?: ComponentProps<typeof Sidebar>["collapsible"];
72
+ /** เปิด sidebar ตอนโหลด (อ่านจาก cookie `sidebar_state` ฝั่ง server แล้วส่งมาได้) */
73
+ defaultOpen?: boolean;
74
+ /** class ของกล่องเนื้อหา (ค่าเริ่มต้น padding ตาม supplychain) */
75
+ contentClassName?: string;
76
+ /** จำกัดความกว้างเนื้อหา เช่น `max-w-5xl` (Pi Auth) — ไม่ส่ง = เต็มจอ (supplychain) */
77
+ contentMaxWidth?: string;
78
+ children: ReactNode;
79
+ }
80
+
81
+ /**
82
+ * โครงแอปมาตรฐาน: sidebar ซ้าย (shadcn Sidebar — ยุบเป็นไอคอนบนเดสก์ท็อป, Sheet บนมือถือ) + header + เนื้อหา
83
+ * ยกจาก supplychain (main layout + AppSidebar) และ Pi Auth ConsoleShell — ใช้จาก layout.tsx ของแอปได้ทั้ง server/client
84
+ *
85
+ * ```tsx
86
+ * <AppShell brand={{ name: "Pi Auth" }} nav={filterNav(NAV, can)}
87
+ * sidebarFooter={<UserMenu user={user} onSignOut={signOut}><ThemeMenuItems /></UserMenu>}>
88
+ * {children}
89
+ * </AppShell>
90
+ * ```
91
+ */
92
+ export function AppShell({
93
+ brand,
94
+ nav,
95
+ searchable = false,
96
+ searchPlaceholder = "ค้นหาเมนู…",
97
+ pin,
98
+ sidebarHeader,
99
+ sidebarFooter,
100
+ header = true,
101
+ brandColor,
102
+ brandForeground,
103
+ collapsible = "icon",
104
+ defaultOpen = true,
105
+ contentClassName,
106
+ contentMaxWidth,
107
+ children,
108
+ }: AppShellProps) {
109
+ const style = brandColor
110
+ ? ({
111
+ "--brand": brandColor,
112
+ ...(brandForeground ? { "--brand-foreground": brandForeground } : {}),
113
+ } as CSSProperties)
114
+ : undefined;
115
+
116
+ const headerOpts = typeof header === "object" ? header : {};
117
+ const showHeader = header !== false;
118
+ const breadcrumbs =
119
+ headerOpts.breadcrumbs === false ? null : headerOpts.breadcrumbs === undefined ||
120
+ isBreadcrumbOptions(headerOpts.breadcrumbs) ? (
121
+ <HeaderBreadcrumbs {...(headerOpts.breadcrumbs ?? {})} />
122
+ ) : (
123
+ headerOpts.breadcrumbs
124
+ );
125
+
126
+ return (
127
+ // base-ui-ok: defaultOpen อ่านจาก cookie ครั้งเดียวตอน mount — หลังจากนั้น Sidebar จัดการ state เอง (ตั้งใจให้ uncontrolled)
128
+ <SidebarProvider defaultOpen={defaultOpen} style={style}>
129
+ <ShellSidebar
130
+ brand={brand}
131
+ nav={nav}
132
+ searchable={searchable}
133
+ searchPlaceholder={searchPlaceholder}
134
+ pin={pin}
135
+ sidebarHeader={sidebarHeader}
136
+ sidebarFooter={sidebarFooter}
137
+ collapsible={collapsible}
138
+ />
139
+
140
+ <SidebarInset>
141
+ {showHeader && (
142
+ <div className="sticky top-0 z-40 shrink-0 bg-background print:static">
143
+ {headerOpts.banner}
144
+ <header
145
+ className={cn(
146
+ "flex h-auto flex-col border-b px-4 md:h-14 md:flex-row md:items-center print:hidden",
147
+ headerOpts.className,
148
+ )}
149
+ >
150
+ <div className="flex h-12 min-w-0 flex-1 items-center gap-2 md:h-full">
151
+ <SidebarTrigger className="-ml-1" />
152
+ {breadcrumbs && (
153
+ <>
154
+ <Separator orientation="vertical" className="mr-2 hidden h-4 md:block" />
155
+ <div className="min-w-0 flex-1">{breadcrumbs}</div>
156
+ </>
157
+ )}
158
+ </div>
159
+ {(headerOpts.center || headerOpts.actions) && (
160
+ <div className="flex h-10 min-w-0 items-center gap-2 pb-1 md:ml-auto md:h-full md:pb-0">
161
+ {headerOpts.center && (
162
+ <div className="min-w-0 flex-1 overflow-hidden md:flex-none">
163
+ {headerOpts.center}
164
+ </div>
165
+ )}
166
+ {headerOpts.actions && (
167
+ <div className="ml-auto flex shrink-0 items-center gap-2">
168
+ {headerOpts.actions}
169
+ </div>
170
+ )}
171
+ </div>
172
+ )}
173
+ </header>
174
+ </div>
175
+ )}
176
+ <div
177
+ className={cn(
178
+ "flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6 print:overflow-visible",
179
+ contentMaxWidth && cn("mx-auto w-full", contentMaxWidth),
180
+ contentClassName,
181
+ )}
182
+ >
183
+ {children}
184
+ </div>
185
+ </SidebarInset>
186
+ </SidebarProvider>
187
+ );
188
+ }
189
+
190
+ /** แผง sidebar — แยกเพื่อใช้ useSidebar (ล้างคำค้นเมื่อยุบ ไม่ให้เมนูถูกกรองทั้งที่มองไม่เห็นช่องค้นหา) */
191
+ function ShellSidebar({
192
+ brand,
193
+ nav,
194
+ searchable,
195
+ searchPlaceholder,
196
+ pin,
197
+ sidebarHeader,
198
+ sidebarFooter,
199
+ collapsible,
200
+ }: Pick<
201
+ AppShellProps,
202
+ | "brand"
203
+ | "nav"
204
+ | "searchable"
205
+ | "searchPlaceholder"
206
+ | "pin"
207
+ | "sidebarHeader"
208
+ | "sidebarFooter"
209
+ | "collapsible"
210
+ >) {
211
+ const { state, isMobile } = useSidebar();
212
+ const [query, setQuery] = useState("");
213
+ const collapsed = state === "collapsed" && !isMobile;
214
+ useEffect(() => {
215
+ if (collapsed) setQuery("");
216
+ }, [collapsed]);
217
+ const visibleNav = useMemo(() => searchNav(nav, query), [nav, query]);
218
+ const searching = query.trim().length > 0;
219
+
220
+ return (
221
+ <Sidebar collapsible={collapsible} className="print:hidden">
222
+ <SidebarHeader>
223
+ <AppBrand {...brand} />
224
+ {searchable && (
225
+ <div className="group-data-[collapsible=icon]:hidden">
226
+ <InputGroup className="h-8 bg-background shadow-none">
227
+ <InputGroupAddon>
228
+ <Search aria-hidden="true" />
229
+ </InputGroupAddon>
230
+ <InputGroupInput
231
+ name="sidebar-search"
232
+ aria-label={searchPlaceholder}
233
+ autoComplete="off"
234
+ placeholder={searchPlaceholder}
235
+ value={query}
236
+ onChange={(e) => setQuery(e.target.value)}
237
+ />
238
+ {query && (
239
+ <InputGroupAddon align="inline-end">
240
+ <InputGroupButton
241
+ onClick={() => setQuery("")}
242
+ aria-label="ล้างคำค้นหาเมนู"
243
+ className="size-6 p-0 hover:bg-transparent"
244
+ >
245
+ <X aria-hidden="true" />
246
+ </InputGroupButton>
247
+ </InputGroupAddon>
248
+ )}
249
+ </InputGroup>
250
+ </div>
251
+ )}
252
+ {sidebarHeader}
253
+ </SidebarHeader>
254
+ <SidebarContent>
255
+ <AppNav nav={visibleNav} pin={pin} forceOpen={searching} />
256
+ </SidebarContent>
257
+ {sidebarFooter && <SidebarFooter>{sidebarFooter}</SidebarFooter>}
258
+ {collapsible !== "none" && <SidebarRail />}
259
+ </Sidebar>
260
+ );
261
+ }
262
+
263
+ function isBreadcrumbOptions(value: unknown): value is HeaderBreadcrumbsProps {
264
+ return (
265
+ typeof value === "object" &&
266
+ value !== null &&
267
+ !("$$typeof" in (value as object)) &&
268
+ !Array.isArray(value)
269
+ );
270
+ }
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import {
4
+ Breadcrumb,
5
+ BreadcrumbItem,
6
+ BreadcrumbLink,
7
+ BreadcrumbList,
8
+ BreadcrumbPage,
9
+ BreadcrumbSeparator,
10
+ } from "@suphark/ui/components/ui/breadcrumb";
11
+ import Link from "next/link";
12
+ import { usePathname } from "next/navigation";
13
+ import { Fragment } from "react";
14
+
15
+ export interface HeaderBreadcrumbsProps {
16
+ /** ชื่อแสดงของแต่ละ segment (`{ "po-tracking": "PO Tracking" }`) — ไม่พบ = ตัวแรกพิมพ์ใหญ่ แทน - ด้วยช่องว่าง */
17
+ labels?: Record<string, string>;
18
+ homeHref?: string;
19
+ homeLabel?: string;
20
+ /** ตัด segment นำหน้าออก เช่น `["console"]` เมื่อทุกหน้าอยู่ใต้ /console */
21
+ skip?: string[];
22
+ /** ซ่อน segment ที่ดูเหมือน id (ตัวเลข/uuid/cuid) */
23
+ hideIds?: boolean;
24
+ }
25
+
26
+ const ID_LIKE = /^(\d+|[0-9a-f]{8}-[0-9a-f-]{27}|c[a-z0-9]{20,}|[0-9a-z]{16,})$/i;
27
+
28
+ function labelOf(segment: string, labels?: Record<string, string>) {
29
+ const known = labels?.[segment];
30
+ if (known) return known;
31
+ const decoded = decodeURIComponent(segment);
32
+ return decoded.charAt(0).toUpperCase() + decoded.slice(1).replace(/-/g, " ");
33
+ }
34
+
35
+ /** breadcrumb อัตโนมัติจาก pathname (ยกจาก supplychain HeaderBreadcrumbs) */
36
+ export function HeaderBreadcrumbs({
37
+ labels,
38
+ homeHref = "/",
39
+ homeLabel = "Home",
40
+ skip = [],
41
+ hideIds = false,
42
+ }: HeaderBreadcrumbsProps) {
43
+ const pathname = usePathname();
44
+ const all = pathname.split("/").filter(Boolean);
45
+ // segments ที่ยังคง href เต็ม แต่ไม่แสดงตัวที่อยู่ใน skip / เป็น id
46
+ const shown = all
47
+ .map((segment, index) => ({ segment, href: `/${all.slice(0, index + 1).join("/")}` }))
48
+ .filter(({ segment }) => !skip.includes(segment) && !(hideIds && ID_LIKE.test(segment)));
49
+
50
+ return (
51
+ <Breadcrumb>
52
+ <BreadcrumbList>
53
+ <BreadcrumbItem className="hidden md:block">
54
+ <BreadcrumbLink render={<Link href={homeHref} />}>{homeLabel}</BreadcrumbLink>
55
+ </BreadcrumbItem>
56
+ {shown.length > 0 && <BreadcrumbSeparator className="hidden md:block" />}
57
+ {shown.map(({ segment, href }, index) => {
58
+ const isLast = index === shown.length - 1;
59
+ const label = labelOf(segment, labels);
60
+ return (
61
+ <Fragment key={href}>
62
+ <BreadcrumbItem>
63
+ {isLast ? (
64
+ <BreadcrumbPage className="max-w-[160px] truncate md:max-w-none">
65
+ {label}
66
+ </BreadcrumbPage>
67
+ ) : (
68
+ <BreadcrumbLink render={<Link href={href} />}>{label}</BreadcrumbLink>
69
+ )}
70
+ </BreadcrumbItem>
71
+ {!isLast && <BreadcrumbSeparator className="hidden md:block" />}
72
+ </Fragment>
73
+ );
74
+ })}
75
+ </BreadcrumbList>
76
+ </Breadcrumb>
77
+ );
78
+ }
@@ -0,0 +1,14 @@
1
+ export { AppBrand, type AppBrandProps } from "./app-brand";
2
+ export { AppNav, AppNavGroupMenu, type AppNavPinProps } from "./app-nav";
3
+ export { AppShell, type AppShellProps } from "./app-shell";
4
+ export { HeaderBreadcrumbs, type HeaderBreadcrumbsProps } from "./header-breadcrumbs";
5
+ export { NavLink } from "./nav-link";
6
+ export {
7
+ type AppNavGroup,
8
+ type AppNavItem,
9
+ filterNav,
10
+ flattenNav,
11
+ isNavActive,
12
+ searchNav,
13
+ } from "./types";
14
+ export { UserMenu, type UserMenuProps, type UserMenuUser } from "./user-menu";
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import { cn } from "@suphark/ui/lib/utils";
4
+ import Link from "next/link";
5
+ import { usePathname } from "next/navigation";
6
+ import type { ComponentProps, ReactNode } from "react";
7
+ import { isNavActive } from "./types";
8
+
9
+ /** ลิงก์ที่ไฮไลต์เมื่อ path ปัจจุบันอยู่ใต้ href (prefix match) — ยกจาก Pi Auth */
10
+ export function NavLink({
11
+ href,
12
+ children,
13
+ exact = false,
14
+ className,
15
+ activeClassName = "bg-accent text-accent-foreground font-medium",
16
+ ...props
17
+ }: Omit<ComponentProps<typeof Link>, "href"> & {
18
+ href: string;
19
+ children: ReactNode;
20
+ exact?: boolean;
21
+ activeClassName?: string;
22
+ }) {
23
+ const pathname = usePathname();
24
+ const active = isNavActive(pathname, href, exact);
25
+ return (
26
+ <Link
27
+ href={href}
28
+ className={cn(className, active && activeClassName)}
29
+ aria-current={active ? "page" : undefined}
30
+ {...props}
31
+ >
32
+ {children}
33
+ </Link>
34
+ );
35
+ }
@@ -0,0 +1,92 @@
1
+ import type { CheckPermissionRequirement } from "@suphark/ui/lib/permission";
2
+ import type { ReactNode } from "react";
3
+
4
+ /**
5
+ * โมเดลเมนูของ AppShell — ใช้ร่วมกันทุกแอป
6
+ *
7
+ * `icon` เป็น ReactNode (เช่น `<UsersIcon />`) ไม่ใช่ component เพื่อให้ประกาศเมนูใน server component
8
+ * (layout.tsx) แล้วส่งเข้า client component ได้ (function ส่งข้าม boundary ไม่ได้)
9
+ */
10
+ export interface AppNavItem {
11
+ title: string;
12
+ /** ไม่มี href = หัวกลุ่มย่อยที่กางออก (ต้องมี items) */
13
+ href?: string;
14
+ icon?: ReactNode;
15
+ /** เมนูย่อย (รองรับ 2 ชั้น: item → items) */
16
+ items?: AppNavItem[];
17
+ /** ไฮไลต์เฉพาะเมื่อ path ตรงทั้งหมด (ค่าเริ่มต้น: prefix match) */
18
+ exact?: boolean;
19
+ /** สิทธิ์ที่ต้องมีจึงเห็นเมนูนี้ — ตัดด้วย `filterNav` */
20
+ permission?: CheckPermissionRequirement;
21
+ /** ป้ายท้ายเมนู เช่นจำนวนงานค้าง */
22
+ badge?: ReactNode;
23
+ /** ฟีเจอร์ทดลอง — แสดงสีอำพัน + ไอคอนขวดทดลอง (ตาม supplychain) */
24
+ isLabs?: boolean;
25
+ }
26
+
27
+ export interface AppNavGroup {
28
+ /** หัวกลุ่ม (ไม่ใส่ = ไม่มีหัว) */
29
+ title?: string;
30
+ items: AppNavItem[];
31
+ permission?: CheckPermissionRequirement;
32
+ }
33
+
34
+ /**
35
+ * ตัดเมนูที่ผู้ใช้ไม่มีสิทธิ์ออกทุกชั้น — ไม่ให้กดแล้วเจอหน้า error (หน้าเองยังต้องตรวจสิทธิ์ซ้ำ)
36
+ * `can` = ตัวตรวจของแอป (เช่น `ctx.can`, หรือ `usePermissionChecker()` ฝั่ง client)
37
+ */
38
+ export function filterNav(
39
+ nav: AppNavGroup[],
40
+ can: (permission: CheckPermissionRequirement) => boolean,
41
+ ): AppNavGroup[] {
42
+ const filterItems = (items: AppNavItem[]): AppNavItem[] =>
43
+ items
44
+ .filter((item) => !item.permission || can(item.permission))
45
+ .map((item) => (item.items ? { ...item, items: filterItems(item.items) } : item))
46
+ // กลุ่มย่อยที่ลูกโดนตัดหมดและตัวเองไม่มี href → ตัดทิ้ง
47
+ .filter((item) => item.href || (item.items && item.items.length > 0));
48
+
49
+ return nav
50
+ .filter((group) => !group.permission || can(group.permission))
51
+ .map((group) => ({ ...group, items: filterItems(group.items) }))
52
+ .filter((group) => group.items.length > 0);
53
+ }
54
+
55
+ /** true เมื่อ pathname อยู่ใต้ href (หรือตรงทั้งหมดเมื่อ exact) */
56
+ export function isNavActive(pathname: string, href: string | undefined, exact = false): boolean {
57
+ if (!href || href === "#") return false;
58
+ if (exact || href === "/") return pathname === href;
59
+ return pathname === href || pathname.startsWith(`${href}/`);
60
+ }
61
+
62
+ /** กรองเมนูตามคำค้น (ช่องค้นหาเมนูใน sidebar) — เก็บ parent ไว้ถ้าลูกตรง */
63
+ export function searchNav(nav: AppNavGroup[], query: string): AppNavGroup[] {
64
+ const q = query.trim().toLowerCase();
65
+ if (!q) return nav;
66
+ const filterItems = (items: AppNavItem[]): AppNavItem[] =>
67
+ items
68
+ .map((item) => {
69
+ if (item.title.toLowerCase().includes(q)) return item;
70
+ const children = item.items ? filterItems(item.items) : [];
71
+ return children.length > 0 ? { ...item, items: children } : null;
72
+ })
73
+ .filter((item): item is AppNavItem => item !== null);
74
+ return nav
75
+ .map((group) => ({ ...group, items: filterItems(group.items) }))
76
+ .filter((group) => group.items.length > 0);
77
+ }
78
+
79
+ /** รายการเมนูทั้งหมดแบบแบน (สำหรับ command palette / pinned menus) */
80
+ export function flattenNav(
81
+ nav: AppNavGroup[],
82
+ ): Array<AppNavItem & { href: string; group?: string }> {
83
+ const out: Array<AppNavItem & { href: string; group?: string }> = [];
84
+ const walk = (items: AppNavItem[], group?: string) => {
85
+ for (const item of items) {
86
+ if (item.href) out.push({ ...item, href: item.href, group });
87
+ if (item.items) walk(item.items, group);
88
+ }
89
+ };
90
+ for (const g of nav) walk(g.items, g.title);
91
+ return out;
92
+ }
@@ -0,0 +1,133 @@
1
+ "use client";
2
+
3
+ import { Initials } from "@suphark/ui/components/shared/initials";
4
+ import { Button } from "@suphark/ui/components/ui/button";
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuGroup,
9
+ DropdownMenuItem,
10
+ DropdownMenuLabel,
11
+ DropdownMenuSeparator,
12
+ DropdownMenuTrigger,
13
+ } from "@suphark/ui/components/ui/dropdown-menu";
14
+ import { cn } from "@suphark/ui/lib/utils";
15
+ import { ChevronsUpDown, LogOut } from "lucide-react";
16
+ import { type ReactNode, useTransition } from "react";
17
+
18
+ export interface UserMenuUser {
19
+ name: string;
20
+ email?: string | null;
21
+ /** URL รูป */
22
+ image?: string | null;
23
+ /** บรรทัดรอง เช่น ตำแหน่ง · องค์กร */
24
+ description?: ReactNode;
25
+ }
26
+
27
+ export interface UserMenuProps {
28
+ user: UserMenuUser;
29
+ /**
30
+ * รายการเมนู (DropdownMenuItem / DropdownMenuGroup / ThemeMenuItems …) — วางระหว่างหัวกับปุ่มออกจากระบบ
31
+ * แอปเป็นคนใส่เอง เช่น บัญชีผู้ใช้, การแจ้งเตือน, ธีม, สลับภาษา
32
+ */
33
+ children?: ReactNode;
34
+ /** ไม่ส่ง = ไม่มีปุ่มออกจากระบบ */
35
+ onSignOut?: () => void | Promise<void>;
36
+ signOutLabel?: ReactNode;
37
+ /**
38
+ * `sidebar` = ปุ่มกว้างเต็ม footer ของ sidebar (Pi Auth) · `header` = ปุ่มกะทัดรัดใน header (supplychain)
39
+ */
40
+ variant?: "sidebar" | "header";
41
+ align?: "start" | "end";
42
+ className?: string;
43
+ }
44
+
45
+ /** เมนูผู้ใช้ (ยกจาก supplychain NavUser + Pi Auth UserMenu) */
46
+ export function UserMenu({
47
+ user,
48
+ children,
49
+ onSignOut,
50
+ signOutLabel = "ออกจากระบบ",
51
+ variant = "sidebar",
52
+ align = variant === "header" ? "end" : "start",
53
+ className,
54
+ }: UserMenuProps) {
55
+ const [pending, start] = useTransition();
56
+ const description = user.description ?? user.email;
57
+ return (
58
+ <DropdownMenu>
59
+ <DropdownMenuTrigger
60
+ render={
61
+ <Button
62
+ variant="ghost"
63
+ aria-label={`เปิดเมนูผู้ใช้ ${user.name}`}
64
+ className={cn(
65
+ variant === "sidebar"
66
+ ? "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"
67
+ : "h-9 min-w-0 gap-2 px-1.5 data-[state=open]:bg-accent sm:px-2",
68
+ className,
69
+ )}
70
+ />
71
+ }
72
+ >
73
+ <Initials name={user.name} src={user.image} size="sm" />
74
+ <span
75
+ className={cn(
76
+ "min-w-0 flex-1 flex-col text-left leading-tight",
77
+ variant === "sidebar" ? "flex group-data-[collapsible=icon]:hidden" : "hidden xl:flex",
78
+ )}
79
+ >
80
+ <span className="truncate font-medium text-sm">{user.name}</span>
81
+ {description && (
82
+ <span className="truncate text-muted-foreground text-xs">{description}</span>
83
+ )}
84
+ </span>
85
+ <ChevronsUpDown
86
+ aria-hidden="true"
87
+ className={cn(
88
+ "text-muted-foreground",
89
+ variant === "sidebar" ? "group-data-[collapsible=icon]:hidden" : "hidden xl:block",
90
+ )}
91
+ />
92
+ </DropdownMenuTrigger>
93
+ <DropdownMenuContent
94
+ align={align}
95
+ side={variant === "header" ? "bottom" : undefined}
96
+ sideOffset={variant === "header" ? 8 : 4}
97
+ className="min-w-60 rounded-lg"
98
+ >
99
+ <DropdownMenuGroup>
100
+ <DropdownMenuLabel className="p-0 font-normal">
101
+ <div className="flex min-w-0 items-center gap-2 px-2 py-2 text-left text-sm">
102
+ <Initials name={user.name} src={user.image} size="sm" />
103
+ <div className="grid min-w-0 flex-1 leading-tight">
104
+ <span className="truncate font-medium">{user.name}</span>
105
+ {user.description && (
106
+ <span className="truncate text-muted-foreground text-xs">{user.description}</span>
107
+ )}
108
+ {user.email && (
109
+ <span className="truncate text-muted-foreground text-xs">{user.email}</span>
110
+ )}
111
+ </div>
112
+ </div>
113
+ </DropdownMenuLabel>
114
+ </DropdownMenuGroup>
115
+ {children && (
116
+ <>
117
+ <DropdownMenuSeparator />
118
+ {children}
119
+ </>
120
+ )}
121
+ {onSignOut && (
122
+ <>
123
+ <DropdownMenuSeparator />
124
+ <DropdownMenuItem disabled={pending} onClick={() => start(() => onSignOut())}>
125
+ <LogOut aria-hidden="true" />
126
+ {signOutLabel}
127
+ </DropdownMenuItem>
128
+ </>
129
+ )}
130
+ </DropdownMenuContent>
131
+ </DropdownMenu>
132
+ );
133
+ }
package/src/index.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  * `chart` (recharts) ไม่อยู่ใน barrel: import ตรง `@suphark/ui/components/ui/chart` เมื่อแอปติดตั้ง recharts เอง
10
10
  */
11
11
 
12
+ // sonner: `toast()` คู่กับ <Toaster /> — แอปไม่ต้องติดตั้ง sonner เอง
13
+ export { toast } from "sonner";
12
14
  export * from "./components/shared/button";
13
15
  export { ClientRedirect } from "./components/shared/client-redirect";
14
16
  export { Combobox } from "./components/shared/combobox";
@@ -131,7 +133,6 @@ export * from "./components/ui/textarea";
131
133
  export * from "./components/ui/toggle";
132
134
  export * from "./components/ui/toggle-group";
133
135
  export * from "./components/ui/tooltip";
134
-
135
136
  // ---------- lib / hooks ----------
136
137
  export { useCopyToClipboard } from "./hooks/use-copy-to-clipboard";
137
138
  export { useIsMobile } from "./hooks/use-mobile";
package/src/next.ts CHANGED
@@ -2,6 +2,9 @@
2
2
  * @suphark/ui/next — component ที่ต้องรันใน Next.js (ใช้ next/navigation หรือ next/link)
3
3
  * แยกจาก barrel หลักเพื่อให้ `@suphark/ui` ใช้ได้ในสภาพแวดล้อมที่ไม่มี next (vitest, Vite, Storybook)
4
4
  */
5
+
6
+ // ---------- app shell (sidebar + header + เนื้อหา) ----------
7
+ export * from "./components/app-shell";
5
8
  export { LinkButton, type LinkButtonProps } from "./components/shared/button/base/link-button";
6
9
  export { RefreshButton } from "./components/shared/button/refresh-button";
7
10
  export { FormDialog } from "./components/shared/form-dialog";