@suphark/ui 0.16.0 → 0.17.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,16 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.17.0] — 2026-09-30
6
+
7
+ ### Added
8
+
9
+ - **`CopyButton`** (barrel) — ปุ่มคัดลอกข้อความ: ไอคอน Copy → ✓ (`text-success`) ชั่วครู่แล้วกลับ, ใช้ `useCopyToClipboard` (มี fallback `execCommand` สำหรับ webview ที่บล็อก clipboard API) แอปไม่ต้องเขียน state เอง (Pi Auth เขียนซ้ำ 12 ไฟล์, Pi Config ใน `RevealedValues`): `value`, `children` (ไม่ส่ง = ปุ่มไอคอนล้วน), `label`/`copiedLabel` (ค่าเริ่มต้น `UiText.copy`/`copied` — เป็น aria-label เมื่อไม่มี children และเป็น `title` เสมอ), `toast` (ค่าเริ่มต้น **false** เพราะปุ่มแสดง ✓ อยู่แล้ว — ส่ง `true` หรือข้อความของแอปได้), `timeout`, `onCopied`, props อื่นของ `Button` (variant/size/className) ส่งผ่านหมด
10
+ - **`CodeBlock`** (barrel, สเปกจาก Pi Config — ของเดิมอยู่ `features/docs/code-block.tsx` ใช้ 2 ไฟล์; Pi Auth มีแบบเดียวกัน ~9 จุด) — บล็อกโค้ด/คำสั่ง: หัวแถบ `title ?? lang ?? "shell"` (mono, `title={false}` = ไม่มีหัวแถบ) + `CopyButton`, เนื้อเป็น `<pre><code>` mono เลื่อนแนวนอน; `copyText` เมื่อคัดลอกไม่เท่ากับที่แสดง (เช่นคัดลอกเฉพาะค่า token ไม่เอา `KEY=`), `copyLabel`, `showCopy`, `wrap` (ตัดบรรทัดแทนเลื่อน), `maxHeightClassName`; ไม่มี syntax highlight โดยตั้งใจ (ไม่เพิ่ม dependency)
11
+ - `SearchInput`: ใช้แบบ **uncontrolled** ได้แล้ว — ไม่ส่ง `value` = คุมค่าในตัวเองจาก `defaultValue` (ปุ่มล้าง/Escape ยังทำงาน) จึงวางใน `<form>` ที่ submit ไป server action/URL param พร้อม `name` ได้ (คำถามจาก Pi Auth: `/console/logs`, `/platform/users`); `onValueChange` ยังเรียกทุกตัวอักษรทั้งสองโหมด — **ไม่มี debounce ในตัว** (ให้แอป debounce เอง) จดไว้ใน docstring แล้ว
12
+ - showcase: หน้า `/components/copy-button`, `/components/code-block` — 101 หน้า; tests 5 กรณีเพิ่ม (รวม 146)
13
+ - test: `date-picker.test.tsx` ตั้ง timeout 20s (เปิด popup = render ปฏิทินทั้งเดือนใน jsdom ช้าเป็นครั้งคราวเกิน 5s ตอนรันทั้งชุด — ทำให้ `pnpm verify`/CI แดงสุ่ม ๆ)
14
+
5
15
  ## [0.16.0] — 2026-09-30
6
16
 
