@bearmenu/ui 0.5.2 → 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 (223) 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 +9 -6
  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/switch.js +4 -3
  104. package/dist/components/table.d.ts +23 -5
  105. package/dist/components/table.js +121 -35
  106. package/dist/components/tabs.d.ts +3 -1
  107. package/dist/components/tabs.js +37 -9
  108. package/dist/components/tag-group.d.ts +9 -0
  109. package/dist/components/tag-group.js +26 -0
  110. package/dist/components/tag.d.ts +17 -0
  111. package/dist/components/tag.js +61 -0
  112. package/dist/components/text.d.ts +3 -3
  113. package/dist/components/text.js +1 -1
  114. package/dist/components/textarea.js +2 -1
  115. package/dist/components/toast.js +1 -1
  116. package/dist/components/toaster.js +1 -1
  117. package/dist/components/toggle-group.js +1 -1
  118. package/dist/components/toggle.js +1 -1
  119. package/dist/components/tooltip.d.ts +3 -2
  120. package/dist/components/tooltip.js +16 -2
  121. package/package.json +44 -26
  122. package/src/components/accordion.tsx +11 -6
  123. package/src/components/alert-dialog.tsx +4 -2
  124. package/src/components/alert.tsx +20 -8
  125. package/src/components/avatar.tsx +106 -13
  126. package/src/components/badge.tsx +12 -11
  127. package/src/components/breadcrumb.tsx +5 -2
  128. package/src/components/button-group.stories.tsx +52 -0
  129. package/src/components/button-group.tsx +40 -0
  130. package/src/components/button.test.tsx +174 -0
  131. package/src/components/button.tsx +42 -19
  132. package/src/components/calendar.tsx +2 -2
  133. package/src/components/card.tsx +55 -13
  134. package/src/components/carousel.tsx +85 -13
  135. package/src/components/checkbox.test.tsx +58 -0
  136. package/src/components/checkbox.tsx +8 -3
  137. package/src/components/circular-progress.stories.tsx +39 -0
  138. package/src/components/circular-progress.tsx +102 -0
  139. package/src/components/collapsible.stories.tsx +7 -3
  140. package/src/components/combobox.stories.tsx +82 -0
  141. package/src/components/combobox.test.tsx +118 -0
  142. package/src/components/combobox.tsx +136 -0
  143. package/src/components/command.test.tsx +91 -0
  144. package/src/components/command.tsx +38 -15
  145. package/src/components/currency-input.test.tsx +63 -0
  146. package/src/components/currency-input.tsx +84 -66
  147. package/src/components/date-input.stories.tsx +31 -0
  148. package/src/components/date-input.tsx +43 -0
  149. package/src/components/description-list.stories.tsx +62 -0
  150. package/src/components/description-list.tsx +68 -0
  151. package/src/components/dialog.test.tsx +84 -0
  152. package/src/components/dialog.tsx +64 -24
  153. package/src/components/drawer.tsx +72 -23
  154. package/src/components/dropdown-menu.test.tsx +180 -0
  155. package/src/components/dropdown-menu.tsx +31 -6
  156. package/src/components/field.stories.tsx +36 -0
  157. package/src/components/field.test.tsx +157 -0
  158. package/src/components/field.tsx +146 -0
  159. package/src/components/form.stories.tsx +3 -2
  160. package/src/components/form.test.tsx +95 -0
  161. package/src/components/form.tsx +9 -0
  162. package/src/components/haptic-button.tsx +1 -1
  163. package/src/components/input-group.stories.tsx +61 -0
  164. package/src/components/input-group.tsx +99 -0
  165. package/src/components/input.test.tsx +103 -0
  166. package/src/components/input.tsx +24 -7
  167. package/src/components/item.stories.tsx +52 -0
  168. package/src/components/item.tsx +115 -0
  169. package/src/components/kbd.stories.tsx +33 -0
  170. package/src/components/kbd.tsx +46 -0
  171. package/src/components/lazy-markdown.tsx +10 -7
  172. package/src/components/multi-select-combobox.test.tsx +101 -0
  173. package/src/components/multi-select-combobox.tsx +85 -82
  174. package/src/components/multi-select-menu.tsx +49 -41
  175. package/src/components/native-select.stories.tsx +52 -0
  176. package/src/components/native-select.tsx +45 -0
  177. package/src/components/pagination.tsx +64 -5
  178. package/src/components/popover.tsx +66 -3
  179. package/src/components/progress.tsx +27 -4
  180. package/src/components/radio-group.test.tsx +64 -0
  181. package/src/components/radio-group.tsx +11 -3
  182. package/src/components/rating.test.tsx +112 -0
  183. package/src/components/rating.tsx +97 -82
  184. package/src/components/search-bar.stories.tsx +1 -32
  185. package/src/components/search-bar.tsx +22 -59
  186. package/src/components/searchable-select.tsx +69 -73
  187. package/src/components/select.tsx +34 -19
  188. package/src/components/selection-bar.stories.tsx +37 -0
  189. package/src/components/selection-bar.tsx +65 -0
  190. package/src/components/separator.tsx +5 -2
  191. package/src/components/sheet.tsx +1 -1
  192. package/src/components/skeleton.tsx +2 -2
  193. package/src/components/slider.test.tsx +45 -0
  194. package/src/components/slider.tsx +31 -12
  195. package/src/components/sliding-panels.tsx +8 -5
  196. package/src/components/spinner.stories.tsx +35 -0
  197. package/src/components/spinner.tsx +50 -0
  198. package/src/components/stepper.stories.tsx +71 -0
  199. package/src/components/stepper.tsx +220 -0
  200. package/src/components/switch.test.tsx +52 -0
  201. package/src/components/switch.tsx +4 -3
  202. package/src/components/table.tsx +140 -35
  203. package/src/components/tabs.test.tsx +107 -0
  204. package/src/components/tabs.tsx +70 -23
  205. package/src/components/tag-group.stories.tsx +39 -0
  206. package/src/components/tag-group.tsx +28 -0
  207. package/src/components/tag.stories.tsx +50 -0
  208. package/src/components/tag.tsx +63 -0
  209. package/src/components/text.test.tsx +1 -1
  210. package/src/components/text.tsx +19 -3
  211. package/src/components/textarea.test.tsx +48 -0
  212. package/src/components/textarea.tsx +2 -1
  213. package/src/components/toast.tsx +4 -3
  214. package/src/components/toggle.tsx +2 -2
  215. package/src/components/tooltip.tsx +12 -2
  216. package/src/globals.css +126 -90
  217. package/dist/chunk-2BMJRXQX.js +0 -80
  218. package/dist/chunk-4AVS6CV4.js +0 -89
  219. package/dist/chunk-4UO73SKC.js +0 -26
  220. package/dist/chunk-CW24PLDW.js +0 -24
  221. package/dist/chunk-IQASCH2V.js +0 -25
  222. package/dist/chunk-NKHH23YK.js +0 -31
  223. package/dist/chunk-QJ54Z7TC.js +0 -83
