@suphark/ui 0.11.1 → 0.12.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,10 +2,29 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.12.0] — 2026-09-23
6
+
7
+ ### Added
8
+
9
+ - **`OrganizationSwitcher` prop `itemAction(org)`** (ขอจาก Pi Auth) — ปุ่มท้ายแถวของแต่ละองค์กรในเมนู เช่น ⚙ "ตั้งค่าองค์กร" แบบ Vercel/Clerk: แอปส่ง element ที่กดได้มาเอง (พร้อม aria-label/title) component ห่อให้และ **หยุด click/keydown ไม่ให้สลับองค์กร**; คืน `null` = แถวนั้นไม่มีปุ่ม; มี `itemAction` แล้วองค์กรเดียวก็ยังมีเมนู (เดิมยุบเป็นป้ายเฉย ๆ เมื่อไม่มี `footer`) — เดิมแอปต้องวาง "ตั้งค่าองค์กร" เป็นรายการแยกใน `footer`
10
+ - **`OrganizationSwitcher` prop `selectableActive`** (ขอจาก Pi Auth) — แถวที่ active กดได้ด้วย เรียก `onSwitch` เหมือนแถวอื่น (ค่าเริ่มต้น false = คงพฤติกรรมเดิม กดแล้วไม่ทำอะไร); สำหรับเมนูที่ "เลือก" = ไปหน้าของสิ่งนั้นไม่ใช่แค่สลับ เช่น crumb บัญชีของ Pi Auth ที่กดชื่อบัญชีแล้วเข้าคอนโซลบัญชีนั้น — ตัดรายการ "คอนโซลบัญชี" ท้ายเมนูที่ซ้ำช่องทางออกได้
11
+ - showcase: หน้า `organization-switcher` ตัวแรกใส่ ⚙ ท้ายแถว; tests 2 กรณี (รวม 130)
12
+
13
+ ## [0.11.2] — 2026-09-22
14
+
15
+ ### Fixed
16
+
17
+ - **`HeaderCrumbs` บนจอแคบใช้ย้อนกลับ/สลับไม่ได้** (ผู้ใช้ชี้ใน `/shell-top` มือถือ): เดิมซ่อน crumb ก่อนสุดท้ายทั้งก้อนแต่ยังเห็นตัวคั่น `/ /` และไม่มีทางเข้าถึงตัวสลับองค์กร/แอป → บน `< sm` ยุบเป็นปุ่ม `…` (aria-label `collapsedLabel` ค่าเริ่มต้น "แสดงเส้นทางทั้งหมด") เปิด `Popover` แสดง crumb ที่ซ่อนเรียงลงมา — แต่ละตัวยังเป็น switcher ของมันเอง (DropdownMenu ซ้อนใน Popover ทำงานได้ ทดสอบสลับองค์กรใน popover แล้ว); ตัวคั่นซ่อนตามตัวที่ซ่อน
18
+ - `HeaderCrumb`: `overflow-hidden` + label `min-w-0` และ wrapper ตัวสุดท้ายบังคับ `max-w-full` — ตัวสุดท้ายหดตามช่องแทนบานทับ `header.actions`; `badge` ซ่อนบนจอแคบ (`hidden sm:inline`) ให้ที่กับชื่อ
19
+ - `SubNav`: key ของกลุ่มใช้ชื่อกลุ่มแทน index (biome `noArrayIndexKey` — suppression เดิมหลุดตอน 0.11.1 จัดรูปแบบใหม่ ทำให้ `biome ci` ใน CI ไม่ผ่าน)
20
+ - showcase: `/shell-top` header actions ซ่อน LayoutSwitch + กระดิ่งบนจอแคบ (ก่อนหน้ากินที่จน crumb เหลือ 25px); tests 2 กรณี (รวม 128)
21
+
5
22
  ## [0.11.1] — 2026-09-22
6
23
 
7
24
  ### Changed
8
25
 
26
+ - showcase: เพิ่มหน้า stub ให้ลิงก์เมนู mock ที่ไม่มีหน้าจริง (`/shell-top/roles`, `/shell-top/security`, `/shell/bidding`, `/shell/orgs`) — Next prefetch เคย 404 ใน console ทุกครั้งที่เข้า `/shell`, `/shell-top`
27
+
9
28
  - `SubNav` (AppShell layout top): หัวกลุ่มกับรายการเคยเป็นสี muted เท่ากันจนแยกไม่ออก (ผู้ใช้ Pi Auth ชี้ในหน้า Settings) → หัวกลุ่มเป็น `text-[0.7rem] font-semibold uppercase tracking-wider text-muted-foreground/70`, รายการที่ไม่ active เป็น `text-foreground/80` (hover `text-foreground`), กลุ่มที่ 2 ขึ้นไปมีเส้นคั่น `border-t border-border/60` + `pt-3`; active และ `<select>` บนจอแคบคงเดิม — ไม่มี prop ใหม่ (ขอจาก Pi Auth)
