@suphark/ui 0.1.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.
Files changed (147) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +21 -0
  3. package/README.md +120 -0
  4. package/components.json +21 -0
  5. package/package.json +108 -0
  6. package/scripts/audit-base-ui-props.mjs +179 -0
  7. package/scripts/audit-tailwind-typography.mjs +100 -0
  8. package/src/components/design-system/design-system-showcase.tsx +138 -0
  9. package/src/components/design-system/sections/showcase-data-display.tsx +454 -0
  10. package/src/components/design-system/sections/showcase-feedback.tsx +273 -0
  11. package/src/components/design-system/sections/showcase-forms.tsx +373 -0
  12. package/src/components/design-system/sections/showcase-foundations.tsx +384 -0
  13. package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +397 -0
  14. package/src/components/design-system/sections/showcase-overlays-navigation.tsx +479 -0
  15. package/src/components/design-system/sections/showcase-overview.tsx +368 -0
  16. package/src/components/design-system/sections/showcase-primitives.tsx +463 -0
  17. package/src/components/shared/__tests__/multi-select-combobox.test.tsx +180 -0
  18. package/src/components/shared/__tests__/name-id-fields.test.ts +24 -0
  19. package/src/components/shared/button/add-new-button.tsx +19 -0
  20. package/src/components/shared/button/base/action-button.tsx +134 -0
  21. package/src/components/shared/button/base/link-button.tsx +130 -0
  22. package/src/components/shared/button/cancel-button.tsx +27 -0
  23. package/src/components/shared/button/confirm-button.tsx +26 -0
  24. package/src/components/shared/button/delete-button.tsx +36 -0
  25. package/src/components/shared/button/floating-save-button.tsx +89 -0
  26. package/src/components/shared/button/index.ts +13 -0
  27. package/src/components/shared/button/print-button.tsx +67 -0
  28. package/src/components/shared/button/refresh-button.tsx +63 -0
  29. package/src/components/shared/button/save-button.tsx +29 -0
  30. package/src/components/shared/button/submit-button.tsx +58 -0
  31. package/src/components/shared/client-redirect.tsx +11 -0
  32. package/src/components/shared/combobox.tsx +116 -0
  33. package/src/components/shared/data-list.tsx +65 -0
  34. package/src/components/shared/date/date-range-picker.tsx +106 -0
  35. package/src/components/shared/description-list.tsx +27 -0
  36. package/src/components/shared/empty-row.tsx +13 -0
  37. package/src/components/shared/empty-state.tsx +31 -0
  38. package/src/components/shared/env-snippet.tsx +62 -0
  39. package/src/components/shared/form/__tests__/simple-select.test.tsx +72 -0
  40. package/src/components/shared/form/form-field-boolean.tsx +93 -0
  41. package/src/components/shared/form/form-field-color-select.tsx +86 -0
  42. package/src/components/shared/form/form-field-combobox.tsx +167 -0
  43. package/src/components/shared/form/form-field-date.tsx +55 -0
  44. package/src/components/shared/form/form-field-formatted-number.tsx +103 -0
  45. package/src/components/shared/form/form-field-input.tsx +372 -0
  46. package/src/components/shared/form/form-field-radio-cards.tsx +127 -0
  47. package/src/components/shared/form/form-field-radio-group.tsx +56 -0
  48. package/src/components/shared/form/form-field-select.tsx +312 -0
  49. package/src/components/shared/form/simple-select.tsx +152 -0
  50. package/src/components/shared/form-dialog.tsx +109 -0
  51. package/src/components/shared/form-message.tsx +30 -0
  52. package/src/components/shared/initials.tsx +33 -0
  53. package/src/components/shared/layout/error-state.tsx +160 -0
  54. package/src/components/shared/layout/page-shell.tsx +57 -0
  55. package/src/components/shared/link/transition-link.tsx +72 -0
  56. package/src/components/shared/multi-select-combobox.tsx +392 -0
  57. package/src/components/shared/name-id-fields.tsx +133 -0
  58. package/src/components/shared/navigation/page-breadcrumb.tsx +100 -0
  59. package/src/components/shared/notice.tsx +35 -0
  60. package/src/components/shared/number/formatted-number.tsx +133 -0
  61. package/src/components/shared/page-header.tsx +25 -0
  62. package/src/components/shared/page-skeleton.tsx +18 -0
  63. package/src/components/shared/row-actions.tsx +93 -0
  64. package/src/components/shared/row-menu.tsx +55 -0
  65. package/src/components/shared/secret-reveal.tsx +53 -0
  66. package/src/components/shared/section-card.tsx +44 -0
  67. package/src/components/shared/stat.tsx +26 -0
  68. package/src/components/shared/status-badge.tsx +47 -0
  69. package/src/components/shared/text/primary-secondary-text.tsx +186 -0
  70. package/src/components/shared/theme/index.ts +4 -0
  71. package/src/components/shared/theme/theme-menu-items.tsx +43 -0
  72. package/src/components/shared/theme/theme-provider.tsx +39 -0
  73. package/src/components/shared/theme/theme-switch.tsx +36 -0
  74. package/src/components/shared/theme/theme-toggle.tsx +76 -0
  75. package/src/components/shared/time/client-date-formatter.tsx +188 -0
  76. package/src/components/shared/ui-text.tsx +47 -0
  77. package/src/components/ui/__tests__/combobox.test.tsx +40 -0
  78. package/src/components/ui/__tests__/date-picker.test.tsx +53 -0
  79. package/src/components/ui/__tests__/slider.test.tsx +18 -0
  80. package/src/components/ui/__tests__/tabs.test.tsx +33 -0
  81. package/src/components/ui/accordion.tsx +69 -0
  82. package/src/components/ui/alert-dialog.tsx +161 -0
  83. package/src/components/ui/alert.tsx +68 -0
  84. package/src/components/ui/aspect-ratio.tsx +22 -0
  85. package/src/components/ui/attachment.tsx +194 -0
  86. package/src/components/ui/avatar.tsx +92 -0
  87. package/src/components/ui/badge.tsx +48 -0
  88. package/src/components/ui/breadcrumb.tsx +102 -0
  89. package/src/components/ui/button-group.tsx +77 -0
  90. package/src/components/ui/button.tsx +57 -0
  91. package/src/components/ui/calendar.tsx +184 -0
  92. package/src/components/ui/card.tsx +87 -0
  93. package/src/components/ui/carousel.tsx +230 -0
  94. package/src/components/ui/chart.tsx +338 -0
  95. package/src/components/ui/checkbox.tsx +27 -0
  96. package/src/components/ui/collapsible.tsx +17 -0
  97. package/src/components/ui/combobox.tsx +272 -0
  98. package/src/components/ui/command.tsx +180 -0
  99. package/src/components/ui/context-menu.tsx +244 -0
  100. package/src/components/ui/date-picker.tsx +308 -0
  101. package/src/components/ui/dialog.tsx +137 -0
  102. package/src/components/ui/drawer.tsx +208 -0
  103. package/src/components/ui/dropdown-menu.tsx +257 -0
  104. package/src/components/ui/empty.tsx +93 -0
  105. package/src/components/ui/field.tsx +223 -0
  106. package/src/components/ui/form.tsx +161 -0
  107. package/src/components/ui/hover-card.tsx +46 -0
  108. package/src/components/ui/input-group.tsx +145 -0
  109. package/src/components/ui/input-otp.tsx +85 -0
  110. package/src/components/ui/input.tsx +19 -0
  111. package/src/components/ui/item.tsx +187 -0
  112. package/src/components/ui/kbd.tsx +26 -0
  113. package/src/components/ui/label.tsx +19 -0
  114. package/src/components/ui/menubar.tsx +266 -0
  115. package/src/components/ui/navigation-menu.tsx +160 -0
  116. package/src/components/ui/pagination.tsx +117 -0
  117. package/src/components/ui/popover.tsx +76 -0
  118. package/src/components/ui/progress.tsx +66 -0
  119. package/src/components/ui/radio-group.tsx +38 -0
  120. package/src/components/ui/relative-date.tsx +40 -0
  121. package/src/components/ui/resizable.tsx +41 -0
  122. package/src/components/ui/scroll-area.tsx +67 -0
  123. package/src/components/ui/searchable-select.tsx +124 -0
  124. package/src/components/ui/select.tsx +189 -0
  125. package/src/components/ui/separator.tsx +21 -0
  126. package/src/components/ui/sheet.tsx +124 -0
  127. package/src/components/ui/sidebar.tsx +691 -0
  128. package/src/components/ui/skeleton.tsx +13 -0
  129. package/src/components/ui/slider.tsx +55 -0
  130. package/src/components/ui/sonner.tsx +45 -0
  131. package/src/components/ui/spinner.tsx +16 -0
  132. package/src/components/ui/switch.tsx +32 -0
  133. package/src/components/ui/table.tsx +95 -0
  134. package/src/components/ui/tabs.tsx +79 -0
  135. package/src/components/ui/textarea.tsx +17 -0
  136. package/src/components/ui/toggle-group.tsx +86 -0
  137. package/src/components/ui/toggle.tsx +44 -0
  138. package/src/components/ui/tooltip.tsx +54 -0
  139. package/src/hooks/use-copy-to-clipboard.ts +27 -0
  140. package/src/hooks/use-mobile.ts +21 -0
  141. package/src/index.ts +156 -0
  142. package/src/lib/formatters.ts +44 -0
  143. package/src/lib/permission.ts +75 -0
  144. package/src/lib/status-colors.ts +52 -0
  145. package/src/lib/utils.ts +6 -0
  146. package/src/next.ts +15 -0
  147. package/src/styles.css +214 -0
