@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +20 -0
  3. package/package.json +5 -1
  4. package/scripts/audit.mjs +218 -0
  5. package/src/components/app-shell/app-brand.tsx +1 -0
  6. package/src/components/app-shell/app-nav.tsx +26 -12
  7. package/src/components/data-table/data-table-faceted-filter.tsx +14 -15
  8. package/src/components/data-table/data-table-row-actions.tsx +2 -1
  9. package/src/components/data-table/use-data-table.ts +7 -11
  10. package/src/components/design-system/sections/showcase-data-display.tsx +2 -2
  11. package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +1 -1
  12. package/src/components/design-system/sections/showcase-overlays-navigation.tsx +2 -2
  13. package/src/components/design-system/sections/showcase-primitives.tsx +1 -1
  14. package/src/components/pwa/pull-to-refresh.tsx +11 -5
  15. package/src/components/shared/action-form.tsx +78 -0
  16. package/src/components/shared/button/floating-save-button.tsx +1 -1
  17. package/src/components/shared/form/form-field-color-select.tsx +1 -1
  18. package/src/components/shared/form/form-field-combobox.tsx +15 -6
  19. package/src/components/shared/form/form-field-date.tsx +1 -1
  20. package/src/components/shared/form/form-field-input.tsx +6 -6
  21. package/src/components/shared/form/form-field-radio-cards.tsx +65 -24
  22. package/src/components/shared/form/labeled-field.tsx +61 -0
  23. package/src/components/shared/form-dialog.tsx +1 -3
  24. package/src/components/shared/form-message.tsx +4 -5
  25. package/src/components/shared/navigation/stepper.tsx +96 -0
  26. package/src/components/shared/notice.tsx +1 -0
  27. package/src/components/shared/number/formatted-number.tsx +39 -102
  28. package/src/components/shared/status-badge.tsx +1 -0
  29. package/src/components/shared/theme/theme-toggle.tsx +11 -8
  30. package/src/components/shared/time/client-date-formatter.tsx +47 -154
  31. package/src/components/ui/relative-date.tsx +1 -10
  32. package/src/hooks/use-mounted.ts +16 -0
  33. package/src/hooks/use-ticker.ts +53 -0
  34. package/src/index.ts +6 -1
  35. package/src/lib/intl-cache.ts +31 -0
  36. package/src/lib/status-colors.ts +1 -0
  37. 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 = pullDistance >= ACTIVATION_DISTANCE;
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
- }, [isRefreshing, pullDistance, router, rootSelector]);
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-emerald-500 text-white shadow-[0_0_20px_rgba(16,185,129,0.4)] hover:bg-emerald-500"
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-red-500">*</span>}
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
- // Singleton client-side cache to persist combobox options across mounts/renders
47
- const comboboxCache = new Map<any, { label: string; value: string; subLabel?: string }[]>();
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
- () => comboboxCache.get(cacheKey) || [],
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 = comboboxCache.get(cacheKey);
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
- comboboxCache.set(cacheKey, data);
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-red-500">*</span>}
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-red-500">*</span>}
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-red-500 ring-red-500" : "")}>
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-red-600" : "")}
190
+ className={cn(isDuplicate ? "text-destructive" : "")}
191
191
  />
192
192
  {isDuplicate && (
193
193
  <InputGroupAddon>
194
- <CircleAlert className="h-4 w-4 text-red-500" />
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-green-500" /> : <Copy className="h-4 w-4" />}
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-red-500">{uniqueErrorMessage}</p>
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-green-500" /> : <Copy className="h-4 w-4" />}
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?: Path<T>;
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 = (value: string, onChange: (val: string) => void) => (
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("grid grid-cols-1 gap-4 md:grid-cols-2", className)}
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
- <div
92
- className={cn(
93
- "absolute top-2 right-2 h-4 w-4 rounded-full border-2 border-muted transition-all",
94
- value === option.value && "scale-110 border-primary bg-primary",
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
- {value === option.value && (
98
- <div className="flex h-full w-full items-center justify-center">
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
- return renderContent(propValue || "", propOnValueChange || (() => {}));
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";