10
29
 
11
30
  ## [0.11.0] — 2026-09-21
package/README.md CHANGED
@@ -128,6 +128,7 @@ export default async function Layout({ children }) {
128
128
  - **`layout="top"`** = แบบ Clerk dashboard: `crumbs={[<HeaderCrumb …/>, …]}` (switcher องค์กร/แอป/สภาพแวดล้อม — ห่อ DropdownMenu เอง), `nav` ชั้นแรกเป็นแท็บบน ชั้นสองเป็น sub-nav ซ้าย, `header.actions` ขวา — ตัวอย่าง `apps/showcase/src/app/shell-top`
129
129
  - `header={false}` ไม่มี header · `collapsible="offcanvas"` ซ่อนทั้งแผง (supplychain) · `contentMaxWidth="max-w-5xl"` จำกัดความกว้าง (Pi Auth)
130
130
  - `pin={{ pinnedHrefs, onTogglePin }}` เปิดปุ่มปักหมุดเมนู (แอปเก็บ state เอง) · `header.center` วาง pinned menus
131
+ - layout top: `HeaderCrumbs` บนจอแคบเหลือ crumb สุดท้าย + ปุ่ม `…` เปิด popover ให้สลับองค์กร/แอปได้ — `header.actions` ควรซ่อนของไม่จำเป็นบนมือถือ (`hidden sm:inline-flex`) ไม่งั้นแย่งที่ crumb
131
132
  - `sidebarHeader` ซ่อนอัตโนมัติเมื่อยุบเป็นไอคอน — ใส่ `sidebarHeaderCollapsed` ถ้าต้องการไอคอนแทน (เช่นปุ่ม ⌘K)
132
133
  - ของที่ต้องเลื่อนไปกับเมนู (ต้นไม้โฟลเดอร์ยาว, มิเตอร์พื้นที่) ใส่ `sidebarContentBefore` / `sidebarContentAfter` (อยู่ใน SidebarContent) — `sidebarHeader` ตรึงอยู่บนไม่เลื่อน
133
134
  - `header.breadcrumbs.labels` รับฟังก์ชัน `(segment, href) => ReactNode` ได้ (ใส่ `t()` ของ next-intl) — **แต่ฟังก์ชันส่งจาก server layout ไม่ได้** (React error "Functions cannot be passed directly to Client Components") ให้ทำ client component ที่เรียก `<HeaderBreadcrumbs labels={…} />` แล้วส่งเป็น `breadcrumbs: <MyBreadcrumbs />` (ดู `apps/showcase/src/app/shell/breadcrumbs.tsx`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.11.1",
3
+ "version": "0.12.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 เอง",
@@ -1,8 +1,10 @@
1
1
  "use client";
2
2
 
3
3
  import { SwitcherChevron } from "@suphark/ui/components/shared/switcher-chevron";
4
+ import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
4
5
  import { cn } from "@suphark/ui/lib/utils";
5
- import { type ComponentProps, Fragment, type ReactNode } from "react";
6
+ import { Ellipsis } from "lucide-react";
7
+ import { type ComponentProps, Fragment, type ReactNode, useState } from "react";
6
8
 
7
9
  /**
8
10
  * ปุ่ม "switcher" ใน header แบบ Clerk dashboard (องค์กร ⌄ / แอป ⌄ / สภาพแวดล้อม ⌄)
@@ -34,7 +36,7 @@ export function HeaderCrumb({
34
36
  <button
35
37
  type="button"
36
38
  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 [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0",
39
+ "inline-flex h-8 max-w-56 items-center gap-1.5 overflow-hidden 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 [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0",
38
40
  className,
39
41
  )}
40
42
  {...props}
@@ -42,10 +44,11 @@ export function HeaderCrumb({
42
44
  {icon && (
43
45
  <span className="flex shrink-0 items-center [&_img]:size-5 [&_img]:rounded">{icon}</span>
44
46
  )}
45
- <span className="truncate font-medium">{label}</span>
47
+ <span className="min-w-0 truncate font-medium">{label}</span>
46
48
  {hint && <span className="hidden truncate text-muted-foreground md:inline">{hint}</span>}
49
+ {/* badge ซ่อนบนจอแคบ (ที่เหลือให้ชื่อ) — เห็นได้ในเมนู/popover */}
47
50
  {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">
51
+ <span className="hidden shrink-0 rounded-md border bg-muted px-1.5 py-px font-medium text-[0.7rem] text-muted-foreground leading-4 sm:inline">
49
52
  {badge}
50
53
  </span>
51
54
  )}
