@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.
- package/dist/{chunk-ERMIA7NP.js → chunk-7PPCH42H.js} +3 -3
- package/dist/{chunk-HLQ2IRE7.js → chunk-BAZFVSSG.js} +3 -3
- package/dist/chunk-D5QHEHXJ.js +26 -0
- package/dist/{chunk-UISA4CIG.js → chunk-N2KNBPAQ.js} +2 -2
- package/dist/{chunk-7H54PTVH.js → chunk-PSR7N36A.js} +38 -10
- package/dist/chunk-RIDXFY6M.js +38 -0
- package/dist/chunk-UUJVXN2O.js +82 -0
- package/dist/chunk-WKMLZDUT.js +122 -0
- package/dist/chunk-XK2ZDQVE.js +122 -0
- package/dist/{chunk-4KOMHDZD.js → chunk-Y5D5LP4A.js} +18 -3
- package/dist/chunk-YJ6OBKZJ.js +32 -0
- package/dist/{chunk-ESO2EM3L.js → chunk-ZBBUKXM5.js} +2 -2
- package/dist/chunk-ZFNOCP6W.js +106 -0
- package/dist/components/accordion.js +11 -5
- package/dist/components/alert-dialog.js +5 -3
- package/dist/components/alert.d.ts +5 -2
- package/dist/components/alert.js +25 -9
- package/dist/components/avatar.d.ts +17 -2
- package/dist/components/avatar.js +91 -12
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/breadcrumb.js +5 -2
- package/dist/components/button-group.d.ts +14 -0
- package/dist/components/button-group.js +39 -0
- package/dist/components/button.d.ts +4 -2
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.js +3 -3
- package/dist/components/card.d.ts +13 -5
- package/dist/components/card.js +2 -2
- package/dist/components/carousel.d.ts +20 -3
- package/dist/components/carousel.js +72 -12
- package/dist/components/checkbox.js +1 -1
- package/dist/components/circular-progress.d.ts +16 -0
- package/dist/components/circular-progress.js +105 -0
- package/dist/components/combobox.d.ts +27 -0
- package/dist/components/combobox.js +102 -0
- package/dist/components/command.d.ts +29 -16
- package/dist/components/command.js +2 -2
- package/dist/components/currency-input.d.ts +1 -0
- package/dist/components/currency-input.js +73 -52
- package/dist/components/date-input.d.ts +10 -0
- package/dist/components/date-input.js +40 -0
- package/dist/components/description-list.d.ts +15 -0
- package/dist/components/description-list.js +67 -0
- package/dist/components/dialog.d.ts +13 -2
- package/dist/components/dialog.js +1 -1
- package/dist/components/drawer.d.ts +5 -2
- package/dist/components/drawer.js +47 -9
- package/dist/components/dropdown-menu.d.ts +7 -2
- package/dist/components/dropdown-menu.js +22 -6
- package/dist/components/empty-state.js +2 -2
- package/dist/components/field.d.ts +22 -0
- package/dist/components/field.js +130 -0
- package/dist/components/form.d.ts +2 -1
- package/dist/components/form.js +11 -1
- package/dist/components/haptic-button.js +3 -3
- package/dist/components/input-group.d.ts +18 -0
- package/dist/components/input-group.js +92 -0
- package/dist/components/input.d.ts +8 -2
- package/dist/components/input.js +1 -1
- package/dist/components/item.d.ts +23 -0
- package/dist/components/item.js +120 -0
- package/dist/components/kbd.d.ts +13 -0
- package/dist/components/kbd.js +49 -0
- package/dist/components/lazy-markdown.d.ts +3 -1
- package/dist/components/lazy-markdown.js +7 -7
- package/dist/components/link-button.js +1 -1
- package/dist/components/multi-select-combobox.js +92 -83
- package/dist/components/multi-select-menu.d.ts +0 -4
- package/dist/components/multi-select-menu.js +28 -17
- package/dist/components/native-select.d.ts +12 -0
- package/dist/components/native-select.js +49 -0
- package/dist/components/pagination.d.ts +5 -2
- package/dist/components/pagination.js +66 -7
- package/dist/components/place-about-tab.js +4 -4
- package/dist/components/place-card.js +1 -1
- package/dist/components/place-details-mobile.js +4 -4
- package/dist/components/popover.d.ts +7 -1
- package/dist/components/popover.js +1 -1
- package/dist/components/progress.d.ts +5 -1
- package/dist/components/progress.js +17 -3
- package/dist/components/radio-group.js +12 -3
- package/dist/components/rating.d.ts +2 -0
- package/dist/components/rating.js +88 -70
- package/dist/components/search-bar.d.ts +1 -2
- package/dist/components/search-bar.js +37 -60
- package/dist/components/searchable-select.js +76 -72
- package/dist/components/select.d.ts +5 -1
- package/dist/components/select.js +30 -14
- package/dist/components/selection-bar.d.ts +11 -0
- package/dist/components/selection-bar.js +65 -0
- package/dist/components/separator.js +3 -2
- package/dist/components/sheet.d.ts +1 -1
- package/dist/components/sheet.js +1 -1
- package/dist/components/skeleton-card.js +1 -1
- package/dist/components/skeleton.js +1 -1
- package/dist/components/slider.js +21 -4
- package/dist/components/sliding-panels.js +15 -13
- package/dist/components/spinner.d.ts +17 -0
- package/dist/components/spinner.js +50 -0
- package/dist/components/stepper.d.ts +18 -0
- package/dist/components/stepper.js +199 -0
- package/dist/components/sticky-container.js +1 -0
- package/dist/components/switch.js +4 -3
- package/dist/components/table.d.ts +23 -5
- package/dist/components/table.js +121 -35
- package/dist/components/tabs.d.ts +3 -1
- package/dist/components/tabs.js +37 -9
- package/dist/components/tag-group.d.ts +9 -0
- package/dist/components/tag-group.js +26 -0
- package/dist/components/tag.d.ts +17 -0
- package/dist/components/tag.js +61 -0
- package/dist/components/text.d.ts +3 -3
- package/dist/components/text.js +1 -1
- package/dist/components/textarea.js +2 -1
- package/dist/components/toast.js +1 -1
- package/dist/components/toaster.js +1 -1
- package/dist/components/toggle-group.js +1 -1
- package/dist/components/toggle.js +1 -1
- package/dist/components/tooltip.d.ts +3 -2
- package/dist/components/tooltip.js +16 -2
- package/package.json +44 -26
- package/src/components/accordion.tsx +11 -6
- package/src/components/alert-dialog.tsx +4 -2
- package/src/components/alert.tsx +20 -8
- package/src/components/avatar.tsx +106 -13
- package/src/components/badge.tsx +12 -11
- package/src/components/breadcrumb.tsx +5 -2
- package/src/components/button-group.stories.tsx +52 -0
- package/src/components/button-group.tsx +40 -0
- package/src/components/button.test.tsx +174 -0
- package/src/components/button.tsx +42 -19
- package/src/components/calendar.tsx +2 -2
- package/src/components/card.tsx +55 -13
- package/src/components/carousel.tsx +85 -13
- package/src/components/checkbox.test.tsx +58 -0
- package/src/components/checkbox.tsx +8 -3
- package/src/components/circular-progress.stories.tsx +39 -0
- package/src/components/circular-progress.tsx +102 -0
- package/src/components/collapsible.stories.tsx +7 -3
- package/src/components/combobox.stories.tsx +82 -0
- package/src/components/combobox.test.tsx +118 -0
- package/src/components/combobox.tsx +136 -0
- package/src/components/command.test.tsx +91 -0
- package/src/components/command.tsx +38 -15
- package/src/components/currency-input.test.tsx +63 -0
- package/src/components/currency-input.tsx +84 -66
- package/src/components/date-input.stories.tsx +31 -0
- package/src/components/date-input.tsx +43 -0
- package/src/components/description-list.stories.tsx +62 -0
- package/src/components/description-list.tsx +68 -0
- package/src/components/dialog.test.tsx +84 -0
- package/src/components/dialog.tsx +64 -24
- package/src/components/drawer.tsx +72 -23
- package/src/components/dropdown-menu.test.tsx +180 -0
- package/src/components/dropdown-menu.tsx +31 -6
- package/src/components/field.stories.tsx +36 -0
- package/src/components/field.test.tsx +157 -0
- package/src/components/field.tsx +146 -0
- package/src/components/form.stories.tsx +3 -2
- package/src/components/form.test.tsx +95 -0
- package/src/components/form.tsx +9 -0
- package/src/components/haptic-button.tsx +1 -1
- package/src/components/input-group.stories.tsx +61 -0
- package/src/components/input-group.tsx +99 -0
- package/src/components/input.test.tsx +103 -0
- package/src/components/input.tsx +24 -7
- package/src/components/item.stories.tsx +52 -0
- package/src/components/item.tsx +115 -0
- package/src/components/kbd.stories.tsx +33 -0
- package/src/components/kbd.tsx +46 -0
- package/src/components/lazy-markdown.tsx +10 -7
- package/src/components/multi-select-combobox.test.tsx +101 -0
- package/src/components/multi-select-combobox.tsx +85 -82
- package/src/components/multi-select-menu.tsx +49 -41
- package/src/components/native-select.stories.tsx +52 -0
- package/src/components/native-select.tsx +45 -0
- package/src/components/pagination.tsx +64 -5
- package/src/components/popover.tsx +66 -3
- package/src/components/progress.tsx +27 -4
- package/src/components/radio-group.test.tsx +64 -0
- package/src/components/radio-group.tsx +11 -3
- package/src/components/rating.test.tsx +112 -0
- package/src/components/rating.tsx +97 -82
- package/src/components/search-bar.stories.tsx +1 -32
- package/src/components/search-bar.tsx +22 -59
- package/src/components/searchable-select.tsx +69 -73
- package/src/components/select.tsx +34 -19
- package/src/components/selection-bar.stories.tsx +37 -0
- package/src/components/selection-bar.tsx +65 -0
- package/src/components/separator.tsx +5 -2
- package/src/components/sheet.tsx +1 -1
- package/src/components/skeleton.tsx +2 -2
- package/src/components/slider.test.tsx +45 -0
- package/src/components/slider.tsx +31 -12
- package/src/components/sliding-panels.tsx +14 -12
- package/src/components/spinner.stories.tsx +35 -0
- package/src/components/spinner.tsx +50 -0
- package/src/components/stepper.stories.tsx +71 -0
- package/src/components/stepper.tsx +220 -0
- package/src/components/sticky-container.tsx +1 -0
- package/src/components/switch.test.tsx +52 -0
- package/src/components/switch.tsx +4 -3
- package/src/components/table.tsx +140 -35
- package/src/components/tabs.test.tsx +107 -0
- package/src/components/tabs.tsx +70 -23
- package/src/components/tag-group.stories.tsx +39 -0
- package/src/components/tag-group.tsx +28 -0
- package/src/components/tag.stories.tsx +50 -0
- package/src/components/tag.tsx +63 -0
- package/src/components/text.test.tsx +1 -1
- package/src/components/text.tsx +19 -3
- package/src/components/textarea.test.tsx +48 -0
- package/src/components/textarea.tsx +2 -1
- package/src/components/toast.tsx +4 -3
- package/src/components/toggle.tsx +2 -2
- package/src/components/tooltip.tsx +12 -2
- package/src/globals.css +142 -90
- package/dist/chunk-2BMJRXQX.js +0 -80
- package/dist/chunk-4AVS6CV4.js +0 -89
- package/dist/chunk-4UO73SKC.js +0 -26
- package/dist/chunk-CW24PLDW.js +0 -24
- package/dist/chunk-IQASCH2V.js +0 -25
- package/dist/chunk-NKHH23YK.js +0 -31
- 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
|
|
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
|
|
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="
|
|
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;
|
|
9
|
-
max?: number;
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
60
|
-
if (!
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
|
85
|
-
: "pl-10 pr-4
|
|
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
|
-
<
|
|
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
|
-
</
|
|
74
|
+
</div>
|
|
112
75
|
);
|
|
113
76
|
}
|