@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.
- package/CHANGELOG.md +47 -0
- package/package.json +14 -13
- package/scripts/audit.mjs +0 -0
- package/src/components/app-shell/app-shell.tsx +15 -9
- 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/confirm-dialog.tsx +41 -2
- package/src/components/shared/count-badge.tsx +41 -0
- package/src/components/shared/filter-bar.tsx +108 -0
- package/src/components/shared/form/slider-field.tsx +79 -0
- package/src/components/shared/form-dialog.tsx +43 -4
- package/src/components/shared/month-stepper.tsx +124 -0
- package/src/components/shared/save-status.tsx +99 -0
- package/src/components/shared/segmented-control.tsx +73 -0
- package/src/components/shared/settings-layout.tsx +170 -0
- package/src/components/shared/settings-row.tsx +118 -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/skip-link.tsx +30 -0
- package/src/components/shared/stat.tsx +110 -6
- package/src/components/shared/ui-text.tsx +35 -0
- package/src/components/shared/user-identity.tsx +61 -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/hooks/use-confirm.tsx +2 -0
- package/src/index.ts +48 -1
- package/src/lib/month.ts +60 -0
- package/src/styles.css +10 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { NativeSelect, NativeSelectOption } from "@suphark/ui/components/ui/native-select";
|
|
5
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
6
|
+
import { XIcon } from "lucide-react";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
import { SearchInput } from "./form/search-input";
|
|
9
|
+
import { useUiText } from "./ui-text";
|
|
10
|
+
|
|
11
|
+
export interface FilterBarFilter {
|
|
12
|
+
id: string;
|
|
13
|
+
/** aria-label ของ select (ไม่มี label ที่มองเห็น) */
|
|
14
|
+
label: string;
|
|
15
|
+
value: string;
|
|
16
|
+
onValueChange: (value: string) => void;
|
|
17
|
+
options: ReadonlyArray<{ value: string; label: string }>;
|
|
18
|
+
/** ค่าที่หมายถึง "ทั้งหมด" (ไม่กรอง) — ใช้ตัดสินว่ามีตัวกรองค้างไหม และค่าที่ปุ่มล้างตั้งกลับ (ค่าเริ่มต้น "") */
|
|
19
|
+
allValue?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* แถบค้นหา + ตัวกรองเหนือตาราง/รายการแบบ client-side (ยกจาก Pi Auth — มี 14 หน้าเขียนซ้ำ):
|
|
25
|
+
* ช่องค้นหาซ้าย · select ตัวกรองขวา · ปุ่ม "ล้างตัวกรอง" โผล่เมื่อมีอะไรค้าง
|
|
26
|
+
* ตารางที่ใช้ tanstack ให้ใช้ toolbar ของ `@suphark/ui/data-table` แทน
|
|
27
|
+
*
|
|
28
|
+
* <FilterBar
|
|
29
|
+
* search={{ value: q, onValueChange: setQ, placeholder: "ค้นหาชื่อหรืออีเมล" }}
|
|
30
|
+
* filters={[{ id: "role", label: "บทบาท", value: role, onValueChange: setRole, allValue: "ALL",
|
|
31
|
+
* options: [{ value: "ALL", label: "ทุกบทบาท" }, { value: "admin", label: "ผู้ดูแล" }] }]}
|
|
32
|
+
* />
|
|
33
|
+
*/
|
|
34
|
+
export function FilterBar({
|
|
35
|
+
search,
|
|
36
|
+
filters = [],
|
|
37
|
+
onClear,
|
|
38
|
+
active,
|
|
39
|
+
children,
|
|
40
|
+
className,
|
|
41
|
+
}: {
|
|
42
|
+
search?: { value: string; onValueChange: (value: string) => void; placeholder?: string };
|
|
43
|
+
filters?: FilterBarFilter[];
|
|
44
|
+
/** ไม่ส่ง = ล้างช่องค้นหา + ตั้งทุกตัวกรองกลับ `allValue` ให้เอง */
|
|
45
|
+
onClear?: () => void;
|
|
46
|
+
/** บังคับสถานะ "มีตัวกรองค้าง" (เช่นมีตัวกรองใน children) — ไม่ส่ง = คำนวณจาก search/filters */
|
|
47
|
+
active?: boolean;
|
|
48
|
+
/** ตัวกรองแบบอื่นที่แอปประกอบเอง (DateRangePicker, ToggleGroup …) วางต่อจาก select */
|
|
49
|
+
children?: ReactNode;
|
|
50
|
+
className?: string;
|
|
51
|
+
}) {
|
|
52
|
+
const text = useUiText();
|
|
53
|
+
const isActive =
|
|
54
|
+
active ?? ((search?.value ?? "") !== "" || filters.some((f) => f.value !== (f.allValue ?? "")));
|
|
55
|
+
|
|
56
|
+
const clear = () => {
|
|
57
|
+
if (onClear) return onClear();
|
|
58
|
+
search?.onValueChange("");
|
|
59
|
+
for (const f of filters) f.onValueChange(f.allValue ?? "");
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
data-slot="filter-bar"
|
|
65
|
+
role="search"
|
|
66
|
+
aria-label={text.filters?.label ?? "ตัวกรอง"}
|
|
67
|
+
className={cn(
|
|
68
|
+
"flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between",
|
|
69
|
+
className,
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{search && (
|
|
73
|
+
<SearchInput
|
|
74
|
+
value={search.value}
|
|
75
|
+
onValueChange={search.onValueChange}
|
|
76
|
+
placeholder={search.placeholder}
|
|
77
|
+
groupClassName="lg:max-w-sm flex-1"
|
|
78
|
+
/>
|
|
79
|
+
)}
|
|
80
|
+
{(filters.length > 0 || children || isActive) && (
|
|
81
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
82
|
+
{filters.map((f) => (
|
|
83
|
+
<NativeSelect
|
|
84
|
+
key={f.id}
|
|
85
|
+
aria-label={f.label}
|
|
86
|
+
value={f.value}
|
|
87
|
+
onChange={(e) => f.onValueChange(e.target.value)}
|
|
88
|
+
className={cn("w-auto max-w-48", f.className)}
|
|
89
|
+
>
|
|
90
|
+
{f.options.map((o) => (
|
|
91
|
+
<NativeSelectOption key={o.value} value={o.value}>
|
|
92
|
+
{o.label}
|
|
93
|
+
</NativeSelectOption>
|
|
94
|
+
))}
|
|
95
|
+
</NativeSelect>
|
|
96
|
+
))}
|
|
97
|
+
{children}
|
|
98
|
+
{isActive && (
|
|
99
|
+
<Button type="button" variant="ghost" size="sm" onClick={clear}>
|
|
100
|
+
<XIcon />
|
|
101
|
+
{text.filters?.clear ?? "ล้างตัวกรอง"}
|
|
102
|
+
</Button>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Label } from "@suphark/ui/components/ui/label";
|
|
4
|
+
import { Slider } from "@suphark/ui/components/ui/slider";
|
|
5
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
6
|
+
import { type ReactNode, useId } from "react";
|
|
7
|
+
|
|
8
|
+
export interface SliderFieldProps {
|
|
9
|
+
label: ReactNode;
|
|
10
|
+
value: number;
|
|
11
|
+
onValueChange: (value: number) => void;
|
|
12
|
+
min?: number;
|
|
13
|
+
max?: number;
|
|
14
|
+
step?: number;
|
|
15
|
+
/** หน่วยต่อท้ายตัวเลข เช่น "%" หรือ " px" */
|
|
16
|
+
unit?: string;
|
|
17
|
+
/** จัดรูปแบบค่าที่แสดงเอง (ทับ `unit`) */
|
|
18
|
+
formatValue?: (value: number) => ReactNode;
|
|
19
|
+
description?: ReactNode;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
/** sm = ระยะห่างแคบ + ป้ายตัวเล็ก */
|
|
22
|
+
size?: "sm" | "default";
|
|
23
|
+
id?: string;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* ช่องเลื่อนพร้อมป้ายชื่อและค่าปัจจุบันมุมขวา (ตั้งค่าที่เป็นตัวเลขต่อเนื่อง เช่น ความละเอียด/ความทึบ)
|
|
29
|
+
* — ยกจาก Pi PDF; ค่าเป็น number เดี่ยว (ช่วงค่าให้ใช้ `Slider` ตรง ๆ)
|
|
30
|
+
*/
|
|
31
|
+
export function SliderField({
|
|
32
|
+
label,
|
|
33
|
+
value,
|
|
34
|
+
onValueChange,
|
|
35
|
+
min = 0,
|
|
36
|
+
max = 100,
|
|
37
|
+
step = 1,
|
|
38
|
+
unit = "",
|
|
39
|
+
formatValue,
|
|
40
|
+
description,
|
|
41
|
+
disabled = false,
|
|
42
|
+
size = "default",
|
|
43
|
+
id,
|
|
44
|
+
className,
|
|
45
|
+
}: SliderFieldProps) {
|
|
46
|
+
const generatedId = useId();
|
|
47
|
+
const sliderId = id ?? generatedId;
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
data-slot="slider-field"
|
|
51
|
+
className={cn(
|
|
52
|
+
"flex flex-col",
|
|
53
|
+
size === "sm" ? "gap-1.5" : "gap-2.5",
|
|
54
|
+
disabled && "pointer-events-none opacity-60",
|
|
55
|
+
className,
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
<div className="flex items-center justify-between gap-4">
|
|
59
|
+
<Label htmlFor={sliderId} className={size === "sm" ? "text-xs" : "font-medium text-sm"}>
|
|
60
|
+
{label}
|
|
61
|
+
</Label>
|
|
62
|
+
<span className="font-medium font-mono text-muted-foreground text-xs tabular-nums">
|
|
63
|
+
{formatValue ? formatValue(value) : `${value}${unit}`}
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
66
|
+
<Slider
|
|
67
|
+
id={sliderId}
|
|
68
|
+
value={value}
|
|
69
|
+
onValueChange={(next) => onValueChange(typeof next === "number" ? next : (next[0] ?? min))}
|
|
70
|
+
min={min}
|
|
71
|
+
max={max}
|
|
72
|
+
step={step}
|
|
73
|
+
disabled={disabled}
|
|
74
|
+
aria-label={typeof label === "string" ? label : undefined}
|
|
75
|
+
/>
|
|
76
|
+
{description && <p className="text-muted-foreground text-xs">{description}</p>}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { SecretReveal } from "@suphark/ui/components/shared/secret-reveal";
|
|
3
4
|
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
5
|
import {
|
|
5
6
|
Dialog,
|
|
@@ -44,6 +45,9 @@ export function FormDialog<TData = undefined>({
|
|
|
44
45
|
onOpenChange,
|
|
45
46
|
keepOpen = false,
|
|
46
47
|
renderSuccess,
|
|
48
|
+
revealSuccess = false,
|
|
49
|
+
successTitle,
|
|
50
|
+
successDescription,
|
|
47
51
|
contentClassName,
|
|
48
52
|
}: {
|
|
49
53
|
/** ปุ่มเปิด (ต้องเป็น <Button> ของ shadcn — Base UI จะใช้ตัวนี้เป็น trigger โดยตรง) */
|
|
@@ -64,6 +68,17 @@ export function FormDialog<TData = undefined>({
|
|
|
64
68
|
* ปิดแล้วเปิดใหม่ = ฟอร์มว่าง รอบใหม่ (state เดิมรวม data ถูกทิ้ง ไม่ถูกส่งกลับไปกับ submit ครั้งถัดไป)
|
|
65
69
|
*/
|
|
66
70
|
renderSuccess?: (state: Extract<FormState<TData>, { ok: true }>) => ReactNode;
|
|
71
|
+
/**
|
|
72
|
+
* แบบไม่ต้องส่งฟังก์ชัน (ใช้จาก **server component** ได้): สำเร็จแล้วถ้า `state.data` เป็น
|
|
73
|
+
* `{ values: Record<string, string> }` จะแสดง `SecretReveal` ให้เอง พร้อมปุ่ม "เสร็จแล้ว"
|
|
74
|
+
* (เคส "สร้างแล้วโชว์ secret ครั้งเดียว" — webhook secret / SCIM token / service account key / client secret)
|
|
75
|
+
* `renderSuccess` เป็นฟังก์ชันจึงข้าม boundary ไม่ได้ (ขอจาก Pi Auth)
|
|
76
|
+
*/
|
|
77
|
+
revealSuccess?: boolean;
|
|
78
|
+
/** หัวข้อของ `SecretReveal` เมื่อใช้ `revealSuccess` (ค่าเริ่มต้น `UiText.showOnce`) */
|
|
79
|
+
successTitle?: string;
|
|
80
|
+
/** ข้อความใต้หัวข้อของหน้าจอสำเร็จ (แทน `description` ของฟอร์ม) */
|
|
81
|
+
successDescription?: ReactNode;
|
|
67
82
|
/** ความกว้าง/คลาสของ DialogContent (ค่าเริ่มต้น sm:max-w-md) */
|
|
68
83
|
contentClassName?: string;
|
|
69
84
|
}) {
|
|
@@ -84,8 +99,11 @@ export function FormDialog<TData = undefined>({
|
|
|
84
99
|
description={description}
|
|
85
100
|
action={action}
|
|
86
101
|
submitLabel={submitLabel}
|
|
87
|
-
keepOpen={keepOpen || renderSuccess !== undefined}
|
|
102
|
+
keepOpen={keepOpen || renderSuccess !== undefined || revealSuccess}
|
|
88
103
|
renderSuccess={renderSuccess}
|
|
104
|
+
revealSuccess={revealSuccess}
|
|
105
|
+
successTitle={successTitle}
|
|
106
|
+
successDescription={successDescription}
|
|
89
107
|
close={() => setOpen(false)}
|
|
90
108
|
>
|
|
91
109
|
{children}
|
|
@@ -103,6 +121,9 @@ function FormDialogBody<TData>({
|
|
|
103
121
|
children,
|
|
104
122
|
keepOpen,
|
|
105
123
|
renderSuccess,
|
|
124
|
+
revealSuccess,
|
|
125
|
+
successTitle,
|
|
126
|
+
successDescription,
|
|
106
127
|
close,
|
|
107
128
|
}: {
|
|
108
129
|
title: ReactNode;
|
|
@@ -112,6 +133,9 @@ function FormDialogBody<TData>({
|
|
|
112
133
|
children: ReactNode | ((ctx: FormRenderContext) => ReactNode);
|
|
113
134
|
keepOpen: boolean;
|
|
114
135
|
renderSuccess?: (state: Extract<FormState<TData>, { ok: true }>) => ReactNode;
|
|
136
|
+
revealSuccess?: boolean;
|
|
137
|
+
successTitle?: string;
|
|
138
|
+
successDescription?: ReactNode;
|
|
115
139
|
close: () => void;
|
|
116
140
|
}) {
|
|
117
141
|
const router = useRouter();
|
|
@@ -129,15 +153,19 @@ function FormDialogBody<TData>({
|
|
|
129
153
|
}
|
|
130
154
|
}, [state, submitted]);
|
|
131
155
|
|
|
132
|
-
|
|
156
|
+
const revealValues = revealSuccess && state?.ok ? revealableValues(state.data) : null;
|
|
157
|
+
|
|
158
|
+
if (state?.ok && (renderSuccess || revealValues)) {
|
|
159
|
+
const successDesc = successDescription ?? description;
|
|
133
160
|
return (
|
|
134
161
|
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
|
135
162
|
<DialogHeader>
|
|
136
163
|
<DialogTitle>{title}</DialogTitle>
|
|
137
|
-
{
|
|
164
|
+
{successDesc && <DialogDescription>{successDesc}</DialogDescription>}
|
|
138
165
|
</DialogHeader>
|
|
139
166
|
<div className="-mx-1 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-1">
|
|
140
|
-
{renderSuccess(state)}
|
|
167
|
+
{renderSuccess ? renderSuccess(state) : null}
|
|
168
|
+
{revealValues && <SecretReveal values={revealValues} title={successTitle} />}
|
|
141
169
|
</div>
|
|
142
170
|
<DialogFooter>
|
|
143
171
|
<Button type="button" onClick={close}>
|
|
@@ -179,3 +207,14 @@ function FormDialogBody<TData>({
|
|
|
179
207
|
</form>
|
|
180
208
|
);
|
|
181
209
|
}
|
|
210
|
+
|
|
211
|
+
/** `data` ของ server action เป็น `{ values: Record<string, string> }` ไหม (ใช้กับ `revealSuccess`) */
|
|
212
|
+
function revealableValues(data: unknown): Record<string, string> | null {
|
|
213
|
+
if (!data || typeof data !== "object" || !("values" in data)) return null;
|
|
214
|
+
const { values } = data as { values: unknown };
|
|
215
|
+
if (!values || typeof values !== "object") return null;
|
|
216
|
+
const entries = Object.entries(values as Record<string, unknown>).filter(
|
|
217
|
+
([, v]) => typeof v === "string",
|
|
218
|
+
) as Array<[string, string]>;
|
|
219
|
+
return entries.length > 0 ? Object.fromEntries(entries) : null;
|
|
220
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import {
|
|
5
|
+
compareMonth,
|
|
6
|
+
currentMonthKey,
|
|
7
|
+
formatMonthKey,
|
|
8
|
+
type MonthKey,
|
|
9
|
+
shiftMonth,
|
|
10
|
+
} from "@suphark/ui/lib/month";
|
|
11
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
12
|
+
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
|
|
13
|
+
import { useCallback, useState } from "react";
|
|
14
|
+
import { useUiText } from "./ui-text";
|
|
15
|
+
|
|
16
|
+
export interface MonthStepperProps {
|
|
17
|
+
/** เดือนที่เลือก `"YYYY-MM"` */
|
|
18
|
+
value: MonthKey;
|
|
19
|
+
onValueChange: (value: MonthKey) => void;
|
|
20
|
+
/** เดือนแรก/สุดท้ายที่เลือกได้ (ปุ่ม ‹ › ปิดเมื่อถึงขอบ) */
|
|
21
|
+
min?: MonthKey;
|
|
22
|
+
max?: MonthKey;
|
|
23
|
+
/** `th-TH` = พ.ศ. (ค่าเริ่มต้น), `th-TH-u-ca-gregory` = ไทย ค.ศ., `en-US` */
|
|
24
|
+
locale?: string;
|
|
25
|
+
/** ชื่อเดือนเต็ม/ย่อ (มือถือแคบ ๆ ใช้ short) */
|
|
26
|
+
format?: "long" | "short";
|
|
27
|
+
/** ข้อความกลางเอง (เช่น "รอบบิล ต.ค. 69") — ไม่ส่ง = ชื่อเดือนตาม locale */
|
|
28
|
+
formatLabel?: (value: MonthKey) => string;
|
|
29
|
+
/** กดชื่อเดือนแล้วกลับเดือนปัจจุบัน (ค่าเริ่มต้น true) */
|
|
30
|
+
resetToCurrent?: boolean;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* เลือกเดือนแบบ ‹ เดือน › — กดชื่อเดือนเพื่อกลับเดือนนี้ (ยกจาก DueBell `MonthSelector`)
|
|
36
|
+
* ใช้คู่กับ `CalendarMonthGrid month` ได้เลย (รูปแบบ `"YYYY-MM"` เดียวกัน)
|
|
37
|
+
*
|
|
38
|
+
* const { month, setMonth } = useMonthCursor();
|
|
39
|
+
* <MonthStepper value={month} onValueChange={setMonth} />
|
|
40
|
+
*/
|
|
41
|
+
export function MonthStepper({
|
|
42
|
+
value,
|
|
43
|
+
onValueChange,
|
|
44
|
+
min,
|
|
45
|
+
max,
|
|
46
|
+
locale = "th-TH",
|
|
47
|
+
format = "long",
|
|
48
|
+
formatLabel,
|
|
49
|
+
resetToCurrent = true,
|
|
50
|
+
className,
|
|
51
|
+
}: MonthStepperProps) {
|
|
52
|
+
const text = useUiText().month;
|
|
53
|
+
const prev = shiftMonth(value, -1);
|
|
54
|
+
const next = shiftMonth(value, 1);
|
|
55
|
+
const canPrev = !min || compareMonth(prev, min) >= 0;
|
|
56
|
+
const canNext = !max || compareMonth(next, max) <= 0;
|
|
57
|
+
const now = currentMonthKey();
|
|
58
|
+
const nowInRange = (!min || compareMonth(now, min) >= 0) && (!max || compareMonth(now, max) <= 0);
|
|
59
|
+
const label = formatLabel ? formatLabel(value) : formatMonthKey(value, locale, format);
|
|
60
|
+
const isCurrent = value === now;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
data-slot="month-stepper"
|
|
65
|
+
className={cn("inline-flex items-center gap-1 rounded-lg border bg-card p-1", className)}
|
|
66
|
+
>
|
|
67
|
+
<Button
|
|
68
|
+
type="button"
|
|
69
|
+
variant="ghost"
|
|
70
|
+
size="icon"
|
|
71
|
+
disabled={!canPrev}
|
|
72
|
+
onClick={() => onValueChange(prev)}
|
|
73
|
+
aria-label={text?.previous ?? "เดือนก่อน"}
|
|
74
|
+
>
|
|
75
|
+
<ChevronLeft />
|
|
76
|
+
</Button>
|
|
77
|
+
{resetToCurrent ? (
|
|
78
|
+
<Button
|
|
79
|
+
type="button"
|
|
80
|
+
variant="ghost"
|
|
81
|
+
disabled={isCurrent || !nowInRange}
|
|
82
|
+
// ปุ่มกลางแสดงชื่อเดือนเสมอ — ปิดเมื่ออยู่เดือนนี้แล้วแต่ยังต้องอ่านออก ไม่ให้จางแบบ disabled ปกติ
|
|
83
|
+
className="min-w-32 gap-2 tabular-nums disabled:opacity-100"
|
|
84
|
+
onClick={() => onValueChange(now)}
|
|
85
|
+
title={isCurrent ? undefined : (text?.current ?? "กลับไปเดือนนี้")}
|
|
86
|
+
aria-live="polite"
|
|
87
|
+
>
|
|
88
|
+
<CalendarDays className="text-muted-foreground" />
|
|
89
|
+
{label}
|
|
90
|
+
</Button>
|
|
91
|
+
) : (
|
|
92
|
+
<span
|
|
93
|
+
className="flex min-w-32 items-center justify-center gap-2 px-2 font-medium text-sm tabular-nums"
|
|
94
|
+
aria-live="polite"
|
|
95
|
+
>
|
|
96
|
+
<CalendarDays className="size-4 text-muted-foreground" />
|
|
97
|
+
{label}
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
<Button
|
|
101
|
+
type="button"
|
|
102
|
+
variant="ghost"
|
|
103
|
+
size="icon"
|
|
104
|
+
disabled={!canNext}
|
|
105
|
+
onClick={() => onValueChange(next)}
|
|
106
|
+
aria-label={text?.next ?? "เดือนถัดไป"}
|
|
107
|
+
>
|
|
108
|
+
<ChevronRight />
|
|
109
|
+
</Button>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* state เดือนปัจจุบัน + ตัวเลื่อน — `initial` ไม่ส่ง = เดือนนี้
|
|
116
|
+
* ต้องการเก็บใน URL ให้ใช้ `useTableSearchParams`/searchParams ของแอปแทน แล้วส่ง value/onValueChange เอง
|
|
117
|
+
*/
|
|
118
|
+
export function useMonthCursor(initial?: MonthKey) {
|
|
119
|
+
const [month, setMonth] = useState<MonthKey>(() => initial ?? currentMonthKey());
|
|
120
|
+
const previous = useCallback(() => setMonth((m) => shiftMonth(m, -1)), []);
|
|
121
|
+
const next = useCallback(() => setMonth((m) => shiftMonth(m, 1)), []);
|
|
122
|
+
const reset = useCallback(() => setMonth(currentMonthKey()), []);
|
|
123
|
+
return { month, setMonth, previous, next, reset };
|
|
124
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ClientDateFormatter } from "@suphark/ui/components/shared/time/client-date-formatter";
|
|
4
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
5
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
6
|
+
import { AlertCircle, Check, Loader2 } from "lucide-react";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
|
|
9
|
+
export type SaveState = "idle" | "saving" | "saved" | "error";
|
|
10
|
+
|
|
11
|
+
export interface SaveStatusText {
|
|
12
|
+
saving: string;
|
|
13
|
+
saved: string;
|
|
14
|
+
error: string;
|
|
15
|
+
/** นำหน้าเวลาที่บันทึกล่าสุด เช่น "บันทึกเมื่อ" */
|
|
16
|
+
savedAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const TEXT: Record<"th" | "en", SaveStatusText> = {
|
|
20
|
+
th: { saving: "กำลังบันทึก…", saved: "บันทึกแล้ว", error: "บันทึกไม่สำเร็จ", savedAt: "บันทึกเมื่อ" },
|
|
21
|
+
en: { saving: "Saving…", saved: "Saved", error: "Couldn't save", savedAt: "Saved" },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* สถานะบันทึกอัตโนมัติแบบบรรทัดเดียว (ยกจาก Pi PDF) — วางใน toolbar/หัวหน้า
|
|
26
|
+
* `savedAt` ใส่มาแล้วจะแสดงเวลาสัมพัทธ์ที่อัปเดตเองฝั่ง client (ไม่ทำ hydration mismatch)
|
|
27
|
+
*/
|
|
28
|
+
export function SaveStatus({
|
|
29
|
+
state,
|
|
30
|
+
savedAt,
|
|
31
|
+
error,
|
|
32
|
+
language = "th",
|
|
33
|
+
text: textProp,
|
|
34
|
+
className,
|
|
35
|
+
}: {
|
|
36
|
+
state: SaveState;
|
|
37
|
+
/** เวลาที่บันทึกล่าสุด — แสดงเป็นเวลาสัมพัทธ์เมื่อ state = "saved"/"idle" */
|
|
38
|
+
savedAt?: Date | string | number | null;
|
|
39
|
+
/** ข้อความ error ของแอป (ไม่ส่ง = ข้อความเริ่มต้น) */
|
|
40
|
+
error?: ReactNode;
|
|
41
|
+
language?: "th" | "en";
|
|
42
|
+
text?: Partial<SaveStatusText>;
|
|
43
|
+
className?: string;
|
|
44
|
+
}) {
|
|
45
|
+
const ui = useUiText();
|
|
46
|
+
const text: SaveStatusText = { ...TEXT[language], ...textProp };
|
|
47
|
+
const base = cn("inline-flex items-center gap-1.5 text-xs", className);
|
|
48
|
+
|
|
49
|
+
if (state === "saving") {
|
|
50
|
+
return (
|
|
51
|
+
<span
|
|
52
|
+
data-slot="save-status"
|
|
53
|
+
data-state="saving"
|
|
54
|
+
aria-live="polite"
|
|
55
|
+
className={cn(base, "text-muted-foreground")}
|
|
56
|
+
>
|
|
57
|
+
<Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
|
|
58
|
+
{text.saving}
|
|
59
|
+
</span>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (state === "error") {
|
|
63
|
+
return (
|
|
64
|
+
<span
|
|
65
|
+
data-slot="save-status"
|
|
66
|
+
data-state="error"
|
|
67
|
+
role="alert"
|
|
68
|
+
className={cn(base, "text-destructive")}
|
|
69
|
+
>
|
|
70
|
+
<AlertCircle className="size-3.5" aria-hidden="true" />
|
|
71
|
+
{error ?? ui.actionFailed ?? text.error}
|
|
72
|
+
</span>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (state === "saved") {
|
|
76
|
+
return (
|
|
77
|
+
<span
|
|
78
|
+
data-slot="save-status"
|
|
79
|
+
data-state="saved"
|
|
80
|
+
aria-live="polite"
|
|
81
|
+
className={cn(base, "text-success")}
|
|
82
|
+
>
|
|
83
|
+
<Check className="size-3.5" aria-hidden="true" />
|
|
84
|
+
{text.saved}
|
|
85
|
+
</span>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (!savedAt) return null;
|
|
89
|
+
return (
|
|
90
|
+
<span data-slot="save-status" data-state="idle" className={cn(base, "text-muted-foreground")}>
|
|
91
|
+
{text.savedAt}{" "}
|
|
92
|
+
<ClientDateFormatter
|
|
93
|
+
date={savedAt}
|
|
94
|
+
formatType="relative"
|
|
95
|
+
locale={language === "en" ? "en-US" : "th-TH"}
|
|
96
|
+
/>
|
|
97
|
+
</span>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ToggleGroup, ToggleGroupItem } from "@suphark/ui/components/ui/toggle-group";
|
|
4
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
import { CountBadge } from "./count-badge";
|
|
7
|
+
|
|
8
|
+
export interface SegmentedOption<T extends string> {
|
|
9
|
+
value: T;
|
|
10
|
+
label: ReactNode;
|
|
11
|
+
icon?: ReactNode;
|
|
12
|
+
/** จำนวนท้ายตัวเลือก เช่นจำนวนรายการในตัวกรองนั้น */
|
|
13
|
+
count?: number;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* ตัวเลือกแบบปุ่มติดกัน "เลือกได้ทีละหนึ่ง และต้องมีค่าเสมอ" (ตัวกรองสถานะ, มุมมอง รายการ/ตาราง)
|
|
19
|
+
* ต่างจาก ToggleGroup ตรง ๆ: กดตัวที่เลือกอยู่แล้วไม่หลุดเป็นค่าว่าง และ value เป็น string ไม่ใช่ array
|
|
20
|
+
* (DueBell bill-list-filter / PDF ตัวกรองโปรเจกต์ เขียนเองพร้อมตัวเลขนับ)
|
|
21
|
+
*
|
|
22
|
+
* <SegmentedControl value={status} onValueChange={setStatus} label="สถานะ"
|
|
23
|
+
* options={[{ value: "all", label: "ทั้งหมด", count: 12 }, { value: "due", label: "ใกล้ครบกำหนด", count: 3 }]} />
|
|
24
|
+
*/
|
|
25
|
+
export function SegmentedControl<T extends string>({
|
|
26
|
+
value,
|
|
27
|
+
onValueChange,
|
|
28
|
+
options,
|
|
29
|
+
label,
|
|
30
|
+
size = "default",
|
|
31
|
+
fullWidth = false,
|
|
32
|
+
className,
|
|
33
|
+
}: {
|
|
34
|
+
value: T;
|
|
35
|
+
onValueChange: (value: T) => void;
|
|
36
|
+
options: ReadonlyArray<SegmentedOption<T>>;
|
|
37
|
+
/** aria-label ของกลุ่ม */
|
|
38
|
+
label: string;
|
|
39
|
+
size?: "sm" | "default";
|
|
40
|
+
fullWidth?: boolean;
|
|
41
|
+
className?: string;
|
|
42
|
+
}) {
|
|
43
|
+
return (
|
|
44
|
+
<ToggleGroup
|
|
45
|
+
aria-label={label}
|
|
46
|
+
value={[value]}
|
|
47
|
+
onValueChange={(next) => {
|
|
48
|
+
// กดตัวที่เลือกอยู่ → Base UI คืน [] — คงค่าเดิมไว้ (ต้องมีค่าเสมอ)
|
|
49
|
+
const v = next[0];
|
|
50
|
+
if (v !== undefined && v !== value) onValueChange(v as T);
|
|
51
|
+
}}
|
|
52
|
+
variant="outline"
|
|
53
|
+
size={size === "sm" ? "sm" : "default"}
|
|
54
|
+
spacing={0}
|
|
55
|
+
className={cn(fullWidth && "w-full", className)}
|
|
56
|
+
>
|
|
57
|
+
{options.map((o) => (
|
|
58
|
+
<ToggleGroupItem
|
|
59
|
+
key={o.value}
|
|
60
|
+
value={o.value}
|
|
61
|
+
disabled={o.disabled}
|
|
62
|
+
className={cn("gap-1.5", fullWidth && "flex-1")}
|
|
63
|
+
>
|
|
64
|
+
{o.icon}
|
|
65
|
+
{o.label}
|
|
66
|
+
{o.count !== undefined && (
|
|
67
|
+
<CountBadge value={o.count} showZero tone={o.value === value ? "primary" : "muted"} />
|
|
68
|
+
)}
|
|
69
|
+
</ToggleGroupItem>
|
|
70
|
+
))}
|
|
71
|
+
</ToggleGroup>
|
|
72
|
+
);
|
|
73
|
+
}
|