@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.
- package/CHANGELOG.md +30 -0
- package/package.json +14 -13
- package/scripts/audit.mjs +0 -0
- package/src/components/app-shell/app-shell.tsx +12 -0
- package/src/components/app-shell/index.ts +1 -0
- package/src/components/app-shell/mobile-tab-bar.tsx +104 -0
- package/src/components/shared/action-menu.tsx +155 -0
- package/src/components/shared/filter-bar.tsx +108 -0
- package/src/components/shared/month-stepper.tsx +124 -0
- package/src/components/shared/segmented-control.tsx +73 -0
- package/src/components/shared/settings-layout.tsx +170 -0
- package/src/components/shared/setup-checklist.tsx +104 -0
- package/src/components/shared/simple-dialog.tsx +172 -0
- package/src/components/shared/simple-pagination.tsx +109 -0
- package/src/components/shared/simple-table.tsx +93 -0
- package/src/components/shared/simple-tabs.tsx +118 -0
- package/src/components/shared/simple-tooltip.tsx +87 -0
- package/src/components/shared/ui-text.tsx +28 -0
- package/src/components/ui/accordion.tsx +2 -2
- package/src/components/ui/alert-dialog.tsx +4 -4
- package/src/components/ui/alert.tsx +2 -2
- package/src/components/ui/attachment.tsx +9 -9
- package/src/components/ui/avatar.tsx +4 -4
- package/src/components/ui/badge.tsx +1 -1
- package/src/components/ui/breadcrumb.tsx +1 -1
- package/src/components/ui/bubble.tsx +123 -0
- package/src/components/ui/button-group.tsx +2 -2
- package/src/components/ui/button.tsx +6 -6
- package/src/components/ui/calendar.tsx +9 -9
- package/src/components/ui/card.tsx +3 -3
- package/src/components/ui/chart.tsx +2 -2
- package/src/components/ui/checkbox.tsx +1 -1
- package/src/components/ui/combobox.tsx +6 -6
- package/src/components/ui/command.tsx +4 -4
- package/src/components/ui/context-menu.tsx +7 -7
- package/src/components/ui/dialog.tsx +4 -4
- package/src/components/ui/direction.tsx +6 -0
- package/src/components/ui/drawer.tsx +10 -10
- package/src/components/ui/dropdown-menu.tsx +8 -8
- package/src/components/ui/empty.tsx +4 -4
- package/src/components/ui/field.tsx +8 -8
- package/src/components/ui/hover-card.tsx +1 -2
- package/src/components/ui/input-group.tsx +3 -3
- package/src/components/ui/input-otp.tsx +1 -1
- package/src/components/ui/input.tsx +1 -1
- package/src/components/ui/item.tsx +4 -4
- package/src/components/ui/kbd.tsx +1 -1
- package/src/components/ui/label.tsx +1 -1
- package/src/components/ui/marker.tsx +67 -0
- package/src/components/ui/menubar.tsx +9 -9
- package/src/components/ui/message-scroller.tsx +129 -0
- package/src/components/ui/message.tsx +84 -0
- package/src/components/ui/native-select.tsx +1 -1
- package/src/components/ui/navigation-menu.tsx +6 -6
- package/src/components/ui/popover.tsx +1 -1
- package/src/components/ui/progress.tsx +2 -3
- package/src/components/ui/questionnaire.tsx +314 -0
- package/src/components/ui/radio-group.tsx +1 -2
- package/src/components/ui/resizable.tsx +1 -1
- package/src/components/ui/select.tsx +4 -4
- package/src/components/ui/separator.tsx +0 -1
- package/src/components/ui/sheet.tsx +3 -3
- package/src/components/ui/sidebar.tsx +14 -14
- package/src/components/ui/switch.tsx +2 -3
- package/src/components/ui/textarea.tsx +1 -1
- package/src/components/ui/toast.tsx +221 -0
- package/src/components/ui/toggle-group.tsx +2 -2
- package/src/components/ui/toggle.tsx +1 -1
- package/src/components/ui/tooltip.tsx +0 -1
- package/src/index.ts +41 -0
- package/src/lib/month.ts +60 -0
- package/src/styles.css +10 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
5
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
6
|
+
import { useUiText } from "./ui-text";
|
|
7
|
+
|
|
8
|
+
/** เลขหน้าที่แสดง: หน้าแรก/สุดท้ายเสมอ + รอบ ๆ หน้าปัจจุบัน, ช่องว่างเป็น "…" */
|
|
9
|
+
export function paginationRange(
|
|
10
|
+
page: number,
|
|
11
|
+
pageCount: number,
|
|
12
|
+
siblings = 1,
|
|
13
|
+
): Array<number | "…"> {
|
|
14
|
+
// จำนวนช่องคงที่ (แรก + สุดท้าย + ปัจจุบัน + เพื่อนบ้าน + … สองฝั่ง) ปุ่มจะได้ไม่กระโดดเวลาเปลี่ยนหน้า
|
|
15
|
+
const slots = siblings * 2 + 5;
|
|
16
|
+
if (pageCount <= slots) return Array.from({ length: pageCount }, (_, i) => i + 1);
|
|
17
|
+
const left = Math.max(page - siblings, 1);
|
|
18
|
+
const right = Math.min(page + siblings, pageCount);
|
|
19
|
+
const leftGap = left > 2;
|
|
20
|
+
const rightGap = right < pageCount - 1;
|
|
21
|
+
const edge = siblings * 2 + 3;
|
|
22
|
+
const seq = (from: number, to: number) =>
|
|
23
|
+
Array.from({ length: to - from + 1 }, (_, i) => from + i);
|
|
24
|
+
if (!leftGap) return [...seq(1, edge), "…", pageCount];
|
|
25
|
+
if (!rightGap) return [1, "…", ...seq(pageCount - edge + 1, pageCount)];
|
|
26
|
+
return [1, "…", ...seq(left, right), "…", pageCount];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* แบ่งหน้าสำเร็จรูป: ส่งหน้าปัจจุบัน + จำนวนหน้า แล้วได้ ‹ 1 … 4 5 6 … 20 › (แทน Pagination 7 ชิ้น + คำนวณช่วงเอง)
|
|
31
|
+
* เลขหน้าเริ่มที่ 1; หน้าเดียวหรือไม่มีข้อมูล = ไม่แสดงอะไร; มือถือซ่อนเลขเหลือ "หน้า x/y"
|
|
32
|
+
* ตาราง tanstack มี pagination ของ `@suphark/ui/data-table` อยู่แล้ว
|
|
33
|
+
*
|
|
34
|
+
* <SimplePagination page={page} pageCount={Math.ceil(total / 20)} onPageChange={setPage} />
|
|
35
|
+
*/
|
|
36
|
+
export function SimplePagination({
|
|
37
|
+
page,
|
|
38
|
+
pageCount,
|
|
39
|
+
onPageChange,
|
|
40
|
+
siblings = 1,
|
|
41
|
+
className,
|
|
42
|
+
}: {
|
|
43
|
+
page: number;
|
|
44
|
+
pageCount: number;
|
|
45
|
+
onPageChange: (page: number) => void;
|
|
46
|
+
/** จำนวนหน้าข้าง ๆ หน้าปัจจุบันที่แสดง (ค่าเริ่มต้น 1) */
|
|
47
|
+
siblings?: number;
|
|
48
|
+
className?: string;
|
|
49
|
+
}) {
|
|
50
|
+
const text = useUiText().pagination;
|
|
51
|
+
if (pageCount <= 1) return null;
|
|
52
|
+
const go = (p: number) => onPageChange(Math.min(pageCount, Math.max(1, p)));
|
|
53
|
+
return (
|
|
54
|
+
<nav
|
|
55
|
+
aria-label={text?.label ?? "แบ่งหน้า"}
|
|
56
|
+
className={cn("flex items-center justify-center gap-1", className)}
|
|
57
|
+
>
|
|
58
|
+
<Button
|
|
59
|
+
type="button"
|
|
60
|
+
variant="ghost"
|
|
61
|
+
size="sm"
|
|
62
|
+
disabled={page <= 1}
|
|
63
|
+
onClick={() => go(page - 1)}
|
|
64
|
+
aria-label={text?.previous ?? "ก่อนหน้า"}
|
|
65
|
+
>
|
|
66
|
+
<ChevronLeft />
|
|
67
|
+
<span className="hidden sm:inline">{text?.previous ?? "ก่อนหน้า"}</span>
|
|
68
|
+
</Button>
|
|
69
|
+
<ul className="hidden items-center gap-1 sm:flex">
|
|
70
|
+
{paginationRange(page, pageCount, siblings).map((p, i) =>
|
|
71
|
+
p === "…" ? (
|
|
72
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: "…" มีได้สองตัว ตำแหน่งคือ key
|
|
73
|
+
<li key={`gap-${i}`} aria-hidden className="px-1 text-muted-foreground">
|
|
74
|
+
…
|
|
75
|
+
</li>
|
|
76
|
+
) : (
|
|
77
|
+
<li key={p}>
|
|
78
|
+
<Button
|
|
79
|
+
type="button"
|
|
80
|
+
size="icon-sm"
|
|
81
|
+
variant={p === page ? "outline" : "ghost"}
|
|
82
|
+
aria-current={p === page ? "page" : undefined}
|
|
83
|
+
aria-label={`${text?.page ?? "หน้า"} ${p}`}
|
|
84
|
+
onClick={() => go(p)}
|
|
85
|
+
className="tabular-nums"
|
|
86
|
+
>
|
|
87
|
+
{p}
|
|
88
|
+
</Button>
|
|
89
|
+
</li>
|
|
90
|
+
),
|
|
91
|
+
)}
|
|
92
|
+
</ul>
|
|
93
|
+
<span className="px-2 text-muted-foreground text-sm tabular-nums sm:hidden">
|
|
94
|
+
{page} / {pageCount}
|
|
95
|
+
</span>
|
|
96
|
+
<Button
|
|
97
|
+
type="button"
|
|
98
|
+
variant="ghost"
|
|
99
|
+
size="sm"
|
|
100
|
+
disabled={page >= pageCount}
|
|
101
|
+
onClick={() => go(page + 1)}
|
|
102
|
+
aria-label={text?.next ?? "ถัดไป"}
|
|
103
|
+
>
|
|
104
|
+
<span className="hidden sm:inline">{text?.next ?? "ถัดไป"}</span>
|
|
105
|
+
<ChevronRight />
|
|
106
|
+
</Button>
|
|
107
|
+
</nav>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -56,6 +56,20 @@ export interface UiText {
|
|
|
56
56
|
/** ConfirmDialog `requireText`: คำนำหน้า/ต่อท้ายช่องพิมพ์ยืนยัน (optional — "พิมพ์" … "เพื่อยืนยัน") */
|
|
57
57
|
confirmTypeToConfirm?: string;
|
|
58
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 }>;
|
|
59
73
|
}
|
|
60
74
|
|
|
61
75
|
export const UI_TEXT_TH: UiText = {
|
|
@@ -93,6 +107,13 @@ export const UI_TEXT_TH: UiText = {
|
|
|
93
107
|
language: { label: "ภาษา" },
|
|
94
108
|
confirmTypeToConfirm: "พิมพ์",
|
|
95
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: "หน้า" },
|
|
96
117
|
};
|
|
97
118
|
|
|
98
119
|
export const UI_TEXT_EN: UiText = {
|
|
@@ -130,6 +151,13 @@ export const UI_TEXT_EN: UiText = {
|
|
|
130
151
|
language: { label: "Language" },
|
|
131
152
|
confirmTypeToConfirm: "Type",
|
|
132
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" },
|
|
133
161
|
};
|
|
134
162
|
|
|
135
163
|
const UiTextContext = createContext<UiText>(UI_TEXT_TH);
|
|
@@ -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
|
|
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-
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
@@ -6,14 +6,14 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
6
6
|
import type * as React from "react";
|
|
7
7
|
|
|
8
8
|
const attachmentVariants = cva(
|
|
9
|
-
"group/attachment relative flex w-fit
|
|
9
|
+
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
|
|
10
10
|
{
|
|
11
11
|
variants: {
|
|
12
12
|
size: {
|
|
13
13
|
default:
|
|
14
|
-
"gap-2 text-sm has-data-[slot=attachment-
|
|
15
|
-
sm: "gap-2.5 text-xs has-data-[slot=attachment-
|
|
16
|
-
xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-
|
|
14
|
+
"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
|
|
15
|
+
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
|
|
16
|
+
xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
|
|
17
17
|
},
|
|
18
18
|
orientation: {
|
|
19
19
|
horizontal: "min-w-40 items-center",
|
|
@@ -46,7 +46,7 @@ function Attachment({
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
const attachmentMediaVariants = cva(
|
|
49
|
-
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5
|
|
49
|
+
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
|
|
50
50
|
{
|
|
51
51
|
variants: {
|
|
52
52
|
variant: {
|
|
@@ -81,7 +81,7 @@ function AttachmentContent({ className, ...props }: React.ComponentProps<"div">)
|
|
|
81
81
|
<div
|
|
82
82
|
data-slot="attachment-content"
|
|
83
83
|
className={cn(
|
|
84
|
-
"
|
|
84
|
+
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
|
|
85
85
|
className,
|
|
86
86
|
)}
|
|
87
87
|
{...props}
|
|
@@ -94,7 +94,7 @@ function AttachmentTitle({ className, ...props }: React.ComponentProps<"span">)
|
|
|
94
94
|
<span
|
|
95
95
|
data-slot="attachment-title"
|
|
96
96
|
className={cn(
|
|
97
|
-
"group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer
|
|
97
|
+
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
|
|
98
98
|
className,
|
|
99
99
|
)}
|
|
100
100
|
{...props}
|
|
@@ -107,7 +107,7 @@ function AttachmentDescription({ className, ...props }: React.ComponentProps<"sp
|
|
|
107
107
|
<span
|
|
108
108
|
data-slot="attachment-description"
|
|
109
109
|
className={cn(
|
|
110
|
-
"mt-0.5 block min-w-0 truncate text-muted-foreground
|
|
110
|
+
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
|
|
111
111
|
"max-w-full",
|
|
112
112
|
className,
|
|
113
113
|
)}
|
|
@@ -173,7 +173,7 @@ function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
173
173
|
<div
|
|
174
174
|
data-slot="attachment-group"
|
|
175
175
|
className={cn(
|
|
176
|
-
"
|
|
176
|
+
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
|
|
177
177
|
className,
|
|
178
178
|
)}
|
|
179
179
|
{...props}
|
|
@@ -16,7 +16,7 @@ function Avatar({
|
|
|
16
16
|
data-slot="avatar"
|
|
17
17
|
data-size={size}
|
|
18
18
|
className={cn(
|
|
19
|
-
"group/avatar relative flex size-8 shrink-0 select-none
|
|
19
|
+
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
|
20
20
|
className,
|
|
21
21
|
)}
|
|
22
22
|
{...props}
|
|
@@ -39,7 +39,7 @@ function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props)
|
|
|
39
39
|
<AvatarPrimitive.Fallback
|
|
40
40
|
data-slot="avatar-fallback"
|
|
41
41
|
className={cn(
|
|
42
|
-
"flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground
|
|
42
|
+
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
|
43
43
|
className,
|
|
44
44
|
)}
|
|
45
45
|
{...props}
|
|
@@ -52,7 +52,7 @@ function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
|
|
52
52
|
<span
|
|
53
53
|
data-slot="avatar-badge"
|
|
54
54
|
className={cn(
|
|
55
|
-
"absolute right-0 bottom-0 z-10 inline-flex
|
|
55
|
+
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
|
56
56
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
|
57
57
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
|
58
58
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
|
@@ -81,7 +81,7 @@ function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">)
|
|
|
81
81
|
<div
|
|
82
82
|
data-slot="avatar-group-count"
|
|
83
83
|
className={cn(
|
|
84
|
-
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground
|
|
84
|
+
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
|
85
85
|
className,
|
|
86
86
|
)}
|
|
87
87
|
{...props}
|
|
@@ -4,7 +4,7 @@ import { cn } from "@suphark/ui/lib/utils";
|
|
|
4
4
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
5
5
|
|
|
6
6
|
const badgeVariants = cva(
|
|
7
|
-
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden
|
|
7
|
+
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
|
8
8
|
{
|
|
9
9
|
variants: {
|
|
10
10
|
variant: {
|