@bearmenu/ui 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/dist/{chunk-ERMIA7NP.js → chunk-7PPCH42H.js} +3 -3
  2. package/dist/{chunk-HLQ2IRE7.js → chunk-BAZFVSSG.js} +3 -3
  3. package/dist/chunk-D5QHEHXJ.js +26 -0
  4. package/dist/{chunk-UISA4CIG.js → chunk-N2KNBPAQ.js} +2 -2
  5. package/dist/{chunk-7H54PTVH.js → chunk-PSR7N36A.js} +38 -10
  6. package/dist/chunk-RIDXFY6M.js +38 -0
  7. package/dist/chunk-UUJVXN2O.js +82 -0
  8. package/dist/chunk-WKMLZDUT.js +122 -0
  9. package/dist/chunk-XK2ZDQVE.js +122 -0
  10. package/dist/{chunk-4KOMHDZD.js → chunk-Y5D5LP4A.js} +18 -3
  11. package/dist/chunk-YJ6OBKZJ.js +32 -0
  12. package/dist/{chunk-ESO2EM3L.js → chunk-ZBBUKXM5.js} +2 -2
  13. package/dist/chunk-ZFNOCP6W.js +106 -0
  14. package/dist/components/accordion.js +11 -5
  15. package/dist/components/alert-dialog.js +5 -3
  16. package/dist/components/alert.d.ts +5 -2
  17. package/dist/components/alert.js +25 -9
  18. package/dist/components/avatar.d.ts +17 -2
  19. package/dist/components/avatar.js +91 -12
  20. package/dist/components/badge.d.ts +1 -1
  21. package/dist/components/badge.js +1 -1
  22. package/dist/components/breadcrumb.js +5 -2
  23. package/dist/components/button-group.d.ts +14 -0
  24. package/dist/components/button-group.js +39 -0
  25. package/dist/components/button.d.ts +4 -2
  26. package/dist/components/button.js +1 -1
  27. package/dist/components/calendar.js +3 -3
  28. package/dist/components/card.d.ts +13 -5
  29. package/dist/components/card.js +2 -2
  30. package/dist/components/carousel.d.ts +20 -3
  31. package/dist/components/carousel.js +72 -12
  32. package/dist/components/checkbox.js +1 -1
  33. package/dist/components/circular-progress.d.ts +16 -0
  34. package/dist/components/circular-progress.js +105 -0
  35. package/dist/components/combobox.d.ts +27 -0
  36. package/dist/components/combobox.js +102 -0
  37. package/dist/components/command.d.ts +29 -16
  38. package/dist/components/command.js +2 -2
  39. package/dist/components/currency-input.d.ts +1 -0
  40. package/dist/components/currency-input.js +73 -52
  41. package/dist/components/date-input.d.ts +10 -0
  42. package/dist/components/date-input.js +40 -0
  43. package/dist/components/description-list.d.ts +15 -0
  44. package/dist/components/description-list.js +67 -0
  45. package/dist/components/dialog.d.ts +13 -2
  46. package/dist/components/dialog.js +1 -1
  47. package/dist/components/drawer.d.ts +5 -2
  48. package/dist/components/drawer.js +47 -9
  49. package/dist/components/dropdown-menu.d.ts +7 -2
  50. package/dist/components/dropdown-menu.js +22 -6
  51. package/dist/components/empty-state.js +2 -2
  52. package/dist/components/field.d.ts +22 -0
  53. package/dist/components/field.js +130 -0
  54. package/dist/components/form.d.ts +2 -1
  55. package/dist/components/form.js +11 -1
  56. package/dist/components/haptic-button.js +3 -3
  57. package/dist/components/input-group.d.ts +18 -0
  58. package/dist/components/input-group.js +92 -0
  59. package/dist/components/input.d.ts +8 -2
  60. package/dist/components/input.js +1 -1
  61. package/dist/components/item.d.ts +23 -0
  62. package/dist/components/item.js +120 -0
  63. package/dist/components/kbd.d.ts +13 -0
  64. package/dist/components/kbd.js +49 -0
  65. package/dist/components/lazy-markdown.d.ts +3 -1
  66. package/dist/components/lazy-markdown.js +7 -7
  67. package/dist/components/link-button.js +1 -1
  68. package/dist/components/multi-select-combobox.js +92 -83
  69. package/dist/components/multi-select-menu.d.ts +0 -4
  70. package/dist/components/multi-select-menu.js +28 -17
  71. package/dist/components/native-select.d.ts +12 -0
  72. package/dist/components/native-select.js +49 -0
  73. package/dist/components/pagination.d.ts +5 -2
  74. package/dist/components/pagination.js +66 -7
  75. package/dist/components/place-about-tab.js +4 -4
  76. package/dist/components/place-card.js +1 -1
  77. package/dist/components/place-details-mobile.js +4 -4
  78. package/dist/components/popover.d.ts +7 -1
  79. package/dist/components/popover.js +1 -1
  80. package/dist/components/progress.d.ts +5 -1
  81. package/dist/components/progress.js +17 -3
  82. package/dist/components/radio-group.js +12 -3
  83. package/dist/components/rating.d.ts +2 -0
  84. package/dist/components/rating.js +88 -70
  85. package/dist/components/search-bar.d.ts +1 -2
  86. package/dist/components/search-bar.js +37 -60
  87. package/dist/components/searchable-select.js +76 -72
  88. package/dist/components/select.d.ts +5 -1
  89. package/dist/components/select.js +30 -14
  90. package/dist/components/selection-bar.d.ts +11 -0
  91. package/dist/components/selection-bar.js +65 -0
  92. package/dist/components/separator.js +3 -2
  93. package/dist/components/sheet.d.ts +1 -1
  94. package/dist/components/sheet.js +1 -1
  95. package/dist/components/skeleton-card.js +1 -1
  96. package/dist/components/skeleton.js +1 -1
  97. package/dist/components/slider.js +21 -4
  98. package/dist/components/sliding-panels.js +15 -13
  99. package/dist/components/spinner.d.ts +17 -0
  100. package/dist/components/spinner.js +50 -0
  101. package/dist/components/stepper.d.ts +18 -0
  102. package/dist/components/stepper.js +199 -0
  103. package/dist/components/sticky-container.js +1 -0
  104. package/dist/components/switch.js +4 -3
  105. package/dist/components/table.d.ts +23 -5
  106. package/dist/components/table.js +121 -35
  107. package/dist/components/tabs.d.ts +3 -1
  108. package/dist/components/tabs.js +37 -9
  109. package/dist/components/tag-group.d.ts +9 -0
  110. package/dist/components/tag-group.js +26 -0
  111. package/dist/components/tag.d.ts +17 -0
  112. package/dist/components/tag.js +61 -0
  113. package/dist/components/text.d.ts +3 -3
  114. package/dist/components/text.js +1 -1
  115. package/dist/components/textarea.js +2 -1
  116. package/dist/components/toast.js +1 -1
  117. package/dist/components/toaster.js +1 -1
  118. package/dist/components/toggle-group.js +1 -1
  119. package/dist/components/toggle.js +1 -1
  120. package/dist/components/tooltip.d.ts +3 -2
  121. package/dist/components/tooltip.js +16 -2
  122. package/package.json +44 -26
  123. package/src/components/accordion.tsx +11 -6
  124. package/src/components/alert-dialog.tsx +4 -2
  125. package/src/components/alert.tsx +20 -8
  126. package/src/components/avatar.tsx +106 -13
  127. package/src/components/badge.tsx +12 -11
  128. package/src/components/breadcrumb.tsx +5 -2
  129. package/src/components/button-group.stories.tsx +52 -0
  130. package/src/components/button-group.tsx +40 -0
  131. package/src/components/button.test.tsx +174 -0
  132. package/src/components/button.tsx +42 -19
  133. package/src/components/calendar.tsx +2 -2
  134. package/src/components/card.tsx +55 -13
  135. package/src/components/carousel.tsx +85 -13
  136. package/src/components/checkbox.test.tsx +58 -0
  137. package/src/components/checkbox.tsx +8 -3
  138. package/src/components/circular-progress.stories.tsx +39 -0
  139. package/src/components/circular-progress.tsx +102 -0
  140. package/src/components/collapsible.stories.tsx +7 -3
  141. package/src/components/combobox.stories.tsx +82 -0
  142. package/src/components/combobox.test.tsx +118 -0
  143. package/src/components/combobox.tsx +136 -0
  144. package/src/components/command.test.tsx +91 -0
  145. package/src/components/command.tsx +38 -15
  146. package/src/components/currency-input.test.tsx +63 -0
  147. package/src/components/currency-input.tsx +84 -66
  148. package/src/components/date-input.stories.tsx +31 -0
  149. package/src/components/date-input.tsx +43 -0
  150. package/src/components/description-list.stories.tsx +62 -0
  151. package/src/components/description-list.tsx +68 -0
  152. package/src/components/dialog.test.tsx +84 -0
  153. package/src/components/dialog.tsx +64 -24
  154. package/src/components/drawer.tsx +72 -23
  155. package/src/components/dropdown-menu.test.tsx +180 -0
  156. package/src/components/dropdown-menu.tsx +31 -6
  157. package/src/components/field.stories.tsx +36 -0
  158. package/src/components/field.test.tsx +157 -0
  159. package/src/components/field.tsx +146 -0
  160. package/src/components/form.stories.tsx +3 -2
  161. package/src/components/form.test.tsx +95 -0
  162. package/src/components/form.tsx +9 -0
  163. package/src/components/haptic-button.tsx +1 -1
  164. package/src/components/input-group.stories.tsx +61 -0
  165. package/src/components/input-group.tsx +99 -0
  166. package/src/components/input.test.tsx +103 -0
  167. package/src/components/input.tsx +24 -7
  168. package/src/components/item.stories.tsx +52 -0
  169. package/src/components/item.tsx +115 -0
  170. package/src/components/kbd.stories.tsx +33 -0
  171. package/src/components/kbd.tsx +46 -0
  172. package/src/components/lazy-markdown.tsx +10 -7
  173. package/src/components/multi-select-combobox.test.tsx +101 -0
  174. package/src/components/multi-select-combobox.tsx +85 -82
  175. package/src/components/multi-select-menu.tsx +49 -41
  176. package/src/components/native-select.stories.tsx +52 -0
  177. package/src/components/native-select.tsx +45 -0
  178. package/src/components/pagination.tsx +64 -5
  179. package/src/components/popover.tsx +66 -3
  180. package/src/components/progress.tsx +27 -4
  181. package/src/components/radio-group.test.tsx +64 -0
  182. package/src/components/radio-group.tsx +11 -3
  183. package/src/components/rating.test.tsx +112 -0
  184. package/src/components/rating.tsx +97 -82
  185. package/src/components/search-bar.stories.tsx +1 -32
  186. package/src/components/search-bar.tsx +22 -59
  187. package/src/components/searchable-select.tsx +69 -73
  188. package/src/components/select.tsx +34 -19
  189. package/src/components/selection-bar.stories.tsx +37 -0
  190. package/src/components/selection-bar.tsx +65 -0
  191. package/src/components/separator.tsx +5 -2
  192. package/src/components/sheet.tsx +1 -1
  193. package/src/components/skeleton.tsx +2 -2
  194. package/src/components/slider.test.tsx +45 -0
  195. package/src/components/slider.tsx +31 -12
  196. package/src/components/sliding-panels.tsx +14 -12
  197. package/src/components/spinner.stories.tsx +35 -0
  198. package/src/components/spinner.tsx +50 -0
  199. package/src/components/stepper.stories.tsx +71 -0
  200. package/src/components/stepper.tsx +220 -0
  201. package/src/components/sticky-container.tsx +1 -0
  202. package/src/components/switch.test.tsx +52 -0
  203. package/src/components/switch.tsx +4 -3
  204. package/src/components/table.tsx +140 -35
  205. package/src/components/tabs.test.tsx +107 -0
  206. package/src/components/tabs.tsx +70 -23
  207. package/src/components/tag-group.stories.tsx +39 -0
  208. package/src/components/tag-group.tsx +28 -0
  209. package/src/components/tag.stories.tsx +50 -0
  210. package/src/components/tag.tsx +63 -0
  211. package/src/components/text.test.tsx +1 -1
  212. package/src/components/text.tsx +19 -3
  213. package/src/components/textarea.test.tsx +48 -0
  214. package/src/components/textarea.tsx +2 -1
  215. package/src/components/toast.tsx +4 -3
  216. package/src/components/toggle.tsx +2 -2
  217. package/src/components/tooltip.tsx +12 -2
  218. package/src/globals.css +142 -90
  219. package/dist/chunk-2BMJRXQX.js +0 -80
  220. package/dist/chunk-4AVS6CV4.js +0 -89
  221. package/dist/chunk-4UO73SKC.js +0 -26
  222. package/dist/chunk-CW24PLDW.js +0 -24
  223. package/dist/chunk-IQASCH2V.js +0 -25
  224. package/dist/chunk-NKHH23YK.js +0 -31
  225. package/dist/chunk-QJ54Z7TC.js +0 -83
