@suphark/ui 0.17.0 → 0.19.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,37 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.19.0] — 2026-09-30
6
+
7
+ ปิดรายการ "component เล็กที่ใช้ร่วม" ของแผนรวม UI ทุกแอป (ดู `todo/todo.md`) — เพิ่มอย่างเดียว ไม่มี prop เดิมเปลี่ยนความหมาย
8
+
9
+ ### Added
10
+
11
+ - **`Stat` เพิ่ม `icon` / `badge` / `delta` / `tone`** + **`StatGrid`** (Pi Auth มี 17 การ์ดแบบนี้, DueBell มี DeltaBadge เอง): `delta` = `{ value, direction: "up"|"down"|"flat", tone?, hint? }` เลือกไอคอนเทรนด์และสีให้ (ขึ้น = success, ลง = destructive — ทับด้วย `tone` ได้เพราะบางตัวชี้วัด "ลง" คือดี), `icon` มีกรอบพื้นตามโทน, `tone` เปลี่ยนสีตัวเลข (`default|success|warning|destructive|info`); `StatGrid` คอลัมน์ 2|3|4 (มือถือ 1 → sm 2)
12
+ - **`SettingsRow`** / **`SwitchRow`** / **`DangerZone`** (barrel) — แถวตั้งค่า: ชื่อ+คำอธิบายซ้าย control ขวา (จอแคบตกลงมา), `htmlFor` ผูกชื่อกับ control, `tone="destructive"`; `SwitchRow` = แถว + `Switch` ที่ผูก id ให้แล้ว (คลิกชื่อแล้วสลับ) รับ props ของ `Switch` ทั้งหมด; `DangerZone` = กรอบ `border-destructive/40` + พื้น subtle + เส้นคั่นแถว (Pi Auth/Pi PDF เขียน layout นี้ซ้ำ)
13
+ - **`ConfirmDialog` / `useConfirm` `requireText`** — ต้องพิมพ์ข้อความให้ตรงก่อนปุ่มยืนยันจะกดได้ (ลบของที่กู้ไม่ได้ แบบ GitHub); `requireTextLabel` ทับข้อความเหนือช่อง, ค่าเริ่มต้นจาก `UiText.confirmTypeToConfirm`/`…Suffix` (key ใหม่); ปิดกล่องแล้วล้างสิ่งที่พิมพ์เสมอ (ยืนยันรายการถัดไปทันทีไม่ได้) — ใช้ผ่าน `confirm({ requireText: app.name, destructive: true })` ได้เลย
14
+ - **`UserIdentity`** (barrel) — avatar (`Initials` สีพื้นคงที่ต่อชื่อ) + ชื่อ + อีเมล + `badge`/`hint`, `size` sm|default, `hideAvatar`; **ไม่มีชื่อ = ใช้อีเมลเป็นบรรทัดหลัก** (ผู้ใช้ที่ยังไม่ตั้งชื่อ) — Pi Auth เขียนซ้ำ ~15 จุด
15
+ - **`CountBadge`** (barrel) — ป้ายจำนวนท้ายเมนู/แท็บ: เกิน `max` เป็น `99+`, `0` ซ่อนอัตโนมัติ (`showZero` บังคับแสดง), `tone` muted|primary|destructive
16
+ - **`SaveStatus`** (barrel, ยกจาก Pi PDF) — สถานะบันทึกอัตโนมัติบรรทัดเดียว: `saving` (spinner) / `saved` (✓ เขียว) / `error` (`role="alert"`, ข้อความของแอปหรือ `UiText.actionFailed`) / `idle` + `savedAt` = เวลาสัมพัทธ์ผ่าน `ClientDateFormatter` (อัปเดตเองหลัง mount ไม่ทำ hydration mismatch); `language`/`text`
17
+ - **`SliderField`** (barrel, ยกจาก Pi PDF) — ช่องเลื่อน + ป้ายชื่อ + ค่าปัจจุบันมุมขวา: `value`/`onValueChange` เป็น number เดี่ยว, `unit` หรือ `formatValue`, `description`, `size` sm|default, `disabled`
18
+ - **`SkipLink`** (barrel) — ลิงก์ "ข้ามไปเนื้อหา" สำหรับหน้าที่ไม่ได้ใช้ `AppShell` (Pi PDF มีของตัวเอง); `AppShell` เปลี่ยนมาใช้ตัวเดียวกันนี้ภายใน
19
+ - **`FormDialog` `revealSuccess`** — แบบ **ไม่ต้องส่งฟังก์ชัน** จึงใช้จาก **server component** ได้ (รายงานจาก Pi Auth: `renderSuccess` เป็นฟังก์ชัน ข้าม server/client boundary ไม่ได้ → `Functions cannot be passed directly to Client Components`): server action คืน `{ ok: true, data: { values: { "Signing secret": "whsec_…" } } }` แล้ว dialog แสดง `SecretReveal` ให้เอง + ปุ่ม "เสร็จแล้ว" (ไม่ปิดเอง); `successTitle` (ค่าเริ่มต้น `UiText.showOnce`), `successDescription` แทน description ของฟอร์ม — เคส "สร้างแล้วโชว์ secret ครั้งเดียว" (webhook secret / SCIM token / service account key / client secret) ซ้ำกันทุกแอป; `renderSuccess` เดิมยังใช้ได้จาก client component
20
+ - showcase: หน้า `/components/settings-row`, `/components/user-identity` + หน้า `stat` ใหม่ (ไอคอน/delta/StatGrid) — 104 หน้า; tests 9 กรณีเพิ่ม (รวม 163)
21
+
22
+ ## [0.18.0] — 2026-09-30
23
+
24
+ > 0.17.0 ถูก publish ตอนที่ `LanguageSwitch` ยังไม่เข้า (commit ถัดไป) — ของชิ้นนี้จึงออกเป็น 0.18.0
25
+
26
+ ### Added
27
+
28
+ - **`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()` ฝั่งแอป):
29
+ - `LanguageSwitch` (นอกเมนู เช่น header/หน้าก่อนล็อกอิน — คู่กับ `ThemeToggle`): `mode` `auto` (ค่าเริ่มต้น: ≤2 ภาษา = ปุ่มเดียววนรอบแสดงชื่อภาษาถัดไป, >2 = เมนูพร้อมธง/บรรทัดรอง) / `toggle` / `menu`, `showLabel` (menu = รหัสภาษาตัวใหญ่, false = ปุ่มไอคอน), `label` (ค่าเริ่มต้น **`UiText.language.label`** — key ใหม่), `align`, props ของ `Button` ส่งผ่านหมด; `locales` ว่าง = ไม่ render
30
+ - `LanguageMenuItems` (ในเมนูผู้ใช้ — คู่กับ `ThemeMenuItems`): `DropdownMenuRadioGroup` + หัวกลุ่มจาก `UiText.language`, `disabled` ปิดระหว่าง pending
31
+ - `useCopyToClipboard`: `copyToClipboard(text, key?, labels?)` — ทับข้อความ toast เฉพาะครั้งนั้นได้ (ข้อสังเกตจาก Pi Config: `RevealedValues` มีข้อความต่างกันทุกปุ่ม "คัดลอกคีย์ X / ค่าของ X / ทั้งชุด N ค่า" แต่เดิม hook รับข้อความเดียวตอนสร้าง จึงต้องปิด toast แล้วเรียก `toast.success()` เอง)
32
+ - `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
33
+ - `SearchInput`: ส่ง `aria-label` ตรง ๆ ได้แล้ว (เดิมคอมโพเนนต์เขียนทับเงียบ ๆ หลัง `{...props}` ต้องใช้ `label` เท่านั้น — รายงานจาก Pi PDF)
34
+ - showcase: หน้า `/components/language-switch` — 102 หน้า; tests 8 กรณีเพิ่ม (รวม 154)
35
+
5
36
  ## [0.17.0] — 2026-09-30
6
37
 
7
38
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.17.0",
3
+ "version": "0.19.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 เอง",
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { SkipLink } from "@suphark/ui/components/shared/skip-link";
3
4
  import {
4
5
  InputGroup,
5
6
  InputGroupAddon,
@@ -123,16 +124,9 @@ export function AppShell(props: AppShellProps) {
123
124
  /** id ของ <main> ของ AppShell (sidebar: SidebarInset, top: main ในเนื้อหา) — ใช้อ้างจาก skip link / anchor */
124
125
  export const APP_MAIN_ID = "app-main";
125
126
 
126
- /** ลิงก์ "ข้ามไปเนื้อหา" สำหรับคีย์บอร์ด/screen reader — มองไม่เห็นจนกด Tab ตัวแรก */
127
+ /** ลิงก์ "ข้ามไปเนื้อหา" ของ AppShell — ใช้ `SkipLink` ตัวเดียวกับที่ export ให้แอปใช้นอก AppShell */
127
128
  function SkipToMain() {
128
- return (
129
- <a
130
- href={`#${APP_MAIN_ID}`}
131
- className="sr-only z-[60] rounded-md bg-background px-3 py-2 text-sm font-medium ring-2 ring-ring focus:not-sr-only focus:fixed focus:top-2 focus:left-2"
132
- >
133
- ข้ามไปเนื้อหา
134
- </a>
135
- );
129
+ return <SkipLink href={`#${APP_MAIN_ID}`} />;
136
130
  }
