@bearmenu/ui 0.5.1 → 0.6.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 (225) hide show
  1. package/dist/{chunk-ERMIA7NP.js → chunk-7PPCH42H.js} +3 -3
  2. package/dist/{chunk-HLQ2IRE7.js → chunk-BAZFVSSG.js} +3 -3
  3. package/dist/chunk-D5QHEHXJ.js +26 -0
  4. package/dist/{chunk-UISA4CIG.js → chunk-N2KNBPAQ.js} +2 -2
  5. package/dist/{chunk-7H54PTVH.js → chunk-PSR7N36A.js} +38 -10
  6. package/dist/chunk-RIDXFY6M.js +38 -0
  7. package/dist/chunk-UUJVXN2O.js +82 -0
  8. package/dist/chunk-WKMLZDUT.js +122 -0
  9. package/dist/chunk-XK2ZDQVE.js +122 -0
  10. package/dist/{chunk-4KOMHDZD.js → chunk-Y5D5LP4A.js} +18 -3
  11. package/dist/chunk-YJ6OBKZJ.js +32 -0
  12. package/dist/{chunk-ESO2EM3L.js → chunk-ZBBUKXM5.js} +2 -2
  13. package/dist/chunk-ZFNOCP6W.js +106 -0
  14. package/dist/components/accordion.js +11 -5
  15. package/dist/components/alert-dialog.js +5 -3
  16. package/dist/components/alert.d.ts +5 -2
  17. package/dist/components/alert.js +25 -9
  18. package/dist/components/avatar.d.ts +17 -2
  19. package/dist/components/avatar.js +91 -12
  20. package/dist/components/badge.d.ts +1 -1
  21. package/dist/components/badge.js +1 -1
  22. package/dist/components/breadcrumb.js +5 -2
  23. package/dist/components/button-group.d.ts +14 -0
  24. package/dist/components/button-group.js +39 -0
  25. package/dist/components/button.d.ts +4 -2
  26. package/dist/components/button.js +1 -1
  27. package/dist/components/calendar.js +3 -3
  28. package/dist/components/card.d.ts +13 -5
  29. package/dist/components/card.js +2 -2
  30. package/dist/components/carousel.d.ts +20 -3
  31. package/dist/components/carousel.js +72 -12
  32. package/dist/components/checkbox.js +1 -1
  33. package/dist/components/circular-progress.d.ts +16 -0
  34. package/dist/components/circular-progress.js +105 -0
  35. package/dist/components/combobox.d.ts +27 -0
  36. package/dist/components/combobox.js +102 -0
  37. package/dist/components/command.d.ts +29 -16
  38. package/dist/components/command.js +2 -2
  39. package/dist/components/currency-input.d.ts +1 -0
  40. package/dist/components/currency-input.js +73 -52
  41. package/dist/components/date-input.d.ts +10 -0
  42. package/dist/components/date-input.js +40 -0
  43. package/dist/components/description-list.d.ts +15 -0
  44. package/dist/components/description-list.js +67 -0
  45. package/dist/components/dialog.d.ts +13 -2
  46. package/dist/components/dialog.js +1 -1
  47. package/dist/components/drawer.d.ts +5 -2
  48. package/dist/components/drawer.js +47 -9
  49. package/dist/components/dropdown-menu.d.ts +7 -2
  50. package/dist/components/dropdown-menu.js +22 -6
  51. package/dist/components/empty-state.js +2 -2
  52. package/dist/components/field.d.ts +22 -0
  53. package/dist/components/field.js +130 -0
  54. package/dist/components/form.d.ts +2 -1
  55. package/dist/components/form.js +11 -1
  56. package/dist/components/haptic-button.js +3 -3
  57. package/dist/components/input-group.d.ts +18 -0
  58. package/dist/components/input-group.js +92 -0
  59. package/dist/components/input.d.ts +8 -2
  60. package/dist/components/input.js +1 -1
  61. package/dist/components/item.d.ts +23 -0
  62. package/dist/components/item.js +120 -0
  63. package/dist/components/kbd.d.ts +13 -0
  64. package/dist/components/kbd.js +49 -0
  65. package/dist/components/lazy-markdown.d.ts +3 -1
  66. package/dist/components/lazy-markdown.js +7 -7
  67. package/dist/components/link-button.js +1 -1
  68. package/dist/components/multi-select-combobox.js +92 -83
  69. package/dist/components/multi-select-menu.d.ts +0 -4
  70. package/dist/components/multi-select-menu.js +28 -17
  71. package/dist/components/native-select.d.ts +12 -0
  72. package/dist/components/native-select.js +49 -0
  73. package/dist/components/pagination.d.ts +5 -2
  74. package/dist/components/pagination.js +66 -7
  75. package/dist/components/place-about-tab.js +4 -4
  76. package/dist/components/place-card.js +1 -1
  77. package/dist/components/place-details-mobile.js +4 -4
  78. package/dist/components/popover.d.ts +7 -1
  79. package/dist/components/popover.js +1 -1
  80. package/dist/components/progress.d.ts +5 -1
  81. package/dist/components/progress.js +17 -3
  82. package/dist/components/radio-group.js +12 -3
  83. package/dist/components/rating.d.ts +2 -0
  84. package/dist/components/rating.js +88 -70
  85. package/dist/components/search-bar.d.ts +1 -2
  86. package/dist/components/search-bar.js +37 -60
  87. package/dist/components/searchable-select.js +76 -72
  88. package/dist/components/select.d.ts +5 -1
  89. package/dist/components/select.js +30 -14
  90. package/dist/components/selection-bar.d.ts +11 -0
  91. package/dist/components/selection-bar.js +65 -0
  92. package/dist/components/separator.js +3 -2
  93. package/dist/components/sheet.d.ts +1 -1
  94. package/dist/components/sheet.js +1 -1
  95. package/dist/components/skeleton-card.js +1 -1
  96. package/dist/components/skeleton.js +1 -1
  97. package/dist/components/slider.js +21 -4
  98. package/dist/components/sliding-panels.js +15 -13
  99. package/dist/components/spinner.d.ts +17 -0
  100. package/dist/components/spinner.js +50 -0
  101. package/dist/components/stepper.d.ts +18 -0
  102. package/dist/components/stepper.js +199 -0
  103. package/dist/components/sticky-container.js +1 -0
  104. package/dist/components/switch.js +4 -3
  105. package/dist/components/table.d.ts +23 -5
  106. package/dist/components/table.js +121 -35
  107. package/dist/components/tabs.d.ts +3 -1
  108. package/dist/components/tabs.js +37 -9
  109. package/dist/components/tag-group.d.ts +9 -0
  110. package/dist/components/tag-group.js +26 -0
  111. package/dist/components/tag.d.ts +17 -0
  112. package/dist/components/tag.js +61 -0
  113. package/dist/components/text.d.ts +3 -3
  114. package/dist/components/text.js +1 -1
  115. package/dist/components/textarea.js +2 -1
  116. package/dist/components/toast.js +1 -1
  117. package/dist/components/toaster.js +1 -1
  118. package/dist/components/toggle-group.js +1 -1
  119. package/dist/components/toggle.js +1 -1
  120. package/dist/components/tooltip.d.ts +3 -2
  121. package/dist/components/tooltip.js +16 -2
  122. package/package.json +44 -26
  123. package/src/components/accordion.tsx +11 -6
  124. package/src/components/alert-dialog.tsx +4 -2
  125. package/src/components/alert.tsx +20 -8
  126. package/src/components/avatar.tsx +106 -13
  127. package/src/components/badge.tsx +12 -11
  128. package/src/components/breadcrumb.tsx +5 -2
  129. package/src/components/button-group.stories.tsx +52 -0
  130. package/src/components/button-group.tsx +40 -0
  131. package/src/components/button.test.tsx +174 -0
  132. package/src/components/button.tsx +42 -19
  133. package/src/components/calendar.tsx +2 -2
  134. package/src/components/card.tsx +55 -13
  135. package/src/components/carousel.tsx +85 -13
  136. package/src/components/checkbox.test.tsx +58 -0
  137. package/src/components/checkbox.tsx +8 -3
  138. package/src/components/circular-progress.stories.tsx +39 -0
  139. package/src/components/circular-progress.tsx +102 -0
  140. package/src/components/collapsible.stories.tsx +7 -3
  141. package/src/components/combobox.stories.tsx +82 -0
  142. package/src/components/combobox.test.tsx +118 -0
  143. package/src/components/combobox.tsx +136 -0
  144. package/src/components/command.test.tsx +91 -0
  145. package/src/components/command.tsx +38 -15
  146. package/src/components/currency-input.test.tsx +63 -0
  147. package/src/components/currency-input.tsx +84 -66
  148. package/src/components/date-input.stories.tsx +31 -0
  149. package/src/components/date-input.tsx +43 -0
  150. package/src/components/description-list.stories.tsx +62 -0
  151. package/src/components/description-list.tsx +68 -0
  152. package/src/components/dialog.test.tsx +84 -0
  153. package/src/components/dialog.tsx +64 -24
  154. package/src/components/drawer.tsx +72 -23
  155. package/src/components/dropdown-menu.test.tsx +180 -0
  156. package/src/components/dropdown-menu.tsx +31 -6
  157. package/src/components/field.stories.tsx +36 -0
  158. package/src/components/field.test.tsx +157 -0
  159. package/src/components/field.tsx +146 -0
  160. package/src/components/form.stories.tsx +3 -2
  161. package/src/components/form.test.tsx +95 -0
  162. package/src/components/form.tsx +9 -0
  163. package/src/components/haptic-button.tsx +1 -1
  164. package/src/components/input-group.stories.tsx +61 -0
  165. package/src/components/input-group.tsx +99 -0
  166. package/src/components/input.test.tsx +103 -0
  167. package/src/components/input.tsx +24 -7
  168. package/src/components/item.stories.tsx +52 -0
  169. package/src/components/item.tsx +115 -0
  170. package/src/components/kbd.stories.tsx +33 -0
  171. package/src/components/kbd.tsx +46 -0
  172. package/src/components/lazy-markdown.tsx +10 -7
  173. package/src/components/multi-select-combobox.test.tsx +101 -0
  174. package/src/components/multi-select-combobox.tsx +85 -82
  175. package/src/components/multi-select-menu.tsx +49 -41
  176. package/src/components/native-select.stories.tsx +52 -0
  177. package/src/components/native-select.tsx +45 -0
  178. package/src/components/pagination.tsx +64 -5
  179. package/src/components/popover.tsx +66 -3
  180. package/src/components/progress.tsx +27 -4
  181. package/src/components/radio-group.test.tsx +64 -0
  182. package/src/components/radio-group.tsx +11 -3
  183. package/src/components/rating.test.tsx +112 -0
  184. package/src/components/rating.tsx +97 -82
  185. package/src/components/search-bar.stories.tsx +1 -32
  186. package/src/components/search-bar.tsx +22 -59
  187. package/src/components/searchable-select.tsx +69 -73
  188. package/src/components/select.tsx +34 -19
  189. package/src/components/selection-bar.stories.tsx +37 -0
  190. package/src/components/selection-bar.tsx +65 -0
  191. package/src/components/separator.tsx +5 -2
  192. package/src/components/sheet.tsx +1 -1
  193. package/src/components/skeleton.tsx +2 -2
  194. package/src/components/slider.test.tsx +45 -0
  195. package/src/components/slider.tsx +31 -12
  196. package/src/components/sliding-panels.tsx +14 -12
  197. package/src/components/spinner.stories.tsx +35 -0
  198. package/src/components/spinner.tsx +50 -0
  199. package/src/components/stepper.stories.tsx +71 -0
  200. package/src/components/stepper.tsx +220 -0
  201. package/src/components/sticky-container.tsx +1 -0
  202. package/src/components/switch.test.tsx +52 -0
  203. package/src/components/switch.tsx +4 -3
  204. package/src/components/table.tsx +140 -35
  205. package/src/components/tabs.test.tsx +107 -0
  206. package/src/components/tabs.tsx +70 -23
  207. package/src/components/tag-group.stories.tsx +39 -0
  208. package/src/components/tag-group.tsx +28 -0
  209. package/src/components/tag.stories.tsx +50 -0
  210. package/src/components/tag.tsx +63 -0
  211. package/src/components/text.test.tsx +1 -1
  212. package/src/components/text.tsx +19 -3
  213. package/src/components/textarea.test.tsx +48 -0
  214. package/src/components/textarea.tsx +2 -1
  215. package/src/components/toast.tsx +4 -3
  216. package/src/components/toggle.tsx +2 -2
  217. package/src/components/tooltip.tsx +12 -2
  218. package/src/globals.css +142 -90
  219. package/dist/chunk-2BMJRXQX.js +0 -80
  220. package/dist/chunk-4AVS6CV4.js +0 -89
  221. package/dist/chunk-4UO73SKC.js +0 -26
  222. package/dist/chunk-CW24PLDW.js +0 -24
  223. package/dist/chunk-IQASCH2V.js +0 -25
  224. package/dist/chunk-NKHH23YK.js +0 -31
  225. package/dist/chunk-QJ54Z7TC.js +0 -83