@@ -0,0 +1,64 @@
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 { RadioGroup, RadioGroupItem } from "./radio-group";
5
+
6
+ function Setup({
7
+ value,
8
+ onValueChange,
9
+ }: {
10
+ value?: string;
11
+ onValueChange?: (v: string) => void;
12
+ }) {
13
+ return (
14
+ <RadioGroup value={value} onValueChange={onValueChange} aria-label="favorite">
15
+ <RadioGroupItem value="a" aria-label="A" />
16
+ <RadioGroupItem value="b" aria-label="B" />
17
+ <RadioGroupItem value="c" aria-label="C" />
18
+ </RadioGroup>
19
+ );
20
+ }
21
+
22
+ describe("RadioGroup", () => {
23
+ it("renders a radiogroup with items", () => {
24
+ render(<Setup />);
25
+ expect(screen.getByRole("radiogroup", { name: /favorite/i })).toBeDefined();
26
+ expect(screen.getAllByRole("radio")).toHaveLength(3);
27
+ });
28
+
29
+ it("marks the matching item as checked", () => {
30
+ render(<Setup value="b" onValueChange={() => {}} />);
31
+ expect(screen.getByRole("radio", { name: "B" })).toHaveAttribute("data-state", "checked");
32
+ expect(screen.getByRole("radio", { name: "A" })).toHaveAttribute("data-state", "unchecked");
33
+ });
34
+
35
+ it("fires onValueChange when clicking an item", async () => {
36
+ const user = userEvent.setup();
37
+ const onValueChange = vi.fn();
38
+ render(<Setup onValueChange={onValueChange} />);
39
+ await user.click(screen.getByRole("radio", { name: "B" }));
40
+ expect(onValueChange).toHaveBeenCalledWith("b");
41
+ });
42
+
43
+ it("disabled items reject clicks", async () => {
44
+ const user = userEvent.setup();
45
+ const onValueChange = vi.fn();
46
+ render(
47
+ <RadioGroup onValueChange={onValueChange} aria-label="x">
48
+ <RadioGroupItem value="a" aria-label="A" disabled />
49
+ <RadioGroupItem value="b" aria-label="B" />
50
+ </RadioGroup>
51
+ );
52
+ await user.click(screen.getByRole("radio", { name: "A" }));
53
+ expect(onValueChange).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it("respects aria-invalid on items", () => {
57
+ render(
58
+ <RadioGroup aria-label="x">
59
+ <RadioGroupItem value="a" aria-label="A" aria-invalid />
60
+ </RadioGroup>
61
+ );
62
+ expect(screen.getByRole("radio", { name: "A" })).toHaveAttribute("aria-invalid", "true");
63
+ });
64
+ });
@@ -9,7 +9,14 @@ const RadioGroup = React.forwardRef<
9
9
  React.ElementRef<typeof RadioGroupPrimitive.Root>,
10
10
  React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
11
11
  >(({ className, ...props }, ref) => {
12
- return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
12
+ return (
13
+ <RadioGroupPrimitive.Root
14
+ data-slot="radio-group"
15
+ className={cn("grid gap-2", className)}
16
+ {...props}
17
+ ref={ref}
18
+ />
19
+ );
13
20
  });
14
21
  RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
15
22
 
@@ -20,14 +27,15 @@ const RadioGroupItem = React.forwardRef<
20
27
  return (
21
28
  <RadioGroupPrimitive.Item
22
29
  ref={ref}
30
+ data-slot="radio-group-item"
23
31
  className={cn(
24
- "aspect-square h-5 w-5 rounded-full border border-border glass text-primary transition-all duration-300 focus: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]:border-primary data-[state=checked]:shadow-lg data-[state=checked]:shadow-primary/20 hover:border-accent/50 hover:shadow-md",
32
+ "aspect-square size-5 rounded-full border border-border bg-background text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary hover:border-primary/50 aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive/40",
25
33
  className
26
34
  )}
27
35
  {...props}
28
36
  >
29
37
  <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
30
- <Circle className="h-2.5 w-2.5 fill-current text-current animate-in zoom-in-50 duration-200" />
38
+ <Circle className="size-2.5 fill-current text-primary animate-in zoom-in-50 duration-150" />
31
39
  </RadioGroupPrimitive.Indicator>
32
40
  </RadioGroupPrimitive.Item>
33
41
  );
@@ -0,0 +1,112 @@
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 { Rating } from "./rating";
5
+
6
+ describe("Rating (readonly)", () => {
7
+ it("renders with role=img and labeled aria description", () => {
8
+ render(<Rating value={3.5} reviewCount={42} />);
9
+ const el = screen.getByRole("img");
10
+ expect(el.getAttribute("aria-label")).toBe("Rating: 3.5 out of 5");
11
+ });
12
+
13
+ it("uses custom label in aria-label", () => {
14
+ render(<Rating value={2} label="Tasty" />);
15
+ expect(screen.getByRole("img").getAttribute("aria-label")).toBe("Tasty: 2 out of 5");
16
+ });
17
+
18
+ it("shows review count when provided and showCount is true", () => {
19
+ render(<Rating value={3} reviewCount={123} />);
20
+ expect(screen.getByText("(123)")).toBeInTheDocument();
21
+ });
22
+
23
+ it("hides count when showCount=false", () => {
24
+ render(<Rating value={3} reviewCount={123} showCount={false} />);
25
+ expect(screen.queryByText("(123)")).toBeNull();
26
+ });
27
+
28
+ it("does not render any radio buttons in readonly mode", () => {
29
+ render(<Rating value={3} />);
30
+ expect(screen.queryByRole("radio")).toBeNull();
31
+ });
32
+ });
33
+
34
+ describe("Rating (interactive)", () => {
35
+ it("renders as radiogroup with `max` radios", () => {
36
+ render(<Rating value={0} readonly={false} onRatingChange={() => {}} />);
37
+ expect(screen.getByRole("radiogroup")).toBeInTheDocument();
38
+ expect(screen.getAllByRole("radio")).toHaveLength(5);
39
+ });
40
+
41
+ it("marks the correct radio as checked", () => {
42
+ render(<Rating value={3} readonly={false} onRatingChange={() => {}} />);
43
+ const radios = screen.getAllByRole("radio");
44
+ expect(radios[2].getAttribute("aria-checked")).toBe("true");
45
+ expect(radios[0].getAttribute("aria-checked")).toBe("false");
46
+ expect(radios[4].getAttribute("aria-checked")).toBe("false");
47
+ });
48
+
49
+ it("fires onRatingChange when a star is clicked", async () => {
50
+ const user = userEvent.setup();
51
+ const onChange = vi.fn();
52
+ render(<Rating value={0} readonly={false} onRatingChange={onChange} />);
53
+ await user.click(screen.getByRole("radio", { name: "4 stars" }));
54
+ expect(onChange).toHaveBeenCalledWith(4);
55
+ });
56
+
57
+ it("ArrowRight increments and ArrowLeft decrements", async () => {
58
+ const user = userEvent.setup();
59
+ const onChange = vi.fn();
60
+ render(<Rating value={3} readonly={false} onRatingChange={onChange} />);
61
+ screen.getAllByRole("radio")[0].focus();
62
+ await user.keyboard("{ArrowRight}");
63
+ expect(onChange).toHaveBeenLastCalledWith(4);
64
+ await user.keyboard("{ArrowLeft}");
65
+ expect(onChange).toHaveBeenLastCalledWith(2);
66
+ });
67
+
68
+ it("ArrowUp increments and ArrowDown decrements", async () => {
69
+ const user = userEvent.setup();
70
+ const onChange = vi.fn();
71
+ render(<Rating value={2} readonly={false} onRatingChange={onChange} />);
72
+ screen.getAllByRole("radio")[0].focus();
73
+ await user.keyboard("{ArrowUp}");
74
+ expect(onChange).toHaveBeenLastCalledWith(3);
75
+ await user.keyboard("{ArrowDown}");
76
+ expect(onChange).toHaveBeenLastCalledWith(1);
77
+ });
78
+
79
+ it("Home jumps to 0 and End jumps to max", async () => {
80
+ const user = userEvent.setup();
81
+ const onChange = vi.fn();
82
+ render(<Rating value={3} readonly={false} onRatingChange={onChange} />);
83
+ screen.getAllByRole("radio")[0].focus();
84
+ await user.keyboard("{Home}");
85
+ expect(onChange).toHaveBeenLastCalledWith(0);
86
+ await user.keyboard("{End}");
87
+ expect(onChange).toHaveBeenLastCalledWith(5);
88
+ });
89
+
90
+ it("clamps at max when ArrowRight pressed at max", async () => {
91
+ const user = userEvent.setup();
92
+ const onChange = vi.fn();
93
+ render(<Rating value={5} readonly={false} onRatingChange={onChange} />);
94
+ screen.getAllByRole("radio")[0].focus();
95
+ await user.keyboard("{ArrowRight}");
96
+ expect(onChange).toHaveBeenLastCalledWith(5);
97
+ });
98
+
99
+ it("clamps at 0 when ArrowLeft pressed at 0", async () => {
100
+ const user = userEvent.setup();
101
+ const onChange = vi.fn();
102
+ render(<Rating value={0} readonly={false} onRatingChange={onChange} />);
103
+ screen.getAllByRole("radio")[0].focus();
104
+ await user.keyboard("{ArrowLeft}");
105
+ expect(onChange).toHaveBeenLastCalledWith(0);
106
+ });
107
+
108
+ it("respects max prop for radio count", () => {
109
+ render(<Rating value={2} max={10} readonly={false} onRatingChange={() => {}} />);
110
+ expect(screen.getAllByRole("radio")).toHaveLength(10);
111
+ });
112
+ });
@@ -1,20 +1,37 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { cn } from "../lib/utils";
5
4
  import { Star, StarHalf } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
6
 
7
7
  interface RatingProps {
8
- value: number; // 1-5
9
- max?: number; // default 5
8
+ value: number;
9
+ max?: number;
10
10
  size?: "sm" | "md" | "lg";
11
11
  showCount?: boolean;
12
12
  reviewCount?: number;
13
13
  readonly?: boolean;
14
14
  onRatingChange?: (rating: number) => void;
15
15
  className?: string;
16
+ /** Accessible label for the rating control. Defaults to "Rating". */
17
+ label?: string;
16
18
  }
17
19
 
20
+ const sizeClasses = {
21
+ sm: "size-3",
22
+ md: "size-4",
23
+ lg: "size-5",
24
+ } as const;
25
+
26
+ const textSizeClasses = {
27
+ sm: "text-xs",
28
+ md: "text-sm",
29
+ lg: "text-base",
30
+ } as const;
31
+
32
+ const FILLED_CLASSES = "fill-amber-400 text-amber-400";
33
+ const EMPTY_CLASSES = "fill-transparent text-muted-foreground/40";
34
+
18
35
  const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
19
36
  (
20
37
  {
@@ -26,103 +43,101 @@ const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
26
43
  readonly = true,
27
44
  onRatingChange,
28
45
  className,
46
+ label = "Rating",
29
47
  ...props
30
48
  },
31
49
  ref
32
50
  ) => {
33
51
  const [hoverValue, setHoverValue] = React.useState<number | null>(null);
52
+ const displayValue = hoverValue ?? value;
34
53
 
35
- const sizeClasses = {
36
- sm: "h-3 w-3",
37
- md: "h-4 w-4",
38
- lg: "h-5 w-5",
39
- };
40
-
41
- const textSizeClasses = {
42
- sm: "text-xs",
43
- md: "text-sm",
44
- lg: "text-base",
45
- };
46
-
47
- const handleClick = (rating: number) => {
48
- if (!readonly && onRatingChange) {
49
- onRatingChange(rating);
50
- }
51
- };
54
+ if (readonly) {
55
+ const fullStars = Math.floor(displayValue);
56
+ const hasHalfStar = displayValue % 1 !== 0;
57
+ const emptyStars = max - fullStars - (hasHalfStar ? 1 : 0);
52
58
 
53
- const handleMouseEnter = (rating: number) => {
54
- if (!readonly) {
55
- setHoverValue(rating);
56
- }
57
- };
59
+ return (
60
+ <div
61
+ ref={ref}
62
+ role="img"
63
+ aria-label={`${label}: ${value} out of ${max}`}
64
+ className={cn("flex items-center gap-1", className)}
65
+ {...props}
66
+ >
67
+ <div className="flex items-center gap-0.5">
68
+ {Array.from({ length: fullStars }).map((_, i) => (
69
+ <Star key={`full-${i}`} className={cn(sizeClasses[size], FILLED_CLASSES)} />
70
+ ))}
71
+ {hasHalfStar && <StarHalf className={cn(sizeClasses[size], FILLED_CLASSES)} />}
72
+ {Array.from({ length: emptyStars }).map((_, i) => (
73
+ <Star key={`empty-${i}`} className={cn(sizeClasses[size], EMPTY_CLASSES)} />
74
+ ))}
75
+ </div>
76
+ {showCount && reviewCount !== undefined && (
77
+ <span className={cn("ml-1 text-muted-foreground", textSizeClasses[size])}>
78
+ ({reviewCount})
79
+ </span>
80
+ )}
81
+ </div>
82
+ );
83
+ }
58
84
 
59
- const handleMouseLeave = () => {
60
- if (!readonly) {
61
- setHoverValue(null);
85
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
86
+ if (!onRatingChange) return;
87
+ if (e.key === "ArrowRight" || e.key === "ArrowUp") {
88
+ e.preventDefault();
89
+ onRatingChange(Math.min(max, value + 1));
90
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
91
+ e.preventDefault();
92
+ onRatingChange(Math.max(0, value - 1));
93
+ } else if (e.key === "Home") {
94
+ e.preventDefault();
95
+ onRatingChange(0);
96
+ } else if (e.key === "End") {
97
+ e.preventDefault();
98
+ onRatingChange(max);
62
99
  }
63
100
  };
64
101
 
65
- const displayValue = hoverValue ?? value;
66
- const fullStars = Math.floor(displayValue);
67
- const hasHalfStar = displayValue % 1 !== 0;
68
- const emptyStars = max - fullStars - (hasHalfStar ? 1 : 0);
69
-
70
102
  return (
71
103
  <div
72
104
  ref={ref}
73
- className={cn("flex items-center gap-1", !readonly && "cursor-pointer", className)}
105
+ role="radiogroup"
106
+ aria-label={label}
107
+ onKeyDown={handleKeyDown}
108
+ className={cn("flex items-center gap-1", className)}
74
109
  {...props}
75
110
  >
76
- {/* Stars */}
77
- <div className="flex items-center gap-0.5">
78
- {/* Full stars */}
79
- {Array.from({ length: fullStars }).map((_, i) => (
80
- <Star
81
- key={`full-${i}`}
82
- className={cn(
83
- sizeClasses[size],
84
- "fill-primary text-primary transition-colors",
85
- !readonly && "hover:fill-primary/80"
86
- )}
87
- onClick={() => handleClick(i + 1)}
88
- onMouseEnter={() => handleMouseEnter(i + 1)}
89
- onMouseLeave={handleMouseLeave}
90
- />
91
- ))}
92
-
93
- {/* Half star */}
94
- {hasHalfStar && (
95
- <StarHalf
96
- className={cn(
97
- sizeClasses[size],
98
- "fill-primary text-primary transition-colors",
99
- !readonly && "hover:fill-primary/80"
100
- )}
101
- onClick={() => handleClick(fullStars + 1)}
102
- onMouseEnter={() => handleMouseEnter(fullStars + 1)}
103
- onMouseLeave={handleMouseLeave}
104
- />
105
- )}
106
-
107
- {/* Empty stars */}
108
- {Array.from({ length: emptyStars }).map((_, i) => (
109
- <Star
110
- key={`empty-${i}`}
111
- className={cn(
112
- sizeClasses[size],
113
- "text-muted-foreground/30 transition-colors",
114
- !readonly && "hover:text-primary/50"
115
- )}
116
- onClick={() => handleClick(fullStars + (hasHalfStar ? 1 : 0) + i + 1)}
117
- onMouseEnter={() => handleMouseEnter(fullStars + (hasHalfStar ? 1 : 0) + i + 1)}
118
- onMouseLeave={handleMouseLeave}
119
- />
120
- ))}
111
+ <div className="flex items-center gap-0.5" onMouseLeave={() => setHoverValue(null)}>
112
+ {Array.from({ length: max }).map((_, i) => {
113
+ const starValue = i + 1;
114
+ const isActive = starValue <= displayValue;
115
+ return (
116
+ <button
117
+ key={starValue}
118
+ type="button"
119
+ role="radio"
120
+ aria-checked={value === starValue}
121
+ aria-label={`${starValue} star${starValue === 1 ? "" : "s"}`}
122
+ onClick={() => onRatingChange?.(starValue)}
123
+ onMouseEnter={() => setHoverValue(starValue)}
124
+ className={cn(
125
+ "rounded-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
126
+ )}
127
+ >
128
+ <Star
129
+ className={cn(
130
+ sizeClasses[size],
131
+ isActive ? FILLED_CLASSES : EMPTY_CLASSES,
132
+ "transition-colors"
133
+ )}
134
+ />
135
+ </button>
136
+ );
137
+ })}
121
138
  </div>
122
-
123
- {/* Review count */}
124
139
  {showCount && reviewCount !== undefined && (
125
- <span className={cn("text-muted-foreground ml-1", textSizeClasses[size])}>
140
+ <span className={cn("ml-1 text-muted-foreground", textSizeClasses[size])}>
126
141
  ({reviewCount})
127
142
  </span>
128
143
  )}
@@ -20,44 +20,13 @@ export const Default: Story = {
20
20
  render: () => <SearchBarDemo />,
21
21
  };
22
22
 
23
- export const Editorial: Story = {
23
+ export const Wide: Story = {
24
24
  render: () => {
25
25
  const [value, setValue] = useState("");
26
26
  return (
27
27
  <SearchBar
28
28
  value={value}
29
29
  onChange={setValue}
30
- variant="editorial"
31
- placeholder="Search..."
32
- className="w-[400px]"
33
- />
34
- );
35
- },
36
- };
37
-
38
- export const Primary: Story = {
39
- render: () => {
40
- const [value, setValue] = useState("");
41
- return (
42
- <SearchBar
43
- value={value}
44
- onChange={setValue}
45
- variant="primary"
46
- placeholder="Search..."
47
- className="w-[400px]"
48
- />
49
- );
50
- },
51
- };
52
-
53
- export const Secondary: Story = {
54
- render: () => {
55
- const [value, setValue] = useState("");
56
- return (
57
- <SearchBar
58
- value={value}
59
- onChange={setValue}
60
- variant="secondary"
61
30
  placeholder="Search..."
62
31
  className="w-[400px]"
63
32
  />
@@ -1,7 +1,6 @@
1
1
  "use client";
2
2
 
3
3
  import type { ChangeEvent } from "react";
4
- import { Card } from "./card";
5
4
  import { Loader2, Search } from "lucide-react";
6
5
  import { cn } from "../lib/utils";
7
6
 
@@ -15,7 +14,6 @@ interface SearchBarProps {
15
14
  autoFocus?: boolean;
16
15
  inputAriaLabel?: string;
17
16
  size?: "default" | "compact";
18
- variant?: "primary" | "secondary" | "editorial";
19
17
  }
20
18
 
21
19
  export function SearchBar({
@@ -28,49 +26,26 @@ export function SearchBar({
28
26
  autoFocus = false,
29
27
  inputAriaLabel,
30
28
  size = "default",
31
- variant = "editorial",
32
29
  }: SearchBarProps) {
33
30
  const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
34
- const newValue = e.target.value;
35
- onChange?.(newValue);
31
+ onChange?.(e.target.value);
36
32
  };
37
33
 
38
- const Wrapper = variant === "editorial" ? "div" : Card;
39
- const wrapperClassName =
40
- variant === "editorial"
41
- ? cn(
42
- "bg-surface border border-border rounded-[14px] h-12 flex items-center transition-colors focus-within:border-primary",
43
- className
44
- )
45
- : cn(
46
- "border-2 border-primary-foreground/20 backdrop-blur-xl rounded-lg p-0",
47
- size === "compact" ? "md:p-1.5" : "md:p-2",
48
- variant === "primary"
49
- ? "!bg-primary-foreground/10 !text-primary !border-primary/20"
50
- : "!bg-primary !text-primary-foreground !border-primary/20",
51
- className
52
- );
53
-
54
34
  return (
55
- <Wrapper className={wrapperClassName}>
56
- <div className="flex items-center relative">
57
- <div
35
+ <div
36
+ className={cn(
37
+ "flex w-full items-center rounded-[14px] border border-border bg-surface transition-colors focus-within:border-primary",
38
+ size === "compact" ? "h-10" : "h-12",
39
+ className
40
+ )}
41
+ >
42
+ <div className="relative flex w-full items-center">
43
+ <Search
58
44
  className={cn(
59
- "absolute top-1/2 z-10 -translate-y-1/2",
60
- size === "compact" ? "left-3.5" : "left-4"
45
+ "pointer-events-none absolute top-1/2 z-10 -translate-y-1/2 text-muted-foreground",
46
+ size === "compact" ? "left-3.5 h-3.5 w-3.5" : "left-4 h-4 w-4"
61
47
  )}
62
- >
63
- <Search
64
- className={cn(
65
- variant === "editorial"
66
- ? "text-muted-foreground"
67
- : variant === "primary"
68
- ? "text-primary"
69
- : "text-primary-foreground",
70
- size === "compact" ? "h-3.5 w-3.5" : "h-4 w-4"
71
- )}
72
- />
73
- </div>
48
+ />
74
49
  <input
75
50
  type="text"
76
51
  placeholder={placeholder}
@@ -79,35 +54,23 @@ export function SearchBar({
79
54
  autoFocus={autoFocus}
80
55
  aria-label={inputAriaLabel ?? placeholder}
81
56
  className={cn(
82
- "w-full bg-transparent border-0 focus:outline-none focus-visible:outline-none",
57
+ "w-full border-0 bg-transparent text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:outline-none",
83
58
  size === "compact"
84
- ? "pl-9 pr-3 py-2.5 text-sm placeholder:text-sm rounded-lg"
85
- : "pl-10 pr-4 py-3 text-[14px] placeholder:text-[14px] rounded-[14px]",
86
- variant === "editorial"
87
- ? "text-foreground placeholder:text-muted-foreground"
88
- : variant === "primary"
89
- ? "!text-primary placeholder:text-primary/60"
90
- : "!text-primary-foreground placeholder:text-primary-foreground/60",
59
+ ? "rounded-lg py-2.5 pl-9 pr-3 text-base placeholder:text-base md:text-sm md:placeholder:text-sm"
60
+ : "rounded-[14px] py-3 pl-10 pr-4 text-base placeholder:text-base md:text-[14px] md:placeholder:text-[14px]",
91
61
  inputClassName
92
62
  )}
93
63
  />
94
64
  {isSearching && (
95
- <div
65
+ <Loader2
66
+ aria-hidden
96
67
  className={cn(
97
- "absolute top-1/2 -translate-y-1/2",
98
- size === "compact" ? "right-3.5" : "right-4"
68
+ "absolute top-1/2 -translate-y-1/2 animate-spin text-muted-foreground",
69
+ size === "compact" ? "right-3.5 h-3.5 w-3.5" : "right-4 h-4 w-4"
99
70
  )}
100
- >
101
- <Loader2
102
- className={cn(
103
- "animate-spin",
104
- variant === "editorial" ? "text-muted-foreground" : "text-primary/60",
105
- size === "compact" ? "h-3.5 w-3.5" : "h-4 w-4"
106
- )}
107
- />
108
- </div>
71
+ />
109
72
  )}
110
73
  </div>
111
- </Wrapper>
74
+ </div>
112
75
  );
113
76
  }