@suphark/ui 0.3.0 → 0.3.2

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,22 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.3.2] — 2026-09-18
6
+
7
+ ### Fixed
8
+
9
+ - `copyText`/`selectContents` export จาก barrel หลักด้วย (แอปใช้ทำปุ่มคัดลอกเองได้)
10
+ - `EnvSnippet`: ค่ายาว (client secret) ล้นออกนอก dialog — ตัดบรรทัดในกรอบ (`whitespace-pre-wrap break-all` + `min-w-0`) แทน scroll แนวนอน (รายงานจากหน้า Register app ของ Pi Auth)
11
+ - `EnvSnippet` / `SecretReveal`: ปุ่มคัดลอกเงียบเมื่อ `navigator.clipboard` ถูกบล็อก (webview/embedded browser เช่น browser ใน Claude desktop) — เพิ่ม `copyText()` (`@suphark/ui/lib/copy-text`) ที่ fallback ไป `execCommand("copy")`; ถ้ายังไม่ได้ EnvSnippet จะเลือกข้อความให้และบอกให้กด Ctrl+C (`UiText.copyFailed` optional)
12
+
13
+ ## [0.3.1] — 2026-09-18
14
+
15
+ ### Changed
16
+
17
+ - `AppShell`: slot `sidebarHeader` ซ่อนอัตโนมัติเมื่อยุบเป็นไอคอน (เดิมล้นทับเนื้อหา — รายงานจาก Pi Auth) + prop ใหม่ `sidebarHeaderCollapsed` สำหรับไอคอนแทนตอนยุบ
18
+ - `HeaderBreadcrumbs`: `labels` รับฟังก์ชัน `(segment, href) => ReactNode` ได้ (ใส่ `t()` ของ next-intl) และ map รับ ReactNode — ฟังก์ชันต้องมาจาก client component (ส่งจาก server layout จะได้ 500 "Functions cannot be passed directly to Client Components"; showcase สาธิตด้วย `breadcrumbs: <ShellBreadcrumbs />`)
19
+ - README: อธิบายว่า `ThemeMenuItems` มี group + หัวในตัว ไม่ต้องห่อเอง
20
+
5
21
  ## [0.3.0] — 2026-09-18
6
22
 
7
23
  ### Added
