Skip to main content

Dialog / Sheet outside-click dismissal scoped to the overlay only (WEB-1497)

Version: 0.10.0 ยท Type: ๐Ÿ› Bug Fix

WEB-1497 (Dialog) ยท WEB-1514 (Sheet)

Problemโ€‹

Dialog's closeOnOverlayClick is named "close on overlay click", but the implementation only forwarded Radix DismissableLayer's default outside-pointerdown judgment. Sheet (aliased as Drawer) didn't even have a toggle โ€” it closed on any outside click by default. Both share the same gap: any globally-mounted, interactive element that visually floats above the modal gets treated as "outside" and dismisses the dialog/sheet when clicked.

Real-world incident (downstream USE-88): web4's ContactSupportWidget (z-9999 with explicit pointer-events-auto, so it stays interactive while Radix locks body to pointer-events: none). Opening the widget from inside the Settings dialog and clicking its close button or dragging its title bar closed the Settings dialog along with it.

Root causeโ€‹

Radix's onPointerDownOutside only checks whether the pointerdown target is inside the Content subtree / a registered DismissableLayerBranch โ€” it has no concept of z-index or visual stacking. A floating element that visually covers the overlay is still judged "outside" by Radix.

Changed Filesโ€‹

  • packages/design/src/utils/overlay-dismiss.ts (new)
  • packages/design/src/components/ui/dialog.tsx
  • packages/design/src/components/ui/sheet.tsx
  • packages/design/src/components/Dialog/Dialog.tsx
  • packages/design/src/components/Dialog/__tests__/Dialog.test.tsx
  • packages/design/src/components/Sheet/Sheet.tsx
  • packages/design/src/components/Sheet/__tests__/Sheet.test.tsx
  • packages/design-site/docs/components/dialog.mdx
  • packages/design-site/docs/components/sheet.mdx
  • packages/design-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/components/dialog.mdx
  • packages/design-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/components/sheet.mdx

Changesโ€‹

  1. Add an internal utility overlay-dismiss.ts exporting shouldDismissOnOutsidePointerDown({ event, overlay }) and a locally-declared PointerDownOutsideEvent type (no new dependency, not publicly exported). It returns true only when event.detail.originalEvent.target is contained by the overlay element; when the overlay is null (non-modal modal={false}, where Radix's DialogOverlay / SheetOverlay render nothing), it returns true to preserve the existing "close on any outside click" behavior.
  2. ui/dialog.tsx / ui/sheet.tsx: DialogContent / SheetContent accept a new overlayRef forwarded to their respective Overlay; both Overlays gain a semantic class (pd-dialog__overlay / pd-sheet__overlay).
  3. Dialog.tsx: holds overlayRef; handlePointerDownOutside now only allows dismissal when closeOnOverlayClick is true and the new check passes, otherwise it always calls preventDefault(). Default remains false, so existing behavior is unchanged. DialogContentSlotProps's Omit now also excludes 'overlayRef' so the internal ref cannot leak into contentProps.
  4. Sheet.tsx: adds closeOnOverlayClick?: boolean, defaulting to true (deliberately different from Dialog's false default โ€” Sheet's existing behavior is "close on outside click", so defaulting to true avoids breaking current consumers while still narrowing the effective area to the overlay; passing false disables outside-click dismissal entirely). SheetOpenOptions inherits the field automatically.
  5. Tests: Dialog gains 2 new cases (clicking a floating element above the dialog does not close it; modal={false} still closes on outside click per the legacy semantics); Sheet gains 4 new cases (default overlay click closes; closeOnOverlayClick={false} does not close; clicking a floating element does not close; modal={false} still closes). Existing overlay-click test cases on both sides now select the overlay by the new semantic class and assert it exists.
  6. Docs (dialog.mdx / sheet.mdx, plus their zh-CN i18n counterparts) updated to describe the new closeOnOverlayClick semantics.

Impactโ€‹

web4's 5 existing call sites that pass closeOnOverlayClick truthy (features/settings/openSettings.tsx, features/credit/components/CreditDetailModal.tsx, features/search/components/search-dialog/index.tsx, PlanSwitchDialog.tsx, ManageSeatsDialog.tsx) all intend the literal "click the overlay" behavior, none rely on "click anywhere outside", so no regression is expected. This also incidentally fixes the related issue where clicking portaled Select / Popover content inside a dialog would dismiss the outer dialog. The only two non-modal call sites (RoutineForm.tsx, EditRoutineForm.tsx, both modal={false}) don't pass closeOnOverlayClick, and the non-modal branch already preserves the legacy semantics.