diff --git a/site/src/components/Drawer/Drawer.stories.tsx b/site/src/components/Drawer/Drawer.stories.tsx new file mode 100644 index 00000000000..fd1ab75e8b2 --- /dev/null +++ b/site/src/components/Drawer/Drawer.stories.tsx @@ -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 = { + title: "components/Drawer", + component: Drawer, + args: { + children: ( + <> + + + + + + Example Drawer Title + Drawer description text + + + + + + + + + + ), + }, +}; + +export default meta; +type Story = StoryObj; + +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: ( + <> + + + + + + Left-side drawer + + Drawers can slide in from any edge via the direction prop. + + + + + + + + + + ), + }, + 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(), + ); + }, +}; diff --git a/site/src/components/Drawer/Drawer.tsx b/site/src/components/Drawer/Drawer.tsx new file mode 100644 index 00000000000..da65c252173 --- /dev/null +++ b/site/src/components/Drawer/Drawer.tsx @@ -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("right"); + +type DrawerProps = React.ComponentPropsWithRef & { + /** The edge of the screen the drawer slides in from. Defaults to "right". */ + direction?: DrawerDirection; +}; + +export const Drawer: React.FC = ({ + direction = "right", + ...props +}) => { + return ( + + + + ); +}; + +export const DrawerTrigger = DialogPrimitive.Trigger; + +export const DrawerClose = DialogPrimitive.Close; + +const DrawerPortal = DialogPrimitive.Portal; + +const DrawerOverlay: React.FC< + React.ComponentPropsWithRef +> = ({ className, ...props }) => { + return ( + + ); +}; + +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 +> = ({ className, children, ...props }) => { + const direction = useContext(DrawerDirectionContext); + + return ( + + + + {children} + + + ); +}; + +export const DrawerHeader: React.FC> = ({ + className, + ...props +}) => { + return ( +
+ ); +}; + +export const DrawerFooter: React.FC> = ({ + className, + ...props +}) => { + return ( +
+ ); +}; + +export const DrawerTitle: React.FC< + React.ComponentPropsWithRef +> = ({ className, ...props }) => { + return ( + + ); +}; + +export const DrawerDescription: React.FC< + React.ComponentPropsWithRef +> = ({ className, ...props }) => { + return ( + + ); +}; diff --git a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.stories.tsx b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.stories.tsx index fbd0e5cad9e..7d6c0b2ccaa 100644 --- a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.stories.tsx +++ b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.stories.tsx @@ -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 = { component: BuildLogsDrawer, args: { open: true, + onClose: fn(), + onFillVariables: fn(), }, }; @@ -21,6 +26,63 @@ type Story = StoryObj; 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(null); + return ( + <> + + 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, diff --git a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx index 30c0993726d..e0e089dae11 100644 --- a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx +++ b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx @@ -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; }; export const BuildLogsDrawer: FC = ({ templateVersion, error, - variablesSectionRef, - ...drawerProps + open, + onClose, + onFillVariables, + onCloseAutoFocus, }) => { const logs = useWatchVersionLogs(templateVersion); @@ -37,52 +52,59 @@ export const BuildLogsDrawer: FC = ({ const hasLogs = logs && logs.length > 0; return ( - -
-
-

Creating template...

- -
+ { + if (!isOpen) { + onClose(); + } + }} + direction="right" + > + +
+
+ + Creating template... + + + + +
- {isMissingVariables ? ( - { - variablesSectionRef.current?.scrollIntoView({ - behavior: "smooth", - }); - const firstVariableInput = - variablesSectionRef.current?.querySelector("input"); - firstVariableInput?.focus(); - drawerProps.onClose(); - }} - /> - ) : ( - <> - {(matchingProvisioners === 0 || !hasLogs) && ( - - )} + {isMissingVariables ? ( + + ) : ( + <> + {(matchingProvisioners === 0 || !hasLogs) && ( + + )} - {hasLogs ? ( -
- -
- ) : ( - - )} - - )} -
+ {hasLogs ? ( +
+ +
+ ) : ( + + )} + + )} +
+
); }; diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index c775f691e69..f5c5f8e8b96 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -20,10 +20,49 @@ const CreateTemplatePage: FC = () => { const [templateVersion, setTemplateVersion] = useState(); const createTemplateMutation = useMutation(createTemplate()); const variablesSectionRef = useRef(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(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} /> );