@@ -0,0 +1,58 @@
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 { Checkbox } from "./checkbox";
5
+
6
+ describe("Checkbox", () => {
7
+ it("renders an unchecked checkbox by default", () => {
8
+ render(<Checkbox aria-label="Accept" />);
9
+ const cb = screen.getByRole("checkbox", { name: /accept/i });
10
+ expect(cb).toHaveAttribute("data-state", "unchecked");
11
+ });
12
+
13
+ it("renders checked when `checked` is true", () => {
14
+ render(<Checkbox aria-label="Accept" checked onCheckedChange={() => {}} />);
15
+ expect(screen.getByRole("checkbox")).toHaveAttribute("data-state", "checked");
16
+ });
17
+
18
+ it("renders indeterminate state", () => {
19
+ render(<Checkbox aria-label="x" checked="indeterminate" onCheckedChange={() => {}} />);
20
+ expect(screen.getByRole("checkbox")).toHaveAttribute("data-state", "indeterminate");
21
+ });
22
+
23
+ it("calls onCheckedChange when clicked", async () => {
24
+ const user = userEvent.setup();
25
+ const onCheckedChange = vi.fn();
26
+ render(<Checkbox aria-label="x" onCheckedChange={onCheckedChange} />);
27
+ await user.click(screen.getByRole("checkbox"));
28
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
29
+ });
30
+
31
+ it("calls onCheckedChange via Space key", async () => {
32
+ const user = userEvent.setup();
33
+ const onCheckedChange = vi.fn();
34
+ render(<Checkbox aria-label="x" onCheckedChange={onCheckedChange} />);
35
+ const cb = screen.getByRole("checkbox");
36
+ cb.focus();
37
+ await user.keyboard(" ");
38
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
39
+ });
40
+
41
+ it("does not fire when disabled", async () => {
42
+ const user = userEvent.setup();
43
+ const onCheckedChange = vi.fn();
44
+ render(<Checkbox aria-label="x" disabled onCheckedChange={onCheckedChange} />);
45
+ await user.click(screen.getByRole("checkbox"));
46
+ expect(onCheckedChange).not.toHaveBeenCalled();
47
+ });
48
+
49
+ it("forwards the aria-invalid attribute", () => {
50
+ render(<Checkbox aria-label="x" aria-invalid />);
51
+ expect(screen.getByRole("checkbox")).toHaveAttribute("aria-invalid", "true");
52
+ });
53
+
54
+ it("merges className", () => {
55
+ render(<Checkbox aria-label="x" className="custom-class" />);
56
+ expect(screen.getByRole("checkbox").className).toContain("custom-class");
57
+ });
58
+ });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
5
- import { Check } from "lucide-react";
5
+ import { Check, Minus } from "lucide-react";
6
6
  import { cn } from "../lib/utils";
