@bearmenu/ui 0.5.2 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +4 -1
- 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 +3 -1
- 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 +16 -3
- 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/scroll-fade.d.ts +15 -0
- package/dist/components/scroll-fade.js +116 -0
- 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 +9 -6
- 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/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 +1 -1
- 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 +45 -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/scroll-fade.stories.tsx +98 -0
- package/src/components/scroll-fade.tsx +127 -0
- 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 +8 -5
- 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/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 +126 -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
|
@@ -4,18 +4,27 @@ import * as React from "react";
|
|
|
4
4
|
import { Drawer as DrawerPrimitive } from "vaul";
|
|
5
5
|
import { cn } from "../lib/utils";
|
|
6
6
|
|
|
7
|
+
type DrawerDirection = "top" | "bottom" | "left" | "right";
|
|
8
|
+
|
|
9
|
+
const DrawerDirectionContext = React.createContext<DrawerDirection>("bottom");
|
|
10
|
+
|
|
7
11
|
const Drawer = ({
|
|
8
12
|
shouldScaleBackground = false,
|
|
13
|
+
direction = "bottom",
|
|
9
14
|
...props
|
|
10
15
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
|
11
|
-
<
|
|
16
|
+
<DrawerDirectionContext.Provider value={direction}>
|
|
17
|
+
<DrawerPrimitive.Root
|
|
18
|
+
shouldScaleBackground={shouldScaleBackground}
|
|
19
|
+
direction={direction}
|
|
20
|
+
{...props}
|
|
21
|
+
/>
|
|
22
|
+
</DrawerDirectionContext.Provider>
|
|
12
23
|
);
|
|
13
24
|
Drawer.displayName = "Drawer";
|
|
14
25
|
|
|
15
26
|
const DrawerTrigger = DrawerPrimitive.Trigger;
|
|
16
|
-
|
|
17
27
|
const DrawerPortal = DrawerPrimitive.Portal;
|
|
18
|
-
|
|
19
28
|
const DrawerClose = DrawerPrimitive.Close;
|
|
20
29
|
|
|
21
30
|
const DrawerOverlay = React.forwardRef<
|
|
@@ -24,40 +33,78 @@ const DrawerOverlay = React.forwardRef<
|
|
|
24
33
|
>(({ className, ...props }, ref) => (
|
|
25
34
|
<DrawerPrimitive.Overlay
|
|
26
35
|
ref={ref}
|
|
36
|
+
data-slot="drawer-overlay"
|
|
27
37
|
className={cn("fixed inset-0 z-[1000] bg-black/60", className)}
|
|
28
38
|
{...props}
|
|
29
39
|
/>
|
|
30
40
|
));
|
|
31
41
|
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
|
32
42
|
|
|
43
|
+
type DrawerContentProps = React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & {
|
|
44
|
+
variant?: "flush" | "floating";
|
|
45
|
+
showHandle?: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
33
48
|
const DrawerContent = React.forwardRef<
|
|
34
49
|
React.ElementRef<typeof DrawerPrimitive.Content>,
|
|
35
|
-
|
|
36
|
-
>(({ className, children, ...props }, ref) =>
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
DrawerContentProps
|
|
51
|
+
>(({ className, variant = "flush", showHandle, children, ...props }, ref) => {
|
|
52
|
+
const direction = React.useContext(DrawerDirectionContext);
|
|
53
|
+
const isVertical = direction === "top" || direction === "bottom";
|
|
54
|
+
const handleEnabled = showHandle ?? (direction === "bottom");
|
|
55
|
+
|
|
56
|
+
const positionClasses =
|
|
57
|
+
direction === "bottom"
|
|
58
|
+
? "inset-x-0 bottom-0 flex-col rounded-t-2xl border-t [padding-bottom:max(env(safe-area-inset-bottom),0px)]"
|
|
59
|
+
: direction === "top"
|
|
60
|
+
? "inset-x-0 top-0 flex-col rounded-b-2xl border-b [padding-top:max(env(safe-area-inset-top),0px)]"
|
|
61
|
+
: direction === "left"
|
|
62
|
+
? "inset-y-0 left-0 h-full w-3/4 max-w-sm flex-col rounded-r-2xl border-r [padding-left:max(env(safe-area-inset-left),0px)]"
|
|
63
|
+
: "inset-y-0 right-0 h-full w-3/4 max-w-sm flex-col rounded-l-2xl border-l [padding-right:max(env(safe-area-inset-right),0px)]";
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<DrawerPortal>
|
|
67
|
+
<DrawerOverlay />
|
|
68
|
+
<DrawerPrimitive.Content
|
|
69
|
+
ref={ref}
|
|
70
|
+
data-slot="drawer-content"
|
|
71
|
+
className={cn(
|
|
72
|
+
"fixed z-[1000] flex border-border bg-card shadow-2xl",
|
|
73
|
+
positionClasses,
|
|
74
|
+
isVertical ? "max-h-[90vh]" : "",
|
|
75
|
+
variant === "floating" && "m-3 rounded-2xl border",
|
|
76
|
+
className
|
|
77
|
+
)}
|
|
78
|
+
{...props}
|
|
79
|
+
>
|
|
80
|
+
{handleEnabled && (
|
|
81
|
+
<div
|
|
82
|
+
aria-hidden
|
|
83
|
+
className="mx-auto mt-2 h-1.5 w-[60px] shrink-0 rounded-full bg-muted-foreground/30"
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
{children}
|
|
87
|
+
</DrawerPrimitive.Content>
|
|
88
|
+
</DrawerPortal>
|
|
89
|
+
);
|
|
90
|
+
});
|
|
52
91
|
DrawerContent.displayName = "DrawerContent";
|
|
53
92
|
|
|
54
93
|
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
|
55
|
-
<div
|
|
94
|
+
<div
|
|
95
|
+
data-slot="drawer-header"
|
|
96
|
+
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
|
97
|
+
{...props}
|
|
98
|
+
/>
|
|
56
99
|
);
|
|
57
100
|
DrawerHeader.displayName = "DrawerHeader";
|
|
58
101
|
|
|
59
102
|
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
|
60
|
-
<div
|
|
103
|
+
<div
|
|
104
|
+
data-slot="drawer-footer"
|
|
105
|
+
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
|
106
|
+
{...props}
|
|
107
|
+
/>
|
|
61
108
|
);
|
|
62
109
|
DrawerFooter.displayName = "DrawerFooter";
|
|
63
110
|
|
|
@@ -67,6 +114,7 @@ const DrawerTitle = React.forwardRef<
|
|
|
67
114
|
>(({ className, ...props }, ref) => (
|
|
68
115
|
<DrawerPrimitive.Title
|
|
69
116
|
ref={ref}
|
|
117
|
+
data-slot="drawer-title"
|
|
70
118
|
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
|
71
119
|
{...props}
|
|
72
120
|
/>
|
|
@@ -79,7 +127,8 @@ const DrawerDescription = React.forwardRef<
|
|
|
79
127
|
>(({ className, ...props }, ref) => (
|
|
80
128
|
<DrawerPrimitive.Description
|
|
81
129
|
ref={ref}
|
|
82
|
-
|
|
130
|
+
data-slot="drawer-description"
|
|
131
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
83
132
|
{...props}
|
|
84
133
|
/>
|
|
85
134
|
));
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuTrigger,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuCheckboxItem,
|
|
10
|
+
DropdownMenuRadioGroup,
|
|
11
|
+
DropdownMenuRadioItem,
|
|
12
|
+
DropdownMenuLabel,
|
|
13
|
+
DropdownMenuSeparator,
|
|
14
|
+
} from "./dropdown-menu";
|
|
15
|
+
import * as React from "react";
|
|
16
|
+
|
|
17
|
+
describe("DropdownMenu", () => {
|
|
18
|
+
it("is closed by default", () => {
|
|
19
|
+
render(
|
|
20
|
+
<DropdownMenu>
|
|
21
|
+
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
|
22
|
+
<DropdownMenuContent>
|
|
23
|
+
<DropdownMenuItem>One</DropdownMenuItem>
|
|
24
|
+
</DropdownMenuContent>
|
|
25
|
+
</DropdownMenu>
|
|
26
|
+
);
|
|
27
|
+
expect(screen.queryByRole("menu")).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("opens on trigger click and renders items with role=menuitem", async () => {
|
|
31
|
+
const user = userEvent.setup();
|
|
32
|
+
render(
|
|
33
|
+
<DropdownMenu>
|
|
34
|
+
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
|
35
|
+
<DropdownMenuContent>
|
|
36
|
+
<DropdownMenuItem>One</DropdownMenuItem>
|
|
37
|
+
<DropdownMenuItem>Two</DropdownMenuItem>
|
|
38
|
+
</DropdownMenuContent>
|
|
39
|
+
</DropdownMenu>
|
|
40
|
+
);
|
|
41
|
+
await user.click(screen.getByText("Open"));
|
|
42
|
+
expect(screen.getByRole("menu")).toBeInTheDocument();
|
|
43
|
+
expect(screen.getAllByRole("menuitem")).toHaveLength(2);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("fires onSelect when item is clicked", async () => {
|
|
47
|
+
const user = userEvent.setup();
|
|
48
|
+
const onSelect = vi.fn();
|
|
49
|
+
render(
|
|
50
|
+
<DropdownMenu>
|
|
51
|
+
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
|
52
|
+
<DropdownMenuContent>
|
|
53
|
+
<DropdownMenuItem onSelect={onSelect}>One</DropdownMenuItem>
|
|
54
|
+
</DropdownMenuContent>
|
|
55
|
+
</DropdownMenu>
|
|
56
|
+
);
|
|
57
|
+
await user.click(screen.getByText("Open"));
|
|
58
|
+
await user.click(screen.getByRole("menuitem", { name: "One" }));
|
|
59
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("checkbox item has role=menuitemcheckbox and reflects checked state", async () => {
|
|
63
|
+
const user = userEvent.setup();
|
|
64
|
+
function Wrap() {
|
|
65
|
+
const [checked, setChecked] = React.useState(false);
|
|
66
|
+
return (
|
|
67
|
+
<DropdownMenu>
|
|
68
|
+
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
|
69
|
+
<DropdownMenuContent>
|
|
70
|
+
<DropdownMenuCheckboxItem checked={checked} onCheckedChange={setChecked}>
|
|
71
|
+
Notify
|
|
72
|
+
</DropdownMenuCheckboxItem>
|
|
73
|
+
</DropdownMenuContent>
|
|
74
|
+
</DropdownMenu>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
render(<Wrap />);
|
|
78
|
+
await user.click(screen.getByText("Open"));
|
|
79
|
+
const item = screen.getByRole("menuitemcheckbox");
|
|
80
|
+
expect(item.getAttribute("aria-checked")).toBe("false");
|
|
81
|
+
await user.click(item);
|
|
82
|
+
// Menu closes on select but state mutates — reopen to check
|
|
83
|
+
await user.click(screen.getByText("Open"));
|
|
84
|
+
expect(screen.getByRole("menuitemcheckbox").getAttribute("aria-checked")).toBe("true");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("radio group exposes role=menuitemradio with single selection", async () => {
|
|
88
|
+
const user = userEvent.setup();
|
|
89
|
+
function Wrap() {
|
|
90
|
+
const [value, setValue] = React.useState("a");
|
|
91
|
+
return (
|
|
92
|
+
<DropdownMenu>
|
|
93
|
+
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
|
94
|
+
<DropdownMenuContent>
|
|
95
|
+
<DropdownMenuRadioGroup value={value} onValueChange={setValue}>
|
|
96
|
+
<DropdownMenuRadioItem value="a">A</DropdownMenuRadioItem>
|
|
97
|
+
<DropdownMenuRadioItem value="b">B</DropdownMenuRadioItem>
|
|
98
|
+
</DropdownMenuRadioGroup>
|
|
99
|
+
</DropdownMenuContent>
|
|
100
|
+
</DropdownMenu>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
render(<Wrap />);
|
|
104
|
+
await user.click(screen.getByText("Open"));
|
|
105
|
+
const items = screen.getAllByRole("menuitemradio");
|
|
106
|
+
expect(items).toHaveLength(2);
|
|
107
|
+
expect(items[0].getAttribute("aria-checked")).toBe("true");
|
|
108
|
+
expect(items[1].getAttribute("aria-checked")).toBe("false");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("closes on Escape", async () => {
|
|
112
|
+
const user = userEvent.setup();
|
|
113
|
+
render(
|
|
114
|
+
<DropdownMenu>
|
|
115
|
+
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
|
116
|
+
<DropdownMenuContent>
|
|
117
|
+
<DropdownMenuItem>One</DropdownMenuItem>
|
|
118
|
+
</DropdownMenuContent>
|
|
119
|
+
</DropdownMenu>
|
|
120
|
+
);
|
|
121
|
+
await user.click(screen.getByText("Open"));
|
|
122
|
+
await user.keyboard("{Escape}");
|
|
123
|
+
expect(screen.queryByRole("menu")).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("supports keyboard navigation through menu items", async () => {
|
|
127
|
+
const user = userEvent.setup();
|
|
128
|
+
render(
|
|
129
|
+
<DropdownMenu>
|
|
130
|
+
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
|
131
|
+
<DropdownMenuContent>
|
|
132
|
+
<DropdownMenuItem>One</DropdownMenuItem>
|
|
133
|
+
<DropdownMenuItem>Two</DropdownMenuItem>
|
|
134
|
+
</DropdownMenuContent>
|
|
135
|
+
</DropdownMenu>
|
|
136
|
+
);
|
|
137
|
+
await user.click(screen.getByText("Open"));
|
|
138
|
+
await user.keyboard("{ArrowDown}");
|
|
139
|
+
expect(screen.getByRole("menuitem", { name: "One" })).toHaveFocus();
|
|
140
|
+
await user.keyboard("{ArrowDown}");
|
|
141
|
+
expect(screen.getByRole("menuitem", { name: "Two" })).toHaveFocus();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("disabled item has data-disabled and does not fire onSelect", async () => {
|
|
145
|
+
const user = userEvent.setup();
|
|
146
|
+
const onSelect = vi.fn();
|
|
147
|
+
render(
|
|
148
|
+
<DropdownMenu>
|
|
149
|
+
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
|
150
|
+
<DropdownMenuContent>
|
|
151
|
+
<DropdownMenuItem disabled onSelect={onSelect}>
|
|
152
|
+
One
|
|
153
|
+
</DropdownMenuItem>
|
|
154
|
+
</DropdownMenuContent>
|
|
155
|
+
</DropdownMenu>
|
|
156
|
+
);
|
|
157
|
+
await user.click(screen.getByText("Open"));
|
|
158
|
+
const item = screen.getByRole("menuitem", { name: "One" });
|
|
159
|
+
expect(item.getAttribute("data-disabled")).not.toBeNull();
|
|
160
|
+
await user.click(item);
|
|
161
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("renders label and separator as structural children", async () => {
|
|
165
|
+
const user = userEvent.setup();
|
|
166
|
+
render(
|
|
167
|
+
<DropdownMenu>
|
|
168
|
+
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
|
169
|
+
<DropdownMenuContent>
|
|
170
|
+
<DropdownMenuLabel>Group</DropdownMenuLabel>
|
|
171
|
+
<DropdownMenuSeparator />
|
|
172
|
+
<DropdownMenuItem>One</DropdownMenuItem>
|
|
173
|
+
</DropdownMenuContent>
|
|
174
|
+
</DropdownMenu>
|
|
175
|
+
);
|
|
176
|
+
await user.click(screen.getByText("Open"));
|
|
177
|
+
expect(screen.getByText("Group")).toBeInTheDocument();
|
|
178
|
+
expect(document.querySelector("[data-slot=dropdown-menu-separator]")).not.toBeNull();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -25,15 +25,21 @@ function DropdownMenuTrigger({
|
|
|
25
25
|
function DropdownMenuContent({
|
|
26
26
|
className,
|
|
27
27
|
sideOffset = 4,
|
|
28
|
+
size = "default",
|
|
28
29
|
...props
|
|
29
|
-
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>
|
|
30
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content> & {
|
|
31
|
+
size?: "sm" | "default" | "lg";
|
|
32
|
+
}) {
|
|
30
33
|
return (
|
|
31
34
|
<DropdownMenuPrimitive.Portal>
|
|
32
35
|
<DropdownMenuPrimitive.Content
|
|
33
36
|
data-slot="dropdown-menu-content"
|
|
34
37
|
sideOffset={sideOffset}
|
|
35
38
|
className={cn(
|
|
36
|
-
"bg-
|
|
39
|
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) overflow-x-hidden overflow-y-auto rounded-xl border border-border p-1 shadow-xl origin-(--radix-dropdown-menu-content-transform-origin)",
|
|
40
|
+
size === "sm" && "min-w-[6rem] text-xs [&_[data-slot=dropdown-menu-item]]:py-1",
|
|
41
|
+
size === "default" && "min-w-[8rem]",
|
|
42
|
+
size === "lg" && "min-w-[12rem] [&_[data-slot=dropdown-menu-item]]:py-2",
|
|
37
43
|
className
|
|
38
44
|
)}
|
|
39
45
|
{...props}
|
|
@@ -73,8 +79,12 @@ function DropdownMenuCheckboxItem({
|
|
|
73
79
|
className,
|
|
74
80
|
children,
|
|
75
81
|
checked = false,
|
|
82
|
+
appearance = "indicator",
|
|
76
83
|
...props
|
|
77
|
-
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>
|
|
84
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
|
85
|
+
/** "indicator" shows a check icon (default). "control" renders a styled checkbox box. */
|
|
86
|
+
appearance?: "indicator" | "control";
|
|
87
|
+
}) {
|
|
78
88
|
return (
|
|
79
89
|
<DropdownMenuPrimitive.CheckboxItem
|
|
80
90
|
data-slot="dropdown-menu-checkbox-item"
|
|
@@ -86,9 +96,24 @@ function DropdownMenuCheckboxItem({
|
|
|
86
96
|
{...props}
|
|
87
97
|
>
|
|
88
98
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
89
|
-
|
|
90
|
-
<
|
|
91
|
-
|
|
99
|
+
{appearance === "control" ? (
|
|
100
|
+
<span
|
|
101
|
+
className={cn(
|
|
102
|
+
"inline-flex size-3.5 items-center justify-center rounded-sm border",
|
|
103
|
+
checked === true
|
|
104
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
105
|
+
: "border-border bg-background"
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
<DropdownMenuPrimitive.ItemIndicator>
|
|
109
|
+
<CheckIcon className="size-3" />
|
|
110
|
+
</DropdownMenuPrimitive.ItemIndicator>
|
|
111
|
+
</span>
|
|
112
|
+
) : (
|
|
113
|
+
<DropdownMenuPrimitive.ItemIndicator>
|
|
114
|
+
<CheckIcon className="size-4" />
|
|
115
|
+
</DropdownMenuPrimitive.ItemIndicator>
|
|
116
|
+
)}
|
|
92
117
|
</span>
|
|
93
118
|
{children}
|
|
94
119
|
</DropdownMenuPrimitive.CheckboxItem>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Field, FieldControl, FieldDescription, FieldError, FieldLabel } from "./field";
|
|
3
|
+
import { Input } from "./input";
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: "Components/Field",
|
|
7
|
+
component: Field,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
} satisfies Meta<typeof Field>;
|
|
10
|
+
|
|
11
|
+
export default meta;
|
|
12
|
+
type Story = StoryObj<typeof meta>;
|
|
13
|
+
|
|
14
|
+
export const Default: Story = {
|
|
15
|
+
render: () => (
|
|
16
|
+
<Field className="w-80">
|
|
17
|
+
<FieldLabel>Email</FieldLabel>
|
|
18
|
+
<FieldControl>
|
|
19
|
+
<Input placeholder="you@example.com" />
|
|
20
|
+
</FieldControl>
|
|
21
|
+
<FieldDescription>We'll never share your email.</FieldDescription>
|
|
22
|
+
</Field>
|
|
23
|
+
),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const Invalid: Story = {
|
|
27
|
+
render: () => (
|
|
28
|
+
<Field className="w-80" invalid>
|
|
29
|
+
<FieldLabel>Email</FieldLabel>
|
|
30
|
+
<FieldControl>
|
|
31
|
+
<Input placeholder="you@example.com" defaultValue="not-an-email" />
|
|
32
|
+
</FieldControl>
|
|
33
|
+
<FieldError>Please enter a valid email address.</FieldError>
|
|
34
|
+
</Field>
|
|
35
|
+
),
|
|
36
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import {
|
|
4
|
+
Field,
|
|
5
|
+
FieldLabel,
|
|
6
|
+
FieldControl,
|
|
7
|
+
FieldDescription,
|
|
8
|
+
FieldError,
|
|
9
|
+
FieldSet,
|
|
10
|
+
FieldLegend,
|
|
11
|
+
} from "./field";
|
|
12
|
+
import { Input } from "./input";
|
|
13
|
+
|
|
14
|
+
describe("Field a11y wiring", () => {
|
|
15
|
+
it("auto-generates an id and wires label htmlFor to control id", () => {
|
|
16
|
+
render(
|
|
17
|
+
<Field>
|
|
18
|
+
<FieldLabel>Email</FieldLabel>
|
|
19
|
+
<FieldControl>
|
|
20
|
+
<Input data-testid="i" />
|
|
21
|
+
</FieldControl>
|
|
22
|
+
</Field>
|
|
23
|
+
);
|
|
24
|
+
const label = screen.getByText("Email") as HTMLLabelElement;
|
|
25
|
+
const input = screen.getByTestId("i");
|
|
26
|
+
const id = input.id;
|
|
27
|
+
expect(id).toBeTruthy();
|
|
28
|
+
expect(label.getAttribute("for")).toBe(id);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("uses a provided id over the generated one", () => {
|
|
32
|
+
render(
|
|
33
|
+
<Field id="custom-id">
|
|
34
|
+
<FieldLabel>L</FieldLabel>
|
|
35
|
+
<FieldControl>
|
|
36
|
+
<Input data-testid="i" />
|
|
37
|
+
</FieldControl>
|
|
38
|
+
</Field>
|
|
39
|
+
);
|
|
40
|
+
expect(screen.getByTestId("i").id).toBe("custom-id");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("wires aria-describedby to the description by default", () => {
|
|
44
|
+
render(
|
|
45
|
+
<Field id="f1">
|
|
46
|
+
<FieldControl>
|
|
47
|
+
<Input data-testid="i" />
|
|
48
|
+
</FieldControl>
|
|
49
|
+
<FieldDescription>Helper text</FieldDescription>
|
|
50
|
+
</Field>
|
|
51
|
+
);
|
|
52
|
+
const input = screen.getByTestId("i");
|
|
53
|
+
const desc = screen.getByText("Helper text");
|
|
54
|
+
expect(desc.id).toBe("f1-description");
|
|
55
|
+
expect(input.getAttribute("aria-describedby")).toBe("f1-description");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("renders FieldError and wires aria-invalid + aria-describedby when invalid", () => {
|
|
59
|
+
render(
|
|
60
|
+
<Field id="f2" invalid>
|
|
61
|
+
<FieldControl>
|
|
62
|
+
<Input data-testid="i" />
|
|
63
|
+
</FieldControl>
|
|
64
|
+
<FieldDescription>Helper</FieldDescription>
|
|
65
|
+
<FieldError>Required</FieldError>
|
|
66
|
+
</Field>
|
|
67
|
+
);
|
|
68
|
+
const input = screen.getByTestId("i");
|
|
69
|
+
const err = screen.getByRole("alert");
|
|
70
|
+
expect(input.getAttribute("aria-invalid")).toBe("true");
|
|
71
|
+
expect(err.textContent).toBe("Required");
|
|
72
|
+
expect(err.id).toBe("f2-error");
|
|
73
|
+
expect(input.getAttribute("aria-describedby")).toBe("f2-description f2-error");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not render FieldError when not invalid", () => {
|
|
77
|
+
render(
|
|
78
|
+
<Field id="f3">
|
|
79
|
+
<FieldControl>
|
|
80
|
+
<Input data-testid="i" />
|
|
81
|
+
</FieldControl>
|
|
82
|
+
<FieldError>Error msg</FieldError>
|
|
83
|
+
</Field>
|
|
84
|
+
);
|
|
85
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does not render FieldError when invalid but no children", () => {
|
|
89
|
+
render(
|
|
90
|
+
<Field id="f4" invalid>
|
|
91
|
+
<FieldControl>
|
|
92
|
+
<Input data-testid="i" />
|
|
93
|
+
</FieldControl>
|
|
94
|
+
<FieldError>{null}</FieldError>
|
|
95
|
+
</Field>
|
|
96
|
+
);
|
|
97
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("applies destructive class to label when invalid", () => {
|
|
101
|
+
render(
|
|
102
|
+
<Field invalid>
|
|
103
|
+
<FieldLabel>L</FieldLabel>
|
|
104
|
+
<FieldControl>
|
|
105
|
+
<Input />
|
|
106
|
+
</FieldControl>
|
|
107
|
+
</Field>
|
|
108
|
+
);
|
|
109
|
+
expect(screen.getByText("L").className).toContain("text-destructive");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("sets data-invalid on the field wrapper when invalid", () => {
|
|
113
|
+
render(
|
|
114
|
+
<Field invalid data-testid="wrapper">
|
|
115
|
+
<FieldControl>
|
|
116
|
+
<Input />
|
|
117
|
+
</FieldControl>
|
|
118
|
+
</Field>
|
|
119
|
+
);
|
|
120
|
+
expect(screen.getByTestId("wrapper").getAttribute("data-invalid")).toBe("true");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("applies horizontal orientation class", () => {
|
|
124
|
+
render(
|
|
125
|
+
<Field orientation="horizontal" data-testid="wrapper">
|
|
126
|
+
<FieldControl>
|
|
127
|
+
<Input />
|
|
128
|
+
</FieldControl>
|
|
129
|
+
</Field>
|
|
130
|
+
);
|
|
131
|
+
expect(screen.getByTestId("wrapper").className).toContain("flex-row");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("applies vertical orientation class by default", () => {
|
|
135
|
+
render(
|
|
136
|
+
<Field data-testid="wrapper">
|
|
137
|
+
<FieldControl>
|
|
138
|
+
<Input />
|
|
139
|
+
</FieldControl>
|
|
140
|
+
</Field>
|
|
141
|
+
);
|
|
142
|
+
expect(screen.getByTestId("wrapper").className).toContain("flex-col");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("FieldSet / FieldLegend", () => {
|
|
147
|
+
it("renders fieldset + legend correctly", () => {
|
|
148
|
+
render(
|
|
149
|
+
<FieldSet data-testid="fs">
|
|
150
|
+
<FieldLegend>Group</FieldLegend>
|
|
151
|
+
</FieldSet>
|
|
152
|
+
);
|
|
153
|
+
const fs = screen.getByTestId("fs");
|
|
154
|
+
expect(fs.tagName).toBe("FIELDSET");
|
|
155
|
+
expect(screen.getByText("Group").tagName).toBe("LEGEND");
|
|
156
|
+
});
|
|
157
|
+
});
|