@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.
- package/CHANGELOG.md +36 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/components.json +21 -0
- package/package.json +108 -0
- package/scripts/audit-base-ui-props.mjs +179 -0
- package/scripts/audit-tailwind-typography.mjs +100 -0
- package/src/components/design-system/design-system-showcase.tsx +138 -0
- package/src/components/design-system/sections/showcase-data-display.tsx +454 -0
- package/src/components/design-system/sections/showcase-feedback.tsx +273 -0
- package/src/components/design-system/sections/showcase-forms.tsx +373 -0
- package/src/components/design-system/sections/showcase-foundations.tsx +384 -0
- package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +397 -0
- package/src/components/design-system/sections/showcase-overlays-navigation.tsx +479 -0
- package/src/components/design-system/sections/showcase-overview.tsx +368 -0
- package/src/components/design-system/sections/showcase-primitives.tsx +463 -0
- package/src/components/shared/__tests__/multi-select-combobox.test.tsx +180 -0
- package/src/components/shared/__tests__/name-id-fields.test.ts +24 -0
- package/src/components/shared/button/add-new-button.tsx +19 -0
- package/src/components/shared/button/base/action-button.tsx +134 -0
- package/src/components/shared/button/base/link-button.tsx +130 -0
- package/src/components/shared/button/cancel-button.tsx +27 -0
- package/src/components/shared/button/confirm-button.tsx +26 -0
- package/src/components/shared/button/delete-button.tsx +36 -0
- package/src/components/shared/button/floating-save-button.tsx +89 -0
- package/src/components/shared/button/index.ts +13 -0
- package/src/components/shared/button/print-button.tsx +67 -0
- package/src/components/shared/button/refresh-button.tsx +63 -0
- package/src/components/shared/button/save-button.tsx +29 -0
- package/src/components/shared/button/submit-button.tsx +58 -0
- package/src/components/shared/client-redirect.tsx +11 -0
- package/src/components/shared/combobox.tsx +116 -0
- package/src/components/shared/data-list.tsx +65 -0
- package/src/components/shared/date/date-range-picker.tsx +106 -0
- package/src/components/shared/description-list.tsx +27 -0
- package/src/components/shared/empty-row.tsx +13 -0
- package/src/components/shared/empty-state.tsx +31 -0
- package/src/components/shared/env-snippet.tsx +62 -0
- package/src/components/shared/form/__tests__/simple-select.test.tsx +72 -0
- package/src/components/shared/form/form-field-boolean.tsx +93 -0
- package/src/components/shared/form/form-field-color-select.tsx +86 -0
- package/src/components/shared/form/form-field-combobox.tsx +167 -0
- package/src/components/shared/form/form-field-date.tsx +55 -0
- package/src/components/shared/form/form-field-formatted-number.tsx +103 -0
- package/src/components/shared/form/form-field-input.tsx +372 -0
- package/src/components/shared/form/form-field-radio-cards.tsx +127 -0
- package/src/components/shared/form/form-field-radio-group.tsx +56 -0
- package/src/components/shared/form/form-field-select.tsx +312 -0
- package/src/components/shared/form/simple-select.tsx +152 -0
- package/src/components/shared/form-dialog.tsx +109 -0
- package/src/components/shared/form-message.tsx +30 -0
- package/src/components/shared/initials.tsx +33 -0
- package/src/components/shared/layout/error-state.tsx +160 -0
- package/src/components/shared/layout/page-shell.tsx +57 -0
- package/src/components/shared/link/transition-link.tsx +72 -0
- package/src/components/shared/multi-select-combobox.tsx +392 -0
- package/src/components/shared/name-id-fields.tsx +133 -0
- package/src/components/shared/navigation/page-breadcrumb.tsx +100 -0
- package/src/components/shared/notice.tsx +35 -0
- package/src/components/shared/number/formatted-number.tsx +133 -0
- package/src/components/shared/page-header.tsx +25 -0
- package/src/components/shared/page-skeleton.tsx +18 -0
- package/src/components/shared/row-actions.tsx +93 -0
- package/src/components/shared/row-menu.tsx +55 -0
- package/src/components/shared/secret-reveal.tsx +53 -0
- package/src/components/shared/section-card.tsx +44 -0
- package/src/components/shared/stat.tsx +26 -0
- package/src/components/shared/status-badge.tsx +47 -0
- package/src/components/shared/text/primary-secondary-text.tsx +186 -0
- package/src/components/shared/theme/index.ts +4 -0
- package/src/components/shared/theme/theme-menu-items.tsx +43 -0
- package/src/components/shared/theme/theme-provider.tsx +39 -0
- package/src/components/shared/theme/theme-switch.tsx +36 -0
- package/src/components/shared/theme/theme-toggle.tsx +76 -0
- package/src/components/shared/time/client-date-formatter.tsx +188 -0
- package/src/components/shared/ui-text.tsx +47 -0
- package/src/components/ui/__tests__/combobox.test.tsx +40 -0
- package/src/components/ui/__tests__/date-picker.test.tsx +53 -0
- package/src/components/ui/__tests__/slider.test.tsx +18 -0
- package/src/components/ui/__tests__/tabs.test.tsx +33 -0
- package/src/components/ui/accordion.tsx +69 -0
- package/src/components/ui/alert-dialog.tsx +161 -0
- package/src/components/ui/alert.tsx +68 -0
- package/src/components/ui/aspect-ratio.tsx +22 -0
- package/src/components/ui/attachment.tsx +194 -0
- package/src/components/ui/avatar.tsx +92 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/breadcrumb.tsx +102 -0
- package/src/components/ui/button-group.tsx +77 -0
- package/src/components/ui/button.tsx +57 -0
- package/src/components/ui/calendar.tsx +184 -0
- package/src/components/ui/card.tsx +87 -0
- package/src/components/ui/carousel.tsx +230 -0
- package/src/components/ui/chart.tsx +338 -0
- package/src/components/ui/checkbox.tsx +27 -0
- package/src/components/ui/collapsible.tsx +17 -0
- package/src/components/ui/combobox.tsx +272 -0
- package/src/components/ui/command.tsx +180 -0
- package/src/components/ui/context-menu.tsx +244 -0
- package/src/components/ui/date-picker.tsx +308 -0
- package/src/components/ui/dialog.tsx +137 -0
- package/src/components/ui/drawer.tsx +208 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/empty.tsx +93 -0
- package/src/components/ui/field.tsx +223 -0
- package/src/components/ui/form.tsx +161 -0
- package/src/components/ui/hover-card.tsx +46 -0
- package/src/components/ui/input-group.tsx +145 -0
- package/src/components/ui/input-otp.tsx +85 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/item.tsx +187 -0
- package/src/components/ui/kbd.tsx +26 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/menubar.tsx +266 -0
- package/src/components/ui/navigation-menu.tsx +160 -0
- package/src/components/ui/pagination.tsx +117 -0
- package/src/components/ui/popover.tsx +76 -0
- package/src/components/ui/progress.tsx +66 -0
- package/src/components/ui/radio-group.tsx +38 -0
- package/src/components/ui/relative-date.tsx +40 -0
- package/src/components/ui/resizable.tsx +41 -0
- package/src/components/ui/scroll-area.tsx +67 -0
- package/src/components/ui/searchable-select.tsx +124 -0
- package/src/components/ui/select.tsx +189 -0
- package/src/components/ui/separator.tsx +21 -0
- package/src/components/ui/sheet.tsx +124 -0
- package/src/components/ui/sidebar.tsx +691 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/slider.tsx +55 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/table.tsx +95 -0
- package/src/components/ui/tabs.tsx +79 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/toggle-group.tsx +86 -0
- package/src/components/ui/toggle.tsx +44 -0
- package/src/components/ui/tooltip.tsx +54 -0
- package/src/hooks/use-copy-to-clipboard.ts +27 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/index.ts +156 -0
- package/src/lib/formatters.ts +44 -0
- package/src/lib/permission.ts +75 -0
- package/src/lib/status-colors.ts +52 -0
- package/src/lib/utils.ts +6 -0
- package/src/next.ts +15 -0
- 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
|
+
}
|