@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,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
|
+
};
|