@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,103 @@
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 { Input, inputVariants } from "./input";
5
+
6
+ // ─── inputVariants CVA ────────────────────────────────────────────────────────
7
+
8
+ describe("inputVariants", () => {
9
+ it("returns default size classes when called with no args", () => {
10
+ const result = inputVariants({});
11
+ expect(result).toContain("h-11");
12
+ });
13
+
14
+ it("maps size prop to correct classes", () => {
15
+ expect(inputVariants({ size: "sm" })).toContain("h-9");
16
+ expect(inputVariants({ size: "default" })).toContain("h-11");
17
+ expect(inputVariants({ size: "lg" })).toContain("h-12");
18
+ });
19
+
20
+ it("includes aria-invalid styles in base classes", () => {
21
+ const result = inputVariants({});
22
+ expect(result).toContain("aria-invalid:border-destructive");
23
+ });
24
+ });
25
+
26
+ // ─── <Input> component ────────────────────────────────────────────────────────
27
+
28
+ describe("Input component", () => {
29
+ it("renders as <input> by default", () => {
30
+ render(<Input placeholder="enter" />);
31
+ const el = screen.getByPlaceholderText("enter");
32
+ expect(el.tagName).toBe("INPUT");
33
+ });
34
+
35
+ it("applies data-slot=input attribute", () => {
36
+ render(<Input data-testid="i" />);
37
+ expect(screen.getByTestId("i").getAttribute("data-slot")).toBe("input");
38
+ });
39
+
40
+ it("forwards ref to the underlying input element", () => {
41
+ const ref = { current: null as HTMLInputElement | null };
42
+ render(<Input ref={ref} />);
43
+ expect(ref.current).toBeInstanceOf(HTMLInputElement);
44
+ });
45
+
46
+ it("respects type prop", () => {
47
+ render(<Input type="email" data-testid="i" />);
48
+ expect((screen.getByTestId("i") as HTMLInputElement).type).toBe("email");
49
+ });
50
+
51
+ it("accepts user input", async () => {
52
+ const user = userEvent.setup();
53
+ render(<Input data-testid="i" />);
54
+ const el = screen.getByTestId("i") as HTMLInputElement;
55
+ await user.type(el, "hello");
56
+ expect(el.value).toBe("hello");
57
+ });
58
+
59
+ it("calls onChange handler", async () => {
60
+ const onChange = vi.fn();
61
+ const user = userEvent.setup();
62
+ render(<Input data-testid="i" onChange={onChange} />);
63
+ await user.type(screen.getByTestId("i"), "a");
64
+ expect(onChange).toHaveBeenCalled();
65
+ });
66
+
67
+ it("respects disabled prop", async () => {
68
+ const onChange = vi.fn();
69
+ const user = userEvent.setup();
70
+ render(<Input data-testid="i" disabled onChange={onChange} />);
71
+ const el = screen.getByTestId("i") as HTMLInputElement;
72
+ expect(el.disabled).toBe(true);
73
+ await user.type(el, "x");
74
+ expect(onChange).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it("wires aria-invalid for invalid state", () => {
78
+ render(<Input data-testid="i" aria-invalid="true" />);
79
+ expect(screen.getByTestId("i").getAttribute("aria-invalid")).toBe("true");
80
+ });
81
+
82
+ it("respects controlled value", () => {
83
+ render(<Input data-testid="i" value="hello" onChange={() => {}} />);
84
+ expect((screen.getByTestId("i") as HTMLInputElement).value).toBe("hello");
85
+ });
86
+
87
+ it("applies size class from size prop", () => {
88
+ render(<Input data-testid="i" size="sm" />);
89
+ expect(screen.getByTestId("i").className).toContain("h-9");
90
+ });
91
+
92
+ it("merges className via twMerge", () => {
93
+ render(<Input data-testid="i" className="text-red-500" />);
94
+ expect(screen.getByTestId("i").className).toContain("text-red-500");
95
+ });
96
+
97
+ it("passes through name and id", () => {
98
+ render(<Input id="email" name="email" data-testid="i" />);
99
+ const el = screen.getByTestId("i");
100
+ expect(el.id).toBe("email");
101
+ expect(el.getAttribute("name")).toBe("email");
102
+ });
103
+ });
@@ -1,17 +1,34 @@
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
 
4
- export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
5
+ const inputVariants = cva(
6
+ "flex w-full rounded-xl border border-border bg-input/50 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:bg-input disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive/40",
7
+ {
8
+ variants: {
9
+ size: {
10
+ sm: "h-9 px-3 text-base md:text-sm",
11
+ default: "h-11 px-4 py-2",
12
+ lg: "h-12 px-5 text-base",
13
+ },
14
+ },
15
+ defaultVariants: {
16
+ size: "default",
17
+ },
18
+ }
19
+ );
20
+
21
+ export interface InputProps
22
+ extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
23
+ VariantProps<typeof inputVariants> {}
5
24
 
6
25
  const Input = React.forwardRef<HTMLInputElement, InputProps>(
7
- ({ className, type, ...props }, ref) => {
26
+ ({ className, type, size, ...props }, ref) => {
8
27
  return (
9
28
  <input
10
29
  type={type}
11
- className={cn(
12
- "flex h-11 w-full rounded-xl border border-border bg-input/50 backdrop-blur-md px-4 py-2 text-base transition-all duration-300 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:bg-input focus-visible:border-accent-foreground/30 focus-visible:shadow-lg focus-visible:shadow-accent/10 disabled:cursor-not-allowed disabled:opacity-50",
13
- className
14
- )}
30
+ data-slot="input"
31
+ className={cn(inputVariants({ size }), className)}
15
32
  ref={ref}
16
33
  {...props}
17
34
  />
@@ -20,4 +37,4 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
20
37
  );
21
38
  Input.displayName = "Input";
22
39
 
23
- export { Input };
40
+ export { Input, inputVariants };
@@ -0,0 +1,52 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Mail, ChevronRight } from "lucide-react";
3
+ import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "./item";
4
+ import { Avatar, AvatarFallback } from "./avatar";
5
+
6
+ const meta = {
7
+ title: "Components/Item",
8
+ component: Item,
9
+ tags: ["autodocs"],
10
+ } satisfies Meta<typeof Item>;
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof meta>;
14
+
15
+ export const Default: Story = {
16
+ render: () => (
17
+ <Item interactive className="w-80">
18
+ <ItemMedia>
19
+ <Mail className="size-5" />
20
+ </ItemMedia>
21
+ <ItemContent>
22
+ <ItemTitle>Inbox</ItemTitle>
23
+ <ItemDescription>3 new messages</ItemDescription>
24
+ </ItemContent>
25
+ <ItemActions>
26
+ <ChevronRight className="size-4 text-muted-foreground" />
27
+ </ItemActions>
28
+ </Item>
29
+ ),
30
+ };
31
+
32
+ export const Variants: Story = {
33
+ render: () => (
34
+ <div className="flex w-80 flex-col gap-2">
35
+ <Item variant="default" interactive>
36
+ <ItemMedia>
37
+ <Avatar size="sm"><AvatarFallback>AS</AvatarFallback></Avatar>
38
+ </ItemMedia>
39
+ <ItemContent>
40
+ <ItemTitle>Alice Smith</ItemTitle>
41
+ <ItemDescription>Online</ItemDescription>
42
+ </ItemContent>
43
+ </Item>
44
+ <Item variant="muted">
45
+ <ItemContent><ItemTitle>Muted background</ItemTitle></ItemContent>
46
+ </Item>
47
+ <Item variant="outline">
48
+ <ItemContent><ItemTitle>Outline border</ItemTitle></ItemContent>
49
+ </Item>
50
+ </div>
51
+ ),
52
+ };
@@ -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
+ });