@suphark/ui 0.16.0 → 0.18.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 +24 -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 +25 -10
- package/src/components/shared/form/simple-select.tsx +24 -5
- package/src/components/shared/language/language-menu-items.tsx +68 -0
- package/src/components/shared/language/language-switch.tsx +115 -0
- package/src/components/shared/text/code-block.tsx +79 -0
- package/src/components/shared/ui-text.tsx +4 -0
- package/src/hooks/use-copy-to-clipboard.ts +9 -3
- package/src/index.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
+
## [0.18.0] — 2026-09-30
|
|
6
|
+
|
|
7
|
+
> 0.17.0 ถูก publish ตอนที่ `LanguageSwitch` ยังไม่เข้า (commit ถัดไป) — ของชิ้นนี้จึงออกเป็น 0.18.0
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`LanguageSwitch`** + **`LanguageMenuItems`** (barrel) — สลับภาษาที่ Pi PDF (`language-selector.tsx`) / Pi Config (`locale-toggle.tsx` + `locale-menu-items.tsx`) / Pi Auth เขียนซ้ำ; แพ็กเกจไม่รู้จัก i18n ของแอป → แอปส่ง `locales[{ value, label, hint?, flag? }]` + `value` + `onValueChange` เอง (เขียน cookie/เรียก server action/`router.refresh()` ฝั่งแอป):
|
|
12
|
+
- `LanguageSwitch` (นอกเมนู เช่น header/หน้าก่อนล็อกอิน — คู่กับ `ThemeToggle`): `mode` `auto` (ค่าเริ่มต้น: ≤2 ภาษา = ปุ่มเดียววนรอบแสดงชื่อภาษาถัดไป, >2 = เมนูพร้อมธง/บรรทัดรอง) / `toggle` / `menu`, `showLabel` (menu = รหัสภาษาตัวใหญ่, false = ปุ่มไอคอน), `label` (ค่าเริ่มต้น **`UiText.language.label`** — key ใหม่), `align`, props ของ `Button` ส่งผ่านหมด; `locales` ว่าง = ไม่ render
|
|
13
|
+
- `LanguageMenuItems` (ในเมนูผู้ใช้ — คู่กับ `ThemeMenuItems`): `DropdownMenuRadioGroup` + หัวกลุ่มจาก `UiText.language`, `disabled` ปิดระหว่าง pending
|
|
14
|
+
- `useCopyToClipboard`: `copyToClipboard(text, key?, labels?)` — ทับข้อความ toast เฉพาะครั้งนั้นได้ (ข้อสังเกตจาก Pi Config: `RevealedValues` มีข้อความต่างกันทุกปุ่ม "คัดลอกคีย์ X / ค่าของ X / ทั้งชุด N ค่า" แต่เดิม hook รับข้อความเดียวตอนสร้าง จึงต้องปิด toast แล้วเรียก `toast.success()` เอง)
|
|
15
|
+
- `SimpleSelect`: **`emptyValue`** — บอกว่า option ไหนหมายถึง "ไม่เลือก/ไม่มี" แล้วคอมโพเนนต์ส่ง **ค่าว่าง** ใน FormData แทน sentinel (Base UI ไม่รับ `value=""`) — เดิม `name` ใช้ได้เฉพาะเคสที่ค่าที่เลือก = ค่าที่ส่ง 1:1 แอปที่มี "ไม่มีกลุ่ม" ต้องทำ hidden input แปลงค่าเอง (รายงานจาก Pi Config `SectionSelect`: `__none__` หลุดเข้า server action); ใช้คู่ `required` ไม่ได้ (hidden input ไม่เข้า constraint validation) ให้ตรวจที่ schema
|
|
16
|
+
- `SearchInput`: ส่ง `aria-label` ตรง ๆ ได้แล้ว (เดิมคอมโพเนนต์เขียนทับเงียบ ๆ หลัง `{...props}` ต้องใช้ `label` เท่านั้น — รายงานจาก Pi PDF)
|
|
17
|
+
- showcase: หน้า `/components/language-switch` — 102 หน้า; tests 8 กรณีเพิ่ม (รวม 154)
|
|
18
|
+
|
|
19
|
+
## [0.17.0] — 2026-09-30
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **`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) ส่งผ่านหมด
|
|
24
|
+
- **`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)
|
|
25
|
+
- `SearchInput`: ใช้แบบ **uncontrolled** ได้แล้ว — ไม่ส่ง `value` = คุมค่าในตัวเองจาก `defaultValue` (ปุ่มล้าง/Escape ยังทำงาน) จึงวางใน `<form>` ที่ submit ไป server action/URL param พร้อม `name` ได้ (คำถามจาก Pi Auth: `/console/logs`, `/platform/users`); `onValueChange` ยังเรียกทุกตัวอักษรทั้งสองโหมด — **ไม่มี debounce ในตัว** (ให้แอป debounce เอง) จดไว้ใน docstring แล้ว
|
|
26
|
+
- showcase: หน้า `/components/copy-button`, `/components/code-block` — 101 หน้า; tests 5 กรณีเพิ่ม (รวม 146)
|
|
27
|
+
- test: `date-picker.test.tsx` ตั้ง timeout 20s (เปิด popup = render ปฏิทินทั้งเดือนใน jsdom ช้าเป็นครั้งคราวเกิน 5s ตอนรันทั้งชุด — ทำให้ `pnpm verify`/CI แดงสุ่ม ๆ)
|
|
28
|
+
|
|
5
29
|
## [0.16.0] — 2026-09-30
|
|
6
30
|
|
|
7
31
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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,18 +9,19 @@ 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 */
|
|
22
23
|
size?: "xs" | "sm" | "default";
|
|
23
|
-
/** aria-label ของช่อง (ค่าเริ่มต้น `UiText.search.label`) */
|
|
24
|
+
/** aria-label ของช่อง (ค่าเริ่มต้น `UiText.search.label`) — ส่ง `aria-label` ตรง ๆ ก็ได้ ชนะ `label` */
|
|
24
25
|
label?: string;
|
|
25
26
|
/** aria-label ของปุ่มล้าง (ค่าเริ่มต้น `UiText.search.clear`) */
|
|
26
27
|
clearLabel?: string;
|
|
@@ -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,
|
|
@@ -51,13 +56,23 @@ export function SearchInput({
|
|
|
51
56
|
disabled,
|
|
52
57
|
onKeyDown,
|
|
53
58
|
ref,
|
|
59
|
+
"aria-label": ariaLabelProp,
|
|
54
60
|
...props
|
|
55
61
|
}: SearchInputProps) {
|
|
56
62
|
const text = useUiText();
|
|
57
63
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
64
|
+
// uncontrolled: เก็บค่าเองเพื่อให้ปุ่มล้าง/Escape ทำงาน และรู้ว่าจะโชว์ปุ่มล้างไหม
|
|
65
|
+
const [uncontrolled, setUncontrolled] = useState(String(defaultValue ?? ""));
|
|
66
|
+
const value = valueProp ?? uncontrolled;
|
|
67
|
+
|
|
68
|
+
function change(next: string) {
|
|
69
|
+
if (valueProp === undefined) setUncontrolled(next);
|
|
70
|
+
onValueChange?.(next);
|
|
71
|
+
}
|
|
58
72
|
|
|
59
73
|
function clear() {
|
|
60
|
-
|
|
74
|
+
change("");
|
|
75
|
+
if (valueProp === undefined && inputRef.current) inputRef.current.value = "";
|
|
61
76
|
onClear?.();
|
|
62
77
|
inputRef.current?.focus();
|
|
63
78
|
}
|
|
@@ -81,10 +96,10 @@ export function SearchInput({
|
|
|
81
96
|
value={value}
|
|
82
97
|
disabled={disabled}
|
|
83
98
|
placeholder={placeholder ?? text.search?.placeholder ?? "ค้นหา…"}
|
|
84
|
-
aria-label={label ?? text.search?.label ?? "ค้นหา"}
|
|
99
|
+
aria-label={ariaLabelProp ?? label ?? text.search?.label ?? "ค้นหา"}
|
|
85
100
|
autoComplete="off"
|
|
86
101
|
spellCheck={false}
|
|
87
|
-
onChange={(e) =>
|
|
102
|
+
onChange={(e) => change(e.target.value)}
|
|
88
103
|
onKeyDown={(e) => {
|
|
89
104
|
onKeyDown?.(e);
|
|
90
105
|
if (clearOnEscape && e.key === "Escape" && value && !e.defaultPrevented) {
|
|
@@ -45,6 +45,13 @@ export interface SimpleSelectProps<T extends string = string> {
|
|
|
45
45
|
name?: string;
|
|
46
46
|
/** บังคับเลือกก่อน submit ฟอร์ม */
|
|
47
47
|
required?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* ค่าของ option ที่หมายถึง "ไม่เลือก/ไม่มี" — Base UI ไม่รับค่าว่างเป็น `value` ของ option แอปจึงต้องใช้
|
|
50
|
+
* ค่าพิเศษ (เช่น `"__none__"`); ส่ง `emptyValue` มาแล้วคอมโพเนนต์จะส่ง **ค่าว่าง** ใน FormData แทน
|
|
51
|
+
* ไม่ให้ sentinel ของ UI หลุดไปถึง server action (ขอจาก Pi Config — `SectionSelect` เคยต้องทำ hidden input เอง)
|
|
52
|
+
* หมายเหตุ: ใช้คู่ `required` ไม่ได้ (hidden input ไม่เข้า constraint validation ของ browser) ให้ตรวจฝั่ง schema
|
|
53
|
+
*/
|
|
54
|
+
emptyValue?: string;
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
function isGroupList<T extends string>(
|
|
@@ -69,7 +76,12 @@ export function SimpleSelect<T extends string = string>({
|
|
|
69
76
|
ariaLabel,
|
|
70
77
|
name,
|
|
71
78
|
required,
|
|
79
|
+
emptyValue,
|
|
72
80
|
}: SimpleSelectProps<T>) {
|
|
81
|
+
// ส่งค่าเองเมื่อมี emptyValue (ต้องแปลง sentinel → "") — นอกนั้นใช้ hidden input ของ Base UI
|
|
82
|
+
const ownsHiddenInput = Boolean(name) && emptyValue !== undefined;
|
|
83
|
+
const [internalValue, setInternalValue] = React.useState<T | undefined>(defaultValue);
|
|
84
|
+
const currentValue = value ?? internalValue;
|
|
73
85
|
const { flatOptions, groups } = React.useMemo(() => {
|
|
74
86
|
if (isGroupList(options)) {
|
|
75
87
|
const flat = options.flatMap((g) => g.options);
|
|
@@ -123,13 +135,13 @@ export function SimpleSelect<T extends string = string>({
|
|
|
123
135
|
// ผู้เรียกเลือกส่งอย่างใดอย่างหนึ่ง ค่าที่ส่งมาจึงไม่ใช่ค่าที่เปลี่ยนไปมา
|
|
124
136
|
defaultValue={defaultValue}
|
|
125
137
|
onValueChange={(val) => {
|
|
126
|
-
if (val
|
|
127
|
-
|
|
128
|
-
|
|
138
|
+
if (val === null) return;
|
|
139
|
+
if (value === undefined) setInternalValue(val as T);
|
|
140
|
+
onValueChange?.(val as T);
|
|
129
141
|
}}
|
|
130
142
|
disabled={disabled}
|
|
131
|
-
name={name}
|
|
132
|
-
required={required}
|
|
143
|
+
name={ownsHiddenInput ? undefined : name}
|
|
144
|
+
required={ownsHiddenInput ? undefined : required}
|
|
133
145
|
>
|
|
134
146
|
<SelectTrigger
|
|
135
147
|
size={size}
|
|
@@ -155,6 +167,13 @@ export function SimpleSelect<T extends string = string>({
|
|
|
155
167
|
</React.Fragment>
|
|
156
168
|
))}
|
|
157
169
|
</SelectContent>
|
|
170
|
+
{ownsHiddenInput && (
|
|
171
|
+
<input
|
|
172
|
+
type="hidden"
|
|
173
|
+
name={name}
|
|
174
|
+
value={currentValue === undefined || currentValue === emptyValue ? "" : currentValue}
|
|
175
|
+
/>
|
|
176
|
+
)}
|
|
158
177
|
</Select>
|
|
159
178
|
);
|
|
160
179
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenuGroup,
|
|
6
|
+
DropdownMenuLabel,
|
|
7
|
+
DropdownMenuRadioGroup,
|
|
8
|
+
DropdownMenuRadioItem,
|
|
9
|
+
} from "@suphark/ui/components/ui/dropdown-menu";
|
|
10
|
+
import type { ReactNode } from "react";
|
|
11
|
+
|
|
12
|
+
export interface LanguageOption {
|
|
13
|
+
/** รหัสภาษา เช่น "th", "en" */
|
|
14
|
+
value: string;
|
|
15
|
+
/** ชื่อภาษา — ควรเขียนเป็นภาษานั้นเอง ("ไทย" / "English") ผู้ใช้จะหาเจอแม้อ่านภาษาปัจจุบันไม่ออก */
|
|
16
|
+
label: ReactNode;
|
|
17
|
+
/** บรรทัดรองในเมนู เช่นชื่อภาษาในภาษาปัจจุบัน */
|
|
18
|
+
hint?: ReactNode;
|
|
19
|
+
/** ธงหน้าชื่อ (emoji หรือ element) */
|
|
20
|
+
flag?: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* กลุ่มตัวเลือกภาษาสำหรับใส่ใน DropdownMenu (เช่นเมนูผู้ใช้) — คู่กับ `ThemeMenuItems`
|
|
25
|
+
* แพ็กเกจไม่รู้จัก i18n ของแอป: แอปส่ง `locales` + `value` + `onValueChange` (จะเขียน cookie/เรียก
|
|
26
|
+
* server action หรือ `router.refresh()` ก็ทำฝั่งแอป) — ยกจาก Pi Config/Pi PDF ที่เขียนเหมือนกัน
|
|
27
|
+
*/
|
|
28
|
+
export function LanguageMenuItems({
|
|
29
|
+
value,
|
|
30
|
+
onValueChange,
|
|
31
|
+
locales,
|
|
32
|
+
label,
|
|
33
|
+
disabled = false,
|
|
34
|
+
}: {
|
|
35
|
+
value: string;
|
|
36
|
+
onValueChange: (value: string) => void;
|
|
37
|
+
locales: ReadonlyArray<LanguageOption>;
|
|
38
|
+
/** หัวกลุ่ม (ค่าเริ่มต้น `UiText.language.label` — "ภาษา") */
|
|
39
|
+
label?: ReactNode;
|
|
40
|
+
/** ปิดระหว่างเปลี่ยนภาษา (pending ของ transition) */
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
}) {
|
|
43
|
+
const text = useUiText();
|
|
44
|
+
return (
|
|
45
|
+
<DropdownMenuGroup>
|
|
46
|
+
<DropdownMenuLabel className="text-muted-foreground">
|
|
47
|
+
{label ?? text.language?.label ?? "ภาษา"}
|
|
48
|
+
</DropdownMenuLabel>
|
|
49
|
+
<DropdownMenuRadioGroup value={value} onValueChange={(next) => onValueChange(String(next))}>
|
|
50
|
+
{locales.map((locale) => (
|
|
51
|
+
<DropdownMenuRadioItem key={locale.value} value={locale.value} disabled={disabled}>
|
|
52
|
+
{locale.flag && (
|
|
53
|
+
<span aria-hidden="true" className="text-base leading-none">
|
|
54
|
+
{locale.flag}
|
|
55
|
+
</span>
|
|
56
|
+
)}
|
|
57
|
+
<span className="flex min-w-0 flex-col">
|
|
58
|
+
<span className="truncate">{locale.label}</span>
|
|
59
|
+
{locale.hint && (
|
|
60
|
+
<span className="truncate text-muted-foreground text-xs">{locale.hint}</span>
|
|
61
|
+
)}
|
|
62
|
+
</span>
|
|
63
|
+
</DropdownMenuRadioItem>
|
|
64
|
+
))}
|
|
65
|
+
</DropdownMenuRadioGroup>
|
|
66
|
+
</DropdownMenuGroup>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
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 {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuTrigger,
|
|
9
|
+
} from "@suphark/ui/components/ui/dropdown-menu";
|
|
10
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
11
|
+
import { Languages } from "lucide-react";
|
|
12
|
+
import type { ComponentProps } from "react";
|
|
13
|
+
import { LanguageMenuItems, type LanguageOption } from "./language-menu-items";
|
|
14
|
+
|
|
15
|
+
export interface LanguageSwitchProps
|
|
16
|
+
extends Omit<ComponentProps<typeof Button>, "value" | "onChange" | "children"> {
|
|
17
|
+
value: string;
|
|
18
|
+
onValueChange: (value: string) => void;
|
|
19
|
+
locales: ReadonlyArray<LanguageOption>;
|
|
20
|
+
/**
|
|
21
|
+
* `toggle` = ปุ่มเดียววนไปภาษาถัดไป (เหมาะกับ 2 ภาษา), `menu` = ปุ่มเปิดเมนูให้เลือก,
|
|
22
|
+
* `auto` (ค่าเริ่มต้น) = 2 ภาษาใช้ toggle มากกว่านั้นใช้ menu
|
|
23
|
+
*/
|
|
24
|
+
mode?: "auto" | "toggle" | "menu";
|
|
25
|
+
/** แสดงข้อความบนปุ่ม (toggle = ชื่อภาษาถัดไป, menu = รหัสภาษาปัจจุบันตัวใหญ่) — false = ปุ่มไอคอนล้วน */
|
|
26
|
+
showLabel?: boolean;
|
|
27
|
+
/** aria-label ของปุ่ม + หัวเมนู (ค่าเริ่มต้น `UiText.language.label`) */
|
|
28
|
+
label?: string;
|
|
29
|
+
/** ด้านที่เมนูชิด (โหมด menu) */
|
|
30
|
+
align?: "start" | "center" | "end";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* สลับภาษานอกเมนูผู้ใช้ (header, หน้าที่ยังไม่ล็อกอิน) — คู่กับ `ThemeToggle`; ในเมนูใช้ `LanguageMenuItems`
|
|
35
|
+
* แพ็กเกจไม่รู้จัก i18n ของแอป: แอปส่ง `locales`/`value`/`onValueChange` เอง — ยกจาก Pi Config/Pi PDF
|
|
36
|
+
*
|
|
37
|
+
* <LanguageSwitch value={locale} onValueChange={setLocale} locales={[{ value: "th", label: "ไทย" }, { value: "en", label: "English" }]} />
|
|
38
|
+
*/
|
|
39
|
+
export function LanguageSwitch({
|
|
40
|
+
value,
|
|
41
|
+
onValueChange,
|
|
42
|
+
locales,
|
|
43
|
+
mode = "auto",
|
|
44
|
+
showLabel = true,
|
|
45
|
+
label,
|
|
46
|
+
align = "end",
|
|
47
|
+
variant = "ghost",
|
|
48
|
+
size = "sm",
|
|
49
|
+
className,
|
|
50
|
+
disabled,
|
|
51
|
+
...props
|
|
52
|
+
}: LanguageSwitchProps) {
|
|
53
|
+
const text = useUiText();
|
|
54
|
+
const ariaLabel = label ?? text.language?.label ?? "ภาษา";
|
|
55
|
+
const index = locales.findIndex((l) => l.value === value);
|
|
56
|
+
const current = locales[index === -1 ? 0 : index];
|
|
57
|
+
const next = locales[(index + 1) % Math.max(locales.length, 1)];
|
|
58
|
+
const asToggle = mode === "toggle" || (mode === "auto" && locales.length <= 2);
|
|
59
|
+
|
|
60
|
+
if (locales.length === 0) return null;
|
|
61
|
+
|
|
62
|
+
if (asToggle) {
|
|
63
|
+
return (
|
|
64
|
+
<Button
|
|
65
|
+
{...props}
|
|
66
|
+
type="button"
|
|
67
|
+
variant={variant}
|
|
68
|
+
size={size}
|
|
69
|
+
disabled={disabled}
|
|
70
|
+
aria-label={ariaLabel}
|
|
71
|
+
title={ariaLabel}
|
|
72
|
+
className={className}
|
|
73
|
+
onClick={() => next && onValueChange(next.value)}
|
|
74
|
+
>
|
|
75
|
+
<Languages aria-hidden="true" />
|
|
76
|
+
{showLabel && next?.label}
|
|
77
|
+
</Button>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<DropdownMenu>
|
|
83
|
+
<DropdownMenuTrigger
|
|
84
|
+
render={
|
|
85
|
+
<Button
|
|
86
|
+
{...props}
|
|
87
|
+
type="button"
|
|
88
|
+
variant={variant}
|
|
89
|
+
size={size}
|
|
90
|
+
disabled={disabled}
|
|
91
|
+
aria-label={ariaLabel}
|
|
92
|
+
title={ariaLabel}
|
|
93
|
+
className={cn("font-normal", className)}
|
|
94
|
+
>
|
|
95
|
+
<Languages aria-hidden="true" className="text-muted-foreground" />
|
|
96
|
+
{showLabel && (
|
|
97
|
+
<span className="font-medium uppercase tracking-wider">
|
|
98
|
+
{current?.value ?? value}
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
</Button>
|
|
102
|
+
}
|
|
103
|
+
/>
|
|
104
|
+
<DropdownMenuContent align={align} className="w-52">
|
|
105
|
+
<LanguageMenuItems
|
|
106
|
+
value={value}
|
|
107
|
+
onValueChange={onValueChange}
|
|
108
|
+
locales={locales}
|
|
109
|
+
label={ariaLabel}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
/>
|
|
112
|
+
</DropdownMenuContent>
|
|
113
|
+
</DropdownMenu>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -51,6 +51,8 @@ export interface UiText {
|
|
|
51
51
|
sectionNav?: Partial<{ tabs: string }>;
|
|
52
52
|
/** SearchInput: placeholder / aria-label ช่อง / aria-label ปุ่มล้าง (optional) */
|
|
53
53
|
search?: Partial<{ placeholder: string; label: string; clear: string }>;
|
|
54
|
+
/** LanguageSwitch/LanguageMenuItems: หัวกลุ่ม + aria-label ปุ่ม (optional — "ภาษา") */
|
|
55
|
+
language?: Partial<{ label: string }>;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
export const UI_TEXT_TH: UiText = {
|
|
@@ -85,6 +87,7 @@ export const UI_TEXT_TH: UiText = {
|
|
|
85
87
|
theme: { label: "ธีม", light: "สว่าง", dark: "มืด", system: "ตามระบบ" },
|
|
86
88
|
sectionNav: { tabs: "แท็บย่อย" },
|
|
87
89
|
search: { placeholder: "ค้นหา…", label: "ค้นหา", clear: "ล้างการค้นหา" },
|
|
90
|
+
language: { label: "ภาษา" },
|
|
88
91
|
};
|
|
89
92
|
|
|
90
93
|
export const UI_TEXT_EN: UiText = {
|
|
@@ -119,6 +122,7 @@ export const UI_TEXT_EN: UiText = {
|
|
|
119
122
|
theme: { label: "Theme", light: "Light", dark: "Dark", system: "System" },
|
|
120
123
|
sectionNav: { tabs: "Section tabs" },
|
|
121
124
|
search: { placeholder: "Search…", label: "Search", clear: "Clear search" },
|
|
125
|
+
language: { label: "Language" },
|
|
122
126
|
};
|
|
123
127
|
|
|
124
128
|
const UiTextContext = createContext<UiText>(UI_TEXT_TH);
|
|
@@ -26,6 +26,7 @@ const DEFAULT_LABELS: CopyToClipboardLabels = {
|
|
|
26
26
|
*
|
|
27
27
|
* const { copyToClipboard, copiedKey } = useCopyToClipboard({ toast: { success: "คัดลอกแล้ว", failed: "คัดลอกไม่สำเร็จ" } });
|
|
28
28
|
* copyToClipboard(row.value, row.id) // หลายปุ่มในหน้าเดียว → copiedKey บอกว่าปุ่มไหนเพิ่งคัดลอก
|
|
29
|
+
* copyToClipboard(all, "all", { success: `คัดลอกทั้งชุด ${n} ค่า` }) // ข้อความเฉพาะครั้งนั้น
|
|
29
30
|
*/
|
|
30
31
|
export function useCopyToClipboard(options: UseCopyToClipboardOptions = {}) {
|
|
31
32
|
const { toast = true, timeout = 2000 } = options;
|
|
@@ -44,16 +45,21 @@ export function useCopyToClipboard(options: UseCopyToClipboardOptions = {}) {
|
|
|
44
45
|
const { success, failed } = labels;
|
|
45
46
|
|
|
46
47
|
const copyToClipboard = useCallback(
|
|
47
|
-
async (
|
|
48
|
+
async (
|
|
49
|
+
text: string,
|
|
50
|
+
key = "default",
|
|
51
|
+
/** ทับข้อความ toast เฉพาะครั้งนี้ — ปุ่มหลายตัวที่ข้อความต่างกัน (ขอจาก Pi Config) */
|
|
52
|
+
labels?: Partial<CopyToClipboardLabels>,
|
|
53
|
+
): Promise<boolean> => {
|
|
48
54
|
const ok = await copyText(text);
|
|
49
55
|
if (timer.current) clearTimeout(timer.current);
|
|
50
56
|
if (!ok) {
|
|
51
57
|
setCopiedKey(null);
|
|
52
|
-
if (showToast) sonner.error(failed);
|
|
58
|
+
if (showToast) sonner.error(labels?.failed ?? failed);
|
|
53
59
|
return false;
|
|
54
60
|
}
|
|
55
61
|
setCopiedKey(key);
|
|
56
|
-
if (showToast) sonner.success(success);
|
|
62
|
+
if (showToast) sonner.success(labels?.success ?? success);
|
|
57
63
|
timer.current = setTimeout(() => setCopiedKey(null), timeout);
|
|
58
64
|
return true;
|
|
59
65
|
},
|
package/src/index.ts
CHANGED
|
@@ -98,6 +98,14 @@ export {
|
|
|
98
98
|
} from "./components/shared/icon-picker";
|
|
99
99
|
export { InfoHint } from "./components/shared/info-hint";
|
|
100
100
|
export { Initials, initialsOf } from "./components/shared/initials";
|
|
101
|
+
export {
|
|
102
|
+
LanguageMenuItems,
|
|
103
|
+
type LanguageOption,
|
|
104
|
+
} from "./components/shared/language/language-menu-items";
|
|
105
|
+
export {
|
|
106
|
+
LanguageSwitch,
|
|
107
|
+
type LanguageSwitchProps,
|
|
108
|
+
} from "./components/shared/language/language-switch";
|
|
101
109
|
export { ErrorState, type RouteError } from "./components/shared/layout/error-state";
|
|
102
110
|
export {
|
|
103
111
|
MultiSelectCombobox,
|
|
@@ -134,6 +142,7 @@ export { SelectionButton } from "./components/shared/selection-button";
|
|
|
134
142
|
export { Stat } from "./components/shared/stat";
|
|
135
143
|
export { StatusBadge, statusTone, type Tone } from "./components/shared/status-badge";
|
|
136
144
|
export { SwitcherChevron } from "./components/shared/switcher-chevron";
|
|
145
|
+
export { CodeBlock, type CodeBlockProps } from "./components/shared/text/code-block";
|
|
137
146
|
export {
|
|
138
147
|
PrimarySecondaryText,
|
|
139
148
|
type PrimarySecondaryTextProps,
|