@suphark/ui 0.19.0 → 0.20.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +14 -13
  3. package/scripts/audit.mjs +0 -0
  4. package/src/components/app-shell/app-shell.tsx +12 -0
  5. package/src/components/app-shell/index.ts +1 -0
  6. package/src/components/app-shell/mobile-tab-bar.tsx +104 -0
  7. package/src/components/shared/action-menu.tsx +155 -0
  8. package/src/components/shared/filter-bar.tsx +108 -0
  9. package/src/components/shared/month-stepper.tsx +124 -0
  10. package/src/components/shared/segmented-control.tsx +73 -0
  11. package/src/components/shared/settings-layout.tsx +170 -0
  12. package/src/components/shared/setup-checklist.tsx +104 -0
  13. package/src/components/shared/simple-dialog.tsx +172 -0
  14. package/src/components/shared/simple-pagination.tsx +109 -0
  15. package/src/components/shared/simple-table.tsx +93 -0
  16. package/src/components/shared/simple-tabs.tsx +118 -0
  17. package/src/components/shared/simple-tooltip.tsx +87 -0
  18. package/src/components/shared/ui-text.tsx +28 -0
  19. package/src/components/ui/accordion.tsx +2 -2
  20. package/src/components/ui/alert-dialog.tsx +4 -4
  21. package/src/components/ui/alert.tsx +2 -2
  22. package/src/components/ui/attachment.tsx +9 -9
  23. package/src/components/ui/avatar.tsx +4 -4
  24. package/src/components/ui/badge.tsx +1 -1
  25. package/src/components/ui/breadcrumb.tsx +1 -1
  26. package/src/components/ui/bubble.tsx +123 -0
  27. package/src/components/ui/button-group.tsx +2 -2
  28. package/src/components/ui/button.tsx +6 -6
  29. package/src/components/ui/calendar.tsx +9 -9
  30. package/src/components/ui/card.tsx +3 -3
  31. package/src/components/ui/chart.tsx +2 -2
  32. package/src/components/ui/checkbox.tsx +1 -1
  33. package/src/components/ui/combobox.tsx +6 -6
  34. package/src/components/ui/command.tsx +4 -4
  35. package/src/components/ui/context-menu.tsx +7 -7
  36. package/src/components/ui/dialog.tsx +4 -4
  37. package/src/components/ui/direction.tsx +6 -0
  38. package/src/components/ui/drawer.tsx +10 -10
  39. package/src/components/ui/dropdown-menu.tsx +8 -8
  40. package/src/components/ui/empty.tsx +4 -4
  41. package/src/components/ui/field.tsx +8 -8
  42. package/src/components/ui/hover-card.tsx +1 -2
  43. package/src/components/ui/input-group.tsx +3 -3
  44. package/src/components/ui/input-otp.tsx +1 -1
  45. package/src/components/ui/input.tsx +1 -1
  46. package/src/components/ui/item.tsx +4 -4
  47. package/src/components/ui/kbd.tsx +1 -1
  48. package/src/components/ui/label.tsx +1 -1
  49. package/src/components/ui/marker.tsx +67 -0
  50. package/src/components/ui/menubar.tsx +9 -9
  51. package/src/components/ui/message-scroller.tsx +129 -0
  52. package/src/components/ui/message.tsx +84 -0
  53. package/src/components/ui/native-select.tsx +1 -1
  54. package/src/components/ui/navigation-menu.tsx +6 -6
  55. package/src/components/ui/popover.tsx +1 -1
  56. package/src/components/ui/progress.tsx +2 -3
  57. package/src/components/ui/questionnaire.tsx +314 -0
  58. package/src/components/ui/radio-group.tsx +1 -2
  59. package/src/components/ui/resizable.tsx +1 -1
  60. package/src/components/ui/select.tsx +4 -4
  61. package/src/components/ui/separator.tsx +0 -1
  62. package/src/components/ui/sheet.tsx +3 -3
  63. package/src/components/ui/sidebar.tsx +14 -14
  64. package/src/components/ui/switch.tsx +2 -3
  65. package/src/components/ui/textarea.tsx +1 -1
  66. package/src/components/ui/toast.tsx +221 -0
  67. package/src/components/ui/toggle-group.tsx +2 -2
  68. package/src/components/ui/toggle.tsx +1 -1
  69. package/src/components/ui/tooltip.tsx +0 -1
  70. package/src/index.ts +41 -0
  71. package/src/lib/month.ts +60 -0
  72. package/src/styles.css +10 -0