@@ -0,0 +1,174 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { Button, buttonVariants } from "./button";
5
+
6
+ // ─── buttonVariants CVA ───────────────────────────────────────────────────────
7
+
8
+ describe("buttonVariants", () => {
9
+ it("returns default variant + size classes when called with no args", () => {
10
+ const result = buttonVariants({});
11
+ expect(result).toContain("bg-primary");
12
+ expect(result).toContain("text-primary-foreground");
13
+ expect(result).toContain("h-11");
14
+ });
15
+
16
+ it("maps variant prop to correct classes", () => {
17
+ expect(buttonVariants({ variant: "default" })).toContain("bg-primary");
18
+ expect(buttonVariants({ variant: "destructive" })).toContain("bg-destructive");
19
+ expect(buttonVariants({ variant: "outline" })).toContain("border-border");
20
+ expect(buttonVariants({ variant: "secondary" })).toContain("bg-secondary");
21
+ expect(buttonVariants({ variant: "ghost" })).toContain("hover:bg-accent");
22
+ expect(buttonVariants({ variant: "link" })).toContain("text-primary");
23
+ expect(buttonVariants({ variant: "glass" })).toContain("glass");
24
+ expect(buttonVariants({ variant: "brown" })).toContain("bg-brown");
25
+ expect(buttonVariants({ variant: "antagonist" })).toContain("bg-primary-foreground");
26
+ });
27
+
28
+ it("maps size prop to correct classes", () => {
29
+ expect(buttonVariants({ size: "xs" })).toContain("h-8");
30
+ expect(buttonVariants({ size: "sm" })).toContain("h-9");
31
+ expect(buttonVariants({ size: "default" })).toContain("h-11");
32
+ expect(buttonVariants({ size: "lg" })).toContain("h-12");
33
+ expect(buttonVariants({ size: "icon" })).toContain("rounded-full");
34
+ expect(buttonVariants({ size: "icon-xs" })).toContain("h-8");
35
+ expect(buttonVariants({ size: "icon-sm" })).toContain("h-9");
36
+ expect(buttonVariants({ size: "icon-lg" })).toContain("h-12");
37
+ });
38
+
39
+ it("applies fullWidth class when fullWidth is true", () => {
40
+ expect(buttonVariants({ fullWidth: true })).toContain("w-full");
41
+ expect(buttonVariants({ fullWidth: false })).not.toContain("w-full");
42
+ });
43
+ });
44
+
45
+ // ─── <Button> component ───────────────────────────────────────────────────────
46
+
47
+ describe("Button component", () => {
48
+ it("renders as <button> by default", () => {
49
+ render(<Button>click</Button>);
50
+ const el = screen.getByRole("button", { name: "click" });
51
+ expect(el.tagName).toBe("BUTTON");
52
+ });
53
+
54
+ it("renders children correctly", () => {
55
+ render(<Button>label content</Button>);
56
+ expect(screen.getByText("label content")).toBeDefined();
57
+ });
58
+
59
+ it("forwards ref to the underlying button element", () => {
60
+ const ref = { current: null as HTMLButtonElement | null };
61
+ render(<Button ref={ref}>ref</Button>);
62
+ expect(ref.current).toBeInstanceOf(HTMLButtonElement);
63
+ });
64
+
65
+ it("renders as Slot child when asChild is true", () => {
66
+ render(
67
+ <Button asChild>
68
+ <a href="/x">link</a>
69
+ </Button>
70
+ );
71
+ const el = screen.getByRole("link", { name: "link" });
72
+ expect(el.tagName).toBe("A");
73
+ expect(el.getAttribute("href")).toBe("/x");
74
+ });
75
+
76
+ it("applies data-slot=button attribute", () => {
77
+ render(<Button>x</Button>);
78
+ expect(screen.getByRole("button").getAttribute("data-slot")).toBe("button");
79
+ });
80
+
81
+ // Disabled / loading states
82
+
83
+ it("disables the button when disabled prop is set", () => {
84
+ render(<Button disabled>x</Button>);
85
+ expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true);
86
+ });
87
+
88
+ it("disables the button and sets aria-busy when isLoading", () => {
89
+ render(<Button isLoading>x</Button>);
90
+ const el = screen.getByRole("button");
91
+ expect((el as HTMLButtonElement).disabled).toBe(true);
92
+ expect(el.getAttribute("aria-busy")).toBe("true");
93
+ });
94
+
95
+ it("renders spinner when isLoading", () => {
96
+ const { container } = render(<Button isLoading>x</Button>);
97
+ expect(container.querySelector(".animate-spin")).not.toBeNull();
98
+ });
99
+
100
+ it("does not set aria-busy when not loading", () => {
101
+ render(<Button>x</Button>);
102
+ expect(screen.getByRole("button").getAttribute("aria-busy")).toBeNull();
103
+ });
104
+
105
+ // Click behavior
106
+
107
+ it("calls onClick when clicked", async () => {
108
+ const onClick = vi.fn();
109
+ const user = userEvent.setup();
110
+ render(<Button onClick={onClick}>x</Button>);
111
+ await user.click(screen.getByRole("button"));
112
+ expect(onClick).toHaveBeenCalledTimes(1);
113
+ });
114
+
115
+ it("does not call onClick when disabled", async () => {
116
+ const onClick = vi.fn();
117
+ const user = userEvent.setup();
118
+ render(
119
+ <Button onClick={onClick} disabled>
120
+ x
121
+ </Button>
122
+ );
123
+ await user.click(screen.getByRole("button"));
124
+ expect(onClick).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it("does not call onClick when loading", async () => {
128
+ const onClick = vi.fn();
129
+ const user = userEvent.setup();
130
+ render(
131
+ <Button onClick={onClick} isLoading>
132
+ x
133
+ </Button>
134
+ );
135
+ await user.click(screen.getByRole("button"));
136
+ expect(onClick).not.toHaveBeenCalled();
137
+ });
138
+
139
+ // Class output
140
+
141
+ it("applies variant class from variant prop", () => {
142
+ render(<Button variant="destructive">x</Button>);
143
+ expect(screen.getByRole("button").className).toContain("bg-destructive");
144
+ });
145
+
146
+ it("applies size class from size prop", () => {
147
+ render(<Button size="sm">x</Button>);
148
+ expect(screen.getByRole("button").className).toContain("h-9");
149
+ });
150
+
151
+ it("applies fullWidth class when fullWidth is true", () => {
152
+ render(<Button fullWidth>x</Button>);
153
+ expect(screen.getByRole("button").className).toContain("w-full");
154
+ });
155
+
156
+ it("merges className via twMerge (overrides defaults)", () => {
157
+ render(<Button className="bg-red-500">x</Button>);
158
+ const el = screen.getByRole("button");
159
+ expect(el.className).toContain("bg-red-500");
160
+ // twMerge strips conflicting bg-primary (but not hover:bg-primary/90)
161
+ expect(el.className).not.toMatch(/(^|\s)bg-primary(\s|$)/);
162
+ });
163
+
164
+ it("passes through arbitrary HTML attributes", () => {
165
+ render(
166
+ <Button id="my-btn" type="submit">
167
+ x
168
+ </Button>
169
+ );
170
+ const el = screen.getByRole("button") as HTMLButtonElement;
171
+ expect(el.id).toBe("my-btn");
172
+ expect(el.type).toBe("submit");
173
+ });
174
+ });
@@ -1,40 +1,50 @@
1
1
  import * as React from "react";
2
2
  import { Slot } from "@radix-ui/react-slot";
3
3
  import { cva, type VariantProps } from "class-variance-authority";
4
+ import { Loader2 } from "lucide-react";
4
5
  import { cn } from "../lib/utils";
5
6
  import { useHaptic } from "../hooks/use-haptic";
6
7
 
7
8
  const buttonVariants = cva(
8
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-3xl text-sm font-medium transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer [@media(pointer:coarse)]:active:scale-95",
9
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer [@media(pointer:coarse)]:active:scale-95",
9
10
  {
10
11
  variants: {
11
12
  variant: {
12
13
  default:
13
- "bg-primary backdrop-blur-sm text-primary-foreground shadow-lg hover:bg-primary/85 border border-primary/20 transition-colors duration-200",
14
+ "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow",
14
15
  destructive:
15
- "bg-destructive text-destructive-foreground shadow-lg hover:bg-destructive/90 hover:shadow-destructive/20 backdrop-blur-sm border border-destructive/20",
16
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/85 hover:shadow",
16
17
  outline:
17
- "border border-border bg-sidebar backdrop-blur-md hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:border-sidebar-accent-foreground/20 hover:shadow-lg",
18
+ "border border-border bg-background hover:bg-accent hover:text-accent-foreground hover:border-accent",
18
19
  secondary:
19
- "bg-secondary text-secondary-foreground shadow-lg hover:bg-secondary/90 hover:shadow-secondary/20 backdrop-blur-sm border border-secondary/20",
20
- ghost: "hover:bg-foreground/10 hover:text-foreground transition-colors duration-200",
21
- link: "text-primary underline-offset-4 hover:underline hover:text-primary/80",
22
- glass: "glass glass-hover text-foreground hover:text-accent-foreground",
20
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-accent hover:text-accent-foreground border border-border hover:border-accent",
21
+ ghost: "hover:bg-accent hover:text-accent-foreground",
22
+ link: "text-primary underline-offset-4 hover:underline",
23
+ glass: "glass glass-hover text-foreground",
23
24
  brown:
24
- "bg-[#482E1F] text-white shadow-lg hover:bg-[#352317] hover:shadow-[#482E1F]/20 backdrop-blur-sm border border-[#482E1F]/30 transition-all duration-300",
25
+ "bg-brown text-brown-foreground shadow-sm hover:bg-brown/85 hover:shadow",
25
26
  antagonist:
26
- "bg-primary-foreground text-primary shadow-lg hover:bg-primary-foreground/90 hover:shadow-primary-foreground/20 backdrop-blur-sm border border-primary-foreground/20 transition-all duration-300",
27
+ "bg-primary-foreground text-primary shadow-sm hover:bg-accent hover:text-accent-foreground border border-border hover:border-accent",
27
28
  },
28
29
  size: {
29
- default: "h-11 px-6 py-2",
30
+ xs: "h-8 rounded-lg px-3 text-xs [&_svg]:size-3.5",
30
31
  sm: "h-9 rounded-lg px-4",
31
- lg: "h-12 rounded-xl px-8",
32
- icon: "h-11 w-11",
32
+ default: "h-11 px-6 py-2",
33
+ lg: "h-12 rounded-2xl px-8",
34
+ icon: "h-11 w-11 rounded-full",
35
+ "icon-xs": "h-8 w-8 rounded-full [&_svg]:size-3.5",
36
+ "icon-sm": "h-9 w-9 rounded-full",
37
+ "icon-lg": "h-12 w-12 rounded-full",
38
+ },
39
+ fullWidth: {
40
+ true: "w-full",
41
+ false: "",
33
42
  },
34
43
  },
35
44
  defaultVariants: {
36
45
  variant: "default",
37
46
  size: "default",
47
+ fullWidth: false,
38
48
  },
39
49
  }
40
50
  );
@@ -44,6 +54,7 @@ export interface ButtonProps
44
54
  asChild?: boolean;
45
55
  haptic?: boolean;
46
56
  hapticStyle?: "light" | "medium" | "heavy";
57
+ isLoading?: boolean;
47
58
  }
