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