@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,84 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogDescription,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
DialogTrigger,
|
|
12
|
+
DialogClose,
|
|
13
|
+
} from "./dialog";
|
|
14
|
+
|
|
15
|
+
function TestDialog({ defaultOpen, showCloseButton }: { defaultOpen?: boolean; showCloseButton?: boolean }) {
|
|
16
|
+
return (
|
|
17
|
+
<Dialog defaultOpen={defaultOpen}>
|
|
18
|
+
<DialogTrigger>Open</DialogTrigger>
|
|
19
|
+
<DialogContent showCloseButton={showCloseButton}>
|
|
20
|
+
<DialogHeader>
|
|
21
|
+
<DialogTitle>Title</DialogTitle>
|
|
22
|
+
<DialogDescription>Description</DialogDescription>
|
|
23
|
+
</DialogHeader>
|
|
24
|
+
<DialogFooter>
|
|
25
|
+
<DialogClose data-testid="footer-close">Dismiss</DialogClose>
|
|
26
|
+
</DialogFooter>
|
|
27
|
+
</DialogContent>
|
|
28
|
+
</Dialog>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("Dialog", () => {
|
|
33
|
+
it("is closed by default", () => {
|
|
34
|
+
render(<TestDialog />);
|
|
35
|
+
expect(screen.queryByRole("dialog")).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("opens on trigger click", async () => {
|
|
39
|
+
const user = userEvent.setup();
|
|
40
|
+
render(<TestDialog />);
|
|
41
|
+
await user.click(screen.getByText("Open"));
|
|
42
|
+
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("renders title and description with aria links", () => {
|
|
46
|
+
render(<TestDialog defaultOpen />);
|
|
47
|
+
const dialog = screen.getByRole("dialog");
|
|
48
|
+
const title = screen.getByText("Title");
|
|
49
|
+
const desc = screen.getByText("Description");
|
|
50
|
+
expect(dialog.getAttribute("aria-labelledby")).toBe(title.id);
|
|
51
|
+
expect(dialog.getAttribute("aria-describedby")).toBe(desc.id);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("renders close button by default", () => {
|
|
55
|
+
render(<TestDialog defaultOpen />);
|
|
56
|
+
expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("hides X close button when showCloseButton=false", () => {
|
|
60
|
+
render(<TestDialog defaultOpen showCloseButton={false} />);
|
|
61
|
+
expect(screen.queryByRole("button", { name: /close/i })).toBeNull();
|
|
62
|
+
expect(screen.getByTestId("footer-close")).toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("closes on Escape key", async () => {
|
|
66
|
+
const user = userEvent.setup();
|
|
67
|
+
render(<TestDialog defaultOpen />);
|
|
68
|
+
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
|
69
|
+
await user.keyboard("{Escape}");
|
|
70
|
+
expect(screen.queryByRole("dialog")).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("closes when DialogClose is clicked", async () => {
|
|
74
|
+
const user = userEvent.setup();
|
|
75
|
+
render(<TestDialog defaultOpen />);
|
|
76
|
+
await user.click(screen.getByTestId("footer-close"));
|
|
77
|
+
expect(screen.queryByRole("dialog")).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("applies data-slot attributes for styling hooks", () => {
|
|
81
|
+
render(<TestDialog defaultOpen />);
|
|
82
|
+
expect(screen.getByRole("dialog").getAttribute("data-slot")).toBe("dialog-content");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -2,15 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
5
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
5
6
|
import { X } from "lucide-react";
|
|
6
7
|
import { cn } from "../lib/utils";
|
|
7
8
|
|
|
8
9
|
const Dialog = DialogPrimitive.Root;
|
|
9
|
-
|
|
10
10
|
const DialogTrigger = DialogPrimitive.Trigger;
|
|
11
|
-
|
|
12
11
|
const DialogPortal = DialogPrimitive.Portal;
|
|
13
|
-
|
|
14
12
|
const DialogClose = DialogPrimitive.Close;
|
|
15
13
|
|
|
16
14
|
const DialogOverlay = React.forwardRef<
|
|
@@ -19,6 +17,7 @@ const DialogOverlay = React.forwardRef<
|
|
|
19
17
|
>(({ className, ...props }, ref) => (
|
|
20
18
|
<DialogPrimitive.Overlay
|
|
21
19
|
ref={ref}
|
|
20
|
+
data-slot="dialog-overlay"
|
|
22
21
|
className={cn(
|
|
23
22
|
"fixed inset-0 z-[1000] bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
24
23
|
className
|
|
@@ -28,37 +27,75 @@ const DialogOverlay = React.forwardRef<
|
|
|
28
27
|
));
|
|
29
28
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|
30
29
|
|
|
30
|
+
const dialogContentVariants = cva(
|
|
31
|
+
"fixed left-[50%] top-[50%] z-[1000] grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 bg-card p-6 shadow-2xl duration-200 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 rounded-2xl border border-border",
|
|
32
|
+
{
|
|
33
|
+
variants: {
|
|
34
|
+
size: {
|
|
35
|
+
sm: "max-w-sm",
|
|
36
|
+
default: "max-w-lg",
|
|
37
|
+
lg: "max-w-2xl",
|
|
38
|
+
xl: "max-w-4xl",
|
|
39
|
+
full: "max-w-[95vw] max-h-[95vh]",
|
|
40
|
+
},
|
|
41
|
+
scrollBehavior: {
|
|
42
|
+
inside: "max-h-[85vh] overflow-hidden",
|
|
43
|
+
outside: "",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
defaultVariants: {
|
|
47
|
+
size: "default",
|
|
48
|
+
scrollBehavior: "outside",
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
|
|
54
|
+
VariantProps<typeof dialogContentVariants> & {
|
|
55
|
+
showCloseButton?: boolean;
|
|
56
|
+
};
|
|
57
|
+
|
|
31
58
|
const DialogContent = React.forwardRef<
|
|
32
59
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
33
|
-
|
|
34
|
-
>(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
DialogContentProps
|
|
61
|
+
>(
|
|
62
|
+
(
|
|
63
|
+
{ className, children, size, scrollBehavior, showCloseButton = true, ...props },
|
|
64
|
+
ref
|
|
65
|
+
) => (
|
|
66
|
+
<DialogPortal>
|
|
67
|
+
<DialogOverlay />
|
|
68
|
+
<DialogPrimitive.Content
|
|
69
|
+
ref={ref}
|
|
70
|
+
data-slot="dialog-content"
|
|
71
|
+
className={cn(dialogContentVariants({ size, scrollBehavior }), className)}
|
|
72
|
+
{...props}
|
|
73
|
+
>
|
|
74
|
+
{children}
|
|
75
|
+
{showCloseButton && (
|
|
76
|
+
<DialogPrimitive.Close className="absolute right-4 top-4 inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-accent-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent">
|
|
77
|
+
<X className="size-4" />
|
|
78
|
+
<span className="sr-only">Close</span>
|
|
79
|
+
</DialogPrimitive.Close>
|
|
80
|
+
)}
|
|
81
|
+
</DialogPrimitive.Content>
|
|
82
|
+
</DialogPortal>
|
|
83
|
+
)
|
|
84
|
+
);
|
|
53
85
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
|
54
86
|
|
|
55
87
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
|
56
|
-
<div
|
|
88
|
+
<div
|
|
89
|
+
data-slot="dialog-header"
|
|
90
|
+
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
|
91
|
+
{...props}
|
|
92
|
+
/>
|
|
57
93
|
);
|
|
58
94
|
DialogHeader.displayName = "DialogHeader";
|
|
59
95
|
|
|
60
96
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
|
61
97
|
<div
|
|
98
|
+
data-slot="dialog-footer"
|
|
62
99
|
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
|
63
100
|
{...props}
|
|
64
101
|
/>
|
|
@@ -71,6 +108,7 @@ const DialogTitle = React.forwardRef<
|
|
|
71
108
|
>(({ className, ...props }, ref) => (
|
|
72
109
|
<DialogPrimitive.Title
|
|
73
110
|
ref={ref}
|
|
111
|
+
data-slot="dialog-title"
|
|
74
112
|
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
|
75
113
|
{...props}
|
|
76
114
|
/>
|
|
@@ -83,6 +121,7 @@ const DialogDescription = React.forwardRef<
|
|
|
83
121
|
>(({ className, ...props }, ref) => (
|
|
84
122
|
<DialogPrimitive.Description
|
|
85
123
|
ref={ref}
|
|
124
|
+
data-slot="dialog-description"
|
|
86
125
|
className={cn("text-sm text-muted-foreground", className)}
|
|
87
126
|
{...props}
|
|
88
127
|
/>
|
|
@@ -100,4 +139,5 @@ export {
|
|
|
100
139
|
DialogFooter,
|
|
101
140
|
DialogTitle,
|
|
102
141
|
DialogDescription,
|
|
142
|
+
dialogContentVariants,
|
|
103
143
|
};
|
|
@@ -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
|
+
};
|