7
7
 
8
8
  const Checkbox = React.forwardRef<
@@ -11,14 +11,19 @@ const Checkbox = React.forwardRef<
11
11
  >(({ className, ...props }, ref) => (
12
12
  <CheckboxPrimitive.Root
13
13
  ref={ref}
14
+ data-slot="checkbox"
14
15
  className={cn(
15
- "peer h-5 w-5 shrink-0 rounded-md border border-border bg-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary data-[state=checked]:shadow-lg data-[state=checked]:shadow-primary/20 data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:shadow-lg data-[state=indeterminate]:shadow-primary/20 hover:border-primary/40 hover:shadow-md",
16
+ "peer size-5 shrink-0 rounded-md border border-border bg-background transition-all duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary hover:border-primary/50 aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive/40",
16
17
  className
17
18
  )}
18
19
  {...props}
19
20
  >
20
21
  <CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
21
- <Check className="h-3.5 w-3.5 animate-in zoom-in-50 duration-200" />
22
+ {props.checked === "indeterminate" ? (
23
+ <Minus className="size-3.5 animate-in fade-in-0 zoom-in-50 duration-200 ease-out" strokeWidth={3} />
24
+ ) : (
25
+ <Check className="size-3.5 animate-in fade-in-0 zoom-in-50 duration-200 ease-out" strokeWidth={3} />
26
+ )}
22
27
  </CheckboxPrimitive.Indicator>
23
28
  </CheckboxPrimitive.Root>
24
29
  ));
