@suphark/ui 0.3.3 → 0.4.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 +8 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/components/app-shell/app-shell.tsx +121 -2
- package/src/components/app-shell/header-crumb.tsx +82 -0
- package/src/components/app-shell/index.ts +3 -0
- package/src/components/app-shell/sub-nav.tsx +147 -0
- package/src/components/app-shell/top-tabs.tsx +68 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
+
## [0.4.0] — 2026-09-19
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`AppShell layout="top"`** — โครงแบบ Clerk dashboard (ขอจาก Pi Auth): header 56px มีโลโก้ + **crumb switchers** คั่น `/` (`crumbs` = array หรือ component ที่ render `<HeaderCrumbs>`) + `header.actions`/`banner`/`center`; **แถบแท็บหลัก** ใต้ header จาก `nav` ชั้นแรก (active underline ตาม prefix, badge, เลื่อนแนวนอนบนมือถือ); แท็บที่มี `items` → **sub-nav ซ้าย** 224px (รองรับหัวกลุ่ม 2 ชั้น, `searchable`, มือถือเป็น select); `contentMaxWidth`, `brandColor` ใช้ได้เหมือนเดิม; breadcrumb ของหน้าปิดเป็นค่าเริ่มต้นในโหมดนี้ — โหมด `sidebar` เดิมไม่เปลี่ยน
|
|
10
|
+
- component ประกอบ (export จาก `@suphark/ui/next`): `HeaderCrumb` (ปุ่ม switcher: icon/label/badge/hint + ⌄ ใช้เป็น trigger ของ DropdownMenu ใดก็ได้), `HeaderCrumbs`, `TopTabs`, `SubNav`, helpers `topLevelTabs`/`isTabActive`
|
|
11
|
+
- showcase: `/shell-top` ตัวอย่างเต็ม (องค์กร → แอป → Production [Invite-only], แท็บ 6 ตัว, "ตั้งค่า" มี sub-nav 2 กลุ่ม)
|
|
12
|
+
|
|
5
13
|
## [0.3.3] — 2026-09-19
|
|
6
14
|
|
|
7
15
|
### Fixed
|
package/README.md
CHANGED
|
@@ -113,6 +113,7 @@ export default async function Layout({ children }) {
|
|
|
113
113
|
}
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
+
- **`layout="top"`** = แบบ Clerk dashboard: `crumbs={[<HeaderCrumb …/>, …]}` (switcher องค์กร/แอป/สภาพแวดล้อม — ห่อ DropdownMenu เอง), `nav` ชั้นแรกเป็นแท็บบน ชั้นสองเป็น sub-nav ซ้าย, `header.actions` ขวา — ตัวอย่าง `apps/showcase/src/app/shell-top`
|
|
116
117
|
- `header={false}` ไม่มี header · `collapsible="offcanvas"` ซ่อนทั้งแผง (supplychain) · `contentMaxWidth="max-w-5xl"` จำกัดความกว้าง (Pi Auth)
|
|
117
118
|
- `pin={{ pinnedHrefs, onTogglePin }}` เปิดปุ่มปักหมุดเมนู (แอปเก็บ state เอง) · `header.center` วาง pinned menus
|
|
118
119
|
- `sidebarHeader` ซ่อนอัตโนมัติเมื่อยุบเป็นไอคอน — ใส่ `sidebarHeaderCollapsed` ถ้าต้องการไอคอนแทน (เช่นปุ่ม ⌘K)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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 เอง",
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
} from "@suphark/ui/components/ui/sidebar";
|
|
21
21
|
import { cn } from "@suphark/ui/lib/utils";
|
|
22
22
|
import { Search, X } from "lucide-react";
|
|
23
|
+
import Link from "next/link";
|
|
24
|
+
import { usePathname } from "next/navigation";
|
|
23
25
|
import {
|
|
24
26
|
type ComponentProps,
|
|
25
27
|
type CSSProperties,
|
|
@@ -31,10 +33,26 @@ import {
|
|
|
31
33
|
import { AppBrand, type AppBrandProps } from "./app-brand";
|
|
32
34
|
import { AppNav, type AppNavPinProps } from "./app-nav";
|
|
33
35
|
import { HeaderBreadcrumbs, type HeaderBreadcrumbsProps } from "./header-breadcrumbs";
|
|
36
|
+
import { HeaderCrumbs } from "./header-crumb";
|
|
37
|
+
import { SubNav } from "./sub-nav";
|
|
38
|
+
import { isTabActive, TopTabs, topLevelTabs } from "./top-tabs";
|
|
34
39
|
import { type AppNavGroup, searchNav } from "./types";
|
|
35
40
|
|
|
36
41
|
export interface AppShellProps {
|
|
37
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* `sidebar` (ค่าเริ่มต้น) = sidebar ซ้าย (supplychain / console ทั่วไป)
|
|
44
|
+
* `top` = แบบ Clerk dashboard: header มี crumb switchers + actions, แถบแท็บหลักใต้ header,
|
|
45
|
+
* แท็บที่มีลูก → sub-nav ซ้ายแคบ; `nav` ชั้นแรกกลายเป็นแท็บ, ชั้นสองเป็น sub-nav
|
|
46
|
+
*/
|
|
47
|
+
layout?: "sidebar" | "top";
|
|
48
|
+
/**
|
|
49
|
+
* (layout top) ปุ่ม switcher ใน header เรียงจากซ้ายคั่นด้วย `/` — ใส่ `HeaderCrumb` ที่ห่อ DropdownMenu ของแอปเอง
|
|
50
|
+
* เช่น [องค์กร ⌄, แอป ⌄, ● Production ⌄]; ส่งเป็น array ให้คั่น `/` ให้ หรือส่ง component ที่ render `<HeaderCrumbs>` เอง; ไม่ส่ง = ชื่อแอปจาก `brand`
|
|
51
|
+
*/
|
|
52
|
+
crumbs?: ReactNode[] | ReactNode;
|
|
53
|
+
/** (layout top) หัวของ sub-nav — ค่าเริ่มต้น = ชื่อแท็บที่ active */
|
|
54
|
+
subNavTitle?: ReactNode;
|
|
55
|
+
/** โลโก้/ชื่อแอปมุมบน sidebar (layout top: โลโก้เล็กหน้า crumbs ลิงก์ไป brand.href) */
|
|
38
56
|
brand: AppBrandProps;
|
|
39
57
|
/** เมนู (ตัดตามสิทธิ์ด้วย `filterNav` ก่อนส่งเข้ามา) */
|
|
40
58
|
nav: AppNavGroup[];
|
|
@@ -91,7 +109,12 @@ export interface AppShellProps {
|
|
|
91
109
|
* </AppShell>
|
|
92
110
|
* ```
|
|
93
111
|
*/
|
|
94
|
-
export function AppShell({
|
|
112
|
+
export function AppShell(props: AppShellProps) {
|
|
113
|
+
if (props.layout === "top") return <TopShell {...props} />;
|
|
114
|
+
return <SidebarShell {...props} />;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function SidebarShell({
|
|
95
118
|
brand,
|
|
96
119
|
nav,
|
|
97
120
|
searchable = false,
|
|
@@ -191,6 +214,102 @@ export function AppShell({
|
|
|
191
214
|
);
|
|
192
215
|
}
|
|
193
216
|
|
|
217
|
+
/** layout "top" — header (โลโก้ + crumbs | actions) → แถบแท็บ → [sub-nav ซ้าย?] + เนื้อหา */
|
|
218
|
+
function TopShell({
|
|
219
|
+
brand,
|
|
220
|
+
nav,
|
|
221
|
+
crumbs,
|
|
222
|
+
subNavTitle,
|
|
223
|
+
searchable = false,
|
|
224
|
+
searchPlaceholder = "ค้นหา…",
|
|
225
|
+
header = true,
|
|
226
|
+
brandColor,
|
|
227
|
+
brandForeground,
|
|
228
|
+
contentClassName,
|
|
229
|
+
contentMaxWidth,
|
|
230
|
+
children,
|
|
231
|
+
}: AppShellProps) {
|
|
232
|
+
const pathname = usePathname();
|
|
233
|
+
const style = brandColor
|
|
234
|
+
? ({
|
|
235
|
+
"--brand": brandColor,
|
|
236
|
+
...(brandForeground ? { "--brand-foreground": brandForeground } : {}),
|
|
237
|
+
} as CSSProperties)
|
|
238
|
+
: undefined;
|
|
239
|
+
const headerOpts = typeof header === "object" ? header : {};
|
|
240
|
+
// breadcrumb ของหน้าใน layout top ปิดเป็นค่าเริ่มต้น (แท็บบอกตำแหน่งอยู่แล้ว) — เปิดด้วย header.breadcrumbs
|
|
241
|
+
const breadcrumbs =
|
|
242
|
+
headerOpts.breadcrumbs === undefined ||
|
|
243
|
+
headerOpts.breadcrumbs === false ? null : isBreadcrumbOptions(headerOpts.breadcrumbs) ? (
|
|
244
|
+
<HeaderBreadcrumbs {...headerOpts.breadcrumbs} />
|
|
245
|
+
) : (
|
|
246
|
+
headerOpts.breadcrumbs
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const activeTab = topLevelTabs(nav).find((t) => isTabActive(pathname, t));
|
|
250
|
+
const subItems = activeTab?.items ?? [];
|
|
251
|
+
const hasSubNav = subItems.length > 0;
|
|
252
|
+
|
|
253
|
+
const logo = (
|
|
254
|
+
<Link
|
|
255
|
+
href={brand.href ?? "/"}
|
|
256
|
+
aria-label={typeof brand.name === "string" ? brand.name : "หน้าแรก"}
|
|
257
|
+
className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-md bg-sidebar-primary font-bold text-sidebar-primary-foreground text-xs no-underline [&_img]:size-full [&_img]:object-contain [&_svg]:size-4"
|
|
258
|
+
>
|
|
259
|
+
{brand.logo ?? (typeof brand.name === "string" ? brand.name.charAt(0).toUpperCase() : "•")}
|
|
260
|
+
</Link>
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div style={style} className="flex min-h-svh flex-col bg-background">
|
|
265
|
+
{header !== false && (
|
|
266
|
+
<div className="sticky top-0 z-40 bg-background print:static">
|
|
267
|
+
{headerOpts.banner}
|
|
268
|
+
<header className={cn("border-b px-4 md:px-6", headerOpts.className)}>
|
|
269
|
+
<div className="flex h-14 items-center gap-3">
|
|
270
|
+
{logo}
|
|
271
|
+
{Array.isArray(crumbs) ? (
|
|
272
|
+
<HeaderCrumbs className="min-w-0 flex-1">{crumbs}</HeaderCrumbs>
|
|
273
|
+
) : crumbs ? (
|
|
274
|
+
<div className="flex min-w-0 flex-1 items-center">{crumbs}</div>
|
|
275
|
+
) : (
|
|
276
|
+
<span className="min-w-0 flex-1 truncate font-semibold text-sm">{brand.name}</span>
|
|
277
|
+
)}
|
|
278
|
+
{headerOpts.center && <div className="hidden md:block">{headerOpts.center}</div>}
|
|
279
|
+
{headerOpts.actions && (
|
|
280
|
+
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
|
281
|
+
{headerOpts.actions}
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
</div>
|
|
285
|
+
<TopTabs nav={nav} />
|
|
286
|
+
</header>
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
<div
|
|
291
|
+
className={cn(
|
|
292
|
+
"flex w-full flex-1 flex-col gap-4 p-4 md:flex-row md:gap-8 md:p-6",
|
|
293
|
+
contentMaxWidth && cn("mx-auto", contentMaxWidth),
|
|
294
|
+
)}
|
|
295
|
+
>
|
|
296
|
+
{hasSubNav && (
|
|
297
|
+
<SubNav
|
|
298
|
+
items={subItems}
|
|
299
|
+
title={subNavTitle ?? activeTab?.title}
|
|
300
|
+
searchable={searchable}
|
|
301
|
+
searchPlaceholder={searchPlaceholder}
|
|
302
|
+
/>
|
|
303
|
+
)}
|
|
304
|
+
<div className={cn("flex min-w-0 flex-1 flex-col gap-4 md:gap-6", contentClassName)}>
|
|
305
|
+
{breadcrumbs}
|
|
306
|
+
{children}
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
194
313
|
/** แผง sidebar — แยกเพื่อใช้ useSidebar (ล้างคำค้นเมื่อยุบ ไม่ให้เมนูถูกกรองทั้งที่มองไม่เห็นช่องค้นหา) */
|
|
195
314
|
function ShellSidebar({
|
|
196
315
|
brand,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
4
|
+
import { ChevronsUpDown } from "lucide-react";
|
|
5
|
+
import { type ComponentProps, Fragment, type ReactNode } from "react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ปุ่ม "switcher" ใน header แบบ Clerk dashboard (องค์กร ⌄ / แอป ⌄ / สภาพแวดล้อม ⌄)
|
|
9
|
+
* ใช้เป็น trigger ของ DropdownMenu/Popover ใดก็ได้: `<DropdownMenuTrigger render={<HeaderCrumb … />} />`
|
|
10
|
+
* หรือเป็นลิงก์: `<HeaderCrumb render={<Link href=… />} />` — เป็น <button> ธรรมดา
|
|
11
|
+
*/
|
|
12
|
+
export function HeaderCrumb({
|
|
13
|
+
icon,
|
|
14
|
+
label,
|
|
15
|
+
badge,
|
|
16
|
+
hint,
|
|
17
|
+
chevron = true,
|
|
18
|
+
className,
|
|
19
|
+
children,
|
|
20
|
+
...props
|
|
21
|
+
}: Omit<ComponentProps<"button">, "children"> & {
|
|
22
|
+
/** ไอคอน/โลโก้เล็กด้านซ้าย (lucide, <img>, Initials, จุดสี) */
|
|
23
|
+
icon?: ReactNode;
|
|
24
|
+
label: ReactNode;
|
|
25
|
+
/** ป้ายเล็กท้ายชื่อ เช่น "Invite-only", "Production" */
|
|
26
|
+
badge?: ReactNode;
|
|
27
|
+
/** ข้อความจางถัดจากชื่อ (จอกว้าง) */
|
|
28
|
+
hint?: ReactNode;
|
|
29
|
+
/** แสดงลูกศร ⌄ (ปิดเมื่อไม่ใช่ dropdown) */
|
|
30
|
+
chevron?: boolean;
|
|
31
|
+
children?: ReactNode;
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
className={cn(
|
|
37
|
+
"inline-flex h-8 max-w-56 items-center gap-1.5 rounded-md px-2 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-[popup-open]:bg-accent data-[state=open]:bg-accent [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0",
|
|
38
|
+
className,
|
|
39
|
+
)}
|
|
40
|
+
{...props}
|
|
41
|
+
>
|
|
42
|
+
{icon && (
|
|
43
|
+
<span className="flex shrink-0 items-center [&_img]:size-5 [&_img]:rounded">{icon}</span>
|
|
44
|
+
)}
|
|
45
|
+
<span className="truncate font-medium">{label}</span>
|
|
46
|
+
{hint && <span className="hidden truncate text-muted-foreground md:inline">{hint}</span>}
|
|
47
|
+
{badge && (
|
|
48
|
+
<span className="shrink-0 rounded-md border bg-muted px-1.5 py-px font-medium text-[0.7rem] text-muted-foreground leading-4">
|
|
49
|
+
{badge}
|
|
50
|
+
</span>
|
|
51
|
+
)}
|
|
52
|
+
{children}
|
|
53
|
+
{chevron && <ChevronsUpDown className="size-3.5! text-muted-foreground" aria-hidden="true" />}
|
|
54
|
+
</button>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** แถว crumbs ใน header: คั่นด้วย `/` จาง ๆ (ซ่อนตัวกลางบนจอแคบให้เหลือตัวสุดท้ายถ้า `collapseOnMobile`) */
|
|
59
|
+
export function HeaderCrumbs({
|
|
60
|
+
children,
|
|
61
|
+
className,
|
|
62
|
+
}: {
|
|
63
|
+
children: ReactNode[] | ReactNode;
|
|
64
|
+
className?: string;
|
|
65
|
+
}) {
|
|
66
|
+
const items = (Array.isArray(children) ? children : [children]).filter(Boolean);
|
|
67
|
+
return (
|
|
68
|
+
<div className={cn("flex min-w-0 items-center gap-0.5", className)}>
|
|
69
|
+
{items.map((item, i) => (
|
|
70
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: ลำดับ crumb คงที่ (องค์กร → แอป → สภาพแวดล้อม)
|
|
71
|
+
<Fragment key={i}>
|
|
72
|
+
{i > 0 && (
|
|
73
|
+
<span aria-hidden="true" className="select-none px-0.5 text-muted-foreground/50">
|
|
74
|
+
/
|
|
75
|
+
</span>
|
|
76
|
+
)}
|
|
77
|
+
<div className={cn("min-w-0", i < items.length - 1 && "hidden sm:block")}>{item}</div>
|
|
78
|
+
</Fragment>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -2,7 +2,10 @@ export { AppBrand, type AppBrandProps } from "./app-brand";
|
|
|
2
2
|
export { AppNav, AppNavGroupMenu, type AppNavPinProps } from "./app-nav";
|
|
3
3
|
export { AppShell, type AppShellProps } from "./app-shell";
|
|
4
4
|
export { HeaderBreadcrumbs, type HeaderBreadcrumbsProps } from "./header-breadcrumbs";
|
|
5
|
+
export { HeaderCrumb, HeaderCrumbs } from "./header-crumb";
|
|
5
6
|
export { NavLink } from "./nav-link";
|
|
7
|
+
export { SubNav } from "./sub-nav";
|
|
8
|
+
export { isTabActive, TopTabs, topLevelTabs } from "./top-tabs";
|
|
6
9
|
export {
|
|
7
10
|
type AppNavGroup,
|
|
8
11
|
type AppNavItem,
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
InputGroup,
|
|
5
|
+
InputGroupAddon,
|
|
6
|
+
InputGroupInput,
|
|
7
|
+
} from "@suphark/ui/components/ui/input-group";
|
|
8
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
9
|
+
import { Search } from "lucide-react";
|
|
10
|
+
import Link from "next/link";
|
|
11
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
12
|
+
import { type ReactNode, useMemo, useState } from "react";
|
|
13
|
+
import { type AppNavItem, isNavActive } from "./types";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* เมนูย่อยซ้ายของแท็บที่ active (แบบ Clerk Configure: User & authentication / SSO / …)
|
|
17
|
+
* รองรับ 2 ชั้น (item → items = หัวกลุ่มย่อย); จอแคบเป็น <select> ด้านบนเนื้อหา
|
|
18
|
+
*/
|
|
19
|
+
export function SubNav({
|
|
20
|
+
items,
|
|
21
|
+
title,
|
|
22
|
+
searchable = false,
|
|
23
|
+
searchPlaceholder = "ค้นหา…",
|
|
24
|
+
className,
|
|
25
|
+
}: {
|
|
26
|
+
items: AppNavItem[];
|
|
27
|
+
/** หัวเมนู (เช่น ชื่อแท็บที่ active) */
|
|
28
|
+
title?: ReactNode;
|
|
29
|
+
searchable?: boolean;
|
|
30
|
+
searchPlaceholder?: string;
|
|
31
|
+
className?: string;
|
|
32
|
+
}) {
|
|
33
|
+
const pathname = usePathname();
|
|
34
|
+
const router = useRouter();
|
|
35
|
+
const [query, setQuery] = useState("");
|
|
36
|
+
const q = query.trim().toLowerCase();
|
|
37
|
+
|
|
38
|
+
const sections = useMemo(() => {
|
|
39
|
+
// ชั้นเดียว = กลุ่มเดียวไม่มีหัว; ชั้นสอง = หัวกลุ่ม + ลูก
|
|
40
|
+
const flat = items.filter((i) => i.href);
|
|
41
|
+
const groups = items.filter((i) => !i.href && i.items?.length);
|
|
42
|
+
const out: Array<{ title?: string; items: Array<AppNavItem & { href: string }> }> = [];
|
|
43
|
+
if (flat.length) out.push({ items: flat as Array<AppNavItem & { href: string }> });
|
|
44
|
+
for (const g of groups) {
|
|
45
|
+
out.push({
|
|
46
|
+
title: g.title,
|
|
47
|
+
items: (g.items ?? []).filter((c): c is AppNavItem & { href: string } => Boolean(c.href)),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (!q) return out;
|
|
51
|
+
return out
|
|
52
|
+
.map((s) => ({ ...s, items: s.items.filter((i) => i.title.toLowerCase().includes(q)) }))
|
|
53
|
+
.filter((s) => s.items.length > 0);
|
|
54
|
+
}, [items, q]);
|
|
55
|
+
|
|
56
|
+
const current = sections
|
|
57
|
+
.flatMap((s) => s.items)
|
|
58
|
+
.find((i) => isNavActive(pathname, i.href, i.exact));
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
{/* จอแคบ */}
|
|
63
|
+
<label className="flex flex-col gap-1 lg:hidden">
|
|
64
|
+
{title && <span className="text-muted-foreground text-xs">{title}</span>}
|
|
65
|
+
<select
|
|
66
|
+
className="h-9 rounded-md border bg-background px-2 text-sm"
|
|
67
|
+
value={current?.href ?? ""}
|
|
68
|
+
onChange={(e) => e.target.value && router.push(e.target.value)}
|
|
69
|
+
aria-label={typeof title === "string" ? title : "เมนูย่อย"}
|
|
70
|
+
>
|
|
71
|
+
{!current && <option value="">เลือกหน้า…</option>}
|
|
72
|
+
{sections.map((s, si) => (
|
|
73
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: กลุ่มคงที่
|
|
74
|
+
<optgroup key={si} label={s.title ?? ""}>
|
|
75
|
+
{s.items.map((i) => (
|
|
76
|
+
<option key={i.href} value={i.href}>
|
|
77
|
+
{i.title}
|
|
78
|
+
</option>
|
|
79
|
+
))}
|
|
80
|
+
</optgroup>
|
|
81
|
+
))}
|
|
82
|
+
</select>
|
|
83
|
+
</label>
|
|
84
|
+
|
|
85
|
+
{/* เดสก์ท็อป */}
|
|
86
|
+
<nav
|
|
87
|
+
aria-label={typeof title === "string" ? title : "เมนูย่อย"}
|
|
88
|
+
className={cn(
|
|
89
|
+
"sticky top-28 hidden w-56 shrink-0 flex-col gap-3 self-start lg:flex",
|
|
90
|
+
className,
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
{title && <p className="px-2 font-semibold text-sm">{title}</p>}
|
|
94
|
+
{searchable && (
|
|
95
|
+
<InputGroup className="h-8">
|
|
96
|
+
<InputGroupAddon>
|
|
97
|
+
<Search aria-hidden="true" />
|
|
98
|
+
</InputGroupAddon>
|
|
99
|
+
<InputGroupInput
|
|
100
|
+
placeholder={searchPlaceholder}
|
|
101
|
+
aria-label={searchPlaceholder}
|
|
102
|
+
value={query}
|
|
103
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
104
|
+
/>
|
|
105
|
+
</InputGroup>
|
|
106
|
+
)}
|
|
107
|
+
{sections.map((s, si) => (
|
|
108
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: กลุ่มคงที่
|
|
109
|
+
<div key={si} className="flex flex-col gap-0.5">
|
|
110
|
+
{s.title && (
|
|
111
|
+
<p className="px-2 pt-1 pb-0.5 font-medium text-muted-foreground text-xs">
|
|
112
|
+
{s.title}
|
|
113
|
+
</p>
|
|
114
|
+
)}
|
|
115
|
+
{s.items.map((i) => {
|
|
116
|
+
const active = isNavActive(pathname, i.href, i.exact);
|
|
117
|
+
return (
|
|
118
|
+
<Link
|
|
119
|
+
key={i.href}
|
|
120
|
+
href={i.href}
|
|
121
|
+
aria-current={active ? "page" : undefined}
|
|
122
|
+
className={cn(
|
|
123
|
+
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm no-underline transition-colors [&_svg:not([class*='size-'])]:size-4",
|
|
124
|
+
active
|
|
125
|
+
? "bg-accent font-medium text-accent-foreground"
|
|
126
|
+
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
|
127
|
+
)}
|
|
128
|
+
>
|
|
129
|
+
{i.icon}
|
|
130
|
+
<span className="truncate">{i.title}</span>
|
|
131
|
+
{i.badge !== undefined && (
|
|
132
|
+
<span className="ml-auto rounded-full bg-muted px-1.5 text-[0.7rem] text-muted-foreground">
|
|
133
|
+
{i.badge}
|
|
134
|
+
</span>
|
|
135
|
+
)}
|
|
136
|
+
</Link>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</div>
|
|
140
|
+
))}
|
|
141
|
+
{sections.length === 0 && (
|
|
142
|
+
<p className="px-2 text-muted-foreground text-xs">ไม่พบ "{query}"</p>
|
|
143
|
+
)}
|
|
144
|
+
</nav>
|
|
145
|
+
</>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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 AppNavGroup, type AppNavItem, isNavActive } from "./types";
|
|
7
|
+
|
|
8
|
+
/** ชั้นแรกของ nav ทุกกลุ่มรวมกันเป็นแท็บ — href ของแท็บที่ไม่มี href = ลูกตัวแรก */
|
|
9
|
+
export function topLevelTabs(nav: AppNavGroup[]): Array<AppNavItem & { href: string }> {
|
|
10
|
+
const out: Array<AppNavItem & { href: string }> = [];
|
|
11
|
+
for (const group of nav) {
|
|
12
|
+
for (const item of group.items) {
|
|
13
|
+
const href = item.href ?? firstHref(item.items);
|
|
14
|
+
if (href) out.push({ ...item, href });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function firstHref(items?: AppNavItem[]): string | undefined {
|
|
21
|
+
for (const i of items ?? []) {
|
|
22
|
+
if (i.href) return i.href;
|
|
23
|
+
const deep = firstHref(i.items);
|
|
24
|
+
if (deep) return deep;
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** แท็บ active = href ตรง (prefix) หรือลูกตัวใดตัวหนึ่ง active */
|
|
30
|
+
export function isTabActive(pathname: string, tab: AppNavItem): boolean {
|
|
31
|
+
if (isNavActive(pathname, tab.href, tab.exact)) return true;
|
|
32
|
+
return (tab.items ?? []).some((c) => isTabActive(pathname, c));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** แท็บหลักแนวนอนใต้ header (แบบ Clerk: Overview · Users · …) — active มีเส้นใต้; จอแคบเลื่อนแนวนอน */
|
|
36
|
+
export function TopTabs({ nav, className }: { nav: AppNavGroup[]; className?: string }) {
|
|
37
|
+
const pathname = usePathname();
|
|
38
|
+
const tabs = topLevelTabs(nav);
|
|
39
|
+
if (tabs.length === 0) return null;
|
|
40
|
+
return (
|
|
41
|
+
<nav aria-label="เมนูหลัก" className={cn("no-scrollbar -mb-px flex overflow-x-auto", className)}>
|
|
42
|
+
{tabs.map((tab) => {
|
|
43
|
+
const active = isTabActive(pathname, tab);
|
|
44
|
+
return (
|
|
45
|
+
<Link
|
|
46
|
+
key={tab.href}
|
|
47
|
+
href={tab.href}
|
|
48
|
+
aria-current={active ? "page" : undefined}
|
|
49
|
+
className={cn(
|
|
50
|
+
"flex shrink-0 items-center gap-1.5 border-b-2 px-3 py-2.5 text-sm no-underline transition-colors [&_svg:not([class*='size-'])]:size-4",
|
|
51
|
+
active
|
|
52
|
+
? "border-foreground font-medium text-foreground"
|
|
53
|
+
: "border-transparent text-muted-foreground hover:border-border hover:text-foreground",
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
{tab.icon}
|
|
57
|
+
<span className="truncate">{tab.title}</span>
|
|
58
|
+
{tab.badge !== undefined && (
|
|
59
|
+
<span className="rounded-full bg-muted px-1.5 py-px font-medium text-[0.7rem] text-muted-foreground leading-4">
|
|
60
|
+
{tab.badge}
|
|
61
|
+
</span>
|
|
62
|
+
)}
|
|
63
|
+
</Link>
|
|
64
|
+
);
|
|
65
|
+
})}
|
|
66
|
+
</nav>
|
|
67
|
+
);
|
|
68
|
+
}
|