refactor(site): add <Drawer /> and migrate build logs drawer (#28009)

> 🤖 This PR was written by Coder Agents on behalf of Jake Howell.

Follow-up to #27798. That PR introduced a shared `Drawer` primitive by
adding [`vaul`](https://github.com/emilkowalski/vaul) as a dependency.
As raised in review, `vaul` is currently unmaintained, and this drawer
is the only place in the UI using that concept.

This PR takes the
[suggested](https://github.com/coder/coder/pull/27798#pullrequestreview-4901095865)
route: a dead-simple, self-owned drawer that covers this one case well,
built directly on Radix UI's `Dialog` (already a dependency via
`radix-ui`) instead of `vaul`. It keeps the same shadcn-style API
(`Drawer`, `DrawerTrigger`, `DrawerContent`, `DrawerHeader`,
`DrawerFooter`, `DrawerTitle`, `DrawerDescription`, `DrawerClose`) so
usage stays familiar, and migrates `CreateTemplatePage`'s
`BuildLogsDrawer` off MUI onto it.

### What changed

- Add `site/src/components/Drawer/Drawer.tsx`: a reusable drawer/sheet
built on `radix-ui` `Dialog`. Supports a `direction` prop
(`top`/`bottom`/`left`/`right`, default `right`) with slide animations
via `tailwindcss-animate`. No new dependencies.
- Migrate `BuildLogsDrawer` from `@mui/material/Drawer` to the new
component. Desktop panel stays at 800px via `min(800px, 100%)` so it
stays within the viewport on mobile.
- Storybook coverage with `play()` functions for both the generic
`Drawer` (open/close, direction) and `BuildLogsDrawer` (close via the X
button and via Escape assert `onClose`), addressing the earlier P1
review note about covering the controlled close path.

### Why Radix instead of vaul

- `radix-ui` `Dialog` is already a dependency and provides
accessibility, focus management, and open/close state.
- No dependency on an unmaintained package for a single-use concept.
- `vaul`'s drag-to-dismiss gesture is not needed for this build-logs use
case.
This commit is contained in:
Jake Howell
2026-08-12 13:51:04 +10:00
committed by GitHub
parent bde38e9d10
commit 726e86cef7
5 changed files with 428 additions and 51 deletions
@@ -0,0 +1,106 @@
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 asChild>
<Button>Open Drawer</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Example Drawer Title</DrawerTitle>
<DrawerDescription>Drawer description text</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
<Button>Submit</Button>
</DrawerFooter>
</DrawerContent>
</>
),
},
};
export default meta;
type Story = StoryObj<typeof Drawer>;
export const Closed: Story = {};
export const Open: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Open Drawer" }));
// The drawer renders into a portal on `document.body`, so query the screen.
await waitFor(() =>
expect(screen.getByText("Example Drawer Title")).toBeInTheDocument(),
);
},
};
export const OpenLeft: Story = {
args: {
direction: "left",
children: (
<>
<DrawerTrigger asChild>
<Button>Open Left Drawer</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Left-side drawer</DrawerTitle>
<DrawerDescription>
Drawers can slide in from any edge via the direction prop.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Close</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</>
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Open Left Drawer" }),
);
await waitFor(() =>
expect(screen.getByText("Left-side drawer")).toBeInTheDocument(),
);
},
};
export const CloseWithButton: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Open Drawer" }));
await waitFor(() =>
expect(screen.getByText("Example Drawer Title")).toBeInTheDocument(),
);
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(
screen.queryByText("Example Drawer Title"),
).not.toBeInTheDocument(),
);
},
};
+147
View File
@@ -0,0 +1,147 @@
import { cva } from "class-variance-authority";
import { Dialog as DialogPrimitive } from "radix-ui";
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> & {
/** The edge of the screen the drawer slides in from. Defaults to "right". */
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.Overlay>
> = ({ className, ...props }) => {
return (
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-overlay",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=open]:duration-300 data-[state=closed]:duration-100",
className,
)}
{...props}
/>
);
};
const drawerContentVariants = cva(
cn(
"fixed z-50 flex h-auto flex-col bg-surface-tertiary outline-none will-change-transform",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:duration-500 data-[state=closed]:duration-300",
),
{
variants: {
direction: {
top: cn(
"inset-x-0 top-0 max-h-[80vh] w-full border-b border-border",
"data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top",
),
bottom: cn(
"inset-x-0 bottom-0 max-h-[80vh] w-full border-t border-border",
"data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
),
left: cn(
"inset-y-0 left-0 h-full w-3/4 border-r border-border sm:max-w-sm",
"data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left",
),
right: cn(
"inset-y-0 right-0 h-full w-3/4 border-l border-border sm:max-w-sm",
"data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right",
),
},
},
defaultVariants: {
direction: "right",
},
},
);
export const DrawerContent: React.FC<
React.ComponentPropsWithRef<typeof DialogPrimitive.Content>
> = ({ className, children, ...props }) => {
const direction = useContext(DrawerDirectionContext);
return (
<DrawerPortal>
<DrawerOverlay />
<DialogPrimitive.Content
className={cn(drawerContentVariants({ direction }), className)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DrawerPortal>
);
};
export const DrawerHeader: React.FC<React.ComponentPropsWithRef<"div">> = ({
className,
...props
}) => {
return (
<div
className={cn("flex flex-col gap-0.5 p-4 md:gap-1.5", 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}
/>
);
};
@@ -1,5 +1,8 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useRef, useState } from "react";
import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test";
import { JobError } from "#/api/queries/templates";
import { Button } from "#/components/Button/Button";
import {
MockProvisionerJob,
MockTemplateVersion,
@@ -13,6 +16,8 @@ const meta: Meta<typeof BuildLogsDrawer> = {
component: BuildLogsDrawer,
args: {
open: true,
onClose: fn(),
onFillVariables: fn(),
},
};
@@ -21,6 +26,63 @@ type Story = StoryObj<typeof BuildLogsDrawer>;
export const Loading: Story = {};
export const CloseWithButton: Story = {
play: async ({ args }) => {
// The drawer portals its content onto `document.body`, so query the screen.
await userEvent.click(
screen.getByRole("button", { name: "Close build logs" }),
);
await waitFor(() => expect(args.onClose).toHaveBeenCalled());
},
};
export const CloseWithEscape: Story = {
play: async ({ args }) => {
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(args.onClose).toHaveBeenCalled());
},
};
// When opened from a button outside the drawer, focus must return to that
// button on close instead of falling back to the document body. The parent
// owns this via `onCloseAutoFocus`.
export const RestoresFocusToOpener: Story = {
render: () => {
const [open, setOpen] = useState(false);
const openerRef = useRef<HTMLButtonElement>(null);
return (
<>
<Button ref={openerRef} onClick={() => setOpen(true)}>
Show build logs
</Button>
<BuildLogsDrawer
open={open}
onClose={() => setOpen(false)}
onFillVariables={fn()}
onCloseAutoFocus={(event) => {
event.preventDefault();
openerRef.current?.focus();
}}
error={undefined}
templateVersion={undefined}
/>
</>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const opener = canvas.getByRole("button", { name: "Show build logs" });
await userEvent.click(opener);
await waitFor(() =>
expect(screen.getByText("Creating template...")).toBeInTheDocument(),
);
await userEvent.click(
screen.getByRole("button", { name: "Close build logs" }),
);
await waitFor(() => expect(opener).toHaveFocus());
},
};
export const MissingVariables: Story = {
args: {
templateVersion: MockTemplateVersion,
@@ -1,9 +1,14 @@
import Drawer from "@mui/material/Drawer";
import { TriangleAlertIcon, XIcon } from "lucide-react";
import type { FC } from "react";
import { JobError } from "#/api/queries/templates";
import type { TemplateVersion } from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerTitle,
} from "#/components/Drawer/Drawer";
import { Loader } from "#/components/Loader/Loader";
import { AlertVariant } from "#/modules/provisioners/ProvisionerAlert";
import { ProvisionerStatusAlert } from "#/modules/provisioners/ProvisionerStatusAlert";
@@ -15,15 +20,25 @@ type BuildLogsDrawerProps = {
error: unknown;
open: boolean;
onClose: () => void;
/** Invoked when the user opts to fill the missing template variables. */
onFillVariables: () => void;
/**
* Forwarded to the drawer's `onCloseAutoFocus`. The drawer has no trigger,
* so the parent owns where focus lands on close (e.g. the opener button).
*/
onCloseAutoFocus?: React.ComponentProps<
typeof DrawerContent
>["onCloseAutoFocus"];
templateVersion: TemplateVersion | undefined;
variablesSectionRef: React.RefObject<HTMLDivElement | null>;
};
export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
templateVersion,
error,
variablesSectionRef,
...drawerProps
open,
onClose,
onFillVariables,
onCloseAutoFocus,
}) => {
const logs = useWatchVersionLogs(templateVersion);
@@ -37,52 +52,59 @@ export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
const hasLogs = logs && logs.length > 0;
return (
<Drawer anchor="right" {...drawerProps}>
<div className="flex h-full w-[800px] flex-col">
<header
className="flex items-center justify-between border-b border-border px-6"
style={{ height: navHeight }}
>
<h3 className="m-0 text-base font-medium">Creating template...</h3>
<Button size="icon-lg" variant="subtle" onClick={drawerProps.onClose}>
<XIcon />
<span className="sr-only">Close build logs</span>
</Button>
</header>
<Drawer
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
onClose();
}
}}
direction="right"
>
<DrawerContent
className="!w-[min(800px,100%)] !max-w-full"
onCloseAutoFocus={onCloseAutoFocus}
>
<div className="flex h-full flex-col">
<header
className="flex items-center justify-between border-b border-border px-6 bg-surface-secondary"
style={{ height: navHeight }}
>
<DrawerTitle className="m-0 text-base font-medium">
Creating template...
</DrawerTitle>
<DrawerClose asChild>
<Button size="icon-lg" variant="subtle">
<XIcon />
<span className="sr-only">Close build logs</span>
</Button>
</DrawerClose>
</header>
{isMissingVariables ? (
<MissingVariablesBanner
onFillVariables={() => {
variablesSectionRef.current?.scrollIntoView({
behavior: "smooth",
});
const firstVariableInput =
variablesSectionRef.current?.querySelector("input");
firstVariableInput?.focus();
drawerProps.onClose();
}}
/>
) : (
<>
{(matchingProvisioners === 0 || !hasLogs) && (
<ProvisionerStatusAlert
matchingProvisioners={matchingProvisioners}
availableProvisioners={availableProvisioners}
tags={templateVersion?.job.tags ?? {}}
variant={AlertVariant.Inline}
/>
)}
{isMissingVariables ? (
<MissingVariablesBanner onFillVariables={onFillVariables} />
) : (
<>
{(matchingProvisioners === 0 || !hasLogs) && (
<ProvisionerStatusAlert
matchingProvisioners={matchingProvisioners}
availableProvisioners={availableProvisioners}
tags={templateVersion?.job.tags ?? {}}
variant={AlertVariant.Inline}
/>
)}
{hasLogs ? (
<section className="flex-1 overflow-auto bg-surface-primary">
<WorkspaceBuildLogs logs={logs} className="border-0" />
</section>
) : (
<Loader />
)}
</>
)}
</div>
{hasLogs ? (
<section className="flex-1 overflow-auto bg-surface-primary">
<WorkspaceBuildLogs logs={logs} className="border-0" />
</section>
) : (
<Loader />
)}
</>
)}
</div>
</DrawerContent>
</Drawer>
);
};
@@ -20,10 +20,49 @@ const CreateTemplatePage: FC = () => {
const [templateVersion, setTemplateVersion] = useState<TemplateVersion>();
const createTemplateMutation = useMutation(createTemplate());
const variablesSectionRef = useRef<HTMLDivElement>(null);
// Remember what had focus when the drawer opened so it can be restored on
// close. The drawer is opened from buttons outside its tree, so Radix has no
// trigger to fall back to.
const buildLogsOpenerRef = useRef<HTMLElement | null>(null);
// Keeps focus on the variables input (rather than the opener) when the drawer
// closes via the "Fill variables" action.
const preserveVariablesFocusRef = useRef(false);
const openBuildLogs = () => {
buildLogsOpenerRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
setIsBuildLogsOpen(true);
};
const fillVariables = () => {
variablesSectionRef.current?.scrollIntoView({ behavior: "smooth" });
preserveVariablesFocusRef.current = true;
setIsBuildLogsOpen(false);
};
const restoreFocusOnDrawerClose = (event: Event) => {
// Radix focuses a DrawerTrigger on close by default, but this drawer has
// none. Return focus to the variables input for the "Fill variables" flow,
// otherwise to whatever opened the drawer, so keyboard users are not
// dropped onto the document body.
if (preserveVariablesFocusRef.current) {
preserveVariablesFocusRef.current = false;
event.preventDefault();
variablesSectionRef.current?.querySelector("input")?.focus();
return;
}
const opener = buildLogsOpenerRef.current;
if (opener?.isConnected) {
event.preventDefault();
opener.focus();
}
};
const pageViewProps: CreateTemplatePageViewProps = {
onCreateTemplate: async (options) => {
setIsBuildLogsOpen(true);
openBuildLogs();
const template = await createTemplateMutation.mutateAsync({
...options,
onCreateVersion: setTemplateVersion,
@@ -36,7 +75,7 @@ const CreateTemplatePage: FC = () => {
{ state: { justCreated: true } },
);
},
onOpenBuildLogsDrawer: () => setIsBuildLogsOpen(true),
onOpenBuildLogsDrawer: openBuildLogs,
error: createTemplateMutation.error,
isCreating: createTemplateMutation.isPending,
variablesSectionRef,
@@ -60,8 +99,9 @@ const CreateTemplatePage: FC = () => {
error={createTemplateMutation.error}
open={isBuildLogsOpen}
onClose={() => setIsBuildLogsOpen(false)}
onFillVariables={fillVariables}
onCloseAutoFocus={restoreFocusOnDrawerClose}
templateVersion={templateVersion}
variablesSectionRef={variablesSectionRef}
/>
</>
);