@@ -0,0 +1,39 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { CircularProgress } from "./circular-progress";
3
+
4
+ const meta = {
5
+ title: "Components/CircularProgress",
6
+ component: CircularProgress,
7
+ tags: ["autodocs"],
8
+ } satisfies Meta<typeof CircularProgress>;
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof meta>;
12
+
13
+ export const Default: Story = { args: { value: 60, showLabel: true } };
14
+
15
+ export const Sizes: Story = {
16
+ render: () => (
17
+ <div className="flex items-center gap-4">
18
+ <CircularProgress value={75} size={24} />
19
+ <CircularProgress value={75} size={40} />
20
+ <CircularProgress value={75} size={56} showLabel />
21
+ <CircularProgress value={75} size={80} showLabel strokeWidth={6} />
22
+ </div>
23
+ ),
24
+ };
25
+
26
+ export const Tones: Story = {
27
+ render: () => (
28
+ <div className="flex items-center gap-4">
29
+ <CircularProgress value={60} tone="primary" showLabel />
30
+ <CircularProgress value={60} tone="success" showLabel />
31
+ <CircularProgress value={60} tone="warning" showLabel />
32
+ <CircularProgress value={60} tone="destructive" showLabel />
33
+ </div>
34
+ ),
35
+ };
36
+
37
+ export const Indeterminate: Story = {
38
+ args: { indeterminate: true, size: 40 },
39
+ };
@@ -0,0 +1,102 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+
6
+ type CircularProgressProps = React.HTMLAttributes<HTMLDivElement> & {
7
+ value?: number;
8
+ max?: number;
9
+ size?: number;
10
+ strokeWidth?: number;
11
+ tone?: "primary" | "success" | "warning" | "destructive";
12
+ showLabel?: boolean;
13
+ /** Indeterminate spinner mode. */
14
+ indeterminate?: boolean;
15
+ /** Accessible label. Defaults to "Progress". */
16
+ label?: string;
17
+ };
18
+
19
+ const toneClasses = {
20
+ primary: "stroke-primary",
21
+ success: "stroke-success",
22
+ warning: "stroke-warning",
23
+ destructive: "stroke-destructive",
24
+ };
25
+
26
+ const CircularProgress = React.forwardRef<HTMLDivElement, CircularProgressProps>(
27
+ (
28
+ {
29
+ className,
30
+ value = 0,
31
+ max = 100,
32
+ size = 40,
33
+ strokeWidth = 4,
34
+ tone = "primary",
35
+ showLabel = false,
36
+ indeterminate = false,
37
+ label = "Progress",
38
+ ...props
39
+ },
40
+ ref
41
+ ) => {
42
+ const radius = (size - strokeWidth) / 2;
43
+ const circumference = 2 * Math.PI * radius;
44
+ const clampedValue = Math.min(Math.max(value, 0), max);
45
+ const percent = (clampedValue / max) * 100;
46
+ const dashOffset = circumference - (circumference * percent) / 100;
47
+
48
+ return (
49
+ <div
50
+ ref={ref}
51
+ data-slot="circular-progress"
52
+ role="progressbar"
53
+ aria-valuemin={0}
54
+ aria-valuemax={max}
55
+ aria-valuenow={indeterminate ? undefined : clampedValue}
56
+ aria-label={label}
57
+ className={cn("relative inline-flex shrink-0 items-center justify-center", className)}
58
+ style={{ width: size, height: size }}
59
+ {...props}
60
+ >
61
+ <svg
62
+ width={size}
63
+ height={size}
64
+ viewBox={`0 0 ${size} ${size}`}
65
+ className={cn(indeterminate && "animate-spin")}
66
+ >
67
+ <circle
68
+ cx={size / 2}
69
+ cy={size / 2}
70
+ r={radius}
71
+ strokeWidth={strokeWidth}
72
+ fill="none"
73
+ className="stroke-muted"
74
+ />
75
+ <circle
76
+ cx={size / 2}
77
+ cy={size / 2}
78
+ r={radius}
79
+ strokeWidth={strokeWidth}
80
+ fill="none"
81
+ strokeLinecap="round"
82
+ className={cn("transition-[stroke-dashoffset] duration-300 ease-out", toneClasses[tone])}
83
+ style={{
84
+ strokeDasharray: circumference,
85
+ strokeDashoffset: indeterminate ? circumference * 0.75 : dashOffset,
86
+ transform: `rotate(-90deg)`,
87
+ transformOrigin: `${size / 2}px ${size / 2}px`,
88
+ }}
89
+ />
90
+ </svg>
91
+ {showLabel && !indeterminate && (
92
+ <span className="absolute text-xs font-medium tabular-nums text-foreground">
93
+ {Math.round(percent)}%
94
+ </span>
95
+ )}
96
+ </div>
97
+ );
98
+ }
99
+ );
100
+ CircularProgress.displayName = "CircularProgress";
101
+
102
+ export { CircularProgress };
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsible";
3
3
  import { Button } from "./button";