137
131
 
138
132
  function SidebarShell({
@@ -10,8 +10,10 @@ import {
10
10
  AlertDialogHeader,
11
11
  AlertDialogTitle,
12
12
  } from "@suphark/ui/components/ui/alert-dialog";
13
+ import { Input } from "@suphark/ui/components/ui/input";
14
+ import { Label } from "@suphark/ui/components/ui/label";
13
15
  import { Loader2 } from "lucide-react";
14
- import type { ReactNode } from "react";
16
+ import { type ReactNode, useEffect, useId, useState } from "react";
15
17
  import { useUiText } from "./ui-text";
16
18
 
17
19
  /** สเปกการยืนยัน — string = description อย่างเดียว (title/ปุ่มใช้ค่าเริ่มต้นจาก UiText) */
@@ -24,6 +26,10 @@ export type ConfirmSpec =
24
26
  cancelLabel?: string;
25
27
  /** ปุ่มยืนยันสีแดง (RowActions ใช้ `destructive` ของรายการเป็นค่าเริ่มต้นอยู่แล้ว) */
26
28
  destructive?: boolean;
29
+ /** ต้องพิมพ์ข้อความนี้ให้ตรงก่อนกดยืนยันได้ (ลบของที่กู้คืนไม่ได้ — แบบ GitHub) */
30
+ requireText?: string;
31
+ /** คำอธิบายเหนือช่องพิมพ์ (ค่าเริ่มต้น "พิมพ์ \"X\" เพื่อยืนยัน") */
32
+ requireTextLabel?: ReactNode;
27
33
  };
28
34
 
29
35
  /** แปลง ConfirmSpec เป็น object เสมอ */
@@ -45,6 +51,8 @@ export function ConfirmDialog({
45
51
  cancelLabel,
46
52
  destructive = false,
47
53
  pending = false,
54
+ requireText,
55
+ requireTextLabel,
48
56
  onConfirm,
49
57
  }: {
50
58
  open: boolean;
@@ -56,9 +64,19 @@ export function ConfirmDialog({
56
64
  /** ปุ่มยืนยันสีแดง (ลบ/เพิกถอน) */
57
65
  destructive?: boolean;
58
66
  pending?: boolean;
67
+ /** ต้องพิมพ์ข้อความนี้ให้ตรงก่อนปุ่มยืนยันจะกดได้ (ชื่อแอป/โปรเจกต์ที่กำลังจะลบ) */
68
+ requireText?: string;
69
+ requireTextLabel?: ReactNode;
59
70
  onConfirm: () => void;
60
71
  }) {
61
72
  const text = useUiText();
73
+ const inputId = useId();
74
+ const [typed, setTyped] = useState("");
75
+ // เปิดใหม่ทุกครั้ง = ล้างสิ่งที่พิมพ์ไว้ (ไม่ให้ยืนยันรายการถัดไปได้ทันที)
76
+ useEffect(() => {
77
+ if (!open) setTyped("");
78
+ }, [open]);
79
+ const blocked = Boolean(requireText) && typed.trim() !== requireText;
62
80
  return (
63
81
  <AlertDialog open={open} onOpenChange={(o) => !pending && onOpenChange(o)}>
64
82
  <AlertDialogContent>
@@ -66,11 +84,32 @@ export function ConfirmDialog({
66
84
  <AlertDialogTitle>{title ?? text.confirmTitle ?? "ยืนยันการทำรายการ"}</AlertDialogTitle>
67
85
  <AlertDialogDescription>{description}</AlertDialogDescription>
68
86
  </AlertDialogHeader>
87
+ {requireText && (
88
+ <div className="flex flex-col gap-1.5">
89
+ <Label htmlFor={inputId} className="font-normal text-sm">
90
+ {requireTextLabel ?? (
91
+ <>
92
+ {text.confirmTypeToConfirm ?? "พิมพ์"}{" "}
93
+ <span className="font-medium">{requireText}</span>{" "}
94
+ {text.confirmTypeToConfirmSuffix ?? "เพื่อยืนยัน"}
95
+ </>
96
+ )}
97
+ </Label>
98
+ <Input
99
+ id={inputId}
100
+ value={typed}
101
+ onChange={(e) => setTyped(e.target.value)}
102
+ autoComplete="off"
103
+ spellCheck={false}
104
+ disabled={pending}
105
+ />
106
+ </div>
107
+ )}
69
108
  <AlertDialogFooter>
70
109
  <AlertDialogCancel disabled={pending}>{cancelLabel ?? text.cancel}</AlertDialogCancel>
71
110
  <AlertDialogAction
72
111
  variant={destructive ? "destructive" : "default"}
73
- disabled={pending}
112
+ disabled={pending || blocked}
74
113
  aria-busy={pending || undefined}
75
114
  onClick={onConfirm}
76
115
  >
@@ -0,0 +1,41 @@
1
+ import { cn } from "@suphark/ui/lib/utils";
2
+ import type { ComponentProps } from "react";
3
+
4
+ /**
5
+ * ป้ายจำนวนเล็ก ๆ ท้ายชื่อเมนู/แท็บ/หัวข้อ — เกิน `max` แสดง "99+"; 0 ซ่อนอัตโนมัติ (`showZero` เพื่อบังคับแสดง)
6
+ *
7
+ * ผู้ใช้ <CountBadge value={members.length} />
8
+ */
9
+ export function CountBadge({
10
+ value,
11
+ max = 99,
12
+ showZero = false,
13
+ tone = "muted",
14
+ className,
15
+ ...props
16
+ }: Omit<ComponentProps<"span">, "children"> & {
17
+ value: number | null | undefined;
18
+ max?: number;
19
+ showZero?: boolean;
20
+ /** muted = พื้นเทา (ค่าเริ่มต้น), primary/destructive = เน้น (เช่นจำนวนที่ยังไม่อ่าน) */
21
+ tone?: "muted" | "primary" | "destructive";
22
+ }) {
23
+ if (value == null || (!showZero && value <= 0)) return null;
24
+ return (
25
+ <span
26
+ data-slot="count-badge"
27
+ className={cn(
28
+ "inline-flex min-w-5 shrink-0 items-center justify-center rounded-full px-1.5 font-medium text-label tabular-nums",
29
+ tone === "primary"
30
+ ? "bg-primary text-primary-foreground"
31
+ : tone === "destructive"
32
+ ? "bg-destructive text-white"
33
+ : "bg-muted text-muted-foreground",
34
+ className,
35
+ )}
36
+ {...props}
37
+ >
38
+ {value > max ? `${max}+` : value}
39
+ </span>
40
+ );
41
+ }
@@ -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,79 @@
1
+ "use client";
2
+
3
+ import { Label } from "@suphark/ui/components/ui/label";
4
+ import { Slider } from "@suphark/ui/components/ui/slider";
5
+ import { cn } from "@suphark/ui/lib/utils";
6
+ import { type ReactNode, useId } from "react";
7
+
8
+ export interface SliderFieldProps {
9
+ label: ReactNode;
10
+ value: number;
11
+ onValueChange: (value: number) => void;
12
+ min?: number;
13
+ max?: number;
14
+ step?: number;
15
+ /** หน่วยต่อท้ายตัวเลข เช่น "%" หรือ " px" */
16
+ unit?: string;
17
+ /** จัดรูปแบบค่าที่แสดงเอง (ทับ `unit`) */
18
+ formatValue?: (value: number) => ReactNode;
19
+ description?: ReactNode;
20
+ disabled?: boolean;
21
+ /** sm = ระยะห่างแคบ + ป้ายตัวเล็ก */
22
+ size?: "sm" | "default";
23
+ id?: string;
24
+ className?: string;
25
+ }
26
+
27
+ /**
28
+ * ช่องเลื่อนพร้อมป้ายชื่อและค่าปัจจุบันมุมขวา (ตั้งค่าที่เป็นตัวเลขต่อเนื่อง เช่น ความละเอียด/ความทึบ)
29
+ * — ยกจาก Pi PDF; ค่าเป็น number เดี่ยว (ช่วงค่าให้ใช้ `Slider` ตรง ๆ)
30
+ */
31
+ export function SliderField({
32
+ label,
33
+ value,
34
+ onValueChange,
35
+ min = 0,
36
+ max = 100,
37
+ step = 1,
38
+ unit = "",
39
+ formatValue,
40
+ description,
41
+ disabled = false,
42
+ size = "default",
43
+ id,
44
+ className,
45
+ }: SliderFieldProps) {
46
+ const generatedId = useId();
47
+ const sliderId = id ?? generatedId;
48
+ return (
49
+ <div
50
+ data-slot="slider-field"
51
+ className={cn(
52
+ "flex flex-col",
53
+ size === "sm" ? "gap-1.5" : "gap-2.5",
54
+ disabled && "pointer-events-none opacity-60",
55
+ className,
56
+ )}
57
+ >
58
+ <div className="flex items-center justify-between gap-4">
59
+ <Label htmlFor={sliderId} className={size === "sm" ? "text-xs" : "font-medium text-sm"}>
60
+ {label}
61
+ </Label>
62
+ <span className="font-medium font-mono text-muted-foreground text-xs tabular-nums">
63
+ {formatValue ? formatValue(value) : `${value}${unit}`}
64
+ </span>
65
+ </div>
66
+ <Slider
67
+ id={sliderId}
68
+ value={value}
69
+ onValueChange={(next) => onValueChange(typeof next === "number" ? next : (next[0] ?? min))}
70
+ min={min}
71
+ max={max}
72
+ step={step}
73
+ disabled={disabled}
74
+ aria-label={typeof label === "string" ? label : undefined}
75
+ />
76
+ {description && <p className="text-muted-foreground text-xs">{description}</p>}
77
+ </div>
78
+ );
79
+ }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { SecretReveal } from "@suphark/ui/components/shared/secret-reveal";
3
4
  import { Button } from "@suphark/ui/components/ui/button";
4
5
  import {
5
6
  Dialog,
@@ -44,6 +45,9 @@ export function FormDialog<TData = undefined>({
44
45
  onOpenChange,
45
46
  keepOpen = false,
46
47
  renderSuccess,
48
+ revealSuccess = false,
49
+ successTitle,
50
+ successDescription,
47
51
  contentClassName,
48
52
  }: {
49
53
  /** ปุ่มเปิด (ต้องเป็น <Button> ของ shadcn — Base UI จะใช้ตัวนี้เป็น trigger โดยตรง) */
@@ -64,6 +68,17 @@ export function FormDialog<TData = undefined>({
64
68
  * ปิดแล้วเปิดใหม่ = ฟอร์มว่าง รอบใหม่ (state เดิมรวม data ถูกทิ้ง ไม่ถูกส่งกลับไปกับ submit ครั้งถัดไป)
65
69
  */
66
70
  renderSuccess?: (state: Extract<FormState<TData>, { ok: true }>) => ReactNode;
71
+ /**
72
+ * แบบไม่ต้องส่งฟังก์ชัน (ใช้จาก **server component** ได้): สำเร็จแล้วถ้า `state.data` เป็น
73
+ * `{ values: Record<string, string> }` จะแสดง `SecretReveal` ให้เอง พร้อมปุ่ม "เสร็จแล้ว"
74
+ * (เคส "สร้างแล้วโชว์ secret ครั้งเดียว" — webhook secret / SCIM token / service account key / client secret)
75
+ * `renderSuccess` เป็นฟังก์ชันจึงข้าม boundary ไม่ได้ (ขอจาก Pi Auth)
76
+ */
77
+ revealSuccess?: boolean;
78
+ /** หัวข้อของ `SecretReveal` เมื่อใช้ `revealSuccess` (ค่าเริ่มต้น `UiText.showOnce`) */
79
+ successTitle?: string;
80
+ /** ข้อความใต้หัวข้อของหน้าจอสำเร็จ (แทน `description` ของฟอร์ม) */
81
+ successDescription?: ReactNode;
67
82
  /** ความกว้าง/คลาสของ DialogContent (ค่าเริ่มต้น sm:max-w-md) */
68
83
  contentClassName?: string;
69
84
  }) {
@@ -84,8 +99,11 @@ export function FormDialog<TData = undefined>({
84
99
  description={description}
85
100
  action={action}
86
101
  submitLabel={submitLabel}
87
- keepOpen={keepOpen || renderSuccess !== undefined}
102
+ keepOpen={keepOpen || renderSuccess !== undefined || revealSuccess}
88
103
  renderSuccess={renderSuccess}
104
+ revealSuccess={revealSuccess}
105
+ successTitle={successTitle}
106
+ successDescription={successDescription}
89
107
  close={() => setOpen(false)}
90
108
  >
91
109
  {children}
@@ -103,6 +121,9 @@ function FormDialogBody<TData>({
103
121
  children,
104
122
  keepOpen,
105
123
  renderSuccess,
124
+ revealSuccess,
125
+ successTitle,
126
+ successDescription,
106
127
  close,
107
128
  }: {
108
129
  title: ReactNode;
@@ -112,6 +133,9 @@ function FormDialogBody<TData>({
112
133
  children: ReactNode | ((ctx: FormRenderContext) => ReactNode);
113
134
  keepOpen: boolean;
114
135
  renderSuccess?: (state: Extract<FormState<TData>, { ok: true }>) => ReactNode;
136
+ revealSuccess?: boolean;
137
+ successTitle?: string;
138
+ successDescription?: ReactNode;
115
139
  close: () => void;
116
140
  }) {
117
141
  const router = useRouter();
@@ -129,15 +153,19 @@ function FormDialogBody<TData>({
129
153
  }
130
154
  }, [state, submitted]);
131
155
 
132
- if (state?.ok && renderSuccess) {
156
+ const revealValues = revealSuccess && state?.ok ? revealableValues(state.data) : null;
157
+
158
+ if (state?.ok && (renderSuccess || revealValues)) {
159
+ const successDesc = successDescription ?? description;
133
160
  return (
134
161
  <div className="flex min-h-0 flex-1 flex-col gap-4">
135
162
  <DialogHeader>
136
163
  <DialogTitle>{title}</DialogTitle>
137
- {description && <DialogDescription>{description}</DialogDescription>}
164
+ {successDesc && <DialogDescription>{successDesc}</DialogDescription>}
138
165
  </DialogHeader>
139
166
  <div className="-mx-1 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-1">
140
- {renderSuccess(state)}
167
+ {renderSuccess ? renderSuccess(state) : null}
168
+ {revealValues && <SecretReveal values={revealValues} title={successTitle} />}
141
169
  </div>
142
170
  <DialogFooter>
143
171
  <Button type="button" onClick={close}>
@@ -179,3 +207,14 @@ function FormDialogBody<TData>({
179
207
  </form>
180
208
  );
181
209
  }
210
+
211
+ /** `data` ของ server action เป็น `{ values: Record<string, string> }` ไหม (ใช้กับ `revealSuccess`) */
212
+ function revealableValues(data: unknown): Record<string, string> | null {
213
+ if (!data || typeof data !== "object" || !("values" in data)) return null;
214
+ const { values } = data as { values: unknown };
215
+ if (!values || typeof values !== "object") return null;
216
+ const entries = Object.entries(values as Record<string, unknown>).filter(
217
+ ([, v]) => typeof v === "string",
218
+ ) as Array<[string, string]>;
219
+ return entries.length > 0 ? Object.fromEntries(entries) : null;
220
+ }
@@ -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,99 @@
1
+ "use client";
2
+
3
+ import { ClientDateFormatter } from "@suphark/ui/components/shared/time/client-date-formatter";
4
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
5
+ import { cn } from "@suphark/ui/lib/utils";
6
+ import { AlertCircle, Check, Loader2 } from "lucide-react";
7
+ import type { ReactNode } from "react";
8
+
9
+ export type SaveState = "idle" | "saving" | "saved" | "error";
10
+
11
+ export interface SaveStatusText {
12
+ saving: string;
13
+ saved: string;
14
+ error: string;
15
+ /** นำหน้าเวลาที่บันทึกล่าสุด เช่น "บันทึกเมื่อ" */
16
+ savedAt: string;
17
+ }
18
+
19
+ const TEXT: Record<"th" | "en", SaveStatusText> = {
20
+ th: { saving: "กำลังบันทึก…", saved: "บันทึกแล้ว", error: "บันทึกไม่สำเร็จ", savedAt: "บันทึกเมื่อ" },
21
+ en: { saving: "Saving…", saved: "Saved", error: "Couldn't save", savedAt: "Saved" },
22
+ };
23
+
24
+ /**
25
+ * สถานะบันทึกอัตโนมัติแบบบรรทัดเดียว (ยกจาก Pi PDF) — วางใน toolbar/หัวหน้า
26
+ * `savedAt` ใส่มาแล้วจะแสดงเวลาสัมพัทธ์ที่อัปเดตเองฝั่ง client (ไม่ทำ hydration mismatch)
27
+ */
28
+ export function SaveStatus({
29
+ state,
30
+ savedAt,
31
+ error,
32
+ language = "th",
33
+ text: textProp,
34
+ className,
35
+ }: {
36
+ state: SaveState;
37
+ /** เวลาที่บันทึกล่าสุด — แสดงเป็นเวลาสัมพัทธ์เมื่อ state = "saved"/"idle" */
38
+ savedAt?: Date | string | number | null;
39
+ /** ข้อความ error ของแอป (ไม่ส่ง = ข้อความเริ่มต้น) */
40
+ error?: ReactNode;
41
+ language?: "th" | "en";
42
+ text?: Partial<SaveStatusText>;
43
+ className?: string;
44
+ }) {
45
+ const ui = useUiText();
46
+ const text: SaveStatusText = { ...TEXT[language], ...textProp };
47
+ const base = cn("inline-flex items-center gap-1.5 text-xs", className);
48
+
49
+ if (state === "saving") {
50
+ return (
51
+ <span
52
+ data-slot="save-status"
53
+ data-state="saving"
54
+ aria-live="polite"
55
+ className={cn(base, "text-muted-foreground")}
56
+ >
57
+ <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
58
+ {text.saving}
59
+ </span>
60
+ );
61
+ }
62
+ if (state === "error") {
63
+ return (
64
+ <span
65
+ data-slot="save-status"
66
+ data-state="error"
67
+ role="alert"
68
+ className={cn(base, "text-destructive")}
69
+ >
70
+ <AlertCircle className="size-3.5" aria-hidden="true" />
71
+ {error ?? ui.actionFailed ?? text.error}
72
+ </span>
73
+ );
74
+ }
75
+ if (state === "saved") {
76
+ return (
77
+ <span
78
+ data-slot="save-status"
79
+ data-state="saved"
80
+ aria-live="polite"
81
+ className={cn(base, "text-success")}
82
+ >
83
+ <Check className="size-3.5" aria-hidden="true" />
84
+ {text.saved}
85
+ </span>
86
+ );
87
+ }
88
+ if (!savedAt) return null;
89
+ return (
90
+ <span data-slot="save-status" data-state="idle" className={cn(base, "text-muted-foreground")}>
91
+ {text.savedAt}{" "}
92
+ <ClientDateFormatter
93
+ date={savedAt}
94
+ formatType="relative"
95
+ locale={language === "en" ? "en-US" : "th-TH"}
96
+ />
97
+ </span>
98
+ );
99
+ }
@@ -0,0 +1,118 @@
1
+ "use client";
2
+
3
+ import { Switch } from "@suphark/ui/components/ui/switch";
4
+ import { cn } from "@suphark/ui/lib/utils";
5
+ import { type ComponentProps, type ReactNode, useId } from "react";
6
+
7
+ /**
8
+ * แถวตั้งค่า: ชื่อ + คำอธิบายด้านซ้าย, control ด้านขวา (จอแคบตกลงมาอยู่ใต้ข้อความ)
9
+ * ใช้เป็นแถวในหน้าตั้งค่า/โซนอันตราย — ยกจาก Pi Auth/Pi PDF ที่เขียน layout เดียวกันซ้ำ
10
+ */
11
+ export function SettingsRow({
12
+ title,
13
+ description,
14
+ children,
15
+ htmlFor,
16
+ tone = "default",
17
+ className,
18
+ }: {
19
+ title: ReactNode;
20
+ description?: ReactNode;
21
+ /** control ด้านขวา (Switch, Button, Select …) */
22
+ children?: ReactNode;
23
+ /** ผูกชื่อแถวกับ control ด้วย id (ทำให้คลิกชื่อแล้วโฟกัส control) */
24
+ htmlFor?: string;
25
+ /** destructive = แถวในโซนอันตราย (ชื่อสีแดง) */
26
+ tone?: "default" | "destructive";
27
+ className?: string;
28
+ }) {
29
+ const Title = htmlFor ? "label" : "span";
30
+ return (
31
+ <div
32
+ data-slot="settings-row"
33
+ className={cn(
34
+ "flex flex-col gap-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-6",
35
+ className,
36
+ )}
37
+ >
38
+ <div className="flex min-w-0 flex-col gap-0.5">
39
+ <Title
40
+ {...(htmlFor ? { htmlFor } : {})}
41
+ className={cn(
42
+ "font-medium text-sm",
43
+ tone === "destructive" && "text-destructive",
44
+ htmlFor && "cursor-pointer",
45
+ )}
46
+ >
47
+ {title}
48
+ </Title>
49
+ {description && <span className="text-muted-foreground text-xs">{description}</span>}
50
+ </div>
51
+ {children && <div className="flex shrink-0 items-center gap-2">{children}</div>}
52
+ </div>
53
+ );
54
+ }
55
+
56
+ /**
57
+ * แถวตั้งค่าแบบเปิด/ปิด — `SettingsRow` + `Switch` ที่ผูก id ให้แล้ว (คลิกชื่อแล้วสลับได้)
58
+ *
59
+ * <SwitchRow title="แจ้งเตือนทางอีเมล" description="ส่งสรุปทุกเช้า" checked={on} onCheckedChange={setOn} />
60
+ */
61
+ export function SwitchRow({
62
+ title,
63
+ description,
64
+ checked,
65
+ onCheckedChange,
66
+ disabled,
67
+ id,
68
+ className,
69
+ ...props
70
+ }: Omit<ComponentProps<typeof Switch>, "title"> & {
71
+ title: ReactNode;
72
+ description?: ReactNode;
73
+ className?: string;
74
+ }) {
75
+ const generatedId = useId();
76
+ const switchId = id ?? generatedId;
77
+ return (
78
+ <SettingsRow title={title} description={description} htmlFor={switchId} className={className}>
79
+ <Switch
80
+ {...props}
81
+ id={switchId}
82
+ checked={checked}
83
+ onCheckedChange={onCheckedChange}
84
+ disabled={disabled}
85
+ />
86
+ </SettingsRow>
87
+ );
88
+ }
89
+
90
+ /**
91
+ * โซนอันตราย (Clerk/GitHub "Danger zone"): กรอบแดงอ่อน + แถวการกระทำที่ย้อนกลับไม่ได้
92
+ * แอปใส่ `SettingsRow tone="destructive"` เป็นลูก (ปุ่มจริงใช้ `ConfirmActionButton`/`useConfirm` ของแพ็กเกจ)
93
+ */
94
+ export function DangerZone({
95
+ title,
96
+ description,
97
+ children,
98
+ className,
99
+ }: {
100
+ title?: ReactNode;
101
+ description?: ReactNode;
102
+ children: ReactNode;
103
+ className?: string;
104
+ }) {
105
+ return (
106
+ <section
107
+ data-slot="danger-zone"
108
+ className={cn(
109
+ "flex flex-col gap-1 rounded-xl border border-destructive/40 bg-destructive-subtle/40 p-4",
110
+ className,
111
+ )}
112
+ >
113
+ {title && <h2 className="font-semibold text-destructive text-sm">{title}</h2>}
114
+ {description && <p className="text-muted-foreground text-xs">{description}</p>}
115
+ <div className="divide-y divide-destructive/20">{children}</div>
116
+ </section>
117
+ );
118
+ }
@@ -0,0 +1,30 @@
1
+ import { cn } from "@suphark/ui/lib/utils";
2
+ import type { ReactNode } from "react";
3
+
4
+ /**
5
+ * ลิงก์ "ข้ามไปเนื้อหา" — element แรกที่ Tab จาก address bar เจอ แล้วกด Enter ข้าม header/เมนูไปที่เนื้อหา
6
+ * มองไม่เห็นจนกว่าจะโฟกัส; `AppShell` ใส่ให้แล้ว (`href="#app-main"`) — ตัวนี้สำหรับหน้าที่ไม่ได้ใช้ AppShell
7
+ * (เช่นหน้า login/เอกสารของ Pi PDF ที่เขียนเอง) — จำไว้ว่าเป้าหมายต้องมี `tabIndex={-1}`
8
+ */
9
+ export function SkipLink({
10
+ href = "#main-content",
11
+ children = "ข้ามไปเนื้อหา",
12
+ className,
13
+ }: {
14
+ href?: string;
15
+ children?: ReactNode;
16
+ className?: string;
17
+ }) {
18
+ return (
19
+ <a
20
+ data-slot="skip-link"
21
+ href={href}
22
+ className={cn(
23
+ "sr-only z-[60] rounded-md bg-background px-3 py-2 font-medium text-sm ring-2 ring-ring focus:not-sr-only focus:fixed focus:top-2 focus:left-2",
24
+ className,
25
+ )}
26
+ >
27
+ {children}
28
+ </a>
29
+ );
30
+ }
@@ -1,26 +1,130 @@
1
1
  import { Card, CardContent } from "@suphark/ui/components/ui/card";
2
2
  import { cn } from "@suphark/ui/lib/utils";
3
+ import { Minus, TrendingDown, TrendingUp } from "lucide-react";
3
4
  import type { ReactNode } from "react";
4
5
 
5
- /** ตัวเลขสรุปบนหน้าภาพรวม/สุขภาพระบบ */
6
+ export type StatTone = "default" | "success" | "warning" | "destructive" | "info";
7
+
8
+ const TONE_TEXT: Record<StatTone, string> = {
9
+ default: "text-foreground",
10
+ success: "text-success",
11
+ warning: "text-warning",
12
+ destructive: "text-destructive",
13
+ info: "text-info",
14
+ };
15
+ const TONE_ICON: Record<StatTone, string> = {
16
+ default: "bg-muted text-muted-foreground",
17
+ success: "bg-success-subtle text-success-subtle-foreground",
18
+ warning: "bg-warning-subtle text-warning-subtle-foreground",
19
+ destructive: "bg-destructive-subtle text-destructive-subtle-foreground",
20
+ info: "bg-info-subtle text-info-subtle-foreground",
21
+ };
22
+
23
+ export interface StatDelta {
24
+ /** ข้อความที่แสดง เช่น "+12%" หรือ "+3 สัปดาห์นี้" */
25
+ value: ReactNode;
26
+ /** ขึ้น = ดี/ไม่ดี ต่างกันไปตามตัวชี้วัด → แอปบอกโทนเอง (ค่าเริ่มต้นดูจาก direction) */
27
+ direction?: "up" | "down" | "flat";
28
+ tone?: StatTone;
29
+ /** ข้อความเทียบ เช่น "จากเดือนก่อน" */
30
+ hint?: ReactNode;
31
+ }
32
+
33
+ const DELTA_ICON = { up: TrendingUp, down: TrendingDown, flat: Minus };
34
+
35
+ /**
36
+ * ตัวเลขสรุปบนหน้าภาพรวม/สุขภาพระบบ — เพิ่มไอคอน, ป้าย และส่วนต่างจากช่วงก่อนได้
37
+ * (Pi Auth มี 17 การ์ดแบบนี้, DueBell มี DeltaBadge ของตัวเอง)
38
+ */
6
39
  export function Stat({
7
40
  label,
8
41
  value,
9
42
  hint,
43
+ icon,
44
+ badge,
45
+ delta,
46
+ tone = "default",
10
47
  className,
11
48
  }: {
12
49
  label: ReactNode;
13
50
  value: ReactNode;
14
51
  hint?: ReactNode;
52
+ /** ไอคอนมุมขวาบน (ใส่กรอบพื้นตาม `tone` ให้) */
53
+ icon?: ReactNode;
54
+ /** ป้ายท้ายป้ายชื่อ เช่น `<StatusBadge>` */
55
+ badge?: ReactNode;
56
+ /** ส่วนต่างจากช่วงก่อน */
57
+ delta?: StatDelta;
58
+ /** โทนของตัวเลขและไอคอน */
59
+ tone?: StatTone;
15
60
  className?: string;
16
61
  }) {
62
+ const direction = delta?.direction ?? "flat";
63
+ const DeltaIcon = DELTA_ICON[direction];
64
+ const deltaTone =
65
+ delta?.tone ??
66
+ (direction === "up" ? "success" : direction === "down" ? "destructive" : "default");
67
+
17
68
  return (
18
- <Card className={cn("gap-1 py-4", className)}>
19
- <CardContent className="flex flex-col gap-1">
20
- <span className="text-xs font-medium text-muted-foreground">{label}</span>
21
- <span className="text-2xl font-semibold tabular-nums tracking-tight">{value}</span>
22
- {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
69
+ <Card data-slot="stat" className={cn("gap-1 py-4", className)}>
70
+ <CardContent className="flex items-start gap-3">
71
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
72
+ <span className="flex items-center gap-1.5 font-medium text-muted-foreground text-xs">
73
+ <span className="truncate">{label}</span>
74
+ {badge}
75
+ </span>
76
+ <span
77
+ className={cn("font-semibold text-2xl tabular-nums tracking-tight", TONE_TEXT[tone])}
78
+ >
79
+ {value}
80
+ </span>
81
+ {delta && (
82
+ <span className="flex items-center gap-1 text-xs">
83
+ <span className={cn("flex items-center gap-0.5 font-medium", TONE_TEXT[deltaTone])}>
84
+ <DeltaIcon className="size-3.5" aria-hidden="true" />
85
+ {delta.value}
86
+ </span>
87
+ {delta.hint && <span className="text-muted-foreground">{delta.hint}</span>}
88
+ </span>
89
+ )}
90
+ {hint && <span className="text-muted-foreground text-xs">{hint}</span>}
91
+ </div>
92
+ {icon && (
93
+ <span
94
+ aria-hidden="true"
95
+ className={cn(
96
+ "grid size-9 shrink-0 place-items-center rounded-lg [&_svg]:size-4",
97
+ TONE_ICON[tone],
98
+ )}
99
+ >
100
+ {icon}
101
+ </span>
102
+ )}
23
103
  </CardContent>
24
104
  </Card>
25
105
  );
26
106
  }
107
+
108
+ /** กริดของ `Stat` — คอลัมน์ตามจำนวนที่ต้องการ (ค่าเริ่มต้น 1 → 2 → 4 ตามความกว้างจอ) */
109
+ export function StatGrid({
110
+ children,
111
+ columns = 4,
112
+ className,
113
+ }: {
114
+ children: ReactNode;
115
+ columns?: 2 | 3 | 4;
116
+ className?: string;
117
+ }) {
118
+ return (
119
+ <div
120
+ data-slot="stat-grid"
121
+ className={cn(
122
+ "grid gap-3 sm:grid-cols-2",
123
+ columns === 2 ? "lg:grid-cols-2" : columns === 3 ? "lg:grid-cols-3" : "lg:grid-cols-4",
124
+ className,
125
+ )}
126
+ >
127
+ {children}
128
+ </div>
129
+ );
130
+ }
@@ -51,6 +51,11 @@ 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 }>;
56
+ /** ConfirmDialog `requireText`: คำนำหน้า/ต่อท้ายช่องพิมพ์ยืนยัน (optional — "พิมพ์" … "เพื่อยืนยัน") */
57
+ confirmTypeToConfirm?: string;
58
+ confirmTypeToConfirmSuffix?: string;
54
59
  }
55
60
 
56
61
  export const UI_TEXT_TH: UiText = {
@@ -85,6 +90,9 @@ export const UI_TEXT_TH: UiText = {
85
90
  theme: { label: "ธีม", light: "สว่าง", dark: "มืด", system: "ตามระบบ" },
86
91
  sectionNav: { tabs: "แท็บย่อย" },
87
92
  search: { placeholder: "ค้นหา…", label: "ค้นหา", clear: "ล้างการค้นหา" },
93
+ language: { label: "ภาษา" },
94
+ confirmTypeToConfirm: "พิมพ์",
95
+ confirmTypeToConfirmSuffix: "เพื่อยืนยัน",
88
96
  };
89
97
 
90
98
  export const UI_TEXT_EN: UiText = {
@@ -119,6 +127,9 @@ export const UI_TEXT_EN: UiText = {
119
127
  theme: { label: "Theme", light: "Light", dark: "Dark", system: "System" },
120
128
  sectionNav: { tabs: "Section tabs" },
121
129
  search: { placeholder: "Search…", label: "Search", clear: "Clear search" },
130
+ language: { label: "Language" },
131
+ confirmTypeToConfirm: "Type",
132
+ confirmTypeToConfirmSuffix: "to confirm",
122
133
  };
123
134
 
124
135
  const UiTextContext = createContext<UiText>(UI_TEXT_TH);
@@ -0,0 +1,61 @@
1
+ import { Initials } from "@suphark/ui/components/shared/initials";
2
+ import { cn } from "@suphark/ui/lib/utils";
3
+ import type { ReactNode } from "react";
4
+
5
+ export interface UserIdentityProps {
6
+ name: string | null | undefined;
7
+ /** บรรทัดรอง — ปกติคืออีเมล */
8
+ email?: ReactNode;
9
+ /** รูปโปรไฟล์ (ไม่มี = ตัวย่อชื่อพร้อมสีพื้นคงที่ต่อชื่อ) */
10
+ src?: string | null;
11
+ /** ต่อท้ายชื่อ เช่น `<StatusBadge>` หรือป้าย "คุณ" */
12
+ badge?: ReactNode;
13
+ /** ใต้บรรทัดอีเมล เช่น "เข้าใช้ล่าสุด 2 ชม.ที่แล้ว" */
14
+ hint?: ReactNode;
15
+ /** sm = ใช้ในแถวตาราง (avatar size-7), default = size-9 */
16
+ size?: "sm" | "default";
17
+ /** ซ่อน avatar (เมื่อแถวมีรูปอยู่แล้ว) */
18
+ hideAvatar?: boolean;
19
+ className?: string;
20
+ }
21
+
22
+ /**
23
+ * ชื่อผู้ใช้ + อีเมล + avatar ในบล็อกเดียว — แถวตาราง, เมนู, การ์ดสมาชิก (Pi Auth เขียนซ้ำ ~15 จุด)
24
+ * ชื่อว่าง = แสดงอีเมลเป็นบรรทัดหลักแทน (ผู้ใช้ที่ยังไม่ตั้งชื่อ)
25
+ */
26
+ export function UserIdentity({
27
+ name,
28
+ email,
29
+ src,
30
+ badge,
31
+ hint,
32
+ size = "default",
33
+ hideAvatar = false,
34
+ className,
35
+ }: UserIdentityProps) {
36
+ const displayName = name?.trim() || null;
37
+ const primary = displayName ?? (typeof email === "string" ? email : null);
38
+ const secondary = displayName ? email : null;
39
+
40
+ return (
41
+ <div data-slot="user-identity" className={cn("flex min-w-0 items-center gap-2", className)}>
42
+ {!hideAvatar && (
43
+ <Initials
44
+ name={displayName ?? (typeof email === "string" ? email : "")}
45
+ src={src}
46
+ className={size === "sm" ? "size-7" : "size-9"}
47
+ />
48
+ )}
49
+ <div className="flex min-w-0 flex-col">
50
+ <span className="flex items-center gap-1.5">
51
+ <span className={cn("truncate font-medium", size === "sm" ? "text-sm" : "text-sm")}>
52
+ {primary ?? "—"}
53
+ </span>
54
+ {badge}
55
+ </span>
56
+ {secondary && <span className="truncate text-muted-foreground text-xs">{secondary}</span>}
57
+ {hint && <span className="truncate text-muted-foreground text-xs">{hint}</span>}
58
+ </div>
59
+ </div>
60
+ );
61
+ }
@@ -55,6 +55,8 @@ export function useConfirm(): {
55
55
  confirmLabel={request.options.confirmLabel}
56
56
  cancelLabel={request.options.cancelLabel}
57
57
  destructive={request.options.destructive}
58
+ requireText={request.options.requireText}
59
+ requireTextLabel={request.options.requireTextLabel}
58
60
  onConfirm={() => settle(true)}
59
61
  />
60
62
  ) : null;
@@ -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
@@ -37,6 +37,7 @@ export {
37
37
  type ConfirmSpec,
38
38
  normalizeConfirm,
39
39
  } from "./components/shared/confirm-dialog";
40
+ export { CountBadge } from "./components/shared/count-badge";
40
41
  export { DataList, DataRow } from "./components/shared/data-list";
41
42
  export { DateRangePicker } from "./components/shared/date/date-range-picker";
42
43
  export { DescriptionList } from "./components/shared/description-list";
@@ -84,6 +85,7 @@ export {
84
85
  type SimpleSelectOptions,
85
86
  type SimpleSelectProps,
86
87
  } from "./components/shared/form/simple-select";
88
+ export { SliderField, type SliderFieldProps } from "./components/shared/form/slider-field";
87
89
  export {
88
90
  type FormAction,
89
91
  FormMessage,
@@ -98,6 +100,14 @@ export {
98
100
  } from "./components/shared/icon-picker";
99
101
  export { InfoHint } from "./components/shared/info-hint";
100
102
  export { Initials, initialsOf } from "./components/shared/initials";
103
+ export {
104
+ LanguageMenuItems,
105
+ type LanguageOption,
106
+ } from "./components/shared/language/language-menu-items";
107
+ export {
108
+ LanguageSwitch,
109
+ type LanguageSwitchProps,
110
+ } from "./components/shared/language/language-switch";
101
111
  export { ErrorState, type RouteError } from "./components/shared/layout/error-state";
102
112
  export {
103
113
  MultiSelectCombobox,
@@ -128,10 +138,13 @@ export {
128
138
  type RuleLeaf,
129
139
  type RuleNot,
130
140
  } from "./components/shared/rule-builder";
141
+ export { type SaveState, SaveStatus, type SaveStatusText } from "./components/shared/save-status";
131
142
  export { SecretReveal } from "./components/shared/secret-reveal";
132
143
  export { SectionCard } from "./components/shared/section-card";
133
144
  export { SelectionButton } from "./components/shared/selection-button";
134
- export { Stat } from "./components/shared/stat";
145
+ export { DangerZone, SettingsRow, SwitchRow } from "./components/shared/settings-row";
146
+ export { SkipLink } from "./components/shared/skip-link";
147
+ export { Stat, type StatDelta, StatGrid, type StatTone } from "./components/shared/stat";
135
148
  export { StatusBadge, statusTone, type Tone } from "./components/shared/status-badge";
136
149
  export { SwitcherChevron } from "./components/shared/switcher-chevron";
137
150
  export { CodeBlock, type CodeBlockProps } from "./components/shared/text/code-block";
@@ -156,6 +169,7 @@ export {
156
169
  type UploadListText,
157
170
  type UploadStatus,
158
171
  } from "./components/shared/upload-list";
172
+ export { UserIdentity, type UserIdentityProps } from "./components/shared/user-identity";
159
173
  // ---------- shadcn/ui primitives ----------
160
174
  export * from "./components/ui/accordion";
161
175
  export * from "./components/ui/alert";