@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,61 @@
|
|
|
1
|
+
import * as ResizablePrimitive from "react-resizable-panels";
|
|
2
|
+
|
|
3
|
+
import { cn } from "#/lib/utils.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resizable panel layout (react-resizable-panels v4).
|
|
7
|
+
*
|
|
8
|
+
* Sizing contract: the Group sets inline `height: 100%; width: 100%`, so a
|
|
9
|
+
* height class on the group itself (`h-48`) is silently overridden — give it a
|
|
10
|
+
* sized parent instead, e.g. `<div className="h-48"><ResizablePanelGroup …>`.
|
|
11
|
+
*/
|
|
12
|
+
function ResizablePanelGroup({
|
|
13
|
+
className,
|
|
14
|
+
...props
|
|
15
|
+
}: ResizablePrimitive.GroupProps) {
|
|
16
|
+
// Layout (display/flex-direction/height/width) comes entirely from the
|
|
17
|
+
// library's inline styles, so no layout classes are needed here.
|
|
18
|
+
return (
|
|
19
|
+
<ResizablePrimitive.Group
|
|
20
|
+
data-slot="resizable-panel-group"
|
|
21
|
+
className={cn(className)}
|
|
22
|
+
{...props}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
|
28
|
+
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ResizableHandle({
|
|
32
|
+
withHandle,
|
|
33
|
+
className,
|
|
34
|
+
...props
|
|
35
|
+
}: ResizablePrimitive.SeparatorProps & {
|
|
36
|
+
withHandle?: boolean;
|
|
37
|
+
}) {
|
|
38
|
+
return (
|
|
39
|
+
<ResizablePrimitive.Separator
|
|
40
|
+
data-slot="resizable-handle"
|
|
41
|
+
// The library emits `data-separator=hover|active|focus|inactive` (its
|
|
42
|
+
// hover state honours the wider hit area), so we drive the resting,
|
|
43
|
+
// hover and drag affordance off those rather than CSS `:hover`. Note the
|
|
44
|
+
// orientation inversion: a horizontal group emits a vertical separator.
|
|
45
|
+
className={cn(
|
|
46
|
+
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2 after:transition-colors focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-hidden data-[separator=hover]:after:bg-ring/40 data-[separator=active]:after:bg-ring data-[separator=active]:[&>[data-slot=resizable-handle-grip]]:bg-ring motion-reduce:after:transition-none aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-2 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
|
47
|
+
className,
|
|
48
|
+
)}
|
|
49
|
+
{...props}
|
|
50
|
+
>
|
|
51
|
+
{withHandle && (
|
|
52
|
+
<div
|
|
53
|
+
data-slot="resizable-handle-grip"
|
|
54
|
+
className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-muted-foreground/40 transition-colors motion-reduce:transition-none"
|
|
55
|
+
/>
|
|
56
|
+
)}
|
|
57
|
+
</ResizablePrimitive.Separator>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Button } from "#/components/ui/button.tsx";
|
|
3
|
+
import {
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogClose,
|
|
6
|
+
DialogContent,
|
|
7
|
+
type DialogContentSize,
|
|
8
|
+
DialogDescription,
|
|
9
|
+
DialogFooter,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
DialogTrigger,
|
|
13
|
+
} from "#/components/ui/dialog.tsx";
|
|
14
|
+
import {
|
|
15
|
+
Drawer,
|
|
16
|
+
DrawerClose,
|
|
17
|
+
DrawerContent,
|
|
18
|
+
DrawerDescription,
|
|
19
|
+
DrawerFooter,
|
|
20
|
+
DrawerHeader,
|
|
21
|
+
DrawerTitle,
|
|
22
|
+
DrawerTrigger,
|
|
23
|
+
} from "#/components/ui/drawer.tsx";
|
|
24
|
+
import { useIsMobile } from "#/hooks/use-mobile.ts";
|
|
25
|
+
import { cn } from "#/lib/utils.ts";
|
|
26
|
+
|
|
27
|
+
// One API, two surfaces: a centered Base UI dialog on desktop and a vaul
|
|
28
|
+
// bottom drawer under the `useIsMobile` breakpoint (768px). The root owns the
|
|
29
|
+
// open state so it survives crossing the breakpoint while open — the two
|
|
30
|
+
// halves are different component trees, so uncontrolled state would reset.
|
|
31
|
+
//
|
|
32
|
+
// The halves disagree on composition (Base UI `render` vs Radix `asChild`);
|
|
33
|
+
// trigger/close bridge that by accepting the element form of `render` only.
|
|
34
|
+
|
|
35
|
+
const ResponsiveDialogContext = React.createContext<boolean | null>(null);
|
|
36
|
+
|
|
37
|
+
function useResponsiveDialogIsMobile(part: string): boolean {
|
|
38
|
+
const isMobile = React.use(ResponsiveDialogContext);
|
|
39
|
+
if (isMobile === null) {
|
|
40
|
+
throw new Error(`${part} must be used within <ResponsiveDialog>`);
|
|
41
|
+
}
|
|
42
|
+
return isMobile;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ResponsiveDialog({
|
|
46
|
+
open,
|
|
47
|
+
defaultOpen = false,
|
|
48
|
+
onOpenChange,
|
|
49
|
+
children,
|
|
50
|
+
}: {
|
|
51
|
+
open?: boolean;
|
|
52
|
+
defaultOpen?: boolean;
|
|
53
|
+
onOpenChange?: (open: boolean) => void;
|
|
54
|
+
children?: React.ReactNode;
|
|
55
|
+
}) {
|
|
56
|
+
const isMobile = useIsMobile();
|
|
57
|
+
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
|
|
58
|
+
const isOpen = open ?? uncontrolledOpen;
|
|
59
|
+
const handleOpenChange = (nextOpen: boolean) => {
|
|
60
|
+
setUncontrolledOpen(nextOpen);
|
|
61
|
+
onOpenChange?.(nextOpen);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<ResponsiveDialogContext.Provider value={isMobile}>
|
|
66
|
+
{isMobile ? (
|
|
67
|
+
<Drawer open={isOpen} onOpenChange={handleOpenChange}>
|
|
68
|
+
{children}
|
|
69
|
+
</Drawer>
|
|
70
|
+
) : (
|
|
71
|
+
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
|
72
|
+
{children}
|
|
73
|
+
</Dialog>
|
|
74
|
+
)}
|
|
75
|
+
</ResponsiveDialogContext.Provider>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ResponsiveDialogTrigger({
|
|
80
|
+
render,
|
|
81
|
+
children,
|
|
82
|
+
...props
|
|
83
|
+
}: Omit<React.ComponentProps<typeof DrawerTrigger>, "asChild"> & {
|
|
84
|
+
/** Element form only — the drawer half clones it, so render functions are unsupported. */
|
|
85
|
+
render?: React.ReactElement;
|
|
86
|
+
}) {
|
|
87
|
+
const isMobile = useResponsiveDialogIsMobile("ResponsiveDialogTrigger");
|
|
88
|
+
if (isMobile) {
|
|
89
|
+
if (render) {
|
|
90
|
+
return (
|
|
91
|
+
<DrawerTrigger asChild {...props}>
|
|
92
|
+
{children === undefined
|
|
93
|
+
? render
|
|
94
|
+
: React.cloneElement(render, undefined, children)}
|
|
95
|
+
</DrawerTrigger>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return <DrawerTrigger {...props}>{children}</DrawerTrigger>;
|
|
99
|
+
}
|
|
100
|
+
return (
|
|
101
|
+
<DialogTrigger render={render} {...props}>
|
|
102
|
+
{children}
|
|
103
|
+
</DialogTrigger>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function ResponsiveDialogClose({
|
|
108
|
+
render,
|
|
109
|
+
children,
|
|
110
|
+
...props
|
|
111
|
+
}: Omit<React.ComponentProps<typeof DrawerClose>, "asChild"> & {
|
|
112
|
+
/** Element form only — the drawer half clones it, so render functions are unsupported. */
|
|
113
|
+
render?: React.ReactElement;
|
|
114
|
+
}) {
|
|
115
|
+
const isMobile = useResponsiveDialogIsMobile("ResponsiveDialogClose");
|
|
116
|
+
if (isMobile) {
|
|
117
|
+
if (render) {
|
|
118
|
+
return (
|
|
119
|
+
<DrawerClose asChild {...props}>
|
|
120
|
+
{children === undefined
|
|
121
|
+
? render
|
|
122
|
+
: React.cloneElement(render, undefined, children)}
|
|
123
|
+
</DrawerClose>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return <DrawerClose {...props}>{children}</DrawerClose>;
|
|
127
|
+
}
|
|
128
|
+
return (
|
|
129
|
+
<DialogClose render={render} {...props}>
|
|
130
|
+
{children}
|
|
131
|
+
</DialogClose>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function ResponsiveDialogContent({
|
|
136
|
+
className,
|
|
137
|
+
children,
|
|
138
|
+
size = "sm",
|
|
139
|
+
showCloseButton = true,
|
|
140
|
+
closeButtonLabel = "Close",
|
|
141
|
+
overlayClassName,
|
|
142
|
+
autoFocus = true,
|
|
143
|
+
}: {
|
|
144
|
+
className?: string;
|
|
145
|
+
children?: React.ReactNode;
|
|
146
|
+
/** Desktop dialog width — the mobile drawer is always full-width. */
|
|
147
|
+
size?: DialogContentSize;
|
|
148
|
+
showCloseButton?: boolean;
|
|
149
|
+
closeButtonLabel?: string;
|
|
150
|
+
overlayClassName?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Whether opening moves focus into the content (the default). Pass `false`
|
|
153
|
+
* when the first field is a text input you don't want to focus on open —
|
|
154
|
+
* on a phone that focus opens the keyboard over the content you just asked to
|
|
155
|
+
* see. The content stays reachable by tab either way.
|
|
156
|
+
*/
|
|
157
|
+
autoFocus?: boolean;
|
|
158
|
+
}) {
|
|
159
|
+
const isMobile = useResponsiveDialogIsMobile("ResponsiveDialogContent");
|
|
160
|
+
if (isMobile) {
|
|
161
|
+
return (
|
|
162
|
+
<DrawerContent
|
|
163
|
+
className={className}
|
|
164
|
+
showCloseButton={showCloseButton}
|
|
165
|
+
closeButtonLabel={closeButtonLabel}
|
|
166
|
+
overlayClassName={overlayClassName}
|
|
167
|
+
onOpenAutoFocus={
|
|
168
|
+
autoFocus ? undefined : (event) => event.preventDefault()
|
|
169
|
+
}
|
|
170
|
+
>
|
|
171
|
+
{children}
|
|
172
|
+
</DrawerContent>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return (
|
|
176
|
+
<DialogContent
|
|
177
|
+
className={className}
|
|
178
|
+
size={size}
|
|
179
|
+
showCloseButton={showCloseButton}
|
|
180
|
+
closeButtonLabel={closeButtonLabel}
|
|
181
|
+
overlayClassName={overlayClassName}
|
|
182
|
+
initialFocus={autoFocus}
|
|
183
|
+
>
|
|
184
|
+
{children}
|
|
185
|
+
</DialogContent>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function ResponsiveDialogHeader(props: React.ComponentProps<"div">) {
|
|
190
|
+
const isMobile = useResponsiveDialogIsMobile("ResponsiveDialogHeader");
|
|
191
|
+
return isMobile ? <DrawerHeader {...props} /> : <DialogHeader {...props} />;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Free-form content between header and footer. The dialog half already pads
|
|
196
|
+
* its popup; the drawer half doesn't, so this adds the missing gutter there.
|
|
197
|
+
*/
|
|
198
|
+
function ResponsiveDialogBody({
|
|
199
|
+
className,
|
|
200
|
+
...props
|
|
201
|
+
}: React.ComponentProps<"div">) {
|
|
202
|
+
const isMobile = useResponsiveDialogIsMobile("ResponsiveDialogBody");
|
|
203
|
+
return (
|
|
204
|
+
<div
|
|
205
|
+
data-slot="responsive-dialog-body"
|
|
206
|
+
className={cn(isMobile && "overflow-y-auto px-4", className)}
|
|
207
|
+
{...props}
|
|
208
|
+
/>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function ResponsiveDialogFooter({
|
|
213
|
+
className,
|
|
214
|
+
closeLabel,
|
|
215
|
+
children,
|
|
216
|
+
...props
|
|
217
|
+
}: React.ComponentProps<"div"> & {
|
|
218
|
+
closeLabel?: string;
|
|
219
|
+
}) {
|
|
220
|
+
const isMobile = useResponsiveDialogIsMobile("ResponsiveDialogFooter");
|
|
221
|
+
if (isMobile) {
|
|
222
|
+
return (
|
|
223
|
+
<DrawerFooter className={className} {...props}>
|
|
224
|
+
{children}
|
|
225
|
+
{/* Last in the column = bottom, matching the dialog footer's
|
|
226
|
+
col-reverse order where the close action sits below the primary. */}
|
|
227
|
+
{closeLabel !== undefined && (
|
|
228
|
+
<DrawerClose asChild>
|
|
229
|
+
<Button data-slot="drawer-footer-close" variant="outline">
|
|
230
|
+
{closeLabel}
|
|
231
|
+
</Button>
|
|
232
|
+
</DrawerClose>
|
|
233
|
+
)}
|
|
234
|
+
</DrawerFooter>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
return (
|
|
238
|
+
<DialogFooter className={className} closeLabel={closeLabel} {...props}>
|
|
239
|
+
{children}
|
|
240
|
+
</DialogFooter>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function ResponsiveDialogTitle({
|
|
245
|
+
className,
|
|
246
|
+
...props
|
|
247
|
+
}: React.ComponentProps<typeof DrawerTitle>) {
|
|
248
|
+
const isMobile = useResponsiveDialogIsMobile("ResponsiveDialogTitle");
|
|
249
|
+
return isMobile ? (
|
|
250
|
+
<DrawerTitle className={className} {...props} />
|
|
251
|
+
) : (
|
|
252
|
+
<DialogTitle className={className} {...props} />
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function ResponsiveDialogDescription({
|
|
257
|
+
className,
|
|
258
|
+
...props
|
|
259
|
+
}: React.ComponentProps<typeof DrawerDescription>) {
|
|
260
|
+
const isMobile = useResponsiveDialogIsMobile("ResponsiveDialogDescription");
|
|
261
|
+
return isMobile ? (
|
|
262
|
+
<DrawerDescription className={className} {...props} />
|
|
263
|
+
) : (
|
|
264
|
+
<DialogDescription className={className} {...props} />
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export {
|
|
269
|
+
ResponsiveDialog,
|
|
270
|
+
ResponsiveDialogBody,
|
|
271
|
+
ResponsiveDialogClose,
|
|
272
|
+
ResponsiveDialogContent,
|
|
273
|
+
ResponsiveDialogDescription,
|
|
274
|
+
ResponsiveDialogFooter,
|
|
275
|
+
ResponsiveDialogHeader,
|
|
276
|
+
ResponsiveDialogTitle,
|
|
277
|
+
ResponsiveDialogTrigger,
|
|
278
|
+
};
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
NativeSelect,
|
|
4
|
+
NativeSelectOptGroup,
|
|
5
|
+
NativeSelectOption,
|
|
6
|
+
} from "#/components/ui/native-select.tsx";
|
|
7
|
+
import {
|
|
8
|
+
Select,
|
|
9
|
+
SelectContent,
|
|
10
|
+
SelectGroup,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectLabel,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
SelectValue,
|
|
15
|
+
} from "#/components/ui/select.tsx";
|
|
16
|
+
import { useIsMobile } from "#/hooks/use-mobile.ts";
|
|
17
|
+
|
|
18
|
+
// A composable select with two surfaces from one declaration: the Base UI
|
|
19
|
+
// `Select` popup on desktop and the OS-native `<select>` under the `useIsMobile`
|
|
20
|
+
// breakpoint (768px), where the native picker is the better touch experience.
|
|
21
|
+
//
|
|
22
|
+
// The parts (`Trigger`, `Value`, `Content`, `Group`, `Label`, `Item`) map 1:1
|
|
23
|
+
// to the underlying `Select` parts on desktop. A native `<select>` is monolithic
|
|
24
|
+
// — it can't be split into a trigger and a portalled popup — so on mobile the
|
|
25
|
+
// `Root` reads the part *elements* (their props, not their rendered output) and
|
|
26
|
+
// projects them onto a single `NativeSelect`. That keeps desktop usage identical
|
|
27
|
+
// to using `Select` directly while still yielding a real native control on phones.
|
|
28
|
+
//
|
|
29
|
+
// Because the projection inspects element types, the parts must be DIRECT
|
|
30
|
+
// children of their parent part (`Trigger` > `Value`, `Content` > `Item`/`Group`,
|
|
31
|
+
// `Group` > `Label`/`Item`). Fragments and `.map()` are fine; wrapper elements
|
|
32
|
+
// (e.g. a `<div>` around items) are not — they'd hide the items from the scan.
|
|
33
|
+
// Item/Label content must be plain text so a native `<option>` can render it.
|
|
34
|
+
|
|
35
|
+
type ItemProps = {
|
|
36
|
+
value: string;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
children?: React.ReactNode;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function getElementsOfType<Props>(
|
|
42
|
+
children: React.ReactNode,
|
|
43
|
+
type: React.ElementType,
|
|
44
|
+
): Array<React.ReactElement<Props>> {
|
|
45
|
+
const result: Array<React.ReactElement<Props>> = [];
|
|
46
|
+
for (const child of React.Children.toArray(children)) {
|
|
47
|
+
if (React.isValidElement(child) && child.type === type) {
|
|
48
|
+
result.push(child as React.ReactElement<Props>);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getElementOfType<Props>(
|
|
55
|
+
children: React.ReactNode,
|
|
56
|
+
type: React.ElementType,
|
|
57
|
+
): React.ReactElement<Props> | undefined {
|
|
58
|
+
return getElementsOfType<Props>(children, type)[0];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Flatten a text-only node to a string for native `<option>`/`<optgroup>` labels. */
|
|
62
|
+
function textOf(node: React.ReactNode): string {
|
|
63
|
+
if (typeof node === "string") return node;
|
|
64
|
+
if (typeof node === "number") return String(node);
|
|
65
|
+
if (Array.isArray(node)) return node.map(textOf).join("");
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type ResponsiveSelectRootProps = {
|
|
70
|
+
value?: string;
|
|
71
|
+
defaultValue?: string;
|
|
72
|
+
onValueChange?: (value: string) => void;
|
|
73
|
+
/** Submitted under this name (hidden input on desktop, the `<select>` on mobile). */
|
|
74
|
+
name?: string;
|
|
75
|
+
disabled?: boolean;
|
|
76
|
+
required?: boolean;
|
|
77
|
+
children?: React.ReactNode;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Desktop `value → label` map so the Base UI trigger renders the selected label. */
|
|
81
|
+
function buildItemsRecord(children: React.ReactNode): Record<string, string> {
|
|
82
|
+
const content = getElementOfType<{ children?: React.ReactNode }>(
|
|
83
|
+
children,
|
|
84
|
+
ResponsiveSelectContent,
|
|
85
|
+
);
|
|
86
|
+
const record: Record<string, string> = {};
|
|
87
|
+
const visit = (nodes: React.ReactNode) => {
|
|
88
|
+
for (const child of React.Children.toArray(nodes)) {
|
|
89
|
+
if (!React.isValidElement(child)) continue;
|
|
90
|
+
if (child.type === ResponsiveSelectItem) {
|
|
91
|
+
const props = child.props as ItemProps;
|
|
92
|
+
record[props.value] = textOf(props.children);
|
|
93
|
+
} else if (child.type === ResponsiveSelectGroup) {
|
|
94
|
+
visit((child.props as { children?: React.ReactNode }).children);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
if (content) visit(content.props.children);
|
|
99
|
+
return record;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function NativeOptionsFromContent({ children }: { children: React.ReactNode }) {
|
|
103
|
+
return (
|
|
104
|
+
<>
|
|
105
|
+
{React.Children.map(children, (child) => {
|
|
106
|
+
if (!React.isValidElement(child)) return null;
|
|
107
|
+
if (child.type === ResponsiveSelectItem) {
|
|
108
|
+
const props = child.props as ItemProps;
|
|
109
|
+
return (
|
|
110
|
+
<NativeSelectOption value={props.value} disabled={props.disabled}>
|
|
111
|
+
{props.children}
|
|
112
|
+
</NativeSelectOption>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (child.type === ResponsiveSelectGroup) {
|
|
116
|
+
const groupChildren = (child.props as { children?: React.ReactNode })
|
|
117
|
+
.children;
|
|
118
|
+
const label = getElementOfType<{ children?: React.ReactNode }>(
|
|
119
|
+
groupChildren,
|
|
120
|
+
ResponsiveSelectLabel,
|
|
121
|
+
);
|
|
122
|
+
const items = getElementsOfType<ItemProps>(
|
|
123
|
+
groupChildren,
|
|
124
|
+
ResponsiveSelectItem,
|
|
125
|
+
);
|
|
126
|
+
return (
|
|
127
|
+
<NativeSelectOptGroup label={textOf(label?.props.children)}>
|
|
128
|
+
{items.map((item) => (
|
|
129
|
+
<NativeSelectOption
|
|
130
|
+
key={item.props.value}
|
|
131
|
+
value={item.props.value}
|
|
132
|
+
disabled={item.props.disabled}
|
|
133
|
+
>
|
|
134
|
+
{item.props.children}
|
|
135
|
+
</NativeSelectOption>
|
|
136
|
+
))}
|
|
137
|
+
</NativeSelectOptGroup>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
})}
|
|
142
|
+
</>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function ResponsiveSelectRoot({
|
|
147
|
+
value,
|
|
148
|
+
defaultValue,
|
|
149
|
+
onValueChange,
|
|
150
|
+
name,
|
|
151
|
+
disabled,
|
|
152
|
+
required,
|
|
153
|
+
children,
|
|
154
|
+
}: ResponsiveSelectRootProps) {
|
|
155
|
+
// Single source of truth for which surface renders: the native `<select>`
|
|
156
|
+
// below 768px, the Base UI popup above it.
|
|
157
|
+
const isMobile = useIsMobile();
|
|
158
|
+
|
|
159
|
+
if (!isMobile) {
|
|
160
|
+
return (
|
|
161
|
+
<Select
|
|
162
|
+
items={buildItemsRecord(children)}
|
|
163
|
+
value={value}
|
|
164
|
+
defaultValue={defaultValue}
|
|
165
|
+
onValueChange={
|
|
166
|
+
onValueChange
|
|
167
|
+
? (next) => {
|
|
168
|
+
// Base UI resets a single select to `null` when its controlled
|
|
169
|
+
// value no longer matches any item (e.g. the chosen item is
|
|
170
|
+
// removed from the list right after selection). The public
|
|
171
|
+
// contract here is string-only, so swallow that reset instead
|
|
172
|
+
// of leaking `null` to consumers.
|
|
173
|
+
if (typeof next === "string") onValueChange(next);
|
|
174
|
+
}
|
|
175
|
+
: undefined
|
|
176
|
+
}
|
|
177
|
+
name={name}
|
|
178
|
+
disabled={disabled}
|
|
179
|
+
required={required}
|
|
180
|
+
>
|
|
181
|
+
{children}
|
|
182
|
+
</Select>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Project the part elements onto one native control. The Select parts below
|
|
187
|
+
// are never instantiated on this surface — we only read their props.
|
|
188
|
+
const trigger = getElementOfType<ResponsiveSelectTriggerProps>(
|
|
189
|
+
children,
|
|
190
|
+
ResponsiveSelectTrigger,
|
|
191
|
+
);
|
|
192
|
+
const content = getElementOfType<{ children?: React.ReactNode }>(
|
|
193
|
+
children,
|
|
194
|
+
ResponsiveSelectContent,
|
|
195
|
+
);
|
|
196
|
+
const triggerProps = trigger?.props ?? {};
|
|
197
|
+
const placeholder = trigger
|
|
198
|
+
? getElementOfType<{ placeholder?: string }>(
|
|
199
|
+
triggerProps.children,
|
|
200
|
+
ResponsiveSelectValue,
|
|
201
|
+
)?.props.placeholder
|
|
202
|
+
: undefined;
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<NativeSelect
|
|
206
|
+
id={triggerProps.id}
|
|
207
|
+
// Base UI allows a state-function className on the trigger; only a plain
|
|
208
|
+
// string is meaningful on a native <select>, so drop the function form.
|
|
209
|
+
className={
|
|
210
|
+
typeof triggerProps.className === "string"
|
|
211
|
+
? triggerProps.className
|
|
212
|
+
: undefined
|
|
213
|
+
}
|
|
214
|
+
size={triggerProps.size}
|
|
215
|
+
aria-invalid={triggerProps["aria-invalid"]}
|
|
216
|
+
aria-label={triggerProps["aria-label"]}
|
|
217
|
+
aria-labelledby={triggerProps["aria-labelledby"]}
|
|
218
|
+
// The trigger types blur against a button; the projected target is a
|
|
219
|
+
// <select>, but the handler only reads currentTarget/relatedTarget.
|
|
220
|
+
onBlur={
|
|
221
|
+
triggerProps.onBlur as
|
|
222
|
+
| React.FocusEventHandler<HTMLSelectElement>
|
|
223
|
+
| undefined
|
|
224
|
+
}
|
|
225
|
+
value={value}
|
|
226
|
+
defaultValue={defaultValue}
|
|
227
|
+
onChange={
|
|
228
|
+
onValueChange ? (event) => onValueChange(event.target.value) : undefined
|
|
229
|
+
}
|
|
230
|
+
name={name}
|
|
231
|
+
disabled={disabled}
|
|
232
|
+
required={required}
|
|
233
|
+
>
|
|
234
|
+
{placeholder !== undefined && (
|
|
235
|
+
<NativeSelectOption value="">{placeholder}</NativeSelectOption>
|
|
236
|
+
)}
|
|
237
|
+
<NativeOptionsFromContent>
|
|
238
|
+
{content?.props.children}
|
|
239
|
+
</NativeOptionsFromContent>
|
|
240
|
+
</NativeSelect>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
type ResponsiveSelectTriggerProps = React.ComponentProps<typeof SelectTrigger>;
|
|
245
|
+
|
|
246
|
+
// On desktop these render their Base UI counterpart; on mobile `Root` reads them
|
|
247
|
+
// instead of rendering them, so they double as the declaration the native
|
|
248
|
+
// `<select>` is built from.
|
|
249
|
+
function ResponsiveSelectTrigger(props: ResponsiveSelectTriggerProps) {
|
|
250
|
+
return <SelectTrigger {...props} />;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function ResponsiveSelectValue(
|
|
254
|
+
props: React.ComponentProps<typeof SelectValue>,
|
|
255
|
+
) {
|
|
256
|
+
return <SelectValue {...props} />;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function ResponsiveSelectContent(
|
|
260
|
+
props: React.ComponentProps<typeof SelectContent>,
|
|
261
|
+
) {
|
|
262
|
+
return <SelectContent {...props} />;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function ResponsiveSelectGroup(
|
|
266
|
+
props: React.ComponentProps<typeof SelectGroup>,
|
|
267
|
+
) {
|
|
268
|
+
return <SelectGroup {...props} />;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function ResponsiveSelectLabel(
|
|
272
|
+
props: React.ComponentProps<typeof SelectLabel>,
|
|
273
|
+
) {
|
|
274
|
+
return <SelectLabel {...props} />;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function ResponsiveSelectItem(props: React.ComponentProps<typeof SelectItem>) {
|
|
278
|
+
return <SelectItem {...props} />;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export const ResponsiveSelect = {
|
|
282
|
+
Root: ResponsiveSelectRoot,
|
|
283
|
+
Trigger: ResponsiveSelectTrigger,
|
|
284
|
+
Value: ResponsiveSelectValue,
|
|
285
|
+
Content: ResponsiveSelectContent,
|
|
286
|
+
Group: ResponsiveSelectGroup,
|
|
287
|
+
Label: ResponsiveSelectLabel,
|
|
288
|
+
Item: ResponsiveSelectItem,
|
|
289
|
+
};
|