@bearmenu/ui 0.5.2 → 0.6.1

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 (227) 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 +4 -1
  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 +3 -1
  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 +16 -3
  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/scroll-fade.d.ts +15 -0
  86. package/dist/components/scroll-fade.js +116 -0
  87. package/dist/components/search-bar.d.ts +1 -2
  88. package/dist/components/search-bar.js +37 -60
  89. package/dist/components/searchable-select.js +76 -72
  90. package/dist/components/select.d.ts +5 -1
  91. package/dist/components/select.js +30 -14
  92. package/dist/components/selection-bar.d.ts +11 -0
  93. package/dist/components/selection-bar.js +65 -0
  94. package/dist/components/separator.js +3 -2
  95. package/dist/components/sheet.d.ts +1 -1
  96. package/dist/components/sheet.js +1 -1
  97. package/dist/components/skeleton-card.js +1 -1
  98. package/dist/components/skeleton.js +1 -1
  99. package/dist/components/slider.js +21 -4
  100. package/dist/components/sliding-panels.js +9 -6
  101. package/dist/components/spinner.d.ts +17 -0
  102. package/dist/components/spinner.js +50 -0
  103. package/dist/components/stepper.d.ts +18 -0
  104. package/dist/components/stepper.js +199 -0
  105. package/dist/components/switch.js +4 -3
  106. package/dist/components/table.d.ts +23 -5
  107. package/dist/components/table.js +121 -35
  108. package/dist/components/tabs.d.ts +3 -1
  109. package/dist/components/tabs.js +37 -9
  110. package/dist/components/tag-group.d.ts +9 -0
  111. package/dist/components/tag-group.js +26 -0
  112. package/dist/components/tag.d.ts +17 -0
  113. package/dist/components/tag.js +61 -0
  114. package/dist/components/text.d.ts +1 -1
  115. package/dist/components/text.js +1 -1
  116. package/dist/components/textarea.js +2 -1
  117. package/dist/components/toast.js +1 -1
  118. package/dist/components/toaster.js +1 -1
  119. package/dist/components/toggle-group.js +1 -1
  120. package/dist/components/toggle.js +1 -1
  121. package/dist/components/tooltip.d.ts +3 -2
  122. package/dist/components/tooltip.js +16 -2
  123. package/package.json +45 -26
  124. package/src/components/accordion.tsx +11 -6
  125. package/src/components/alert-dialog.tsx +4 -2
  126. package/src/components/alert.tsx +20 -8
  127. package/src/components/avatar.tsx +106 -13
  128. package/src/components/badge.tsx +12 -11
  129. package/src/components/breadcrumb.tsx +5 -2
  130. package/src/components/button-group.stories.tsx +52 -0
  131. package/src/components/button-group.tsx +40 -0
  132. package/src/components/button.test.tsx +174 -0
  133. package/src/components/button.tsx +42 -19
  134. package/src/components/calendar.tsx +2 -2
  135. package/src/components/card.tsx +55 -13
  136. package/src/components/carousel.tsx +85 -13
  137. package/src/components/checkbox.test.tsx +58 -0
  138. package/src/components/checkbox.tsx +8 -3
  139. package/src/components/circular-progress.stories.tsx +39 -0
  140. package/src/components/circular-progress.tsx +102 -0
  141. package/src/components/collapsible.stories.tsx +7 -3
  142. package/src/components/combobox.stories.tsx +82 -0
  143. package/src/components/combobox.test.tsx +118 -0
  144. package/src/components/combobox.tsx +136 -0
  145. package/src/components/command.test.tsx +91 -0
  146. package/src/components/command.tsx +38 -15
  147. package/src/components/currency-input.test.tsx +63 -0
  148. package/src/components/currency-input.tsx +84 -66
  149. package/src/components/date-input.stories.tsx +31 -0
  150. package/src/components/date-input.tsx +43 -0
  151. package/src/components/description-list.stories.tsx +62 -0
  152. package/src/components/description-list.tsx +68 -0
  153. package/src/components/dialog.test.tsx +84 -0
  154. package/src/components/dialog.tsx +64 -24
  155. package/src/components/drawer.tsx +72 -23
  156. package/src/components/dropdown-menu.test.tsx +180 -0
  157. package/src/components/dropdown-menu.tsx +31 -6
  158. package/src/components/field.stories.tsx +36 -0
  159. package/src/components/field.test.tsx +157 -0
  160. package/src/components/field.tsx +146 -0
  161. package/src/components/form.stories.tsx +3 -2
  162. package/src/components/form.test.tsx +95 -0
  163. package/src/components/form.tsx +9 -0
  164. package/src/components/haptic-button.tsx +1 -1
  165. package/src/components/input-group.stories.tsx +61 -0
  166. package/src/components/input-group.tsx +99 -0
  167. package/src/components/input.test.tsx +103 -0
  168. package/src/components/input.tsx +24 -7
  169. package/src/components/item.stories.tsx +52 -0
  170. package/src/components/item.tsx +115 -0
  171. package/src/components/kbd.stories.tsx +33 -0
  172. package/src/components/kbd.tsx +46 -0
  173. package/src/components/lazy-markdown.tsx +10 -7
  174. package/src/components/multi-select-combobox.test.tsx +101 -0
  175. package/src/components/multi-select-combobox.tsx +85 -82
  176. package/src/components/multi-select-menu.tsx +49 -41
  177. package/src/components/native-select.stories.tsx +52 -0
  178. package/src/components/native-select.tsx +45 -0
  179. package/src/components/pagination.tsx +64 -5
  180. package/src/components/popover.tsx +66 -3
  181. package/src/components/progress.tsx +27 -4
  182. package/src/components/radio-group.test.tsx +64 -0
  183. package/src/components/radio-group.tsx +11 -3
  184. package/src/components/rating.test.tsx +112 -0
  185. package/src/components/rating.tsx +97 -82
  186. package/src/components/scroll-fade.stories.tsx +98 -0
  187. package/src/components/scroll-fade.tsx +127 -0
  188. package/src/components/search-bar.stories.tsx +1 -32
  189. package/src/components/search-bar.tsx +22 -59
  190. package/src/components/searchable-select.tsx +69 -73
  191. package/src/components/select.tsx +34 -19
  192. package/src/components/selection-bar.stories.tsx +37 -0
  193. package/src/components/selection-bar.tsx +65 -0
  194. package/src/components/separator.tsx +5 -2
  195. package/src/components/sheet.tsx +1 -1
  196. package/src/components/skeleton.tsx +2 -2
  197. package/src/components/slider.test.tsx +45 -0
  198. package/src/components/slider.tsx +31 -12
  199. package/src/components/sliding-panels.tsx +8 -5
  200. package/src/components/spinner.stories.tsx +35 -0
  201. package/src/components/spinner.tsx +50 -0
  202. package/src/components/stepper.stories.tsx +71 -0
  203. package/src/components/stepper.tsx +220 -0
  204. package/src/components/switch.test.tsx +52 -0
  205. package/src/components/switch.tsx +4 -3
  206. package/src/components/table.tsx +140 -35
  207. package/src/components/tabs.test.tsx +107 -0
  208. package/src/components/tabs.tsx +70 -23
  209. package/src/components/tag-group.stories.tsx +39 -0
  210. package/src/components/tag-group.tsx +28 -0
  211. package/src/components/tag.stories.tsx +50 -0
  212. package/src/components/tag.tsx +63 -0
  213. package/src/components/text.test.tsx +1 -1
  214. package/src/components/text.tsx +19 -3
  215. package/src/components/textarea.test.tsx +48 -0
  216. package/src/components/textarea.tsx +2 -1
  217. package/src/components/toast.tsx +4 -3
  218. package/src/components/toggle.tsx +2 -2
  219. package/src/components/tooltip.tsx +12 -2
  220. package/src/globals.css +126 -90
  221. package/dist/chunk-2BMJRXQX.js +0 -80
  222. package/dist/chunk-4AVS6CV4.js +0 -89
  223. package/dist/chunk-4UO73SKC.js +0 -26
  224. package/dist/chunk-CW24PLDW.js +0 -24
  225. package/dist/chunk-IQASCH2V.js +0 -25
  226. package/dist/chunk-NKHH23YK.js +0 -31
  227. package/dist/chunk-QJ54Z7TC.js +0 -83
