Skip to content

Commit 43fcd8e

Browse files
committed
feat(Drawer): add Drawer component
1 parent 077dc03 commit 43fcd8e

File tree

7 files changed

+470
-0
lines changed

7 files changed

+470
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { FloatingFocusManager } from "@floating-ui/react";
2+
import type { PrimitiveProps } from "../../primitives";
3+
import { usePopperAria } from "../../primitives/popper/popper-aria-context";
4+
import { PopperFloating } from "../../primitives/popper/popper-floating";
5+
import { usePopperTransition } from "../../primitives/popper/popper-transtion-context";
6+
import { tx } from "../../utils";
7+
import { useDialog } from "../dialog/dialog-context";
8+
import { useDrawer } from "./drawer-context";
9+
10+
const placementStyles = {
11+
start: {
12+
base: "start-0 top-0 bottom-0",
13+
open: "translate-x-none",
14+
close: "-translate-x-full",
15+
},
16+
end: {
17+
base: "end-0 top-0 bottom-0",
18+
open: "translate-x-none",
19+
close: "translate-x-full",
20+
},
21+
top: {
22+
base: "top-0 left-0 right-0",
23+
open: "translate-y-none",
24+
close: "-translate-y-full",
25+
},
26+
bottom: {
27+
base: "bottom-0 left-0 right-0",
28+
open: "translate-y-none",
29+
close: "translate-y-full",
30+
},
31+
};
32+
33+
export const DrawerContent = (props: PrimitiveProps<"div">) => {
34+
const { children, className, ...rest } = props;
35+
36+
const { placement } = useDrawer();
37+
const { labelId, descriptionId } = usePopperAria();
38+
const { context, initialFocus, finalFocus } = useDialog();
39+
const { status, mounted, duration } = usePopperTransition();
40+
41+
if (!mounted) {
42+
return null;
43+
}
44+
45+
const drawerStyle = placementStyles[placement];
46+
47+
return (
48+
<div className={"z-55 fixed left-0 top-0 flex h-screen w-screen justify-center"}>
49+
<FloatingFocusManager context={context} initialFocus={initialFocus} returnFocus={finalFocus}>
50+
<PopperFloating
51+
duration={duration}
52+
className={tx(
53+
"fixed flex flex-col shadow-md transition-[opacity,translate]",
54+
drawerStyle.base,
55+
!className?.split(" ").some((cls) => cls.startsWith("bg-")) && "bg-bg-normal",
56+
status == "open" ? ["opacity-100", drawerStyle.open] : ["opacity-0", drawerStyle.close],
57+
className,
58+
)}
59+
aria-labelledby={labelId}
60+
aria-describedby={descriptionId}
61+
{...rest}
62+
>
63+
{children}
64+
</PopperFloating>
65+
</FloatingFocusManager>
66+
</div>
67+
);
68+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { createSafeContext } from "../../primitives";
2+
3+
export type DrawerPlacement = "start" | "end" | "bottom" | "top";
4+
5+
export type DrawerContextValue = {
6+
/**
7+
* 放置位置
8+
* @default 'right'
9+
*/
10+
placement: DrawerPlacement;
11+
};
12+
13+
export const [DrawerContext, useDrawer] = createSafeContext<DrawerContextValue>({
14+
name: "DrawerContext",
15+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { PrimitiveProps } from "../../primitives";
2+
import { DialogRoot, type DialogRootProps } from "../dialog/dialog-root";
3+
import { DrawerContext, type DrawerContextValue } from "./drawer-context";
4+
5+
export type DrawerRootProps = Omit<DialogRootProps, "scrollBehavior" | "preventScroll" | "placement" | "role"> &
6+
Partial<DrawerContextValue>;
7+
8+
export const DrawerRoot = (props: PrimitiveProps<"div", DrawerRootProps, "role">) => {
9+
const { placement = "end", children, ...rest } = props;
10+
11+
return (
12+
<DrawerContext value={{ placement }}>
13+
<DialogRoot role={"dialog"} scrollBehavior={"inside"} preventScroll {...rest}>
14+
{children}
15+
</DialogRoot>
16+
</DrawerContext>
17+
);
18+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { ComponentProps } from "react";
2+
import { PopperBackdrop } from "../../primitives/popper/popper-backdrop";
3+
import { PopperClose } from "../../primitives/popper/popper-close";
4+
import { PopperDescrition } from "../../primitives/popper/popper-description";
5+
import { PopperPortal } from "../../primitives/popper/popper-portal";
6+
import { PopperTitle } from "../../primitives/popper/popper-title";
7+
import { PopperTrigger } from "../../primitives/popper/popper-trigger";
8+
import { DrawerRoot, type DrawerRootProps } from "./drawer-root";
9+
10+
export type DrawerProps = DrawerRootProps;
11+
12+
export const Drawer = DrawerRoot;
13+
14+
export const DrawerTrigger = (props: Omit<ComponentProps<typeof PopperTrigger>, "active">) => {
15+
return <PopperTrigger active={false} {...props} />;
16+
};
17+
18+
export const DrawerPortal = PopperPortal;
19+
20+
export const DrawerBackdrop = PopperBackdrop;
21+
22+
export { DrawerContent } from "./drawer-content";
23+
24+
export const DrawerTitle = PopperTitle;
25+
export const DrawerDescription = PopperDescrition;
26+
export const DrawerClose = PopperClose;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
2+
import { userEvent } from "@testing-library/user-event";
3+
import { afterEach, describe, expect, test, vi } from "vitest";
4+
import { axe } from "vitest-axe";
5+
import {
6+
Drawer,
7+
DrawerBackdrop,
8+
DrawerClose,
9+
DrawerContent,
10+
DrawerDescription,
11+
DrawerPortal,
12+
DrawerTitle,
13+
DrawerTrigger,
14+
type DrawerProps,
15+
} from "../drawer";
16+
17+
const ComponentUnderTest = (props: DrawerProps) => (
18+
<Drawer {...props}>
19+
<DrawerTrigger>Open Drawer</DrawerTrigger>
20+
<DrawerPortal>
21+
<DrawerBackdrop />
22+
<DrawerContent>
23+
<DrawerTitle>Drawer Title</DrawerTitle>
24+
<DrawerDescription>Drawer Description</DrawerDescription>
25+
<DrawerClose>Close</DrawerClose>
26+
</DrawerContent>
27+
</DrawerPortal>
28+
</Drawer>
29+
);
30+
31+
describe("Drawer", () => {
32+
afterEach(() => {
33+
cleanup();
34+
});
35+
36+
test("should have no a11y violations", async () => {
37+
const { container } = render(<ComponentUnderTest />);
38+
const results = await axe(container);
39+
40+
expect(results).toHaveNoViolations();
41+
});
42+
43+
test("should show drawer content when opened", async () => {
44+
render(<ComponentUnderTest />);
45+
46+
await userEvent.click(screen.getByText("Open Drawer"));
47+
expect(await screen.findByText("Drawer Title")).toBeVisible();
48+
49+
await userEvent.click(screen.getByText("Close"));
50+
51+
await waitFor(() => expect(screen.queryByText("Drawer Title")).not.toBeInTheDocument());
52+
});
53+
54+
test("should invoke onOpenChange if drawer is closed", async () => {
55+
const onOpenChange = vi.fn();
56+
render(<ComponentUnderTest open onOpenChange={onOpenChange} />);
57+
58+
await userEvent.click(screen.getByText("Close"));
59+
expect(onOpenChange).toHaveBeenCalledTimes(1);
60+
});
61+
62+
test("should be fully controlled (true)", async () => {
63+
render(<ComponentUnderTest open={true} />);
64+
65+
expect(screen.queryByRole("button", { name: "Close" })).toBeVisible();
66+
67+
await userEvent.click(screen.getByRole("button", { name: "Close" }));
68+
expect(screen.queryByRole("button", { name: "Close" })).toBeVisible();
69+
});
70+
71+
test("should be fully controlled (false)", async () => {
72+
render(<ComponentUnderTest open={false} />);
73+
74+
expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument();
75+
76+
await userEvent.click(screen.getByRole("button", { name: "Open Drawer" }));
77+
expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument();
78+
});
79+
});

packages/react-ui/src/components/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export * from "./context-menu/context-menu-item";
1919
export * from "./context-menu/context-menu-radio-group";
2020
export * from "./context-menu/context-menu-radio-item";
2121
export * from "./dialog/dialog";
22+
export * from "./drawer/drawer";
2223
export * from "./dropdown-menu/dropdown-menu";
2324
export * from "./dropdown-menu/dropdown-menu-checkbox-item";
2425
export * from "./dropdown-menu/dropdown-menu-item";

0 commit comments

Comments
 (0)