@suphark/ui 0.5.1 → 0.6.1
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 +60 -0
- package/README.md +20 -0
- package/package.json +5 -1
- package/scripts/audit.mjs +218 -0
- package/src/components/app-shell/app-brand.tsx +1 -0
- package/src/components/app-shell/app-nav.tsx +26 -12
- package/src/components/data-table/data-table-faceted-filter.tsx +14 -15
- package/src/components/data-table/data-table-row-actions.tsx +2 -1
- package/src/components/data-table/use-data-table.ts +7 -11
- package/src/components/design-system/sections/showcase-data-display.tsx +2 -2
- package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +1 -1
- package/src/components/design-system/sections/showcase-overlays-navigation.tsx +2 -2
- package/src/components/design-system/sections/showcase-primitives.tsx +1 -1
- package/src/components/pwa/pull-to-refresh.tsx +11 -5
- package/src/components/shared/action-form.tsx +78 -0
- package/src/components/shared/button/floating-save-button.tsx +1 -1
- package/src/components/shared/form/form-field-color-select.tsx +1 -1
- package/src/components/shared/form/form-field-combobox.tsx +15 -6
- package/src/components/shared/form/form-field-date.tsx +1 -1
- package/src/components/shared/form/form-field-input.tsx +6 -6
- package/src/components/shared/form/form-field-radio-cards.tsx +65 -24
- package/src/components/shared/form/labeled-field.tsx +61 -0
- package/src/components/shared/form-dialog.tsx +1 -3
- package/src/components/shared/form-message.tsx +4 -5
- package/src/components/shared/navigation/stepper.tsx +96 -0
- package/src/components/shared/notice.tsx +1 -0
- package/src/components/shared/number/formatted-number.tsx +39 -102
- package/src/components/shared/status-badge.tsx +1 -0
- package/src/components/shared/theme/theme-toggle.tsx +11 -8
- package/src/components/shared/time/client-date-formatter.tsx +47 -154
- package/src/components/ui/relative-date.tsx +1 -10
- package/src/hooks/use-mounted.ts +16 -0
- package/src/hooks/use-ticker.ts +53 -0
- package/src/index.ts +6 -1
- package/src/lib/intl-cache.ts +31 -0
- package/src/lib/status-colors.ts +1 -0
- package/src/next.ts +1 -0
|
@@ -46,8 +46,14 @@ export function PullToRefresh({
|
|
|
46
46
|
const isPullingRef = useRef(false);
|
|
47
47
|
const [pullDistance, setPullDistance] = useState(0);
|
|
48
48
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
49
|
+
// listener อ่านค่าล่าสุดผ่าน ref — ไม่ผูก state เป็น deps ของ effect (เดิมถอด/ใส่ listener ใหม่ทุก touchmove)
|
|
50
|
+
const pullDistanceRef = useRef(0);
|
|
51
|
+
const isRefreshingRef = useRef(false);
|
|
52
|
+
pullDistanceRef.current = pullDistance;
|
|
53
|
+
isRefreshingRef.current = isRefreshing;
|
|
49
54
|
|
|
50
55
|
useEffect(() => {
|
|
56
|
+
const isRefreshing = () => isRefreshingRef.current;
|
|
51
57
|
const isMobileViewport = () => window.innerWidth < MOBILE_BREAKPOINT;
|
|
52
58
|
const getRoot = () => document.querySelector<HTMLElement>(rootSelector);
|
|
53
59
|
const isRootAtTop = (root: HTMLElement) => root.scrollTop <= 0;
|
|
@@ -60,7 +66,7 @@ export function PullToRefresh({
|
|
|
60
66
|
|
|
61
67
|
const handleTouchStart = (event: TouchEvent) => {
|
|
62
68
|
const touch = event.touches[0];
|
|
63
|
-
if (!isMobileViewport() || isRefreshing || event.touches.length !== 1 || !touch) {
|
|
69
|
+
if (!isMobileViewport() || isRefreshing() || event.touches.length !== 1 || !touch) {
|
|
64
70
|
resetPull();
|
|
65
71
|
return;
|
|
66
72
|
}
|
|
@@ -76,7 +82,7 @@ export function PullToRefresh({
|
|
|
76
82
|
const handleTouchMove = (event: TouchEvent) => {
|
|
77
83
|
const start = startRef.current;
|
|
78
84
|
const touch = event.touches[0];
|
|
79
|
-
if (!start || isRefreshing || event.touches.length !== 1 || !touch) return;
|
|
85
|
+
if (!start || isRefreshing() || event.touches.length !== 1 || !touch) return;
|
|
80
86
|
|
|
81
87
|
const root = getRoot();
|
|
82
88
|
if (!root || !isRootAtTop(root)) {
|
|
@@ -99,9 +105,9 @@ export function PullToRefresh({
|
|
|
99
105
|
};
|
|
100
106
|
|
|
101
107
|
const handleTouchEnd = () => {
|
|
102
|
-
const shouldRefresh =
|
|
108
|
+
const shouldRefresh = pullDistanceRef.current >= ACTIVATION_DISTANCE;
|
|
103
109
|
resetPull();
|
|
104
|
-
if (!shouldRefresh || isRefreshing) return;
|
|
110
|
+
if (!shouldRefresh || isRefreshing()) return;
|
|
105
111
|
setIsRefreshing(true);
|
|
106
112
|
router.refresh();
|
|
107
113
|
window.setTimeout(() => setIsRefreshing(false), 700);
|
|
@@ -117,7 +123,7 @@ export function PullToRefresh({
|
|
|
117
123
|
window.removeEventListener("touchend", handleTouchEnd);
|
|
118
124
|
window.removeEventListener("touchcancel", resetPull);
|
|
119
125
|
};
|
|
120
|
-
}, [
|
|
126
|
+
}, [router, rootSelector]);
|
|
121
127
|
|
|
122
128
|
const isVisible = pullDistance > 0 || isRefreshing;
|
|
123
129
|
const progress = Math.min(1, pullDistance / ACTIVATION_DISTANCE);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { Card, CardContent } from "@suphark/ui/components/ui/card";
|
|
5
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
6
|
+
import { type ReactNode, startTransition, useActionState } from "react";
|
|
7
|
+
import { type FormAction, FormMessage, type FormState } from "./form-message";
|
|
8
|
+
import { useUiText } from "./ui-text";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ฟอร์ม inline คู่กับ `FormDialog` (อันนั้นอยู่ในกล่องโต้ตอบ อันนี้อยู่บนหน้า): `useActionState` + `FormMessage` + ปุ่มส่ง
|
|
12
|
+
* ไม่ใช้ `action=` บน <form> — React 19 รีเซ็ตฟอร์ม uncontrolled หลัง action จบทุกครั้ง (แม้คืน error)
|
|
13
|
+
* ค่าที่พิมพ์จะหาย; เรียก action ใน transition เองแทน ฟอร์มจึงคงค่าไว้ให้แก้ต่อ (แบบเดียวกับ FormDialog)
|
|
14
|
+
* ไม่พึ่ง next (ไม่ refresh เอง — ให้ server action `revalidatePath` หรือส่ง `onSuccess`)
|
|
15
|
+
*
|
|
16
|
+
* <ActionForm action={updateName} title="ชื่อองค์กร" submitLabel="บันทึก">
|
|
17
|
+
* <LabeledField label="ชื่อ"><Input name="name" defaultValue={org.name} /></LabeledField>
|
|
18
|
+
* </ActionForm>
|
|
19
|
+
*/
|
|
20
|
+
export function ActionForm({
|
|
21
|
+
action,
|
|
22
|
+
title,
|
|
23
|
+
submitLabel,
|
|
24
|
+
children,
|
|
25
|
+
compact = false,
|
|
26
|
+
className,
|
|
27
|
+
onSuccess,
|
|
28
|
+
}: {
|
|
29
|
+
action: FormAction;
|
|
30
|
+
title?: ReactNode;
|
|
31
|
+
submitLabel?: ReactNode;
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
/** ฟอร์มเล็กในแถวตาราง — ไม่มีกรอบ Card, ปุ่ม size sm, จัดแนวนอน wrap */
|
|
34
|
+
compact?: boolean;
|
|
35
|
+
className?: string;
|
|
36
|
+
/** เรียกเมื่อ action คืน `ok: true` (เช่น router.refresh, toast) */
|
|
37
|
+
onSuccess?: (state: Extract<FormState, { ok: true }>) => void;
|
|
38
|
+
}) {
|
|
39
|
+
const text = useUiText();
|
|
40
|
+
const [state, formAction, pending] = useActionState<FormState, FormData>(async (prev, fd) => {
|
|
41
|
+
const next = await action(prev, fd);
|
|
42
|
+
if (next?.ok) onSuccess?.(next);
|
|
43
|
+
return next;
|
|
44
|
+
}, null);
|
|
45
|
+
|
|
46
|
+
const form = (
|
|
47
|
+
<form
|
|
48
|
+
onSubmit={(e) => {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
const fd = new FormData(e.currentTarget);
|
|
51
|
+
startTransition(() => formAction(fd));
|
|
52
|
+
}}
|
|
53
|
+
className={cn(
|
|
54
|
+
compact ? "flex flex-wrap items-end gap-2" : "flex flex-col gap-3",
|
|
55
|
+
compact && className,
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
{title && !compact && <h2 className="font-semibold text-sm">{title}</h2>}
|
|
59
|
+
{children}
|
|
60
|
+
<FormMessage state={state} className={compact ? "basis-full" : undefined} />
|
|
61
|
+
<Button
|
|
62
|
+
type="submit"
|
|
63
|
+
size={compact ? "sm" : "default"}
|
|
64
|
+
disabled={pending}
|
|
65
|
+
className={compact ? undefined : "self-start"}
|
|
66
|
+
>
|
|
67
|
+
{submitLabel ?? text.save}
|
|
68
|
+
</Button>
|
|
69
|
+
</form>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (compact) return form;
|
|
73
|
+
return (
|
|
74
|
+
<Card className={className}>
|
|
75
|
+
<CardContent>{form}</CardContent>
|
|
76
|
+
</Card>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -68,7 +68,7 @@ export function FloatingSaveButton({
|
|
|
68
68
|
className={cn(
|
|
69
69
|
"h-14 gap-3 rounded-full border-4 border-background px-8 text-base shadow-2xl transition-all duration-300",
|
|
70
70
|
showSuccess
|
|
71
|
-
? "scale-105 bg-
|
|
71
|
+
? "scale-105 bg-success text-success-foreground shadow-2xl shadow-success/40 hover:bg-success"
|
|
72
72
|
: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
73
73
|
buttonClassName,
|
|
74
74
|
)}
|
|
@@ -55,7 +55,7 @@ export function FormFieldColorSelect<T extends FieldValues>({
|
|
|
55
55
|
render={({ field }) => (
|
|
56
56
|
<FormItem>
|
|
57
57
|
<FormLabel>
|
|
58
|
-
{label} {props.required && <span className="text-
|
|
58
|
+
{label} {props.required && <span className="text-destructive">*</span>}
|
|
59
59
|
</FormLabel>
|
|
60
60
|
<Select onValueChange={field.onChange} value={field.value} {...props}>
|
|
61
61
|
<FormControl>
|
|
@@ -43,8 +43,17 @@ interface FormFieldComboboxProps<T extends FieldValues> {
|
|
|
43
43
|
onSelect?: (option: { label: string; value: string; [key: string]: any }) => void;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
type CacheEntry = { label: string; value: string; subLabel?: string }[];
|
|
47
|
+
// cache ตัวเลือกข้าม mount: key เป็น string (`fetcherKey`) เก็บใน Map; key เป็นฟังก์ชัน (ไม่ส่ง fetcherKey)
|
|
48
|
+
// เก็บใน WeakMap — inline arrow ที่ identity เปลี่ยนทุก render จะถูก GC ไม่โตค้างตลอดอายุแท็บ
|
|
49
|
+
const stringCache = new Map<string, CacheEntry>();
|
|
50
|
+
const fnCache = new WeakMap<object, CacheEntry>();
|
|
51
|
+
const cacheGet = (key: string | object) =>
|
|
52
|
+
typeof key === "string" ? stringCache.get(key) : fnCache.get(key);
|
|
53
|
+
const cacheSet = (key: string | object, data: CacheEntry) => {
|
|
54
|
+
if (typeof key === "string") stringCache.set(key, data);
|
|
55
|
+
else fnCache.set(key, data);
|
|
56
|
+
};
|
|
48
57
|
|
|
49
58
|
export function FormFieldCombobox<T extends FieldValues>({
|
|
50
59
|
control,
|
|
@@ -61,7 +70,7 @@ export function FormFieldCombobox<T extends FieldValues>({
|
|
|
61
70
|
}: FormFieldComboboxProps<T>) {
|
|
62
71
|
const cacheKey = fetcherKey ?? fetcher;
|
|
63
72
|
const [options, setOptions] = useState<{ label: string; value: string; subLabel?: string }[]>(
|
|
64
|
-
() =>
|
|
73
|
+
() => cacheGet(cacheKey) || [],
|
|
65
74
|
);
|
|
66
75
|
const [isLoading, startTransition] = useTransition();
|
|
67
76
|
const [open, setOpen] = React.useState(false);
|
|
@@ -73,14 +82,14 @@ export function FormFieldCombobox<T extends FieldValues>({
|
|
|
73
82
|
|
|
74
83
|
useEffect(() => {
|
|
75
84
|
// แสดงค่าจากแคชก่อนเพื่อลดการกระพริบ แล้วค่อยโหลดค่าล่าสุดทับ
|
|
76
|
-
const cached =
|
|
85
|
+
const cached = cacheGet(cacheKey);
|
|
77
86
|
if (cached) setOptions(cached);
|
|
78
87
|
|
|
79
88
|
startTransition(async () => {
|
|
80
89
|
try {
|
|
81
90
|
const data = await fetcherRef.current();
|
|
82
91
|
setOptions(data);
|
|
83
|
-
|
|
92
|
+
cacheSet(cacheKey, data);
|
|
84
93
|
} catch (error) {
|
|
85
94
|
console.error("Failed to fetch combobox options:", error);
|
|
86
95
|
}
|
|
@@ -94,7 +103,7 @@ export function FormFieldCombobox<T extends FieldValues>({
|
|
|
94
103
|
render={({ field }) => (
|
|
95
104
|
<FormItem className={cn("flex flex-col gap-1.5", className)}>
|
|
96
105
|
<FormLabel>
|
|
97
|
-
{label} {required && <span className="text-
|
|
106
|
+
{label} {required && <span className="text-destructive">*</span>}
|
|
98
107
|
</FormLabel>
|
|
99
108
|
<FormControl>
|
|
100
109
|
<Popover open={open} onOpenChange={setOpen}>
|
|
@@ -36,7 +36,7 @@ export function FormFieldDate<T extends FieldValues>({
|
|
|
36
36
|
render={({ field }) => (
|
|
37
37
|
<FormItem className="flex flex-col">
|
|
38
38
|
<FormLabel>
|
|
39
|
-
{label} {required && <span className="text-
|
|
39
|
+
{label} {required && <span className="text-destructive">*</span>}
|
|
40
40
|
</FormLabel>
|
|
41
41
|
<FormControl>
|
|
42
42
|
<DatePicker
|
|
@@ -182,16 +182,16 @@ function UniqueCheckInput<T extends FieldValues>({
|
|
|
182
182
|
|
|
183
183
|
return (
|
|
184
184
|
<div>
|
|
185
|
-
<InputGroup className={cn(isDuplicate ? "border-
|
|
185
|
+
<InputGroup className={cn(isDuplicate ? "border-destructive ring-destructive" : "")}>
|
|
186
186
|
<InputGroupInput
|
|
187
187
|
{...field}
|
|
188
188
|
{...props}
|
|
189
189
|
value={value || ""}
|
|
190
|
-
className={cn(isDuplicate ? "text-
|
|
190
|
+
className={cn(isDuplicate ? "text-destructive" : "")}
|
|
191
191
|
/>
|
|
192
192
|
{isDuplicate && (
|
|
193
193
|
<InputGroupAddon>
|
|
194
|
-
<CircleAlert className="h-4 w-4 text-
|
|
194
|
+
<CircleAlert className="h-4 w-4 text-destructive" />
|
|
195
195
|
</InputGroupAddon>
|
|
196
196
|
)}
|
|
197
197
|
{enableCopy && (
|
|
@@ -200,12 +200,12 @@ function UniqueCheckInput<T extends FieldValues>({
|
|
|
200
200
|
type="button"
|
|
201
201
|
title="Copy to clipboard"
|
|
202
202
|
>
|
|
203
|
-
{isCopied ? <Check className="h-4 w-4 text-
|
|
203
|
+
{isCopied ? <Check className="h-4 w-4 text-success" /> : <Copy className="h-4 w-4" />}
|
|
204
204
|
</InputGroupButton>
|
|
205
205
|
)}
|
|
206
206
|
</InputGroup>
|
|
207
207
|
{isDuplicate && (
|
|
208
|
-
<p className="mt-1 font-medium text-[0.8rem] text-
|
|
208
|
+
<p className="mt-1 font-medium text-[0.8rem] text-destructive">{uniqueErrorMessage}</p>
|
|
209
209
|
)}
|
|
210
210
|
</div>
|
|
211
211
|
);
|
|
@@ -224,7 +224,7 @@ function CopyableInput(props: ComponentProps<typeof InputGroupInput>) {
|
|
|
224
224
|
type="button"
|
|
225
225
|
title="Copy to clipboard"
|
|
226
226
|
>
|
|
227
|
-
{isCopied ? <Check className="h-4 w-4 text-
|
|
227
|
+
{isCopied ? <Check className="h-4 w-4 text-success" /> : <Copy className="h-4 w-4" />}
|
|
228
228
|
</InputGroupButton>
|
|
229
229
|
</InputGroup>
|
|
230
230
|
);
|
|
@@ -17,49 +17,99 @@ type RadioCardOption = {
|
|
|
17
17
|
|
|
18
18
|
interface FormFieldRadioCardsProps<T extends FieldValues = any> {
|
|
19
19
|
control?: Control<T>;
|
|
20
|
-
name
|
|
20
|
+
/** กับ react-hook-form = ชื่อ field; ไม่มี `control` = `name` ของ RadioGroup (ส่งค่าใน FormData ของ server action) */
|
|
21
|
+
name?: Path<T> | string;
|
|
21
22
|
value?: string;
|
|
23
|
+
/** ค่าเริ่มต้นเมื่อไม่มี control และไม่คุม value เอง (ฟอร์ม server action) */
|
|
24
|
+
defaultValue?: string;
|
|
22
25
|
onValueChange?: (value: string) => void;
|
|
23
26
|
label?: string;
|
|
24
27
|
options: RadioCardOption[];
|
|
25
28
|
disabled?: boolean;
|
|
26
29
|
className?: string;
|
|
27
30
|
size?: "sm" | "md" | "lg";
|
|
31
|
+
/**
|
|
32
|
+
* `grid` (ค่าเริ่มต้น) = การ์ดสี่เหลี่ยมไอคอนกลาง จุดเลือกมุมขวาบน;
|
|
33
|
+
* `row` = การ์ดแนวนอน: ปุ่มวิทยุจริงซ้าย, ไอคอน+ชื่อบรรทัดบน, คำอธิบายบรรทัดล่าง (แบบ Pi Auth เลือกประเภทแอป)
|
|
34
|
+
*/
|
|
35
|
+
layout?: "grid" | "row";
|
|
36
|
+
/** ชื่อกลุ่มสำหรับ screen reader เมื่อไม่แสดง `label` (ส่งต่อไป RadioGroup) */
|
|
37
|
+
"aria-label"?: string;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
export function FormFieldRadioCards<T extends FieldValues = any>({
|
|
31
41
|
control,
|
|
32
42
|
name,
|
|
33
43
|
value: propValue,
|
|
44
|
+
defaultValue,
|
|
34
45
|
onValueChange: propOnValueChange,
|
|
35
46
|
label,
|
|
36
47
|
options,
|
|
37
48
|
disabled,
|
|
38
49
|
className,
|
|
39
50
|
size = "md",
|
|
51
|
+
layout = "grid",
|
|
52
|
+
"aria-label": ariaLabel,
|
|
40
53
|
}: FormFieldRadioCardsProps<T>) {
|
|
41
54
|
const baseId = useId();
|
|
42
55
|
|
|
43
|
-
const renderContent = (
|
|
56
|
+
const renderContent = (
|
|
57
|
+
value: string | undefined,
|
|
58
|
+
onChange: (val: string) => void,
|
|
59
|
+
groupName?: string,
|
|
60
|
+
) => (
|
|
44
61
|
<div className="space-y-3">
|
|
45
62
|
{label && <Label className="block font-semibold text-base">{label}</Label>}
|
|
46
63
|
<RadioGroup
|
|
64
|
+
name={groupName}
|
|
65
|
+
aria-label={label ? undefined : ariaLabel}
|
|
47
66
|
onValueChange={(val) => {
|
|
48
|
-
if (val !== null) onChange(val);
|
|
67
|
+
if (val !== null) onChange(String(val));
|
|
49
68
|
}}
|
|
50
69
|
value={value}
|
|
70
|
+
// base-ui-ok: ค่าเริ่มต้นของฟอร์ม server action — ไม่ถูกเปลี่ยนหลัง mount
|
|
71
|
+
defaultValue={value === undefined ? defaultValue : undefined}
|
|
51
72
|
disabled={disabled}
|
|
52
|
-
className={cn(
|
|
73
|
+
className={cn(
|
|
74
|
+
"grid grid-cols-1 gap-4",
|
|
75
|
+
layout === "grid" ? "md:grid-cols-2" : "gap-3 sm:grid-cols-2",
|
|
76
|
+
className,
|
|
77
|
+
)}
|
|
53
78
|
>
|
|
54
79
|
{options.map((option) => {
|
|
55
80
|
const id = `${baseId}-${option.value}`;
|
|
81
|
+
if (layout === "row") {
|
|
82
|
+
return (
|
|
83
|
+
<Label
|
|
84
|
+
key={option.value}
|
|
85
|
+
htmlFor={id}
|
|
86
|
+
className={cn(
|
|
87
|
+
"flex cursor-pointer items-start gap-3 rounded-lg border p-3 font-normal transition-colors hover:bg-muted/40 has-data-checked:border-primary has-data-checked:bg-primary/5",
|
|
88
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
89
|
+
)}
|
|
90
|
+
>
|
|
91
|
+
<RadioGroupItem value={option.value} id={id} className="mt-0.5" />
|
|
92
|
+
<span className="flex min-w-0 flex-col gap-0.5">
|
|
93
|
+
<span className="flex items-center gap-1.5 font-medium text-sm">
|
|
94
|
+
{option.icon && (
|
|
95
|
+
<span className={cn("[&_svg]:size-4", option.colorClass)}>{option.icon}</span>
|
|
96
|
+
)}
|
|
97
|
+
{option.label}
|
|
98
|
+
</span>
|
|
99
|
+
{option.description && (
|
|
100
|
+
<span className="text-muted-foreground text-xs">{option.description}</span>
|
|
101
|
+
)}
|
|
102
|
+
</span>
|
|
103
|
+
</Label>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
56
106
|
return (
|
|
57
107
|
<div key={option.value} className="relative">
|
|
58
108
|
<RadioGroupItem value={option.value} id={id} className="peer sr-only" />
|
|
59
109
|
<Label
|
|
60
110
|
htmlFor={id}
|
|
61
111
|
className={cn(
|
|
62
|
-
"flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 transition-all hover:border-primary hover:bg-accent peer-data-checked:border-primary peer-data-checked:bg-primary/5",
|
|
112
|
+
"flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 transition-all hover:border-primary hover:bg-accent peer-data-checked:border-primary peer-data-checked:bg-primary/5 peer-data-checked:[&>[data-dot]]:scale-110 peer-data-checked:[&>[data-dot]]:border-primary peer-data-checked:[&>[data-dot]]:bg-primary peer-data-checked:[&>[data-dot]>span]:opacity-100",
|
|
63
113
|
size === "sm" && "min-h-[70px] gap-1 p-2",
|
|
64
114
|
size === "md" && "min-h-[100px] gap-2 p-4",
|
|
65
115
|
size === "lg" && "min-h-[120px] gap-4 p-6",
|
|
@@ -77,29 +127,19 @@ export function FormFieldRadioCards<T extends FieldValues = any>({
|
|
|
77
127
|
{option.label}
|
|
78
128
|
</div>
|
|
79
129
|
{option.description && (
|
|
80
|
-
<div
|
|
81
|
-
className={cn(
|
|
82
|
-
"font-normal text-muted-foreground leading-tight",
|
|
83
|
-
size === "sm" ? "text-xs" : "text-xs",
|
|
84
|
-
)}
|
|
85
|
-
>
|
|
130
|
+
<div className="font-normal text-muted-foreground text-xs leading-tight">
|
|
86
131
|
{option.description}
|
|
87
132
|
</div>
|
|
88
133
|
)}
|
|
89
134
|
</div>
|
|
90
135
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
)}
|
|
136
|
+
{/* จุดเลือกมุมขวาบน — ตามสถานะจริงของปุ่มวิทยุ (peer) จึงถูกทั้ง controlled/uncontrolled */}
|
|
137
|
+
<span
|
|
138
|
+
data-dot=""
|
|
139
|
+
className="absolute top-2 right-2 flex h-4 w-4 items-center justify-center rounded-full border-2 border-muted transition-all"
|
|
96
140
|
>
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
<div className="h-1.5 w-1.5 rounded-full bg-white" />
|
|
100
|
-
</div>
|
|
101
|
-
)}
|
|
102
|
-
</div>
|
|
141
|
+
<span className="h-1.5 w-1.5 rounded-full bg-white opacity-0" />
|
|
142
|
+
</span>
|
|
103
143
|
</Label>
|
|
104
144
|
</div>
|
|
105
145
|
);
|
|
@@ -112,7 +152,7 @@ export function FormFieldRadioCards<T extends FieldValues = any>({
|
|
|
112
152
|
return (
|
|
113
153
|
<FormField
|
|
114
154
|
control={control}
|
|
115
|
-
name={name}
|
|
155
|
+
name={name as Path<T>}
|
|
116
156
|
render={({ field }) => (
|
|
117
157
|
<FormItem className="space-y-3">
|
|
118
158
|
<FormControl>{renderContent(field.value, field.onChange)}</FormControl>
|
|
@@ -123,5 +163,6 @@ export function FormFieldRadioCards<T extends FieldValues = any>({
|
|
|
123
163
|
);
|
|
124
164
|
}
|
|
125
165
|
|
|
126
|
-
|
|
166
|
+
// ไม่มี control: คุม value เอง (`value` + `onValueChange`) หรือปล่อย uncontrolled ด้วย `name` + `defaultValue` (server action)
|
|
167
|
+
return renderContent(propValue, propOnValueChange ?? (() => {}), name);
|
|
127
168
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Checkbox } from "@suphark/ui/components/ui/checkbox";
|
|
2
|
+
import { Field, FieldDescription, FieldError, FieldLabel } from "@suphark/ui/components/ui/field";
|
|
3
|
+
import { Label } from "@suphark/ui/components/ui/label";
|
|
4
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
5
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ช่องฟอร์มสำหรับ server action ธรรมดา (ไม่ใช่ react-hook-form): label + control + hint หรือ error
|
|
9
|
+
* ประกอบจาก shadcn Field/FieldLabel/FieldDescription/FieldError — ยกจาก Pi Auth (ใช้ 42 ไฟล์ คง API เดิม)
|
|
10
|
+
* ใช้ได้จาก server component (ไม่มี state)
|
|
11
|
+
*
|
|
12
|
+
* <LabeledField label="ชื่อแอป" hint="แสดงในหน้าเข้าสู่ระบบ" error={state?.errors?.name}>
|
|
13
|
+
* <Input name="name" required />
|
|
14
|
+
* </LabeledField>
|
|
15
|
+
*/
|
|
16
|
+
export function LabeledField({
|
|
17
|
+
label,
|
|
18
|
+
hint,
|
|
19
|
+
error,
|
|
20
|
+
children,
|
|
21
|
+
className,
|
|
22
|
+
}: {
|
|
23
|
+
label: ReactNode;
|
|
24
|
+
/** ข้อความช่วยใต้ช่อง — ซ่อนเมื่อมี error */
|
|
25
|
+
hint?: ReactNode;
|
|
26
|
+
error?: ReactNode;
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
className?: string;
|
|
29
|
+
}) {
|
|
30
|
+
return (
|
|
31
|
+
<Field className={cn("gap-1.5", className)} data-invalid={error ? true : undefined}>
|
|
32
|
+
<FieldLabel className="w-full flex-col items-stretch gap-1.5 font-normal [&_[data-slot=native-select-wrapper]]:w-full">
|
|
33
|
+
<span className="font-medium text-sm">{label}</span>
|
|
34
|
+
{children}
|
|
35
|
+
</FieldLabel>
|
|
36
|
+
{error ? (
|
|
37
|
+
<FieldError className="text-xs">{error}</FieldError>
|
|
38
|
+
) : (
|
|
39
|
+
hint && <FieldDescription className="text-xs">{hint}</FieldDescription>
|
|
40
|
+
)}
|
|
41
|
+
</Field>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Checkbox ของ shadcn + label บรรทัดเดียว — ใส่ `name` แล้วส่ง "on" ใน FormData ของ server action ได้
|
|
47
|
+
*
|
|
48
|
+
* <CheckboxField name="allowSignup" defaultChecked label="เปิดให้สมัครเอง" />
|
|
49
|
+
*/
|
|
50
|
+
export function CheckboxField({
|
|
51
|
+
label,
|
|
52
|
+
className,
|
|
53
|
+
...props
|
|
54
|
+
}: ComponentProps<typeof Checkbox> & { label: ReactNode }) {
|
|
55
|
+
return (
|
|
56
|
+
<Label className={cn("flex items-center gap-2 font-normal", className)}>
|
|
57
|
+
<Checkbox {...props} />
|
|
58
|
+
<span className="text-sm">{label}</span>
|
|
59
|
+
</Label>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -20,11 +20,9 @@ import {
|
|
|
20
20
|
useEffect,
|
|
21
21
|
useState,
|
|
22
22
|
} from "react";
|
|
23
|
-
import { FormMessage, type FormState } from "./form-message";
|
|
23
|
+
import { type FormAction, FormMessage, type FormState } from "./form-message";
|
|
24
24
|
import { useUiText } from "./ui-text";
|
|
25
25
|
|
|
26
|
-
type FormAction = (prev: FormState, formData: FormData) => Promise<FormState>;
|
|
27
|
-
|
|
28
26
|
/**
|
|
29
27
|
* ฟอร์ม "สร้าง/แก้ไข" ในกล่องโต้ตอบ — เปิดจากปุ่ม (trigger) ปิดเองเมื่อสำเร็จ
|
|
30
28
|
* ใช้แทนฟอร์มที่เคยวางค้างไว้บนหน้า เพื่อให้หน้า list สะอาดและมีการกระทำหลักอันเดียว
|
|
@@ -5,6 +5,9 @@ import { useUiText } from "./ui-text";
|
|
|
5
5
|
|
|
6
6
|
export type FormState = { ok: true; message?: string } | { ok: false; error: string } | null;
|
|
7
7
|
|
|
8
|
+
/** server action สำหรับ FormDialog/ActionForm: (state ก่อนหน้า, FormData) → FormState */
|
|
9
|
+
export type FormAction = (prev: FormState, formData: FormData) => Promise<FormState>;
|
|
10
|
+
|
|
8
11
|
/** ข้อความผลของ server action ใต้ฟอร์ม */
|
|
9
12
|
export function FormMessage({
|
|
10
13
|
state,
|
|
@@ -18,11 +21,7 @@ export function FormMessage({
|
|
|
18
21
|
return (
|
|
19
22
|
<p
|
|
20
23
|
role={state.ok ? "status" : "alert"}
|
|
21
|
-
className={cn(
|
|
22
|
-
"text-xs",
|
|
23
|
-
state.ok ? "text-emerald-700 dark:text-emerald-300" : "text-destructive",
|
|
24
|
-
className,
|
|
25
|
-
)}
|
|
24
|
+
className={cn("text-xs", state.ok ? "text-success" : "text-destructive", className)}
|
|
26
25
|
>
|
|
27
26
|
{state.ok ? (state.message ?? text.success) : state.error}
|
|
28
27
|
</p>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { StatusBadge, type Tone } from "@suphark/ui/components/shared/status-badge";
|
|
2
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
3
|
+
import { AlertTriangle, Check } from "lucide-react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
|
|
6
|
+
export type StepStatus = "done" | "todo" | "problem";
|
|
7
|
+
|
|
8
|
+
export interface StepperStep {
|
|
9
|
+
key: string;
|
|
10
|
+
href: string;
|
|
11
|
+
label: string;
|
|
12
|
+
status: StepStatus;
|
|
13
|
+
/** ป้ายสถานะเล็กใต้ชื่อขั้น เช่น "ตั้งค่าแล้ว", "ยังไม่มี redirect URI" */
|
|
14
|
+
statusLabel?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TONE: Record<StepStatus, Tone> = { done: "success", todo: "neutral", problem: "warning" };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* แถบขั้นตอน wizard ที่ทุกขั้นกดเข้าได้ (ไม่บังคับลำดับ) — แนวนอนเลื่อนได้บนจอแคบ / แนวตั้งบน lg
|
|
21
|
+
* ไม่มี client state → ใช้จาก server component ได้ (อยู่ที่ `@suphark/ui/next` เพราะใช้ next/link)
|
|
22
|
+
*
|
|
23
|
+
* <Stepper current="basics" ariaLabel="ขั้นตอนตั้งค่าแอป" steps={[
|
|
24
|
+
* { key: "basics", href: `/apps/${id}`, label: "ข้อมูลพื้นฐาน", status: "done", statusLabel: "ครบ" },
|
|
25
|
+
* { key: "oauth", href: `/apps/${id}/oauth`, label: "OAuth", status: "problem", statusLabel: "ไม่มี redirect URI" },
|
|
26
|
+
* ]} />
|
|
27
|
+
*/
|
|
28
|
+
export function Stepper({
|
|
29
|
+
steps,
|
|
30
|
+
current,
|
|
31
|
+
ariaLabel = "ขั้นตอน",
|
|
32
|
+
className,
|
|
33
|
+
}: {
|
|
34
|
+
steps: StepperStep[];
|
|
35
|
+
/** key ของขั้นที่กำลังอยู่ */
|
|
36
|
+
current: string;
|
|
37
|
+
ariaLabel?: string;
|
|
38
|
+
className?: string;
|
|
39
|
+
}) {
|
|
40
|
+
return (
|
|
41
|
+
<nav aria-label={ariaLabel} className={className}>
|
|
42
|
+
<ol className="no-scrollbar flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
|
|
43
|
+
{steps.map((step, i) => {
|
|
44
|
+
const active = step.key === current;
|
|
45
|
+
return (
|
|
46
|
+
<li key={step.key} className="shrink-0 lg:shrink">
|
|
47
|
+
<Link
|
|
48
|
+
href={step.href}
|
|
49
|
+
aria-current={active ? "step" : undefined}
|
|
50
|
+
className={cn(
|
|
51
|
+
"flex items-center gap-3 rounded-md px-3 py-2 text-sm no-underline outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50",
|
|
52
|
+
active && "bg-accent font-medium",
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
<StepMark status={step.status} index={i + 1} />
|
|
56
|
+
<span className="flex min-w-0 flex-col gap-0.5">
|
|
57
|
+
<span className="truncate">{step.label}</span>
|
|
58
|
+
{step.statusLabel && (
|
|
59
|
+
<StatusBadge
|
|
60
|
+
tone={TONE[step.status]}
|
|
61
|
+
className="w-fit px-1.5 py-0 font-normal text-[0.7rem] leading-4"
|
|
62
|
+
>
|
|
63
|
+
{step.statusLabel}
|
|
64
|
+
</StatusBadge>
|
|
65
|
+
)}
|
|
66
|
+
</span>
|
|
67
|
+
</Link>
|
|
68
|
+
</li>
|
|
69
|
+
);
|
|
70
|
+
})}
|
|
71
|
+
</ol>
|
|
72
|
+
</nav>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function StepMark({ status, index }: { status: StepStatus; index: number }) {
|
|
77
|
+
return (
|
|
78
|
+
<span
|
|
79
|
+
aria-hidden="true"
|
|
80
|
+
className={cn(
|
|
81
|
+
"flex size-6 shrink-0 items-center justify-center rounded-full border font-medium text-xs",
|
|
82
|
+
status === "done" && "border-success bg-success text-success-foreground",
|
|
83
|
+
status === "problem" && "border-warning text-warning",
|
|
84
|
+
status === "todo" && "border-border text-muted-foreground",
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{status === "done" ? (
|
|
88
|
+
<Check className="size-3.5" />
|
|
89
|
+
) : status === "problem" ? (
|
|
90
|
+
<AlertTriangle className="size-3.5" />
|
|
91
|
+
) : (
|
|
92
|
+
index
|
|
93
|
+
)}
|
|
94
|
+
</span>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* ui-audit-ok-file: โทน info/success/warning/danger ใช้ palette 50/900 คู่ dark โดยตั้งใจ — รอตัดสินใจ token ระดับ tone (todo) */
|
|
1
2
|
import { Alert, AlertDescription, AlertTitle } from "@suphark/ui/components/ui/alert";
|
|
2
3
|
import { cn } from "@suphark/ui/lib/utils";
|
|
3
4
|
import type { ComponentProps, ReactNode } from "react";
|