@suphark/ui 0.18.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 (82) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +14 -13
  3. package/scripts/audit.mjs +0 -0
  4. package/src/components/app-shell/app-shell.tsx +15 -9
  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/confirm-dialog.tsx +41 -2
  9. package/src/components/shared/count-badge.tsx +41 -0
  10. package/src/components/shared/filter-bar.tsx +108 -0
  11. package/src/components/shared/form/slider-field.tsx +79 -0
  12. package/src/components/shared/form-dialog.tsx +43 -4
  13. package/src/components/shared/month-stepper.tsx +124 -0
  14. package/src/components/shared/save-status.tsx +99 -0
  15. package/src/components/shared/segmented-control.tsx +73 -0
  16. package/src/components/shared/settings-layout.tsx +170 -0
  17. package/src/components/shared/settings-row.tsx +118 -0
  18. package/src/components/shared/setup-checklist.tsx +104 -0
  19. package/src/components/shared/simple-dialog.tsx +172 -0
  20. package/src/components/shared/simple-pagination.tsx +109 -0
  21. package/src/components/shared/simple-table.tsx +93 -0
  22. package/src/components/shared/simple-tabs.tsx +118 -0
  23. package/src/components/shared/simple-tooltip.tsx +87 -0
  24. package/src/components/shared/skip-link.tsx +30 -0
  25. package/src/components/shared/stat.tsx +110 -6
  26. package/src/components/shared/ui-text.tsx +35 -0
  27. package/src/components/shared/user-identity.tsx +61 -0
  28. package/src/components/ui/accordion.tsx +2 -2
  29. package/src/components/ui/alert-dialog.tsx +4 -4
  30. package/src/components/ui/alert.tsx +2 -2
  31. package/src/components/ui/attachment.tsx +9 -9
  32. package/src/components/ui/avatar.tsx +4 -4
  33. package/src/components/ui/badge.tsx +1 -1
  34. package/src/components/ui/breadcrumb.tsx +1 -1
  35. package/src/components/ui/bubble.tsx +123 -0
  36. package/src/components/ui/button-group.tsx +2 -2
  37. package/src/components/ui/button.tsx +6 -6
  38. package/src/components/ui/calendar.tsx +9 -9
  39. package/src/components/ui/card.tsx +3 -3
  40. package/src/components/ui/chart.tsx +2 -2
  41. package/src/components/ui/checkbox.tsx +1 -1
  42. package/src/components/ui/combobox.tsx +6 -6
  43. package/src/components/ui/command.tsx +4 -4
  44. package/src/components/ui/context-menu.tsx +7 -7
  45. package/src/components/ui/dialog.tsx +4 -4
  46. package/src/components/ui/direction.tsx +6 -0
  47. package/src/components/ui/drawer.tsx +10 -10
  48. package/src/components/ui/dropdown-menu.tsx +8 -8
  49. package/src/components/ui/empty.tsx +4 -4
  50. package/src/components/ui/field.tsx +8 -8
  51. package/src/components/ui/hover-card.tsx +1 -2
  52. package/src/components/ui/input-group.tsx +3 -3
  53. package/src/components/ui/input-otp.tsx +1 -1
  54. package/src/components/ui/input.tsx +1 -1
  55. package/src/components/ui/item.tsx +4 -4
  56. package/src/components/ui/kbd.tsx +1 -1
  57. package/src/components/ui/label.tsx +1 -1
  58. package/src/components/ui/marker.tsx +67 -0
  59. package/src/components/ui/menubar.tsx +9 -9
  60. package/src/components/ui/message-scroller.tsx +129 -0
  61. package/src/components/ui/message.tsx +84 -0
  62. package/src/components/ui/native-select.tsx +1 -1
  63. package/src/components/ui/navigation-menu.tsx +6 -6
  64. package/src/components/ui/popover.tsx +1 -1
  65. package/src/components/ui/progress.tsx +2 -3
  66. package/src/components/ui/questionnaire.tsx +314 -0
  67. package/src/components/ui/radio-group.tsx +1 -2
  68. package/src/components/ui/resizable.tsx +1 -1
  69. package/src/components/ui/select.tsx +4 -4
  70. package/src/components/ui/separator.tsx +0 -1
  71. package/src/components/ui/sheet.tsx +3 -3
  72. package/src/components/ui/sidebar.tsx +14 -14
  73. package/src/components/ui/switch.tsx +2 -3
  74. package/src/components/ui/textarea.tsx +1 -1
  75. package/src/components/ui/toast.tsx +221 -0
  76. package/src/components/ui/toggle-group.tsx +2 -2
  77. package/src/components/ui/toggle.tsx +1 -1
  78. package/src/components/ui/tooltip.tsx +0 -1
  79. package/src/hooks/use-confirm.tsx +2 -0
  80. package/src/index.ts +48 -1
  81. package/src/lib/month.ts +60 -0
  82. package/src/styles.css +10 -0
