@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,166 @@
1
+ import { cva, type VariantProps } from "#/lib/cva.ts";
2
+
3
+ /**
4
+ * The single canonical badge cva - base, variants, palette and sizes - shared
5
+ * by `Badge` and `Chip`. Like `button-variants.ts`/`menu-variants.ts`, the
6
+ * recipe lives in a React-free module so it stays importable from non-React
7
+ * surfaces and cross-component without pulling the component in.
8
+ */
9
+ export const badgeVariants = cva({
10
+ base: "group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden rounded-4xl border border-transparent font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg:not([class*='size-'])]:size-3",
11
+ variants: {
12
+ // Solid variants only hover when rendered as an anchor ([a]:hover) -
13
+ // static chips should stay inert - while `ghost` and `link` exist for
14
+ // interactive use and hover unconditionally.
15
+ variant: {
16
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
17
+ secondary:
18
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
19
+ provider: "bg-provider text-provider-foreground [a]:hover:bg-provider/90",
20
+ organization:
21
+ "bg-organization text-organization-foreground [a]:hover:bg-organization/90",
22
+ destructive:
23
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
24
+ outline:
25
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
26
+ ghost:
27
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
28
+ link: "text-primary underline-offset-4 hover:underline",
29
+ },
30
+ // Catalog palette. Declared after `variant` so its bg/text win conflicts
31
+ // via tailwind-merge when both a `variant` and a `color` are passed. Keep
32
+ // in sync with `BadgeColorEnum` in the domain and the `--badge-*` tokens.
33
+ color: {
34
+ slate:
35
+ "bg-badge-slate text-badge-slate-foreground [a]:hover:bg-badge-slate/90",
36
+ gray: "bg-badge-gray text-badge-gray-foreground [a]:hover:bg-badge-gray/90",
37
+ zinc: "bg-badge-zinc text-badge-zinc-foreground [a]:hover:bg-badge-zinc/90",
38
+ red: "bg-badge-red text-badge-red-foreground [a]:hover:bg-badge-red/90",
39
+ orange:
40
+ "bg-badge-orange text-badge-orange-foreground [a]:hover:bg-badge-orange/90",
41
+ amber:
42
+ "bg-badge-amber text-badge-amber-foreground [a]:hover:bg-badge-amber/90",
43
+ yellow:
44
+ "bg-badge-yellow text-badge-yellow-foreground [a]:hover:bg-badge-yellow/90",
45
+ lime: "bg-badge-lime text-badge-lime-foreground [a]:hover:bg-badge-lime/90",
46
+ green:
47
+ "bg-badge-green text-badge-green-foreground [a]:hover:bg-badge-green/90",
48
+ emerald:
49
+ "bg-badge-emerald text-badge-emerald-foreground [a]:hover:bg-badge-emerald/90",
50
+ teal: "bg-badge-teal text-badge-teal-foreground [a]:hover:bg-badge-teal/90",
51
+ cyan: "bg-badge-cyan text-badge-cyan-foreground [a]:hover:bg-badge-cyan/90",
52
+ sky: "bg-badge-sky text-badge-sky-foreground [a]:hover:bg-badge-sky/90",
53
+ blue: "bg-badge-blue text-badge-blue-foreground [a]:hover:bg-badge-blue/90",
54
+ indigo:
55
+ "bg-badge-indigo text-badge-indigo-foreground [a]:hover:bg-badge-indigo/90",
56
+ violet:
57
+ "bg-badge-violet text-badge-violet-foreground [a]:hover:bg-badge-violet/90",
58
+ purple:
59
+ "bg-badge-purple text-badge-purple-foreground [a]:hover:bg-badge-purple/90",
60
+ fuchsia:
61
+ "bg-badge-fuchsia text-badge-fuchsia-foreground [a]:hover:bg-badge-fuchsia/90",
62
+ pink: "bg-badge-pink text-badge-pink-foreground [a]:hover:bg-badge-pink/90",
63
+ rose: "bg-badge-rose text-badge-rose-foreground [a]:hover:bg-badge-rose/90",
64
+ },
65
+ size: {
66
+ default:
67
+ "h-5 gap-1 px-2 py-0.5 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
68
+ sm: "h-4 gap-0.5 px-1.5 py-0 text-[10px] has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 [&>svg:not([class*='size-'])]:size-2.5",
69
+ },
70
+ },
71
+ });
72
+
73
+ export type BadgeVariants = VariantProps<typeof badgeVariants>;
74
+
75
+ /** The catalog palette names - keep in sync with the `color` variant above. */
76
+ export const badgeColors = [
77
+ "slate",
78
+ "gray",
79
+ "zinc",
80
+ "red",
81
+ "orange",
82
+ "amber",
83
+ "yellow",
84
+ "lime",
85
+ "green",
86
+ "emerald",
87
+ "teal",
88
+ "cyan",
89
+ "sky",
90
+ "blue",
91
+ "indigo",
92
+ "violet",
93
+ "purple",
94
+ "fuchsia",
95
+ "pink",
96
+ "rose",
97
+ ] as const satisfies readonly NonNullable<BadgeVariants["color"]>[];
98
+
99
+ export type BadgeColor = (typeof badgeColors)[number];
100
+
101
+ /**
102
+ * Static lookup for the palette background utilities. Consumers (e.g. the
103
+ * color picker) must use this instead of composing `bg-badge-${name}` at
104
+ * runtime, which would silently break if these literals stopped being
105
+ * scanned by Tailwind.
106
+ */
107
+ export const badgeColorBackgroundClass: Record<BadgeColor, string> = {
108
+ slate: "bg-badge-slate",
109
+ gray: "bg-badge-gray",
110
+ zinc: "bg-badge-zinc",
111
+ red: "bg-badge-red",
112
+ orange: "bg-badge-orange",
113
+ amber: "bg-badge-amber",
114
+ yellow: "bg-badge-yellow",
115
+ lime: "bg-badge-lime",
116
+ green: "bg-badge-green",
117
+ emerald: "bg-badge-emerald",
118
+ teal: "bg-badge-teal",
119
+ cyan: "bg-badge-cyan",
120
+ sky: "bg-badge-sky",
121
+ blue: "bg-badge-blue",
122
+ indigo: "bg-badge-indigo",
123
+ violet: "bg-badge-violet",
124
+ purple: "bg-badge-purple",
125
+ fuchsia: "bg-badge-fuchsia",
126
+ pink: "bg-badge-pink",
127
+ rose: "bg-badge-rose",
128
+ };
129
+
130
+ /** Static lookup for the contrast-aware palette foreground utilities. */
131
+ export const badgeColorForegroundClass: Record<BadgeColor, string> = {
132
+ slate: "text-badge-slate-foreground",
133
+ gray: "text-badge-gray-foreground",
134
+ zinc: "text-badge-zinc-foreground",
135
+ red: "text-badge-red-foreground",
136
+ orange: "text-badge-orange-foreground",
137
+ amber: "text-badge-amber-foreground",
138
+ yellow: "text-badge-yellow-foreground",
139
+ lime: "text-badge-lime-foreground",
140
+ green: "text-badge-green-foreground",
141
+ emerald: "text-badge-emerald-foreground",
142
+ teal: "text-badge-teal-foreground",
143
+ cyan: "text-badge-cyan-foreground",
144
+ sky: "text-badge-sky-foreground",
145
+ blue: "text-badge-blue-foreground",
146
+ indigo: "text-badge-indigo-foreground",
147
+ violet: "text-badge-violet-foreground",
148
+ purple: "text-badge-purple-foreground",
149
+ fuchsia: "text-badge-fuchsia-foreground",
150
+ pink: "text-badge-pink-foreground",
151
+ rose: "text-badge-rose-foreground",
152
+ };
153
+
154
+ /** The semantic variant names - keep in sync with the `variant` axis above. */
155
+ export const badgeVariantOptions = [
156
+ "default",
157
+ "secondary",
158
+ "provider",
159
+ "organization",
160
+ "destructive",
161
+ "outline",
162
+ "ghost",
163
+ "link",
164
+ ] as const satisfies readonly NonNullable<BadgeVariants["variant"]>[];
165
+
166
+ export type BadgeVariant = (typeof badgeVariantOptions)[number];
@@ -0,0 +1,45 @@
1
+ import { mergeProps } from "@base-ui/react/merge-props";
2
+ import { useRender } from "@base-ui/react/use-render";
3
+ import {
4
+ type BadgeVariants,
5
+ badgeVariants,
6
+ } from "#/components/ui/badge-variants.ts";
7
+ import { cn } from "#/lib/utils.ts";
8
+
9
+ function Badge({
10
+ className,
11
+ variant = "default",
12
+ color,
13
+ size = "default",
14
+ render,
15
+ ...props
16
+ }: useRender.ComponentProps<"span"> & BadgeVariants) {
17
+ return useRender({
18
+ defaultTagName: "span",
19
+ props: mergeProps<"span">(
20
+ {
21
+ className: cn(badgeVariants({ variant, color, size }), className),
22
+ },
23
+ props,
24
+ ),
25
+ render,
26
+ state: {
27
+ slot: "badge",
28
+ variant,
29
+ color,
30
+ size,
31
+ },
32
+ });
33
+ }
34
+
35
+ export {
36
+ type BadgeColor,
37
+ type BadgeVariant,
38
+ type BadgeVariants,
39
+ badgeColorBackgroundClass,
40
+ badgeColorForegroundClass,
41
+ badgeColors,
42
+ badgeVariantOptions,
43
+ badgeVariants,
44
+ } from "#/components/ui/badge-variants.ts";
45
+ export { Badge };
@@ -0,0 +1,84 @@
1
+ import { XIcon } from "@phosphor-icons/react";
2
+ import type * as React from "react";
3
+ import { cva, type VariantProps } from "#/lib/cva.ts";
4
+
5
+ import { cn } from "#/lib/utils.ts";
6
+
7
+ const bannerVariants = cva({
8
+ base: "group/banner relative flex w-full items-center gap-3 px-4 py-2.5 text-sm has-data-[slot=banner-close]:pr-12 md:px-6 [&>svg]:shrink-0 [&>svg:not([class*='size-'])]:size-4",
9
+ variants: {
10
+ variant: {
11
+ default: "bg-primary text-primary-foreground",
12
+ muted: "bg-muted text-foreground",
13
+ success: "bg-success text-success-foreground",
14
+ warning: "bg-warning text-warning-foreground",
15
+ destructive: "bg-destructive text-destructive-foreground",
16
+ },
17
+ },
18
+ defaultVariants: {
19
+ variant: "default",
20
+ },
21
+ });
22
+
23
+ /**
24
+ * Page-level announcement bar spanning the full width of its container
25
+ * (release notes, maintenance windows, billing reminders). Unlike `Alert`,
26
+ * which sits inline within the content flow, a Banner is pinned at the top of
27
+ * a page or layout and uses solid semantic backgrounds.
28
+ */
29
+ function Banner({
30
+ className,
31
+ variant,
32
+ ...props
33
+ }: React.ComponentProps<"div"> & VariantProps<typeof bannerVariants>) {
34
+ return (
35
+ <div
36
+ data-slot="banner"
37
+ role="status"
38
+ className={cn(bannerVariants({ variant }), className)}
39
+ {...props}
40
+ />
41
+ );
42
+ }
43
+
44
+ function BannerTitle({ className, ...props }: React.ComponentProps<"div">) {
45
+ return (
46
+ <div
47
+ data-slot="banner-title"
48
+ className={cn(
49
+ "flex-1 font-medium text-balance [&_a]:underline [&_a]:underline-offset-4",
50
+ className,
51
+ )}
52
+ {...props}
53
+ />
54
+ );
55
+ }
56
+
57
+ function BannerAction({ className, ...props }: React.ComponentProps<"div">) {
58
+ return (
59
+ <div
60
+ data-slot="banner-action"
61
+ className={cn("flex shrink-0 items-center gap-2", className)}
62
+ {...props}
63
+ />
64
+ );
65
+ }
66
+
67
+ function BannerClose({ className, ...props }: React.ComponentProps<"button">) {
68
+ return (
69
+ <button
70
+ type="button"
71
+ data-slot="banner-close"
72
+ aria-label="Dismiss"
73
+ className={cn(
74
+ "absolute top-1/2 right-3 inline-flex size-6 shrink-0 -translate-y-1/2 cursor-pointer items-center justify-center rounded-md opacity-70 transition-opacity outline-none hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-current/50 [&>svg]:size-4",
75
+ className,
76
+ )}
77
+ {...props}
78
+ >
79
+ <XIcon aria-hidden />
80
+ </button>
81
+ );
82
+ }
83
+
84
+ export { Banner, BannerAction, BannerClose, BannerTitle, bannerVariants };
@@ -0,0 +1,127 @@
1
+ import { mergeProps } from "@base-ui/react/merge-props";
2
+ import { useRender } from "@base-ui/react/use-render";
3
+ import { CaretRightIcon, DotsThreeIcon } from "@phosphor-icons/react";
4
+ import type * as React from "react";
5
+ import { cn } from "#/lib/utils.ts";
6
+
7
+ function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
8
+ return (
9
+ <nav
10
+ aria-label="breadcrumb"
11
+ data-slot="breadcrumb"
12
+ className={className}
13
+ {...props}
14
+ />
15
+ );
16
+ }
17
+
18
+ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
19
+ return (
20
+ <ol
21
+ data-slot="breadcrumb-list"
22
+ className={cn(
23
+ "flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
24
+ className,
25
+ )}
26
+ {...props}
27
+ />
28
+ );
29
+ }
30
+
31
+ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
32
+ return (
33
+ <li
34
+ data-slot="breadcrumb-item"
35
+ className={cn("inline-flex items-center gap-1", className)}
36
+ {...props}
37
+ />
38
+ );
39
+ }
40
+
41
+ function BreadcrumbLink({
42
+ className,
43
+ render,
44
+ ...props
45
+ }: useRender.ComponentProps<"a">) {
46
+ return useRender({
47
+ defaultTagName: "a",
48
+ props: mergeProps<"a">(
49
+ {
50
+ className: cn("transition-colors hover:text-foreground", className),
51
+ },
52
+ props,
53
+ ),
54
+ render,
55
+ state: {
56
+ slot: "breadcrumb-link",
57
+ },
58
+ });
59
+ }
60
+
61
+ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
62
+ return (
63
+ <span
64
+ data-slot="breadcrumb-page"
65
+ aria-current="page"
66
+ className={cn("font-normal text-foreground", className)}
67
+ {...props}
68
+ />
69
+ );
70
+ }
71
+
72
+ function BreadcrumbSeparator({
73
+ children,
74
+ className,
75
+ ...props
76
+ }: React.ComponentProps<"li">) {
77
+ return (
78
+ <li
79
+ data-slot="breadcrumb-separator"
80
+ role="presentation"
81
+ aria-hidden="true"
82
+ className={cn("[&>svg]:size-3.5", className)}
83
+ {...props}
84
+ >
85
+ {children ?? <CaretRightIcon />}
86
+ </li>
87
+ );
88
+ }
89
+
90
+ function BreadcrumbEllipsis({
91
+ className,
92
+ render,
93
+ ...props
94
+ }: useRender.ComponentProps<"span">) {
95
+ return useRender({
96
+ defaultTagName: "span",
97
+ props: mergeProps<"span">(
98
+ {
99
+ className: cn(
100
+ "flex size-5 items-center justify-center [&>svg]:size-4",
101
+ className,
102
+ ),
103
+ children: (
104
+ <>
105
+ <DotsThreeIcon />
106
+ <span className="sr-only">More</span>
107
+ </>
108
+ ),
109
+ },
110
+ props,
111
+ ),
112
+ render,
113
+ state: {
114
+ slot: "breadcrumb-ellipsis",
115
+ },
116
+ });
117
+ }
118
+
119
+ export {
120
+ Breadcrumb,
121
+ BreadcrumbEllipsis,
122
+ BreadcrumbItem,
123
+ BreadcrumbLink,
124
+ BreadcrumbList,
125
+ BreadcrumbPage,
126
+ BreadcrumbSeparator,
127
+ };
@@ -0,0 +1,99 @@
1
+ import { mergeProps } from "@base-ui/react/merge-props";
2
+ import { useRender } from "@base-ui/react/use-render";
3
+ import * as React from "react";
4
+ import { Separator } from "#/components/ui/separator.tsx";
5
+ import { cva, type VariantProps } from "#/lib/cva.ts";
6
+ import { cn } from "#/lib/utils.ts";
7
+
8
+ const buttonGroupVariants = cva({
9
+ base: "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
10
+ variants: {
11
+ orientation: {
12
+ horizontal:
13
+ "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
14
+ vertical:
15
+ "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
16
+ },
17
+ },
18
+ });
19
+
20
+ const ButtonGroupContext = React.createContext<"horizontal" | "vertical">(
21
+ "horizontal",
22
+ );
23
+
24
+ /**
25
+ * Visual-only grouping: label it for screen readers with `aria-label` or
26
+ * `aria-labelledby`. For a single-select segmented control (Day/Week/Month),
27
+ * prefer `ToggleGroup`, which carries the pressed-state semantics.
28
+ */
29
+ function ButtonGroup({
30
+ className,
31
+ orientation = "horizontal",
32
+ ...props
33
+ }: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
34
+ return (
35
+ <ButtonGroupContext.Provider value={orientation}>
36
+ <div
37
+ role="group"
38
+ data-slot="button-group"
39
+ data-orientation={orientation}
40
+ className={cn(buttonGroupVariants({ orientation }), className)}
41
+ {...props}
42
+ />
43
+ </ButtonGroupContext.Provider>
44
+ );
45
+ }
46
+
47
+ function ButtonGroupText({
48
+ className,
49
+ render,
50
+ ...props
51
+ }: useRender.ComponentProps<"div">) {
52
+ return useRender({
53
+ defaultTagName: "div",
54
+ props: mergeProps<"div">(
55
+ {
56
+ className: cn(
57
+ "flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
58
+ className,
59
+ ),
60
+ },
61
+ props,
62
+ ),
63
+ render,
64
+ state: {
65
+ slot: "button-group-text",
66
+ },
67
+ });
68
+ }
69
+
70
+ function ButtonGroupSeparator({
71
+ className,
72
+ orientation,
73
+ ...props
74
+ }: React.ComponentProps<typeof Separator>) {
75
+ const groupOrientation = React.useContext(ButtonGroupContext);
76
+ return (
77
+ <Separator
78
+ data-slot="button-group-separator"
79
+ // A horizontal group needs a vertical rule between members, and
80
+ // vice versa - derived from the group so consumers never pass it.
81
+ orientation={
82
+ orientation ??
83
+ (groupOrientation === "horizontal" ? "vertical" : "horizontal")
84
+ }
85
+ className={cn(
86
+ "relative self-stretch bg-input data-[orientation=horizontal]:mx-px data-[orientation=horizontal]:w-auto data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto",
87
+ className,
88
+ )}
89
+ {...props}
90
+ />
91
+ );
92
+ }
93
+
94
+ export {
95
+ ButtonGroup,
96
+ ButtonGroupSeparator,
97
+ ButtonGroupText,
98
+ buttonGroupVariants,
99
+ };
@@ -0,0 +1,108 @@
1
+ import { cva, type VariantProps } from "#/lib/cva.ts";
2
+
3
+ /**
4
+ * The single canonical button cva - base, variants and sizes - shared by the
5
+ * app's React `Button` (Base UI) and the marketing Astro button, so both
6
+ * surfaces render the same button. Built on the project-wide,
7
+ * tailwind-merge-configured `cva` (see `#/lib/cva.ts`).
8
+ *
9
+ * Variant colors map to the design tokens: `default` (primary, black),
10
+ * `provider` (blue), `organization` (orange), plus the neutral/utility roles.
11
+ * `primary` is an alias of `default` for the marketing site, whose buttons
12
+ * default to the `primary` variant name.
13
+ *
14
+ * `active:not-aria-[haspopup]:translate-y-px` gives a tactile press nudge on
15
+ * click, but skips popup triggers (dropdown/menu/select) so they don't jump
16
+ * down as their menu opens.
17
+ */
18
+ export const buttonVariants = cva({
19
+ base: "group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 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",
20
+ variants: {
21
+ // Solid variants (default/primary/provider/organization) only hover when
22
+ // rendered as an anchor ([a]:hover) - a plain <button> is usually the
23
+ // page's primary action and gets its feedback from active:translate-y-px
24
+ // instead, so it stays inert under the cursor. The neutral/utility roles
25
+ // (secondary/outline/ghost/destructive/link) hover unconditionally.
26
+ // Mirrors the same split in `badgeVariants`.
27
+ variant: {
28
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
29
+ primary: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
30
+ secondary:
31
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
32
+ provider: "bg-provider text-provider-foreground [a]:hover:bg-provider/90",
33
+ organization:
34
+ "bg-organization text-organization-foreground [a]:hover:bg-organization/90",
35
+ outline:
36
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
37
+ ghost:
38
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
39
+ destructive:
40
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
41
+ link: "border-transparent text-primary underline-offset-4 hover:underline",
42
+ },
43
+ size: {
44
+ default:
45
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
46
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
47
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
48
+ lg: "h-11 gap-2 px-6 text-base has-data-[icon=inline-end]:pr-4 has-data-[icon=inline-start]:pl-4",
49
+ icon: "size-8",
50
+ "icon-xs":
51
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
52
+ "icon-sm":
53
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
54
+ "icon-lg": "size-9",
55
+ },
56
+ // Corner shape. `default` keeps the size-driven radius; `pill` forces a
57
+ // fully-rounded button (used by the storefront CTAs). Listed last so its
58
+ // rounded-full wins tailwind-merge over the size radii above.
59
+ shape: {
60
+ default: "",
61
+ pill: "rounded-full",
62
+ },
63
+ },
64
+ defaultVariants: {
65
+ variant: "default",
66
+ size: "default",
67
+ shape: "default",
68
+ },
69
+ });
70
+
71
+ export type ButtonVariants = VariantProps<typeof buttonVariants>;
72
+
73
+ /**
74
+ * The semantic variant names - keep in sync with the `variant` axis above.
75
+ * Exported (like `badgeColors`) so stories and form builders can iterate the
76
+ * set instead of hand-copying it.
77
+ */
78
+ export const buttonVariantOptions = [
79
+ "default",
80
+ "primary",
81
+ "secondary",
82
+ "provider",
83
+ "organization",
84
+ "outline",
85
+ "ghost",
86
+ "destructive",
87
+ "link",
88
+ ] as const satisfies readonly NonNullable<ButtonVariants["variant"]>[];
89
+
90
+ export type ButtonVariant = (typeof buttonVariantOptions)[number];
91
+
92
+ /**
93
+ * The size names, ordered smallest to largest (text sizes then icon sizes) -
94
+ * keep in sync with the `size` axis above. The scale is xs < sm < default < lg,
95
+ * with `default` (h-8) as the unmarked middle.
96
+ */
97
+ export const buttonSizeOptions = [
98
+ "xs",
99
+ "sm",
100
+ "default",
101
+ "lg",
102
+ "icon-xs",
103
+ "icon-sm",
104
+ "icon",
105
+ "icon-lg",
106
+ ] as const satisfies readonly NonNullable<ButtonVariants["size"]>[];
107
+
108
+ export type ButtonSize = (typeof buttonSizeOptions)[number];
@@ -0,0 +1,54 @@
1
+ import { Button as ButtonPrimitive } from "@base-ui/react/button";
2
+
3
+ import {
4
+ type ButtonVariants,
5
+ buttonVariants,
6
+ } from "#/components/ui/button-variants.ts";
7
+ import { Spinner } from "#/components/ui/spinner.tsx";
8
+ import { cn } from "#/lib/utils.ts";
9
+
10
+ type ButtonProps = ButtonPrimitive.Props &
11
+ ButtonVariants & {
12
+ /**
13
+ * Show a leading spinner and mark the button `aria-busy`, disabling
14
+ * interaction while an async action is in flight (e.g. a form submit).
15
+ */
16
+ loading?: boolean;
17
+ };
18
+
19
+ function Button({
20
+ className,
21
+ variant = "default",
22
+ size = "default",
23
+ shape = "default",
24
+ loading = false,
25
+ disabled,
26
+ children,
27
+ ...props
28
+ }: ButtonProps) {
29
+ return (
30
+ <ButtonPrimitive
31
+ data-slot="button"
32
+ data-variant={variant}
33
+ data-size={size}
34
+ data-shape={shape}
35
+ disabled={disabled || loading}
36
+ aria-busy={loading || undefined}
37
+ className={cn(buttonVariants({ variant, size, shape }), className)}
38
+ {...props}
39
+ >
40
+ {loading ? <Spinner /> : null}
41
+ {children}
42
+ </ButtonPrimitive>
43
+ );
44
+ }
45
+
46
+ export {
47
+ type ButtonSize,
48
+ type ButtonVariant,
49
+ type ButtonVariants,
50
+ buttonSizeOptions,
51
+ buttonVariantOptions,
52
+ buttonVariants,
53
+ } from "#/components/ui/button-variants.ts";
54
+ export { Button, type ButtonProps };