package/README.md CHANGED
@@ -4,6 +4,8 @@ Design system กลางของ Suphark apps (Pi Auth, DueBell, Pi PDF, supp
4
4
 
5
5
  แพ็กเกจส่ง **source (.ts/.tsx) ไม่ build** — แอป Next.js transpile เองผ่าน `transpilePackages`
6
6
 
7
+ ดูตัวอย่างสด: **https://ui.suphark.com** (design system, `/shell` AppShell, `/brand`)
8
+
7
9
  ## ติดตั้ง
8
10
 
9
11
  ```bash
@@ -113,6 +115,9 @@ export default async function Layout({ children }) {
113
115
 
114
116
  - `header={false}` ไม่มี header · `collapsible="offcanvas"` ซ่อนทั้งแผง (supplychain) · `contentMaxWidth="max-w-5xl"` จำกัดความกว้าง (Pi Auth)
115
117
  - `pin={{ pinnedHrefs, onTogglePin }}` เปิดปุ่มปักหมุดเมนู (แอปเก็บ state เอง) · `header.center` วาง pinned menus
118
+ - `sidebarHeader` ซ่อนอัตโนมัติเมื่อยุบเป็นไอคอน — ใส่ `sidebarHeaderCollapsed` ถ้าต้องการไอคอนแทน (เช่นปุ่ม ⌘K)
119
+ - `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`)
120
+ - `<UserMenu>` children คือรายการเมนู: `ThemeMenuItems` มี `DropdownMenuGroup` + หัว "ธีม" ในตัวแล้ว (ปรับข้อความด้วย `label`/`labels`) ไม่ต้องห่อเพิ่ม; รายการอื่นใส่ `DropdownMenuItem`/`DropdownMenuGroup` เอง
116
121
  - ตัวอย่างรันได้ที่ `apps/showcase/src/app/shell`
117
122
 
118
123
  ### สีแบรนด์ต่อ tenant/แอป (`--brand`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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,8 +43,10 @@ export interface AppShellProps {
43
43
  searchPlaceholder?: string;
44
44
  /** ปุ่ม pin เมนูขึ้น header (แอปเก็บ state เอง) */
45
45
  pin?: AppNavPinProps;
46
- /** ส่วนเพิ่มใน sidebar ใต้โลโก้ เช่น TenantSwitcher / CommandPalette */
46
+ /** ส่วนเพิ่มใน sidebar ใต้โลโก้ เช่น TenantSwitcher / CommandPalette — ซ่อนอัตโนมัติเมื่อยุบเป็นไอคอน */
47
47
  sidebarHeader?: ReactNode;
48
+ /** สิ่งที่แสดงแทน `sidebarHeader` ตอนยุบเป็นไอคอน (เช่นปุ่มไอคอน ⌘K) — ไม่ส่ง = ไม่แสดงอะไร */
49
+ sidebarHeaderCollapsed?: ReactNode;
48
50
  /** ท้าย sidebar — ปกติคือ `<UserMenu variant="sidebar" />` */
49
51
  sidebarFooter?: ReactNode;
50
52
  /**
@@ -96,6 +98,7 @@ export function AppShell({
96
98
  searchPlaceholder = "ค้นหาเมนู…",
97
99
  pin,
98
100
  sidebarHeader,
101
+ sidebarHeaderCollapsed,
99
102
  sidebarFooter,
100
103
  header = true,
101
104
  brandColor,
@@ -133,6 +136,7 @@ export function AppShell({
133
136
  searchPlaceholder={searchPlaceholder}
134
137
  pin={pin}
135
138
  sidebarHeader={sidebarHeader}
139
+ sidebarHeaderCollapsed={sidebarHeaderCollapsed}
136
140
  sidebarFooter={sidebarFooter}
137
141
  collapsible={collapsible}
138
142
  />
@@ -195,6 +199,7 @@ function ShellSidebar({
195
199
  searchPlaceholder,
196
200
  pin,
197
201
  sidebarHeader,
202
+ sidebarHeaderCollapsed,
198
203
  sidebarFooter,
199
204
  collapsible,
200
205
  }: Pick<
@@ -205,6 +210,7 @@ function ShellSidebar({
205
210
  | "searchPlaceholder"
206
211
  | "pin"
207
212
  | "sidebarHeader"
213
+ | "sidebarHeaderCollapsed"
208
214
  | "sidebarFooter"
209
215
  | "collapsible"
210
216
  >) {
@@ -249,7 +255,12 @@ function ShellSidebar({
249
255
  </InputGroup>
250
256
  </div>
251
257
  )}
252
- {sidebarHeader}
258
+ {sidebarHeader && (
259
+ <div className="group-data-[collapsible=icon]:hidden">{sidebarHeader}</div>
260
+ )}
261
+ {sidebarHeaderCollapsed && (
262
+ <div className="hidden group-data-[collapsible=icon]:block">{sidebarHeaderCollapsed}</div>
263
+ )}
253
264
  </SidebarHeader>
254
265
  <SidebarContent>
255
266
  <AppNav nav={visibleNav} pin={pin} forceOpen={searching} />
@@ -10,11 +10,18 @@ import {
10
10
  } from "@suphark/ui/components/ui/breadcrumb";
11
11
  import Link from "next/link";
12
12
  import { usePathname } from "next/navigation";
13
- import { Fragment } from "react";
13
+ import { Fragment, type ReactNode } from "react";
14
14
 
15
15
  export interface HeaderBreadcrumbsProps {
16
- /** ชื่อแสดงของแต่ละ segment (`{ "po-tracking": "PO Tracking" }`) — ไม่พบ = ตัวแรกพิมพ์ใหญ่ แทน - ด้วยช่องว่าง */
17
- labels?: Record<string, string>;
16
+ /**
17
+ * ชื่อแสดงของแต่ละ segment: map (`{ "po-tracking": "PO Tracking" }`) หรือฟังก์ชัน
18
+ * `(segment, href) => ReactNode | undefined` (เช่น t() ของ next-intl)
19
+ * คืน undefined/ไม่พบ = ตัวแรกพิมพ์ใหญ่ แทน - ด้วยช่องว่าง
20
+ *
21
+ * ระวัง: ฟังก์ชันส่งข้าม server→client boundary ไม่ได้ ("Functions cannot be passed directly to Client Components")
22
+ * ถ้า layout เป็น server component ให้ใช้ map หรือส่ง `breadcrumbs: <MyBreadcrumbs />` ที่เป็น client component เรียก HeaderBreadcrumbs เอง
23
+ */
24
+ labels?: Record<string, ReactNode> | ((segment: string, href: string) => ReactNode | undefined);
18
25
  homeHref?: string;
19
26
  homeLabel?: string;
20
27
  /** ตัด segment นำหน้าออก เช่น `["console"]` เมื่อทุกหน้าอยู่ใต้ /console */
@@ -25,9 +32,9 @@ export interface HeaderBreadcrumbsProps {
25
32
 
26
33
  const ID_LIKE = /^(\d+|[0-9a-f]{8}-[0-9a-f-]{27}|c[a-z0-9]{20,}|[0-9a-z]{16,})$/i;
27
34
 
28
- function labelOf(segment: string, labels?: Record<string, string>) {
29
- const known = labels?.[segment];
30
- if (known) return known;
35
+ function labelOf(segment: string, href: string, labels?: HeaderBreadcrumbsProps["labels"]) {
36
+ const known = typeof labels === "function" ? labels(segment, href) : labels?.[segment];
37
+ if (known !== undefined && known !== null && known !== "") return known;
31
38
  const decoded = decodeURIComponent(segment);
32
39
  return decoded.charAt(0).toUpperCase() + decoded.slice(1).replace(/-/g, " ");
33
40
  }
@@ -56,7 +63,7 @@ export function HeaderBreadcrumbs({
56
63
  {shown.length > 0 && <BreadcrumbSeparator className="hidden md:block" />}
57
64
  {shown.map(({ segment, href }, index) => {
58
65
  const isLast = index === shown.length - 1;
59
- const label = labelOf(segment, labels);
66
+ const label = labelOf(segment, href, labels);
60
67
  return (
61
68
  <Fragment key={href}>
62
69
  <BreadcrumbItem>
@@ -1,13 +1,16 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@suphark/ui/components/ui/button";
4
+ import { copyText, selectContents } from "@suphark/ui/lib/copy-text";
4
5
  import { CheckIcon, CopyIcon } from "lucide-react";
5
- import { useState } from "react";
6
+ import { useRef, useState } from "react";
6
7
  import { useUiText } from "./ui-text";
7
8
 
8
9
  /**
9
10
  * บล็อก .env สำเร็จรูป: คัดลอกทั้งก้อนปุ่มเดียว (Clerk-style "quick copy")
10
11
  * ค่าที่เป็น secret แสดงจริง (หน้าที่เรียกต้องเป็นหน้า "แสดงครั้งเดียว" อยู่แล้ว)
12
+ * - ค่ายาว (secret) ตัดบรรทัดในกรอบ ไม่ล้น dialog
13
+ * - clipboard ถูกบล็อก (webview/embedded browser) → fallback execCommand; ถ้ายังไม่ได้ เลือกข้อความให้กด Ctrl+C
11
14
  */
12
15
  export function EnvSnippet({
13
16
  title = ".env.local",
@@ -20,34 +23,46 @@ export function EnvSnippet({
20
23
  copyLabel?: string;
21
24
  }) {
22
25
  const text = useUiText();
23
- const [copied, setCopied] = useState(false);
26
+ const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
27
+ const preRef = useRef<HTMLPreElement>(null);
24
28
  const content = lines.map(([k, v]) => `${k}=${v}`).join("\n");
29
+
25
30
  async function copy() {
26
- try {
27
- await navigator.clipboard.writeText(`${content}\n`);
28
- setCopied(true);
29
- setTimeout(() => setCopied(false), 1500);
30
- } catch {
31
- // clipboard ไม่ได้ (http/permission) — ผู้ใช้ยังเลือกข้อความคัดลอกเองได้
31
+ const ok = await copyText(`${content}\n`);
32
+ if (ok) {
33
+ setState("copied");
34
+ setTimeout(() => setState((s) => (s === "copied" ? "idle" : s)), 1500);
35
+ } else {
36
+ selectContents(preRef.current);
37
+ setState("failed");
32
38
  }
33
39
  }
40
+
34
41
  return (
35
- <div className="overflow-hidden rounded-lg border bg-card">
42
+ <div className="min-w-0 overflow-hidden rounded-lg border bg-card">
36
43
  <div className="flex items-center justify-between gap-2 border-b bg-muted px-3 py-1.5">
37
44
  <span className="font-mono text-xs text-muted-foreground">{title}</span>
38
45
  <Button type="button" variant="outline" size="xs" onClick={copy}>
39
- {copied ? <CheckIcon /> : <CopyIcon />}
40
- {copied ? text.copied : (copyLabel ?? text.copy)}
46
+ {state === "copied" ? <CheckIcon /> : <CopyIcon />}
47
+ {state === "copied" ? text.copied : (copyLabel ?? text.copy)}
41
48
  </Button>
42
49
  </div>
43
- <pre className="overflow-x-auto px-3 py-2.5 font-mono text-xs leading-6">
50
+ <pre
51
+ ref={preRef}
52
+ className="whitespace-pre-wrap break-all px-3 py-2.5 font-mono text-xs leading-6"
53
+ >
44
54
  {lines.map(([k, v]) => (
45
55
  <div key={k}>
46
56
  <span className="text-muted-foreground">{k}=</span>
47
- <span className="break-all">{v}</span>
57
+ <span>{v}</span>
48
58
  </div>
49
59
  ))}
50
60
  </pre>
61
+ {state === "failed" ? (
62
+ <p className="border-t bg-muted/50 px-3 py-1.5 text-muted-foreground text-xs">
63
+ {text.copyFailed ?? "คัดลอกอัตโนมัติไม่ได้ — เลือกข้อความให้แล้ว กด Ctrl+C / ⌘C"}
64
+ </p>
65
+ ) : null}
51
66
  </div>
52
67
  );
53
68
  }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@suphark/ui/components/ui/button";
4
+ import { copyText } from "@suphark/ui/lib/copy-text";
4
5
  import { CheckIcon, CopyIcon } from "lucide-react";
5
6
  import { useState } from "react";
6
7
  import { Notice } from "./notice";
@@ -17,13 +18,10 @@ export function SecretReveal({
17
18
  const text = useUiText();
18
19
  const [copied, setCopied] = useState<string | null>(null);
19
20
  async function copy(key: string, value: string) {
20
- try {
21
- await navigator.clipboard.writeText(value);
22
- setCopied(key);
23
- setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500);
24
- } catch {
25
- // clipboard ไม่ได้ (http/permission) — ผู้ใช้ยังคัดลอกจากข้อความได้
26
- }
21
+ // clipboard API ถูกบล็อก (webview/embedded browser) → copyText ลอง execCommand ให้; ไม่ได้จริง ๆ ผู้ใช้ยังเลือกจากข้อความได้
22
+ if (!(await copyText(value))) return;
23
+ setCopied(key);
24
+ setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500);
27
25
  }
28
26
  return (
29
27
  <Notice tone="warning" title={title ?? text.showOnce}>
@@ -12,6 +12,8 @@ export interface UiText {
12
12
  copied: string;
13
13
  showOnce: string;
14
14
  success: string;
15
+ /** EnvSnippet: คัดลอกอัตโนมัติไม่ได้ (optional — มีค่าเริ่มต้นในตัว) */
16
+ copyFailed?: string;
15
17
  }
16
18
 
17
19
  export const UI_TEXT_TH: UiText = {
@@ -23,6 +25,7 @@ export const UI_TEXT_TH: UiText = {
23
25
  copied: "คัดลอกแล้ว",
24
26
  showOnce: "แสดงครั้งเดียว — คัดลอกเก็บไว้ทันที",
25
27
  success: "สำเร็จ",
28
+ copyFailed: "คัดลอกอัตโนมัติไม่ได้ — เลือกข้อความให้แล้ว กด Ctrl+C / ⌘C",
26
29
  };
27
30
 
28
31
  export const UI_TEXT_EN: UiText = {
@@ -34,6 +37,7 @@ export const UI_TEXT_EN: UiText = {
34
37
  copied: "Copied",
35
38
  showOnce: "Shown once — copy it now",
36
39
  success: "Done",
40
+ copyFailed: "Automatic copy is blocked — text selected, press Ctrl+C / ⌘C",
37
41
  };
38
42
 
39
43
  const UiTextContext = createContext<UiText>(UI_TEXT_TH);
package/src/index.ts CHANGED
@@ -136,6 +136,7 @@ export * from "./components/ui/tooltip";
136
136
  // ---------- lib / hooks ----------
137
137
  export { useCopyToClipboard } from "./hooks/use-copy-to-clipboard";
138
138
  export { useIsMobile } from "./hooks/use-mobile";
139
+ export { copyText, selectContents } from "./lib/copy-text";
139
140
  export { formatRelativeThaiDate, formatThaiFullDate } from "./lib/formatters";
140
141
  export {
141
142
  type AtomicPermissionRequirement,
@@ -0,0 +1,48 @@
1
+ /**
2
+ * คัดลอกข้อความลง clipboard ให้ได้มากที่สุด:
3
+ * 1. navigator.clipboard (ต้อง secure context + permission — บาง embedded browser/webview บล็อก)
4
+ * 2. fallback: textarea ชั่วคราว + document.execCommand("copy") (deprecated แต่ยังใช้ได้ทุก browser)
5
+ * คืน true เมื่อสำเร็จ — ผู้เรียกควรมีทางหนี (เลือกข้อความให้ผู้ใช้กด Ctrl+C เอง) เมื่อได้ false
6
+ */
7
+ export async function copyText(text: string): Promise<boolean> {
8
+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
9
+ try {
10
+ await navigator.clipboard.writeText(text);
11
+ return true;
12
+ } catch {
13
+ // ลอง fallback ต่อ
14
+ }
15
+ }
16
+ if (typeof document === "undefined") return false;
17
+ const ta = document.createElement("textarea");
18
+ ta.value = text;
19
+ ta.setAttribute("readonly", "");
20
+ ta.style.position = "fixed";
21
+ ta.style.top = "0";
22
+ ta.style.left = "0";
23
+ ta.style.opacity = "0";
24
+ document.body.appendChild(ta);
25
+ const active = document.activeElement as HTMLElement | null;
26
+ try {
27
+ ta.focus();
28
+ ta.select();
29
+ ta.setSelectionRange(0, text.length);
30
+ return document.execCommand("copy");
31
+ } catch {
32
+ return false;
33
+ } finally {
34
+ ta.remove();
35
+ active?.focus?.();
36
+ }
37
+ }
38
+
39
+ /** เลือกข้อความทั้งก้อนใน element (ให้ผู้ใช้กด Ctrl+C ได้ทันทีเมื่อ copy อัตโนมัติไม่ได้) */
40
+ export function selectContents(el: Element | null): void {
41
+ if (!el || typeof window === "undefined") return;
42
+ const sel = window.getSelection();
43
+ if (!sel) return;
44
+ const range = document.createRange();
45
+ range.selectNodeContents(el);
46
+ sel.removeAllRanges();
47
+ sel.addRange(range);
48
+ }