@@ -0,0 +1,372 @@
1
+ "use client";
2
+
3
+ import {
4
+ Command,
5
+ CommandEmpty,
6
+ CommandGroup,
7
+ CommandItem,
8
+ CommandList,
9
+ } from "@suphark/ui/components/ui/command";
10
+ import {
11
+ FormControl,
12
+ FormField,
13
+ FormItem,
14
+ FormLabel,
15
+ FormMessage,
16
+ } from "@suphark/ui/components/ui/form";
17
+ import { Input } from "@suphark/ui/components/ui/input";
18
+ import {
19
+ InputGroup,
20
+ InputGroupAddon,
21
+ InputGroupButton,
22
+ InputGroupInput,
23
+ } from "@suphark/ui/components/ui/input-group";
24
+ import { Textarea } from "@suphark/ui/components/ui/textarea";
25
+ import { useCopyToClipboard } from "@suphark/ui/hooks/use-copy-to-clipboard";
26
+ import { cn } from "@suphark/ui/lib/utils";
27
+ import { Command as CommandPrimitive } from "cmdk";
28
+ import { Check, CircleAlert, Copy } from "lucide-react";
29
+ import type { ComponentProps } from "react";
30
+ import { useState } from "react";
31
+ import type { ControllerRenderProps, FieldValues, Path, PathValue } from "react-hook-form";
32
+
33
+ // --- Types ---
34
+
35
+ type BaseProps<T extends FieldValues> = {
36
+ control: any;
37
+ name: Path<T>;
38
+ label: string;
39
+ required?: boolean;
40
+ description?: string;
41
+ suffix?: string;
42
+ };
43
+
44
+ // Variant 1: Standard Input
45
+ type StandardInputProps<T extends FieldValues> = BaseProps<T> & {
46
+ variant?: "default";
47
+ enableCopy?: boolean;
48
+ isNumeric?: boolean;
49
+ } & Omit<ComponentProps<typeof Input>, "required" | "form" | "name" | "value" | "onChange">;
50
+
51
+ // Variant 2: Autocomplete
52
+ export type AutocompleteOption = {
53
+ value: string;
54
+ label: string;
55
+ };
56
+ type AutocompleteProps<T extends FieldValues> = BaseProps<T> & {
57
+ variant: "autocomplete";
58
+ data: AutocompleteOption[];
59
+ emptyMessage?: string;
60
+ placeholder?: string;
61
+ disabled?: boolean;
62
+ };
63
+
64
+ // Variant 3: Unique Check
65
+ type UniqueCheckProps<T extends FieldValues> = BaseProps<T> & {
66
+ variant: "unique-check";
67
+ existingValues: string[] | ((value: string) => boolean);
68
+ uniqueErrorMessage?: string;
69
+ enableCopy?: boolean;
70
+ } & Omit<ComponentProps<typeof InputGroupInput>, "required" | "form" | "name" | "value">;
71
+
72
+ // Variant 4: Textarea
73
+ type TextareaProps<T extends FieldValues> = BaseProps<T> & {
74
+ variant: "textarea";
75
+ } & Omit<ComponentProps<typeof Textarea>, "required" | "form" | "name">;
76
+
77
+ export type FormFieldInputProps<T extends FieldValues> =
78
+ | StandardInputProps<T>
79
+ | AutocompleteProps<T>
80
+ | UniqueCheckProps<T>
81
+ | TextareaProps<T>;
82
+
83
+ // --- Helper Components ---
84
+
85
+ function AutocompleteInput<T extends FieldValues>({
86
+ field,
87
+ data,
88
+ placeholder,
89
+ disabled,
90
+ emptyMessage = "No results found.",
91
+ required,
92
+ }: {
93
+ field: ControllerRenderProps<T, Path<T>>;
94
+ data: AutocompleteOption[];
95
+ placeholder?: string;
96
+ disabled?: boolean;
97
+ emptyMessage?: string;
98
+ required?: boolean;
99
+ }) {
100
+ const [open, setOpen] = useState(false);
101
+
102
+ return (
103
+ <Command className="overflow-visible bg-transparent">
104
+ <div
105
+ className={cn(
106
+ "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm ring-offset-background focus-within:border-ring focus-within:ring-1 focus-within:ring-ring",
107
+ disabled && "cursor-not-allowed opacity-50",
108
+ required && !field.value && "border-input",
109
+ )}
110
+ >
111
+ <CommandPrimitive.Input
112
+ value={field.value as string}
113
+ onValueChange={field.onChange}
114
+ onFocus={() => setOpen(true)}
115
+ onBlur={() => setOpen(false)}
116
+ placeholder={placeholder}
117
+ disabled={disabled}
118
+ className="w-full bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
119
+ />
120
+ </div>
121
+ <div className="relative mt-1">
122
+ {open && field.value && (
123
+ <CommandList className="fade-in-0 zoom-in-95 absolute top-0 z-50 w-full animate-in rounded-md border bg-popover text-popover-foreground shadow-md outline-none">
124
+ <CommandEmpty className="py-2 text-center text-muted-foreground text-sm">
125
+ {emptyMessage}
126
+ </CommandEmpty>
127
+ <CommandGroup>
128
+ {data.map((option) => (
129
+ <CommandItem
130
+ key={option.value}
131
+ value={option.label}
132
+ onSelect={() => {
133
+ field.onChange(option.value as PathValue<T, Path<T>>);
134
+ setOpen(false);
135
+ }}
136
+ onMouseDown={(e) => {
137
+ e.preventDefault();
138
+ e.stopPropagation();
139
+ }}
140
+ >
141
+ <Check
142
+ className={cn(
143
+ "mr-2 h-4 w-4",
144
+ field.value === option.value ? "opacity-100" : "opacity-0",
145
+ )}
146
+ />
147
+ {option.label}
148
+ </CommandItem>
149
+ ))}
150
+ </CommandGroup>
151
+ </CommandList>
152
+ )}
153
+ </div>
154
+ </Command>
155
+ );
156
+ }
157
+
158
+ function UniqueCheckInput<T extends FieldValues>({
159
+ field,
160
+ existingValues,
161
+ uniqueErrorMessage = "ข้อมูลนี้มีอยู่ในระบบแล้ว",
162
+ enableCopy,
163
+ ...props
164
+ }: {
165
+ field: ControllerRenderProps<T, Path<T>>;
166
+ existingValues: string[] | ((value: string) => boolean);
167
+ uniqueErrorMessage?: string;
168
+ enableCopy?: boolean;
169
+ } & Omit<ComponentProps<typeof InputGroupInput>, "value">) {
170
+ const value = field.value as string;
171
+ const { isCopied, copyToClipboard } = useCopyToClipboard();
172
+
173
+ // Determine if duplicate
174
+ let isDuplicate = false;
175
+ if (value) {
176
+ if (Array.isArray(existingValues)) {
177
+ isDuplicate = existingValues.includes(value);
178
+ } else {
179
+ isDuplicate = existingValues(value);
180
+ }
181
+ }
182
+
183
+ return (
184
+ <div>
185
+ <InputGroup className={cn(isDuplicate ? "border-red-500 ring-red-500" : "")}>
186
+ <InputGroupInput
187
+ {...field}
188
+ {...props}
189
+ value={value || ""}
190
+ className={cn(isDuplicate ? "text-red-600" : "")}
191
+ />
192
+ {isDuplicate && (
193
+ <InputGroupAddon>
194
+ <CircleAlert className="h-4 w-4 text-red-500" />
195
+ </InputGroupAddon>
196
+ )}
197
+ {enableCopy && (
198
+ <InputGroupButton
199
+ onClick={() => copyToClipboard(String(field.value || ""))}
200
+ type="button"
201
+ title="Copy to clipboard"
202
+ >
203
+ {isCopied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
204
+ </InputGroupButton>
205
+ )}
206
+ </InputGroup>
207
+ {isDuplicate && (
208
+ <p className="mt-1 font-medium text-[0.8rem] text-red-500">{uniqueErrorMessage}</p>
209
+ )}
210
+ </div>
211
+ );
212
+ }
213
+
214
+ // --- Main Component ---
215
+
216
+ /** ช่องกรอกที่มีปุ่มคัดลอก — แยกเป็น component เพื่อให้เรียก hook ที่ระดับบนสุดตาม rules of hooks */
217
+ function CopyableInput(props: ComponentProps<typeof InputGroupInput>) {
218
+ const { isCopied, copyToClipboard } = useCopyToClipboard();
219
+ return (
220
+ <InputGroup>
221
+ <InputGroupInput {...props} />
222
+ <InputGroupButton
223
+ onClick={() => copyToClipboard(String(props.value || ""))}
224
+ type="button"
225
+ title="Copy to clipboard"
226
+ >
227
+ {isCopied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
228
+ </InputGroupButton>
229
+ </InputGroup>
230
+ );
231
+ }
232
+
233
+ export function FormFieldInput<T extends FieldValues>({
234
+ control,
235
+ name,
236
+ label,
237
+ required,
238
+ ...props
239
+ }: FormFieldInputProps<T>) {
240
+ return (
241
+ <FormField
242
+ control={control}
243
+ name={name}
244
+ render={({ field }) => (
245
+ <FormItem>
246
+ <FormLabel>
247
+ {label}{" "}
248
+ {required ? (
249
+ <span className="text-destructive" aria-hidden="true">
250
+ *
251
+ </span>
252
+ ) : null}
253
+ {required ? <span className="sr-only"> required</span> : null}
254
+ </FormLabel>
255
+ <FormControl>
256
+ {(() => {
257
+ if (props.variant === "autocomplete") {
258
+ const { data, emptyMessage, ...rest } = props as AutocompleteProps<T>;
259
+ return (
260
+ <AutocompleteInput
261
+ field={field}
262
+ data={data}
263
+ emptyMessage={emptyMessage}
264
+ required={required}
265
+ {...rest}
266
+ />
267
+ );
268
+ } else if (props.variant === "unique-check") {
269
+ const { existingValues, uniqueErrorMessage, variant, enableCopy, ...rest } =
270
+ props as UniqueCheckProps<T>;
271
+ return (
272
+ <UniqueCheckInput
273
+ field={field}
274
+ existingValues={existingValues}
275
+ uniqueErrorMessage={uniqueErrorMessage}
276
+ enableCopy={enableCopy}
277
+ {...rest}
278
+ />
279
+ );
280
+ } else if (props.variant === "textarea") {
281
+ const { variant, ...rest } = props as TextareaProps<T>;
282
+ return (
283
+ <Textarea
284
+ {...field}
285
+ {...rest}
286
+ value={field.value ? String(field.value) : ""}
287
+ className={cn(required && !field.value && "border-input", rest.className)}
288
+ />
289
+ );
290
+ } else {
291
+ // Default Standard Input
292
+ const { variant, enableCopy, isNumeric, ...rest } = props as StandardInputProps<T>;
293
+
294
+ const value = field.value ? String(field.value) : "";
295
+
296
+ // --- Numeric Formatting Logic ---
297
+ let displayValue = value;
298
+ if (isNumeric && value !== "" && value !== "-") {
299
+ const parts = value.split(".");
300
+ const intPart = parts[0];
301
+ const decimalPart = parts.length > 1 ? `.${parts[1]}` : "";
302
+ const numInt = Number(intPart);
303
+ if (!Number.isNaN(numInt)) {
304
+ displayValue = numInt.toLocaleString("en-US") + decimalPart;
305
+ }
306
+ }
307
+
308
+ const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => {
309
+ const rawValue = e.target.value.replace(/,/g, "");
310
+ if (rawValue === "" || rawValue === "-" || rawValue.endsWith(".")) {
311
+ field.onChange(rawValue as PathValue<T, Path<T>>);
312
+ return;
313
+ }
314
+ const numValue = Number(rawValue);
315
+ if (!Number.isNaN(numValue)) {
316
+ field.onChange(numValue as PathValue<T, Path<T>>);
317
+ }
318
+ };
319
+ // --------------------------------
320
+
321
+ if (enableCopy) {
322
+ return (
323
+ <CopyableInput
324
+ {...field}
325
+ {...rest}
326
+ type={isNumeric ? "text" : rest.type}
327
+ value={displayValue}
328
+ onChange={isNumeric ? handleNumericChange : field.onChange}
329
+ />
330
+ );
331
+ }
332
+
333
+ if (props.suffix) {
334
+ return (
335
+ <InputGroup>
336
+ <InputGroupInput
337
+ {...field}
338
+ {...rest}
339
+ type={isNumeric ? "text" : rest.type}
340
+ value={displayValue}
341
+ onChange={isNumeric ? handleNumericChange : field.onChange}
342
+ className={cn(required && !field.value && "border-input", rest.className)}
343
+ />
344
+ <InputGroupAddon className="bg-muted/50 px-3 text-muted-foreground text-xs">
345
+ {props.suffix}
346
+ </InputGroupAddon>
347
+ </InputGroup>
348
+ );
349
+ }
350
+
351
+ return (
352
+ <Input
353
+ {...field}
354
+ {...rest}
355
+ type={isNumeric ? "text" : rest.type}
356
+ value={displayValue}
357
+ onChange={isNumeric ? handleNumericChange : field.onChange}
358
+ className={cn(required && !field.value && "border-input", rest.className)}
359
+ />
360
+ );
361
+ }
362
+ })()}
363
+ </FormControl>
364
+ {props.description && (
365
+ <p className="text-[0.8rem] text-muted-foreground">{props.description}</p>
366
+ )}
367
+ <FormMessage />
368
+ </FormItem>
369
+ )}
370
+ />
371
+ );
372
+ }
@@ -0,0 +1,127 @@
1
+ "use client";
2
+
3
+ import { FormControl, FormField, FormItem, FormMessage } from "@suphark/ui/components/ui/form";
4
+ import { Label } from "@suphark/ui/components/ui/label";
5
+ import { RadioGroup, RadioGroupItem } from "@suphark/ui/components/ui/radio-group";
6
+ import { cn } from "@suphark/ui/lib/utils";
7
+ import { type ReactNode, useId } from "react";
8
+ import type { Control, FieldValues, Path } from "react-hook-form";
9
+
10
+ type RadioCardOption = {
11
+ value: string;
12
+ label: string;
13
+ description?: string;
14
+ icon?: ReactNode;
15
+ colorClass?: string;
16
+ };
17
+
18
+ interface FormFieldRadioCardsProps<T extends FieldValues = any> {
19
+ control?: Control<T>;
20
+ name?: Path<T>;
21
+ value?: string;
22
+ onValueChange?: (value: string) => void;
23
+ label?: string;
24
+ options: RadioCardOption[];
25
+ disabled?: boolean;
26
+ className?: string;
27
+ size?: "sm" | "md" | "lg";
28
+ }
29
+
30
+ export function FormFieldRadioCards<T extends FieldValues = any>({
31
+ control,
32
+ name,
33
+ value: propValue,
34
+ onValueChange: propOnValueChange,
35
+ label,
36
+ options,
37
+ disabled,
38
+ className,
39
+ size = "md",
40
+ }: FormFieldRadioCardsProps<T>) {
41
+ const baseId = useId();
42
+
43
+ const renderContent = (value: string, onChange: (val: string) => void) => (
44
+ <div className="space-y-3">
45
+ {label && <Label className="block font-semibold text-base">{label}</Label>}
46
+ <RadioGroup
47
+ onValueChange={(val) => {
48
+ if (val !== null) onChange(val);
49
+ }}
50
+ value={value}
51
+ disabled={disabled}
52
+ className={cn("grid grid-cols-1 gap-4 md:grid-cols-2", className)}
53
+ >
54
+ {options.map((option) => {
55
+ const id = `${baseId}-${option.value}`;
56
+ return (
57
+ <div key={option.value} className="relative">
58
+ <RadioGroupItem value={option.value} id={id} className="peer sr-only" />
59
+ <Label
60
+ htmlFor={id}
61
+ 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-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5",
63
+ size === "sm" && "min-h-[70px] gap-1 p-2",
64
+ size === "md" && "min-h-[100px] gap-2 p-4",
65
+ size === "lg" && "min-h-[120px] gap-4 p-6",
66
+ disabled && "cursor-not-allowed opacity-50",
67
+ )}
68
+ >
69
+ {option.icon && (
70
+ <div className={cn(option.colorClass, size === "sm" ? "text-lg" : "text-2xl")}>
71
+ {option.icon}
72
+ </div>
73
+ )}
74
+
75
+ <div className="text-center">
76
+ <div className={cn("font-bold", size === "sm" ? "text-xs" : "text-base")}>
77
+ {option.label}
78
+ </div>
79
+ {option.description && (
80
+ <div
81
+ className={cn(
82
+ "font-normal text-muted-foreground leading-tight",
83
+ size === "sm" ? "text-xs" : "text-xs",
84
+ )}
85
+ >
86
+ {option.description}
87
+ </div>
88
+ )}
89
+ </div>
90
+
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
+ )}
96
+ >
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>
103
+ </Label>
104
+ </div>
105
+ );
106
+ })}
107
+ </RadioGroup>
108
+ </div>
109
+ );
110
+
111
+ if (control && name) {
112
+ return (
113
+ <FormField
114
+ control={control}
115
+ name={name}
116
+ render={({ field }) => (
117
+ <FormItem className="space-y-3">
118
+ <FormControl>{renderContent(field.value, field.onChange)}</FormControl>
119
+ <FormMessage />
120
+ </FormItem>
121
+ )}
122
+ />
123
+ );
124
+ }
125
+
126
+ return renderContent(propValue || "", propOnValueChange || (() => {}));
127
+ }
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { FormControl, FormItem, FormLabel, FormMessage } from "@suphark/ui/components/ui/form";
4
+ import { RadioGroup, RadioGroupItem } from "@suphark/ui/components/ui/radio-group";
5
+ import { type Control, Controller, type FieldValues, type Path } from "react-hook-form";
6
+
7
+ type RadioOption = {
8
+ value: string;
9
+ label: string;
10
+ };
11
+
12
+ interface FormFieldRadioGroupProps<T extends FieldValues> {
13
+ control: Control<T>;
14
+ name: Path<T>;
15
+ label: string;
16
+ options: RadioOption[];
17
+ disabled?: boolean;
18
+ }
19
+
20
+ export function FormFieldRadioGroup<T extends FieldValues>({
21
+ control,
22
+ name,
23
+ label,
24
+ options,
25
+ disabled,
26
+ }: FormFieldRadioGroupProps<T>) {
27
+ return (
28
+ <FormItem className="space-y-3">
29
+ <FormLabel>{label}</FormLabel>
30
+ <Controller
31
+ control={control}
32
+ name={name}
33
+ render={({ field }) => (
34
+ <FormControl>
35
+ <RadioGroup
36
+ onValueChange={(value) => field.onChange(value === "null" ? null : value === "true")}
37
+ value={String(field.value)}
38
+ disabled={disabled}
39
+ className="flex items-center space-x-4"
40
+ >
41
+ {options.map((option) => (
42
+ <FormItem key={option.value} className="flex items-center space-x-2 space-y-0">
43
+ <FormControl>
44
+ <RadioGroupItem value={option.value} />
45
+ </FormControl>
46
+ <FormLabel className="font-normal">{option.label}</FormLabel>
47
+ </FormItem>
48
+ ))}
49
+ </RadioGroup>
50
+ </FormControl>
51
+ )}
52
+ />
53
+ <FormMessage />
54
+ </FormItem>
55
+ );
56
+ }