@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,225 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
Drawer,
|
|
4
|
+
DrawerContent,
|
|
5
|
+
DrawerDescription,
|
|
6
|
+
DrawerFooter,
|
|
7
|
+
DrawerHeader,
|
|
8
|
+
DrawerTitle,
|
|
9
|
+
DrawerTrigger,
|
|
10
|
+
} from "#/components/ui/drawer.tsx";
|
|
11
|
+
import {
|
|
12
|
+
Sheet,
|
|
13
|
+
SheetContent,
|
|
14
|
+
type SheetContentSize,
|
|
15
|
+
SheetDescription,
|
|
16
|
+
SheetFooter,
|
|
17
|
+
SheetHeader,
|
|
18
|
+
SheetTitle,
|
|
19
|
+
SheetTrigger,
|
|
20
|
+
} from "#/components/ui/sheet.tsx";
|
|
21
|
+
import { useIsMobile } from "#/hooks/use-mobile.ts";
|
|
22
|
+
import { cn } from "#/lib/utils.ts";
|
|
23
|
+
|
|
24
|
+
// One API, two surfaces: a side sheet on desktop and a vaul bottom drawer under
|
|
25
|
+
// the `useIsMobile` breakpoint (768px) — the same split `ResponsiveDialog` makes
|
|
26
|
+
// for centered dialogs. The root owns the open state so it survives crossing the
|
|
27
|
+
// breakpoint while open: the two halves are different component trees, so
|
|
28
|
+
// uncontrolled state would reset.
|
|
29
|
+
//
|
|
30
|
+
// The halves disagree on composition (Base UI `render` vs Radix `asChild`); the
|
|
31
|
+
// trigger bridges that by accepting the element form of `render` only.
|
|
32
|
+
|
|
33
|
+
const ResponsiveSheetContext = React.createContext<boolean | null>(null);
|
|
34
|
+
|
|
35
|
+
function useResponsiveSheetIsMobile(part: string): boolean {
|
|
36
|
+
const isMobile = React.use(ResponsiveSheetContext);
|
|
37
|
+
if (isMobile === null) {
|
|
38
|
+
throw new Error(`${part} must be used within <ResponsiveSheet>`);
|
|
39
|
+
}
|
|
40
|
+
return isMobile;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function ResponsiveSheet({
|
|
44
|
+
open,
|
|
45
|
+
defaultOpen = false,
|
|
46
|
+
onOpenChange,
|
|
47
|
+
children,
|
|
48
|
+
}: {
|
|
49
|
+
open?: boolean;
|
|
50
|
+
defaultOpen?: boolean;
|
|
51
|
+
onOpenChange?: (open: boolean) => void;
|
|
52
|
+
children?: React.ReactNode;
|
|
53
|
+
}) {
|
|
54
|
+
const isMobile = useIsMobile();
|
|
55
|
+
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
|
|
56
|
+
const isOpen = open ?? uncontrolledOpen;
|
|
57
|
+
const handleOpenChange = (nextOpen: boolean) => {
|
|
58
|
+
setUncontrolledOpen(nextOpen);
|
|
59
|
+
onOpenChange?.(nextOpen);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<ResponsiveSheetContext.Provider value={isMobile}>
|
|
64
|
+
{isMobile ? (
|
|
65
|
+
<Drawer open={isOpen} onOpenChange={handleOpenChange}>
|
|
66
|
+
{children}
|
|
67
|
+
</Drawer>
|
|
68
|
+
) : (
|
|
69
|
+
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
|
|
70
|
+
{children}
|
|
71
|
+
</Sheet>
|
|
72
|
+
)}
|
|
73
|
+
</ResponsiveSheetContext.Provider>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ResponsiveSheetTrigger({
|
|
78
|
+
render,
|
|
79
|
+
children,
|
|
80
|
+
...props
|
|
81
|
+
}: Omit<React.ComponentProps<typeof DrawerTrigger>, "asChild"> & {
|
|
82
|
+
/** Element form only — the drawer half clones it, so render functions are unsupported. */
|
|
83
|
+
render?: React.ReactElement;
|
|
84
|
+
}) {
|
|
85
|
+
const isMobile = useResponsiveSheetIsMobile("ResponsiveSheetTrigger");
|
|
86
|
+
if (isMobile) {
|
|
87
|
+
if (render) {
|
|
88
|
+
return (
|
|
89
|
+
<DrawerTrigger asChild {...props}>
|
|
90
|
+
{children === undefined
|
|
91
|
+
? render
|
|
92
|
+
: React.cloneElement(render, undefined, children)}
|
|
93
|
+
</DrawerTrigger>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return <DrawerTrigger {...props}>{children}</DrawerTrigger>;
|
|
97
|
+
}
|
|
98
|
+
return (
|
|
99
|
+
<SheetTrigger render={render} {...props}>
|
|
100
|
+
{children}
|
|
101
|
+
</SheetTrigger>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function ResponsiveSheetContent({
|
|
106
|
+
className,
|
|
107
|
+
children,
|
|
108
|
+
size = "default",
|
|
109
|
+
showCloseButton = true,
|
|
110
|
+
closeButtonLabel = "Close",
|
|
111
|
+
}: {
|
|
112
|
+
className?: string;
|
|
113
|
+
children?: React.ReactNode;
|
|
114
|
+
/** Desktop panel width — the mobile drawer is always full-width. */
|
|
115
|
+
size?: SheetContentSize;
|
|
116
|
+
showCloseButton?: boolean;
|
|
117
|
+
closeButtonLabel?: string;
|
|
118
|
+
}) {
|
|
119
|
+
const isMobile = useResponsiveSheetIsMobile("ResponsiveSheetContent");
|
|
120
|
+
if (isMobile) {
|
|
121
|
+
return (
|
|
122
|
+
<DrawerContent
|
|
123
|
+
className={className}
|
|
124
|
+
showCloseButton={showCloseButton}
|
|
125
|
+
closeButtonLabel={closeButtonLabel}
|
|
126
|
+
>
|
|
127
|
+
{children}
|
|
128
|
+
</DrawerContent>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return (
|
|
132
|
+
<SheetContent
|
|
133
|
+
className={className}
|
|
134
|
+
size={size}
|
|
135
|
+
showCloseButton={showCloseButton}
|
|
136
|
+
closeButtonLabel={closeButtonLabel}
|
|
137
|
+
>
|
|
138
|
+
{children}
|
|
139
|
+
</SheetContent>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function ResponsiveSheetHeader({
|
|
144
|
+
className,
|
|
145
|
+
...props
|
|
146
|
+
}: React.ComponentProps<"div">) {
|
|
147
|
+
const isMobile = useResponsiveSheetIsMobile("ResponsiveSheetHeader");
|
|
148
|
+
return isMobile ? (
|
|
149
|
+
// The bottom drawer centers its header by default; a detail panel reads
|
|
150
|
+
// the same on both surfaces only if the mobile half stays left-aligned.
|
|
151
|
+
<DrawerHeader
|
|
152
|
+
className={cn(
|
|
153
|
+
"group-data-[vaul-drawer-direction=bottom]/drawer-content:text-left",
|
|
154
|
+
className,
|
|
155
|
+
)}
|
|
156
|
+
{...props}
|
|
157
|
+
/>
|
|
158
|
+
) : (
|
|
159
|
+
<SheetHeader className={className} {...props} />
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The scrolling region between header and footer, gutters included. */
|
|
164
|
+
function ResponsiveSheetBody({
|
|
165
|
+
className,
|
|
166
|
+
...props
|
|
167
|
+
}: React.ComponentProps<"div">) {
|
|
168
|
+
return (
|
|
169
|
+
<div
|
|
170
|
+
data-slot="responsive-sheet-body"
|
|
171
|
+
className={cn(
|
|
172
|
+
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4",
|
|
173
|
+
className,
|
|
174
|
+
)}
|
|
175
|
+
{...props}
|
|
176
|
+
/>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function ResponsiveSheetFooter({
|
|
181
|
+
className,
|
|
182
|
+
...props
|
|
183
|
+
}: React.ComponentProps<"div">) {
|
|
184
|
+
const isMobile = useResponsiveSheetIsMobile("ResponsiveSheetFooter");
|
|
185
|
+
return isMobile ? (
|
|
186
|
+
<DrawerFooter className={className} {...props} />
|
|
187
|
+
) : (
|
|
188
|
+
<SheetFooter className={className} {...props} />
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function ResponsiveSheetTitle({
|
|
193
|
+
className,
|
|
194
|
+
...props
|
|
195
|
+
}: React.ComponentProps<typeof DrawerTitle>) {
|
|
196
|
+
const isMobile = useResponsiveSheetIsMobile("ResponsiveSheetTitle");
|
|
197
|
+
return isMobile ? (
|
|
198
|
+
<DrawerTitle className={className} {...props} />
|
|
199
|
+
) : (
|
|
200
|
+
<SheetTitle className={className} {...props} />
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function ResponsiveSheetDescription({
|
|
205
|
+
className,
|
|
206
|
+
...props
|
|
207
|
+
}: React.ComponentProps<typeof DrawerDescription>) {
|
|
208
|
+
const isMobile = useResponsiveSheetIsMobile("ResponsiveSheetDescription");
|
|
209
|
+
return isMobile ? (
|
|
210
|
+
<DrawerDescription className={className} {...props} />
|
|
211
|
+
) : (
|
|
212
|
+
<SheetDescription className={className} {...props} />
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export {
|
|
217
|
+
ResponsiveSheet,
|
|
218
|
+
ResponsiveSheetBody,
|
|
219
|
+
ResponsiveSheetContent,
|
|
220
|
+
ResponsiveSheetDescription,
|
|
221
|
+
ResponsiveSheetFooter,
|
|
222
|
+
ResponsiveSheetHeader,
|
|
223
|
+
ResponsiveSheetTitle,
|
|
224
|
+
ResponsiveSheetTrigger,
|
|
225
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
|
|
2
|
+
|
|
3
|
+
import { cn } from "#/lib/utils.ts";
|
|
4
|
+
|
|
5
|
+
function ScrollArea({
|
|
6
|
+
className,
|
|
7
|
+
children,
|
|
8
|
+
...props
|
|
9
|
+
}: ScrollAreaPrimitive.Root.Props) {
|
|
10
|
+
return (
|
|
11
|
+
<ScrollAreaPrimitive.Root
|
|
12
|
+
data-slot="scroll-area"
|
|
13
|
+
className={cn("relative", className)}
|
|
14
|
+
{...props}
|
|
15
|
+
>
|
|
16
|
+
<ScrollAreaPrimitive.Viewport
|
|
17
|
+
data-slot="scroll-area-viewport"
|
|
18
|
+
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
|
19
|
+
>
|
|
20
|
+
{children}
|
|
21
|
+
</ScrollAreaPrimitive.Viewport>
|
|
22
|
+
<ScrollBar />
|
|
23
|
+
<ScrollAreaPrimitive.Corner />
|
|
24
|
+
</ScrollAreaPrimitive.Root>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ScrollBar({
|
|
29
|
+
className,
|
|
30
|
+
orientation = "vertical",
|
|
31
|
+
...props
|
|
32
|
+
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
|
33
|
+
return (
|
|
34
|
+
<ScrollAreaPrimitive.Scrollbar
|
|
35
|
+
data-slot="scroll-area-scrollbar"
|
|
36
|
+
orientation={orientation}
|
|
37
|
+
className={cn(
|
|
38
|
+
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2.5 data-[orientation=vertical]:border-l data-[orientation=vertical]:border-l-transparent",
|
|
39
|
+
className,
|
|
40
|
+
)}
|
|
41
|
+
{...props}
|
|
42
|
+
>
|
|
43
|
+
<ScrollAreaPrimitive.Thumb
|
|
44
|
+
data-slot="scroll-area-thumb"
|
|
45
|
+
className="relative flex-1 rounded-full bg-border"
|
|
46
|
+
/>
|
|
47
|
+
</ScrollAreaPrimitive.Scrollbar>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { ScrollArea, ScrollBar };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { mergeProps } from "@base-ui/react/merge-props";
|
|
2
|
+
import { useRender } from "@base-ui/react/use-render";
|
|
3
|
+
|
|
4
|
+
import { cn } from "#/lib/utils.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Vertical rhythm wrapper for an in-page section - heading row + content with
|
|
8
|
+
* consistent spacing, the `PageHeader` anatomy one level down. Renders a
|
|
9
|
+
* `section` by default - pass `render` for a plain `div` when the landmark
|
|
10
|
+
* is unwanted.
|
|
11
|
+
*/
|
|
12
|
+
function Section({
|
|
13
|
+
className,
|
|
14
|
+
render,
|
|
15
|
+
...props
|
|
16
|
+
}: useRender.ComponentProps<"section">) {
|
|
17
|
+
return useRender({
|
|
18
|
+
defaultTagName: "section",
|
|
19
|
+
props: mergeProps<"section">(
|
|
20
|
+
{
|
|
21
|
+
className: cn("flex flex-col gap-4", className),
|
|
22
|
+
},
|
|
23
|
+
props,
|
|
24
|
+
),
|
|
25
|
+
render,
|
|
26
|
+
state: {
|
|
27
|
+
slot: "section",
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function SectionHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
data-slot="section-header"
|
|
36
|
+
className={cn("flex items-center justify-between gap-4", className)}
|
|
37
|
+
{...props}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function SectionHeading({ className, ...props }: React.ComponentProps<"div">) {
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
data-slot="section-heading"
|
|
46
|
+
className={cn("flex min-w-0 flex-col gap-1", className)}
|
|
47
|
+
{...props}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Renders an `h2` - pass `render` to fit the page's heading outline. */
|
|
53
|
+
function SectionTitle({
|
|
54
|
+
className,
|
|
55
|
+
render,
|
|
56
|
+
...props
|
|
57
|
+
}: useRender.ComponentProps<"h2">) {
|
|
58
|
+
return useRender({
|
|
59
|
+
defaultTagName: "h2",
|
|
60
|
+
props: mergeProps<"h2">(
|
|
61
|
+
{
|
|
62
|
+
className: cn("text-lg font-semibold tracking-tight", className),
|
|
63
|
+
},
|
|
64
|
+
props,
|
|
65
|
+
),
|
|
66
|
+
render,
|
|
67
|
+
state: {
|
|
68
|
+
slot: "section-title",
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function SectionDescription({
|
|
74
|
+
className,
|
|
75
|
+
...props
|
|
76
|
+
}: React.ComponentProps<"p">) {
|
|
77
|
+
return (
|
|
78
|
+
<p
|
|
79
|
+
data-slot="section-description"
|
|
80
|
+
className={cn(
|
|
81
|
+
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
|
82
|
+
className,
|
|
83
|
+
)}
|
|
84
|
+
{...props}
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function SectionActions({ className, ...props }: React.ComponentProps<"div">) {
|
|
90
|
+
return (
|
|
91
|
+
<div
|
|
92
|
+
data-slot="section-actions"
|
|
93
|
+
className={cn("flex shrink-0 items-center gap-2", className)}
|
|
94
|
+
{...props}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export {
|
|
100
|
+
Section,
|
|
101
|
+
SectionActions,
|
|
102
|
+
SectionDescription,
|
|
103
|
+
SectionHeader,
|
|
104
|
+
SectionHeading,
|
|
105
|
+
SectionTitle,
|
|
106
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Radio as RadioPrimitive } from "@base-ui/react/radio";
|
|
2
|
+
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cva, type VariantProps } from "#/lib/cva.ts";
|
|
5
|
+
import { cn } from "#/lib/utils.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Single-select switcher (Day/Week/Month) with an animated selection thumb.
|
|
9
|
+
* Built on radio semantics - exactly one segment is always selected - which
|
|
10
|
+
* is what separates it from `ToggleGroup`'s pressable toolbar buttons.
|
|
11
|
+
*/
|
|
12
|
+
const segmentedControlVariants = cva({
|
|
13
|
+
base: "group/segmented-control relative isolate inline-flex w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
|
|
14
|
+
variants: {
|
|
15
|
+
size: {
|
|
16
|
+
default: "h-8",
|
|
17
|
+
sm: "h-7 rounded-[min(var(--radius-md),12px)]",
|
|
18
|
+
lg: "h-9",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
defaultVariants: {
|
|
22
|
+
size: "default",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const SegmentedControlContext = React.createContext<
|
|
27
|
+
VariantProps<typeof segmentedControlVariants>
|
|
28
|
+
>({ size: "default" });
|
|
29
|
+
|
|
30
|
+
type SegmentedControlProps = RadioGroupPrimitive.Props &
|
|
31
|
+
VariantProps<typeof segmentedControlVariants>;
|
|
32
|
+
|
|
33
|
+
function SegmentedControl({
|
|
34
|
+
className,
|
|
35
|
+
size = "default",
|
|
36
|
+
children,
|
|
37
|
+
onValueChange,
|
|
38
|
+
...props
|
|
39
|
+
}: SegmentedControlProps) {
|
|
40
|
+
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
|
41
|
+
const thumbRef = React.useRef<HTMLSpanElement | null>(null);
|
|
42
|
+
const hasMeasuredRef = React.useRef(false);
|
|
43
|
+
// Re-rendering on uncontrolled selection changes keeps the layout effect
|
|
44
|
+
// below in sync with Base UI's internal state (the parent component does
|
|
45
|
+
// not re-render when only the primitive's state moves).
|
|
46
|
+
const [, forceMeasure] = React.useReducer((count: number) => count + 1, 0);
|
|
47
|
+
|
|
48
|
+
const measure = React.useCallback(() => {
|
|
49
|
+
const root = rootRef.current;
|
|
50
|
+
const thumb = thumbRef.current;
|
|
51
|
+
if (!root || !thumb) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const checked = root.querySelector<HTMLElement>(
|
|
55
|
+
"[data-slot='segmented-control-item'][data-checked]",
|
|
56
|
+
);
|
|
57
|
+
if (!checked) {
|
|
58
|
+
thumb.style.opacity = "0";
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Skip the slide-in animation on the very first paint - the thumb
|
|
62
|
+
// should appear on the selected segment, not travel from the edge.
|
|
63
|
+
const isFirstMeasure = !hasMeasuredRef.current;
|
|
64
|
+
if (isFirstMeasure) {
|
|
65
|
+
thumb.style.transitionDuration = "0s";
|
|
66
|
+
}
|
|
67
|
+
thumb.style.opacity = "1";
|
|
68
|
+
thumb.style.width = `${checked.offsetWidth}px`;
|
|
69
|
+
thumb.style.transform = `translateX(${checked.offsetLeft}px)`;
|
|
70
|
+
if (isFirstMeasure) {
|
|
71
|
+
hasMeasuredRef.current = true;
|
|
72
|
+
requestAnimationFrame(() => {
|
|
73
|
+
thumb.style.transitionDuration = "";
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
React.useLayoutEffect(() => {
|
|
79
|
+
measure();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
React.useLayoutEffect(() => {
|
|
83
|
+
const root = rootRef.current;
|
|
84
|
+
if (!root || typeof ResizeObserver === "undefined") {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const observer = new ResizeObserver(measure);
|
|
88
|
+
observer.observe(root);
|
|
89
|
+
for (const item of root.querySelectorAll(
|
|
90
|
+
"[data-slot='segmented-control-item']",
|
|
91
|
+
)) {
|
|
92
|
+
observer.observe(item);
|
|
93
|
+
}
|
|
94
|
+
return () => observer.disconnect();
|
|
95
|
+
}, [measure]);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<RadioGroupPrimitive
|
|
99
|
+
ref={rootRef}
|
|
100
|
+
data-slot="segmented-control"
|
|
101
|
+
data-size={size}
|
|
102
|
+
className={cn(segmentedControlVariants({ size }), className)}
|
|
103
|
+
onValueChange={(value, eventDetails) => {
|
|
104
|
+
onValueChange?.(value, eventDetails);
|
|
105
|
+
forceMeasure();
|
|
106
|
+
}}
|
|
107
|
+
{...props}
|
|
108
|
+
>
|
|
109
|
+
<span
|
|
110
|
+
ref={thumbRef}
|
|
111
|
+
aria-hidden
|
|
112
|
+
data-slot="segmented-control-thumb"
|
|
113
|
+
className="absolute inset-y-[3px] left-0 -z-10 rounded-md border border-transparent bg-background opacity-0 shadow-sm transition-[transform,width] duration-200 ease-out group-data-[size=sm]/segmented-control:rounded-[min(var(--radius-sm),10px)] motion-reduce:transition-none dark:border-input dark:bg-input/30"
|
|
114
|
+
/>
|
|
115
|
+
<SegmentedControlContext.Provider value={{ size }}>
|
|
116
|
+
{children}
|
|
117
|
+
</SegmentedControlContext.Provider>
|
|
118
|
+
</RadioGroupPrimitive>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function SegmentedControlItem({
|
|
123
|
+
className,
|
|
124
|
+
...props
|
|
125
|
+
}: RadioPrimitive.Root.Props) {
|
|
126
|
+
const { size } = React.useContext(SegmentedControlContext);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<RadioPrimitive.Root
|
|
130
|
+
data-slot="segmented-control-item"
|
|
131
|
+
data-size={size}
|
|
132
|
+
className={cn(
|
|
133
|
+
// The thumb slides behind the items, so items keep a transparent
|
|
134
|
+
// background and only transition their text color.
|
|
135
|
+
"inline-flex h-full min-w-0 items-center justify-center gap-1.5 rounded-md border border-transparent px-2.5 text-sm font-medium whitespace-nowrap text-foreground/60 outline-none transition-colors hover:text-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 data-checked:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-[size=sm]:px-2 data-[size=sm]:text-[0.8rem] dark:text-muted-foreground dark:hover:text-foreground dark:data-checked:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[size=sm]:[&_svg:not([class*='size-'])]:size-3.5",
|
|
136
|
+
className,
|
|
137
|
+
)}
|
|
138
|
+
{...props}
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export {
|
|
144
|
+
SegmentedControl,
|
|
145
|
+
SegmentedControlItem,
|
|
146
|
+
type SegmentedControlProps,
|
|
147
|
+
segmentedControlVariants,
|
|
148
|
+
};
|