48
59
 
49
60
  const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
@@ -52,10 +63,14 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
52
63
  className,
53
64
  variant,
54
65
  size,
66
+ fullWidth,
55
67
  asChild = false,
56
68
  haptic = true,
57
69
  hapticStyle = "light",
70
+ isLoading = false,
71
+ disabled,
58
72
  onClick,
73
+ children,
59
74
  ...props
60
75
  },
61
76
  ref
@@ -64,28 +79,36 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
64
79
  const { trigger } = useHaptic();
65
80
  const [isMounted, setIsMounted] = React.useState(false);
66
81
 
67
- // Track mounted state to prevent hydration issues
68
82
  React.useEffect(() => {
69
83
  setIsMounted(true);
70
84
  }, []);
71
85
 
72
86
  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
73
- // Only trigger haptic feedback after component has mounted to prevent hydration issues
74
87
  if (haptic && isMounted) {
75
88
  trigger(hapticStyle);
76
89
  }
77
-
78
- // Call the original onClick handler
79
90
  onClick?.(event);
80
91
  };
81
92
 
82
93
  return (
83
94
  <Comp
84
- className={cn(buttonVariants({ variant, size, className }))}
95
+ data-slot="button"
96
+ className={cn(buttonVariants({ variant, size, fullWidth, className }))}
85
97
  ref={ref}
86
98
  onClick={handleClick}
99
+ disabled={disabled || isLoading}
100
+ aria-busy={isLoading || undefined}
87
101
  {...props}
88
- />
102
+ >
103
+ {isLoading ? (
104
+ <>
105
+ <Loader2 className="animate-spin" aria-hidden="true" />
106
+ {!asChild && children}
107
+ </>
108
+ ) : (
109
+ children
110
+ )}
111
+ </Comp>
89
112
  );