4
- import { ChevronsUpDown } from "lucide-react";
4
+ import { ChevronDown } from "lucide-react";
5
5
 
6
6
  const meta = {
7
7
  title: "Components/Collapsible",
@@ -18,8 +18,12 @@ export const Default: Story = {
18
18
  <div className="flex items-center justify-between space-x-4 px-4">
19
19
  <h4 className="text-sm font-semibold">@peduarte starred 3 repositories</h4>
20
20
  <CollapsibleTrigger asChild>
21
- <Button variant="ghost" size="sm" className="w-9 p-0">
22
- <ChevronsUpDown className="h-4 w-4" />
21
+ <Button
22
+ variant="ghost"
23
+ size="sm"
24
+ className="w-9 p-0 [&[data-state=open]>svg]:rotate-180"
25
+ >
26
+ <ChevronDown className="h-4 w-4 transition-transform duration-200" />
23
27
  <span className="sr-only">Toggle</span>
24
28
  </Button>
25
29
  </CollapsibleTrigger>
@@ -0,0 +1,82 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { Combobox, type ComboboxOption } from "./combobox";
4
+
5
+ const meta = {
6
+ title: "Components/Combobox",
7
+ component: Combobox,
8
+ tags: ["autodocs"],
9
+ } satisfies Meta<typeof Combobox>;
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ const frameworks: ComboboxOption[] = [
15
+ { value: "next", label: "Next.js", description: "React framework" },
16
+ { value: "remix", label: "Remix", description: "Full-stack web framework" },
17
+ { value: "astro", label: "Astro", description: "Content-driven web framework" },
18
+ { value: "vite", label: "Vite", description: "Frontend tooling" },
19
+ { value: "nuxt", label: "Nuxt", description: "Vue framework" },
20
+ ];
21
+
22
+ export const Default: Story = {
23
+ render: () => {
24
+ const [value, setValue] = useState<string | undefined>("next");
25
+ return (
26
+ <div className="w-72">
27
+ <Combobox
28
+ options={frameworks}
29
+ value={value}
30
+ onValueChange={setValue}
31
+ placeholder="Pick a framework"
32
+ />
33
+ </div>
34
+ );
35
+ },
36
+ };
37
+
38
+ export const Empty: Story = {
39
+ render: () => {
40
+ const [value, setValue] = useState<string | undefined>(undefined);
41
+ return (
42
+ <div className="w-72">
43
+ <Combobox
44
+ options={frameworks}
45
+ value={value}
46
+ onValueChange={setValue}
47
+ placeholder="No selection"
48
+ />
49
+ </div>
50
+ );
51
+ },
52
+ };
53
+
54
+ export const Disabled: Story = {
55
+ render: () => (
56
+ <div className="w-72">
57
+ <Combobox
58
+ options={frameworks}
59
+ value="next"
60
+ onValueChange={() => {}}
61
+ disabled
62
+ />
63
+ </div>
64
+ ),
65
+ };
66
+
67
+ export const Loading: Story = {
68
+ render: () => {
69
+ const [value, setValue] = useState<string | undefined>(undefined);
70
+ return (
71
+ <div className="w-72">
72
+ <Combobox
73
+ options={[]}
74
+ value={value}
75
+ onValueChange={setValue}
76
+ loading
77
+ placeholder="Pick something…"
78
+ />
79
+ </div>
80
+ );
81
+ },
82
+ };
@@ -0,0 +1,118 @@
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 * as React from "react";
5
+ import { Combobox, type ComboboxOption } from "./combobox";
6
+
7
+ const options: ComboboxOption[] = [
8
+ { value: "apple", label: "Apple" },
9
+ { value: "banana", label: "Banana" },
10
+ { value: "cherry", label: "Cherry", disabled: true },
11
+ ];
12
+
13
+ function Wrap({
14
+ initial,
15
+ ...rest
16
+ }: {
17
+ initial?: string;
18
+ clearable?: boolean;
19
+ disabled?: boolean;
20
+ loading?: boolean;
21
+ onChange?: (v: string | undefined) => void;
22
+ }) {
23
+ const [value, setValue] = React.useState<string | undefined>(initial);
24
+ return (
25
+ <Combobox
26
+ options={options}
27
+ value={value}
28
+ onValueChange={(v) => {
29
+ setValue(v);
30
+ rest.onChange?.(v);
31
+ }}
32
+ clearable={rest.clearable}
33
+ disabled={rest.disabled}
34
+ loading={rest.loading}
35
+ />
36
+ );
37
+ }
38
+
39
+ describe("Combobox", () => {
40
+ it("renders placeholder when nothing is selected", () => {
41
+ render(<Wrap />);
42
+ expect(screen.getByRole("combobox")).toHaveTextContent("Select...");
43
+ });
44
+
45
+ it("renders selected label when value provided", () => {
46
+ render(<Wrap initial="banana" />);
47
+ expect(screen.getByRole("combobox")).toHaveTextContent("Banana");
48
+ });
49
+
50
+ it("opens on trigger click and exposes aria-expanded=true", async () => {
51
+ const user = userEvent.setup();
52
+ render(<Wrap />);
53
+ const trigger = screen.getByRole("combobox");
54
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
55
+ await user.click(trigger);
56
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
57
+ expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
58
+ });
59
+
60
+ it("selects an option and closes the popover", async () => {
61
+ const user = userEvent.setup();
62
+ const onChange = vi.fn();
63
+ render(<Wrap onChange={onChange} />);
64
+ await user.click(screen.getByRole("combobox"));
65
+ await user.click(screen.getByText("Apple"));
66
+ expect(onChange).toHaveBeenCalledWith("apple");
67
+ expect(screen.getByRole("combobox")).toHaveTextContent("Apple");
68
+ });
69
+
70
+ it("clears selection when the same value is re-selected", async () => {
71
+ const user = userEvent.setup();
72
+ const onChange = vi.fn();
73
+ render(<Wrap initial="apple" onChange={onChange} />);
74
+ await user.click(screen.getByRole("combobox"));
75
+ await user.click(screen.getByRole("option", { name: /apple/i }));
76
+ expect(onChange).toHaveBeenCalledWith(undefined);
77
+ });
78
+
79
+ it("disabled options cannot be selected via click", async () => {
80
+ const user = userEvent.setup();
81
+ const onChange = vi.fn();
82
+ render(<Wrap onChange={onChange} />);
83
+ await user.click(screen.getByRole("combobox"));
84
+ const cherry = screen.getByText("Cherry");
85
+ await user.click(cherry);
86
+ expect(onChange).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it("shows clear button when value is set and clearable=true", async () => {
90
+ render(<Wrap initial="banana" />);
91
+ expect(screen.getByRole("button", { name: "Clear selection" })).toBeInTheDocument();
92
+ });
93
+
94
+ it("clear button resets the value", async () => {
95
+ const user = userEvent.setup();
96
+ const onChange = vi.fn();
97
+ render(<Wrap initial="banana" onChange={onChange} />);
98
+ await user.click(screen.getByRole("button", { name: "Clear selection" }));
99
+ expect(onChange).toHaveBeenCalledWith(undefined);
100
+ });
101
+
102
+ it("hides clear button when clearable=false", () => {
103
+ render(<Wrap initial="banana" clearable={false} />);
104
+ expect(screen.queryByRole("button", { name: "Clear selection" })).toBeNull();
105
+ });
106
+
107
+ it("disables trigger when disabled=true", () => {
108
+ render(<Wrap initial="banana" disabled />);
109
+ expect((screen.getByRole("combobox") as HTMLButtonElement).disabled).toBe(true);
110
+ });
111
+
112
+ it("shows loading state instead of empty message", async () => {
113
+ const user = userEvent.setup();
114
+ render(<Wrap loading />);
115
+ await user.click(screen.getByRole("combobox"));
116
+ expect(screen.getByText("Loading…")).toBeInTheDocument();
117
+ });
118
+ });
@@ -0,0 +1,136 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Check, ChevronsUpDown, X } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { Button } from "./button";
7
+ import {
8
+ Command,
9
+ CommandEmpty,
10
+ CommandGroup,
11
+ CommandInput,
12
+ CommandItem,
13
+ CommandList,
14
+ CommandLoading,
15
+ } from "./command";
16
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover";
17
+
18
+ export interface ComboboxOption {
19
+ value: string;
20
+ label: string;
21
+ description?: string;
22
+ disabled?: boolean;
23
+ }
24
+
25
+ interface ComboboxProps {
26
+ options: ComboboxOption[];
27
+ value: string | undefined;
28
+ onValueChange: (value: string | undefined) => void;
29
+ placeholder?: string;
30
+ searchPlaceholder?: string;
31
+ emptyText?: string;
32
+ loadingText?: string;
33
+ className?: string;
34
+ disabled?: boolean;
35
+ loading?: boolean;
36
+ /** Allow clearing the selection. Defaults to true. */
37
+ clearable?: boolean;
38
+ /** If set, external search drives the option list (controlled). */
39
+ onSearchChange?: (search: string) => void;
40
+ }
41
+
42
+ function Combobox({
43
+ options,
44
+ value,
45
+ onValueChange,
46
+ placeholder = "Select...",
47
+ searchPlaceholder = "Search...",
48
+ emptyText = "No results.",
49
+ loadingText = "Loading…",
50
+ className,
51
+ disabled = false,
52
+ loading = false,
53
+ clearable = true,
54
+ onSearchChange,
55
+ }: ComboboxProps) {
56
+ const [open, setOpen] = React.useState(false);
57
+ const selected = options.find((o) => o.value === value);
58
+
59
+ return (
60
+ <div className={cn("relative", className)}>
61
+ <Popover open={open} onOpenChange={setOpen}>
62
+ <PopoverTrigger asChild>
63
+ <Button
64
+ variant="outline"
65
+ role="combobox"
66
+ aria-expanded={open}
67
+ aria-haspopup="listbox"
68
+ disabled={disabled}
69
+ className={cn("w-full justify-between font-normal", !selected && "text-muted-foreground", value && clearable && !disabled && "pr-16")}
70
+ >
71
+ <span className="truncate">{selected?.label || placeholder}</span>
72
+ <ChevronsUpDown className="size-4 shrink-0 opacity-50" />
73
+ </Button>
74
+ </PopoverTrigger>
75
+ <PopoverContent
76
+ align="start"
77
+ className="w-[var(--radix-popover-trigger-width)] p-0"
78
+ >
79
+ <Command shouldFilter={!onSearchChange}>
80
+ <CommandInput placeholder={searchPlaceholder} onValueChange={onSearchChange} />
81
+ <CommandList>
82
+ {loading ? (
83
+ <CommandLoading>{loadingText}</CommandLoading>
84
+ ) : (
85
+ <CommandEmpty>{emptyText}</CommandEmpty>
86
+ )}
87
+ <CommandGroup>
88
+ {options.map((option) => (
89
+ <CommandItem
90
+ key={option.value}
91
+ value={option.value}
92
+ disabled={option.disabled}
93
+ onSelect={() => {
94
+ onValueChange(option.value === value ? undefined : option.value);
95
+ setOpen(false);
96
+ }}
97
+ >
98
+ <Check
99
+ className={cn(
100
+ "mr-2 size-4",
101
+ value === option.value ? "opacity-100" : "opacity-0"
102
+ )}
103
+ />
104
+ <div className="flex min-w-0 flex-col">
105
+ <span className="truncate">{option.label}</span>
106
+ {option.description && (
107
+ <span className="truncate text-xs text-muted-foreground">
108
+ {option.description}
109
+ </span>
110
+ )}
111
+ </div>
112
+ </CommandItem>
113
+ ))}
114
+ </CommandGroup>
115
+ </CommandList>
116
+ </Command>
117
+ </PopoverContent>
118
+ </Popover>
119
+ {value && clearable && !disabled && (
120
+ <button
121
+ type="button"
122
+ aria-label="Clear selection"
123
+ onClick={(e) => {
124
+ e.stopPropagation();
125
+ onValueChange(undefined);
126
+ }}
127
+ className="absolute right-9 top-1/2 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
128
+ >
129
+ <X className="size-3.5" />
130
+ </button>
131
+ )}
132
+ </div>
133
+ );
134
+ }
135
+
136
+ export { Combobox };
@@ -0,0 +1,91 @@
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 {
5
+ Command,
6
+ CommandInput,
7
+ CommandList,
8
+ CommandEmpty,
9
+ CommandGroup,
10
+ CommandItem,
11
+ } from "./command";
12
+
13
+ function CommandSample({ onSelect }: { onSelect?: (v: string) => void }) {
14
+ return (
15
+ <Command>
16
+ <CommandInput placeholder="Search…" />
17
+ <CommandList>
18
+ <CommandEmpty>No results.</CommandEmpty>
19
+ <CommandGroup heading="Fruit">
20
+ <CommandItem value="apple" onSelect={() => onSelect?.("apple")}>
21
+ Apple
22
+ </CommandItem>
23
+ <CommandItem value="banana" onSelect={() => onSelect?.("banana")}>
24
+ Banana
25
+ </CommandItem>
26
+ <CommandItem value="cherry" onSelect={() => onSelect?.("cherry")}>
27
+ Cherry
28
+ </CommandItem>
29
+ </CommandGroup>
30
+ </CommandList>
31
+ </Command>
32
+ );
33
+ }
34
+
35
+ describe("Command", () => {
36
+ it("renders input with combobox role and options", () => {
37
+ render(<CommandSample />);
38
+ expect(screen.getByRole("combobox")).toBeInTheDocument();
39
+ expect(screen.getAllByRole("option")).toHaveLength(3);
40
+ });
41
+
42
+ it("filters items based on input typing", async () => {
43
+ const user = userEvent.setup();
44
+ render(<CommandSample />);
45
+ const input = screen.getByPlaceholderText("Search…");
46
+ await user.type(input, "ban");
47
+ const visible = screen
48
+ .getAllByRole("option")
49
+ .filter((el) => el.getAttribute("aria-disabled") !== "true" && el.getAttribute("data-hidden") !== "true");
50
+ expect(visible.map((el) => el.textContent)).toEqual(["Banana"]);
51
+ });
52
+
53
+ it("shows empty state when no items match", async () => {
54
+ const user = userEvent.setup();
55
+ render(<CommandSample />);
56
+ await user.type(screen.getByPlaceholderText("Search…"), "xyz");
57
+ expect(screen.getByText("No results.")).toBeInTheDocument();
58
+ });
59
+
60
+ it("supports ArrowDown / ArrowUp navigation via aria-selected", async () => {
61
+ const user = userEvent.setup();
62
+ render(<CommandSample />);
63
+ const input = screen.getByRole("combobox");
64
+ input.focus();
65
+ // First item is selected by default
66
+ const options = screen.getAllByRole("option");
67
+ expect(options[0].getAttribute("aria-selected")).toBe("true");
68
+ await user.keyboard("{ArrowDown}");
69
+ expect(screen.getAllByRole("option")[1].getAttribute("aria-selected")).toBe("true");
70
+ await user.keyboard("{ArrowUp}");
71
+ expect(screen.getAllByRole("option")[0].getAttribute("aria-selected")).toBe("true");
72
+ });
73
+
74
+ it("calls onSelect when item is clicked", async () => {
75
+ const user = userEvent.setup();
76
+ const onSelect = vi.fn();
77
+ render(<CommandSample onSelect={onSelect} />);
78
+ await user.click(screen.getByText("Banana"));
79
+ expect(onSelect).toHaveBeenCalledWith("banana");
80
+ });
81
+
82
+ it("calls onSelect when Enter is pressed on highlighted item", async () => {
83
+ const user = userEvent.setup();
84
+ const onSelect = vi.fn();
85
+ render(<CommandSample onSelect={onSelect} />);
86
+ const input = screen.getByRole("combobox");
87
+ input.focus();
88
+ await user.keyboard("{ArrowDown}{Enter}");
89
+ expect(onSelect).toHaveBeenCalledWith("banana");
90
+ });
91
+ });