@suphark/ui 0.1.1 → 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.
Files changed (30) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +45 -3
  3. package/package.json +14 -2
  4. package/src/components/app-shell/app-brand.tsx +81 -0
  5. package/src/components/app-shell/app-nav.tsx +260 -0
  6. package/src/components/app-shell/app-shell.tsx +270 -0
  7. package/src/components/app-shell/header-breadcrumbs.tsx +78 -0
  8. package/src/components/app-shell/index.ts +14 -0
  9. package/src/components/app-shell/nav-link.tsx +35 -0
  10. package/src/components/app-shell/types.ts +92 -0
  11. package/src/components/app-shell/user-menu.tsx +133 -0
  12. package/src/components/data-table/data-table-column-header.tsx +71 -0
  13. package/src/components/data-table/data-table-date-range-filter.tsx +25 -0
  14. package/src/components/data-table/data-table-faceted-filter.tsx +181 -0
  15. package/src/components/data-table/data-table-pagination.tsx +114 -0
  16. package/src/components/data-table/data-table-row-actions.tsx +166 -0
  17. package/src/components/data-table/data-table-toolbar.tsx +207 -0
  18. package/src/components/data-table/data-table-view-option.tsx +59 -0
  19. package/src/components/data-table/data-table.tsx +399 -0
  20. package/src/components/data-table/index.ts +9 -0
  21. package/src/components/data-table/types.ts +106 -0
  22. package/src/components/data-table/use-data-table.ts +229 -0
  23. package/src/components/design-system/sections/showcase-data-display.tsx +71 -0
  24. package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +8 -8
  25. package/src/components/shared/button/delete-many-button.tsx +85 -0
  26. package/src/components/shared/resource-property-filter.tsx +399 -0
  27. package/src/data-table.ts +21 -0
  28. package/src/hooks/use-table-search-params.ts +147 -0
  29. package/src/index.ts +2 -1
  30. package/src/next.ts +8 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
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
+
16
+ ## [0.2.0] — 2026-09-18
17
+
18
+ ### Added
19
+
20
+ - entry **`@suphark/ui/data-table`** (ยกจาก supplychain `components/data-table`): `DataTable`, `DataTableColumnHeader`, `DataTableToolbar`, `DataTablePagination`, `DataTableFacetedFilter`, `DataTableDateRangeFilter`, `DataTableViewOptions`, `DataTableRowActions`, `useDataTable`, types (`DataTableProps`, `FacetedFilterConfig`) + `DeleteManyButton`, `ResourcePropertyFilter` (+helpers) — ต้องมี peer `@tanstack/react-table` (optional; แอปที่ไม่ใช้ตารางไม่ต้องติดตั้ง); ลากเรียงแถวใช้ `@dnd-kit/*` เป็น dependency ของแพ็กเกจ
21
+ - `useTableSearchParams` / `TABLE_PARAM` (state ตารางใน URL) ที่ `@suphark/ui/next`
22
+ - tests: data-table, data-table-faceted-filter, resource-property-filter (รวม 34)
23
+ - exports `./package.json`, `./components/data-table`
24
+ - showcase: การ์ด DataTable จริงในแท็บ Data Display; code recipe ใช้ import path ของแพ็กเกจ
25
+ - `apps/showcase/vercel.json` + `docs/ops/deploy-vercel.md`
26
+
5
27
  ## [0.1.1] — 2026-09-18
6
28
 
7
29
  ### Added
