@trycompai/design-system 1.0.0

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 (71) hide show
  1. package/README.md +110 -0
  2. package/components.json +21 -0
  3. package/hooks/use-mobile.tsx +19 -0
  4. package/lib/utils.ts +6 -0
  5. package/package.json +103 -0
  6. package/postcss.config.mjs +8 -0
  7. package/src/components/ui/accordion.tsx +60 -0
  8. package/src/components/ui/alert-dialog.tsx +161 -0
  9. package/src/components/ui/alert.tsx +109 -0
  10. package/src/components/ui/aspect-ratio.tsx +21 -0
  11. package/src/components/ui/avatar.tsx +74 -0
  12. package/src/components/ui/badge.tsx +48 -0
  13. package/src/components/ui/breadcrumb.tsx +254 -0
  14. package/src/components/ui/button-group.tsx +89 -0
  15. package/src/components/ui/button.tsx +122 -0
  16. package/src/components/ui/calendar.tsx +190 -0
  17. package/src/components/ui/card.tsx +155 -0
  18. package/src/components/ui/carousel.tsx +216 -0
  19. package/src/components/ui/chart.tsx +325 -0
  20. package/src/components/ui/checkbox.tsx +22 -0
  21. package/src/components/ui/collapsible.tsx +17 -0
  22. package/src/components/ui/combobox.tsx +248 -0
  23. package/src/components/ui/command.tsx +189 -0
  24. package/src/components/ui/container.tsx +34 -0
  25. package/src/components/ui/context-menu.tsx +235 -0
  26. package/src/components/ui/dialog.tsx +122 -0
  27. package/src/components/ui/drawer.tsx +102 -0
  28. package/src/components/ui/dropdown-menu.tsx +242 -0
  29. package/src/components/ui/empty.tsx +94 -0
  30. package/src/components/ui/field.tsx +215 -0
  31. package/src/components/ui/grid.tsx +135 -0
  32. package/src/components/ui/heading.tsx +56 -0
  33. package/src/components/ui/hover-card.tsx +46 -0
  34. package/src/components/ui/index.ts +61 -0
  35. package/src/components/ui/input-group.tsx +128 -0
  36. package/src/components/ui/input-otp.tsx +84 -0
  37. package/src/components/ui/input.tsx +15 -0
  38. package/src/components/ui/item.tsx +188 -0
  39. package/src/components/ui/kbd.tsx +26 -0
  40. package/src/components/ui/label.tsx +15 -0
  41. package/src/components/ui/menubar.tsx +163 -0
  42. package/src/components/ui/navigation-menu.tsx +147 -0
  43. package/src/components/ui/page-header.tsx +51 -0
  44. package/src/components/ui/page-layout.tsx +65 -0
  45. package/src/components/ui/pagination.tsx +104 -0
  46. package/src/components/ui/popover.tsx +57 -0
  47. package/src/components/ui/progress.tsx +61 -0
  48. package/src/components/ui/radio-group.tsx +37 -0
  49. package/src/components/ui/resizable.tsx +41 -0
  50. package/src/components/ui/scroll-area.tsx +48 -0
  51. package/src/components/ui/section.tsx +64 -0
  52. package/src/components/ui/select.tsx +166 -0
  53. package/src/components/ui/separator.tsx +17 -0
  54. package/src/components/ui/sheet.tsx +104 -0
  55. package/src/components/ui/sidebar.tsx +707 -0
  56. package/src/components/ui/skeleton.tsx +5 -0
  57. package/src/components/ui/slider.tsx +51 -0
  58. package/src/components/ui/sonner.tsx +43 -0
  59. package/src/components/ui/spinner.tsx +14 -0
  60. package/src/components/ui/stack.tsx +72 -0
  61. package/src/components/ui/switch.tsx +26 -0
  62. package/src/components/ui/table.tsx +65 -0
  63. package/src/components/ui/tabs.tsx +69 -0
  64. package/src/components/ui/text.tsx +59 -0
  65. package/src/components/ui/textarea.tsx +13 -0
  66. package/src/components/ui/toggle-group.tsx +87 -0
  67. package/src/components/ui/toggle.tsx +42 -0
  68. package/src/components/ui/tooltip.tsx +52 -0
  69. package/src/index.ts +3 -0
  70. package/src/styles/globals.css +122 -0
  71. package/tailwind.config.ts +59 -0