@@ -55,26 +58,84 @@ export function HeaderCrumb({
55
58
  );
56
59
  }
57
60
 
58
- /** แถว crumbs ใน header: คั่นด้วย `/` จาง ๆ (ซ่อนตัวกลางบนจอแคบให้เหลือตัวสุดท้ายถ้า `collapseOnMobile`) */
61
+ /**
62
+ * แถว crumbs ใน header: คั่นด้วย `/` จาง ๆ — บนจอแคบ (< sm) เหลือตัวสุดท้าย ส่วนตัวก่อนหน้ายุบเป็นปุ่ม `…`
63
+ * ที่เปิด popover แสดง crumb ที่ซ่อนเรียงลงมา (แต่ละตัวยังเป็น switcher ของมันเอง) — ไม่งั้นบนมือถือจะสลับ
64
+ * องค์กร/แอปหรือย้อนกลับผ่าน header ไม่ได้เลย (เดิมซ่อนทั้งก้อนแต่ยังเห็น `/ /`)
65
+ */
59
66
  export function HeaderCrumbs({
60
67
  children,
61
68
  className,
69
+ collapsedLabel = "แสดงเส้นทางทั้งหมด",
62
70
  }: {
63
71
  children: ReactNode[] | ReactNode;
64
72
  className?: string;
73
+ /** aria-label ของปุ่ม `…` บนจอแคบ */
74
+ collapsedLabel?: string;
65
75
  }) {
66
76
  const items = (Array.isArray(children) ? children : [children]).filter(Boolean);
77
+ const hidden = items.slice(0, -1);
78
+ const [open, setOpen] = useState(false);
67
79
  return (
68
80
  <div className={cn("flex min-w-0 items-center gap-0.5", className)}>
81
+ {hidden.length > 0 && (
82
+ <>
83
+ <div className="sm:hidden">
84
+ <Popover open={open} onOpenChange={setOpen}>
85
+ <PopoverTrigger
86
+ render={
87
+ <button
88
+ type="button"
89
+ aria-label={collapsedLabel}
90
+ className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground 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 [&_svg]:size-4"
91
+ >
92
+ <Ellipsis aria-hidden="true" />
93
+ </button>
94
+ }
95
+ />
96
+ <PopoverContent
97
+ align="start"
98
+ className="w-64 p-1 [&_button]:w-full [&_button]:max-w-none [&_button]:justify-start"
99
+ >
100
+ <div className="flex flex-col gap-0.5">
101
+ {hidden.map((item, i) => (
102
+ // biome-ignore lint/suspicious/noArrayIndexKey: ลำดับ crumb คงที่
103
+ <div key={i} className="min-w-0">
104
+ {item}
105
+ </div>
106
+ ))}
107
+ </div>
108
+ </PopoverContent>
109
+ </Popover>
110
+ </div>
111
+ <span
112
+ aria-hidden="true"
113
+ className="select-none px-0.5 text-muted-foreground/50 sm:hidden"
114
+ >
115
+ /
116
+ </span>
117
+ </>
118
+ )}
69
119
  {items.map((item, i) => (
70
120
  // biome-ignore lint/suspicious/noArrayIndexKey: ลำดับ crumb คงที่ (องค์กร → แอป → สภาพแวดล้อม)
71
121
  <Fragment key={i}>
72
122
  {i > 0 && (
73
- <span aria-hidden="true" className="select-none px-0.5 text-muted-foreground/50">
123
+ <span
124
+ aria-hidden="true"
125
+ className="hidden select-none px-0.5 text-muted-foreground/50 sm:inline"
126
+ >
74
127
  /
75
128
  </span>
76
129
  )}
77
- <div className={cn("min-w-0", i < items.length - 1 && "hidden sm:block")}>{item}</div>
130
+ {/* ตัวสุดท้ายบนจอแคบต้องหดได้ (ปุ่มมี max-w-56 — บังคับไม่เกินช่อง ไม่งั้นทับ actions) */}
131
+ <div
132
+ className={cn(
133
+ "flex min-w-0 [&>button]:max-w-full",
134
+ i < items.length - 1 && "hidden sm:flex",
135
+ )}
136
+ >
137
+ {item}
138
+ </div>
78
139
  </Fragment>
79
140
  ))}
80
141
  </div>
@@ -40,6 +40,16 @@ export interface OrganizationSwitcherProps {
40
40
  hint?: (org: OrganizationOption) => ReactNode;
41
41
  /** slot ท้ายเมนู เช่น "สร้างองค์กร…", "จัดการองค์กร" */
42
42
  footer?: ReactNode;
43
+ /**
44
+ * ปุ่มท้ายแถวของแต่ละองค์กร เช่น ⚙ "ตั้งค่าองค์กร" (แบบ Vercel/Clerk) — คืน `null` เพื่อไม่แสดงในแถวนั้น
45
+ * คลิกแล้ว **ไม่สลับองค์กร** (component หยุด event ให้); ส่ง element ที่กดได้เองและใส่ aria-label/title ด้วย
46
+ */
47
+ itemAction?: (org: OrganizationOption) => ReactNode;
48
+ /**
49
+ * แถวที่ active กดได้ด้วย (เรียก `onSwitch` เหมือนแถวอื่น) — ค่าเริ่มต้น false = กดแล้วไม่ทำอะไร
50
+ * ใช้เมื่อ "เลือก" = ไปหน้าของสิ่งนั้น ไม่ใช่แค่สลับ (เช่น crumb บัญชี: กดชื่อบัญชี = เข้าคอนโซลบัญชีนั้น)
51
+ */
52
+ selectableActive?: boolean;
43
53
  /** `header` = HeaderCrumb ใน header (layout top); `sidebar` = ปุ่มเต็มกว้างใน `sidebarHeader` ของ AppShell */
44
54
  placement?: "header" | "sidebar";
45
55
  className?: string;
@@ -64,6 +74,8 @@ export function OrganizationSwitcher({
64
74
  heading,
65
75
  hint = (o) => (o.slug ? `/${o.slug}` : undefined),
66
76
  footer,
77
+ itemAction,
78
+ selectableActive = false,
67
79
  placement = "header",
68
80
  className,
69
81
  }: OrganizationSwitcherProps) {
@@ -72,7 +84,7 @@ export function OrganizationSwitcher({
72
84
  const [pending, start] = useTransition();
73
85
  const active = organizations.find((o) => o.id === activeId);
74
86
  const label = active ? active.name : (fallbackLabel ?? text.switcher?.choose ?? "เลือกองค์กร");
75
- const single = organizations.length <= 1 && !footer;
87
+ const single = organizations.length <= 1 && !footer && !itemAction;
76
88
 
77
89
  const trigger =
78
90
  placement === "sidebar" ? (
@@ -102,7 +114,7 @@ export function OrganizationSwitcher({
102
114
  if (single) return trigger;
103
115
 
104
116
  function pick(id: string) {
105
- if (id === activeId) return;
117
+ if (id === activeId && !selectableActive) return;
106
118
  start(async () => {
107
119
  await onSwitch(id);
108
120
  if (refresh) router.refresh();
@@ -119,6 +131,7 @@ export function OrganizationSwitcher({
119
131
  </DropdownMenuLabel>
120
132
  {organizations.map((o) => {
121
133
  const isActive = o.id === activeId;
134
+ const action = itemAction?.(o);
122
135
  return (
123
136
  <DropdownMenuItem
124
137
  key={o.id}
@@ -134,6 +147,17 @@ export function OrganizationSwitcher({
134
147
  )}
135
148
  </span>
136
149
  {isActive && <Check className="size-4 shrink-0" aria-hidden="true" />}
150
+ {action && (
151
+ // ปุ่มท้ายแถว (⚙) — กดแล้วต้องไม่สลับองค์กร จึงหยุด event ไม่ให้ถึง onClick ของแถว
152
+ // biome-ignore lint/a11y/noStaticElementInteractions: ตัวห่อไม่ใช่ปุ่ม — แอปส่ง element ที่กดได้มาเอง
153
+ <span
154
+ className="-my-1 -mr-1 flex shrink-0 items-center"
155
+ onClick={(e) => e.stopPropagation()}
156
+ onKeyDown={(e) => e.stopPropagation()}
157
+ >
158
+ {action}
159
+ </span>
160
+ )}
137
161
  </DropdownMenuItem>
138
162
  );
139
163
  })}
@@ -105,9 +105,8 @@ export function SubNav({
105
105
  </InputGroup>
106
106
  )}
107
107
  {sections.map((s, si) => (
108
- // biome-ignore lint/suspicious/noArrayIndexKey: กลุ่มคงที่
109
108
  <div
110
- key={si}
109
+ key={s.title ?? "_flat"}
111
110
  className={cn("flex flex-col gap-0.5", si > 0 && "border-border/60 border-t pt-3")}
112
111
  >
113
112
  {/* หัวกลุ่มแบบ Clerk/Linear — ตัวเล็ก uppercase จางกว่ารายการ ให้แยกจากรายการได้ชัด (ขอจาก Pi Auth) */}