@suphark/ui 0.4.1 → 0.4.2

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,21 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.4.2] — 2026-09-19
6
+
7
+ ### Added
8
+
9
+ - **`ConfirmDialog`** (barrel) — กล่องยืนยันมาตรฐานบน `AlertDialog` ของแพ็กเกจ ใช้แทน `window.confirm` (webview/automation บางตัวปิด window.confirm ทิ้ง กดแล้วไม่เกิดอะไร และไม่เข้า design system): controlled `open`/`onOpenChange`, `title?` (ค่าเริ่มต้น `UiText.confirmTitle`), `description`, `confirmLabel?` (ค่าเริ่มต้น `UiText.confirm`), `cancelLabel?`, `destructive` (ปุ่มยืนยันสีแดง), `pending` (ปุ่มหมุน + ปิดไม่ได้ชั่วคราว), `onConfirm`; พร้อม type `ConfirmSpec` (= `string | { title?, description, confirmLabel?, cancelLabel? }`) และ `normalizeConfirm()`
10
+ - **`ConfirmActionButton`** (`@suphark/ui/next`) — ปุ่มเดี่ยว + ConfirmDialog + server action ในตัว (`confirm`, `action`, `data`, `onDone?` + props ของ ActionButton เช่น `variant`/`icon`/`permission`) รอ action เสร็จแล้ว `router.refresh()` — ใช้แทน `<form action>` + window.confirm ที่ Pi Auth มี (ชื่อไม่ใช้ `ConfirmButton` เพราะมีปุ่มธรรมดาชื่อนั้นอยู่แล้วใน standard buttons)
11
+ - `UiText.confirm` / `UiText.confirmTitle` (optional มีค่าเริ่มต้นไทย/อังกฤษ) — แอปที่ส่ง UiText เองไม่ต้องเพิ่ม
12
+ - export type `RowActionItem` (รายการที่ไม่ใช่ separator)
13
+
14
+ ### Changed
15
+
16
+ - **`RowActions` / `RowMenu` prop `confirm`** เปิด `ConfirmDialog` แทน `window.confirm` (ขอจาก Pi Auth ผ่าน Pi Mail) — รับ `ConfirmSpec`: string เดิมยังใช้ได้ (= description, title/ปุ่มค่าเริ่มต้น) หรือ object กำหนด `title`/`confirmLabel`/`cancelLabel`; ปุ่มยืนยันสีแดงเมื่อ `destructive`; dropdown ปิดก่อน dialog เปิด และ dialog render นอก DropdownMenu (กัน focus trap ของ Base UI ชนกัน); กดยืนยันแล้วค่อยรัน `href`/`onSelect`/`action` ใน transition เหมือนเดิม — โค้ดเรียกเดิมไม่ต้องแก้
17
+ - ไม่มี `window.confirm` เหลือในแพ็กเกจ
18
+ - showcase: หน้า `/components/confirm-dialog` (ConfirmDialog + ConfirmActionButton) และ `/components/row-actions` ใช้ confirm แบบ object
19
+
5
20
  ## [0.4.1] — 2026-09-19
6
21
 
