@suphark/ui 0.17.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 CHANGED
@@ -2,6 +2,20 @@
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
+
5
19
  ## [0.17.0] — 2026-09-30
6
20
 
7
21
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.17.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 เอง",
@@ -21,7 +21,7 @@ export interface SearchInputProps
21
21
  onClear?: () => void;
22
22
  /** xs = h-7, sm = h-8 (ค่าเริ่มต้น), default = h-9 */
23
23
  size?: "xs" | "sm" | "default";
24
- /** aria-label ของช่อง (ค่าเริ่มต้น `UiText.search.label`) */
24
+ /** aria-label ของช่อง (ค่าเริ่มต้น `UiText.search.label`) — ส่ง `aria-label` ตรง ๆ ก็ได้ ชนะ `label` */
25
25
  label?: string;
26
26
  /** aria-label ของปุ่มล้าง (ค่าเริ่มต้น `UiText.search.clear`) */
27
27
  clearLabel?: string;
@@ -56,6 +56,7 @@ export function SearchInput({
56
56
  disabled,
57
57
  onKeyDown,
58
58
  ref,
59
+ "aria-label": ariaLabelProp,
59
60
  ...props
60
61
  }: SearchInputProps) {
61
62
  const text = useUiText();
@@ -95,7 +96,7 @@ export function SearchInput({
95
96
  value={value}
96
97
  disabled={disabled}
97
98
  placeholder={placeholder ?? text.search?.placeholder ?? "ค้นหา…"}
98
- aria-label={label ?? text.search?.label ?? "ค้นหา"}
99
+ aria-label={ariaLabelProp ?? label ?? text.search?.label ?? "ค้นหา"}
99
100
  autoComplete="off"
100
101
  spellCheck={false}
101
102
  onChange={(e) => change(e.target.value)}
@@ -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 !== null && onValueChange) {
127
- onValueChange(val as T);
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
+ }
@@ -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 (text: string, key = "default"): Promise<boolean> => {
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,