@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,132 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "#/lib/utils.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wraps the `<table>` in a scroll container so wide tables pan horizontally
|
|
7
|
+
* instead of breaking the page layout. Style the container itself (max-height,
|
|
8
|
+
* sticky headers, borders) through `containerClassName`.
|
|
9
|
+
*/
|
|
10
|
+
function Table({
|
|
11
|
+
className,
|
|
12
|
+
containerClassName,
|
|
13
|
+
...props
|
|
14
|
+
}: React.ComponentProps<"table"> & { containerClassName?: string }) {
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
data-slot="table-container"
|
|
18
|
+
className={cn("relative w-full overflow-x-auto", containerClassName)}
|
|
19
|
+
>
|
|
20
|
+
<table
|
|
21
|
+
data-slot="table"
|
|
22
|
+
className={cn("w-full caption-bottom text-sm", className)}
|
|
23
|
+
{...props}
|
|
24
|
+
/>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
|
30
|
+
return (
|
|
31
|
+
<thead
|
|
32
|
+
data-slot="table-header"
|
|
33
|
+
className={cn("[&_tr]:border-b", className)}
|
|
34
|
+
{...props}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
|
40
|
+
return (
|
|
41
|
+
<tbody
|
|
42
|
+
data-slot="table-body"
|
|
43
|
+
className={cn("[&_tr:last-child]:border-0", className)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
|
50
|
+
return (
|
|
51
|
+
<tfoot
|
|
52
|
+
data-slot="table-footer"
|
|
53
|
+
className={cn(
|
|
54
|
+
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
|
55
|
+
className,
|
|
56
|
+
)}
|
|
57
|
+
{...props}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Hover highlighting only applies to body rows, not header/footer rows. Stamp
|
|
64
|
+
* `data-selected` on a row to mark it as selected.
|
|
65
|
+
*/
|
|
66
|
+
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|
67
|
+
return (
|
|
68
|
+
<tr
|
|
69
|
+
data-slot="table-row"
|
|
70
|
+
className={cn(
|
|
71
|
+
"border-b transition-colors has-aria-expanded:bg-muted/50 data-selected:bg-muted [tbody_&]:hover:bg-muted/50",
|
|
72
|
+
className,
|
|
73
|
+
)}
|
|
74
|
+
{...props}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|
80
|
+
return (
|
|
81
|
+
<th
|
|
82
|
+
data-slot="table-head"
|
|
83
|
+
className={cn(
|
|
84
|
+
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
|
85
|
+
className,
|
|
86
|
+
)}
|
|
87
|
+
{...props}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Cells don't wrap by default - long content pans the table horizontally via
|
|
94
|
+
* the scroll container. Pass `whitespace-normal` for columns that should wrap
|
|
95
|
+
* (e.g. descriptions).
|
|
96
|
+
*/
|
|
97
|
+
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|
98
|
+
return (
|
|
99
|
+
<td
|
|
100
|
+
data-slot="table-cell"
|
|
101
|
+
className={cn(
|
|
102
|
+
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
|
103
|
+
className,
|
|
104
|
+
)}
|
|
105
|
+
{...props}
|
|
106
|
+
/>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function TableCaption({
|
|
111
|
+
className,
|
|
112
|
+
...props
|
|
113
|
+
}: React.ComponentProps<"caption">) {
|
|
114
|
+
return (
|
|
115
|
+
<caption
|
|
116
|
+
data-slot="table-caption"
|
|
117
|
+
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
|
118
|
+
{...props}
|
|
119
|
+
/>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export {
|
|
124
|
+
Table,
|
|
125
|
+
TableBody,
|
|
126
|
+
TableCaption,
|
|
127
|
+
TableCell,
|
|
128
|
+
TableFooter,
|
|
129
|
+
TableHead,
|
|
130
|
+
TableHeader,
|
|
131
|
+
TableRow,
|
|
132
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
|
|
2
|
+
import { cva, type VariantProps } from "#/lib/cva.ts";
|
|
3
|
+
|
|
4
|
+
import { cn } from "#/lib/utils.ts";
|
|
5
|
+
|
|
6
|
+
function Tabs({ className, ...props }: TabsPrimitive.Root.Props) {
|
|
7
|
+
return (
|
|
8
|
+
<TabsPrimitive.Root
|
|
9
|
+
data-slot="tabs"
|
|
10
|
+
className={cn(
|
|
11
|
+
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
|
12
|
+
className,
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const tabsListVariants = cva({
|
|
20
|
+
base: "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
|
21
|
+
variants: {
|
|
22
|
+
variant: {
|
|
23
|
+
default: "bg-muted",
|
|
24
|
+
line: "gap-1 bg-transparent",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
defaultVariants: {
|
|
28
|
+
variant: "default",
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function TabsList({
|
|
33
|
+
className,
|
|
34
|
+
variant = "default",
|
|
35
|
+
...props
|
|
36
|
+
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
|
37
|
+
return (
|
|
38
|
+
<TabsPrimitive.List
|
|
39
|
+
data-slot="tabs-list"
|
|
40
|
+
data-variant={variant}
|
|
41
|
+
className={cn(tabsListVariants({ variant }), className)}
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
|
48
|
+
return (
|
|
49
|
+
<TabsPrimitive.Tab
|
|
50
|
+
data-slot="tabs-trigger"
|
|
51
|
+
className={cn(
|
|
52
|
+
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 data-disabled:pointer-events-none data-disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
53
|
+
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
|
54
|
+
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
|
55
|
+
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
|
56
|
+
className,
|
|
57
|
+
)}
|
|
58
|
+
{...props}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
|
64
|
+
return (
|
|
65
|
+
<TabsPrimitive.Panel
|
|
66
|
+
data-slot="tabs-content"
|
|
67
|
+
className={cn(
|
|
68
|
+
// Inactive panels are unmounted, so the fade runs on each switch.
|
|
69
|
+
"flex-1 animate-in text-sm outline-none fade-in-0 motion-reduce:animate-none!",
|
|
70
|
+
className,
|
|
71
|
+
)}
|
|
72
|
+
{...props}
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "#/lib/utils.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Auto-grows with content (`field-sizing-content`) between `min-h-16` and
|
|
7
|
+
* `max-h-64`, then scrolls. Note that content sizing makes the `rows`
|
|
8
|
+
* attribute inert - size a taller or shorter field with `min-h-*`/`max-h-*`
|
|
9
|
+
* through `className` instead.
|
|
10
|
+
*/
|
|
11
|
+
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|
12
|
+
return (
|
|
13
|
+
<textarea
|
|
14
|
+
data-slot="textarea"
|
|
15
|
+
className={cn(
|
|
16
|
+
"field-sizing-content max-h-64 min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-[color,background-color,border-color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:scheme-dark dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
17
|
+
className,
|
|
18
|
+
)}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { Textarea };
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { ClockIcon } from "@phosphor-icons/react";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { Button, type ButtonProps } from "#/components/ui/button.tsx";
|
|
4
|
+
import { DatePickerTrigger } from "#/components/ui/date-picker.tsx";
|
|
5
|
+
import { Popover, PopoverContent } from "#/components/ui/popover.tsx";
|
|
6
|
+
import { usePickerState } from "#/hooks/use-picker-state.ts";
|
|
7
|
+
import {
|
|
8
|
+
formatMinutesLabel,
|
|
9
|
+
minutesToTimeValue,
|
|
10
|
+
parseTimeToMinutes,
|
|
11
|
+
} from "#/lib/time-math.ts";
|
|
12
|
+
import { cn } from "#/lib/utils.ts";
|
|
13
|
+
|
|
14
|
+
function formatTimeLabel(
|
|
15
|
+
time: string,
|
|
16
|
+
locale: string | undefined,
|
|
17
|
+
options: Intl.DateTimeFormatOptions | undefined,
|
|
18
|
+
): string {
|
|
19
|
+
const totalMinutes = parseTimeToMinutes(time);
|
|
20
|
+
// An unparseable value is the caller's literal string; show it as-is.
|
|
21
|
+
if (totalMinutes === null) return time;
|
|
22
|
+
return formatMinutesLabel(totalMinutes, locale, options);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function timeRangeBoundsInMinutes(
|
|
26
|
+
min: string,
|
|
27
|
+
max: string,
|
|
28
|
+
): { first: number; last: number } {
|
|
29
|
+
return {
|
|
30
|
+
first: parseTimeToMinutes(min) ?? 0,
|
|
31
|
+
last: parseTimeToMinutes(max) ?? 23 * 60 + 59,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function timeOptionValues(min: string, max: string, step: number): string[] {
|
|
36
|
+
const stepMinutes = step > 0 ? step : 30;
|
|
37
|
+
const { first, last } = timeRangeBoundsInMinutes(min, max);
|
|
38
|
+
const times: string[] = [];
|
|
39
|
+
for (let minutes = first; minutes <= last; minutes += stepMinutes) {
|
|
40
|
+
times.push(minutesToTimeValue(minutes));
|
|
41
|
+
}
|
|
42
|
+
return times;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The hidden form input serializing the selection as HH:mm (none without a name). */
|
|
46
|
+
function HiddenTimeInput({
|
|
47
|
+
name,
|
|
48
|
+
value,
|
|
49
|
+
}: {
|
|
50
|
+
name: string | undefined;
|
|
51
|
+
value: string | null | undefined;
|
|
52
|
+
}) {
|
|
53
|
+
if (!name) return null;
|
|
54
|
+
return <input type="hidden" name={name} value={value ?? ""} />;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function TimePickerOption({
|
|
58
|
+
time,
|
|
59
|
+
label,
|
|
60
|
+
selected,
|
|
61
|
+
selectedOptionRef,
|
|
62
|
+
onSelect,
|
|
63
|
+
}: {
|
|
64
|
+
time: string;
|
|
65
|
+
label: string;
|
|
66
|
+
selected: boolean;
|
|
67
|
+
selectedOptionRef: React.Ref<HTMLButtonElement>;
|
|
68
|
+
onSelect: (time: string) => void;
|
|
69
|
+
}) {
|
|
70
|
+
return (
|
|
71
|
+
<Button
|
|
72
|
+
role="option"
|
|
73
|
+
aria-selected={selected}
|
|
74
|
+
data-selected={selected || undefined}
|
|
75
|
+
variant="ghost"
|
|
76
|
+
size="sm"
|
|
77
|
+
className="shrink-0 justify-center font-normal data-selected:bg-primary data-selected:text-primary-foreground data-selected:hover:bg-primary data-selected:hover:text-primary-foreground"
|
|
78
|
+
ref={selected ? selectedOptionRef : undefined}
|
|
79
|
+
onClick={() => onSelect(time)}
|
|
80
|
+
>
|
|
81
|
+
{label}
|
|
82
|
+
</Button>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function TimePicker(props: {
|
|
87
|
+
/** Controlled "HH:mm" value; pass `null` for a controlled empty selection. */
|
|
88
|
+
value?: string | null;
|
|
89
|
+
defaultValue?: string;
|
|
90
|
+
onValueChange?: (time: string | null) => void;
|
|
91
|
+
placeholder?: string;
|
|
92
|
+
/**
|
|
93
|
+
* `Intl.DateTimeFormat` options for the labels. Defaults to a short localized
|
|
94
|
+
* time (`{ hour: "numeric", minute: "2-digit" }`, e.g. "2:30 PM" / "14:30").
|
|
95
|
+
*/
|
|
96
|
+
formatOptions?: Intl.DateTimeFormatOptions;
|
|
97
|
+
/** BCP-47 locale (e.g. "fr-FR"), applied to the trigger and option labels. */
|
|
98
|
+
locale?: string;
|
|
99
|
+
disabled?: boolean;
|
|
100
|
+
/** Minutes between two options. Defaults to 30. */
|
|
101
|
+
step?: number;
|
|
102
|
+
/** First selectable time, "HH:mm" inclusive. */
|
|
103
|
+
min?: string;
|
|
104
|
+
/** Last selectable time, "HH:mm" inclusive. */
|
|
105
|
+
max?: string;
|
|
106
|
+
/** Name for the hidden form input; the value is serialized as HH:mm. */
|
|
107
|
+
name?: string;
|
|
108
|
+
id?: string;
|
|
109
|
+
className?: string;
|
|
110
|
+
"aria-invalid"?: React.AriaAttributes["aria-invalid"];
|
|
111
|
+
"aria-label"?: string;
|
|
112
|
+
variant?: ButtonProps["variant"];
|
|
113
|
+
defaultOpen?: boolean;
|
|
114
|
+
open?: boolean;
|
|
115
|
+
onOpenChange?: (open: boolean) => void;
|
|
116
|
+
}) {
|
|
117
|
+
const {
|
|
118
|
+
value: controlledValue,
|
|
119
|
+
defaultValue,
|
|
120
|
+
onValueChange,
|
|
121
|
+
placeholder = "Pick a time",
|
|
122
|
+
formatOptions,
|
|
123
|
+
locale,
|
|
124
|
+
disabled = false,
|
|
125
|
+
step = 30,
|
|
126
|
+
min = "00:00",
|
|
127
|
+
max = "23:59",
|
|
128
|
+
name,
|
|
129
|
+
id,
|
|
130
|
+
className,
|
|
131
|
+
"aria-invalid": ariaInvalid,
|
|
132
|
+
"aria-label": ariaLabel,
|
|
133
|
+
variant,
|
|
134
|
+
defaultOpen,
|
|
135
|
+
open: controlledOpen,
|
|
136
|
+
onOpenChange,
|
|
137
|
+
} = props;
|
|
138
|
+
|
|
139
|
+
const { isControlled, value, setUncontrolledValue, open, setOpen } =
|
|
140
|
+
usePickerState<string>({
|
|
141
|
+
value: controlledValue,
|
|
142
|
+
defaultValue,
|
|
143
|
+
open: controlledOpen,
|
|
144
|
+
defaultOpen,
|
|
145
|
+
onOpenChange,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const options = React.useMemo(
|
|
149
|
+
() => timeOptionValues(min, max, step),
|
|
150
|
+
[step, min, max],
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const selectedMinutes = parseTimeToMinutes(value);
|
|
154
|
+
|
|
155
|
+
const handleSelect = (time: string) => {
|
|
156
|
+
if (!isControlled) setUncontrolledValue(time);
|
|
157
|
+
onValueChange?.(time);
|
|
158
|
+
setOpen(false);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// The selected option receives the popup's initial focus (otherwise Base UI
|
|
162
|
+
// focuses the first option, scrolling the list back to the top) and is then
|
|
163
|
+
// centered once the popup has been positioned — the ref fires too early, so
|
|
164
|
+
// defer by a frame. (Optional call: jsdom has no scrollIntoView.)
|
|
165
|
+
const selectedOptionRef = React.useRef<HTMLButtonElement | null>(null);
|
|
166
|
+
const setSelectedOption = React.useCallback(
|
|
167
|
+
(node: HTMLButtonElement | null) => {
|
|
168
|
+
selectedOptionRef.current = node;
|
|
169
|
+
if (!node) return;
|
|
170
|
+
requestAnimationFrame(() => node.scrollIntoView?.({ block: "center" }));
|
|
171
|
+
},
|
|
172
|
+
[],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
177
|
+
<DatePickerTrigger
|
|
178
|
+
slot="time-picker-trigger"
|
|
179
|
+
icon={<ClockIcon className="size-4 shrink-0 text-muted-foreground" />}
|
|
180
|
+
id={id}
|
|
181
|
+
className={cn("min-w-32", className)}
|
|
182
|
+
disabled={disabled}
|
|
183
|
+
empty={!value}
|
|
184
|
+
aria-invalid={ariaInvalid}
|
|
185
|
+
aria-label={ariaLabel}
|
|
186
|
+
variant={variant}
|
|
187
|
+
>
|
|
188
|
+
{value ? formatTimeLabel(value, locale, formatOptions) : placeholder}
|
|
189
|
+
</DatePickerTrigger>
|
|
190
|
+
<HiddenTimeInput name={name} value={value} />
|
|
191
|
+
<PopoverContent
|
|
192
|
+
data-slot="time-picker-content"
|
|
193
|
+
className="w-auto min-w-(--anchor-width) p-1"
|
|
194
|
+
align="start"
|
|
195
|
+
initialFocus={selectedMinutes !== null ? selectedOptionRef : undefined}
|
|
196
|
+
>
|
|
197
|
+
<div
|
|
198
|
+
role="listbox"
|
|
199
|
+
aria-label={ariaLabel ?? placeholder}
|
|
200
|
+
className="flex max-h-64 flex-col gap-0.5 overflow-y-auto overscroll-contain"
|
|
201
|
+
>
|
|
202
|
+
{options.map((time) => (
|
|
203
|
+
<TimePickerOption
|
|
204
|
+
key={time}
|
|
205
|
+
time={time}
|
|
206
|
+
label={formatTimeLabel(time, locale, formatOptions)}
|
|
207
|
+
selected={
|
|
208
|
+
selectedMinutes !== null &&
|
|
209
|
+
parseTimeToMinutes(time) === selectedMinutes
|
|
210
|
+
}
|
|
211
|
+
selectedOptionRef={setSelectedOption}
|
|
212
|
+
onSelect={handleSelect}
|
|
213
|
+
/>
|
|
214
|
+
))}
|
|
215
|
+
</div>
|
|
216
|
+
</PopoverContent>
|
|
217
|
+
</Popover>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export { TimePicker };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
|
|
2
|
+
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { toggleVariants } from "#/components/ui/toggle.tsx";
|
|
5
|
+
import type { VariantProps } from "#/lib/cva.ts";
|
|
6
|
+
import { cn } from "#/lib/utils.ts";
|
|
7
|
+
|
|
8
|
+
const ToggleGroupContext = React.createContext<
|
|
9
|
+
VariantProps<typeof toggleVariants> & {
|
|
10
|
+
spacing?: number;
|
|
11
|
+
orientation?: "horizontal" | "vertical";
|
|
12
|
+
}
|
|
13
|
+
>({
|
|
14
|
+
size: "default",
|
|
15
|
+
variant: "default",
|
|
16
|
+
spacing: 0,
|
|
17
|
+
orientation: "horizontal",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function ToggleGroup({
|
|
21
|
+
className,
|
|
22
|
+
variant,
|
|
23
|
+
size,
|
|
24
|
+
spacing = 0,
|
|
25
|
+
orientation = "horizontal",
|
|
26
|
+
children,
|
|
27
|
+
...props
|
|
28
|
+
}: ToggleGroupPrimitive.Props &
|
|
29
|
+
VariantProps<typeof toggleVariants> & {
|
|
30
|
+
spacing?: number;
|
|
31
|
+
orientation?: "horizontal" | "vertical";
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<ToggleGroupPrimitive
|
|
35
|
+
data-slot="toggle-group"
|
|
36
|
+
data-variant={variant}
|
|
37
|
+
data-size={size}
|
|
38
|
+
data-spacing={spacing}
|
|
39
|
+
orientation={orientation}
|
|
40
|
+
style={{ "--gap": spacing } as React.CSSProperties}
|
|
41
|
+
className={cn(
|
|
42
|
+
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch",
|
|
43
|
+
className,
|
|
44
|
+
)}
|
|
45
|
+
{...props}
|
|
46
|
+
>
|
|
47
|
+
<ToggleGroupContext.Provider
|
|
48
|
+
value={{ variant, size, spacing, orientation }}
|
|
49
|
+
>
|
|
50
|
+
{children}
|
|
51
|
+
</ToggleGroupContext.Provider>
|
|
52
|
+
</ToggleGroupPrimitive>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ToggleGroupItem({
|
|
57
|
+
className,
|
|
58
|
+
children,
|
|
59
|
+
variant = "default",
|
|
60
|
+
size = "default",
|
|
61
|
+
...props
|
|
62
|
+
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
|
63
|
+
const context = React.useContext(ToggleGroupContext);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<TogglePrimitive
|
|
67
|
+
data-slot="toggle-group-item"
|
|
68
|
+
data-variant={context.variant || variant}
|
|
69
|
+
data-size={context.size || size}
|
|
70
|
+
data-spacing={context.spacing}
|
|
71
|
+
className={cn(
|
|
72
|
+
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-[orientation=horizontal]/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-[orientation=vertical]/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-[orientation=horizontal]/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-[orientation=vertical]/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-[orientation=horizontal]/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-[orientation=vertical]/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-[orientation=horizontal]/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-[orientation=vertical]/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
|
73
|
+
toggleVariants({
|
|
74
|
+
variant: context.variant || variant,
|
|
75
|
+
size: context.size || size,
|
|
76
|
+
}),
|
|
77
|
+
className,
|
|
78
|
+
)}
|
|
79
|
+
{...props}
|
|
80
|
+
>
|
|
81
|
+
{children}
|
|
82
|
+
</TogglePrimitive>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export { ToggleGroup, ToggleGroupItem };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
|
|
2
|
+
import { cva, type VariantProps } from "#/lib/cva.ts";
|
|
3
|
+
|
|
4
|
+
import { cn } from "#/lib/utils.ts";
|
|
5
|
+
|
|
6
|
+
const toggleVariants = cva({
|
|
7
|
+
base: "group/toggle inline-flex items-center justify-center gap-1 rounded-lg border border-transparent text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-pressed:bg-muted data-pressed:inset-shadow-xs dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "bg-transparent",
|
|
11
|
+
outline: "border-input bg-transparent hover:bg-muted",
|
|
12
|
+
},
|
|
13
|
+
size: {
|
|
14
|
+
default:
|
|
15
|
+
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
16
|
+
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
17
|
+
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
defaultVariants: {
|
|
21
|
+
variant: "default",
|
|
22
|
+
size: "default",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
type ToggleProps = TogglePrimitive.Props & VariantProps<typeof toggleVariants>;
|
|
27
|
+
|
|
28
|
+
function Toggle({
|
|
29
|
+
className,
|
|
30
|
+
variant = "default",
|
|
31
|
+
size = "default",
|
|
32
|
+
...props
|
|
33
|
+
}: ToggleProps) {
|
|
34
|
+
return (
|
|
35
|
+
<TogglePrimitive
|
|
36
|
+
data-slot="toggle"
|
|
37
|
+
className={cn(toggleVariants({ variant, size }), className)}
|
|
38
|
+
{...props}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { Toggle, type ToggleProps, toggleVariants };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
|
|
2
|
+
|
|
3
|
+
import { cn } from "#/lib/utils.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Kit opinion: tooltips open instantly (`delay={0}`) instead of Base UI's
|
|
7
|
+
* default delay. `Tooltip` embeds its own provider, which shadows any outer
|
|
8
|
+
* one - to slow a tooltip down, set `delay` on that `Tooltip` directly.
|
|
9
|
+
*/
|
|
10
|
+
function TooltipProvider({
|
|
11
|
+
delay = 0,
|
|
12
|
+
...props
|
|
13
|
+
}: TooltipPrimitive.Provider.Props) {
|
|
14
|
+
return (
|
|
15
|
+
<TooltipPrimitive.Provider
|
|
16
|
+
data-slot="tooltip-provider"
|
|
17
|
+
delay={delay}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function Tooltip({
|
|
24
|
+
delay,
|
|
25
|
+
...props
|
|
26
|
+
}: TooltipPrimitive.Root.Props &
|
|
27
|
+
Pick<TooltipPrimitive.Provider.Props, "delay">) {
|
|
28
|
+
return (
|
|
29
|
+
<TooltipProvider delay={delay}>
|
|
30
|
+
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
|
31
|
+
</TooltipProvider>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
|
36
|
+
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function TooltipContent({
|
|
40
|
+
className,
|
|
41
|
+
side = "top",
|
|
42
|
+
sideOffset = 4,
|
|
43
|
+
align = "center",
|
|
44
|
+
alignOffset = 0,
|
|
45
|
+
collisionPadding,
|
|
46
|
+
children,
|
|
47
|
+
...props
|
|
48
|
+
}: TooltipPrimitive.Popup.Props &
|
|
49
|
+
Pick<
|
|
50
|
+
TooltipPrimitive.Positioner.Props,
|
|
51
|
+
"align" | "alignOffset" | "collisionPadding" | "side" | "sideOffset"
|
|
52
|
+
>) {
|
|
53
|
+
return (
|
|
54
|
+
<TooltipPrimitive.Portal>
|
|
55
|
+
<TooltipPrimitive.Positioner
|
|
56
|
+
align={align}
|
|
57
|
+
alignOffset={alignOffset}
|
|
58
|
+
collisionPadding={collisionPadding}
|
|
59
|
+
side={side}
|
|
60
|
+
sideOffset={sideOffset}
|
|
61
|
+
className="isolate z-50"
|
|
62
|
+
>
|
|
63
|
+
<TooltipPrimitive.Popup
|
|
64
|
+
data-slot="tooltip-content"
|
|
65
|
+
className={cn(
|
|
66
|
+
"inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:rounded-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:animate-none!",
|
|
67
|
+
className,
|
|
68
|
+
)}
|
|
69
|
+
{...props}
|
|
70
|
+
>
|
|
71
|
+
{children}
|
|
72
|
+
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
|
73
|
+
</TooltipPrimitive.Popup>
|
|
74
|
+
</TooltipPrimitive.Positioner>
|
|
75
|
+
</TooltipPrimitive.Portal>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|