@suphark/ui 0.18.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,23 @@
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
+
5
22
  ## [0.18.0] — 2026-09-30
6
23
 
7
24
  > 0.17.0 ถูก publish ตอนที่ `LanguageSwitch` ยังไม่เข้า (commit ถัดไป) — ของชิ้นนี้จึงออกเป็น 0.18.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.18.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
+ }
@@ -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,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
+ }
@@ -53,6 +53,9 @@ export interface UiText {
53
53
  search?: Partial<{ placeholder: string; label: string; clear: string }>;
54
54
  /** LanguageSwitch/LanguageMenuItems: หัวกลุ่ม + aria-label ปุ่ม (optional — "ภาษา") */
55
55
  language?: Partial<{ label: string }>;
56
+ /** ConfirmDialog `requireText`: คำนำหน้า/ต่อท้ายช่องพิมพ์ยืนยัน (optional — "พิมพ์" … "เพื่อยืนยัน") */
57
+ confirmTypeToConfirm?: string;
58
+ confirmTypeToConfirmSuffix?: string;
56
59
  }
57
60
 
58
61
  export const UI_TEXT_TH: UiText = {
@@ -88,6 +91,8 @@ export const UI_TEXT_TH: UiText = {
88
91
  sectionNav: { tabs: "แท็บย่อย" },
89
92
  search: { placeholder: "ค้นหา…", label: "ค้นหา", clear: "ล้างการค้นหา" },
90
93
  language: { label: "ภาษา" },
94
+ confirmTypeToConfirm: "พิมพ์",
95
+ confirmTypeToConfirmSuffix: "เพื่อยืนยัน",
91
96
  };
92
97
 
93
98
  export const UI_TEXT_EN: UiText = {
@@ -123,6 +128,8 @@ export const UI_TEXT_EN: UiText = {
123
128
  sectionNav: { tabs: "Section tabs" },
124
129
  search: { placeholder: "Search…", label: "Search", clear: "Clear search" },
125
130
  language: { label: "Language" },
131
+ confirmTypeToConfirm: "Type",
132
+ confirmTypeToConfirmSuffix: "to confirm",
126
133
  };
127
134
 
128
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;
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,
@@ -136,10 +138,13 @@ export {
136
138
  type RuleLeaf,
137
139
  type RuleNot,
138
140
  } from "./components/shared/rule-builder";
141
+ export { type SaveState, SaveStatus, type SaveStatusText } from "./components/shared/save-status";
139
142
  export { SecretReveal } from "./components/shared/secret-reveal";
140
143
  export { SectionCard } from "./components/shared/section-card";
141
144
  export { SelectionButton } from "./components/shared/selection-button";
142
- 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";
143
148
  export { StatusBadge, statusTone, type Tone } from "./components/shared/status-badge";
144
149
  export { SwitcherChevron } from "./components/shared/switcher-chevron";
145
150
  export { CodeBlock, type CodeBlockProps } from "./components/shared/text/code-block";
@@ -164,6 +169,7 @@ export {
164
169
  type UploadListText,
165
170
  type UploadStatus,
166
171
  } from "./components/shared/upload-list";
172
+ export { UserIdentity, type UserIdentityProps } from "./components/shared/user-identity";
167
173
  // ---------- shadcn/ui primitives ----------
168
174
  export * from "./components/ui/accordion";
169
175
  export * from "./components/ui/alert";