@suphark/ui 0.13.1 → 0.15.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 +42 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/components/design-system/sections/showcase-foundations.tsx +14 -0
- package/src/components/shared/button/confirm-action-button.tsx +5 -1
- package/src/components/shared/empty-state.tsx +44 -6
- package/src/components/shared/form/labeled-field.tsx +21 -4
- package/src/components/shared/form/simple-select.tsx +8 -0
- package/src/components/shared/form-dialog.tsx +100 -41
- package/src/components/shared/info-hint.tsx +44 -0
- package/src/components/shared/notice.tsx +5 -8
- package/src/components/shared/status-badge.tsx +4 -8
- package/src/hooks/use-copy-to-clipboard.ts +61 -24
- package/src/index.ts +1 -0
- package/src/lib/formatters.ts +12 -3
- package/src/styles.css +45 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
+
## [0.15.0] — 2026-09-30
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`InfoHint`** (ขอจาก Pi Auth): ไอคอน (i) ท้ายหัวข้อ ที่เก็บคำอธิบายยาว ๆ ไว้ใน tooltip แทนการวาง
|
|
10
|
+
เป็น `description` ใต้หัวข้อถาวร — ใช้กับคำอธิบายที่คนซึ่งรู้อยู่แล้วไม่ต้องอ่านซ้ำทุกครั้ง
|
|
11
|
+
props: `children` (เนื้อหา), `label` (ชื่อที่ screen reader อ่าน — บังคับ), `side`, `className`
|
|
12
|
+
trigger เป็น **ปุ่มจริง** ไม่ใช่ `<span>` จึงเปิดได้ด้วยคีย์บอร์ด (Tab) และจอสัมผัส ไม่ใช่ hover อย่างเดียว
|
|
13
|
+
- showcase: หน้า component `/components/info-hint`
|
|
14
|
+
|
|
15
|
+
## [0.14.0] — 2026-09-29
|
|
16
|
+
|
|
17
|
+
> **0.13.0 และ 0.13.1 ถูก publish ขึ้น npm เมื่อ 2026-09-25 จากโค้ดที่ไม่เคยเข้า git** (สเกลตัวอักษรตามบทบาท ขอจาก Pi Done) — รุ่นนี้กู้โค้ดจาก tarball 0.13.1 กลับเข้า `main` แล้ว (3-way merge กับ 0.12.0) บันทึกของสองรุ่นนั้นอยู่ข้างล่างตามที่ publish จริง
|
|
18
|
+
|
|
19
|
+
รุ่นแรกของแผนรวม UI จากทุกแอป (สำรวจ Pi Auth / DueBell / Pi PDF / Pi Config — ดู `todo/todo.md`) — เพิ่มอย่างเดียว ไม่มี prop ถูกเปลี่ยนความหมาย
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **token สีโทนใน `styles.css`**: `--info` / `--info-foreground` (`bg-info`, `text-info`) และพื้นอ่อนครบ 4 โทน `--{info|success|warning|destructive}-subtle` / `-subtle-foreground` / `-border` (`bg-info-subtle text-info-subtle-foreground border-info-border` …) สลับ dark ให้เอง — แทน `sky-*` ที่ PDF ใช้ ~20 จุด และ `--info` ที่ DueBell ตั้งเอง (ถ้าแอปตั้ง `--info` ไว้เองให้ลบออก ค่าของแพ็กเกจ = sky-600 / dark sky-400)
|
|
24
|
+
- `EmptyState`: `variant` (`dashed` ค่าเริ่มต้น · `card` · `plain`), `size="compact"`, `tone="destructive"`, `secondaryAction` (ขอจาก PDF ที่มีสำเนาของตัวเอง)
|
|
25
|
+
- `CheckboxField`: `hint` — คำอธิบายใต้ label (Config เขียน `<p>` ต่อท้ายเอง ~8 จุด)
|
|
26
|
+
- `SimpleSelect`: `name` + `required` — ส่งค่าใน FormData ของ server action ได้ (Config เคยใช้ hidden input เอง)
|
|
27
|
+
- `FormDialog`: `renderSuccess(state)` — สำเร็จแล้วแสดงผลแทนฟอร์ม (secret/token ที่ออกครั้งเดียว) พร้อมปุ่ม "เสร็จแล้ว"; Pi Auth `SecretDialog` ใช้แทนได้
|
|
28
|
+
- `ConfirmActionButton`: `redirectTo` — สำเร็จแล้ว `router.push` แทน refresh (Pi Auth `uninstall-button.tsx` ใช้แทนได้)
|
|
29
|
+
- `useCopyToClipboard({ toast, timeout })`: คืน `copiedKey` (`copyToClipboard(text, key)` — หลายปุ่มในหน้าเดียวรู้ว่าปุ่มไหนเพิ่งคัดลอก), `toast: false` หรือข้อความของแอป (แปลภาษาได้), ใช้ `copyText` ที่มี fallback; `copyToClipboard` คืน `Promise<boolean>` — ของเดิม (`isCopied`, `copyToClipboard(text)`) ใช้ได้เหมือนเดิม (Pi Auth เขียน copy เอง 12 ไฟล์)
|
|
30
|
+
- `formatBytes(bytes, locale, { minUnit })` — บังคับหน่วยเล็กสุด เช่น `"MB"` (PDF มีสำเนาเพราะต้องการแสดงเป็น MB)
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- `Notice` / `StatusBadge` ใช้ token โทนแทน palette ดิบ (ถอด `ui-audit-ok-file`) — สีเท่าเดิม ยกเว้นตัวอักษรของ `Notice` จาก 900→800 (dark 100→200) ให้ตรงกับ `StatusBadge`
|
|
35
|
+
- `FormDialog`: state ของ action อยู่ในเนื้อ dialog — ปิดแล้วเปิดใหม่ได้ฟอร์มรอบใหม่เสมอ (เดิม `keepOpen` เปิดใหม่ยังเห็นผลรอบก่อน และ state ที่มี secret จะถูกส่งเป็น `prev` ไปกับ submit ถัดไป)
|
|
36
|
+
- `useCopyToClipboard`: ไม่มี `navigator.clipboard` แล้วลอง fallback แทนการเตือน "Clipboard not supported"
|
|
37
|
+
|
|
38
|
+
### ตรวจแล้วไม่แก้
|
|
39
|
+
|
|
40
|
+
- hydration mismatch ของ trigger ที่ render ผ่าน Button (PDF รายงาน, ห่อ wrapper `slot-props.ts`): showcase ใช้ `render={<Button/>}` กับ Dialog/Sheet/Popover/DropdownMenu/Tooltip ไม่มี hydration error ใน dev — น่าจะมาจาก wrapper `asChild` ของ PDF เอง ให้ PDF ลองเปลี่ยนเป็น `render=` ตรง ๆ ก่อน
|
|
41
|
+
- ข้อความ sr-only "Close" ใน `DialogContent`/`SheetContent` ยังแปลไม่ได้ — อยู่ในไฟล์ shadcn (ห้ามแก้มือ) รอตัดสินใจ
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- showcase: เมนู "ออกจากระบบ" ที่หัวเว็บดูเหมือนกดไม่ได้ — ออกแล้วกลับหน้าเดิม middleware ส่งไปล็อกอิน และ Pi Auth ยังมี session จึงล็อกอินกลับทันที → `UserButton afterSignOutUrl="/login"` จอดหน้าเข้าสู่ระบบ
|
|
46
|
+
|
|
5
47
|
## [0.13.1] - 2026-09-25
|
|
6
48
|
|
|
7
49
|
### Fixed
|
package/README.md
CHANGED
|
@@ -89,7 +89,7 @@ import { ChartContainer } from "@suphark/ui/components/ui/chart";
|
|
|
89
89
|
| `src/components/app-shell/*` | AppShell (sidebar + header + เนื้อหา), AppNav, AppBrand, UserMenu, HeaderBreadcrumbs, NavLink, filterNav/searchNav | export ผ่าน `@suphark/ui/next` (ใช้ next/link, next/navigation) |
|
|
90
90
|
| `src/components/design-system/*` | หน้า showcase (render ใน `apps/showcase`) | ไม่อยู่ใน barrel |
|
|
91
91
|
| `src/lib/*`, `src/hooks/*` | `cn`, formatter วันที่ไทย + `formatBytes`, สีสถานะ, permission context, `useIsMobile`, `useCopyToClipboard`, `useMounted`, `useTicker` | |
|
|
92
|
-
| `src/styles.css` | token (slate + success/warning/sidebar/chart), radius, ฟอนต์, scrollbar, dark variant | |
|
|
92
|
+
| `src/styles.css` | token (slate + success/warning/info + พื้นอ่อนตามโทน `*-subtle`/`*-border` + sidebar/chart), สเกลตัวอักษรตามบทบาท (`text-body` …), radius, ฟอนต์, scrollbar, dark variant | |
|
|
93
93
|
| `scripts/*.mjs` | audit บังคับ: Base UI props (`default*`/`onSelect`), typography scale | แอปปลายทางรันได้จาก `node_modules/@suphark/ui/scripts` |
|
|
94
94
|
|
|
95
95
|
### โครงแอป (AppShell)
|
|
@@ -200,7 +200,7 @@ npx suphark-ui-audit --strict # warning นับเป็น error (ใช้
|
|
|
200
200
|
| `radix-state` | error | `data-[state=open\|checked\|active…]` → `data-open` / `data-checked` / `data-active` / `data-popup-open` (Base UI ไม่มี data-state) |
|
|
201
201
|
| `arbitrary-text` | error | `text-[Npx]` → `text-xs … text-3xl` |
|
|
202
202
|
| `base-ui-props` | error | `default*` ที่ค่าเปลี่ยนได้ / `onSelect` บนเมนู (ดู `scripts/audit-base-ui-props.mjs`) |
|
|
203
|
-
| `raw-palette` | warning | `bg-red-500`, `text-slate-600` … → `bg-destructive`, `text-muted-foreground`, `bg-success`, `bg-warning`, `text-primary` |
|
|
203
|
+
| `raw-palette` | warning | `bg-red-500`, `text-slate-600` … → `bg-destructive`, `text-muted-foreground`, `bg-success`, `bg-warning`, `bg-info`, `text-primary`; พื้นอ่อน `bg-sky-50 text-sky-800` → `bg-info-subtle text-info-subtle-foreground` |
|
|
204
204
|
| `important` | warning (เฉพาะ `--important`) | utility ลงท้าย `!` ในโค้ดแพ็กเกจ |
|
|
205
205
|
|
|
206
206
|
ยกเว้นรายบรรทัด `// ui-audit-ok: <เหตุผล>` (บรรทัดเดียวกันหรือบรรทัดก่อน) — ยกเว้นทั้งไฟล์ `/* ui-audit-ok-file: <เหตุผล> */`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
|
|
@@ -72,6 +72,20 @@ export function ShowcaseFoundations() {
|
|
|
72
72
|
tailwind: "bg-success / text-success-foreground",
|
|
73
73
|
usage: "Approved status, Success badges, Positive rate variance",
|
|
74
74
|
},
|
|
75
|
+
{
|
|
76
|
+
name: "Info",
|
|
77
|
+
class: "bg-info text-info-foreground",
|
|
78
|
+
token: "var(--info)",
|
|
79
|
+
tailwind: "bg-info / text-info-foreground / text-info",
|
|
80
|
+
usage: "ข้อมูลประกอบ, รายการที่เลือก, ลิงก์ช่วยเหลือ — แทน sky-* ที่แอปเขียนเอง",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "Tone subtle (info / success / warning / destructive)",
|
|
84
|
+
class: "bg-info-subtle text-info-subtle-foreground border border-info-border",
|
|
85
|
+
token: "var(--{tone}-subtle) / var(--{tone}-border)",
|
|
86
|
+
tailwind: "bg-{tone}-subtle / text-{tone}-subtle-foreground / border-{tone}-border",
|
|
87
|
+
usage: "พื้นอ่อนของ Notice, StatusBadge, callout — สลับ dark ให้เอง ไม่ต้องใส่ dark: เอง",
|
|
88
|
+
},
|
|
75
89
|
{
|
|
76
90
|
name: "Border / Input / Ring",
|
|
77
91
|
class: "bg-card text-foreground border-2 border-ring",
|
|
@@ -29,6 +29,7 @@ export function ConfirmActionButton({
|
|
|
29
29
|
action,
|
|
30
30
|
data,
|
|
31
31
|
onDone,
|
|
32
|
+
redirectTo,
|
|
32
33
|
variant,
|
|
33
34
|
children,
|
|
34
35
|
...props
|
|
@@ -38,6 +39,8 @@ export function ConfirmActionButton({
|
|
|
38
39
|
data?: Record<string, string>;
|
|
39
40
|
/** เรียกหลัง action สำเร็จ (ก่อน refresh) เช่น toast */
|
|
40
41
|
onDone?: () => void;
|
|
42
|
+
/** สำเร็จแล้วไปหน้านี้แทน refresh (เช่น ลบรายการแล้วกลับหน้า list) */
|
|
43
|
+
redirectTo?: string;
|
|
41
44
|
}) {
|
|
42
45
|
const router = useRouter();
|
|
43
46
|
const text = useUiText();
|
|
@@ -78,7 +81,8 @@ export function ConfirmActionButton({
|
|
|
78
81
|
}
|
|
79
82
|
onDone?.();
|
|
80
83
|
setOpen(false);
|
|
81
|
-
router.
|
|
84
|
+
if (redirectTo) router.push(redirectTo);
|
|
85
|
+
else router.refresh();
|
|
82
86
|
});
|
|
83
87
|
}}
|
|
84
88
|
/>
|
|
@@ -1,31 +1,69 @@
|
|
|
1
1
|
import { cn } from "@suphark/ui/lib/utils";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* หน้าว่าง = คำเชิญให้ทำขั้นถัดไป: ไอคอน, ประโยคเดียวว่าคืออะไร, ปุ่มเดียวว่าทำอะไรต่อ
|
|
6
|
+
*
|
|
7
|
+
* - `variant`: "dashed" (ค่าเริ่มต้น กรอบเส้นประ) · "card" (พื้น card เส้นทึบ) · "plain" (ไม่มีกรอบ — วางใน card ที่มีอยู่แล้ว)
|
|
8
|
+
* - `size="compact"`: ระยะ/ไอคอนเล็ก สำหรับช่องแคบ เช่น panel ข้าง, popover, ตารางย่อย
|
|
9
|
+
* - `tone="destructive"`: ไอคอนสีแดง สำหรับสถานะว่างเพราะผิดพลาด (เช่น โหลดไม่ได้) — ข้อผิดพลาดทั้งหน้าใช้ ErrorState
|
|
10
|
+
* - `secondaryAction`: ปุ่มรอง วางข้าง `action`
|
|
11
|
+
*/
|
|
5
12
|
export function EmptyState({
|
|
6
13
|
icon,
|
|
7
14
|
title,
|
|
8
15
|
description,
|
|
9
16
|
action,
|
|
17
|
+
secondaryAction,
|
|
18
|
+
variant = "dashed",
|
|
19
|
+
size = "default",
|
|
20
|
+
tone = "default",
|
|
10
21
|
className,
|
|
11
22
|
}: {
|
|
12
23
|
icon?: ReactNode;
|
|
13
24
|
title: ReactNode;
|
|
14
25
|
description?: ReactNode;
|
|
15
26
|
action?: ReactNode;
|
|
27
|
+
secondaryAction?: ReactNode;
|
|
28
|
+
variant?: "dashed" | "card" | "plain";
|
|
29
|
+
size?: "default" | "compact";
|
|
30
|
+
tone?: "default" | "destructive";
|
|
16
31
|
className?: string;
|
|
17
32
|
}) {
|
|
33
|
+
const compact = size === "compact";
|
|
18
34
|
return (
|
|
19
35
|
<div
|
|
20
36
|
className={cn(
|
|
21
|
-
"flex flex-col items-center justify-center gap-2 rounded-xl
|
|
37
|
+
"flex flex-col items-center justify-center gap-2 rounded-xl text-center",
|
|
38
|
+
compact ? "px-4 py-6" : "px-6 py-12",
|
|
39
|
+
variant === "dashed" && "border border-dashed",
|
|
40
|
+
variant === "card" && "border bg-card text-card-foreground",
|
|
22
41
|
className,
|
|
23
42
|
)}
|
|
24
43
|
>
|
|
25
|
-
{icon &&
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
44
|
+
{icon && (
|
|
45
|
+
<div
|
|
46
|
+
className={cn(
|
|
47
|
+
"mb-1",
|
|
48
|
+
compact ? "[&_svg]:size-6" : "[&_svg]:size-8",
|
|
49
|
+
tone === "destructive" ? "text-destructive" : "text-muted-foreground",
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
{icon}
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
<p className="font-medium text-sm">{title}</p>
|
|
56
|
+
{description && (
|
|
57
|
+
<p className={cn("max-w-sm text-muted-foreground", compact ? "text-xs" : "text-sm")}>
|
|
58
|
+
{description}
|
|
59
|
+
</p>
|
|
60
|
+
)}
|
|
61
|
+
{(action || secondaryAction) && (
|
|
62
|
+
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
|
|
63
|
+
{action}
|
|
64
|
+
{secondaryAction}
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
29
67
|
</div>
|
|
30
68
|
);
|
|
31
69
|
}
|
|
@@ -67,16 +67,33 @@ export function LabeledField({
|
|
|
67
67
|
* Checkbox ของ shadcn + label บรรทัดเดียว — ใส่ `name` แล้วส่ง "on" ใน FormData ของ server action ได้
|
|
68
68
|
*
|
|
69
69
|
* <CheckboxField name="allowSignup" defaultChecked label="เปิดให้สมัครเอง" />
|
|
70
|
+
* <CheckboxField name="overwrite" label="เขียนทับค่าเดิม" hint="คีย์ที่มีอยู่แล้วจะถูกแทนที่" />
|
|
70
71
|
*/
|
|
71
72
|
export function CheckboxField({
|
|
72
73
|
label,
|
|
74
|
+
hint,
|
|
73
75
|
className,
|
|
74
76
|
...props
|
|
75
|
-
}: ComponentProps<typeof Checkbox> & {
|
|
77
|
+
}: ComponentProps<typeof Checkbox> & {
|
|
78
|
+
label: ReactNode;
|
|
79
|
+
/** คำอธิบายใต้ label (เยื้องตรงกับ label) */
|
|
80
|
+
hint?: ReactNode;
|
|
81
|
+
}) {
|
|
82
|
+
if (!hint) {
|
|
83
|
+
return (
|
|
84
|
+
<Label className={cn("flex items-center gap-2 font-normal", className)}>
|
|
85
|
+
<Checkbox {...props} />
|
|
86
|
+
<span className="text-sm">{label}</span>
|
|
87
|
+
</Label>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
76
90
|
return (
|
|
77
|
-
<Label className={cn("flex items-
|
|
78
|
-
<Checkbox {...props} />
|
|
79
|
-
<span className="
|
|
91
|
+
<Label className={cn("flex items-start gap-2 font-normal", className)}>
|
|
92
|
+
<Checkbox className="mt-0.5" {...props} />
|
|
93
|
+
<span className="flex flex-col gap-0.5">
|
|
94
|
+
<span className="text-sm">{label}</span>
|
|
95
|
+
<span className="text-muted-foreground text-xs">{hint}</span>
|
|
96
|
+
</span>
|
|
80
97
|
</Label>
|
|
81
98
|
);
|
|
82
99
|
}
|
|
@@ -41,6 +41,10 @@ export interface SimpleSelectProps<T extends string = string> {
|
|
|
41
41
|
triggerClassName?: string;
|
|
42
42
|
contentClassName?: string;
|
|
43
43
|
ariaLabel?: string;
|
|
44
|
+
/** ชื่อฟิลด์ใน FormData (server action) — ส่งค่าที่เลือกผ่าน hidden input ของ Base UI */
|
|
45
|
+
name?: string;
|
|
46
|
+
/** บังคับเลือกก่อน submit ฟอร์ม */
|
|
47
|
+
required?: boolean;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
function isGroupList<T extends string>(
|
|
@@ -63,6 +67,8 @@ export function SimpleSelect<T extends string = string>({
|
|
|
63
67
|
triggerClassName,
|
|
64
68
|
contentClassName,
|
|
65
69
|
ariaLabel,
|
|
70
|
+
name,
|
|
71
|
+
required,
|
|
66
72
|
}: SimpleSelectProps<T>) {
|
|
67
73
|
const { flatOptions, groups } = React.useMemo(() => {
|
|
68
74
|
if (isGroupList(options)) {
|
|
@@ -122,6 +128,8 @@ export function SimpleSelect<T extends string = string>({
|
|
|
122
128
|
}
|
|
123
129
|
}}
|
|
124
130
|
disabled={disabled}
|
|
131
|
+
name={name}
|
|
132
|
+
required={required}
|
|
125
133
|
>
|
|
126
134
|
<SelectTrigger
|
|
127
135
|
size={size}
|
|
@@ -43,6 +43,7 @@ export function FormDialog<TData = undefined>({
|
|
|
43
43
|
open: controlledOpen,
|
|
44
44
|
onOpenChange,
|
|
45
45
|
keepOpen = false,
|
|
46
|
+
renderSuccess,
|
|
46
47
|
contentClassName,
|
|
47
48
|
}: {
|
|
48
49
|
/** ปุ่มเปิด (ต้องเป็น <Button> ของ shadcn — Base UI จะใช้ตัวนี้เป็น trigger โดยตรง) */
|
|
@@ -58,65 +59,123 @@ export function FormDialog<TData = undefined>({
|
|
|
58
59
|
onOpenChange?: (open: boolean) => void;
|
|
59
60
|
/** ผลลัพธ์ต้องอ่านหลังสำเร็จ (เช่น secret แสดงครั้งเดียว) → ไม่ปิดเอง */
|
|
60
61
|
keepOpen?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* สำเร็จแล้วแสดงผลแทนฟอร์ม (เช่น secret/token ที่ออกใหม่ แสดงครั้งเดียว) พร้อมปุ่ม "เสร็จแล้ว" — ไม่ปิดเอง
|
|
64
|
+
* ปิดแล้วเปิดใหม่ = ฟอร์มว่าง รอบใหม่ (state เดิมรวม data ถูกทิ้ง ไม่ถูกส่งกลับไปกับ submit ครั้งถัดไป)
|
|
65
|
+
*/
|
|
66
|
+
renderSuccess?: (state: Extract<FormState<TData>, { ok: true }>) => ReactNode;
|
|
61
67
|
/** ความกว้าง/คลาสของ DialogContent (ค่าเริ่มต้น sm:max-w-md) */
|
|
62
68
|
contentClassName?: string;
|
|
63
69
|
}) {
|
|
64
|
-
const router = useRouter();
|
|
65
|
-
const text = useUiText();
|
|
66
70
|
const [uncontrolled, setUncontrolled] = useState(false);
|
|
67
71
|
const open = controlledOpen ?? uncontrolled;
|
|
68
72
|
const setOpen = onOpenChange ?? setUncontrolled;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
76
|
+
{trigger && <DialogTrigger render={trigger} />}
|
|
77
|
+
{/* จำกัดความสูงไว้ในจอ: header/footer ตรึง เฉพาะเนื้อฟอร์มเลื่อน (ฟอร์มยาวไม่ล้นในหน้าต่างเตี้ย) */}
|
|
78
|
+
<DialogContent
|
|
79
|
+
className={cn("flex max-h-[calc(100dvh-2rem)] flex-col", contentClassName ?? "sm:max-w-md")}
|
|
80
|
+
>
|
|
81
|
+
{/* state ของ action อยู่ในเนื้อ dialog ซึ่ง unmount ตอนปิด → เปิดใหม่ได้ฟอร์มรอบใหม่ */}
|
|
82
|
+
<FormDialogBody
|
|
83
|
+
title={title}
|
|
84
|
+
description={description}
|
|
85
|
+
action={action}
|
|
86
|
+
submitLabel={submitLabel}
|
|
87
|
+
keepOpen={keepOpen || renderSuccess !== undefined}
|
|
88
|
+
renderSuccess={renderSuccess}
|
|
89
|
+
close={() => setOpen(false)}
|
|
90
|
+
>
|
|
91
|
+
{children}
|
|
92
|
+
</FormDialogBody>
|
|
93
|
+
</DialogContent>
|
|
94
|
+
</Dialog>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function FormDialogBody<TData>({
|
|
99
|
+
title,
|
|
100
|
+
description,
|
|
101
|
+
action,
|
|
102
|
+
submitLabel,
|
|
103
|
+
children,
|
|
104
|
+
keepOpen,
|
|
105
|
+
renderSuccess,
|
|
106
|
+
close,
|
|
107
|
+
}: {
|
|
108
|
+
title: ReactNode;
|
|
109
|
+
description?: ReactNode;
|
|
110
|
+
action: FormAction<TData>;
|
|
111
|
+
submitLabel?: string;
|
|
112
|
+
children: ReactNode | ((ctx: FormRenderContext) => ReactNode);
|
|
113
|
+
keepOpen: boolean;
|
|
114
|
+
renderSuccess?: (state: Extract<FormState<TData>, { ok: true }>) => ReactNode;
|
|
115
|
+
close: () => void;
|
|
116
|
+
}) {
|
|
117
|
+
const router = useRouter();
|
|
118
|
+
const text = useUiText();
|
|
69
119
|
const [state, formAction, pending] = useActionState<FormState<TData>, FormData>(action, null);
|
|
70
120
|
const [submitted, setSubmitted] = useState(0);
|
|
71
121
|
|
|
72
122
|
// biome-ignore lint/correctness/useExhaustiveDependencies: ทำงานเมื่อผลของการ submit เปลี่ยนเท่านั้น
|
|
73
123
|
useEffect(() => {
|
|
74
124
|
if (state?.ok && !keepOpen) {
|
|
75
|
-
|
|
125
|
+
close();
|
|
76
126
|
router.refresh();
|
|
77
127
|
} else if (state?.ok) {
|
|
78
128
|
router.refresh();
|
|
79
129
|
}
|
|
80
130
|
}, [state, submitted]);
|
|
81
131
|
|
|
132
|
+
if (state?.ok && renderSuccess) {
|
|
133
|
+
return (
|
|
134
|
+
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
|
135
|
+
<DialogHeader>
|
|
136
|
+
<DialogTitle>{title}</DialogTitle>
|
|
137
|
+
{description && <DialogDescription>{description}</DialogDescription>}
|
|
138
|
+
</DialogHeader>
|
|
139
|
+
<div className="-mx-1 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-1">
|
|
140
|
+
{renderSuccess(state)}
|
|
141
|
+
</div>
|
|
142
|
+
<DialogFooter>
|
|
143
|
+
<Button type="button" onClick={close}>
|
|
144
|
+
{text.done}
|
|
145
|
+
</Button>
|
|
146
|
+
</DialogFooter>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
82
151
|
return (
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
|
|
112
|
-
{text.cancel}
|
|
113
|
-
</Button>
|
|
114
|
-
<Button type="submit" disabled={pending}>
|
|
115
|
-
{submitLabel ?? text.save}
|
|
116
|
-
</Button>
|
|
117
|
-
</DialogFooter>
|
|
118
|
-
</form>
|
|
119
|
-
</DialogContent>
|
|
120
|
-
</Dialog>
|
|
152
|
+
<form
|
|
153
|
+
// ไม่ใช้ `action=` — React 19 รีเซ็ตฟอร์ม uncontrolled หลัง action จบทุกครั้ง (แม้คืน error)
|
|
154
|
+
// ทำให้ค่าที่พิมพ์หาย; เรียก action ใน transition เองแทน ฟอร์มจึงคงค่าไว้ให้แก้ต่อได้
|
|
155
|
+
onSubmit={(e) => {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
const fd = new FormData(e.currentTarget);
|
|
158
|
+
setSubmitted((n) => n + 1);
|
|
159
|
+
startTransition(() => formAction(fd));
|
|
160
|
+
}}
|
|
161
|
+
className="flex min-h-0 flex-1 flex-col gap-4"
|
|
162
|
+
>
|
|
163
|
+
<DialogHeader>
|
|
164
|
+
<DialogTitle>{title}</DialogTitle>
|
|
165
|
+
{description && <DialogDescription>{description}</DialogDescription>}
|
|
166
|
+
</DialogHeader>
|
|
167
|
+
<div className="-mx-1 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-1">
|
|
168
|
+
{typeof children === "function" ? children(formRenderContext(state, pending)) : children}
|
|
169
|
+
</div>
|
|
170
|
+
<FormMessage state={state} />
|
|
171
|
+
<DialogFooter>
|
|
172
|
+
<Button type="button" variant="ghost" onClick={close}>
|
|
173
|
+
{text.cancel}
|
|
174
|
+
</Button>
|
|
175
|
+
<Button type="submit" disabled={pending}>
|
|
176
|
+
{submitLabel ?? text.save}
|
|
177
|
+
</Button>
|
|
178
|
+
</DialogFooter>
|
|
179
|
+
</form>
|
|
121
180
|
);
|
|
122
181
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@suphark/ui/components/ui/tooltip";
|
|
4
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
5
|
+
import { InfoIcon } from "lucide-react";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* คำอธิบายที่ไม่ควรกินพื้นที่ถาวร — เก็บไว้หลังไอคอน (i) ท้ายหัวข้อ แล้วค่อยแสดงเมื่อวางเมาส์/โฟกัส
|
|
10
|
+
* ใช้กับคำอธิบายที่คนซึ่งรู้อยู่แล้วไม่ต้องอ่านซ้ำทุกครั้ง (เช่นหัวข้อการ์ดในหน้าตั้งค่า)
|
|
11
|
+
*
|
|
12
|
+
* trigger เป็นปุ่มจริงเพื่อให้เข้าถึงได้ด้วยคีย์บอร์ดและจอสัมผัส ไม่ใช่ hover อย่างเดียว —
|
|
13
|
+
* `label` คือสิ่งที่ screen reader อ่าน ควรบอกว่าเป็นคำอธิบายของอะไร
|
|
14
|
+
*/
|
|
15
|
+
export function InfoHint({
|
|
16
|
+
children,
|
|
17
|
+
label,
|
|
18
|
+
side = "top",
|
|
19
|
+
className,
|
|
20
|
+
}: {
|
|
21
|
+
/** เนื้อหาใน tooltip */
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
/** ชื่อที่ screen reader อ่าน เช่น "คำอธิบาย Attribute" */
|
|
24
|
+
label: string;
|
|
25
|
+
side?: "top" | "bottom" | "left" | "right";
|
|
26
|
+
className?: string;
|
|
27
|
+
}) {
|
|
28
|
+
return (
|
|
29
|
+
<Tooltip>
|
|
30
|
+
<TooltipTrigger
|
|
31
|
+
aria-label={label}
|
|
32
|
+
className={cn(
|
|
33
|
+
"inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50",
|
|
34
|
+
className,
|
|
35
|
+
)}
|
|
36
|
+
>
|
|
37
|
+
<InfoIcon className="size-3.5" aria-hidden />
|
|
38
|
+
</TooltipTrigger>
|
|
39
|
+
<TooltipContent side={side} className="max-w-xs text-pretty leading-relaxed">
|
|
40
|
+
{children}
|
|
41
|
+
</TooltipContent>
|
|
42
|
+
</Tooltip>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* ui-audit-ok-file: โทน info/success/warning/danger ใช้ palette 50/900 คู่ dark โดยตั้งใจ — รอตัดสินใจ token ระดับ tone (todo) */
|
|
2
1
|
import { Alert, AlertDescription, AlertTitle } from "@suphark/ui/components/ui/alert";
|
|
3
2
|
import { cn } from "@suphark/ui/lib/utils";
|
|
4
3
|
import type { ComponentProps, ReactNode } from "react";
|
|
@@ -6,14 +5,12 @@ import type { Tone } from "./status-badge";
|
|
|
6
5
|
|
|
7
6
|
export type NoticeTone = Exclude<Tone, "neutral">;
|
|
8
7
|
|
|
8
|
+
/** token โทนใน styles.css (สลับ dark ให้เอง) */
|
|
9
9
|
const TONE: Record<NoticeTone, string> = {
|
|
10
|
-
info: "border-
|
|
11
|
-
success:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100",
|
|
15
|
-
danger:
|
|
16
|
-
"border-red-200 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100",
|
|
10
|
+
info: "border-info-border bg-info-subtle text-info-subtle-foreground",
|
|
11
|
+
success: "border-success-border bg-success-subtle text-success-subtle-foreground",
|
|
12
|
+
warning: "border-warning-border bg-warning-subtle text-warning-subtle-foreground",
|
|
13
|
+
danger: "border-destructive-border bg-destructive-subtle text-destructive-subtle-foreground",
|
|
17
14
|
};
|
|
18
15
|
|
|
19
16
|
/** Alert ของ shadcn + สีตามความหมาย (info/success/warning/danger) */
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* ui-audit-ok-file: โทน badge ใช้ palette 50/800 คู่ dark โดยตั้งใจ — รอตัดสินใจ token ระดับ tone (todo) */
|
|
2
1
|
import { Badge } from "@suphark/ui/components/ui/badge";
|
|
3
2
|
import { cn } from "@suphark/ui/lib/utils";
|
|
4
3
|
import type { ComponentProps } from "react";
|
|
@@ -7,13 +6,10 @@ export type Tone = "neutral" | "success" | "warning" | "danger" | "info";
|
|
|
7
6
|
|
|
8
7
|
const TONE: Record<Tone, string> = {
|
|
9
8
|
neutral: "border-transparent bg-muted text-muted-foreground",
|
|
10
|
-
success:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
danger:
|
|
15
|
-
"border-red-200 bg-red-50 text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200",
|
|
16
|
-
info: "border-sky-200 bg-sky-50 text-sky-800 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200",
|
|
9
|
+
success: "border-success-border bg-success-subtle text-success-subtle-foreground",
|
|
10
|
+
warning: "border-warning-border bg-warning-subtle text-warning-subtle-foreground",
|
|
11
|
+
danger: "border-destructive-border bg-destructive-subtle text-destructive-subtle-foreground",
|
|
12
|
+
info: "border-info-border bg-info-subtle text-info-subtle-foreground",
|
|
17
13
|
};
|
|
18
14
|
|
|
19
15
|
/** Badge ของ shadcn (variant outline) + สีตามความหมายของสถานะ */
|
|
@@ -1,27 +1,64 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
3
|
+
import { copyText } from "@suphark/ui/lib/copy-text";
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
5
|
+
import { toast as sonner } from "sonner";
|
|
6
|
+
|
|
7
|
+
export interface CopyToClipboardLabels {
|
|
8
|
+
success: string;
|
|
9
|
+
failed: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UseCopyToClipboardOptions {
|
|
13
|
+
/** false = ไม่แสดง toast (แอปแสดง ✓ ที่ปุ่มเอง); object = ข้อความของแอป (แปลภาษาได้) */
|
|
14
|
+
toast?: boolean | Partial<CopyToClipboardLabels>;
|
|
15
|
+
/** ms ที่ `isCopied`/`copiedKey` ค้างไว้หลังคัดลอก */
|
|
16
|
+
timeout?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_LABELS: CopyToClipboardLabels = {
|
|
20
|
+
success: "Copied to clipboard",
|
|
21
|
+
failed: "Failed to copy",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* คัดลอกข้อความ (มี fallback execCommand สำหรับ browser/webview ที่บล็อก clipboard API)
|
|
26
|
+
*
|
|
27
|
+
* const { copyToClipboard, copiedKey } = useCopyToClipboard({ toast: { success: "คัดลอกแล้ว", failed: "คัดลอกไม่สำเร็จ" } });
|
|
28
|
+
* copyToClipboard(row.value, row.id) // หลายปุ่มในหน้าเดียว → copiedKey บอกว่าปุ่มไหนเพิ่งคัดลอก
|
|
29
|
+
*/
|
|
30
|
+
export function useCopyToClipboard(options: UseCopyToClipboardOptions = {}) {
|
|
31
|
+
const { toast = true, timeout = 2000 } = options;
|
|
32
|
+
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
|
33
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
34
|
+
|
|
35
|
+
useEffect(
|
|
36
|
+
() => () => {
|
|
37
|
+
if (timer.current) clearTimeout(timer.current);
|
|
38
|
+
},
|
|
39
|
+
[],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const labels = typeof toast === "object" ? { ...DEFAULT_LABELS, ...toast } : DEFAULT_LABELS;
|
|
43
|
+
const showToast = toast !== false;
|
|
44
|
+
const { success, failed } = labels;
|
|
45
|
+
|
|
46
|
+
const copyToClipboard = useCallback(
|
|
47
|
+
async (text: string, key = "default"): Promise<boolean> => {
|
|
48
|
+
const ok = await copyText(text);
|
|
49
|
+
if (timer.current) clearTimeout(timer.current);
|
|
50
|
+
if (!ok) {
|
|
51
|
+
setCopiedKey(null);
|
|
52
|
+
if (showToast) sonner.error(failed);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
setCopiedKey(key);
|
|
56
|
+
if (showToast) sonner.success(success);
|
|
57
|
+
timer.current = setTimeout(() => setCopiedKey(null), timeout);
|
|
58
|
+
return true;
|
|
59
|
+
},
|
|
60
|
+
[showToast, success, failed, timeout],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return { isCopied: copiedKey !== null, copiedKey, copyToClipboard };
|
|
27
64
|
}
|
package/src/index.ts
CHANGED
|
@@ -95,6 +95,7 @@ export {
|
|
|
95
95
|
type IconPickerProps,
|
|
96
96
|
type IconPickerText,
|
|
97
97
|
} from "./components/shared/icon-picker";
|
|
98
|
+
export { InfoHint } from "./components/shared/info-hint";
|
|
98
99
|
export { Initials, initialsOf } from "./components/shared/initials";
|
|
99
100
|
export { ErrorState, type RouteError } from "./components/shared/layout/error-state";
|
|
100
101
|
export {
|
package/src/lib/formatters.ts
CHANGED
|
@@ -49,15 +49,24 @@ export function formatThaiFullDate(date: Date | string | number | null | undefin
|
|
|
49
49
|
* ขนาดไฟล์อ่านง่าย (ฐาน 1024): 0 B, 1.5 KB, 12.3 MB, 1.2 GB — ตัวเลขทศนิยม 1 ตำแหน่งเมื่อ < 10
|
|
50
50
|
* formatBytes(1536) → "1.5 KB"
|
|
51
51
|
*/
|
|
52
|
-
export function formatBytes(
|
|
52
|
+
export function formatBytes(
|
|
53
|
+
bytes: number | null | undefined,
|
|
54
|
+
locale = "th-TH",
|
|
55
|
+
options: {
|
|
56
|
+
/** หน่วยเล็กสุดที่แสดง เช่น "MB" → 200 KB แสดงเป็น "0.2 MB" (หน้าที่แสดงโควตา/ไฟล์ใหญ่) */
|
|
57
|
+
minUnit?: "B" | "KB" | "MB" | "GB";
|
|
58
|
+
} = {},
|
|
59
|
+
): string {
|
|
53
60
|
if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return "—";
|
|
54
61
|
const units = ["B", "KB", "MB", "GB", "TB"] as const;
|
|
62
|
+
const minIndex = options.minUnit ? units.indexOf(options.minUnit) : 0;
|
|
55
63
|
let value = bytes;
|
|
56
64
|
let i = 0;
|
|
57
|
-
while (value >= 1024 && i < units.length - 1) {
|
|
65
|
+
while ((value >= 1024 || i < minIndex) && i < units.length - 1) {
|
|
58
66
|
value /= 1024;
|
|
59
67
|
i += 1;
|
|
60
68
|
}
|
|
61
|
-
|
|
69
|
+
// ค่า < 1 เกิดได้เฉพาะเมื่อบังคับ minUnit → ให้ทศนิยม 2 ตำแหน่งไม่ให้ปัดเป็น 0
|
|
70
|
+
const digits = i === 0 ? 0 : value < 1 ? 2 : value < 10 ? 1 : 0;
|
|
62
71
|
return `${value.toLocaleString(locale, { maximumFractionDigits: digits })} ${units[i]}`;
|
|
63
72
|
}
|
package/src/styles.css
CHANGED
|
@@ -50,6 +50,21 @@
|
|
|
50
50
|
--color-warning: var(--warning);
|
|
51
51
|
--color-warning-foreground: var(--warning-foreground);
|
|
52
52
|
--color-warning-muted-foreground: var(--warning-muted-foreground);
|
|
53
|
+
--color-info: var(--info);
|
|
54
|
+
--color-info-foreground: var(--info-foreground);
|
|
55
|
+
/* พื้นอ่อน/ตัวอักษร/เส้นขอบของโทน (Notice, StatusBadge, callout ของแอป) — bg-info-subtle text-info-subtle-foreground border-info-border */
|
|
56
|
+
--color-info-subtle: var(--info-subtle);
|
|
57
|
+
--color-info-subtle-foreground: var(--info-subtle-foreground);
|
|
58
|
+
--color-info-border: var(--info-border);
|
|
59
|
+
--color-success-subtle: var(--success-subtle);
|
|
60
|
+
--color-success-subtle-foreground: var(--success-subtle-foreground);
|
|
61
|
+
--color-success-border: var(--success-border);
|
|
62
|
+
--color-warning-subtle: var(--warning-subtle);
|
|
63
|
+
--color-warning-subtle-foreground: var(--warning-subtle-foreground);
|
|
64
|
+
--color-warning-border: var(--warning-border);
|
|
65
|
+
--color-destructive-subtle: var(--destructive-subtle);
|
|
66
|
+
--color-destructive-subtle-foreground: var(--destructive-subtle-foreground);
|
|
67
|
+
--color-destructive-border: var(--destructive-border);
|
|
53
68
|
--color-accent-foreground: var(--accent-foreground);
|
|
54
69
|
--color-accent: var(--accent);
|
|
55
70
|
--color-muted-foreground: var(--muted-foreground);
|
|
@@ -142,6 +157,21 @@
|
|
|
142
157
|
--warning: oklch(0.852 0.199 91.936);
|
|
143
158
|
--warning-foreground: oklch(0.286 0.066 53.813);
|
|
144
159
|
--warning-muted-foreground: oklch(0.421 0.095 57.708);
|
|
160
|
+
/* โทน: ค่าเท่า Tailwind sky/emerald/amber/red ที่ Notice/StatusBadge เคยใช้ตรง ๆ (50 พื้น · 200 ขอบ · 800 ตัวอักษร) */
|
|
161
|
+
--info: oklch(0.588 0.158 241.966);
|
|
162
|
+
--info-foreground: oklch(0.984 0.003 247.858);
|
|
163
|
+
--info-subtle: oklch(0.977 0.013 236.62);
|
|
164
|
+
--info-subtle-foreground: oklch(0.443 0.11 240.79);
|
|
165
|
+
--info-border: oklch(0.901 0.058 230.902);
|
|
166
|
+
--success-subtle: oklch(0.979 0.021 166.113);
|
|
167
|
+
--success-subtle-foreground: oklch(0.432 0.095 166.913);
|
|
168
|
+
--success-border: oklch(0.905 0.093 164.15);
|
|
169
|
+
--warning-subtle: oklch(0.987 0.022 95.277);
|
|
170
|
+
--warning-subtle-foreground: oklch(0.473 0.137 46.201);
|
|
171
|
+
--warning-border: oklch(0.924 0.12 95.746);
|
|
172
|
+
--destructive-subtle: oklch(0.971 0.013 17.38);
|
|
173
|
+
--destructive-subtle-foreground: oklch(0.444 0.177 26.899);
|
|
174
|
+
--destructive-border: oklch(0.885 0.062 18.334);
|
|
145
175
|
--border: oklch(0.929 0.013 255.508);
|
|
146
176
|
--input: oklch(0.929 0.013 255.508);
|
|
147
177
|
--ring: oklch(0.704 0.04 256.788);
|
|
@@ -183,6 +213,21 @@
|
|
|
183
213
|
--warning: oklch(0.852 0.199 91.936);
|
|
184
214
|
--warning-foreground: oklch(0.286 0.066 53.813);
|
|
185
215
|
--warning-muted-foreground: oklch(0.905 0.182 98.111);
|
|
216
|
+
/* dark: 950 พื้น · 900 ขอบ · 200 ตัวอักษร · info ทึบ = sky-400 */
|
|
217
|
+
--info: oklch(0.746 0.16 232.661);
|
|
218
|
+
--info-foreground: oklch(0.293 0.066 243.157);
|
|
219
|
+
--info-subtle: oklch(0.293 0.066 243.157);
|
|
220
|
+
--info-subtle-foreground: oklch(0.901 0.058 230.902);
|
|
221
|
+
--info-border: oklch(0.391 0.09 240.876);
|
|
222
|
+
--success-subtle: oklch(0.262 0.051 172.552);
|
|
223
|
+
--success-subtle-foreground: oklch(0.905 0.093 164.15);
|
|
224
|
+
--success-border: oklch(0.378 0.077 168.94);
|
|
225
|
+
--warning-subtle: oklch(0.279 0.077 45.635);
|
|
226
|
+
--warning-subtle-foreground: oklch(0.924 0.12 95.746);
|
|
227
|
+
--warning-border: oklch(0.414 0.112 45.904);
|
|
228
|
+
--destructive-subtle: oklch(0.258 0.092 26.042);
|
|
229
|
+
--destructive-subtle-foreground: oklch(0.885 0.062 18.334);
|
|
230
|
+
--destructive-border: oklch(0.396 0.141 25.723);
|
|
186
231
|
--border: oklch(1 0 0 / 10%);
|
|
187
232
|
--input: oklch(1 0 0 / 15%);
|
|
188
233
|
--ring: oklch(0.551 0.027 264.364);
|