@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,131 @@
1
+ import { cva, type VariantProps } from "#/lib/cva.ts";
2
+
3
+ import { cn } from "#/lib/utils.ts";
4
+
5
+ type EmptyProps = React.ComponentProps<"div"> & {
6
+ bordered?: boolean;
7
+ };
8
+
9
+ function Empty({ className, bordered = false, ...props }: EmptyProps) {
10
+ return (
11
+ <div
12
+ data-slot="empty"
13
+ data-bordered={bordered || undefined}
14
+ className={cn(
15
+ "flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl p-6 text-center text-balance",
16
+ bordered && "border border-dashed",
17
+ className,
18
+ )}
19
+ {...props}
20
+ />
21
+ );
22
+ }
23
+
24
+ function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
25
+ return (
26
+ <div
27
+ data-slot="empty-header"
28
+ className={cn("flex max-w-sm flex-col items-center gap-2", className)}
29
+ {...props}
30
+ />
31
+ );
32
+ }
33
+
34
+ const emptyMediaVariants = cva({
35
+ base: "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
36
+ variants: {
37
+ variant: {
38
+ default: "bg-transparent",
39
+ icon: "rounded-lg bg-muted text-foreground",
40
+ },
41
+ size: {
42
+ sm: "",
43
+ default: "",
44
+ lg: "",
45
+ },
46
+ },
47
+ compoundVariants: [
48
+ {
49
+ variant: "icon",
50
+ size: "sm",
51
+ className: "size-6 [&_svg:not([class*='size-'])]:size-3.5",
52
+ },
53
+ {
54
+ variant: "icon",
55
+ size: "default",
56
+ className: "size-8 [&_svg:not([class*='size-'])]:size-4",
57
+ },
58
+ {
59
+ variant: "icon",
60
+ size: "lg",
61
+ className: "size-10 [&_svg:not([class*='size-'])]:size-6",
62
+ },
63
+ ],
64
+ defaultVariants: {
65
+ variant: "default",
66
+ size: "default",
67
+ },
68
+ });
69
+
70
+ function EmptyMedia({
71
+ className,
72
+ variant = "default",
73
+ size = "default",
74
+ ...props
75
+ }: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
76
+ return (
77
+ <div
78
+ data-slot="empty-media"
79
+ data-variant={variant}
80
+ data-size={size}
81
+ className={cn(emptyMediaVariants({ variant, size }), className)}
82
+ {...props}
83
+ />
84
+ );
85
+ }
86
+
87
+ function EmptyTitle({ className, ...props }: React.ComponentProps<"h3">) {
88
+ return (
89
+ <h3
90
+ data-slot="empty-title"
91
+ className={cn("text-sm font-medium tracking-tight", className)}
92
+ {...props}
93
+ />
94
+ );
95
+ }
96
+
97
+ function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
98
+ return (
99
+ <p
100
+ data-slot="empty-description"
101
+ className={cn(
102
+ "text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
103
+ className,
104
+ )}
105
+ {...props}
106
+ />
107
+ );
108
+ }
109
+
110
+ function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
111
+ return (
112
+ <div
113
+ data-slot="empty-content"
114
+ className={cn(
115
+ "flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
116
+ className,
117
+ )}
118
+ {...props}
119
+ />
120
+ );
121
+ }
122
+
123
+ export {
124
+ Empty,
125
+ EmptyContent,
126
+ EmptyDescription,
127
+ EmptyHeader,
128
+ EmptyMedia,
129
+ type EmptyProps,
130
+ EmptyTitle,
131
+ };
@@ -0,0 +1,257 @@
1
+ import { useMemo } from "react";
2
+ import { Label } from "#/components/ui/label.tsx";
3
+ import { Separator } from "#/components/ui/separator.tsx";
4
+ import { cva, type VariantProps } from "#/lib/cva.ts";
5
+ import { cn } from "#/lib/utils.ts";
6
+
7
+ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
8
+ return (
9
+ <fieldset
10
+ data-slot="field-set"
11
+ className={cn(
12
+ "flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
13
+ className,
14
+ )}
15
+ {...props}
16
+ />
17
+ );
18
+ }
19
+
20
+ const fieldLegendVariants = cva({
21
+ base: "mb-1.5 font-medium",
22
+ variants: {
23
+ variant: {
24
+ legend: "text-base",
25
+ label: "text-sm",
26
+ },
27
+ },
28
+ defaultVariants: {
29
+ variant: "legend",
30
+ },
31
+ });
32
+
33
+ function FieldLegend({
34
+ className,
35
+ variant = "legend",
36
+ ...props
37
+ }: React.ComponentProps<"legend"> & VariantProps<typeof fieldLegendVariants>) {
38
+ return (
39
+ <legend
40
+ data-slot="field-legend"
41
+ data-variant={variant}
42
+ className={cn(fieldLegendVariants({ variant }), className)}
43
+ {...props}
44
+ />
45
+ );
46
+ }
47
+
48
+ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
49
+ return (
50
+ <div
51
+ data-slot="field-group"
52
+ className={cn(
53
+ "group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
54
+ className,
55
+ )}
56
+ {...props}
57
+ />
58
+ );
59
+ }
60
+
61
+ const fieldVariants = cva({
62
+ base: "group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
63
+ variants: {
64
+ orientation: {
65
+ vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
66
+ // Direct checkbox/radio children get a 1px nudge (`mt-px`) so they
67
+ // optically align with the first line of an adjacent FieldContent.
68
+ horizontal:
69
+ "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
70
+ // Vertical below the @md container breakpoint, horizontal above it.
71
+ responsive:
72
+ "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
73
+ },
74
+ },
75
+ defaultVariants: {
76
+ orientation: "vertical",
77
+ },
78
+ });
79
+
80
+ function Field({
81
+ className,
82
+ orientation = "vertical",
83
+ invalid,
84
+ ...props
85
+ }: React.ComponentProps<"div"> &
86
+ VariantProps<typeof fieldVariants> & { invalid?: boolean }) {
87
+ return (
88
+ <div
89
+ role="group"
90
+ data-slot="field"
91
+ data-orientation={orientation}
92
+ data-invalid={invalid || undefined}
93
+ className={cn(fieldVariants({ orientation }), className)}
94
+ {...props}
95
+ />
96
+ );
97
+ }
98
+
99
+ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
100
+ return (
101
+ <div
102
+ data-slot="field-content"
103
+ className={cn(
104
+ "group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
105
+ className,
106
+ )}
107
+ {...props}
108
+ />
109
+ );
110
+ }
111
+
112
+ function FieldLabel({
113
+ className,
114
+ ...props
115
+ }: React.ComponentProps<typeof Label>) {
116
+ return (
117
+ <Label
118
+ data-slot="field-label"
119
+ className={cn(
120
+ "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
121
+ "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
122
+ className,
123
+ )}
124
+ {...props}
125
+ />
126
+ );
127
+ }
128
+
129
+ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
130
+ return (
131
+ <div
132
+ data-slot="field-title"
133
+ className={cn(
134
+ "flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
135
+ className,
136
+ )}
137
+ {...props}
138
+ />
139
+ );
140
+ }
141
+
142
+ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
143
+ return (
144
+ <p
145
+ data-slot="field-description"
146
+ className={cn(
147
+ // `[[data-variant=legend]+&]` tightens the gap when the description
148
+ // directly follows a legend-sized FieldLegend.
149
+ "text-left text-sm leading-normal font-normal text-muted-foreground group-data-[orientation=horizontal]/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
150
+ // `nth-last-2` pulls the description toward the control when it sits
151
+ // just above it (i.e. it is the second-to-last child of the Field).
152
+ "last:mt-0 nth-last-2:-mt-1",
153
+ "[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
154
+ className,
155
+ )}
156
+ {...props}
157
+ />
158
+ );
159
+ }
160
+
161
+ function FieldSeparator({
162
+ children,
163
+ className,
164
+ ...props
165
+ }: React.ComponentProps<"div"> & {
166
+ children?: React.ReactNode;
167
+ }) {
168
+ return (
169
+ <div
170
+ data-slot="field-separator"
171
+ data-content={!!children}
172
+ className={cn(
173
+ "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
174
+ className,
175
+ )}
176
+ {...props}
177
+ >
178
+ <Separator className="absolute inset-0 top-1/2" />
179
+ {children && (
180
+ <span
181
+ className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
182
+ data-slot="field-separator-content"
183
+ >
184
+ {children}
185
+ </span>
186
+ )}
187
+ </div>
188
+ );
189
+ }
190
+
191
+ function FieldError({
192
+ className,
193
+ children,
194
+ errors,
195
+ ...props
196
+ }: React.ComponentProps<"div"> & {
197
+ errors?: Array<{ message?: string } | undefined>;
198
+ }) {
199
+ const content = useMemo(() => {
200
+ if (children) {
201
+ return children;
202
+ }
203
+
204
+ if (!errors?.length) {
205
+ return null;
206
+ }
207
+
208
+ const uniqueErrors = [
209
+ ...new Map(errors.map((error) => [error?.message, error])).values(),
210
+ ];
211
+
212
+ if (uniqueErrors?.length === 1) {
213
+ return uniqueErrors[0]?.message;
214
+ }
215
+
216
+ return (
217
+ <ul className="ml-4 flex list-disc flex-col gap-1">
218
+ {uniqueErrors.map(
219
+ (error) =>
220
+ error?.message && <li key={error.message}>{error.message}</li>,
221
+ )}
222
+ </ul>
223
+ );
224
+ }, [children, errors]);
225
+
226
+ if (!content) {
227
+ return null;
228
+ }
229
+
230
+ return (
231
+ <div
232
+ role="alert"
233
+ data-slot="field-error"
234
+ className={cn(
235
+ "text-sm font-normal text-destructive",
236
+ "animate-in fade-in slide-in-from-top-1 motion-reduce:animate-none",
237
+ className,
238
+ )}
239
+ {...props}
240
+ >
241
+ {content}
242
+ </div>
243
+ );
244
+ }
245
+
246
+ export {
247
+ Field,
248
+ FieldContent,
249
+ FieldDescription,
250
+ FieldError,
251
+ FieldGroup,
252
+ FieldLabel,
253
+ FieldLegend,
254
+ FieldSeparator,
255
+ FieldSet,
256
+ FieldTitle,
257
+ };
@@ -0,0 +1,219 @@
1
+ import * as React from "react";
2
+
3
+ import { Input } from "#/components/ui/input.tsx";
4
+
5
+ /**
6
+ * Digit masks for the French identifiers the apps collect. `#` is a digit
7
+ * slot; any other character is a literal separator inserted while typing.
8
+ */
9
+ const rppsMask = "###########";
10
+ const siretMask = "### ### ### #####";
11
+ const frenchPhoneMask = "## ## ## ## ##";
12
+
13
+ function countDigitSlots(mask: string): number {
14
+ let count = 0;
15
+ for (const maskCharacter of mask) {
16
+ if (maskCharacter === "#") count += 1;
17
+ }
18
+ return count;
19
+ }
20
+
21
+ function extractDigits(text: string, maximumDigits: number): string {
22
+ return text.replace(/\D/g, "").slice(0, maximumDigits);
23
+ }
24
+
25
+ function applyMask(digits: string, mask: string): string {
26
+ let formatted = "";
27
+ let digitIndex = 0;
28
+ for (const maskCharacter of mask) {
29
+ if (digitIndex >= digits.length) break;
30
+ if (maskCharacter === "#") {
31
+ formatted += digits[digitIndex];
32
+ digitIndex += 1;
33
+ } else {
34
+ formatted += maskCharacter;
35
+ }
36
+ }
37
+ return formatted;
38
+ }
39
+
40
+ function caretPositionAfterDigits(
41
+ formatted: string,
42
+ digitCount: number,
43
+ ): number {
44
+ if (digitCount <= 0) return 0;
45
+ let digitsSeen = 0;
46
+ for (let index = 0; index < formatted.length; index += 1) {
47
+ if (/\d/.test(formatted.charAt(index))) {
48
+ digitsSeen += 1;
49
+ if (digitsSeen === digitCount) return index + 1;
50
+ }
51
+ }
52
+ return formatted.length;
53
+ }
54
+
55
+ type InputChangeEvent = Parameters<
56
+ NonNullable<React.ComponentProps<typeof Input>["onChange"]>
57
+ >[0];
58
+
59
+ /**
60
+ * Deleting a literal separator leaves the digits untouched, which would
61
+ * reformat to the exact same string and trap the caret. Forward the deletion
62
+ * to the adjacent digit instead.
63
+ */
64
+ function forwardSeparatorDeletion(input: {
65
+ digits: string;
66
+ digitsBeforeCaret: number;
67
+ inputType: string | undefined;
68
+ }): { digits: string; digitsBeforeCaret: number } {
69
+ if (
70
+ input.inputType === "deleteContentBackward" &&
71
+ input.digitsBeforeCaret > 0
72
+ ) {
73
+ return {
74
+ digits:
75
+ input.digits.slice(0, input.digitsBeforeCaret - 1) +
76
+ input.digits.slice(input.digitsBeforeCaret),
77
+ digitsBeforeCaret: input.digitsBeforeCaret - 1,
78
+ };
79
+ }
80
+ if (input.inputType === "deleteContentForward") {
81
+ return {
82
+ digits:
83
+ input.digits.slice(0, input.digitsBeforeCaret) +
84
+ input.digits.slice(input.digitsBeforeCaret + 1),
85
+ digitsBeforeCaret: input.digitsBeforeCaret,
86
+ };
87
+ }
88
+ return { digits: input.digits, digitsBeforeCaret: input.digitsBeforeCaret };
89
+ }
90
+
91
+ function computeMaskedChange(input: {
92
+ rawText: string;
93
+ selectionStart: number | null;
94
+ inputType: string | undefined;
95
+ currentDigits: string;
96
+ formattedValue: string;
97
+ mask: string;
98
+ maximumDigits: number;
99
+ }): { nextDigits: string; caretPosition: number } {
100
+ const selectionStart = input.selectionStart ?? input.rawText.length;
101
+ let nextDigits = extractDigits(input.rawText, input.maximumDigits);
102
+ let digitsBeforeCaret = extractDigits(
103
+ input.rawText.slice(0, selectionStart),
104
+ input.maximumDigits,
105
+ ).length;
106
+
107
+ if (
108
+ nextDigits === input.currentDigits &&
109
+ input.rawText.length < input.formattedValue.length
110
+ ) {
111
+ const forwarded = forwardSeparatorDeletion({
112
+ digits: nextDigits,
113
+ digitsBeforeCaret,
114
+ inputType: input.inputType,
115
+ });
116
+ nextDigits = forwarded.digits;
117
+ digitsBeforeCaret = forwarded.digitsBeforeCaret;
118
+ }
119
+
120
+ const caretPosition = caretPositionAfterDigits(
121
+ applyMask(nextDigits, input.mask),
122
+ Math.min(digitsBeforeCaret, nextDigits.length),
123
+ );
124
+ return { nextDigits, caretPosition };
125
+ }
126
+
127
+ type FormattedInputProps = Omit<
128
+ React.ComponentProps<typeof Input>,
129
+ "value" | "defaultValue" | "onValueChange" | "type"
130
+ > & {
131
+ /** Digit mask, e.g. `"### ### ### #####"` for SIRET. `#` marks a digit slot. */
132
+ mask: string;
133
+ /** Raw digits (unformatted). */
134
+ value?: string;
135
+ /** Raw digits (unformatted). */
136
+ defaultValue?: string;
137
+ /** Receives the raw digits whenever they change. */
138
+ onValueChange?: (value: string) => void;
139
+ };
140
+
141
+ function FormattedInput({
142
+ mask,
143
+ value,
144
+ defaultValue,
145
+ onValueChange,
146
+ onChange,
147
+ ref,
148
+ ...props
149
+ }: FormattedInputProps) {
150
+ const maximumDigits = countDigitSlots(mask);
151
+ const [internalDigits, setInternalDigits] = React.useState(() =>
152
+ extractDigits(defaultValue ?? "", maximumDigits),
153
+ );
154
+ const digits =
155
+ value !== undefined ? extractDigits(value, maximumDigits) : internalDigits;
156
+ const formattedValue = applyMask(digits, mask);
157
+
158
+ const inputRef = React.useRef<HTMLInputElement | null>(null);
159
+ const pendingCaret = React.useRef<number | null>(null);
160
+
161
+ // The caret can only be repositioned once React has re-rendered the input
162
+ // with the reformatted value, hence the layout effect rather than doing it
163
+ // inside the change handler.
164
+ React.useLayoutEffect(() => {
165
+ if (pendingCaret.current === null) return;
166
+ inputRef.current?.setSelectionRange(
167
+ pendingCaret.current,
168
+ pendingCaret.current,
169
+ );
170
+ pendingCaret.current = null;
171
+ });
172
+
173
+ const commitDigits = (nextDigits: string) => {
174
+ if (value === undefined) setInternalDigits(nextDigits);
175
+ if (nextDigits !== digits) onValueChange?.(nextDigits);
176
+ };
177
+
178
+ const handleChange = (event: InputChangeEvent) => {
179
+ const { nextDigits, caretPosition } = computeMaskedChange({
180
+ rawText: event.target.value,
181
+ selectionStart: event.target.selectionStart,
182
+ inputType: (event.nativeEvent as InputEvent).inputType,
183
+ currentDigits: digits,
184
+ formattedValue,
185
+ mask,
186
+ maximumDigits,
187
+ });
188
+ pendingCaret.current = caretPosition;
189
+ commitDigits(nextDigits);
190
+ onChange?.(event);
191
+ };
192
+
193
+ return (
194
+ <Input
195
+ data-slot="formatted-input"
196
+ type="text"
197
+ inputMode="numeric"
198
+ autoComplete="off"
199
+ spellCheck={false}
200
+ {...props}
201
+ ref={(element: HTMLInputElement | null) => {
202
+ inputRef.current = element;
203
+ if (typeof ref === "function") return ref(element);
204
+ if (ref) ref.current = element;
205
+ }}
206
+ value={formattedValue}
207
+ onChange={handleChange}
208
+ />
209
+ );
210
+ }
211
+
212
+ export {
213
+ applyMask,
214
+ FormattedInput,
215
+ type FormattedInputProps,
216
+ frenchPhoneMask,
217
+ rppsMask,
218
+ siretMask,
219
+ };