@@ -0,0 +1,115 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Slot } from "@radix-ui/react-slot";
5
+ import { cva, type VariantProps } from "class-variance-authority";
6
+ import { cn } from "../lib/utils";
7
+
8
+ const itemVariants = cva(
9
+ "flex w-full items-center gap-3 rounded-xl text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
10
+ {
11
+ variants: {
12
+ variant: {
13
+ default: "bg-transparent",
14
+ muted: "bg-muted",
15
+ outline: "border border-border bg-transparent",
16
+ },
17
+ size: {
18
+ sm: "px-3 py-2",
19
+ default: "px-4 py-3",
20
+ lg: "px-5 py-4",
21
+ },
22
+ interactive: {
23
+ true: "cursor-pointer hover:bg-accent hover:text-accent-foreground active:scale-[0.99]",
24
+ false: "",
25
+ },
26
+ },
27
+ defaultVariants: {
28
+ variant: "default",
29
+ size: "default",
30
+ interactive: false,
31
+ },
32
+ }
33
+ );
34
+
35
+ type ItemProps = React.HTMLAttributes<HTMLDivElement> &
36
+ VariantProps<typeof itemVariants> & {
37
+ asChild?: boolean;
38
+ };
39
+
40
+ const Item = React.forwardRef<HTMLDivElement, ItemProps>(
41
+ ({ className, variant, size, interactive, asChild, ...props }, ref) => {
42
+ const Comp = asChild ? Slot : "div";
43
+ return (
44
+ <Comp
45
+ ref={ref}
46
+ data-slot="item"
47
+ className={cn(itemVariants({ variant, size, interactive }), className)}
48
+ {...props}
49
+ />
50
+ );
51
+ }
52
+ );
53
+ Item.displayName = "Item";
54
+
55
+ const ItemMedia = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
56
+ ({ className, ...props }, ref) => (
57
+ <div
58
+ ref={ref}
59
+ data-slot="item-media"
60
+ className={cn("inline-flex shrink-0 items-center justify-center text-muted-foreground", className)}
61
+ {...props}
62
+ />
63
+ )
64
+ );
65
+ ItemMedia.displayName = "ItemMedia";
66
+
67
+ const ItemContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
68
+ ({ className, ...props }, ref) => (
69
+ <div
70
+ ref={ref}
71
+ data-slot="item-content"
72
+ className={cn("flex min-w-0 flex-1 flex-col gap-0.5", className)}
73
+ {...props}
74
+ />
75
+ )
76
+ );
77
+ ItemContent.displayName = "ItemContent";
78
+
79
+ const ItemTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
80
+ ({ className, ...props }, ref) => (
81
+ <div
82
+ ref={ref}
83
+ data-slot="item-title"
84
+ className={cn("truncate text-sm font-medium text-foreground", className)}
85
+ {...props}
86
+ />
87
+ )
88
+ );
89
+ ItemTitle.displayName = "ItemTitle";
90
+
91
+ const ItemDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
92
+ ({ className, ...props }, ref) => (
93
+ <div
94
+ ref={ref}
95
+ data-slot="item-description"
96
+ className={cn("truncate text-xs text-muted-foreground", className)}
97
+ {...props}
98
+ />
99
+ )
100
+ );
101
+ ItemDescription.displayName = "ItemDescription";
102
+
103
+ const ItemActions = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
104
+ ({ className, ...props }, ref) => (
105
+ <div
106
+ ref={ref}
107
+ data-slot="item-actions"
108
+ className={cn("ml-auto inline-flex shrink-0 items-center gap-1", className)}
109
+ {...props}
110
+ />
111
+ )
112
+ );
113
+ ItemActions.displayName = "ItemActions";
114
+
115
+ export { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions, itemVariants };
@@ -0,0 +1,33 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Kbd, KbdGroup } from "./kbd";
3
+
4
+ const meta = {
5
+ title: "Components/Kbd",
6
+ component: Kbd,
7
+ tags: ["autodocs"],
8
+ } satisfies Meta<typeof Kbd>;
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof meta>;
12
+
13
+ export const Default: Story = { args: { children: "⌘" } };
14
+
15
+ export const Sizes: Story = {
16
+ render: () => (
17
+ <div className="flex items-center gap-2">
18
+ <Kbd size="sm">⌘</Kbd>
19
+ <Kbd size="default">⌘</Kbd>
20
+ <Kbd size="lg">⌘</Kbd>
21
+ </div>
22
+ ),
23
+ };
24
+
25
+ export const Combination: Story = {
26
+ render: () => (
27
+ <KbdGroup>
28
+ <Kbd>⌘</Kbd>
29
+ <span className="text-muted-foreground">+</span>
30
+ <Kbd>K</Kbd>
31
+ </KbdGroup>
32
+ ),
33
+ };
@@ -0,0 +1,46 @@
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { cn } from "../lib/utils";
4
+
5
+ const kbdVariants = cva(
6
+ "inline-flex select-none items-center justify-center gap-1 rounded-md border border-border bg-muted font-mono font-medium text-muted-foreground shadow-[0_1px_0_0_var(--border)]",
7
+ {
8
+ variants: {
9
+ size: {
10
+ sm: "h-5 min-w-5 px-1 text-[10px]",
11
+ default: "h-6 min-w-6 px-1.5 text-xs",
12
+ lg: "h-7 min-w-7 px-2 text-sm",
13
+ },
14
+ },
15
+ defaultVariants: {
16
+ size: "default",
17
+ },
18
+ }
19
+ );
20
+
21
+ const Kbd = React.forwardRef<
22
+ HTMLElement,
23
+ React.HTMLAttributes<HTMLElement> & VariantProps<typeof kbdVariants>
24
+ >(({ className, size, ...props }, ref) => (
25
+ <kbd
26
+ ref={ref}
27
+ data-slot="kbd"
28
+ className={cn(kbdVariants({ size }), className)}
29
+ {...props}
30
+ />
31
+ ));
32
+ Kbd.displayName = "Kbd";
33
+
34
+ const KbdGroup = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(
35
+ ({ className, ...props }, ref) => (
36
+ <span
37
+ ref={ref}
38
+ data-slot="kbd-group"
39
+ className={cn("inline-flex items-center gap-1", className)}
40
+ {...props}
41
+ />
42
+ )
43
+ );
44
+ KbdGroup.displayName = "KbdGroup";
45
+
46
+ export { Kbd, KbdGroup, kbdVariants };
@@ -1,20 +1,23 @@
1
1
  "use client";