7
17
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.16.0",
3
+ "version": "0.17.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 เอง",
@@ -0,0 +1,72 @@
1
+ "use client";
2
+
3
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
4
+ import { Button } from "@suphark/ui/components/ui/button";
5
+ import { useCopyToClipboard } from "@suphark/ui/hooks/use-copy-to-clipboard";
6
+ import { cn } from "@suphark/ui/lib/utils";
7
+ import { Check, Copy } from "lucide-react";
8
+ import type { ComponentProps, ReactNode } from "react";
9
+
10
+ export interface CopyButtonProps
11
+ extends Omit<ComponentProps<typeof Button>, "onClick" | "children" | "value"> {
12
+ /** ข้อความที่จะคัดลอก */
13
+ value: string;
14
+ /** ป้ายบนปุ่ม — ไม่ส่ง = ปุ่มไอคอนล้วน (ใช้ `label` เป็น aria-label) */
15
+ children?: ReactNode;
16
+ /** aria-label/title ของปุ่ม (ค่าเริ่มต้น `UiText.copy`) */
17
+ label?: string;
18
+ /** aria-label/title หลังคัดลอกแล้ว (ค่าเริ่มต้น `UiText.copied`) */
19
+ copiedLabel?: string;
20
+ /** toast ของ `useCopyToClipboard` — ค่าเริ่มต้น `false` เพราะปุ่มนี้แสดง ✓ อยู่แล้ว */
21
+ toast?: boolean | { success?: string; failed?: string };
22
+ /** ms ที่ไอคอน ✓ ค้างไว้ (ค่าเริ่มต้น 2000) */
23
+ timeout?: number;
24
+ onCopied?: (value: string) => void;
25
+ }
26
+
27
+ /**
28
+ * ปุ่มคัดลอกข้อความ: ไอคอน Copy → ✓ ชั่วครู่หลังคัดลอก (แอปไม่ต้องเขียน state/clipboard เอง)
29
+ * ใช้ `useCopyToClipboard` ที่มี fallback `execCommand` สำหรับ webview ที่บล็อก clipboard API
30
+ *
31
+ * <CopyButton value={token} /> // ปุ่มไอคอน
32
+ * <CopyButton value={url} variant="outline" size="sm">คัดลอกลิงก์</CopyButton>
33
+ */
34
+ export function CopyButton({
35
+ value,
36
+ children,
37
+ label,
38
+ copiedLabel,
39
+ toast = false,
40
+ timeout = 2000,
41
+ onCopied,
42
+ variant = "ghost",
43
+ size,
44
+ className,
45
+ disabled,
46
+ ...props
47
+ }: CopyButtonProps) {
48
+ const text = useUiText();
49
+ const { isCopied, copyToClipboard } = useCopyToClipboard({ toast, timeout });
50
+ const idle = label ?? text.copy;
51
+ const done = copiedLabel ?? text.copied;
52
+ const title = isCopied ? done : idle;
53
+
54
+ return (
55
+ <Button
56
+ {...props}
57
+ type="button"
58
+ variant={variant}
59
+ size={size ?? (children ? "sm" : "icon-sm")}
60
+ disabled={disabled}
61
+ aria-label={children ? undefined : title}
62
+ title={title}
63
+ className={cn(isCopied && "text-success", className)}
64
+ onClick={async () => {
65
+ if (await copyToClipboard(value)) onCopied?.(value);
66
+ }}
67
+ >
68
+ {isCopied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
69
+ {children}
70
+ </Button>
71
+ );
72
+ }
@@ -6,6 +6,7 @@ export { AddNewButton } from "./add-new-button";
6
6
  export { ActionButton, type ActionButtonProps } from "./base/action-button";
7
7
  export { CancelButton } from "./cancel-button";
8
8
  export { ConfirmButton } from "./confirm-button";
9
+ export { CopyButton, type CopyButtonProps } from "./copy-button";
9
10
  export { DeleteButton } from "./delete-button";
10
11
  export { FloatingSaveButton } from "./floating-save-button";
11
12
  export { PrintButton } from "./print-button";
@@ -9,13 +9,14 @@ import {
9
9
  } from "@suphark/ui/components/ui/input-group";
10
10
  import { cn } from "@suphark/ui/lib/utils";
11
11
  import { Search, X } from "lucide-react";
12
- import { type ComponentProps, useRef } from "react";
12
+ import { type ComponentProps, useRef, useState } from "react";
13
13
 
14
14
  export interface SearchInputProps
15
15
  extends Omit<ComponentProps<"input">, "size" | "value" | "onChange" | "type"> {
16
- value: string;
17
- /** รับค่าใหม่ตรง ๆ (ไม่ใช่ event) — ปุ่มล้างก็เรียกด้วย `""` */
18
- onValueChange: (value: string) => void;
16
+ /** ไม่ส่ง = uncontrolled (คุมค่าในตัวเอง จาก `defaultValue`) — ใช้กับฟอร์มที่ submit เองได้ */
17
+ value?: string;
18
+ /** รับค่าใหม่ตรง ๆ (ไม่ใช่ event) — ปุ่มล้างก็เรียกด้วย `""`; uncontrolled ก็ยังเรียกให้ */
19
+ onValueChange?: (value: string) => void;
19
20
  /** เรียกเพิ่มหลังกดปุ่มล้าง/Escape (เช่นรีเซ็ตหน้า pagination) */
20
21
  onClear?: () => void;
21
22
  /** xs = h-7, sm = h-8 (ค่าเริ่มต้น), default = h-9 */
@@ -32,14 +33,18 @@ export interface SearchInputProps
32
33
 
33
34
  /**
34
35
  * ช่องค้นหามาตรฐาน: ไอคอนแว่นขยายซ้าย + ปุ่มล้าง (X) ขวาเมื่อมีข้อความ + Escape ล้างค่า
35
- * เป็น controlled ล้วน (แอปคุม state/debounce เอง) — ซ้ำอยู่ใน Pi Auth/Pi PDF/DueBell จึงยกมาไว้ที่นี่
36
+ * ใช้ได้ทั้ง controlled (`value` + `onValueChange` — แอปคุม state/debounce เอง) และ uncontrolled
37
+ * (`defaultValue` + `name` ในฟอร์มที่ submit ไป server action/URL param) — ซ้ำอยู่ใน Pi Auth/Pi PDF/DueBell
38
+ * `onValueChange` ยิงทุกตัวอักษร (ไม่มี debounce ในตัว — ให้แอป debounce เองถ้าต้องยิง query)
36
39
  *
37
40
  * const [q, setQ] = useState("");
38
41
  * <SearchInput value={q} onValueChange={setQ} placeholder="ค้นหาสมาชิก…" />
42
+ * <form action={search}><SearchInput name="q" defaultValue={q} /></form>
39
43
  */
40
44
  export function SearchInput({
41
- value,
45
+ value: valueProp,
42
46
  onValueChange,
47
+ defaultValue,
43
48
  onClear,
44
49
  size = "sm",
45
50
  label,
@@ -55,9 +60,18 @@ export function SearchInput({
55
60
  }: SearchInputProps) {
56
61
  const text = useUiText();
57
62
  const inputRef = useRef<HTMLInputElement>(null);
63
+ // uncontrolled: เก็บค่าเองเพื่อให้ปุ่มล้าง/Escape ทำงาน และรู้ว่าจะโชว์ปุ่มล้างไหม
64
+ const [uncontrolled, setUncontrolled] = useState(String(defaultValue ?? ""));
65
+ const value = valueProp ?? uncontrolled;
66
+
67
+ function change(next: string) {
68
+ if (valueProp === undefined) setUncontrolled(next);
69
+ onValueChange?.(next);
70
+ }
58
71
 
59
72
  function clear() {
60
- onValueChange("");
73
+ change("");
74
+ if (valueProp === undefined && inputRef.current) inputRef.current.value = "";
61
75
  onClear?.();
62
76
  inputRef.current?.focus();
63
77
  }
@@ -84,7 +98,7 @@ export function SearchInput({
84
98
  aria-label={label ?? text.search?.label ?? "ค้นหา"}
85
99
  autoComplete="off"
86
100
  spellCheck={false}
87
- onChange={(e) => onValueChange(e.target.value)}
101
+ onChange={(e) => change(e.target.value)}
88
102
  onKeyDown={(e) => {
89
103
  onKeyDown?.(e);
90
104
  if (clearOnEscape && e.key === "Escape" && value && !e.defaultPrevented) {
@@ -0,0 +1,79 @@
1
+ "use client";
2
+
3
+ import { CopyButton } from "@suphark/ui/components/shared/button/copy-button";
4
+ import { cn } from "@suphark/ui/lib/utils";
5
+ import type { ReactNode } from "react";
6
+
7
+ export interface CodeBlockProps {
8
+ /** โค้ด/คำสั่งที่แสดง */
9
+ code: string;
10
+ /** ป้ายหัวแถบ (ค่าเริ่มต้น `lang` หรือ "shell") — ส่ง `false` เพื่อซ่อนหัวแถบทั้งแถว */
11
+ title?: ReactNode | false;
12
+ /** ชื่อภาษา — เป็นป้ายเฉย ๆ ไม่มี syntax highlight */
13
+ lang?: string;
14
+ /** สิ่งที่คัดลอกจริงเมื่อต่างจากที่แสดง (เช่นคัดลอกเฉพาะค่า token ไม่เอา `KEY=`) */
15
+ copyText?: string;
16
+ /** aria-label ของปุ่มคัดลอก */
17
+ copyLabel?: string;
18
+ showCopy?: boolean;
19
+ /** ตัดบรรทัดแทนเลื่อนแนวนอน */
20
+ wrap?: boolean;
21
+ /** จำกัดความสูงแล้วเลื่อน เช่น `"max-h-72"` */
22
+ maxHeightClassName?: string;
23
+ className?: string;
24
+ }
25
+
26
+ /**
27
+ * บล็อกโค้ด/คำสั่งพร้อมปุ่มคัดลอก — หัวแถบชื่อไฟล์/ภาษา + `<pre><code>` mono เลื่อนแนวนอนได้
28
+ * ไม่มี syntax highlight โดยตั้งใจ (คำสั่ง/ค่า config ไม่ต้องการ และเลี่ยง dependency)
29
+ *
30
+ * <CodeBlock code="pnpm add @suphark/ui" />
31
+ * <CodeBlock title="API_TOKEN" code={`API_TOKEN=${token}`} copyText={token} />
32
+ */
33
+ export function CodeBlock({
34
+ code,
35
+ title,
36
+ lang,
37
+ copyText,
38
+ copyLabel,
39
+ showCopy = true,
40
+ wrap = false,
41
+ maxHeightClassName,
42
+ className,
43
+ }: CodeBlockProps) {
44
+ const heading = title === false ? null : (title ?? lang ?? "shell");
45
+ const showHeader = heading !== null || showCopy;
46
+
47
+ return (
48
+ <div
49
+ data-slot="code-block"
50
+ className={cn("min-w-0 overflow-hidden rounded-lg border bg-card", className)}
51
+ >
52
+ {showHeader && (
53
+ <div className="flex items-center justify-between gap-2 border-b bg-muted px-3 py-1.5">
54
+ <span className="min-w-0 truncate font-mono text-muted-foreground text-xs">
55
+ {heading}
56
+ </span>
57
+ {showCopy && (
58
+ <CopyButton
59
+ value={copyText ?? code}
60
+ label={copyLabel}
61
+ variant="outline"
62
+ size="icon-xs"
63
+ className="shrink-0"
64
+ />
65
+ )}
66
+ </div>
67
+ )}
68
+ <pre
69
+ className={cn(
70
+ "px-3 py-2 font-mono text-xs leading-relaxed",
71
+ wrap ? "whitespace-pre-wrap break-words" : "overflow-x-auto",
72
+ maxHeightClassName && cn("overflow-y-auto", maxHeightClassName),
73
+ )}
74
+ >
75
+ <code>{code}</code>
76
+ </pre>
77
+ </div>
78
+ );
79
+ }
package/src/index.ts CHANGED
@@ -134,6 +134,7 @@ export { SelectionButton } from "./components/shared/selection-button";
134
134
  export { Stat } from "./components/shared/stat";
135
135
  export { StatusBadge, statusTone, type Tone } from "./components/shared/status-badge";
136
136
  export { SwitcherChevron } from "./components/shared/switcher-chevron";
137
+ export { CodeBlock, type CodeBlockProps } from "./components/shared/text/code-block";
137
138
  export {
138
139
  PrimarySecondaryText,
139
140
  type PrimarySecondaryTextProps,