Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"#/*": "./src/*"
},
"dependencies": {
"@base-ui-components/react": "1.0.0-rc.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
Expand Down
56 changes: 56 additions & 0 deletions site/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

89 changes: 89 additions & 0 deletions site/src/components/Drawer/Drawer.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, userEvent, waitFor, within } from "storybook/test";
import { Button } from "#/components/Button/Button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "./Drawer";

const meta: Meta<typeof Drawer> = {
title: "components/Drawer",
component: Drawer,
args: {
children: (
<>
<DrawerTrigger render={<Button>Open Drawer</Button>} />
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Example Drawer Title</DrawerTitle>
<DrawerDescription>Drawer description text</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose render={<Button variant="outline">Cancel</Button>} />
<Button>Submit</Button>
</DrawerFooter>
</DrawerContent>
</>
),
},
};

export default meta;
type Story = StoryObj<typeof Drawer>;

export const OpenDrawer: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Open Drawer" }));
await expect(await screen.findByRole("dialog")).toBeInTheDocument();
},
};

export const CloseDrawer: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Open Drawer" }));
const dialog = await screen.findByRole("dialog");
await userEvent.click(
within(dialog).getByRole("button", { name: "Cancel" }),
);
await waitFor(() =>
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
);
},
};

export const OpenRightDrawer: Story = {
args: {
direction: "right",
children: (
<>
<DrawerTrigger render={<Button>Open Right Drawer</Button>} />
<DrawerContent className="sm:max-w-md">
<DrawerHeader>
<DrawerTitle>Right-side drawer</DrawerTitle>
<DrawerDescription>
Drawers can open from any side via the direction prop.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose render={<Button variant="outline">Close</Button>} />
</DrawerFooter>
</DrawerContent>
</>
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Open Right Drawer" }),
);
await expect(await screen.findByRole("dialog")).toBeInTheDocument();
},
};
146 changes: 146 additions & 0 deletions site/src/components/Drawer/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Adapted from shadcn/ui's Drawer, reimplemented on top of Base UI's Dialog
* primitive instead of the unmaintained `vaul` package. Base UI has no
* dedicated drawer, so the slide-in behavior is layered on the Dialog parts
* with the same public API shadcn exposes.
* @see {@link https://ui.shadcn.com/docs/components/drawer}
* @see {@link https://base-ui.com/react/components/dialog}
*/
import { Dialog as DialogPrimitive } from "@base-ui-components/react/dialog";
import { createContext, useContext } from "react";
import { cn } from "#/utils/cn";

type DrawerDirection = "top" | "bottom" | "left" | "right";

const DrawerDirectionContext = createContext<DrawerDirection>("right");

type DrawerProps = React.ComponentPropsWithRef<typeof DialogPrimitive.Root> & {
direction?: DrawerDirection;
};

export const Drawer: React.FC<DrawerProps> = ({
direction = "right",
...props
}) => {
return (
<DrawerDirectionContext.Provider value={direction}>
<DialogPrimitive.Root {...props} />
</DrawerDirectionContext.Provider>
);
};

export const DrawerTrigger = DialogPrimitive.Trigger;

export const DrawerClose = DialogPrimitive.Close;

const DrawerPortal = DialogPrimitive.Portal;

const DrawerOverlay: React.FC<
React.ComponentPropsWithRef<typeof DialogPrimitive.Backdrop>
> = ({ className, ...props }) => {
return (
<DialogPrimitive.Backdrop
className={cn(
`fixed inset-0 z-50 bg-overlay
data-[open]:animate-in data-[closed]:animate-out
data-[closed]:fade-out-0 data-[open]:fade-in-0`,
className,
)}
{...props}
/>
);
};

const directionClasses: Record<DrawerDirection, string> = {
top: `inset-x-0 top-0 mb-24 max-h-[80vh] rounded-b-lg border-b border-border
data-[open]:slide-in-from-top data-[closed]:slide-out-to-top`,
bottom: `inset-x-0 bottom-0 mt-24 max-h-[80vh] rounded-t-lg border-t border-border
data-[open]:slide-in-from-bottom data-[closed]:slide-out-to-bottom`,
right: `inset-y-0 right-0 h-full w-3/4 border-l border-border sm:max-w-sm
data-[open]:slide-in-from-right data-[closed]:slide-out-to-right`,
left: `inset-y-0 left-0 h-full w-3/4 border-r border-border sm:max-w-sm
data-[open]:slide-in-from-left data-[closed]:slide-out-to-left`,
};

export const DrawerContent: React.FC<
React.ComponentPropsWithRef<typeof DialogPrimitive.Popup>
> = ({ className, children, ...props }) => {
const direction = useContext(DrawerDirectionContext);
return (
<DrawerPortal>
<DrawerOverlay />
<DialogPrimitive.Popup
data-drawer-direction={direction}
className={cn(
`group/drawer-content fixed z-50 flex h-auto flex-col bg-surface-primary outline-none
transition ease-in-out data-[open]:animate-in data-[closed]:animate-out
data-[closed]:duration-300 data-[open]:duration-500`,
directionClasses[direction],
className,
)}
{...props}
>
<div
className={`mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-surface-tertiary
group-data-[drawer-direction=bottom]/drawer-content:block`}
/>
{children}
</DialogPrimitive.Popup>
</DrawerPortal>
);
};

export const DrawerHeader: React.FC<React.ComponentPropsWithRef<"div">> = ({
className,
...props
}) => {
return (
<div
className={cn(
`flex flex-col gap-0.5 p-4
group-data-[drawer-direction=bottom]/drawer-content:text-center
group-data-[drawer-direction=top]/drawer-content:text-center
md:gap-1.5 md:text-left`,
className,
)}
{...props}
/>
);
};

export const DrawerFooter: React.FC<React.ComponentPropsWithRef<"div">> = ({
className,
...props
}) => {
return (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
};

export const DrawerTitle: React.FC<
React.ComponentPropsWithRef<typeof DialogPrimitive.Title>
> = ({ className, ...props }) => {
return (
<DialogPrimitive.Title
className={cn(
"text-lg font-semibold leading-none tracking-tight text-content-primary",
className,
)}
{...props}
/>
);
};

export const DrawerDescription: React.FC<
React.ComponentPropsWithRef<typeof DialogPrimitive.Description>
> = ({ className, ...props }) => {
return (
<DialogPrimitive.Description
className={cn("text-sm text-content-secondary", className)}
{...props}
/>
);
};
Loading
Loading