@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,23 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "#/lib/utils.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Purely visual placeholder with no semantics of its own: wrap the loading
|
|
7
|
+
* region in `role="status"` with sr-only "Loading..." text (or mark the
|
|
8
|
+
* skeletons `aria-hidden`) so screen readers announce the load.
|
|
9
|
+
*/
|
|
10
|
+
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
|
11
|
+
return (
|
|
12
|
+
<div
|
|
13
|
+
data-slot="skeleton"
|
|
14
|
+
className={cn(
|
|
15
|
+
"animate-pulse rounded-md bg-muted motion-reduce:animate-none",
|
|
16
|
+
className,
|
|
17
|
+
)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { Skeleton };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Slider as SliderPrimitive } from "@base-ui/react/slider";
|
|
2
|
+
|
|
3
|
+
import { cn } from "#/lib/utils.ts";
|
|
4
|
+
|
|
5
|
+
function resolveThumbValues(
|
|
6
|
+
value: SliderPrimitive.Root.Props["value"],
|
|
7
|
+
defaultValue: SliderPrimitive.Root.Props["defaultValue"],
|
|
8
|
+
min: number,
|
|
9
|
+
): readonly number[] {
|
|
10
|
+
const source = value ?? defaultValue;
|
|
11
|
+
if (Array.isArray(source)) return source;
|
|
12
|
+
if (typeof source === "number") return [source];
|
|
13
|
+
return [min];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function Slider({
|
|
17
|
+
className,
|
|
18
|
+
children,
|
|
19
|
+
defaultValue,
|
|
20
|
+
value,
|
|
21
|
+
min = 0,
|
|
22
|
+
max = 100,
|
|
23
|
+
...props
|
|
24
|
+
}: SliderPrimitive.Root.Props) {
|
|
25
|
+
const thumbValues = resolveThumbValues(value, defaultValue, min);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<SliderPrimitive.Root
|
|
29
|
+
className={cn(
|
|
30
|
+
"data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full",
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
data-slot="slider"
|
|
34
|
+
defaultValue={defaultValue}
|
|
35
|
+
value={value}
|
|
36
|
+
min={min}
|
|
37
|
+
max={max}
|
|
38
|
+
thumbAlignment="edge"
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
<SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-40 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col">
|
|
42
|
+
<SliderPrimitive.Track
|
|
43
|
+
data-slot="slider-track"
|
|
44
|
+
className="relative grow overflow-hidden rounded-full bg-muted select-none data-[orientation=horizontal]:h-1 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1"
|
|
45
|
+
>
|
|
46
|
+
<SliderPrimitive.Indicator
|
|
47
|
+
data-slot="slider-range"
|
|
48
|
+
className="bg-primary select-none data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
|
49
|
+
/>
|
|
50
|
+
</SliderPrimitive.Track>
|
|
51
|
+
{Array.from({ length: thumbValues.length }, (_, index) => (
|
|
52
|
+
<SliderPrimitive.Thumb
|
|
53
|
+
data-slot="slider-thumb"
|
|
54
|
+
key={index}
|
|
55
|
+
className="relative block size-3 shrink-0 rounded-full border border-ring bg-background ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-none active:ring-3 data-disabled:pointer-events-none data-disabled:opacity-50"
|
|
56
|
+
/>
|
|
57
|
+
))}
|
|
58
|
+
</SliderPrimitive.Control>
|
|
59
|
+
{children}
|
|
60
|
+
</SliderPrimitive.Root>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function SliderValue({ className, ...props }: SliderPrimitive.Value.Props) {
|
|
65
|
+
return (
|
|
66
|
+
<SliderPrimitive.Value
|
|
67
|
+
data-slot="slider-value"
|
|
68
|
+
className={cn("text-sm text-muted-foreground tabular-nums", className)}
|
|
69
|
+
{...props}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { Slider, SliderValue };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CheckCircleIcon,
|
|
3
|
+
InfoIcon,
|
|
4
|
+
WarningIcon,
|
|
5
|
+
WarningOctagonIcon,
|
|
6
|
+
} from "@phosphor-icons/react";
|
|
7
|
+
import * as React from "react";
|
|
8
|
+
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
|
9
|
+
import { Spinner } from "#/components/ui/spinner.tsx";
|
|
10
|
+
|
|
11
|
+
/* Theme is resolved from the `.dark` class on <html> — the same mechanism the
|
|
12
|
+
* design tokens use — so the Toaster works without any theme provider. */
|
|
13
|
+
function subscribeToRootThemeClass(onChange: () => void): () => void {
|
|
14
|
+
const observer = new MutationObserver(onChange);
|
|
15
|
+
observer.observe(document.documentElement, {
|
|
16
|
+
attributes: true,
|
|
17
|
+
attributeFilter: ["class"],
|
|
18
|
+
});
|
|
19
|
+
return () => observer.disconnect();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getResolvedTheme(): "light" | "dark" {
|
|
23
|
+
return document.documentElement.classList.contains("dark") ? "dark" : "light";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function useResolvedTheme(): "light" | "dark" {
|
|
27
|
+
return React.useSyncExternalStore(
|
|
28
|
+
subscribeToRootThemeClass,
|
|
29
|
+
getResolvedTheme,
|
|
30
|
+
() => "light",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function Toaster(props: ToasterProps) {
|
|
35
|
+
const theme = useResolvedTheme();
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<Sonner
|
|
39
|
+
theme={theme}
|
|
40
|
+
className="toaster group"
|
|
41
|
+
closeButton
|
|
42
|
+
icons={{
|
|
43
|
+
success: <CheckCircleIcon className="size-4 text-success" />,
|
|
44
|
+
info: <InfoIcon className="size-4" />,
|
|
45
|
+
warning: <WarningIcon className="size-4 text-warning" />,
|
|
46
|
+
error: <WarningOctagonIcon className="size-4 text-destructive" />,
|
|
47
|
+
loading: <Spinner />,
|
|
48
|
+
}}
|
|
49
|
+
style={
|
|
50
|
+
{
|
|
51
|
+
"--normal-bg": "var(--popover)",
|
|
52
|
+
"--normal-text": "var(--popover-foreground)",
|
|
53
|
+
"--normal-border": "var(--border)",
|
|
54
|
+
"--border-radius": "var(--radius)",
|
|
55
|
+
} as React.CSSProperties
|
|
56
|
+
}
|
|
57
|
+
{...props}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { toast } from "sonner";
|
|
63
|
+
export type { ToasterProps };
|
|
64
|
+
export { Toaster };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { SpinnerGapIcon } from "@phosphor-icons/react";
|
|
2
|
+
import { cn } from "#/lib/utils.ts";
|
|
3
|
+
|
|
4
|
+
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
|
5
|
+
return (
|
|
6
|
+
<SpinnerGapIcon
|
|
7
|
+
data-slot="spinner"
|
|
8
|
+
role="status"
|
|
9
|
+
aria-label="Loading"
|
|
10
|
+
className={cn(
|
|
11
|
+
"size-4 animate-spin motion-reduce:animate-[spin_3s_linear_infinite]",
|
|
12
|
+
className,
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { Spinner };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { TrendDownIcon, TrendUpIcon } from "@phosphor-icons/react";
|
|
2
|
+
|
|
3
|
+
import { Card } from "#/components/ui/card.tsx";
|
|
4
|
+
import { cva, type VariantProps } from "#/lib/cva.ts";
|
|
5
|
+
import { cn } from "#/lib/utils.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* KPI tile for dashboards: a Card composition pairing a muted label, a large
|
|
9
|
+
* value, an optional trend delta and an optional full-bleed sparkline. Put a
|
|
10
|
+
* `Chart.Root` inside `StatCardChart` — or `Chart.Skeleton` / `Chart.Empty`
|
|
11
|
+
* (from `@voila.dev/ui-chart`) while the metric is loading or has no data.
|
|
12
|
+
*/
|
|
13
|
+
function StatCard({ className, ...props }: React.ComponentProps<typeof Card>) {
|
|
14
|
+
return (
|
|
15
|
+
<Card
|
|
16
|
+
data-slot="stat-card"
|
|
17
|
+
className={cn("gap-1.5", className)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function StatCardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
data-slot="stat-card-header"
|
|
27
|
+
className={cn(
|
|
28
|
+
"flex items-center justify-between gap-2 px-4 group-data-[size=sm]/card:px-3",
|
|
29
|
+
className,
|
|
30
|
+
)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function StatCardLabel({ className, ...props }: React.ComponentProps<"div">) {
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
data-slot="stat-card-label"
|
|
40
|
+
className={cn("truncate text-sm text-muted-foreground", className)}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function StatCardValue({ className, ...props }: React.ComponentProps<"div">) {
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
data-slot="stat-card-value"
|
|
50
|
+
className={cn(
|
|
51
|
+
"px-4 text-2xl font-semibold tracking-tight tabular-nums group-data-[size=sm]/card:px-3",
|
|
52
|
+
className,
|
|
53
|
+
)}
|
|
54
|
+
{...props}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type StatCardTrend = "up" | "down" | "neutral";
|
|
60
|
+
|
|
61
|
+
const statCardDeltaVariants = cva({
|
|
62
|
+
base: "inline-flex shrink-0 items-center gap-1 text-xs font-medium tabular-nums [&_svg]:size-3.5 [&_svg]:shrink-0",
|
|
63
|
+
variants: {
|
|
64
|
+
trend: {
|
|
65
|
+
up: "text-success",
|
|
66
|
+
down: "text-destructive",
|
|
67
|
+
neutral: "text-muted-foreground",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
defaultVariants: {
|
|
71
|
+
trend: "neutral",
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const trendIcons: Record<
|
|
76
|
+
StatCardTrend,
|
|
77
|
+
React.ComponentType<{ "aria-hidden"?: boolean }> | null
|
|
78
|
+
> = {
|
|
79
|
+
up: TrendUpIcon,
|
|
80
|
+
down: TrendDownIcon,
|
|
81
|
+
neutral: null,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Trend indicator next to the label or value. `up` renders in the success
|
|
86
|
+
* color and `down` in the destructive color - for metrics where down is the
|
|
87
|
+
* good direction (e.g. cancellations), invert with `className` ("text-success"
|
|
88
|
+
* / "text-destructive" win over the variant color).
|
|
89
|
+
*/
|
|
90
|
+
function StatCardDelta({
|
|
91
|
+
className,
|
|
92
|
+
trend = "neutral",
|
|
93
|
+
children,
|
|
94
|
+
...props
|
|
95
|
+
}: React.ComponentProps<"div"> & VariantProps<typeof statCardDeltaVariants>) {
|
|
96
|
+
const TrendIcon = trendIcons[trend];
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
data-slot="stat-card-delta"
|
|
100
|
+
data-trend={trend}
|
|
101
|
+
className={cn(statCardDeltaVariants({ trend }), className)}
|
|
102
|
+
{...props}
|
|
103
|
+
>
|
|
104
|
+
{TrendIcon ? <TrendIcon aria-hidden /> : null}
|
|
105
|
+
{children}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Full-bleed chart slot pinned to the bottom edge of the card: the negative
|
|
112
|
+
* margin cancels the card's bottom padding and the card's `overflow-hidden`
|
|
113
|
+
* clips the chart to the rounded corners. Constrain the chart's height on the
|
|
114
|
+
* `Chart.Root` itself (e.g. `className="h-16 w-full"`).
|
|
115
|
+
*/
|
|
116
|
+
function StatCardChart({ className, ...props }: React.ComponentProps<"div">) {
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
data-slot="stat-card-chart"
|
|
120
|
+
className={cn(
|
|
121
|
+
"mt-auto -mb-4 pt-2 group-data-[size=sm]/card:-mb-3",
|
|
122
|
+
className,
|
|
123
|
+
)}
|
|
124
|
+
{...props}
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export {
|
|
130
|
+
StatCard,
|
|
131
|
+
StatCardChart,
|
|
132
|
+
StatCardDelta,
|
|
133
|
+
StatCardHeader,
|
|
134
|
+
StatCardLabel,
|
|
135
|
+
type StatCardTrend,
|
|
136
|
+
StatCardValue,
|
|
137
|
+
statCardDeltaVariants,
|
|
138
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { CheckIcon } from "@phosphor-icons/react";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "#/lib/utils.ts";
|
|
5
|
+
|
|
6
|
+
type StepperOrientation = "horizontal" | "vertical";
|
|
7
|
+
type StepperStepState = "completed" | "active" | "inactive";
|
|
8
|
+
|
|
9
|
+
const StepperContext = React.createContext<{
|
|
10
|
+
value: number;
|
|
11
|
+
orientation: StepperOrientation;
|
|
12
|
+
} | null>(null);
|
|
13
|
+
|
|
14
|
+
function useStepperContext(componentName: string) {
|
|
15
|
+
const context = React.useContext(StepperContext);
|
|
16
|
+
if (!context) {
|
|
17
|
+
throw new Error(`<${componentName}> must be used within <Stepper>`);
|
|
18
|
+
}
|
|
19
|
+
return context;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const StepperItemContext = React.createContext<{
|
|
23
|
+
step: number;
|
|
24
|
+
state: StepperStepState;
|
|
25
|
+
} | null>(null);
|
|
26
|
+
|
|
27
|
+
function useStepperItemContext(componentName: string) {
|
|
28
|
+
const context = React.useContext(StepperItemContext);
|
|
29
|
+
if (!context) {
|
|
30
|
+
throw new Error(`<${componentName}> must be used within <StepperItem>`);
|
|
31
|
+
}
|
|
32
|
+
return context;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Presentational progress indicator for multi-step flows (onboarding,
|
|
37
|
+
* booking). `value` is the 1-based active step: steps below it render as
|
|
38
|
+
* completed, the step equal to it as active, the rest as inactive. Navigation
|
|
39
|
+
* stays with the consumer - wire buttons or router links around it.
|
|
40
|
+
*/
|
|
41
|
+
function Stepper({
|
|
42
|
+
className,
|
|
43
|
+
value,
|
|
44
|
+
orientation = "horizontal",
|
|
45
|
+
...props
|
|
46
|
+
}: React.ComponentProps<"ol"> & {
|
|
47
|
+
value: number;
|
|
48
|
+
orientation?: StepperOrientation;
|
|
49
|
+
}) {
|
|
50
|
+
const contextValue = React.useMemo(
|
|
51
|
+
() => ({ value, orientation }),
|
|
52
|
+
[value, orientation],
|
|
53
|
+
);
|
|
54
|
+
return (
|
|
55
|
+
<StepperContext.Provider value={contextValue}>
|
|
56
|
+
<ol
|
|
57
|
+
data-slot="stepper"
|
|
58
|
+
data-orientation={orientation}
|
|
59
|
+
className={cn(
|
|
60
|
+
"group/stepper flex w-full data-[orientation=horizontal]:items-center data-[orientation=horizontal]:gap-2 data-[orientation=vertical]:flex-col",
|
|
61
|
+
className,
|
|
62
|
+
)}
|
|
63
|
+
{...props}
|
|
64
|
+
/>
|
|
65
|
+
</StepperContext.Provider>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function StepperItem({
|
|
70
|
+
className,
|
|
71
|
+
step,
|
|
72
|
+
...props
|
|
73
|
+
}: React.ComponentProps<"li"> & {
|
|
74
|
+
step: number;
|
|
75
|
+
}) {
|
|
76
|
+
const { value, orientation } = useStepperContext("StepperItem");
|
|
77
|
+
const state: StepperStepState =
|
|
78
|
+
step < value ? "completed" : step === value ? "active" : "inactive";
|
|
79
|
+
const contextValue = React.useMemo(() => ({ step, state }), [step, state]);
|
|
80
|
+
return (
|
|
81
|
+
<StepperItemContext.Provider value={contextValue}>
|
|
82
|
+
<li
|
|
83
|
+
data-slot="stepper-item"
|
|
84
|
+
data-state={state}
|
|
85
|
+
data-orientation={orientation}
|
|
86
|
+
aria-current={state === "active" ? "step" : undefined}
|
|
87
|
+
className={cn(
|
|
88
|
+
"group/stepper-item flex items-center gap-2 data-[orientation=vertical]:relative data-[orientation=vertical]:items-start data-[orientation=vertical]:pb-6 data-[orientation=vertical]:last:pb-0 data-[orientation=horizontal]:[&:not(:last-child)]:flex-1",
|
|
89
|
+
className,
|
|
90
|
+
)}
|
|
91
|
+
{...props}
|
|
92
|
+
/>
|
|
93
|
+
</StepperItemContext.Provider>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Step circle: renders the step number (or custom children) and swaps to a
|
|
99
|
+
* check mark once the step is completed.
|
|
100
|
+
*/
|
|
101
|
+
function StepperIndicator({
|
|
102
|
+
className,
|
|
103
|
+
children,
|
|
104
|
+
...props
|
|
105
|
+
}: React.ComponentProps<"span">) {
|
|
106
|
+
const { step, state } = useStepperItemContext("StepperIndicator");
|
|
107
|
+
return (
|
|
108
|
+
<span
|
|
109
|
+
data-slot="stepper-indicator"
|
|
110
|
+
className={cn(
|
|
111
|
+
"flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground tabular-nums transition-colors group-data-[state=active]/stepper-item:bg-primary group-data-[state=completed]/stepper-item:bg-primary group-data-[state=active]/stepper-item:text-primary-foreground group-data-[state=completed]/stepper-item:text-primary-foreground [&>svg]:size-3.5",
|
|
112
|
+
className,
|
|
113
|
+
)}
|
|
114
|
+
{...props}
|
|
115
|
+
>
|
|
116
|
+
{state === "completed" ? <CheckIcon aria-hidden /> : (children ?? step)}
|
|
117
|
+
</span>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function StepperTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
data-slot="stepper-title"
|
|
125
|
+
className={cn(
|
|
126
|
+
"text-sm leading-tight font-medium whitespace-nowrap group-data-[state=inactive]/stepper-item:text-muted-foreground",
|
|
127
|
+
className,
|
|
128
|
+
)}
|
|
129
|
+
{...props}
|
|
130
|
+
/>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function StepperDescription({
|
|
135
|
+
className,
|
|
136
|
+
...props
|
|
137
|
+
}: React.ComponentProps<"div">) {
|
|
138
|
+
return (
|
|
139
|
+
<div
|
|
140
|
+
data-slot="stepper-description"
|
|
141
|
+
className={cn("text-xs text-muted-foreground", className)}
|
|
142
|
+
{...props}
|
|
143
|
+
/>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Connecting line to the next step. Horizontal: a flexible rule filling the
|
|
149
|
+
* gap between items. Vertical: an absolute line dropping from the indicator,
|
|
150
|
+
* sized by the item's bottom padding.
|
|
151
|
+
*/
|
|
152
|
+
function StepperSeparator({
|
|
153
|
+
className,
|
|
154
|
+
...props
|
|
155
|
+
}: React.ComponentProps<"div">) {
|
|
156
|
+
const { orientation } = useStepperContext("StepperSeparator");
|
|
157
|
+
return (
|
|
158
|
+
<div
|
|
159
|
+
data-slot="stepper-separator"
|
|
160
|
+
data-orientation={orientation}
|
|
161
|
+
aria-hidden
|
|
162
|
+
className={cn(
|
|
163
|
+
"bg-border transition-colors group-data-[state=completed]/stepper-item:bg-primary",
|
|
164
|
+
"data-[orientation=horizontal]:h-px data-[orientation=horizontal]:min-w-4 data-[orientation=horizontal]:flex-1",
|
|
165
|
+
"data-[orientation=vertical]:absolute data-[orientation=vertical]:top-7 data-[orientation=vertical]:bottom-1 data-[orientation=vertical]:left-3 data-[orientation=vertical]:w-px data-[orientation=vertical]:-translate-x-1/2",
|
|
166
|
+
className,
|
|
167
|
+
)}
|
|
168
|
+
{...props}
|
|
169
|
+
/>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export {
|
|
174
|
+
Stepper,
|
|
175
|
+
StepperDescription,
|
|
176
|
+
StepperIndicator,
|
|
177
|
+
StepperItem,
|
|
178
|
+
StepperSeparator,
|
|
179
|
+
type StepperStepState,
|
|
180
|
+
StepperTitle,
|
|
181
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import { cn } from "#/lib/utils.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bottom-anchored in-page action bar for a mobile primary action — a sticky
|
|
6
|
+
* footer, NOT a dialog. It sticks to the bottom of the viewport with a
|
|
7
|
+
* top border and a background fill, and reserves extra bottom padding for the
|
|
8
|
+
* iOS home indicator via `env(safe-area-inset-bottom)`.
|
|
9
|
+
*
|
|
10
|
+
* It's shown on mobile and hidden from `md` up by default; pass
|
|
11
|
+
* `hideOnDesktop={false}` (or override via `className`) to keep it on every
|
|
12
|
+
* breakpoint. Children are laid out in a flex row, so multiple buttons sit
|
|
13
|
+
* side by side.
|
|
14
|
+
*/
|
|
15
|
+
function StickyActionBar({
|
|
16
|
+
className,
|
|
17
|
+
children,
|
|
18
|
+
hideOnDesktop = true,
|
|
19
|
+
...props
|
|
20
|
+
}: React.ComponentProps<"div"> & {
|
|
21
|
+
/** Hide the bar from the `md` breakpoint up (default `true`). */
|
|
22
|
+
hideOnDesktop?: boolean;
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
data-slot="sticky-action-bar"
|
|
27
|
+
className={cn(
|
|
28
|
+
"sticky inset-x-0 bottom-0 z-40 flex items-center gap-2 border-t bg-background px-4 pt-3",
|
|
29
|
+
// Extra bottom padding clears the iOS home indicator.
|
|
30
|
+
"pb-[calc(0.75rem+env(safe-area-inset-bottom))]",
|
|
31
|
+
"*:data-[slot=button]:flex-1",
|
|
32
|
+
hideOnDesktop && "md:hidden",
|
|
33
|
+
className,
|
|
34
|
+
)}
|
|
35
|
+
{...props}
|
|
36
|
+
>
|
|
37
|
+
{children}
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { StickyActionBar };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
|
|
2
|
+
|
|
3
|
+
import { cn } from "#/lib/utils.ts";
|
|
4
|
+
|
|
5
|
+
type SwitchProps = SwitchPrimitive.Root.Props & {
|
|
6
|
+
size?: "sm" | "default";
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function Switch({ className, size = "default", ...props }: SwitchProps) {
|
|
10
|
+
return (
|
|
11
|
+
<SwitchPrimitive.Root
|
|
12
|
+
data-slot="switch"
|
|
13
|
+
data-size={size}
|
|
14
|
+
className={cn(
|
|
15
|
+
// Track dimensions live on the spacing scale (default 32×18.4, sm
|
|
16
|
+
// 24×14); the thumb's translate math depends on these widths.
|
|
17
|
+
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 motion-reduce:transition-none dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:border-input data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
|
18
|
+
className,
|
|
19
|
+
)}
|
|
20
|
+
{...props}
|
|
21
|
+
>
|
|
22
|
+
<SwitchPrimitive.Thumb
|
|
23
|
+
data-slot="switch-thumb"
|
|
24
|
+
className="pointer-events-none block rounded-full bg-background transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] motion-reduce:transition-none dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
|
25
|
+
/>
|
|
26
|
+
</SwitchPrimitive.Root>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export { Switch, type SwitchProps };
|