@@ -0,0 +1,74 @@
1
+ 'use client';
2
+
3
+ import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar';
4
+ import * as React from 'react';
5
+
6
+ type AvatarSize = 'xs' | 'sm' | 'default' | 'lg' | 'xl';
7
+
8
+ function Avatar({
9
+ size = 'default',
10
+ ...props
11
+ }: Omit<AvatarPrimitive.Root.Props, 'className'> & {
12
+ size?: AvatarSize;
13
+ }) {
14
+ return (
15
+ <AvatarPrimitive.Root
16
+ data-slot="avatar"
17
+ data-size={size}
18
+ className="size-8 rounded-full after:rounded-full data-[size=xs]:size-5 data-[size=sm]:size-6 data-[size=lg]:size-10 data-[size=xl]:size-14 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten"
19
+ {...props}
20
+ />
21
+ );
22
+ }
23
+
24
+ function AvatarImage({ ...props }: Omit<AvatarPrimitive.Image.Props, 'className'>) {
25
+ return (
26
+ <AvatarPrimitive.Image
27
+ data-slot="avatar-image"
28
+ className="rounded-full aspect-square size-full object-cover"
29
+ {...props}
30
+ />
31
+ );
32
+ }
33
+
34
+ function AvatarFallback({ ...props }: Omit<AvatarPrimitive.Fallback.Props, 'className'>) {
35
+ return (
36
+ <AvatarPrimitive.Fallback
37
+ data-slot="avatar-fallback"
38
+ className="bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-xs group-data-[size=xs]/avatar:text-[8px] group-data-[size=sm]/avatar:text-[10px] group-data-[size=lg]/avatar:text-sm group-data-[size=xl]/avatar:text-base"
39
+ {...props}
40
+ />
41
+ );
42
+ }
43
+
44
+ function AvatarBadge({ ...props }: Omit<React.ComponentProps<'span'>, 'className'>) {
45
+ return (
46
+ <span
47
+ data-slot="avatar-badge"
48
+ className="bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none group-data-[size=xs]/avatar:size-1.5 group-data-[size=xs]/avatar:[&>svg]:hidden group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2 group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2 group-data-[size=xl]/avatar:size-3.5 group-data-[size=xl]/avatar:[&>svg]:size-2.5"
49
+ {...props}
50
+ />
51
+ );
52
+ }
53
+
54
+ function AvatarGroup({ ...props }: Omit<React.ComponentProps<'div'>, 'className'>) {
55
+ return (
56
+ <div
57
+ data-slot="avatar-group"
58
+ className="*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2"
59
+ {...props}
60
+ />
61
+ );
62
+ }
63
+
64
+ function AvatarGroupCount({ ...props }: Omit<React.ComponentProps<'div'>, 'className'>) {
65
+ return (
66
+ <div
67
+ data-slot="avatar-group-count"
68
+ className="bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=xs]/avatar-group:size-5 group-has-data-[size=xs]/avatar-group:text-[10px] group-has-data-[size=sm]/avatar-group:size-6 group-has-data-[size=sm]/avatar-group:text-xs group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=lg]/avatar-group:text-base group-has-data-[size=xl]/avatar-group:size-14 group-has-data-[size=xl]/avatar-group:text-lg ring-background relative flex shrink-0 items-center justify-center ring-2"
69
+ {...props}
70
+ />
71
+ );
72
+ }
73
+
74
+ export { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage };
@@ -0,0 +1,48 @@
1
+ import { mergeProps } from '@base-ui/react/merge-props';
2
+ import { useRender } from '@base-ui/react/use-render';
3
+ import { cva, type VariantProps } from 'class-variance-authority';
4
+
5
+ const badgeVariants = cva(
6
+ 'h-5 gap-1 rounded-4xl border px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-colors overflow-hidden group/badge',
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default: 'border-transparent bg-primary text-primary-foreground [a]:hover:bg-primary/80',
11
+ secondary:
12
+ 'border-transparent bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
13
+ destructive:
14
+ 'border-transparent bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
15
+ outline:
16
+ 'border-border bg-transparent text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
17
+ ghost:
18
+ 'border-transparent bg-transparent hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
19
+ link: 'border-transparent bg-transparent text-primary underline-offset-4 hover:underline',
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ variant: 'default',
24
+ },
25
+ },
26
+ );
27
+
28
+ type BadgeProps = Omit<useRender.ComponentProps<'span'>, 'className'> &
29
+ VariantProps<typeof badgeVariants>;
30
+
31
+ function Badge({ variant = 'default', render, ...props }: BadgeProps) {
32
+ return useRender({
33
+ defaultTagName: 'span',
34
+ props: mergeProps<'span'>(
35
+ {
36
+ className: badgeVariants({ variant }),
37
+ },
38
+ props,
39
+ ),
40
+ render,
41
+ state: {
42
+ slot: 'badge',
43
+ variant,
44
+ },
45
+ });
46
+ }
47
+
48
+ export { Badge, badgeVariants };
@@ -0,0 +1,254 @@
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
+
5
+ import { ArrowRightIcon, ChevronRightIcon, MoreHorizontalIcon, SlashIcon } from 'lucide-react';
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuItem,
10
+ DropdownMenuTrigger,
11
+ } from './dropdown-menu';
12
+
13
+ interface BreadcrumbItemData {
14
+ /** The text label for the breadcrumb item */
15
+ label?: React.ReactNode;
16
+ /** The href for the link. If omitted on the last item, it becomes the current page */
17
+ href?: string;
18
+ /** Whether this item is the current page (auto-detected if last item has no href) */
19
+ isCurrent?: boolean;
20
+ /** Render as ellipsis (collapsed items indicator) */
21
+ isEllipsis?: boolean;
22
+ /** Additional props to pass to the link or page element */
23
+ props?: Record<string, unknown>;
24
+ }
25
+
26
+ type BreadcrumbSeparatorType = 'chevron' | 'slash' | 'arrow';
27
+
28
+ const separatorIcons: Record<BreadcrumbSeparatorType, React.ReactNode> = {
29
+ chevron: <ChevronRightIcon />,
30
+ slash: <SlashIcon />,
31
+ arrow: <ArrowRightIcon />,
32
+ };
33
+
34
+ interface BreadcrumbProps extends Omit<React.ComponentProps<'nav'>, 'children' | 'className'> {
35
+ /** Simple API: Array of breadcrumb items */
36
+ items?: BreadcrumbItemData[];
37
+ /** Separator style: 'chevron' (default), 'slash', or 'arrow' */
38
+ separator?: BreadcrumbSeparatorType;
39
+ /** Max visible items (including ellipsis). Default: 4. Set to 0 to disable auto-collapse. */
40
+ maxItems?: number;
41
+ /** Number of items to show at the start before ellipsis. Default: 1 */
42
+ itemsBeforeCollapse?: number;
43
+ /** Children for compound component pattern */
44
+ children?: React.ReactNode;
45
+ }
46
+
47
+ interface CollapseResult {
48
+ displayItems: Array<BreadcrumbItemData & { collapsedItems?: BreadcrumbItemData[] }>;
49
+ hasCollapsed: boolean;
50
+ }
51
+
52
+ function collapseItems({
53
+ items,
54
+ maxItems,
55
+ itemsBeforeCollapse,
56
+ }: {
57
+ items: BreadcrumbItemData[];
58
+ maxItems: number;
59
+ itemsBeforeCollapse: number;
60
+ }): CollapseResult {
61
+ // If maxItems is 0 or items already fit, return as-is
62
+ if (maxItems === 0 || items.length <= maxItems) {
63
+ return { displayItems: items, hasCollapsed: false };
64
+ }
65
+
66
+ // Ensure we have at least 1 item before ellipsis
67
+ const before = Math.max(1, Math.min(itemsBeforeCollapse, maxItems - 2));
68
+
69
+ // Calculate items after ellipsis: maxItems - before - 1 (for ellipsis)
70
+ const after = maxItems - before - 1;
71
+
72
+ // If we can't fit at least 1 after, no point collapsing
73
+ if (after < 1) {
74
+ return { displayItems: items, hasCollapsed: false };
75
+ }
76
+
77
+ // If before + after >= items.length, no need to collapse
78
+ if (before + after >= items.length) {
79
+ return { displayItems: items, hasCollapsed: false };
80
+ }
81
+
82
+ const startItems = items.slice(0, before);
83
+ const collapsedItems = items.slice(before, items.length - after);
84
+ const endItems = items.slice(items.length - after);
85
+
86
+ return {
87
+ displayItems: [...startItems, { isEllipsis: true, collapsedItems }, ...endItems],
88
+ hasCollapsed: true,
89
+ };
90
+ }
91
+
92
+ function Breadcrumb({
93
+ items,
94
+ separator = 'chevron',
95
+ maxItems = 4,
96
+ itemsBeforeCollapse = 1,
97
+ children,
98
+ ...props
99
+ }: BreadcrumbProps) {
100
+ // If items prop is provided, render simple API
101
+ if (items && items.length > 0) {
102
+ const separatorIcon = separatorIcons[separator];
103
+
104
+ // Auto-collapse if needed
105
+ const { displayItems } = collapseItems({
106
+ items,
107
+ maxItems,
108
+ itemsBeforeCollapse,
109
+ });
110
+
111
+ return (
112
+ <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props}>
113
+ <BreadcrumbList>
114
+ {displayItems.map((item, index) => {
115
+ const isLast = index === displayItems.length - 1;
116
+ const isCurrent = item.isCurrent ?? (isLast && !item.href && !item.isEllipsis);
117
+ const collapsedItems = 'collapsedItems' in item ? item.collapsedItems : undefined;
118
+
119
+ return (
120
+ <React.Fragment key={index}>
121
+ <BreadcrumbItem>
122
+ {item.isEllipsis ? (
123
+ <BreadcrumbEllipsisMenu collapsedItems={collapsedItems} />
124
+ ) : isCurrent ? (
125
+ <BreadcrumbPage {...item.props}>{item.label}</BreadcrumbPage>
126
+ ) : (
127
+ <BreadcrumbLink href={item.href} {...item.props}>
128
+ {item.label}
129
+ </BreadcrumbLink>
130
+ )}
131
+ </BreadcrumbItem>
132
+ {!isLast && <BreadcrumbSeparator>{separatorIcon}</BreadcrumbSeparator>}
133
+ </React.Fragment>
134
+ );
135
+ })}
136
+ </BreadcrumbList>
137
+ </nav>
138
+ );
139
+ }
140
+
141
+ // Otherwise, render compound component pattern
142
+ return (
143
+ <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props}>
144
+ {children}
145
+ </nav>
146
+ );
147
+ }
148
+
149
+ function BreadcrumbList({ ...props }: Omit<React.ComponentProps<'ol'>, 'className'>) {
150
+ return (
151
+ <ol
152
+ data-slot="breadcrumb-list"
153
+ className="text-muted-foreground gap-1.5 text-sm sm:gap-2.5 flex flex-wrap items-center break-words"
154
+ {...props}
155
+ />
156
+ );
157
+ }
158
+
159
+ function BreadcrumbItem({ ...props }: Omit<React.ComponentProps<'li'>, 'className'>) {
160
+ return <li data-slot="breadcrumb-item" className="gap-1.5 inline-flex items-center" {...props} />;
161
+ }
162
+
163
+ function BreadcrumbLink({ render, ...props }: Omit<useRender.ComponentProps<'a'>, 'className'>) {
164
+ return useRender({
165
+ defaultTagName: 'a',
166
+ props: mergeProps<'a'>(
167
+ {
168
+ className: 'hover:text-foreground transition-colors',
169
+ },
170
+ props,
171
+ ),
172
+ render,
173
+ state: {
174
+ slot: 'breadcrumb-link',
175
+ },
176
+ });
177
+ }
178
+
179
+ function BreadcrumbPage({ ...props }: Omit<React.ComponentProps<'span'>, 'className'>) {
180
+ return (
181
+ <span
182
+ data-slot="breadcrumb-page"
183
+ role="link"
184
+ aria-disabled="true"
185
+ aria-current="page"
186
+ className="text-foreground font-normal"
187
+ {...props}
188
+ />
189
+ );
190
+ }
191
+
192
+ function BreadcrumbSeparator({
193
+ children,
194
+ ...props
195
+ }: Omit<React.ComponentProps<'li'>, 'className'>) {
196
+ return (
197
+ <li
198
+ data-slot="breadcrumb-separator"
199
+ role="presentation"
200
+ aria-hidden="true"
201
+ className="[&>svg]:size-3.5"
202
+ {...props}
203
+ >
204
+ {children ?? <ChevronRightIcon />}
205
+ </li>
206
+ );
207
+ }
208
+
209
+ function BreadcrumbEllipsis({ ...props }: Omit<React.ComponentProps<'span'>, 'className'>) {
210
+ return (
211
+ <span
212
+ data-slot="breadcrumb-ellipsis"
213
+ role="presentation"
214
+ aria-hidden="true"
215
+ className="size-5 [&>svg]:size-4 flex items-center justify-center"
216
+ {...props}
217
+ >
218
+ <MoreHorizontalIcon />
219
+ <span className="sr-only">More</span>
220
+ </span>
221
+ );
222
+ }
223
+
224
+ function BreadcrumbEllipsisMenu({ collapsedItems }: { collapsedItems?: BreadcrumbItemData[] }) {
225
+ if (!collapsedItems || collapsedItems.length === 0) {
226
+ return <BreadcrumbEllipsis />;
227
+ }
228
+
229
+ return (
230
+ <DropdownMenu>
231
+ <DropdownMenuTrigger variant="ellipsis" aria-label="Show hidden breadcrumb items">
232
+ <MoreHorizontalIcon />
233
+ </DropdownMenuTrigger>
234
+ <DropdownMenuContent align="start">
235
+ {collapsedItems.map((item, index) => (
236
+ <DropdownMenuItem key={index} render={item.href ? <a href={item.href} /> : undefined}>
237
+ {item.label}
238
+ </DropdownMenuItem>
239
+ ))}
240
+ </DropdownMenuContent>
241
+ </DropdownMenu>
242
+ );
243
+ }
244
+
245
+ export {
246
+ Breadcrumb,
247
+ BreadcrumbEllipsis,
248
+ BreadcrumbItem,
249
+ BreadcrumbLink,
250
+ BreadcrumbList,
251
+ BreadcrumbPage,
252
+ BreadcrumbSeparator,
253
+ };
254
+ export type { BreadcrumbItemData, BreadcrumbSeparatorType };
@@ -0,0 +1,89 @@
1
+ import { mergeProps } from '@base-ui/react/merge-props';
2
+ import { Separator as SeparatorPrimitive } from '@base-ui/react/separator';
3
+ import { useRender } from '@base-ui/react/use-render';
4
+ import { cva, type VariantProps } from 'class-variance-authority';
5
+
6
+ const buttonGroupVariants = cva(
7
+ "has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
8
+ {
9
+ variants: {
10
+ orientation: {
11
+ horizontal:
12
+ '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0 [&>[data-slot]]:rounded-r-none',
13
+ vertical:
14
+ '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! flex-col [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0 [&>[data-slot]]:rounded-b-none',
15
+ },
16
+ },
17
+ defaultVariants: {
18
+ orientation: 'horizontal',
19
+ },
20
+ },
21
+ );
22
+
23
+ const buttonGroupTextVariants = cva(
24
+ "gap-2 rounded-md border px-2.5 text-sm font-medium shadow-xs [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
25
+ {
26
+ variants: {
27
+ variant: {
28
+ default: 'bg-muted',
29
+ display: 'bg-background border-y border-x-0 min-w-[60px] justify-center tabular-nums',
30
+ },
31
+ },
32
+ defaultVariants: {
33
+ variant: 'default',
34
+ },
35
+ },
36
+ );
37
+
38
+ function ButtonGroup({
39
+ orientation,
40
+ ...props
41
+ }: Omit<React.ComponentProps<'div'>, 'className'> & VariantProps<typeof buttonGroupVariants>) {
42
+ return (
43
+ <div
44
+ role="group"
45
+ data-slot="button-group"
46
+ data-orientation={orientation}
47
+ className={buttonGroupVariants({ orientation })}
48
+ {...props}
49
+ />
50
+ );
51
+ }
52
+
53
+ function ButtonGroupText({
54
+ variant,
55
+ render,
56
+ ...props
57
+ }: Omit<useRender.ComponentProps<'div'>, 'className'> &
58
+ VariantProps<typeof buttonGroupTextVariants>) {
59
+ return useRender({
60
+ defaultTagName: 'div',
61
+ props: mergeProps<'div'>(
62
+ {
63
+ className: buttonGroupTextVariants({ variant }),
64
+ },
65
+ props,
66
+ ),
67
+ render,
68
+ state: {
69
+ slot: 'button-group-text',
70
+ variant,
71
+ },
72
+ });
73
+ }
74
+
75
+ function ButtonGroupSeparator({
76
+ orientation = 'vertical',
77
+ ...props
78
+ }: Omit<SeparatorPrimitive.Props, 'className'>) {
79
+ return (
80
+ <SeparatorPrimitive
81
+ data-slot="button-group-separator"
82
+ orientation={orientation}
83
+ className="bg-input shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch relative data-[orientation=horizontal]:mx-px data-[orientation=horizontal]:w-auto data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto"
84
+ {...props}
85
+ />
86
+ );
87
+ }
88
+
89
+ export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };
@@ -0,0 +1,122 @@
1
+ import { Button as ButtonPrimitive } from '@base-ui/react/button';
2
+ import { cva, type VariantProps } from 'class-variance-authority';
3
+ import * as React from 'react';
4
+
5
+ import { Spinner } from './spinner';
6
+
7
+ const buttonVariants = cva(
8
+ "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: 'bg-primary text-primary-foreground hover:bg-primary/80',
13
+ outline:
14
+ 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs',
15
+ secondary:
16
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
17
+ ghost:
18
+ 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
19
+ destructive:
20
+ 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
21
+ link: 'text-primary underline-offset-4 hover:underline',
22
+ },
23
+ width: {
24
+ auto: '',
25
+ full: 'w-full',
26
+ },
27
+ size: {
28
+ default:
29
+ 'h-8 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
30
+ 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",
31
+ 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",
32
+ lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
33
+ icon: 'size-8',
34
+ 'icon-xs':
35
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
36
+ 'icon-sm':
37
+ 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
38
+ 'icon-lg': 'size-9',
39
+ // Calendar day button - special size for calendar day cells
40
+ 'calendar-day': [
41
+ // Base sizing
42
+ 'relative isolate z-10 aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal',
43
+ // Selection states
44
+ 'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground',
45
+ 'data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-middle=true]:rounded-none',
46
+ 'data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius)',
47
+ 'data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius)',
48
+ // Focus states (from parent day cell)
49
+ 'group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 group-data-[focused=true]/day:ring-[3px]',
50
+ // Dark mode hover
51
+ 'dark:hover:text-foreground',
52
+ // Nested span styling for additional content
53
+ '[&>span]:text-xs [&>span]:opacity-70',
54
+ ].join(' '),
55
+ },
56
+ },
57
+ defaultVariants: {
58
+ variant: 'default',
59
+ width: 'auto',
60
+ size: 'default',
61
+ },
62
+ },
63
+ );
64
+
65
+ type ButtonProps = Omit<ButtonPrimitive.Props, 'className'> &
66
+ VariantProps<typeof buttonVariants> & {
67
+ /** Show loading spinner and disable button */
68
+ loading?: boolean;
69
+ /** Icon to show on the left side of the button */
70
+ iconLeft?: React.ReactNode;
71
+ /** Icon to show on the right side of the button */
72
+ iconRight?: React.ReactNode;
73
+ };
74
+
75
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
76
+ (
77
+ {
78
+ variant = 'default',
79
+ width = 'auto',
80
+ size = 'default',
81
+ loading = false,
82
+ iconLeft,
83
+ iconRight,
84
+ disabled,
85
+ children,
86
+ ...props
87
+ },
88
+ ref,
89
+ ) => {
90
+ const isDisabled = disabled || loading;
91
+
92
+ return (
93
+ <ButtonPrimitive
94
+ ref={ref}
95
+ data-slot="button"
96
+ data-loading={loading || undefined}
97
+ disabled={isDisabled}
98
+ className={buttonVariants({ variant, width, size })}
99
+ {...props}
100
+ >
101
+ {loading ? (
102
+ <Spinner />
103
+ ) : iconLeft ? (
104
+ <span data-icon="inline-start" className="shrink-0">
105
+ {iconLeft}
106
+ </span>
107
+ ) : null}
108
+ {children}
109
+ {!loading && iconRight ? (
110
+ <span data-icon="inline-end" className="shrink-0">
111
+ {iconRight}
112
+ </span>
113
+ ) : null}
114
+ </ButtonPrimitive>
115
+ );
116
+ },
117
+ );
118
+
119
+ Button.displayName = 'Button';
120
+
121
+ export { Button, buttonVariants };
122
+ export type { ButtonProps };