@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 +10 -0
- package/package.json +1 -1
- package/src/components/shared/button/copy-button.tsx +72 -0
- package/src/components/shared/button/index.ts +1 -0
- package/src/components/shared/form/search-input.tsx +22 -8
- package/src/components/shared/text/code-block.tsx +79 -0
- package/src/index.ts +1 -0
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.
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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) =>
|
|
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,
|