/** * Outside-interaction policy shared by `DialogContent` and `SheetContent`. * * Radix dismisses an overlay surface when an outside interaction is not * default-prevented. That loses whatever the user typed, and a stray click on * the page behind a form is easy to make. So by default the surface decides * from its own contents: while it holds a form field, an outside interaction * keeps it open; a surface with nothing to lose (a confirmation, a picker, a * read-only panel) closes. Escape and the close button are unaffected — they * still close. * * `dismissOnOutsideClick` overrides the detection in either direction. *//** base-ui popups (combobox/select) portal their list OUTSIDE the surface, so a * click on an option reads as an interaction outside it. Such a click must * never dismiss, even when the surface opts in. */export const PORTALED_POPUP_SELECTOR = "[data-slot='combobox-content'],[data-slot='select-content']";/** True when the interaction started inside a popup this surface portals out. */export const isInsidePortaledPopup = (target: unknown): boolean => typeof Element !== "undefined" && target instanceof Element && target.closest(PORTALED_POPUP_SELECTOR) !== null;/** * Controls whose presence means an outside click could discard user state. * * Tag selectors alone are not enough: base-ui and Radix render select and * combobox triggers, checkboxes, switches, radios, and sliders as `button` * elements carrying only an ARIA role, so the roles have to be matched too. */export const FORM_FIELD_SELECTOR = [ "input", "textarea", "select", "[contenteditable]:not([contenteditable='false'])", "[role='checkbox']", "[role='combobox']", "[role='listbox']", "[role='radio']", "[role='searchbox']", "[role='slider']", "[role='spinbutton']", "[role='switch']", "[role='textbox']",].join(",");/** The one Element method the policy needs, so tests can stub it without a DOM. */type SurfaceLike = { querySelector(selectors: string): unknown };/** True when the surface currently holds a form field. */export const containsFormField = (surface: SurfaceLike | null): boolean => surface !== null && surface.querySelector(FORM_FIELD_SELECTOR) !== null;/** * Whether an outside interaction should close the surface. * * Pure so the decision is testable without a DOM: the caller does the element * lookups and passes the answers in. `dismissOnOutsideClick` left undefined * means "decide from the contents". */export const dismissesOnOutsideInteraction = (input: { readonly dismissOnOutsideClick: boolean | undefined; readonly insidePortaledPopup: boolean; readonly containsFormField: boolean;}): boolean => !input.insidePortaledPopup && (input.dismissOnOutsideClick ?? !input.containsFormField);/** The shape Radix hands to `onInteractOutside` and `onPointerDownOutside`. */type OutsideInteractionEvent = { readonly detail: { readonly originalEvent: { readonly target: unknown } }; readonly preventDefault: () => void;};/** Apply the policy to a Radix outside-interaction event. Radix dispatches the * event on the OUTSIDE element, so the surface node must be passed in. A * missing node cannot prove the surface is safe to discard, so it stays open. */export const applyOutsideDismissPolicy = ( event: OutsideInteractionEvent, dismissOnOutsideClick: boolean | undefined, surface: SurfaceLike | null,): void => { const dismisses = surface !== null && dismissesOnOutsideInteraction({ dismissOnOutsideClick, insidePortaledPopup: isInsidePortaledPopup(event.detail.originalEvent.target), containsFormField: containsFormField(surface), }); if (!dismisses) event.preventDefault();};
/**
* Outside-interaction policy shared by `DialogContent` and `SheetContent`.
*
* Radix dismisses an overlay surface when an outside interaction is not
* default-prevented. That loses whatever the user typed, and a stray click on
* the page behind a form is easy to make. So by default the surface decides
* from its own contents: while it holds a form field, an outside interaction
* keeps it open; a surface with nothing to lose (a confirmation, a picker, a
* read-only panel) closes. Escape and the close button are unaffected — they
* still close.
*
* `dismissOnOutsideClick` overrides the detection in either direction.
*/
/** base-ui popups (combobox/select) portal their list OUTSIDE the surface, so a
* click on an option reads as an interaction outside it. Such a click must
* never dismiss, even when the surface opts in. */
export const PORTALED_POPUP_SELECTOR =
"[data-slot='combobox-content'],[data-slot='select-content']";
/** True when the interaction started inside a popup this surface portals out. */
export const isInsidePortaledPopup = (target: unknown): boolean =>
typeof Element !== "undefined" &&
target instanceof Element &&
target.closest(PORTALED_POPUP_SELECTOR) !== null;
/**
* Controls whose presence means an outside click could discard user state.
*
* Tag selectors alone are not enough: base-ui and Radix render select and
* combobox triggers, checkboxes, switches, radios, and sliders as `button`
* elements carrying only an ARIA role, so the roles have to be matched too.
*/
export const FORM_FIELD_SELECTOR = [
"input",
"textarea",
"select",
"[contenteditable]:not([contenteditable='false'])",
"[role='checkbox']",
"[role='combobox']",
"[role='listbox']",
"[role='radio']",
"[role='searchbox']",
"[role='slider']",
"[role='spinbutton']",
"[role='switch']",
"[role='textbox']",
].join(",");
/** The one Element method the policy needs, so tests can stub it without a DOM. */
type SurfaceLike = { querySelector(selectors: string): unknown };
/** True when the surface currently holds a form field. */
export const containsFormField = (surface: SurfaceLike | null): boolean =>
surface !== null && surface.querySelector(FORM_FIELD_SELECTOR) !== null;
/**
* Whether an outside interaction should close the surface.
*
* Pure so the decision is testable without a DOM: the caller does the element
* lookups and passes the answers in. `dismissOnOutsideClick` left undefined
* means "decide from the contents".
*/
export const dismissesOnOutsideInteraction = (input: {
readonly dismissOnOutsideClick: boolean | undefined;
readonly insidePortaledPopup: boolean;
readonly containsFormField: boolean;
}): boolean =>
!input.insidePortaledPopup && (input.dismissOnOutsideClick ?? !input.containsFormField);
/** The shape Radix hands to `onInteractOutside` and `onPointerDownOutside`. */
type OutsideInteractionEvent = {
readonly detail: { readonly originalEvent: { readonly target: unknown } };
readonly preventDefault: () => void;
};
/** Apply the policy to a Radix outside-interaction event. Radix dispatches the
* event on the OUTSIDE element, so the surface node must be passed in. A
* missing node cannot prove the surface is safe to discard, so it stays open. */
export const applyOutsideDismissPolicy = (
event: OutsideInteractionEvent,
dismissOnOutsideClick: boolean | undefined,
surface: SurfaceLike | null,
): void => {
const dismisses =
surface !== null &&
dismissesOnOutsideInteraction({
dismissOnOutsideClick,
insidePortaledPopup: isInsidePortaledPopup(event.detail.originalEvent.target),
containsFormField: containsFormField(surface),
});
if (!dismisses) event.preventDefault();
};