package/README.md CHANGED
@@ -10,7 +10,7 @@ Design system กลางของ Suphark apps (Pi Auth, DueBell, Pi PDF, supp
10
10
  pnpm add @suphark/ui @base-ui/react lucide-react react-hook-form
11
11
  ```
12
12
 
13
- peer ที่ต้องมีอยู่แล้ว: `react`/`react-dom` ≥19, `tailwindcss` ^4, `next` ≥15 (เฉพาะ `@suphark/ui/next`), `recharts` (เฉพาะ `components/ui/chart`)
13
+ peer ที่ต้องมีอยู่แล้ว: `react`/`react-dom` ≥19, `tailwindcss` ^4, `next` ≥15 (เฉพาะ `@suphark/ui/next`), `@tanstack/react-table` (เฉพาะ `@suphark/ui/data-table`), `recharts` (เฉพาะ `components/ui/chart`)
14
14
 
15
15
  ### 1. next.config.ts
16
16
 
@@ -55,8 +55,11 @@ 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
+
61
+ // ตารางข้อมูล (ต้องติดตั้ง @tanstack/react-table เอง)
62
+ import { DataTable, DataTableColumnHeader, useDataTable } from "@suphark/ui/data-table";
60
63
 
61
64
  // import ตรงรายไฟล์ก็ได้ (เช่น chart ที่ต้องมี recharts)
62
65
  import { ChartContainer } from "@suphark/ui/components/ui/chart";
@@ -68,11 +71,50 @@ import { ChartContainer } from "@suphark/ui/components/ui/chart";
68
71
  |---|---|---|
69
72
  | `src/components/ui/*` | shadcn/ui ต้นฉบับ 59 ตัว (+ `__tests__`) | **ห้ามแก้มือ** — เพิ่ม/อัปเดตด้วย `pnpm dlx shadcn@latest add <name>` ใน `packages/ui` (`components.json` ตั้ง alias ไว้แล้ว) |
70
73
  | `src/components/shared/*` | ของที่เราประกอบเอง: ปุ่มมาตรฐาน (`button/`), form-field บน react-hook-form (`form/`), layout, theme, combobox, และชุดจาก Pi Auth (FormDialog, NameIdFields, StatusBadge, RowMenu, …) | แก้ได้ แต่คง props เดิมให้มากที่สุด (minor = เพิ่ม, major = เปลี่ยน) |
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) |
71
76
  | `src/components/design-system/*` | หน้า showcase (render ใน `apps/showcase`) | ไม่อยู่ใน barrel |
72
77
  | `src/lib/*`, `src/hooks/*` | `cn`, formatter วันที่ไทย, สีสถานะ, permission context, `useIsMobile`, `useCopyToClipboard` | |
73
78
  | `src/styles.css` | token (slate + success/warning/sidebar/chart), radius, ฟอนต์, scrollbar, dark variant | |
74
79
  | `scripts/*.mjs` | audit บังคับ: Base UI props (`default*`/`onSelect`), typography scale | แอปปลายทางรันได้จาก `node_modules/@suphark/ui/scripts` |
75
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
+
76
118
  ### สีแบรนด์ต่อ tenant/แอป (`--brand`)
77
119
 
78
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.1.1",
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 เอง",
@@ -38,14 +38,21 @@
38
38
  "exports": {
39
39
  ".": "./src/index.ts",
40
40
  "./next": "./src/next.ts",
41
+ "./data-table": "./src/data-table.ts",
41
42
  "./styles.css": "./src/styles.css",
42
43
  "./components/shared/button": "./src/components/shared/button/index.ts",
43
44
  "./components/shared/theme": "./src/components/shared/theme/index.ts",
45
+ "./components/data-table": "./src/components/data-table/index.ts",
46
+ "./components/app-shell": "./src/components/app-shell/index.ts",
44
47
  "./components/*": "./src/components/*.tsx",
45
48
  "./lib/*": "./src/lib/*.ts",
46
- "./hooks/*": "./src/hooks/*.ts"
49
+ "./hooks/*": "./src/hooks/*.ts",
50
+ "./package.json": "./package.json"
47
51
  },
48
52
  "dependencies": {
53
+ "@dnd-kit/core": "^6.3.1",
54
+ "@dnd-kit/sortable": "^10.0.0",
55
+ "@dnd-kit/utilities": "^3.2.2",
49
56
  "@fontsource/noto-sans-thai-looped": "^5.3.0",
50
57
  "class-variance-authority": "^0.7.1",
51
58
  "clsx": "^2.1.1",
@@ -64,6 +71,7 @@
64
71
  },
65
72
  "peerDependencies": {
66
73
  "@base-ui/react": ">=1.7.0",
74
+ "@tanstack/react-table": ">=8.20.0",
67
75
  "lucide-react": ">=1.30.0",
68
76
  "next": ">=15",
69
77
  "react": ">=19",
@@ -78,10 +86,14 @@
78
86
  },
79
87
  "recharts": {
80
88
  "optional": true
89
+ },
90
+ "@tanstack/react-table": {
91
+ "optional": true
81
92
  }
82
93
  },
83
94
  "devDependencies": {
84
95
  "@base-ui/react": "^1.8.0",
96
+ "@tanstack/react-table": "^8.21.3",
85
97
  "@testing-library/jest-dom": "^6.9.1",
86
98
  "@testing-library/react": "^16.3.2",
87
99
  "@testing-library/user-event": "^14.6.1",
@@ -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
+ }