90
113
  }
91
114
  );
@@ -25,7 +25,7 @@ function Calendar({
25
25
  <DayPicker
26
26
  showOutsideDays={showOutsideDays}
27
27
  className={cn(
28
- "group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
28
+ "group/calendar bg-background p-3 [--cell-size:--spacing(9)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
29
29
  String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
30
30
  String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
31
31
  className
@@ -168,7 +168,7 @@ function CalendarDayButton({
168
168
  data-range-end={modifiers.range_end}
169
169
  data-range-middle={modifiers.range_middle}
170
170
  className={cn(
171
- "group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
171
+ "group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground flex aspect-square h-(--cell-size) w-(--cell-size) size-auto min-w-0 flex-col items-center justify-center gap-1 rounded-md p-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
172
172
  defaultClassNames.day,
173
173
  className
174
174
  )}
@@ -1,21 +1,42 @@
1
1
  import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
2
3
  import { cn } from "../lib/utils";
3
4
  import { textVariants } from "./text";
4
5
 
5
- type CardVariant = "glass" | "surface";
6
+ const cardVariants = cva("rounded-3xl text-card-foreground transition-colors", {
7
+ variants: {
8
+ variant: {
9
+ surface: "bg-card border border-border",
10
+ glass: "glass",
11
+ outline: "bg-transparent border border-border",
12
+ muted: "bg-muted border border-transparent",
13
+ },
14
+ size: {
15
+ sm: "[&_[data-slot=card-header]]:p-4 [&_[data-slot=card-content]]:p-4 [&_[data-slot=card-content]]:pt-0 [&_[data-slot=card-footer]]:p-4 [&_[data-slot=card-footer]]:pt-0",
16
+ default:
17
+ "[&_[data-slot=card-header]]:p-6 [&_[data-slot=card-content]]:p-6 [&_[data-slot=card-content]]:pt-0 [&_[data-slot=card-footer]]:p-6 [&_[data-slot=card-footer]]:pt-0",
18
+ lg: "[&_[data-slot=card-header]]:p-8 [&_[data-slot=card-content]]:p-8 [&_[data-slot=card-content]]:pt-0 [&_[data-slot=card-footer]]:p-8 [&_[data-slot=card-footer]]:pt-0",
19
+ },
20
+ interactive: {
21
+ true: "cursor-pointer hover:border-primary/40 hover:-translate-y-0.5 transition-[transform,border-color] duration-200",
22
+ false: "",
23
+ },
24
+ },
25
+ defaultVariants: {
26
+ variant: "surface",
27
+ size: "default",
28
+ interactive: false,
29
+ },
30
+ });
6
31
 
7
32
  const Card = React.forwardRef<
8
33
  HTMLDivElement,
9
- React.HTMLAttributes<HTMLDivElement> & { variant?: CardVariant }
10
- >(({ className, variant = "glass", ...props }, ref) => (
34
+ React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof cardVariants>
35
+ >(({ className, variant, size, interactive, ...props }, ref) => (
11
36
  <div
12
37
  ref={ref}
13
- className={cn(
14
- variant === "glass"
15
- ? "glass rounded-[22px] border border-transparent text-card-foreground transition-[transform,border-color] duration-300 [@media(hover:hover)]:hover:-translate-y-0.5 hover:border-primary/40"
16
- : "bg-surface rounded-[22px] border border-border text-card-foreground transition-all duration-200 hover:border-primary/40 hover:-translate-y-0.5",
17
- className
18
- )}
38
+ data-slot="card"
39
+ className={cn(cardVariants({ variant, size, interactive }), className)}
19
40
  {...props}
20
41
  />
21
42
  ));
@@ -23,7 +44,12 @@ Card.displayName = "Card";
23
44
 
24
45
  const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
25
46
  ({ className, ...props }, ref) => (
26
- <div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
47
+ <div
48
+ ref={ref}
49
+ data-slot="card-header"
50
+ className={cn("flex flex-col space-y-1.5 p-6", className)}
51
+ {...props}
52
+ />
27
53
  )
28
54
  );
29
55
  CardHeader.displayName = "CardHeader";
@@ -32,6 +58,7 @@ const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivE
32
58
  ({ className, ...props }, ref) => (
33
59
  <div
34
60
  ref={ref}
61
+ data-slot="card-title"
35
62
  className={cn(textVariants({ variant: "h4", wrap: "balance" }), className)}
36
63
  {...props}
37
64
  />
@@ -43,6 +70,7 @@ const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HT
43
70
  ({ className, ...props }, ref) => (
44
71
  <div
45
72
  ref={ref}
73
+ data-slot="card-description"
46
74
  className={cn("text-sm", textVariants({ color: "muted", wrap: "pretty" }), className)}
47
75
  {...props}
48
76
  />
@@ -52,7 +80,7 @@ CardDescription.displayName = "CardDescription";
52
80
 
53
81
  const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
54
82
  ({ className, ...props }, ref) => (
55
- <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
83
+ <div ref={ref} data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />
56
84
  )
57
85
  );
58
86
  CardContent.displayName = "CardContent";
@@ -71,9 +99,23 @@ CardAction.displayName = "CardAction";
71
99
 
72
100
  const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
73
101
  ({ className, ...props }, ref) => (
74
- <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
102
+ <div
103
+ ref={ref}
104
+ data-slot="card-footer"
105
+ className={cn("flex items-center p-6 pt-0", className)}
106
+ {...props}
107
+ />
75
108
  )
76
109
  );
77
110
  CardFooter.displayName = "CardFooter";
78
111
 
79
- export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
112
+ export {
113
+ Card,
114
+ CardHeader,
115
+ CardFooter,
116
+ CardTitle,
117
+ CardAction,
118
+ CardDescription,
119
+ CardContent,
120
+ cardVariants,
121
+ };
@@ -16,6 +16,8 @@ type CarouselProps = {
16
16
  opts?: CarouselOptions;
17
17
  plugins?: CarouselPlugin;
18
18
  orientation?: "horizontal" | "vertical";
19
+ /** Gap between slides in pixels. Defaults to 16. */
20
+ gap?: number;
19
21
  setApi?: (api: CarouselApi) => void;
20
22
  };
21
23
 
@@ -24,19 +26,21 @@ type CarouselContextProps = {
24
26
  api: ReturnType<typeof useEmblaCarousel>[1];
25
27
  scrollPrev: () => void;
26
28
  scrollNext: () => void;
29
+ scrollTo: (index: number) => void;
27
30
  canScrollPrev: boolean;
28
31
  canScrollNext: boolean;
32
+ selectedIndex: number;
33
+ slideCount: number;
34
+ gap: number;
29
35
  } & CarouselProps;
30
36
 
31
37
  const CarouselContext = React.createContext<CarouselContextProps | null>(null);
32
38
 
33
39
  function useCarousel() {
34
40
  const context = React.useContext(CarouselContext);
35
-
36
41
  if (!context) {
37
42
  throw new Error("useCarousel must be used within a <Carousel />");
38
43
  }
39
-
40
44
  return context;
41
45
  }
42
46
 
@@ -45,6 +49,7 @@ function Carousel({
45
49
  opts,
46
50
  setApi,
47
51
  plugins,
52
+ gap = 16,
48
53
  className,
49
54
  children,
50
55
  ...props
@@ -58,11 +63,15 @@ function Carousel({
58
63
  );
59
64
  const [canScrollPrev, setCanScrollPrev] = React.useState(false);
60
65
  const [canScrollNext, setCanScrollNext] = React.useState(false);
66
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
67
+ const [slideCount, setSlideCount] = React.useState(0);
61
68
 
62
69
  const onSelect = React.useCallback((api: CarouselApi) => {
63
70
  if (!api) return;
64
71
  setCanScrollPrev(api.canScrollPrev());
65
72
  setCanScrollNext(api.canScrollNext());
73
+ setSelectedIndex(api.selectedScrollSnap());
74
+ setSlideCount(api.scrollSnapList().length);
66
75
  }, []);
67
76
 
68
77
  const scrollPrev = React.useCallback(() => {
@@ -73,6 +82,13 @@ function Carousel({
73
82
  api?.scrollNext();
74
83
  }, [api]);
75
84
 
85
+ const scrollTo = React.useCallback(
86
+ (index: number) => {
87
+ api?.scrollTo(index);
88
+ },
89
+ [api]
90
+ );
91
+
76
92
  const handleKeyDown = React.useCallback(
77
93
  (event: React.KeyboardEvent<HTMLDivElement>) => {
78
94
  if (event.key === "ArrowLeft") {
@@ -96,7 +112,6 @@ function Carousel({
96
112
  onSelect(api);
97
113
  api.on("reInit", onSelect);
98
114
  api.on("select", onSelect);
99
-
100
115
  return () => {
101
116
  api?.off("select", onSelect);
102
117
  };
@@ -106,13 +121,17 @@ function Carousel({
106
121
  <CarouselContext.Provider
107
122
  value={{
108
123
  carouselRef,
109
- api: api,
124
+ api,
110
125
  opts,
111
126
  orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
127
+ gap,
112
128
  scrollPrev,
113
129
  scrollNext,
130
+ scrollTo,
114
131
  canScrollPrev,
115
132
  canScrollNext,
133
+ selectedIndex,
134
+ slideCount,
116
135
  }}
117
136
  >
118
137
  <div
@@ -130,31 +149,36 @@ function Carousel({
130
149
  }
131
150
 
132
151
  function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
133
- const { carouselRef, orientation } = useCarousel();
152
+ const { carouselRef, orientation, gap } = useCarousel();
134
153
 
135
154
  return (
136
155
  <div ref={carouselRef} className="overflow-hidden" data-slot="carousel-content">
137
156
  <div
138
- className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)}
157
+ className={cn("flex", orientation === "horizontal" ? "" : "flex-col", className)}
158
+ style={
159
+ orientation === "horizontal"
160
+ ? { marginLeft: `-${gap}px` }
161
+ : { marginTop: `-${gap}px` }
162
+ }
139
163
  {...props}
140
164
  />
141
165
  </div>
142
166
  );
143
167
  }
144
168
 
145
- function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
146
- const { orientation } = useCarousel();
169
+ function CarouselItem({ className, style, ...props }: React.ComponentProps<"div">) {
170
+ const { orientation, gap } = useCarousel();
147
171
 
148
172
  return (
149
173
  <div
150
174
  role="group"
151
175
  aria-roledescription="slide"
152
176
  data-slot="carousel-item"
153
- className={cn(
154
- "min-w-0 shrink-0 grow-0 basis-full",
155
- orientation === "horizontal" ? "pl-4" : "pt-4",
156
- className
157
- )}
177
+ className={cn("min-w-0 shrink-0 grow-0 basis-full", className)}
178
+ style={{
179
+ ...(orientation === "horizontal" ? { paddingLeft: `${gap}px` } : { paddingTop: `${gap}px` }),
180
+ ...style,
181
+ }}
158
182
  {...props}
159
183
  />
160
184
  );
@@ -220,6 +244,51 @@ function CarouselNext({
220
244
  );
221
245
  }
222
246
 
247
+ function CarouselDots({ className, ...props }: React.ComponentProps<"div">) {
248
+ const { slideCount, selectedIndex, scrollTo } = useCarousel();
249
+
250
+ if (slideCount === 0) return null;
251
+
252
+ return (
253
+ <div
254
+ data-slot="carousel-dots"
255
+ className={cn("flex items-center justify-center gap-1.5", className)}
256
+ {...props}
257
+ >
258
+ {Array.from({ length: slideCount }).map((_, i) => (
259
+ <button
260
+ key={i}
261
+ type="button"
262
+ aria-label={`Go to slide ${i + 1}`}
263
+ aria-current={i === selectedIndex ? "true" : undefined}
264
+ onClick={() => scrollTo(i)}
265
+ className={cn(
266
+ "size-2 rounded-full transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
267
+ i === selectedIndex ? "w-6 bg-primary" : "bg-muted-foreground/30 hover:bg-muted-foreground/50"
268
+ )}
269
+ />
270
+ ))}
271
+ </div>
272
+ );
273
+ }
274
+
275
+ function CarouselCounter({ className, ...props }: React.ComponentProps<"div">) {
276
+ const { slideCount, selectedIndex } = useCarousel();
277
+
278
+ if (slideCount === 0) return null;
279
+
280
+ return (
281
+ <div
282
+ data-slot="carousel-counter"
283
+ aria-live="polite"
284
+ className={cn("text-xs tabular-nums text-muted-foreground", className)}
285
+ {...props}
286
+ >
287
+ {selectedIndex + 1} / {slideCount}
288
+ </div>
289
+ );
290
+ }
291
+
223
292
  export {
224
293
  type CarouselApi,
225
294
  Carousel,
@@ -227,4 +296,7 @@ export {
227
296
  CarouselItem,
228
297
  CarouselPrevious,
229
298
  CarouselNext,
299
+ CarouselDots,
300
+ CarouselCounter,
301
+ useCarousel,
230
302
  };