@suphark/ui 0.15.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,23 @@
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
+
15
+ ## [0.16.0] — 2026-09-30
16
+
17
+ ### Added
18
+
19
+ - **`SearchInput`** (barrel) — ช่องค้นหามาตรฐานที่ Pi Auth (~15 จุด) / Pi PDF (5) / DueBell (8) เขียนซ้ำ: ไอคอนแว่นขยายซ้าย, ปุ่มล้าง (X) ขวาเมื่อมีข้อความ (กดแล้ว focus กลับที่ช่อง), **Escape ล้างค่า** (`clearOnEscape`), `type="search"` แต่ซ่อนปุ่ม X ของ WebKit; controlled ล้วน `value` + `onValueChange(next)` (แอปคุม state/debounce เอง), `onClear`, `size` xs|sm|default (h-7/h-8/h-9), `label`/`clearLabel`/`placeholder` (ค่าเริ่มต้นจาก **`UiText.search`** `{ placeholder, label, clear }` — key ใหม่), `groupClassName` (กรอบ) แยกจาก `className` (ตัว `<input>`), ส่ง `ref` ถึง input ได้; props อื่นของ `<input>` ส่งผ่านหมด
20
+ - showcase: หน้า `/components/search-input`; **เมนูซ้ายของ `/components` มีช่องค้นหาแล้ว** — กรองตามชื่อ/slug/ชื่อ export/คำอธิบาย, บอกจำนวนที่พบ (`aria-live`), กด `/` ที่ไหนก็ได้เพื่อโฟกัสช่องค้นหา (ไม่ดักเมื่อกำลังพิมพ์ในช่องอื่น)
21
+
5
22
  ## [0.15.0] — 2026-09-30
6
23
 
7
24
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.15.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";
@@ -0,0 +1,129 @@
1
+ "use client";
2
+
3
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
4
+ import {
5
+ InputGroup,
6
+ InputGroupAddon,
7
+ InputGroupButton,
8
+ InputGroupInput,
9
+ } from "@suphark/ui/components/ui/input-group";
10
+ import { cn } from "@suphark/ui/lib/utils";
11
+ import { Search, X } from "lucide-react";
12
+ import { type ComponentProps, useRef, useState } from "react";
13
+
14
+ export interface SearchInputProps
15
+ extends Omit<ComponentProps<"input">, "size" | "value" | "onChange" | "type"> {
16
+ /** ไม่ส่ง = uncontrolled (คุมค่าในตัวเอง จาก `defaultValue`) — ใช้กับฟอร์มที่ submit เองได้ */
17
+ value?: string;
18
+ /** รับค่าใหม่ตรง ๆ (ไม่ใช่ event) — ปุ่มล้างก็เรียกด้วย `""`; uncontrolled ก็ยังเรียกให้ */
19
+ onValueChange?: (value: string) => void;
20
+ /** เรียกเพิ่มหลังกดปุ่มล้าง/Escape (เช่นรีเซ็ตหน้า pagination) */
21
+ onClear?: () => void;
22
+ /** xs = h-7, sm = h-8 (ค่าเริ่มต้น), default = h-9 */
23
+ size?: "xs" | "sm" | "default";
24
+ /** aria-label ของช่อง (ค่าเริ่มต้น `UiText.search.label`) */
25
+ label?: string;
26
+ /** aria-label ของปุ่มล้าง (ค่าเริ่มต้น `UiText.search.clear`) */
27
+ clearLabel?: string;
28
+ /** Escape ในช่องล้างค่า (ค่าเริ่มต้น true) */
29
+ clearOnEscape?: boolean;
30
+ /** class ของกรอบ (InputGroup) — `className` ไปที่ `<input>` */
31
+ groupClassName?: string;
32
+ }
33
+
34
+ /**
35
+ * ช่องค้นหามาตรฐาน: ไอคอนแว่นขยายซ้าย + ปุ่มล้าง (X) ขวาเมื่อมีข้อความ + Escape ล้างค่า
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)
39
+ *
40
+ * const [q, setQ] = useState("");
41
+ * <SearchInput value={q} onValueChange={setQ} placeholder="ค้นหาสมาชิก…" />
42
+ * <form action={search}><SearchInput name="q" defaultValue={q} /></form>
43
+ */
44
+ export function SearchInput({
45
+ value: valueProp,
46
+ onValueChange,
47
+ defaultValue,
48
+ onClear,
49
+ size = "sm",
50
+ label,
51
+ clearLabel,
52
+ clearOnEscape = true,
53
+ placeholder,
54
+ className,
55
+ groupClassName,
56
+ disabled,
57
+ onKeyDown,
58
+ ref,
59
+ ...props
60
+ }: SearchInputProps) {
61
+ const text = useUiText();
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
+ }
71
+
72
+ function clear() {
73
+ change("");
74
+ if (valueProp === undefined && inputRef.current) inputRef.current.value = "";
75
+ onClear?.();
76
+ inputRef.current?.focus();
77
+ }
78
+
79
+ return (
80
+ <InputGroup
81
+ data-slot="search-input"
82
+ className={cn(size === "xs" ? "h-7" : size === "default" ? "h-9" : "h-8", groupClassName)}
83
+ >
84
+ <InputGroupAddon>
85
+ <Search aria-hidden="true" className={size === "xs" ? "size-3.5" : undefined} />
86
+ </InputGroupAddon>
87
+ <InputGroupInput
88
+ {...props}
89
+ ref={(node) => {
90
+ inputRef.current = node;
91
+ if (typeof ref === "function") ref(node);
92
+ else if (ref) ref.current = node;
93
+ }}
94
+ type="search"
95
+ value={value}
96
+ disabled={disabled}
97
+ placeholder={placeholder ?? text.search?.placeholder ?? "ค้นหา…"}
98
+ aria-label={label ?? text.search?.label ?? "ค้นหา"}
99
+ autoComplete="off"
100
+ spellCheck={false}
101
+ onChange={(e) => change(e.target.value)}
102
+ onKeyDown={(e) => {
103
+ onKeyDown?.(e);
104
+ if (clearOnEscape && e.key === "Escape" && value && !e.defaultPrevented) {
105
+ e.preventDefault();
106
+ clear();
107
+ }
108
+ }}
109
+ className={cn(
110
+ // ซ่อนปุ่ม X ของ type=search ใน WebKit — เรามีปุ่มล้างของเราเอง
111
+ "[&::-webkit-search-cancel-button]:appearance-none",
112
+ size === "xs" && "text-xs",
113
+ className,
114
+ )}
115
+ />
116
+ {value && !disabled && (
117
+ <InputGroupAddon align="inline-end">
118
+ <InputGroupButton
119
+ size={size === "xs" ? "icon-xs" : "icon-sm"}
120
+ onClick={clear}
121
+ aria-label={clearLabel ?? text.search?.clear ?? "ล้างการค้นหา"}
122
+ >
123
+ <X aria-hidden="true" />
124
+ </InputGroupButton>
125
+ </InputGroupAddon>
126
+ )}
127
+ </InputGroup>
128
+ );
129
+ }
@@ -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
+ }
@@ -49,6 +49,8 @@ export interface UiText {
49
49
  theme?: Partial<{ label: string; light: string; dark: string; system: string }>;
50
50
  /** SectionNav: aria-label ของแถบแท็บย่อย (optional — "แท็บย่อย") */
51
51
  sectionNav?: Partial<{ tabs: string }>;
52
+ /** SearchInput: placeholder / aria-label ช่อง / aria-label ปุ่มล้าง (optional) */
53
+ search?: Partial<{ placeholder: string; label: string; clear: string }>;
52
54
  }
