From b4dc346ab26e98ea463ab959fee5e0fab982ea3a Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 13 Aug 2026 11:12:30 +0000 Subject: [PATCH] feat(webapp): restyle the modal and sheet close buttons Replace the close button on Dialog and Sheet with a square icon-only button using the project's CrossIcon. The esc key label moves out of the button and into a hover tooltip ("Close" + the Esc key) delayed by 500ms. Both surfaces are Radix Dialog underneath, so the treatment lives in one new ModalCloseButton primitive. Each surface keeps its existing height (size-7 for dialogs, size-6 for sheets) so nothing repositions - the buttons only get narrower. The tooltip is driven by its own hover timer rather than Radix's: Radix opens tooltips instantly on focus, and both surfaces autofocus the close button when they hold no other tabbable content, which would otherwise pop the tooltip open on mount and leave it there. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/components/primitives/Dialog.tsx | 19 ++---- .../primitives/ModalCloseButton.tsx | 58 +++++++++++++++++++ .../app/components/primitives/SheetV3.tsx | 11 ++-- 3 files changed, 67 insertions(+), 21 deletions(-) create mode 100644 apps/webapp/app/components/primitives/ModalCloseButton.tsx diff --git a/apps/webapp/app/components/primitives/Dialog.tsx b/apps/webapp/app/components/primitives/Dialog.tsx index 3a7666eb80..b62bb01f22 100644 --- a/apps/webapp/app/components/primitives/Dialog.tsx +++ b/apps/webapp/app/components/primitives/Dialog.tsx @@ -3,8 +3,7 @@ import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { cn } from "~/utils/cn"; -import { XMarkIcon } from "@heroicons/react/24/solid"; -import { ShortcutKey } from "./ShortcutKey"; +import { ModalCloseButton } from "./ModalCloseButton"; const Dialog = DialogPrimitive.Root; @@ -56,18 +55,10 @@ const DialogContent = React.forwardRef< >
{children} - {showCloseButton && ( - - - - Close - - )} + {/* The default size-7 is the height this button had when it rendered the esc key alongside the + icon, so the vertical geometry dialogs align against (the top-11 divider, absolutely + positioned titles) is unchanged — it only gets narrower. */} + {showCloseButton && } )); diff --git a/apps/webapp/app/components/primitives/ModalCloseButton.tsx b/apps/webapp/app/components/primitives/ModalCloseButton.tsx new file mode 100644 index 0000000000..8827a702e4 --- /dev/null +++ b/apps/webapp/app/components/primitives/ModalCloseButton.tsx @@ -0,0 +1,58 @@ +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import * as React from "react"; +import { CrossIcon } from "~/assets/icons/CrossIcon"; +import { cn } from "~/utils/cn"; +import { ShortcutKey } from "./ShortcutKey"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip"; + +const CLOSE_TOOLTIP_DELAY_MS = 500; + +/** + * The close button for modal surfaces — Dialog and Sheet, which are both Radix Dialog underneath. + * Pass `className` to position it, and to override the default `size-7` box where a surface needs + * to keep a tighter header height. + */ +export function ModalCloseButton({ className }: { className?: string }) { + const [open, setOpen] = React.useState(false); + const openTimeout = React.useRef>(); + + const cancelOpen = () => clearTimeout(openTimeout.current); + React.useEffect(() => cancelOpen, []); + + const close = () => { + cancelOpen(); + setOpen(false); + }; + + return ( + + {/* The tooltip is driven by our own hover timer rather than Radix's: Radix opens tooltips + instantly on focus, and these surfaces autofocus this button whenever they hold no other + tabbable content, which would pop the tooltip open on mount and leave it there. + Radix-initiated opens are ignored; its closes (pointer leave, blur, click) are honoured. */} + !nextOpen && close()}> + + { + if (event.pointerType === "touch") return; + cancelOpen(); + openTimeout.current = setTimeout(() => setOpen(true), CLOSE_TOOLTIP_DELAY_MS); + }} + onPointerLeave={close} + className={cn( + "group flex size-7 items-center justify-center rounded-sm opacity-70 transition focus-custom hover:bg-background-hover hover:opacity-100 focus-visible:focus-custom disabled:pointer-events-none", + className + )} + > + + Close + + + + Close + + + + + ); +} diff --git a/apps/webapp/app/components/primitives/SheetV3.tsx b/apps/webapp/app/components/primitives/SheetV3.tsx index 922f28d5ba..5bfc14285e 100644 --- a/apps/webapp/app/components/primitives/SheetV3.tsx +++ b/apps/webapp/app/components/primitives/SheetV3.tsx @@ -1,9 +1,8 @@ -import { XMarkIcon } from "@heroicons/react/20/solid"; import * as SheetPrimitive from "@radix-ui/react-dialog"; import { cva, type VariantProps } from "class-variance-authority"; import * as React from "react"; import { cn } from "~/utils/cn"; -import { ShortcutKey } from "./ShortcutKey"; +import { ModalCloseButton } from "./ModalCloseButton"; const Sheet = SheetPrimitive.Root; @@ -91,11 +90,9 @@ const SheetTitle = React.forwardRef< {...props} > {children} - - - - Close - + {/* size-6 rather than the default size-7 keeps this header row at the height it had when the + button rendered the esc key alongside the icon — it only gets narrower. */} + )); SheetTitle.displayName = SheetPrimitive.Title.displayName;