Dialog / Sheet outside-click dismissal scoped to the overlay only (WEB-1497)
Version: 0.10.0 ยท Type: ๐ Bug Fix
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.tsxpackages/design/src/components/ui/sheet.tsxpackages/design/src/components/Dialog/Dialog.tsxpackages/design/src/components/Dialog/__tests__/Dialog.test.tsxpackages/design/src/components/Sheet/Sheet.tsxpackages/design/src/components/Sheet/__tests__/Sheet.test.tsxpackages/design-site/docs/components/dialog.mdxpackages/design-site/docs/components/sheet.mdxpackages/design-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/components/dialog.mdxpackages/design-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/components/sheet.mdx
Changesโ
- Add an internal utility
overlay-dismiss.tsexportingshouldDismissOnOutsidePointerDown({ event, overlay })and a locally-declaredPointerDownOutsideEventtype (no new dependency, not publicly exported). It returnstrueonly whenevent.detail.originalEvent.targetis contained by the overlay element; when the overlay isnull(non-modalmodal={false}, where Radix'sDialogOverlay/SheetOverlayrender nothing), it returnstrueto preserve the existing "close on any outside click" behavior. ui/dialog.tsx/ui/sheet.tsx:DialogContent/SheetContentaccept a newoverlayRefforwarded to their respective Overlay; both Overlays gain a semantic class (pd-dialog__overlay/pd-sheet__overlay).Dialog.tsx: holdsoverlayRef;handlePointerDownOutsidenow only allows dismissal whencloseOnOverlayClickis true and the new check passes, otherwise it always callspreventDefault(). Default remainsfalse, so existing behavior is unchanged.DialogContentSlotProps'sOmitnow also excludes'overlayRef'so the internal ref cannot leak intocontentProps.Sheet.tsx: addscloseOnOverlayClick?: boolean, defaulting totrue(deliberately different from Dialog'sfalsedefault โ Sheet's existing behavior is "close on outside click", so defaulting totrueavoids breaking current consumers while still narrowing the effective area to the overlay; passingfalsedisables outside-click dismissal entirely).SheetOpenOptionsinherits the field automatically.- 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. - Docs (
dialog.mdx/sheet.mdx, plus their zh-CN i18n counterparts) updated to describe the newcloseOnOverlayClicksemantics.
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.