@voila.dev/ui 1.1.9
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/package.json +78 -0
- package/src/components/ui/accordion.tsx +79 -0
- package/src/components/ui/alert-dialog.tsx +198 -0
- package/src/components/ui/alert.tsx +105 -0
- package/src/components/ui/aspect-ratio.tsx +30 -0
- package/src/components/ui/avatar.tsx +125 -0
- package/src/components/ui/badge-variants.ts +166 -0
- package/src/components/ui/badge.tsx +45 -0
- package/src/components/ui/banner.tsx +84 -0
- package/src/components/ui/breadcrumb.tsx +127 -0
- package/src/components/ui/button-group.tsx +99 -0
- package/src/components/ui/button-variants.ts +108 -0
- package/src/components/ui/button.tsx +54 -0
- package/src/components/ui/calendar.tsx +266 -0
- package/src/components/ui/card.tsx +155 -0
- package/src/components/ui/carousel.tsx +319 -0
- package/src/components/ui/chat.tsx +896 -0
- package/src/components/ui/checkbox-group.tsx +44 -0
- package/src/components/ui/checkbox.tsx +34 -0
- package/src/components/ui/chip.tsx +60 -0
- package/src/components/ui/collapsible.tsx +44 -0
- package/src/components/ui/color-picker.tsx +191 -0
- package/src/components/ui/combobox.tsx +317 -0
- package/src/components/ui/command.tsx +187 -0
- package/src/components/ui/confirm-dialog.tsx +125 -0
- package/src/components/ui/context-menu.tsx +254 -0
- package/src/components/ui/copyable-text.tsx +65 -0
- package/src/components/ui/date-picker.tsx +316 -0
- package/src/components/ui/date-time-picker.tsx +1071 -0
- package/src/components/ui/dialog.tsx +176 -0
- package/src/components/ui/direction.tsx +15 -0
- package/src/components/ui/drawer.tsx +178 -0
- package/src/components/ui/dropdown-menu.tsx +247 -0
- package/src/components/ui/empty.tsx +131 -0
- package/src/components/ui/field.tsx +257 -0
- package/src/components/ui/formatted-input.tsx +219 -0
- package/src/components/ui/gallery.tsx +161 -0
- package/src/components/ui/hover-card.tsx +77 -0
- package/src/components/ui/icon-picker.tsx +193 -0
- package/src/components/ui/image-cropper.tsx +787 -0
- package/src/components/ui/image-upload-field.tsx +344 -0
- package/src/components/ui/input-group.tsx +175 -0
- package/src/components/ui/input-otp.tsx +83 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/item.tsx +212 -0
- package/src/components/ui/kbd.tsx +62 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/list.tsx +46 -0
- package/src/components/ui/menu-variants.ts +71 -0
- package/src/components/ui/menubar.tsx +226 -0
- package/src/components/ui/money-input.tsx +96 -0
- package/src/components/ui/native-date-picker.tsx +120 -0
- package/src/components/ui/native-select.tsx +74 -0
- package/src/components/ui/navigation-menu.tsx +183 -0
- package/src/components/ui/pagination.tsx +150 -0
- package/src/components/ui/popover.tsx +104 -0
- package/src/components/ui/profile-header.tsx +196 -0
- package/src/components/ui/progress.tsx +71 -0
- package/src/components/ui/radio-group.tsx +115 -0
- package/src/components/ui/rating.tsx +200 -0
- package/src/components/ui/resizable.tsx +61 -0
- package/src/components/ui/responsive-dialog.tsx +278 -0
- package/src/components/ui/responsive-select.tsx +289 -0
- package/src/components/ui/responsive-sheet.tsx +225 -0
- package/src/components/ui/scroll-area.tsx +51 -0
- package/src/components/ui/section.tsx +106 -0
- package/src/components/ui/segmented-control.tsx +148 -0
- package/src/components/ui/select.tsx +216 -0
- package/src/components/ui/separator.tsx +69 -0
- package/src/components/ui/sheet.tsx +154 -0
- package/src/components/ui/shortcut.tsx +41 -0
- package/src/components/ui/sidebar.tsx +739 -0
- package/src/components/ui/skeleton.tsx +23 -0
- package/src/components/ui/slider.tsx +74 -0
- package/src/components/ui/sonner.tsx +64 -0
- package/src/components/ui/spinner.tsx +19 -0
- package/src/components/ui/stat-card.tsx +138 -0
- package/src/components/ui/stepper.tsx +181 -0
- package/src/components/ui/sticky-action-bar.tsx +42 -0
- package/src/components/ui/switch.tsx +30 -0
- package/src/components/ui/table.tsx +132 -0
- package/src/components/ui/tabs.tsx +77 -0
- package/src/components/ui/textarea.tsx +24 -0
- package/src/components/ui/time-picker.tsx +221 -0
- package/src/components/ui/toggle-group.tsx +86 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +79 -0
- package/src/components/ui/translation-input.tsx +126 -0
- package/src/components/ui/user-avatar.tsx +74 -0
- package/src/css-modules.d.ts +4 -0
- package/src/hooks/use-command-palette.ts +28 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/hooks/use-pagination.ts +81 -0
- package/src/hooks/use-picker-state.ts +44 -0
- package/src/lib/cva.ts +18 -0
- package/src/lib/time-math.ts +62 -0
- package/src/lib/utils.ts +14 -0
- package/src/styles/globals.css +36 -0
- package/src/styles.css +10 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { TrashIcon, UploadSimpleIcon } from "@phosphor-icons/react";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { Button } from "#/components/ui/button.tsx";
|
|
4
|
+
import {
|
|
5
|
+
ImageCropper,
|
|
6
|
+
ImageCropperArea,
|
|
7
|
+
ImageCropperDropzone,
|
|
8
|
+
useImageCropper,
|
|
9
|
+
} from "#/components/ui/image-cropper.tsx";
|
|
10
|
+
import { Progress } from "#/components/ui/progress.tsx";
|
|
11
|
+
import { cn } from "#/lib/utils.ts";
|
|
12
|
+
|
|
13
|
+
type ImageUploadShape = "circle" | "rectangle";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Confirm / cancel controls for an active crop. Rendered inside
|
|
17
|
+
* <ImageCropper> so it can reach `cropToBlob`; shown only once a file is
|
|
18
|
+
* picked. On confirm it produces a Blob and hands it to the parent — this
|
|
19
|
+
* component never performs the network upload itself.
|
|
20
|
+
*/
|
|
21
|
+
function CropControls({
|
|
22
|
+
hasPicked,
|
|
23
|
+
isUploading,
|
|
24
|
+
cancelLabel,
|
|
25
|
+
confirmLabel,
|
|
26
|
+
outputSize,
|
|
27
|
+
onCropped,
|
|
28
|
+
}: {
|
|
29
|
+
hasPicked: boolean;
|
|
30
|
+
isUploading: boolean;
|
|
31
|
+
cancelLabel: string;
|
|
32
|
+
confirmLabel: string;
|
|
33
|
+
outputSize?: { readonly width: number; readonly height: number };
|
|
34
|
+
onCropped: (blob: Blob) => void;
|
|
35
|
+
}) {
|
|
36
|
+
const { cropToBlob, removeImage } = useImageCropper();
|
|
37
|
+
if (!hasPicked) return null;
|
|
38
|
+
return (
|
|
39
|
+
<div data-slot="image-upload-field-crop-controls" className="flex gap-2">
|
|
40
|
+
<Button
|
|
41
|
+
type="button"
|
|
42
|
+
variant="ghost"
|
|
43
|
+
size="sm"
|
|
44
|
+
className="flex-1"
|
|
45
|
+
disabled={isUploading}
|
|
46
|
+
onClick={removeImage}
|
|
47
|
+
>
|
|
48
|
+
{cancelLabel}
|
|
49
|
+
</Button>
|
|
50
|
+
<Button
|
|
51
|
+
type="button"
|
|
52
|
+
size="sm"
|
|
53
|
+
className="flex-1"
|
|
54
|
+
loading={isUploading}
|
|
55
|
+
onClick={async () => {
|
|
56
|
+
onCropped(await cropToBlob({ ...outputSize, type: "image/png" }));
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
{confirmLabel}
|
|
60
|
+
</Button>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The "view" state: the current image with Replace / Remove, or a Progress bar
|
|
67
|
+
* while the parent's upload is in flight.
|
|
68
|
+
*/
|
|
69
|
+
function ImageUploadFieldView({
|
|
70
|
+
value,
|
|
71
|
+
shape,
|
|
72
|
+
aspectRatio,
|
|
73
|
+
isUploading,
|
|
74
|
+
replaceLabel,
|
|
75
|
+
removeLabel,
|
|
76
|
+
onReplace,
|
|
77
|
+
onRemove,
|
|
78
|
+
className,
|
|
79
|
+
...props
|
|
80
|
+
}: React.ComponentProps<"div"> & {
|
|
81
|
+
value: string;
|
|
82
|
+
shape: ImageUploadShape;
|
|
83
|
+
aspectRatio: number;
|
|
84
|
+
isUploading: boolean;
|
|
85
|
+
replaceLabel: React.ReactNode;
|
|
86
|
+
removeLabel: React.ReactNode;
|
|
87
|
+
onReplace: () => void;
|
|
88
|
+
onRemove?: () => void;
|
|
89
|
+
}) {
|
|
90
|
+
const isCircle = shape === "circle";
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
data-slot="image-upload-field"
|
|
94
|
+
data-shape={shape}
|
|
95
|
+
data-state="view"
|
|
96
|
+
className={cn("flex flex-col items-center gap-3", className)}
|
|
97
|
+
{...props}
|
|
98
|
+
>
|
|
99
|
+
<img
|
|
100
|
+
src={value}
|
|
101
|
+
alt=""
|
|
102
|
+
data-slot="image-upload-field-preview"
|
|
103
|
+
style={{ aspectRatio: String(aspectRatio) }}
|
|
104
|
+
className={cn(
|
|
105
|
+
"w-full max-w-xs border border-border object-cover",
|
|
106
|
+
isCircle ? "mx-auto size-32 rounded-full" : "rounded-lg",
|
|
107
|
+
)}
|
|
108
|
+
/>
|
|
109
|
+
{isUploading ? (
|
|
110
|
+
<Progress
|
|
111
|
+
value={null}
|
|
112
|
+
className="w-full max-w-xs"
|
|
113
|
+
data-slot="image-upload-field-progress"
|
|
114
|
+
/>
|
|
115
|
+
) : (
|
|
116
|
+
<div className="flex items-center gap-2">
|
|
117
|
+
<Button type="button" variant="outline" size="sm" onClick={onReplace}>
|
|
118
|
+
<UploadSimpleIcon />
|
|
119
|
+
{replaceLabel}
|
|
120
|
+
</Button>
|
|
121
|
+
{onRemove ? (
|
|
122
|
+
<Button type="button" variant="ghost" size="sm" onClick={onRemove}>
|
|
123
|
+
<TrashIcon />
|
|
124
|
+
{removeLabel}
|
|
125
|
+
</Button>
|
|
126
|
+
) : null}
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type ImageUploadCropperProps = {
|
|
134
|
+
shape: ImageUploadShape;
|
|
135
|
+
aspectRatio: number;
|
|
136
|
+
isUploading: boolean;
|
|
137
|
+
label: React.ReactNode;
|
|
138
|
+
description: React.ReactNode;
|
|
139
|
+
cancelLabel: string;
|
|
140
|
+
confirmLabel: string;
|
|
141
|
+
outputSize?: { readonly width: number; readonly height: number };
|
|
142
|
+
hasPicked: boolean;
|
|
143
|
+
onPickedChange: (hasPicked: boolean) => void;
|
|
144
|
+
onFileCropped: (blob: Blob) => void;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** The dropzone → crop → confirm flow itself, minus the surrounding chrome. */
|
|
148
|
+
function ImageUploadCropper({
|
|
149
|
+
shape,
|
|
150
|
+
aspectRatio,
|
|
151
|
+
isUploading,
|
|
152
|
+
label,
|
|
153
|
+
description,
|
|
154
|
+
cancelLabel,
|
|
155
|
+
confirmLabel,
|
|
156
|
+
outputSize,
|
|
157
|
+
hasPicked,
|
|
158
|
+
onPickedChange,
|
|
159
|
+
onFileCropped,
|
|
160
|
+
}: ImageUploadCropperProps) {
|
|
161
|
+
const isCircle = shape === "circle";
|
|
162
|
+
// The dropzone renders its own chrome around plain text, so a rich node has
|
|
163
|
+
// no slot to go in and the dropzone falls back to its built-in copy.
|
|
164
|
+
const dropzoneLabel = typeof label === "string" ? label : undefined;
|
|
165
|
+
const dropzoneDescription =
|
|
166
|
+
typeof description === "string" ? description : undefined;
|
|
167
|
+
return (
|
|
168
|
+
<ImageCropper
|
|
169
|
+
aspectRatio={aspectRatio}
|
|
170
|
+
disabled={isUploading}
|
|
171
|
+
onImageChange={(file) => onPickedChange(file !== null)}
|
|
172
|
+
>
|
|
173
|
+
<ImageCropperDropzone
|
|
174
|
+
label={dropzoneLabel}
|
|
175
|
+
description={dropzoneDescription}
|
|
176
|
+
className={isCircle ? "mx-auto size-40 rounded-full" : undefined}
|
|
177
|
+
/>
|
|
178
|
+
<ImageCropperArea shape={isCircle ? "circle" : "rectangle"} />
|
|
179
|
+
{isUploading ? (
|
|
180
|
+
<Progress value={null} data-slot="image-upload-field-progress" />
|
|
181
|
+
) : null}
|
|
182
|
+
<CropControls
|
|
183
|
+
hasPicked={hasPicked}
|
|
184
|
+
isUploading={isUploading}
|
|
185
|
+
cancelLabel={cancelLabel}
|
|
186
|
+
confirmLabel={confirmLabel}
|
|
187
|
+
outputSize={outputSize}
|
|
188
|
+
onCropped={onFileCropped}
|
|
189
|
+
/>
|
|
190
|
+
</ImageCropper>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The "edit" state: the crop flow, plus the way back to an existing image. */
|
|
195
|
+
function ImageUploadFieldEditor({
|
|
196
|
+
cropper,
|
|
197
|
+
canCancel,
|
|
198
|
+
onCancel,
|
|
199
|
+
className,
|
|
200
|
+
...props
|
|
201
|
+
}: React.ComponentProps<"div"> & {
|
|
202
|
+
cropper: ImageUploadCropperProps;
|
|
203
|
+
/** Show the escape hatch back to the existing image (only when one exists). */
|
|
204
|
+
canCancel: boolean;
|
|
205
|
+
onCancel: () => void;
|
|
206
|
+
}) {
|
|
207
|
+
return (
|
|
208
|
+
<div
|
|
209
|
+
data-slot="image-upload-field"
|
|
210
|
+
data-shape={cropper.shape}
|
|
211
|
+
data-state="edit"
|
|
212
|
+
className={cn("flex w-full flex-col gap-3", className)}
|
|
213
|
+
{...props}
|
|
214
|
+
>
|
|
215
|
+
<ImageUploadCropper {...cropper} />
|
|
216
|
+
{canCancel ? (
|
|
217
|
+
<Button
|
|
218
|
+
type="button"
|
|
219
|
+
variant="ghost"
|
|
220
|
+
size="sm"
|
|
221
|
+
disabled={cropper.isUploading}
|
|
222
|
+
onClick={onCancel}
|
|
223
|
+
>
|
|
224
|
+
{cropper.cancelLabel}
|
|
225
|
+
</Button>
|
|
226
|
+
) : null}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Controlled presentational bridge over <ImageCropper> for avatar / cover
|
|
233
|
+
* uploads. It is deliberately network-free: it surfaces the cropped `Blob`
|
|
234
|
+
* through `onFileCropped` and the parent owns the actual upload, passing back
|
|
235
|
+
* the resulting URL as `value` and toggling `isUploading`.
|
|
236
|
+
*
|
|
237
|
+
* Two states:
|
|
238
|
+
* - "view": there is a `value` and no edit in progress — shows the current
|
|
239
|
+
* image with Replace / Remove.
|
|
240
|
+
* - "edit": the dropzone → crop → confirm flow.
|
|
241
|
+
*/
|
|
242
|
+
function ImageUploadField({
|
|
243
|
+
value,
|
|
244
|
+
aspectRatio = 1,
|
|
245
|
+
shape = "circle",
|
|
246
|
+
isUploading = false,
|
|
247
|
+
outputSize,
|
|
248
|
+
onFileCropped,
|
|
249
|
+
onRemove,
|
|
250
|
+
label = "Upload an image",
|
|
251
|
+
description = "Click to browse or drag and drop",
|
|
252
|
+
replaceLabel = "Replace",
|
|
253
|
+
removeLabel = "Remove",
|
|
254
|
+
cancelLabel = "Cancel",
|
|
255
|
+
confirmLabel = "Save",
|
|
256
|
+
className,
|
|
257
|
+
...props
|
|
258
|
+
}: Omit<React.ComponentProps<"div">, "onChange"> & {
|
|
259
|
+
/** Current image URL, or null/undefined when nothing is set yet. */
|
|
260
|
+
value?: string | null;
|
|
261
|
+
/** Crop aspect ratio: 1 for avatars, 3 for covers. */
|
|
262
|
+
aspectRatio?: number;
|
|
263
|
+
shape?: ImageUploadShape;
|
|
264
|
+
/** While `true`, the confirm button spins and a Progress bar shows. */
|
|
265
|
+
isUploading?: boolean;
|
|
266
|
+
/**
|
|
267
|
+
* Fixed output dimensions for the cropped image (e.g. a 512×512 avatar).
|
|
268
|
+
* Defaults to the cropped region's natural size.
|
|
269
|
+
*/
|
|
270
|
+
outputSize?: { readonly width: number; readonly height: number };
|
|
271
|
+
/** Fired with the cropped Blob once the user confirms — parent uploads it. */
|
|
272
|
+
onFileCropped: (blob: Blob) => void;
|
|
273
|
+
onRemove?: () => void;
|
|
274
|
+
label?: React.ReactNode;
|
|
275
|
+
description?: React.ReactNode;
|
|
276
|
+
replaceLabel?: React.ReactNode;
|
|
277
|
+
removeLabel?: React.ReactNode;
|
|
278
|
+
cancelLabel?: string;
|
|
279
|
+
confirmLabel?: string;
|
|
280
|
+
}) {
|
|
281
|
+
const hasValue = value !== null && value !== undefined && value !== "";
|
|
282
|
+
// Start in "view" when there's already an image; otherwise show the cropper.
|
|
283
|
+
const [mode, setMode] = React.useState<"view" | "edit">(
|
|
284
|
+
hasValue ? "view" : "edit",
|
|
285
|
+
);
|
|
286
|
+
const [hasPicked, setHasPicked] = React.useState(false);
|
|
287
|
+
|
|
288
|
+
// If the parent finishes an upload (value appears) while we're editing,
|
|
289
|
+
// return to the view state and clear the picked flag.
|
|
290
|
+
const previousValueRef = React.useRef(value);
|
|
291
|
+
React.useEffect(() => {
|
|
292
|
+
if (value !== previousValueRef.current) {
|
|
293
|
+
previousValueRef.current = value;
|
|
294
|
+
if (value) {
|
|
295
|
+
setMode("view");
|
|
296
|
+
setHasPicked(false);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}, [value]);
|
|
300
|
+
|
|
301
|
+
if (hasValue && mode === "view") {
|
|
302
|
+
return (
|
|
303
|
+
<ImageUploadFieldView
|
|
304
|
+
value={value}
|
|
305
|
+
shape={shape}
|
|
306
|
+
aspectRatio={aspectRatio}
|
|
307
|
+
isUploading={isUploading}
|
|
308
|
+
replaceLabel={replaceLabel}
|
|
309
|
+
removeLabel={removeLabel}
|
|
310
|
+
onReplace={() => {
|
|
311
|
+
setHasPicked(false);
|
|
312
|
+
setMode("edit");
|
|
313
|
+
}}
|
|
314
|
+
onRemove={onRemove}
|
|
315
|
+
className={className}
|
|
316
|
+
{...props}
|
|
317
|
+
/>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<ImageUploadFieldEditor
|
|
323
|
+
cropper={{
|
|
324
|
+
shape,
|
|
325
|
+
aspectRatio,
|
|
326
|
+
isUploading,
|
|
327
|
+
label,
|
|
328
|
+
description,
|
|
329
|
+
cancelLabel,
|
|
330
|
+
confirmLabel,
|
|
331
|
+
outputSize,
|
|
332
|
+
hasPicked,
|
|
333
|
+
onPickedChange: setHasPicked,
|
|
334
|
+
onFileCropped,
|
|
335
|
+
}}
|
|
336
|
+
canCancel={hasValue && !hasPicked}
|
|
337
|
+
onCancel={() => setMode("view")}
|
|
338
|
+
className={className}
|
|
339
|
+
{...props}
|
|
340
|
+
/>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export { ImageUploadField, type ImageUploadShape };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import { Button } from "#/components/ui/button.tsx";
|
|
3
|
+
import { Input } from "#/components/ui/input.tsx";
|
|
4
|
+
import { Textarea } from "#/components/ui/textarea.tsx";
|
|
5
|
+
import { cva, type VariantProps } from "#/lib/cva.ts";
|
|
6
|
+
import { cn } from "#/lib/utils.ts";
|
|
7
|
+
|
|
8
|
+
function InputGroup({ className, ...props }: React.ComponentProps<"fieldset">) {
|
|
9
|
+
return (
|
|
10
|
+
<fieldset
|
|
11
|
+
data-slot="input-group"
|
|
12
|
+
className={cn(
|
|
13
|
+
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-[color,background-color,border-color,box-shadow] outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
|
14
|
+
className,
|
|
15
|
+
)}
|
|
16
|
+
{...props}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const inputGroupAddonVariants = cva({
|
|
22
|
+
base: "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none [&>svg:not([class*='size-'])]:size-4",
|
|
23
|
+
variants: {
|
|
24
|
+
align: {
|
|
25
|
+
"inline-start": "order-first pl-2 has-[>button]:-ml-1 has-[>kbd]:-ml-0.5",
|
|
26
|
+
"inline-end": "order-last pr-2 has-[>button]:-mr-1 has-[>kbd]:-mr-0.5",
|
|
27
|
+
"block-start":
|
|
28
|
+
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
|
29
|
+
"block-end":
|
|
30
|
+
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
defaultVariants: {
|
|
34
|
+
align: "inline-start",
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `align` is mirrored as a `data-align` attribute because the `InputGroup`
|
|
40
|
+
* root styles off it (`has-[>[data-align=block-end]]:flex-col`, control
|
|
41
|
+
* padding, ...) - overriding `data-align` from the outside breaks that
|
|
42
|
+
* coupling. Clicking the addon's EMPTY SPACE forwards focus to the group's
|
|
43
|
+
* control (input or textarea); a consumer `onClick` runs first and can cancel
|
|
44
|
+
* the forwarding with `event.preventDefault()`.
|
|
45
|
+
*
|
|
46
|
+
* A click that lands on an interactive element inside the addon is left alone.
|
|
47
|
+
* That covers more than buttons: `TranslationInput` and `MoneyInput` both put a
|
|
48
|
+
* `<select>` in a trailing addon, and forwarding focus to the input on
|
|
49
|
+
* mousedown-then-click closed the native dropdown the moment it opened - the
|
|
50
|
+
* locale/currency simply could not be changed with the pointer.
|
|
51
|
+
*/
|
|
52
|
+
function InputGroupAddon({
|
|
53
|
+
className,
|
|
54
|
+
align = "inline-start",
|
|
55
|
+
onClick,
|
|
56
|
+
...props
|
|
57
|
+
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
role="group"
|
|
61
|
+
data-slot="input-group-addon"
|
|
62
|
+
data-align={align}
|
|
63
|
+
className={cn(inputGroupAddonVariants({ align }), className)}
|
|
64
|
+
onClick={(event) => {
|
|
65
|
+
onClick?.(event);
|
|
66
|
+
if (event.defaultPrevented) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (
|
|
70
|
+
(event.target as HTMLElement).closest(
|
|
71
|
+
"button, a, input, select, textarea, label",
|
|
72
|
+
)
|
|
73
|
+
) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
event.currentTarget.parentElement
|
|
77
|
+
?.querySelector<HTMLElement>("[data-slot=input-group-control]")
|
|
78
|
+
?.focus();
|
|
79
|
+
}}
|
|
80
|
+
{...props}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const inputGroupButtonVariants = cva({
|
|
86
|
+
base: "flex items-center gap-2 text-sm shadow-none",
|
|
87
|
+
variants: {
|
|
88
|
+
size: {
|
|
89
|
+
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
|
90
|
+
// `sm` intentionally adds nothing: it falls through to the underlying
|
|
91
|
+
// `Button` default size (h-8), filling the group's full height.
|
|
92
|
+
sm: "",
|
|
93
|
+
"icon-xs":
|
|
94
|
+
"size-6 rounded-[min(var(--radius-md),10px)] p-0 has-[>svg]:p-0",
|
|
95
|
+
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
defaultVariants: {
|
|
99
|
+
size: "xs",
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
function InputGroupButton({
|
|
104
|
+
className,
|
|
105
|
+
type = "button",
|
|
106
|
+
variant = "ghost",
|
|
107
|
+
size = "xs",
|
|
108
|
+
...props
|
|
109
|
+
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
|
110
|
+
VariantProps<typeof inputGroupButtonVariants> & {
|
|
111
|
+
type?: "button" | "submit" | "reset";
|
|
112
|
+
}) {
|
|
113
|
+
return (
|
|
114
|
+
<Button
|
|
115
|
+
type={type}
|
|
116
|
+
variant={variant}
|
|
117
|
+
className={cn(inputGroupButtonVariants({ size }), className)}
|
|
118
|
+
{...props}
|
|
119
|
+
/>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
|
124
|
+
return (
|
|
125
|
+
<span
|
|
126
|
+
data-slot="input-group-text"
|
|
127
|
+
className={cn(
|
|
128
|
+
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
|
129
|
+
className,
|
|
130
|
+
)}
|
|
131
|
+
{...props}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function InputGroupInput({
|
|
137
|
+
className,
|
|
138
|
+
...props
|
|
139
|
+
}: React.ComponentProps<typeof Input>) {
|
|
140
|
+
return (
|
|
141
|
+
<Input
|
|
142
|
+
data-slot="input-group-control"
|
|
143
|
+
className={cn(
|
|
144
|
+
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
|
145
|
+
className,
|
|
146
|
+
)}
|
|
147
|
+
{...props}
|
|
148
|
+
/>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function InputGroupTextarea({
|
|
153
|
+
className,
|
|
154
|
+
...props
|
|
155
|
+
}: React.ComponentProps<typeof Textarea>) {
|
|
156
|
+
return (
|
|
157
|
+
<Textarea
|
|
158
|
+
data-slot="input-group-control"
|
|
159
|
+
className={cn(
|
|
160
|
+
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
|
161
|
+
className,
|
|
162
|
+
)}
|
|
163
|
+
{...props}
|
|
164
|
+
/>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export {
|
|
169
|
+
InputGroup,
|
|
170
|
+
InputGroupAddon,
|
|
171
|
+
InputGroupButton,
|
|
172
|
+
InputGroupInput,
|
|
173
|
+
InputGroupText,
|
|
174
|
+
InputGroupTextarea,
|
|
175
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { MinusIcon } from "@phosphor-icons/react";
|
|
2
|
+
import { OTPInput, OTPInputContext } from "input-otp";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "#/lib/utils.ts";
|
|
5
|
+
|
|
6
|
+
function InputOTP({
|
|
7
|
+
className,
|
|
8
|
+
containerClassName,
|
|
9
|
+
...props
|
|
10
|
+
}: React.ComponentProps<typeof OTPInput> & {
|
|
11
|
+
containerClassName?: string;
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<OTPInput
|
|
15
|
+
data-slot="input-otp"
|
|
16
|
+
containerClassName={cn(
|
|
17
|
+
"flex items-center has-disabled:opacity-50 [&:has(:disabled)_[data-slot=input-otp-slot]]:cursor-not-allowed [&:has(:disabled)_[data-slot=input-otp-slot]]:bg-muted",
|
|
18
|
+
containerClassName,
|
|
19
|
+
)}
|
|
20
|
+
spellCheck={false}
|
|
21
|
+
className={cn("disabled:cursor-not-allowed", className)}
|
|
22
|
+
{...props}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
data-slot="input-otp-group"
|
|
31
|
+
className={cn(
|
|
32
|
+
"flex items-center rounded-lg has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40",
|
|
33
|
+
className,
|
|
34
|
+
)}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function InputOTPSlot({
|
|
41
|
+
index,
|
|
42
|
+
className,
|
|
43
|
+
...props
|
|
44
|
+
}: React.ComponentProps<"div"> & {
|
|
45
|
+
index: number;
|
|
46
|
+
}) {
|
|
47
|
+
const inputOTPContext = React.useContext(OTPInputContext);
|
|
48
|
+
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
data-slot="input-otp-slot"
|
|
53
|
+
data-active={isActive}
|
|
54
|
+
className={cn(
|
|
55
|
+
"relative flex size-8 items-center justify-center border-y border-r border-input text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-3 data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
|
|
56
|
+
className,
|
|
57
|
+
)}
|
|
58
|
+
{...props}
|
|
59
|
+
>
|
|
60
|
+
{char}
|
|
61
|
+
{hasFakeCaret && (
|
|
62
|
+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
63
|
+
<div className="h-4 w-px animate-caret-blink bg-foreground motion-reduce:animate-none" />
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
data-slot="input-otp-separator"
|
|
74
|
+
className="flex items-center [&_svg:not([class*='size-'])]:size-4"
|
|
75
|
+
role="separator"
|
|
76
|
+
{...props}
|
|
77
|
+
>
|
|
78
|
+
<MinusIcon />
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Input as InputPrimitive } from "@base-ui/react/input";
|
|
2
|
+
|
|
3
|
+
import { cn } from "#/lib/utils.ts";
|
|
4
|
+
|
|
5
|
+
function Input({ className, ...props }: InputPrimitive.Props) {
|
|
6
|
+
return (
|
|
7
|
+
<InputPrimitive
|
|
8
|
+
data-slot="input"
|
|
9
|
+
className={cn(
|
|
10
|
+
// `text-base` then `md:text-sm` is an iOS zoom guard: Safari auto-zooms
|
|
11
|
+
// a focused field whose font is < 16px, so we ship 16px on small screens
|
|
12
|
+
// and drop to 14px from `md` up.
|
|
13
|
+
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
14
|
+
className,
|
|
15
|
+
)}
|
|
16
|
+
{...props}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { Input };
|