2
2
 
3
- import dynamic from "next/dynamic";
3
+ import * as React from "react";
4
4
  import type { Components } from "react-markdown";
5
5
 
6
- // Dynamically imported to keep react-markdown + remark-gfm out of the initial bundle.
7
- const MarkdownRendererDynamic = dynamic(
8
- () => import("./markdown-renderer").then((m) => m.MarkdownRenderer),
9
- { ssr: false }
6
+ const MarkdownRendererLazy = React.lazy(() =>
7
+ import("./markdown-renderer").then((m) => ({ default: m.MarkdownRenderer }))
10
8
  );
11
9
 
12
10
  type LazyMarkdownProps = {
13
11
  content: string;
14
12
  className?: string;
15
13
  components?: Components;
14
+ fallback?: React.ReactNode;
16
15
  };
17
16
 
18
- export function LazyMarkdown(props: LazyMarkdownProps) {
19
- return <MarkdownRendererDynamic {...props} />;
17
+ export function LazyMarkdown({ fallback = null, ...props }: LazyMarkdownProps) {
18
+ return (
19
+ <React.Suspense fallback={fallback}>
20
+ <MarkdownRendererLazy {...props} />
21
+ </React.Suspense>
22
+ );
20
23
  }
@@ -0,0 +1,101 @@
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 { MultiSelectCombobox, type MultiSelectOption } from "./multi-select-combobox";
6
+
7
+ const options: MultiSelectOption[] = [
8
+ { value: "apple", label: "Apple" },
9
+ { value: "banana", label: "Banana" },
10
+ { value: "cherry", label: "Cherry" },
11
+ { value: "date", label: "Date" },
12
+ ];
13
+
14
+ function Wrap({
15
+ initial = [],
16
+ maxDisplay,
17
+ onChange,
18
+ }: {
19
+ initial?: string[];
20
+ maxDisplay?: number;
21
+ onChange?: (v: string[]) => void;
22
+ }) {
23
+ const [selected, setSelected] = React.useState<string[]>(initial);
24
+ return (
25
+ <MultiSelectCombobox
26
+ options={options}
27
+ selected={selected}
28
+ maxDisplay={maxDisplay}
29
+ onSelectionChange={(v) => {
30
+ setSelected(v);
31
+ onChange?.(v);
32
+ }}
33
+ />
34
+ );
35
+ }
36
+
37
+ describe("MultiSelectCombobox", () => {
38
+ it("renders placeholder when nothing is selected", () => {
39
+ render(<Wrap />);
40
+ expect(screen.getByRole("combobox")).toHaveTextContent("Select items...");
41
+ });
42
+
43
+ it("renders count when items are selected", () => {
44
+ render(<Wrap initial={["apple", "banana"]} />);
45
+ expect(screen.getByRole("combobox")).toHaveTextContent("2 selected");
46
+ });
47
+
48
+ it("opens on trigger click and exposes aria-expanded=true", async () => {
49
+ const user = userEvent.setup();
50
+ render(<Wrap />);
51
+ const trigger = screen.getByRole("combobox");
52
+ await user.click(trigger);
53
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
54
+ expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
55
+ });
56
+
57
+ it("adds a value when an option is selected", async () => {
58
+ const user = userEvent.setup();
59
+ const onChange = vi.fn();
60
+ render(<Wrap onChange={onChange} />);
61
+ await user.click(screen.getByRole("combobox"));
62
+ await user.click(screen.getByRole("option", { name: /apple/i }));
63
+ expect(onChange).toHaveBeenCalledWith(["apple"]);
64
+ });
65
+
66
+ it("removes a value when an already-selected option is clicked", async () => {
67
+ const user = userEvent.setup();
68
+ const onChange = vi.fn();
69
+ render(<Wrap initial={["apple", "banana"]} onChange={onChange} />);
70
+ await user.click(screen.getByRole("combobox"));
71
+ await user.click(screen.getByRole("option", { name: /banana/i }));
72
+ expect(onChange).toHaveBeenCalledWith(["apple"]);
73
+ });
74
+
75
+ it("renders one removal button per visible selection", () => {
76
+ render(<Wrap initial={["apple", "banana"]} />);
77
+ expect(screen.getByRole("button", { name: "Remove Apple" })).toBeInTheDocument();
78
+ expect(screen.getByRole("button", { name: "Remove Banana" })).toBeInTheDocument();
79
+ });
80
+
81
+ it("removes a value when its badge X is clicked", async () => {
82
+ const user = userEvent.setup();
83
+ const onChange = vi.fn();
84
+ render(<Wrap initial={["apple", "banana"]} onChange={onChange} />);
85
+ await user.click(screen.getByRole("button", { name: "Remove Apple" }));
86
+ expect(onChange).toHaveBeenCalledWith(["banana"]);
87
+ });
88
+
89
+ it("respects maxDisplay and shows a +N more badge", () => {
90
+ render(<Wrap initial={["apple", "banana", "cherry", "date"]} maxDisplay={2} />);
91
+ expect(screen.getByRole("button", { name: "Remove Apple" })).toBeInTheDocument();
92
+ expect(screen.getByRole("button", { name: "Remove Banana" })).toBeInTheDocument();
93
+ expect(screen.queryByRole("button", { name: "Remove Cherry" })).toBeNull();
94
+ expect(screen.getByText("+2 more")).toBeInTheDocument();
95
+ });
96
+
97
+ it("does not render the +N more badge when all fit", () => {
98
+ render(<Wrap initial={["apple"]} maxDisplay={3} />);
99
+ expect(screen.queryByText(/\+\d+ more/)).toBeNull();
100
+ });
101
+ });
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { Check, ChevronsUpDown, X } from "lucide-react";
4
+ import { ChevronsUpDown, X } from "lucide-react";
5
5
  import { cn } from "../lib/utils";
6
6
  import { Button } from "./button";
7
7
  import { Badge } from "./badge";
8
+ import { Checkbox } from "./checkbox";
8
9
  import {
9
10
  Command,
10
11
  CommandEmpty,
@@ -50,6 +51,7 @@ export function MultiSelectCombobox({
50
51
  shouldFilter = true,
51
52
  }: MultiSelectComboboxProps) {
52
53
  const [open, setOpen] = React.useState(false);
54
+ const listboxId = React.useId();
53
55
 
54
56
  const handleSelect = (value: string) => {
55
57
  const newSelected = selected.includes(value)
@@ -58,91 +60,92 @@ export function MultiSelectCombobox({
58
60
  onSelectionChange(newSelected);
59
61
  };
60
62
 
61
- const handleRemove = (value: string, e: React.MouseEvent | React.KeyboardEvent) => {
62
- e.stopPropagation();
63
- onSelectionChange(selected.filter((item) => item !== value));
64
- };
65
-
66
- const selectedLabels = selected
67
- .map((value) => options.find((opt) => opt.value === value)?.label || value)
68
- .slice(0, maxDisplay);
69
-
70
- const remainingCount = selected.length - maxDisplay;
63
+ const visible = selected.slice(0, maxDisplay);
64
+ const remaining = selected.length - maxDisplay;
71
65
 
72
66
  return (
73
- <Popover open={open} onOpenChange={setOpen}>
74
- <PopoverTrigger asChild>
75
- <Button
76
- variant="outline"
77
- role="combobox"
78
- aria-expanded={open}
79
- className={cn(
80
- "w-full justify-between h-auto min-h-10 py-2",
81
- selected.length === 0 && "text-muted-foreground",
82
- className
83
- )}
84
- disabled={disabled}
67
+ <div className={cn("flex flex-col gap-2", className)}>
68
+ <Popover open={open} onOpenChange={setOpen}>
69
+ <PopoverTrigger asChild>
70
+ <Button
71
+ variant="outline"
72
+ role="combobox"
73
+ aria-expanded={open}
74
+ aria-haspopup="listbox"
75
+ aria-controls={open ? listboxId : undefined}
76
+ className={cn(
77
+ "w-full justify-between font-normal",
78
+ selected.length === 0 && "text-muted-foreground"
79
+ )}
80
+ disabled={disabled}
81
+ >
82
+ <span className="truncate">
83
+ {selected.length === 0
84
+ ? placeholder
85
+ : `${selected.length} selected`}
86
+ </span>
87
+ <ChevronsUpDown className="size-4 shrink-0 opacity-50" />
88
+ </Button>
89
+ </PopoverTrigger>
90
+ <PopoverContent
91
+ id={listboxId}
92
+ className="w-[var(--radix-popover-trigger-width)] p-0"
93
+ align="start"
85
94
  >
86
- <div className="flex flex-wrap gap-1 flex-1 text-left">
87
- {selected.length === 0 ? (
88
- <span>{placeholder}</span>
89
- ) : (
90
- <>
91
- {selectedLabels.map((label, i) => (
92
- <Badge key={selected[i]} variant="secondary" className="text-xs">
93
- {label}
94
- <div
95
- role="button"
96
- tabIndex={0}
97
- className="ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
98
- onClick={(e) => handleRemove(selected[i], e)}
99
- onKeyDown={(e) => {
100
- if (e.key === "Enter" || e.key === " ") {
101
- e.preventDefault();
102
- handleRemove(selected[i], e);
103
- }
104
- }}
95
+ <Command shouldFilter={shouldFilter}>
96
+ <CommandInput placeholder={searchPlaceholder} onValueChange={onSearchChange} />
97
+ <CommandList>
98
+ <CommandEmpty>{loading ? "Loading…" : emptyText}</CommandEmpty>
99
+ <CommandGroup>
100
+ {options.map((option) => {
101
+ const isSelected = selected.includes(option.value);
102
+ return (
103
+ <CommandItem
104
+ key={option.value}
105
+ value={option.value}
106
+ onSelect={() => handleSelect(option.value)}
107
+ className="gap-2"
105
108
  >
106
- <X className="h-3 w-3" />
107
- </div>
108
- </Badge>
109
- ))}
110
- {remainingCount > 0 && (
111
- <Badge variant="secondary" className="text-xs">
112
- +{remainingCount} more
113
- </Badge>
114
- )}
115
- </>
116
- )}
117
- </div>
118
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
119
- </Button>
120
- </PopoverTrigger>
121
- <PopoverContent className="w-full p-0" align="start">
122
- <Command shouldFilter={shouldFilter}>
123
- <CommandInput placeholder={searchPlaceholder} onValueChange={onSearchChange} />
124
- <CommandList>
125
- <CommandEmpty>{loading ? "Loading..." : emptyText}</CommandEmpty>
126
- <CommandGroup>
127
- {options.map((option) => (
128
- <CommandItem
129
- key={option.value}
130
- value={option.value}
131
- onSelect={() => handleSelect(option.value)}
109
+ <Checkbox
110
+ checked={isSelected}
111
+ tabIndex={-1}
112
+ aria-hidden
113
+ className="pointer-events-none"
114
+ />
115
+ {option.label}
116
+ </CommandItem>
117
+ );
118
+ })}
119
+ </CommandGroup>
120
+ </CommandList>
121
+ </Command>
122
+ </PopoverContent>
123
+ </Popover>
124
+ {selected.length > 0 && (
125
+ <div className="flex flex-wrap gap-1">
126
+ {visible.map((value) => {
127
+ const label = options.find((opt) => opt.value === value)?.label || value;
128
+ return (
129
+ <Badge key={value} variant="secondary" className="gap-1 pr-1">
130
+ {label}
131
+ <button
132
+ type="button"
133
+ aria-label={`Remove ${label}`}
134
+ onClick={() =>
135
+ onSelectionChange(selected.filter((item) => item !== value))
136
+ }
137
+ className="rounded-full p-0.5 hover:bg-foreground/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
132
138
  >
133
- <Check
134
- className={cn(
135
- "mr-2 h-4 w-4",
136
- selected.includes(option.value) ? "opacity-100" : "opacity-0"
137
- )}
138
- />
139
- {option.label}
140
- </CommandItem>
141
- ))}
142
- </CommandGroup>
143
- </CommandList>
144
- </Command>
145
- </PopoverContent>
146
- </Popover>
139
+ <X className="size-3" />
140
+ </button>
141
+ </Badge>
142
+ );
143
+ })}
144
+ {remaining > 0 && (
145
+ <Badge variant="secondary">+{remaining} more</Badge>
146
+ )}
147
+ </div>
148
+ )}
149
+ </div>
147
150
  );
148
151
  }
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
4
5
  import { cn } from "../lib/utils";
5
6
  import { Checkbox } from "./checkbox";
6
- import { Popover, PopoverContent, PopoverTrigger } from "./popover";
7
7
 
8
8
  export interface MultiSelectMenuOption {
9
9
  value: string;
@@ -15,14 +15,10 @@ export interface MultiSelectMenuProps {
15
15
  options: MultiSelectMenuOption[];
16
16
  selected: string[];
17
17
  onSelectedChange: (values: string[]) => void;
18
- /** Trigger element rendered via Radix `asChild` — typically a Button. */
19
18
  trigger: React.ReactNode;
20
- /** Optional heading rendered above the option list. */
21
19
  label?: string;
22
20
  align?: "start" | "center" | "end";
23
- /** Pixel width of the popover content. Defaults to 220. */
24
21
  width?: number;
25
- /** className for the popover content. */
26
22
  contentClassName?: string;
27
23
  emptyText?: string;
28
24
  }
@@ -51,46 +47,58 @@ export function MultiSelectMenu({
51
47
  }
52
48
 
53
49
  return (
54
- <Popover>
55
- <PopoverTrigger asChild>{trigger}</PopoverTrigger>
56
- <PopoverContent
57
- align={align}
58
- className={cn("p-2", contentClassName)}
59
- style={{ width }}
60
- >
61
- {label && (
62
- <div className="text-foreground px-2 py-1.5 text-sm font-semibold">
63
- {label}
64
- </div>
65
- )}
66
- <div role="group" aria-label={label} className="flex flex-col gap-0.5">
50
+ <DropdownMenuPrimitive.Root>
51
+ <DropdownMenuPrimitive.Trigger asChild>{trigger}</DropdownMenuPrimitive.Trigger>
52
+ <DropdownMenuPrimitive.Portal>
53
+ <DropdownMenuPrimitive.Content
54
+ align={align}
55
+ sideOffset={4}
56
+ style={{ width }}
57
+ className={cn(
58
+ "z-50 overflow-hidden rounded-xl border border-border bg-popover p-2 text-popover-foreground shadow-xl",
59
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
60
+ "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
61
+ contentClassName
62
+ )}
63
+ >
64
+ {label && (
65
+ <DropdownMenuPrimitive.Label className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
66
+ {label}
67
+ </DropdownMenuPrimitive.Label>
68
+ )}
67
69
  {options.length === 0 ? (
68
- <p className="text-muted-foreground px-2 py-1.5 text-sm">{emptyText}</p>
70
+ <p className="px-2 py-1.5 text-sm text-muted-foreground">{emptyText}</p>
69
71
  ) : (
70
- options.map((option) => {
71
- const checked = selectedSet.has(option.value);
72
- return (
73
- <label
74
- key={option.value}
75
- className={cn(
76
- "flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm select-none",
77
- "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
78
- option.disabled && "pointer-events-none opacity-50",
79
- )}
80
- >
81
- <Checkbox
72
+ <DropdownMenuPrimitive.Group className="flex flex-col gap-0.5">
73
+ {options.map((option) => {
74
+ const checked = selectedSet.has(option.value);
75
+ return (
76
+ <DropdownMenuPrimitive.CheckboxItem
77
+ key={option.value}
82
78
  checked={checked}
79
+ disabled={option.disabled ?? false}
80
+ onSelect={(e) => e.preventDefault()}
83
81
  onCheckedChange={() => handleToggle(option.value)}
84
- disabled={option.disabled}
85
- aria-label={typeof option.label === "string" ? option.label : option.value}
86
- />
87
- <span className="flex-1">{option.label}</span>
88
- </label>
89
- );
90
- })
82
+ className={cn(
83
+ "relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none",
84
+ "data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
85
+ "data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
86
+ )}
87
+ >
88
+ <Checkbox
89
+ checked={checked}
90
+ tabIndex={-1}
91
+ aria-hidden
92
+ className="pointer-events-none"
93
+ />
94
+ <span className="flex-1">{option.label}</span>
95
+ </DropdownMenuPrimitive.CheckboxItem>
96
+ );
97
+ })}
98
+ </DropdownMenuPrimitive.Group>
91
99
  )}
92
- </div>
93
- </PopoverContent>
94
- </Popover>
100
+ </DropdownMenuPrimitive.Content>
101
+ </DropdownMenuPrimitive.Portal>
102
+ </DropdownMenuPrimitive.Root>
95
103
  );
96
104
  }