7
22
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
@@ -0,0 +1,73 @@
1
+ "use client";
2
+
3
+ import {
4
+ ActionButton,
5
+ type ActionButtonProps,
6
+ } from "@suphark/ui/components/shared/button/base/action-button";
7
+ import {
8
+ ConfirmDialog,
9
+ type ConfirmSpec,
10
+ normalizeConfirm,
11
+ } from "@suphark/ui/components/shared/confirm-dialog";
12
+ import { useRouter } from "next/navigation";
13
+ import { useState, useTransition } from "react";
14
+
15
+ /**
16
+ * ปุ่มเดี่ยวที่ถามยืนยัน (ConfirmDialog) ก่อนเรียก server action — ใช้แทน `<form action>` + window.confirm
17
+ * `confirm` เหมือน `RowAction.confirm`; `action` + `data` เหมือน RowAction; refresh หน้าเมื่อเสร็จ
18
+ * ต้องการ next/navigation → export จาก `@suphark/ui/next`
19
+ */
20
+ export function ConfirmActionButton({
21
+ confirm,
22
+ action,
23
+ data,
24
+ onDone,
25
+ variant,
26
+ children,
27
+ ...props
28
+ }: Omit<ActionButtonProps, "onClick" | "isLoading" | "type"> & {
29
+ confirm: ConfirmSpec;
30
+ action: (formData: FormData) => Promise<unknown>;
31
+ data?: Record<string, string>;
32
+ /** เรียกหลัง action สำเร็จ (ก่อน refresh) เช่น toast */
33
+ onDone?: () => void;
34
+ }) {
35
+ const router = useRouter();
36
+ const [open, setOpen] = useState(false);
37
+ const [pending, start] = useTransition();
38
+ const spec = normalizeConfirm(confirm);
39
+ const destructive = variant === "destructive";
40
+ return (
41
+ <>
42
+ <ActionButton
43
+ type="button"
44
+ variant={variant}
45
+ isLoading={pending}
46
+ onClick={() => setOpen(true)}
47
+ {...props}
48
+ >
49
+ {children}
50
+ </ActionButton>
51
+ <ConfirmDialog
52
+ open={open}
53
+ onOpenChange={setOpen}
54
+ title={spec.title}
55
+ description={spec.description}
56
+ confirmLabel={spec.confirmLabel}
57
+ cancelLabel={spec.cancelLabel}
58
+ destructive={destructive}
59
+ pending={pending}
60
+ onConfirm={() => {
61
+ const fd = new FormData();
62
+ for (const [k, v] of Object.entries(data ?? {})) fd.set(k, v);
63
+ start(async () => {
64
+ await action(fd);
65
+ onDone?.();
66
+ setOpen(false);
67
+ router.refresh();
68
+ });
69
+ }}
70
+ />
71
+ </>
72
+ );
73
+ }
@@ -0,0 +1,81 @@
1
+ "use client";
2
+
3
+ import {
4
+ AlertDialog,
5
+ AlertDialogAction,
6
+ AlertDialogCancel,
7
+ AlertDialogContent,
8
+ AlertDialogDescription,
9
+ AlertDialogFooter,
10
+ AlertDialogHeader,
11
+ AlertDialogTitle,
12
+ } from "@suphark/ui/components/ui/alert-dialog";
13
+ import { Loader2 } from "lucide-react";
14
+ import type { ReactNode } from "react";
15
+ import { useUiText } from "./ui-text";
16
+
17
+ /** สเปกการยืนยัน — string = description อย่างเดียว (title/ปุ่มใช้ค่าเริ่มต้นจาก UiText) */
18
+ export type ConfirmSpec =
19
+ | string
20
+ | {
21
+ title?: ReactNode;
22
+ description: ReactNode;
23
+ confirmLabel?: string;
24
+ cancelLabel?: string;
25
+ };
26
+
27
+ /** แปลง ConfirmSpec เป็น object เสมอ */
28
+ export function normalizeConfirm(spec: ConfirmSpec): Exclude<ConfirmSpec, string> {
29
+ return typeof spec === "string" ? { description: spec } : spec;
30
+ }
31
+
32
+ /**
33
+ * กล่องยืนยันมาตรฐานของแพ็กเกจ (AlertDialog) — ใช้แทน `window.confirm` ทุกที่
34
+ * (webview/automation บางตัวปิด window.confirm ทิ้ง กดแล้วไม่เกิดอะไร และไม่เข้า design system)
35
+ * controlled: `open`/`onOpenChange`; `pending` = กำลังรัน action → ปุ่มยืนยันหมุน ปิดไม่ได้ชั่วคราว
36
+ */
37
+ export function ConfirmDialog({
38
+ open,
39
+ onOpenChange,
40
+ title,
41
+ description,
42
+ confirmLabel,
43
+ cancelLabel,
44
+ destructive = false,
45
+ pending = false,
46
+ onConfirm,
47
+ }: {
48
+ open: boolean;
49
+ onOpenChange: (open: boolean) => void;
50
+ title?: ReactNode;
51
+ description: ReactNode;
52
+ confirmLabel?: string;
53
+ cancelLabel?: string;
54
+ /** ปุ่มยืนยันสีแดง (ลบ/เพิกถอน) */
55
+ destructive?: boolean;
56
+ pending?: boolean;
57
+ onConfirm: () => void;
58
+ }) {
59
+ const text = useUiText();
60
+ return (
61
+ <AlertDialog open={open} onOpenChange={(o) => !pending && onOpenChange(o)}>
62
+ <AlertDialogContent>
63
+ <AlertDialogHeader>
64
+ <AlertDialogTitle>{title ?? text.confirmTitle ?? "ยืนยันการทำรายการ"}</AlertDialogTitle>
65
+ <AlertDialogDescription>{description}</AlertDialogDescription>
66
+ </AlertDialogHeader>
67
+ <AlertDialogFooter>
68
+ <AlertDialogCancel disabled={pending}>{cancelLabel ?? text.cancel}</AlertDialogCancel>
69
+ <AlertDialogAction
70
+ variant={destructive ? "destructive" : "default"}
71
+ disabled={pending}
72
+ onClick={onConfirm}
73
+ >
74
+ {pending && <Loader2 className="animate-spin" aria-hidden="true" />}
75
+ {confirmLabel ?? text.confirm ?? "ยืนยัน"}
76
+ </AlertDialogAction>
77
+ </AlertDialogFooter>
78
+ </AlertDialogContent>
79
+ </AlertDialog>
80
+ );
81
+ }
@@ -11,83 +11,110 @@ import {
11
11
  import { MoreHorizontalIcon } from "lucide-react";
12
12
  import { useRouter } from "next/navigation";
13
13
  import { useState, useTransition } from "react";
14
+ import { ConfirmDialog, type ConfirmSpec, normalizeConfirm } from "./confirm-dialog";
14
15
  import { useUiText } from "./ui-text";
15
16
 
16
- export type RowAction =
17
- | {
18
- type?: "item";
19
- label: string;
20
- href?: string;
21
- onSelect?: () => void;
22
- destructive?: boolean;
23
- confirm?: string;
24
- action?: (formData: FormData) => Promise<unknown>;
25
- data?: Record<string, string>;
26
- disabled?: boolean;
27
- }
28
- | { type: "separator" };
17
+ export type RowActionItem = {
18
+ type?: "item";
19
+ label: string;
20
+ href?: string;
21
+ onSelect?: () => void;
22
+ destructive?: boolean;
23
+ /** ถามยืนยันก่อน (ConfirmDialog ของแพ็กเกจ — ไม่ใช่ window.confirm): string = ข้อความ หรือ object กำหนด title/ปุ่ม */
24
+ confirm?: ConfirmSpec;
25
+ action?: (formData: FormData) => Promise<unknown>;
26
+ data?: Record<string, string>;
27
+ disabled?: boolean;
28
+ };
29
+
30
+ export type RowAction = RowActionItem | { type: "separator" };
29
31
 
30
32
  /**
31
33
  * เมนู "…" ท้ายแถว — รวมการกระทำรองไว้ที่เดียวแทนปุ่ม/ฟอร์มหลายอันในแถว
32
34
  * รายการเรียก server action ได้โดยตรง (`action` + `data`) หรือไปหน้าอื่น (`href`) หรือเปิด dialog (`onSelect`)
33
- * `confirm` = ถามยืนยันก่อน (ใช้กับสิ่งที่ย้อนกลับไม่ได้)
35
+ * `confirm` = ถามยืนยันก่อนด้วย ConfirmDialog (ใช้กับสิ่งที่ย้อนกลับไม่ได้) — dialog render นอก DropdownMenu กัน focus trap ชนกัน
34
36
  */
35
37
  export function RowActions({ items, label }: { items: RowAction[]; label?: string }) {
36
38
  const router = useRouter();
37
39
  const text = useUiText();
38
40
  const [pending, start] = useTransition();
39
41
  const [open, setOpen] = useState(false);
42
+ const [confirming, setConfirming] = useState<RowActionItem | null>(null);
43
+
44
+ function run(it: RowActionItem) {
45
+ if (it.href) {
46
+ router.push(it.href);
47
+ return;
48
+ }
49
+ if (it.onSelect) {
50
+ it.onSelect();
51
+ return;
52
+ }
53
+ if (it.action) {
54
+ const fd = new FormData();
55
+ for (const [k, v] of Object.entries(it.data ?? {})) fd.set(k, v);
56
+ const act = it.action;
57
+ start(async () => {
58
+ await act(fd);
59
+ router.refresh();
60
+ });
61
+ }
62
+ }
63
+
64
+ const spec = confirming?.confirm ? normalizeConfirm(confirming.confirm) : null;
40
65
  return (
41
- <DropdownMenu open={open} onOpenChange={setOpen}>
42
- <DropdownMenuTrigger
43
- render={
44
- <Button
45
- variant="ghost"
46
- size="icon-sm"
47
- aria-label={label ?? text.actions}
48
- disabled={pending}
49
- />
50
- }
51
- >
52
- <MoreHorizontalIcon />
53
- </DropdownMenuTrigger>
54
- <DropdownMenuContent align="end" className="min-w-52">
55
- {items.map((it, i) =>
56
- it.type === "separator" ? (
57
- // biome-ignore lint/suspicious/noArrayIndexKey: separator ไม่มี identity
58
- <DropdownMenuSeparator key={`sep-${i}`} />
59
- ) : (
60
- <DropdownMenuItem
61
- key={it.label}
62
- variant={it.destructive ? "destructive" : "default"}
63
- disabled={it.disabled}
64
- onClick={() => {
65
- setOpen(false);
66
- if (it.confirm && !window.confirm(it.confirm)) return;
67
- if (it.href) {
68
- router.push(it.href);
69
- return;
70
- }
71
- if (it.onSelect) {
72
- it.onSelect();
73
- return;
74
- }
75
- if (it.action) {
76
- const fd = new FormData();
77
- for (const [k, v] of Object.entries(it.data ?? {})) fd.set(k, v);
78
- const run = it.action;
79
- start(async () => {
80
- await run(fd);
81
- router.refresh();
82
- });
83
- }
84
- }}
85
- >
86
- {it.label}
87
- </DropdownMenuItem>
88
- ),
89
- )}
90
- </DropdownMenuContent>
91
- </DropdownMenu>
66
+ <>
67
+ <DropdownMenu open={open} onOpenChange={setOpen}>
68
+ <DropdownMenuTrigger
69
+ render={
70
+ <Button
71
+ variant="ghost"
72
+ size="icon-sm"
73
+ aria-label={label ?? text.actions}
74
+ disabled={pending}
75
+ />
76
+ }
77
+ >
78
+ <MoreHorizontalIcon />
79
+ </DropdownMenuTrigger>
80
+ <DropdownMenuContent align="end" className="min-w-52">
81
+ {items.map((it, i) =>
82
+ it.type === "separator" ? (
83
+ // biome-ignore lint/suspicious/noArrayIndexKey: separator ไม่มี identity
84
+ <DropdownMenuSeparator key={`sep-${i}`} />
85
+ ) : (
86
+ <DropdownMenuItem
87
+ key={it.label}
88
+ variant={it.destructive ? "destructive" : "default"}
89
+ disabled={it.disabled}
90
+ onClick={() => {
91
+ setOpen(false);
92
+ if (it.confirm) setConfirming(it);
93
+ else run(it);
94
+ }}
95
+ >
96
+ {it.label}
97
+ </DropdownMenuItem>
98
+ ),
99
+ )}
100
+ </DropdownMenuContent>
101
+ </DropdownMenu>
102
+ {spec && confirming && (
103
+ <ConfirmDialog
104
+ open
105
+ onOpenChange={(o) => !o && setConfirming(null)}
106
+ title={spec.title}
107
+ description={spec.description}
108
+ confirmLabel={spec.confirmLabel}
109
+ cancelLabel={spec.cancelLabel}
110
+ destructive={confirming.destructive}
111
+ onConfirm={() => {
112
+ const it = confirming;
113
+ setConfirming(null);
114
+ run(it);
115
+ }}
116
+ />
117
+ )}
118
+ </>
92
119
  );
93
120
  }
@@ -14,6 +14,10 @@ export interface UiText {
14
14
  success: string;
15
15
  /** EnvSnippet: คัดลอกอัตโนมัติไม่ได้ (optional — มีค่าเริ่มต้นในตัว) */
16
16
  copyFailed?: string;
17
+ /** ConfirmDialog: ปุ่มยืนยัน (optional — ค่าเริ่มต้น "ยืนยัน") */
18
+ confirm?: string;
19
+ /** ConfirmDialog: หัวกล่อง (optional — ค่าเริ่มต้น "ยืนยันการทำรายการ") */
20
+ confirmTitle?: string;
17
21
  }
18
22
 
19
23
  export const UI_TEXT_TH: UiText = {
@@ -26,6 +30,8 @@ export const UI_TEXT_TH: UiText = {
26
30
  showOnce: "แสดงครั้งเดียว — คัดลอกเก็บไว้ทันที",
27
31
  success: "สำเร็จ",
28
32
  copyFailed: "คัดลอกอัตโนมัติไม่ได้ — เลือกข้อความให้แล้ว กด Ctrl+C / ⌘C",
33
+ confirm: "ยืนยัน",
34
+ confirmTitle: "ยืนยันการทำรายการ",
29
35
  };
30
36
 
31
37
  export const UI_TEXT_EN: UiText = {
@@ -38,6 +44,8 @@ export const UI_TEXT_EN: UiText = {
38
44
  showOnce: "Shown once — copy it now",
39
45
  success: "Done",
40
46
  copyFailed: "Automatic copy is blocked — text selected, press Ctrl+C / ⌘C",
47
+ confirm: "Confirm",
48
+ confirmTitle: "Please confirm",
41
49
  };
42
50
 
43
51
  const UiTextContext = createContext<UiText>(UI_TEXT_TH);
package/src/index.ts CHANGED
@@ -16,6 +16,11 @@ export { toast } from "sonner";
16
16
  export * from "./components/shared/button";
17
17
  export { ClientRedirect } from "./components/shared/client-redirect";
18
18
  export { Combobox } from "./components/shared/combobox";
19
+ export {
20
+ ConfirmDialog,
21
+ type ConfirmSpec,
22
+ normalizeConfirm,
23
+ } from "./components/shared/confirm-dialog";
19
24
  export { DataList, DataRow } from "./components/shared/data-list";
20
25
  export { DateRangePicker } from "./components/shared/date/date-range-picker";
21
26
  export { DescriptionList } from "./components/shared/description-list";
package/src/next.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // ---------- app shell (sidebar + header + เนื้อหา) ----------
7
7
  export * from "./components/app-shell";
8
8
  export { LinkButton, type LinkButtonProps } from "./components/shared/button/base/link-button";
9
+ export { ConfirmActionButton } from "./components/shared/button/confirm-action-button";
9
10
  export { RefreshButton } from "./components/shared/button/refresh-button";
10
11
  export { FormDialog } from "./components/shared/form-dialog";
11
12
  export { PageShell } from "./components/shared/layout/page-shell";
@@ -14,7 +15,7 @@ export {
14
15
  PageBreadcrumb,
15
16
  type PageBreadcrumbItem,
16
17
  } from "./components/shared/navigation/page-breadcrumb";
17
- export { type RowAction, RowActions } from "./components/shared/row-actions";
18
+ export { type RowAction, type RowActionItem, RowActions } from "./components/shared/row-actions";
18
19
  export { RowMenu, type RowMenuDialog, type RowMenuItem } from "./components/shared/row-menu";
19
20
  export {
20
21
  TABLE_PARAM,