@@ -0,0 +1,93 @@
1
+ "use client";
2
+
3
+ import {
4
+ Table,
5
+ TableBody,
6
+ TableCell,
7
+ TableHead,
8
+ TableHeader,
9
+ TableRow,
10
+ } from "@suphark/ui/components/ui/table";
11
+ import { cn } from "@suphark/ui/lib/utils";
12
+ import type { ReactNode } from "react";
13
+ import { EmptyRow } from "./empty-row";
14
+
15
+ export interface SimpleTableColumn<T> {
16
+ /** key ของคอลัมน์ — ถ้าตรงกับ field ของแถวและไม่ส่ง cell จะแสดงค่านั้นตรง ๆ */
17
+ key: string;
18
+ header: ReactNode;
19
+ cell?: (row: T, index: number) => ReactNode;
20
+ align?: "left" | "center" | "right";
21
+ /** class ของทั้งหัวและเซลล์ เช่น "w-32" หรือ "hidden md:table-cell" (ซ่อนบนมือถือ) */
22
+ className?: string;
23
+ }
24
+
25
+ /**
26
+ * ตารางสำเร็จรูปจาก columns + rows (แทน Table/TableHeader/TableRow/TableHead/TableBody/TableCell + map สองชั้น)
27
+ * สำหรับรายการเล็ก–กลางที่ไม่ต้อง sort/filter/เลือกหลายแถว — ต้องการพวกนั้นใช้ `@suphark/ui/data-table`
28
+ * ไม่มีแถว = แสดง `empty` ในแถวเดียวเต็มความกว้าง; ตัวเลขใช้ `align: "right"` (tabular-nums ให้เอง)
29
+ *
30
+ * <SimpleTable rows={members} getRowKey={(m) => m.id} empty="ยังไม่มีสมาชิก"
31
+ * columns={[{ key: "name", header: "ชื่อ" },
32
+ * { key: "role", header: "บทบาท", cell: (m) => <StatusBadge>{m.role}</StatusBadge> },
33
+ * { key: "actions", header: "", align: "right", cell: (m) => <ActionMenu items={…} /> }]} />
34
+ */
35
+ export function SimpleTable<T>({
36
+ columns,
37
+ rows,
38
+ getRowKey,
39
+ empty = "ไม่มีข้อมูล",
40
+ onRowClick,
41
+ caption,
42
+ className,
43
+ }: {
44
+ columns: ReadonlyArray<SimpleTableColumn<T>>;
45
+ rows: ReadonlyArray<T>;
46
+ getRowKey: (row: T, index: number) => string | number;
47
+ empty?: ReactNode;
48
+ /** คลิกทั้งแถว (เช่นเปิดรายละเอียด) — ปุ่มในแถวควร stopPropagation เอง */
49
+ onRowClick?: (row: T) => void;
50
+ /** คำอธิบายตารางสำหรับ screen reader (sr-only) */
51
+ caption?: string;
52
+ className?: string;
53
+ }) {
54
+ const alignClass = (a?: "left" | "center" | "right") =>
55
+ a === "right" ? "text-right tabular-nums" : a === "center" ? "text-center" : undefined;
56
+ return (
57
+ <div className={cn("overflow-hidden rounded-xl border bg-card", className)}>
58
+ <Table>
59
+ {caption && <caption className="sr-only">{caption}</caption>}
60
+ <TableHeader>
61
+ <TableRow>
62
+ {columns.map((c) => (
63
+ <TableHead key={c.key} className={cn(alignClass(c.align), c.className)}>
64
+ {c.header}
65
+ </TableHead>
66
+ ))}
67
+ </TableRow>
68
+ </TableHeader>
69
+ <TableBody>
70
+ {rows.length === 0 ? (
71
+ <EmptyRow colSpan={columns.length}>{empty}</EmptyRow>
72
+ ) : (
73
+ rows.map((row, i) => (
74
+ <TableRow
75
+ key={getRowKey(row, i)}
76
+ onClick={onRowClick ? () => onRowClick(row) : undefined}
77
+ className={cn(onRowClick && "cursor-pointer")}
78
+ >
79
+ {columns.map((c) => (
80
+ <TableCell key={c.key} className={cn(alignClass(c.align), c.className)}>
81
+ {c.cell
82
+ ? c.cell(row, i)
83
+ : String((row as Record<string, unknown>)[c.key] ?? "")}
84
+ </TableCell>
85
+ ))}
86
+ </TableRow>
87
+ ))
88
+ )}
89
+ </TableBody>
90
+ </Table>
91
+ </div>
92
+ );
93
+ }
@@ -0,0 +1,118 @@
1
+ "use client";
2
+
3
+ import {
4
+ Accordion,
5
+ AccordionContent,
6
+ AccordionItem,
7
+ AccordionTrigger,
8
+ } from "@suphark/ui/components/ui/accordion";
9
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@suphark/ui/components/ui/tabs";
10
+ import { cn } from "@suphark/ui/lib/utils";
11
+ import type { ReactNode } from "react";
12
+
13
+ export interface SimpleTabItem {
14
+ value: string;
15
+ label: ReactNode;
16
+ content: ReactNode;
17
+ /** ท้ายชื่อแท็บ เช่น `<CountBadge value={3} />` */
18
+ badge?: ReactNode;
19
+ icon?: ReactNode;
20
+ disabled?: boolean;
21
+ }
22
+
23
+ /**
24
+ * แท็บสำเร็จรูปจาก array เดียว (แทน Tabs/TabsList/TabsTrigger/TabsContent 4 ชิ้นต่อแท็บ)
25
+ * แท็บที่เป็นหน้าแยก (เปลี่ยน URL) ใช้ `SectionNav`/`TopTabs` แทน — ตัวนี้สลับเนื้อหาในหน้าเดียว
26
+ *
27
+ * <SimpleTabs items={[{ value: "info", label: "ข้อมูล", content: <Info /> },
28
+ * { value: "log", label: "ประวัติ", badge: <CountBadge value={3} />, content: <Log /> }]} />
29
+ */
30
+ export function SimpleTabs({
31
+ items,
32
+ value,
33
+ onValueChange,
34
+ initialValue,
35
+ variant = "default",
36
+ fullWidth = false,
37
+ className,
38
+ listClassName,
39
+ }: {
40
+ items: ReadonlyArray<SimpleTabItem>;
41
+ value?: string;
42
+ onValueChange?: (value: string) => void;
43
+ /** แท็บเริ่มต้นแบบ uncontrolled (ค่าเริ่มต้น: แท็บแรก) */
44
+ initialValue?: string;
45
+ /** default = พื้นเทาแบบปุ่ม · line = เส้นใต้ (หัวการ์ด/หน้า) */
46
+ variant?: "default" | "line";
47
+ /** แท็บแบ่งเต็มความกว้าง (มือถือ/กล่องแคบ) */
48
+ fullWidth?: boolean;
49
+ className?: string;
50
+ listClassName?: string;
51
+ }) {
52
+ return (
53
+ <Tabs
54
+ value={value}
55
+ // base-ui-ok: ค่าเริ่มต้นใช้ครั้งเดียวตอน mount (uncontrolled) — ส่ง value ถ้าจะคุมเอง
56
+ defaultValue={value === undefined ? (initialValue ?? items[0]?.value) : undefined}
57
+ onValueChange={(v) => onValueChange?.(String(v))}
58
+ className={className}
59
+ >
60
+ <TabsList
61
+ variant={variant}
62
+ className={cn(fullWidth && "w-full", "max-w-full overflow-x-auto", listClassName)}
63
+ >
64
+ {items.map((item) => (
65
+ <TabsTrigger key={item.value} value={item.value} disabled={item.disabled}>
66
+ {item.icon}
67
+ {item.label}
68
+ {item.badge}
69
+ </TabsTrigger>
70
+ ))}
71
+ </TabsList>
72
+ {items.map((item) => (
73
+ <TabsContent key={item.value} value={item.value}>
74
+ {item.content}
75
+ </TabsContent>
76
+ ))}
77
+ </Tabs>
78
+ );
79
+ }
80
+
81
+ export interface SimpleAccordionItem {
82
+ value: string;
83
+ title: ReactNode;
84
+ content: ReactNode;
85
+ disabled?: boolean;
86
+ }
87
+
88
+ /**
89
+ * หัวข้อพับเก็บได้จาก array เดียว (FAQ, รายละเอียดเพิ่มเติม, กลุ่มการตั้งค่า)
90
+ * `multiple` = เปิดหลายข้อพร้อมกันได้; `initialOpen` = ข้อที่เปิดไว้ตอนแรก
91
+ */
92
+ export function SimpleAccordion({
93
+ items,
94
+ multiple = false,
95
+ initialOpen,
96
+ className,
97
+ }: {
98
+ items: ReadonlyArray<SimpleAccordionItem>;
99
+ multiple?: boolean;
100
+ initialOpen?: ReadonlyArray<string>;
101
+ className?: string;
102
+ }) {
103
+ return (
104
+ <Accordion
105
+ multiple={multiple}
106
+ // base-ui-ok: ค่าเริ่มต้นใช้ครั้งเดียวตอน mount
107
+ defaultValue={initialOpen ? [...initialOpen] : undefined}
108
+ className={className}
109
+ >
110
+ {items.map((item) => (
111
+ <AccordionItem key={item.value} value={item.value} disabled={item.disabled}>
112
+ <AccordionTrigger>{item.title}</AccordionTrigger>
113
+ <AccordionContent>{item.content}</AccordionContent>
114
+ </AccordionItem>
115
+ ))}
116
+ </Accordion>
117
+ );
118
+ }
@@ -0,0 +1,87 @@
1
+ "use client";
2
+
3
+ import {
4
+ Popover,
5
+ PopoverContent,
6
+ PopoverDescription,
7
+ PopoverHeader,
8
+ PopoverTitle,
9
+ PopoverTrigger,
10
+ } from "@suphark/ui/components/ui/popover";
11
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@suphark/ui/components/ui/tooltip";
12
+ import { cn } from "@suphark/ui/lib/utils";
13
+ import type { ReactElement, ReactNode } from "react";
14
+
15
+ type Side = "top" | "bottom" | "left" | "right";
16
+
17
+ /**
18
+ * tooltip สำเร็จรูป: ห่อ element เดียว + ข้อความ (แทน Tooltip/TooltipTrigger/TooltipContent 3 ชิ้น)
19
+ * `content` ว่าง = ไม่มี tooltip (ไม่ต้องเขียนเงื่อนไขเอง) · ปุ่มไอคอนอย่างเดียวยังต้องมี aria-label ของตัวเอง
20
+ * คำอธิบายยาวหลังไอคอน (i) ใช้ `InfoHint`
21
+ *
22
+ * <SimpleTooltip content="คัดลอกลิงก์"><Button size="icon" aria-label="คัดลอกลิงก์"><Copy /></Button></SimpleTooltip>
23
+ */
24
+ export function SimpleTooltip({
25
+ content,
26
+ children,
27
+ side = "top",
28
+ className,
29
+ }: {
30
+ content: ReactNode;
31
+ /** element ที่ชี้แล้วโชว์ tooltip (ต้องรับ ref/props ได้ เช่น Button) */
32
+ children: ReactElement;
33
+ side?: Side;
34
+ className?: string;
35
+ }) {
36
+ if (content === null || content === undefined || content === "") return children;
37
+ return (
38
+ <Tooltip>
39
+ <TooltipTrigger render={children} />
40
+ <TooltipContent side={side} className={cn("max-w-xs text-pretty", className)}>
41
+ {content}
42
+ </TooltipContent>
43
+ </Tooltip>
44
+ );
45
+ }
46
+
47
+ /**
48
+ * popover สำเร็จรูป: trigger + หัวข้อ/คำอธิบาย (ไม่บังคับ) + เนื้อหา ในคอมโพเนนต์เดียว
49
+ * (แทน Popover/PopoverTrigger/PopoverContent/PopoverHeader/PopoverTitle/PopoverDescription)
50
+ * ใช้กับตัวกรองเล็ก ๆ, รายละเอียดสั้น, ฟอร์มบรรทัดเดียว — เนื้อหายาวใช้ `SimpleSheet`/`SimpleDialog`
51
+ */
52
+ export function SimplePopover({
53
+ trigger,
54
+ title,
55
+ description,
56
+ children,
57
+ open,
58
+ onOpenChange,
59
+ side = "bottom",
60
+ align = "center",
61
+ className,
62
+ }: {
63
+ trigger: ReactElement;
64
+ title?: ReactNode;
65
+ description?: ReactNode;
66
+ children?: ReactNode;
67
+ open?: boolean;
68
+ onOpenChange?: (open: boolean) => void;
69
+ side?: Side;
70
+ align?: "start" | "center" | "end";
71
+ className?: string;
72
+ }) {
73
+ return (
74
+ <Popover open={open} onOpenChange={onOpenChange}>
75
+ <PopoverTrigger render={trigger} />
76
+ <PopoverContent side={side} align={align} className={className}>
77
+ {(title || description) && (
78
+ <PopoverHeader>
79
+ {title && <PopoverTitle>{title}</PopoverTitle>}
80
+ {description && <PopoverDescription>{description}</PopoverDescription>}
81
+ </PopoverHeader>
82
+ )}
83
+ {children}
84
+ </PopoverContent>
85
+ </Popover>
86
+ );
87
+ }
@@ -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,23 @@ 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;
59
+ /** MonthStepper (optional) */
60
+ month?: Partial<{ previous: string; next: string; current: string }>;
61
+ /** SetupChecklist: ป้ายขั้นเสริม + ความคืบหน้า ({done}/{total}) + ปุ่มของขั้นที่ทำแล้ว (optional) */
62
+ setup?: Partial<{ optional: string; progress: string; view: string }>;
63
+ /** FilterBar: ปุ่มล้างตัวกรอง (optional) */
64
+ filters?: Partial<{ clear: string; label: string }>;
65
+ /** SettingsLayout: ปุ่มย้อนกลับบนมือถือ + ไม่พบหัวข้อ (optional) */
66
+ settings?: Partial<{ back: string; empty: string; label: string }>;
67
+ /** MobileTabBar: aria-label ของแถบ (optional) */
68
+ mobileTabs?: string;
69
+ /** SimpleDialog/SimpleSheet: ปุ่มปิด (sr-only) (optional — "ปิด") */
70
+ close?: string;
71
+ /** SimplePagination (optional) */
72
+ pagination?: Partial<{ previous: string; next: string; label: string; page: string }>;
56
73
  }
