@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.
Files changed (99) hide show
  1. package/package.json +78 -0
  2. package/src/components/ui/accordion.tsx +79 -0
  3. package/src/components/ui/alert-dialog.tsx +198 -0
  4. package/src/components/ui/alert.tsx +105 -0
  5. package/src/components/ui/aspect-ratio.tsx +30 -0
  6. package/src/components/ui/avatar.tsx +125 -0
  7. package/src/components/ui/badge-variants.ts +166 -0
  8. package/src/components/ui/badge.tsx +45 -0
  9. package/src/components/ui/banner.tsx +84 -0
  10. package/src/components/ui/breadcrumb.tsx +127 -0
  11. package/src/components/ui/button-group.tsx +99 -0
  12. package/src/components/ui/button-variants.ts +108 -0
  13. package/src/components/ui/button.tsx +54 -0
  14. package/src/components/ui/calendar.tsx +266 -0
  15. package/src/components/ui/card.tsx +155 -0
  16. package/src/components/ui/carousel.tsx +319 -0
  17. package/src/components/ui/chat.tsx +896 -0
  18. package/src/components/ui/checkbox-group.tsx +44 -0
  19. package/src/components/ui/checkbox.tsx +34 -0
  20. package/src/components/ui/chip.tsx +60 -0
  21. package/src/components/ui/collapsible.tsx +44 -0
  22. package/src/components/ui/color-picker.tsx +191 -0
  23. package/src/components/ui/combobox.tsx +317 -0
  24. package/src/components/ui/command.tsx +187 -0
  25. package/src/components/ui/confirm-dialog.tsx +125 -0
  26. package/src/components/ui/context-menu.tsx +254 -0
  27. package/src/components/ui/copyable-text.tsx +65 -0
  28. package/src/components/ui/date-picker.tsx +316 -0
  29. package/src/components/ui/date-time-picker.tsx +1071 -0
  30. package/src/components/ui/dialog.tsx +176 -0
  31. package/src/components/ui/direction.tsx +15 -0
  32. package/src/components/ui/drawer.tsx +178 -0
  33. package/src/components/ui/dropdown-menu.tsx +247 -0
  34. package/src/components/ui/empty.tsx +131 -0
  35. package/src/components/ui/field.tsx +257 -0
  36. package/src/components/ui/formatted-input.tsx +219 -0
  37. package/src/components/ui/gallery.tsx +161 -0
  38. package/src/components/ui/hover-card.tsx +77 -0
  39. package/src/components/ui/icon-picker.tsx +193 -0
  40. package/src/components/ui/image-cropper.tsx +787 -0
  41. package/src/components/ui/image-upload-field.tsx +344 -0
  42. package/src/components/ui/input-group.tsx +175 -0
  43. package/src/components/ui/input-otp.tsx +83 -0
  44. package/src/components/ui/input.tsx +21 -0
  45. package/src/components/ui/item.tsx +212 -0
  46. package/src/components/ui/kbd.tsx +62 -0
  47. package/src/components/ui/label.tsx +19 -0
  48. package/src/components/ui/list.tsx +46 -0
  49. package/src/components/ui/menu-variants.ts +71 -0
  50. package/src/components/ui/menubar.tsx +226 -0
  51. package/src/components/ui/money-input.tsx +96 -0
  52. package/src/components/ui/native-date-picker.tsx +120 -0
  53. package/src/components/ui/native-select.tsx +74 -0
  54. package/src/components/ui/navigation-menu.tsx +183 -0
  55. package/src/components/ui/pagination.tsx +150 -0
  56. package/src/components/ui/popover.tsx +104 -0
  57. package/src/components/ui/profile-header.tsx +196 -0
  58. package/src/components/ui/progress.tsx +71 -0
  59. package/src/components/ui/radio-group.tsx +115 -0
  60. package/src/components/ui/rating.tsx +200 -0
  61. package/src/components/ui/resizable.tsx +61 -0
  62. package/src/components/ui/responsive-dialog.tsx +278 -0
  63. package/src/components/ui/responsive-select.tsx +289 -0
  64. package/src/components/ui/responsive-sheet.tsx +225 -0
  65. package/src/components/ui/scroll-area.tsx +51 -0
  66. package/src/components/ui/section.tsx +106 -0
  67. package/src/components/ui/segmented-control.tsx +148 -0
  68. package/src/components/ui/select.tsx +216 -0
  69. package/src/components/ui/separator.tsx +69 -0
  70. package/src/components/ui/sheet.tsx +154 -0
  71. package/src/components/ui/shortcut.tsx +41 -0
  72. package/src/components/ui/sidebar.tsx +739 -0
  73. package/src/components/ui/skeleton.tsx +23 -0
  74. package/src/components/ui/slider.tsx +74 -0
  75. package/src/components/ui/sonner.tsx +64 -0
  76. package/src/components/ui/spinner.tsx +19 -0
  77. package/src/components/ui/stat-card.tsx +138 -0
  78. package/src/components/ui/stepper.tsx +181 -0
  79. package/src/components/ui/sticky-action-bar.tsx +42 -0
  80. package/src/components/ui/switch.tsx +30 -0
  81. package/src/components/ui/table.tsx +132 -0
  82. package/src/components/ui/tabs.tsx +77 -0
  83. package/src/components/ui/textarea.tsx +24 -0
  84. package/src/components/ui/time-picker.tsx +221 -0
  85. package/src/components/ui/toggle-group.tsx +86 -0
  86. package/src/components/ui/toggle.tsx +43 -0
  87. package/src/components/ui/tooltip.tsx +79 -0
  88. package/src/components/ui/translation-input.tsx +126 -0
  89. package/src/components/ui/user-avatar.tsx +74 -0
  90. package/src/css-modules.d.ts +4 -0
  91. package/src/hooks/use-command-palette.ts +28 -0
  92. package/src/hooks/use-mobile.ts +21 -0
  93. package/src/hooks/use-pagination.ts +81 -0
  94. package/src/hooks/use-picker-state.ts +44 -0
  95. package/src/lib/cva.ts +18 -0
  96. package/src/lib/time-math.ts +62 -0
  97. package/src/lib/utils.ts +14 -0
  98. package/src/styles/globals.css +36 -0
  99. 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 };