@@ -0,0 +1,124 @@
1
+ "use client";
2
+
3
+ import { Button } from "@suphark/ui/components/ui/button";
4
+ import {
5
+ compareMonth,
6
+ currentMonthKey,
7
+ formatMonthKey,
8
+ type MonthKey,
9
+ shiftMonth,
10
+ } from "@suphark/ui/lib/month";
11
+ import { cn } from "@suphark/ui/lib/utils";
12
+ import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
13
+ import { useCallback, useState } from "react";
14
+ import { useUiText } from "./ui-text";
15
+
16
+ export interface MonthStepperProps {
17
+ /** เดือนที่เลือก `"YYYY-MM"` */
18
+ value: MonthKey;
19
+ onValueChange: (value: MonthKey) => void;
20
+ /** เดือนแรก/สุดท้ายที่เลือกได้ (ปุ่ม ‹ › ปิดเมื่อถึงขอบ) */
21
+ min?: MonthKey;
22
+ max?: MonthKey;
23
+ /** `th-TH` = พ.ศ. (ค่าเริ่มต้น), `th-TH-u-ca-gregory` = ไทย ค.ศ., `en-US` */
24
+ locale?: string;
25
+ /** ชื่อเดือนเต็ม/ย่อ (มือถือแคบ ๆ ใช้ short) */
26
+ format?: "long" | "short";
27
+ /** ข้อความกลางเอง (เช่น "รอบบิล ต.ค. 69") — ไม่ส่ง = ชื่อเดือนตาม locale */
28
+ formatLabel?: (value: MonthKey) => string;
29
+ /** กดชื่อเดือนแล้วกลับเดือนปัจจุบัน (ค่าเริ่มต้น true) */
30
+ resetToCurrent?: boolean;
31
+ className?: string;
32
+ }
33
+
34
+ /**
35
+ * เลือกเดือนแบบ ‹ เดือน › — กดชื่อเดือนเพื่อกลับเดือนนี้ (ยกจาก DueBell `MonthSelector`)
36
+ * ใช้คู่กับ `CalendarMonthGrid month` ได้เลย (รูปแบบ `"YYYY-MM"` เดียวกัน)
37
+ *
38
+ * const { month, setMonth } = useMonthCursor();
39
+ * <MonthStepper value={month} onValueChange={setMonth} />
40
+ */
41
+ export function MonthStepper({
42
+ value,
43
+ onValueChange,
44
+ min,
45
+ max,
46
+ locale = "th-TH",
47
+ format = "long",
48
+ formatLabel,
49
+ resetToCurrent = true,
50
+ className,
51
+ }: MonthStepperProps) {
52
+ const text = useUiText().month;
53
+ const prev = shiftMonth(value, -1);
54
+ const next = shiftMonth(value, 1);
55
+ const canPrev = !min || compareMonth(prev, min) >= 0;
56
+ const canNext = !max || compareMonth(next, max) <= 0;
57
+ const now = currentMonthKey();
58
+ const nowInRange = (!min || compareMonth(now, min) >= 0) && (!max || compareMonth(now, max) <= 0);
59
+ const label = formatLabel ? formatLabel(value) : formatMonthKey(value, locale, format);
60
+ const isCurrent = value === now;
61
+
62
+ return (
63
+ <div
64
+ data-slot="month-stepper"
65
+ className={cn("inline-flex items-center gap-1 rounded-lg border bg-card p-1", className)}
66
+ >
67
+ <Button
68
+ type="button"
69
+ variant="ghost"
70
+ size="icon"
71
+ disabled={!canPrev}
72
+ onClick={() => onValueChange(prev)}
73
+ aria-label={text?.previous ?? "เดือนก่อน"}
74
+ >
75
+ <ChevronLeft />
76
+ </Button>
77
+ {resetToCurrent ? (
78
+ <Button
79
+ type="button"
80
+ variant="ghost"
81
+ disabled={isCurrent || !nowInRange}
82
+ // ปุ่มกลางแสดงชื่อเดือนเสมอ — ปิดเมื่ออยู่เดือนนี้แล้วแต่ยังต้องอ่านออก ไม่ให้จางแบบ disabled ปกติ
83
+ className="min-w-32 gap-2 tabular-nums disabled:opacity-100"
84
+ onClick={() => onValueChange(now)}
85
+ title={isCurrent ? undefined : (text?.current ?? "กลับไปเดือนนี้")}
86
+ aria-live="polite"
87
+ >
88
+ <CalendarDays className="text-muted-foreground" />
89
+ {label}
90
+ </Button>
91
+ ) : (
92
+ <span
93
+ className="flex min-w-32 items-center justify-center gap-2 px-2 font-medium text-sm tabular-nums"
94
+ aria-live="polite"
95
+ >
96
+ <CalendarDays className="size-4 text-muted-foreground" />
97
+ {label}
98
+ </span>
99
+ )}
100
+ <Button
101
+ type="button"
102
+ variant="ghost"
103
+ size="icon"
104
+ disabled={!canNext}
105
+ onClick={() => onValueChange(next)}
106
+ aria-label={text?.next ?? "เดือนถัดไป"}
107
+ >
108
+ <ChevronRight />
109
+ </Button>
110
+ </div>
111
+ );
112
+ }
113
+
114
+ /**
115
+ * state เดือนปัจจุบัน + ตัวเลื่อน — `initial` ไม่ส่ง = เดือนนี้
116
+ * ต้องการเก็บใน URL ให้ใช้ `useTableSearchParams`/searchParams ของแอปแทน แล้วส่ง value/onValueChange เอง
117
+ */
118
+ export function useMonthCursor(initial?: MonthKey) {
119
+ const [month, setMonth] = useState<MonthKey>(() => initial ?? currentMonthKey());
120
+ const previous = useCallback(() => setMonth((m) => shiftMonth(m, -1)), []);
121
+ const next = useCallback(() => setMonth((m) => shiftMonth(m, 1)), []);
122
+ const reset = useCallback(() => setMonth(currentMonthKey()), []);
123
+ return { month, setMonth, previous, next, reset };
124
+ }
@@ -0,0 +1,73 @@
1
+ "use client";
2
+
3
+ import { ToggleGroup, ToggleGroupItem } from "@suphark/ui/components/ui/toggle-group";
4
+ import { cn } from "@suphark/ui/lib/utils";
5
+ import type { ReactNode } from "react";
6
+ import { CountBadge } from "./count-badge";
7
+
8
+ export interface SegmentedOption<T extends string> {
9
+ value: T;
10
+ label: ReactNode;
11
+ icon?: ReactNode;
12
+ /** จำนวนท้ายตัวเลือก เช่นจำนวนรายการในตัวกรองนั้น */
13
+ count?: number;
14
+ disabled?: boolean;
15
+ }
16
+
17
+ /**
18
+ * ตัวเลือกแบบปุ่มติดกัน "เลือกได้ทีละหนึ่ง และต้องมีค่าเสมอ" (ตัวกรองสถานะ, มุมมอง รายการ/ตาราง)
19
+ * ต่างจาก ToggleGroup ตรง ๆ: กดตัวที่เลือกอยู่แล้วไม่หลุดเป็นค่าว่าง และ value เป็น string ไม่ใช่ array
20
+ * (DueBell bill-list-filter / PDF ตัวกรองโปรเจกต์ เขียนเองพร้อมตัวเลขนับ)
21
+ *
22
+ * <SegmentedControl value={status} onValueChange={setStatus} label="สถานะ"
23
+ * options={[{ value: "all", label: "ทั้งหมด", count: 12 }, { value: "due", label: "ใกล้ครบกำหนด", count: 3 }]} />
24
+ */
25
+ export function SegmentedControl<T extends string>({
26
+ value,
27
+ onValueChange,
28
+ options,
29
+ label,
30
+ size = "default",
31
+ fullWidth = false,
32
+ className,
33
+ }: {
34
+ value: T;
35
+ onValueChange: (value: T) => void;
36
+ options: ReadonlyArray<SegmentedOption<T>>;
37
+ /** aria-label ของกลุ่ม */
38
+ label: string;
39
+ size?: "sm" | "default";
40
+ fullWidth?: boolean;
41
+ className?: string;
42
+ }) {
43
+ return (
44
+ <ToggleGroup
45
+ aria-label={label}
46
+ value={[value]}
47
+ onValueChange={(next) => {
48
+ // กดตัวที่เลือกอยู่ → Base UI คืน [] — คงค่าเดิมไว้ (ต้องมีค่าเสมอ)
49
+ const v = next[0];
50
+ if (v !== undefined && v !== value) onValueChange(v as T);
51
+ }}
52
+ variant="outline"
53
+ size={size === "sm" ? "sm" : "default"}
54
+ spacing={0}
55
+ className={cn(fullWidth && "w-full", className)}
56
+ >
57
+ {options.map((o) => (
58
+ <ToggleGroupItem
59
+ key={o.value}
60
+ value={o.value}
61
+ disabled={o.disabled}
62
+ className={cn("gap-1.5", fullWidth && "flex-1")}
63
+ >
64
+ {o.icon}
65
+ {o.label}
66
+ {o.count !== undefined && (
67
+ <CountBadge value={o.count} showZero tone={o.value === value ? "primary" : "muted"} />
68
+ )}
69
+ </ToggleGroupItem>
70
+ ))}
71
+ </ToggleGroup>
72
+ );
73
+ }
@@ -0,0 +1,170 @@
1
+ "use client";
2
+
3
+ import { cn } from "@suphark/ui/lib/utils";
4
+ import { ChevronLeft, ChevronRight } from "lucide-react";
5
+ import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react";
6
+ import { SearchInput } from "./form/search-input";
7
+ import { useUiText } from "./ui-text";
8
+
9
+ export interface SettingsSection {
10
+ id: string;
11
+ label: string;
12
+ description?: ReactNode;
13
+ icon?: ReactNode;
14
+ /** คำค้นเพิ่ม (ทั้งไทย/อังกฤษ) — ค้นจาก label + description แบบข้อความ + keywords */
15
+ keywords?: ReadonlyArray<string>;
16
+ /** ท้ายรายการ เช่น `<CountBadge>` / `<StatusBadge>` */
17
+ badge?: ReactNode;
18
+ content: ReactNode;
19
+ }
20
+
21
+ /**
22
+ * หน้าตั้งค่าหลายหัวข้อ (ยกจาก DueBell settings + PDF app-settings-form):
23
+ * - จอกว้าง: สารบัญซ้าย (sticky) + เนื้อหาหัวข้อที่เลือกทางขวา
24
+ * - มือถือ: รายการหัวข้อ → แตะแล้วเข้าหน้าหัวข้อพร้อมปุ่ม "กลับ" (master-detail)
25
+ * - `searchable`: ช่องค้นหาหัวข้อเหนือสารบัญ (ค้น label / description / keywords)
26
+ *
27
+ * state ของหัวข้อที่เลือกอยู่ในตัว (`initialSection`) หรือคุมเองผ่าน `value`/`onValueChange`
28
+ * (เช่นเก็บใน `?tab=` ของ URL) — แต่ละหัวข้อเป็น content ที่ render ไว้แล้ว ไม่ใช่หน้าแยก
29
+ */
30
+ export function SettingsLayout({
31
+ sections,
32
+ value: valueProp,
33
+ onValueChange,
34
+ initialSection,
35
+ searchable = false,
36
+ searchPlaceholder,
37
+ className,
38
+ }: {
39
+ sections: ReadonlyArray<SettingsSection>;
40
+ value?: string;
41
+ onValueChange?: (id: string) => void;
42
+ /** หัวข้อเริ่มต้นแบบ uncontrolled (ค่าเริ่มต้น: หัวข้อแรก) */
43
+ initialSection?: string;
44
+ searchable?: boolean;
45
+ searchPlaceholder?: string;
46
+ className?: string;
47
+ }) {
48
+ const text = useUiText();
49
+ const headingId = useId();
50
+ const [inner, setInner] = useState(initialSection ?? sections[0]?.id ?? "");
51
+ const active = valueProp ?? inner;
52
+ // มือถือ: false = เห็นรายการหัวข้อ, true = อยู่ในหน้าหัวข้อ (จอกว้างเห็นทั้งสองฝั่งเสมอ)
53
+ const [mobileDetail, setMobileDetail] = useState(false);
54
+ const [query, setQuery] = useState("");
55
+ const headingRef = useRef<HTMLHeadingElement>(null);
56
+
57
+ const visible = useMemo(() => {
58
+ const q = query.trim().toLowerCase();
59
+ if (!q) return sections;
60
+ return sections.filter((s) =>
61
+ [s.label, typeof s.description === "string" ? s.description : "", ...(s.keywords ?? [])]
62
+ .join(" ")
63
+ .toLowerCase()
64
+ .includes(q),
65
+ );
66
+ }, [sections, query]);
67
+
68
+ const current = sections.find((s) => s.id === active) ?? sections[0];
69
+
70
+ const select = (id: string) => {
71
+ if (valueProp === undefined) setInner(id);
72
+ onValueChange?.(id);
73
+ setMobileDetail(true);
74
+ };
75
+
76
+ // เข้าหน้าหัวข้อบนมือถือ → ย้ายโฟกัสไปหัวข้อ ให้ screen reader รู้ว่าเนื้อหาเปลี่ยน
77
+ useEffect(() => {
78
+ if (mobileDetail) headingRef.current?.focus({ preventScroll: true });
79
+ }, [mobileDetail]);
80
+
81
+ if (!current) return null;
82
+
83
+ return (
84
+ <div
85
+ data-slot="settings-layout"
86
+ data-view={mobileDetail ? "detail" : "list"}
87
+ className={cn("grid gap-6 md:grid-cols-[14rem_minmax(0,1fr)] lg:gap-8", className)}
88
+ >
89
+ <nav
90
+ aria-label={text.settings?.label ?? "หัวข้อการตั้งค่า"}
91
+ className={cn(
92
+ "flex flex-col gap-3 md:sticky md:top-20 md:self-start",
93
+ mobileDetail && "hidden md:flex",
94
+ )}
95
+ >
96
+ {searchable && (
97
+ <SearchInput value={query} onValueChange={setQuery} placeholder={searchPlaceholder} />
98
+ )}
99
+ {visible.length === 0 ? (
100
+ <p className="px-2 py-4 text-muted-foreground text-sm">
101
+ {text.settings?.empty ?? "ไม่พบหัวข้อที่ตรงกับคำค้น"}
102
+ </p>
103
+ ) : (
104
+ <ul className="flex flex-col gap-0.5 rounded-xl border bg-card p-1 md:border-0 md:bg-transparent md:p-0">
105
+ {visible.map((s) => {
106
+ const selected = s.id === current.id;
107
+ return (
108
+ <li key={s.id}>
109
+ <button
110
+ type="button"
111
+ onClick={() => select(s.id)}
112
+ aria-current={selected ? "page" : undefined}
113
+ className={cn(
114
+ "flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring md:py-2",
115
+ selected && "md:bg-accent md:font-medium md:text-accent-foreground",
116
+ )}
117
+ >
118
+ {s.icon && (
119
+ <span className="text-muted-foreground [&_svg]:size-4" aria-hidden>
120
+ {s.icon}
121
+ </span>
122
+ )}
123
+ <span className="flex min-w-0 flex-1 flex-col">
124
+ <span className="truncate">{s.label}</span>
125
+ {s.description && (
126
+ <span className="truncate text-muted-foreground text-xs md:hidden">
127
+ {s.description}
128
+ </span>
129
+ )}
130
+ </span>
131
+ {s.badge}
132
+ <ChevronRight className="size-4 text-muted-foreground md:hidden" aria-hidden />
133
+ </button>
134
+ </li>
135
+ );
136
+ })}
137
+ </ul>
138
+ )}
139
+ </nav>
140
+
141
+ <section
142
+ aria-labelledby={headingId}
143
+ className={cn("flex min-w-0 flex-col gap-4", !mobileDetail && "hidden md:flex")}
144
+ >
145
+ <button
146
+ type="button"
147
+ onClick={() => setMobileDetail(false)}
148
+ className="-ml-1 flex w-fit items-center gap-1 rounded-md px-1 py-1 text-muted-foreground text-sm outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring md:hidden"
149
+ >
150
+ <ChevronLeft className="size-4" aria-hidden />
151
+ {text.settings?.back ?? "กลับ"}
152
+ </button>
153
+ <header className="flex flex-col gap-1">
154
+ <h2
155
+ id={headingId}
156
+ ref={headingRef}
157
+ tabIndex={-1}
158
+ className="font-semibold text-title outline-none"
159
+ >
160
+ {current.label}
161
+ </h2>
162
+ {current.description && (
163
+ <p className="text-caption text-muted-foreground">{current.description}</p>
164
+ )}
165
+ </header>
166
+ {current.content}
167
+ </section>
168
+ </div>
169
+ );
170
+ }
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import { Badge } from "@suphark/ui/components/ui/badge";
4
+ import { cn } from "@suphark/ui/lib/utils";
5
+ import { CheckIcon } from "lucide-react";
6
+ import type { ReactNode } from "react";
7
+ import { SectionCard } from "./section-card";
8
+ import { useUiText } from "./ui-text";
9
+
10
+ export interface SetupChecklistStep {
11
+ id: string;
12
+ title: ReactNode;
13
+ description?: ReactNode;
14
+ done: boolean;
15
+ /** ขั้นเสริม — ไม่นับในความคืบหน้า และไม่ทำให้กล่องค้าง */
16
+ optional?: boolean;
17
+ /**
18
+ * ปุ่มไปทำขั้นนี้ (แอปส่งเอง เช่น `LinkButton` จาก `@suphark/ui/next`) — รับ `current` เพื่อเน้นขั้นถัดไป:
19
+ * `action: ({ current, done }) => <LinkButton href="…" variant={current ? "default" : "ghost"} size="sm">ตั้งค่า</LinkButton>`
20
+ */
21
+ action?: ReactNode | ((state: { current: boolean; done: boolean }) => ReactNode);
22
+ }
23
+
24
+ /**
25
+ * รายการ "ต้องตั้งอะไรให้ครบ" แบบเรียงขั้น (ยกจาก Pi Auth `SetupSteps`): เลขขั้น → ✓ เมื่อทำแล้ว,
26
+ * ขั้นบังคับถัดไปที่ยังไม่ทำถูกเน้น, ทำครบทุกขั้นบังคับแล้ว**ซ่อนทั้งกล่อง** (`hideWhenComplete`)
27
+ * ต่างจาก `Stepper` ที่เป็น wizard ทีละหน้า — ตัวนี้คือ checklist ที่ทำข้ามลำดับได้
28
+ */
29
+ export function SetupChecklist({
30
+ title,
31
+ description,
32
+ steps,
33
+ hideWhenComplete = true,
34
+ className,
35
+ }: {
36
+ title: ReactNode;
37
+ /** ไม่ส่ง = "ทำแล้ว {done} จาก {total} ขั้น" (UiText `setup.progress`) */
38
+ description?: ReactNode;
39
+ steps: SetupChecklistStep[];
40
+ hideWhenComplete?: boolean;
41
+ className?: string;
42
+ }) {
43
+ const ui = useUiText();
44
+ const text = ui.setup;
45
+ const required = steps.filter((s) => !s.optional);
46
+ const doneCount = required.filter((s) => s.done).length;
47
+ if (hideWhenComplete && doneCount === required.length) return null;
48
+ const currentId = steps.find((s) => !s.done && !s.optional)?.id;
49
+ const progress = (text?.progress ?? "ทำแล้ว {done} จาก {total} ขั้น")
50
+ .replace("{done}", String(doneCount))
51
+ .replace("{total}", String(required.length));
52
+
53
+ return (
54
+ <SectionCard
55
+ title={title}
56
+ description={description ?? progress}
57
+ contentClassName="p-0"
58
+ className={className}
59
+ >
60
+ <ol className="divide-y">
61
+ {steps.map((step, i) => {
62
+ const current = step.id === currentId;
63
+ const action =
64
+ typeof step.action === "function"
65
+ ? step.action({ current, done: step.done })
66
+ : step.action;
67
+ return (
68
+ <li
69
+ key={step.id}
70
+ aria-current={current ? "step" : undefined}
71
+ className={cn(
72
+ "flex flex-wrap items-center gap-3 px-4 py-3",
73
+ current && "bg-accent/40",
74
+ step.done && "opacity-70",
75
+ )}
76
+ >
77
+ <span
78
+ aria-hidden
79
+ className={cn(
80
+ "flex size-7 shrink-0 items-center justify-center rounded-full border font-semibold text-label",
81
+ step.done && "border-success bg-success text-success-foreground",
82
+ current && "border-primary text-primary",
83
+ )}
84
+ >
85
+ {step.done ? <CheckIcon className="size-4" /> : i + 1}
86
+ </span>
87
+ <span className="flex min-w-0 flex-1 flex-col">
88
+ <span className="flex flex-wrap items-center gap-2 font-medium text-sm">
89
+ {step.title}
90
+ {step.optional && <Badge variant="outline">{text?.optional ?? "ไม่บังคับ"}</Badge>}
91
+ {step.done && <span className="sr-only">({ui.done})</span>}
92
+ </span>
93
+ {step.description && (
94
+ <span className="text-muted-foreground text-xs">{step.description}</span>
95
+ )}
96
+ </span>
97
+ {action}
98
+ </li>
99
+ );
100
+ })}
101
+ </ol>
102
+ </SectionCard>
103
+ );
104
+ }
@@ -0,0 +1,172 @@
1
+ "use client";
2
+
3
+ import { Button } from "@suphark/ui/components/ui/button";
4
+ import {
5
+ Dialog,
6
+ DialogClose,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ DialogTrigger,
13
+ } from "@suphark/ui/components/ui/dialog";
14
+ import {
15
+ Sheet,
16
+ SheetClose,
17
+ SheetContent,
18
+ SheetDescription,
19
+ SheetFooter,
20
+ SheetHeader,
21
+ SheetTitle,
22
+ SheetTrigger,
23
+ } from "@suphark/ui/components/ui/sheet";
24
+ import { cn } from "@suphark/ui/lib/utils";
25
+ import { XIcon } from "lucide-react";
26
+ import { type ReactElement, type ReactNode, useState } from "react";
27
+ import { useUiText } from "./ui-text";
28
+
29
+ const DIALOG_SIZE = {
30
+ sm: "sm:max-w-sm",
31
+ md: "sm:max-w-md",
32
+ lg: "sm:max-w-lg",
33
+ xl: "sm:max-w-2xl",
34
+ full: "sm:max-w-[min(64rem,calc(100%-2rem))]",
35
+ } as const;
36
+
37
+ interface SimpleOverlayProps {
38
+ /** ปุ่มเปิด (เช่น `<Button>`) — ไม่ส่งก็ได้ถ้าคุมด้วย open/onOpenChange */
39
+ trigger?: ReactElement;
40
+ title: ReactNode;
41
+ description?: ReactNode;
42
+ children?: ReactNode;
43
+ /**
44
+ * ปุ่มท้ายกล่อง — ReactNode หรือฟังก์ชันที่ได้ `close()` (เช่นปุ่ม "บันทึก" ที่ปิดเองหลังทำเสร็จ)
45
+ * ไม่ส่ง = ไม่มี footer
46
+ */
47
+ footer?: ReactNode | ((close: () => void) => ReactNode);
48
+ open?: boolean;
49
+ onOpenChange?: (open: boolean) => void;
50
+ /** ซ่อนปุ่ม X มุมขวาบน */
51
+ hideClose?: boolean;
52
+ className?: string;
53
+ }
54
+
55
+ /** state เปิด/ปิดแบบ controlled หรือในตัว — ใช้ร่วม Dialog/Sheet */
56
+ function useOpenState(open?: boolean, onOpenChange?: (open: boolean) => void) {
57
+ const [inner, setInner] = useState(false);
58
+ const value = open ?? inner;
59
+ const set = (next: boolean) => {
60
+ if (open === undefined) setInner(next);
61
+ onOpenChange?.(next);
62
+ };
63
+ return [value, set] as const;
64
+ }
65
+
66
+ /**
67
+ * กล่องโต้ตอบสำเร็จรูป: trigger + หัวข้อ + คำอธิบาย + เนื้อหา + ปุ่มท้าย ในคอมโพเนนต์เดียว
68
+ * (แทน Dialog/DialogTrigger/DialogContent/DialogHeader/DialogTitle/DialogDescription/DialogFooter 7 ชิ้น)
69
+ * ปุ่ม X มี aria-label จาก UiText (`close`) — แปลภาษาได้ ต่างจาก DialogContent ของ shadcn
70
+ * ฟอร์มที่ส่ง server action ใช้ `FormDialog`, การยืนยันใช้ `ConfirmDialog`/`useConfirm`
71
+ *
72
+ * <SimpleDialog trigger={<Button>ดูรายละเอียด</Button>} title="ใบสั่งซื้อ PO-001" size="lg">
73
+ * …เนื้อหา…
74
+ * </SimpleDialog>
75
+ */
76
+ export function SimpleDialog({
77
+ trigger,
78
+ title,
79
+ description,
80
+ children,
81
+ footer,
82
+ open,
83
+ onOpenChange,
84
+ hideClose = false,
85
+ size = "md",
86
+ className,
87
+ }: SimpleOverlayProps & {
88
+ /** ความกว้างบนจอ ≥ sm (มือถือเต็มจอลบขอบเสมอ) */
89
+ size?: keyof typeof DIALOG_SIZE;
90
+ }) {
91
+ const text = useUiText();
92
+ const [isOpen, setOpen] = useOpenState(open, onOpenChange);
93
+ const close = () => setOpen(false);
94
+ return (
95
+ <Dialog open={isOpen} onOpenChange={setOpen}>
96
+ {trigger && <DialogTrigger render={trigger} />}
97
+ <DialogContent
98
+ showCloseButton={false}
99
+ className={cn("flex max-h-[calc(100dvh-2rem)] flex-col", DIALOG_SIZE[size], className)}
100
+ >
101
+ <DialogHeader className="pr-8">
102
+ <DialogTitle>{title}</DialogTitle>
103
+ {description && <DialogDescription>{description}</DialogDescription>}
104
+ </DialogHeader>
105
+ {children !== undefined && (
106
+ <div className="-mx-1 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-1">
107
+ {children}
108
+ </div>
109
+ )}
110
+ {footer && (
111
+ <DialogFooter>{typeof footer === "function" ? footer(close) : footer}</DialogFooter>
112
+ )}
113
+ {!hideClose && (
114
+ <DialogClose
115
+ render={<Button variant="ghost" size="icon-sm" className="absolute top-2 right-2" />}
116
+ >
117
+ <XIcon />
118
+ <span className="sr-only">{text.close ?? "ปิด"}</span>
119
+ </DialogClose>
120
+ )}
121
+ </DialogContent>
122
+ </Dialog>
123
+ );
124
+ }
125
+
126
+ /**
127
+ * แผงเลื่อนจากขอบจอสำเร็จรูป (ตัวกรองขั้นสูง, รายละเอียดแถว, ฟอร์มยาว) — props เดียวกับ SimpleDialog + `side`
128
+ * เนื้อหาเลื่อนได้ หัว/ท้ายตรึง
129
+ */
130
+ export function SimpleSheet({
131
+ trigger,
132
+ title,
133
+ description,
134
+ children,
135
+ footer,
136
+ open,
137
+ onOpenChange,
138
+ hideClose = false,
139
+ side = "right",
140
+ className,
141
+ }: SimpleOverlayProps & {
142
+ side?: "right" | "left" | "top" | "bottom";
143
+ }) {
144
+ const text = useUiText();
145
+ const [isOpen, setOpen] = useOpenState(open, onOpenChange);
146
+ const close = () => setOpen(false);
147
+ return (
148
+ <Sheet open={isOpen} onOpenChange={setOpen}>
149
+ {trigger && <SheetTrigger render={trigger} />}
150
+ <SheetContent side={side} showCloseButton={false} className={cn("gap-0", className)}>
151
+ <SheetHeader className="border-b pr-12">
152
+ <SheetTitle>{title}</SheetTitle>
153
+ {description && <SheetDescription>{description}</SheetDescription>}
154
+ </SheetHeader>
155
+ <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">{children}</div>
156
+ {footer && (
157
+ <SheetFooter className="border-t">
158
+ {typeof footer === "function" ? footer(close) : footer}
159
+ </SheetFooter>
160
+ )}
161
+ {!hideClose && (
162
+ <SheetClose
163
+ render={<Button variant="ghost" size="icon-sm" className="absolute top-3 right-3" />}
164
+ >
165
+ <XIcon />
166
+ <span className="sr-only">{text.close ?? "ปิด"}</span>
167
+ </SheetClose>
168
+ )}
169
+ </SheetContent>
170
+ </Sheet>
171
+ );
172
+ }