53
55
 
54
56
  export const UI_TEXT_TH: UiText = {
@@ -82,6 +84,7 @@ export const UI_TEXT_TH: UiText = {
82
84
  signOut: "ออกจากระบบ",
83
85
  theme: { label: "ธีม", light: "สว่าง", dark: "มืด", system: "ตามระบบ" },
84
86
  sectionNav: { tabs: "แท็บย่อย" },
87
+ search: { placeholder: "ค้นหา…", label: "ค้นหา", clear: "ล้างการค้นหา" },
85
88
  };
86
89
 
87
90
  export const UI_TEXT_EN: UiText = {
@@ -115,6 +118,7 @@ export const UI_TEXT_EN: UiText = {
115
118
  signOut: "Sign out",
116
119
  theme: { label: "Theme", light: "Light", dark: "Dark", system: "System" },
117
120
  sectionNav: { tabs: "Section tabs" },
121
+ search: { placeholder: "Search…", label: "Search", clear: "Clear search" },
118
122
  };
119
123
 
120
124
  const UiTextContext = createContext<UiText>(UI_TEXT_TH);
package/src/index.ts CHANGED
@@ -76,6 +76,7 @@ export { FormFieldRadioCards } from "./components/shared/form/form-field-radio-c
76
76
  export { FormFieldRadioGroup } from "./components/shared/form/form-field-radio-group";
77
77
  export { FormFieldSelect, type SelectOption } from "./components/shared/form/form-field-select";
78
78
  export { CheckboxField, LabeledField } from "./components/shared/form/labeled-field";
79
+ export { SearchInput, type SearchInputProps } from "./components/shared/form/search-input";
79
80
  export {
80
81
  SimpleSelect,
81
82
  type SimpleSelectGroup,
@@ -133,6 +134,7 @@ export { SelectionButton } from "./components/shared/selection-button";
133
134
  export { Stat } from "./components/shared/stat";
134
135
  export { StatusBadge, statusTone, type Tone } from "./components/shared/status-badge";
135
136
  export { SwitcherChevron } from "./components/shared/switcher-chevron";
137
+ export { CodeBlock, type CodeBlockProps } from "./components/shared/text/code-block";
136
138
  export {
137
139
  PrimarySecondaryText,
138
140
  type PrimarySecondaryTextProps,