57
74
 
58
75
  export const UI_TEXT_TH: UiText = {
@@ -88,6 +105,15 @@ export const UI_TEXT_TH: UiText = {
88
105
  sectionNav: { tabs: "แท็บย่อย" },
89
106
  search: { placeholder: "ค้นหา…", label: "ค้นหา", clear: "ล้างการค้นหา" },
90
107
  language: { label: "ภาษา" },
108
+ confirmTypeToConfirm: "พิมพ์",
109
+ confirmTypeToConfirmSuffix: "เพื่อยืนยัน",
110
+ month: { previous: "เดือนก่อน", next: "เดือนถัดไป", current: "กลับไปเดือนนี้" },
111
+ setup: { optional: "ไม่บังคับ", progress: "ทำแล้ว {done} จาก {total} ขั้น", view: "ดู" },
112
+ filters: { clear: "ล้างตัวกรอง", label: "ตัวกรอง" },
113
+ settings: { back: "กลับ", empty: "ไม่พบหัวข้อที่ตรงกับคำค้น", label: "หัวข้อการตั้งค่า" },
114
+ mobileTabs: "เมนูหลัก",
115
+ close: "ปิด",
116
+ pagination: { previous: "ก่อนหน้า", next: "ถัดไป", label: "แบ่งหน้า", page: "หน้า" },
91
117
  };
92
118
 
93
119
  export const UI_TEXT_EN: UiText = {
@@ -123,6 +149,15 @@ export const UI_TEXT_EN: UiText = {
123
149
  sectionNav: { tabs: "Section tabs" },
124
150
  search: { placeholder: "Search…", label: "Search", clear: "Clear search" },
125
151
  language: { label: "Language" },
152
+ confirmTypeToConfirm: "Type",
153
+ confirmTypeToConfirmSuffix: "to confirm",
154
+ month: { previous: "Previous month", next: "Next month", current: "Back to this month" },
155
+ setup: { optional: "Optional", progress: "{done} of {total} steps done", view: "View" },
156
+ filters: { clear: "Clear filters", label: "Filters" },
157
+ settings: { back: "Back", empty: "No settings match your search", label: "Settings sections" },
158
+ mobileTabs: "Main menu",
159
+ close: "Close",
160
+ pagination: { previous: "Previous", next: "Next", label: "Pagination", page: "Page" },
126
161
  };
127
162
 
128
163
  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
+ }
@@ -28,7 +28,7 @@ function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.
28
28
  <AccordionPrimitive.Trigger
29
29
  data-slot="accordion-trigger"
30
30
  className={cn(
31
- "group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left font-medium text-sm outline-none transition-all hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
31
+ "group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
32
32
  className,
33
33
  )}
34
34
  {...props}
@@ -51,7 +51,7 @@ function AccordionContent({ className, children, ...props }: AccordionPrimitive.
51
51
  return (
52
52
  <AccordionPrimitive.Panel
53
53
  data-slot="accordion-content"
54
- className="overflow-hidden text-sm data-closed:animate-accordion-up data-open:animate-accordion-down"
54
+ className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
55
55
  {...props}
56
56
  >
57
57
  <div
@@ -22,7 +22,7 @@ function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdr
22
22
  <AlertDialogPrimitive.Backdrop
23
23
  data-slot="alert-dialog-overlay"
24
24
  className={cn(
25
- "data-open:fade-in-0 data-closed:fade-out-0 fixed inset-0 isolate z-50 bg-black/10 duration-100 data-closed:animate-out data-open:animate-in supports-backdrop-filter:backdrop-blur-xs",
25
+ "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
26
26
  className,
27
27
  )}
28
28
  {...props}
@@ -44,7 +44,7 @@ function AlertDialogContent({
44
44
  data-slot="alert-dialog-content"
45
45
  data-size={size}
46
46
  className={cn(
47
- "group/alert-dialog-content data-open:fade-in-0 data-open:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground outline-none ring-1 ring-foreground/10 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-closed:animate-out data-open:animate-in data-[size=default]:sm:max-w-sm",
47
+ "group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
48
48
  className,
49
49
  )}
50
50
  {...props}
@@ -100,7 +100,7 @@ function AlertDialogTitle({
100
100
  <AlertDialogPrimitive.Title
101
101
  data-slot="alert-dialog-title"
102
102
  className={cn(
103
- "font-medium text-base sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
103
+ "text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
104
104
  className,
105
105
  )}
106
106
  {...props}
@@ -116,7 +116,7 @@ function AlertDialogDescription({
116
116
  <AlertDialogPrimitive.Description
117
117
  data-slot="alert-dialog-description"
118
118
  className={cn(
119
- "text-balance text-muted-foreground text-sm md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
119
+ "text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
120
120
  className,
121
121
  )}
122
122
  {...props}
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
3
3
  import type * as React from "react";
4
4
 
5
5
  const alertVariants = cva(
6
- "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 has-data-[slot=alert-action]:pr-18 *:[svg:not([class*='size-'])]:size-4 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current",
6
+ "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
7
7
  {
8
8
  variants: {
9
9
  variant: {
@@ -51,7 +51,7 @@ function AlertDescription({ className, ...props }: React.ComponentProps<"div">)
51
51
  <div
52
52
  data-slot="alert-description"
53
53
  className={cn(
54
- "text-balance text-muted-foreground text-sm md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
54
+ "text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
55
55
  className,
56
56
  )}
57
57
  {...props}