diff --git a/.changeset/stripe-third-party-overlays.md b/.changeset/stripe-third-party-overlays.md new file mode 100644 index 00000000..929ba392 --- /dev/null +++ b/.changeset/stripe-third-party-overlays.md @@ -0,0 +1,5 @@ +--- +"@easypost/easy-ui": minor +--- + +Add `allowsThirdPartyOverlays` to `Modal.Trigger` and `ModalContainer`. When enabled, the modal stops trapping focus and stops applying `aria-hidden`/`inert` to the rest of the page, so third-party overlays that render outside the modal (e.g. Stripe Link/autofill, reCAPTCHA) remain focusable and clickable instead of locking up. diff --git a/easy-ui-react/src/Modal/Modal.mdx b/easy-ui-react/src/Modal/Modal.mdx index 4e30ef3f..dc88af15 100644 --- a/easy-ui-react/src/Modal/Modal.mdx +++ b/easy-ui-react/src/Modal/Modal.mdx @@ -115,6 +115,24 @@ A `` accepts a single `` as a child. If no child is p +## Third-party overlays + +By default, a `` traps focus and hides the rest of the page from assistive technologies (via `aria-hidden`/`inert`) while it's open. This is the correct behavior for most modals, but it breaks third-party widgets that inject their own overlays into the document _outside_ the modal—e.g. Stripe Link/autofill or reCAPTCHA. Those overlays get `inert`'d (making them unclickable) and lose focus back to the modal. + +Set `allowsThirdPartyOverlays` on `` or `` to opt out of focus trapping and background aria-hiding so those overlays stay usable. Closing on interaction outside, scroll locking, and restoring focus to the trigger on close are all preserved. + +Use this sparingly. It trades away the modal's focus containment and background hiding, so reach for it only when a modal must host a third-party overlay that requires it. + +```tsx + + + + New Credit Card + {/* Stripe CardElement, etc. */} + + +``` + ## Properties ### Modal.Trigger diff --git a/easy-ui-react/src/Modal/Modal.test.tsx b/easy-ui-react/src/Modal/Modal.test.tsx index 4253f655..e11a12ba 100644 --- a/easy-ui-react/src/Modal/Modal.test.tsx +++ b/easy-ui-react/src/Modal/Modal.test.tsx @@ -202,6 +202,16 @@ describe("", () => { ); expect(handleSecondaryAction).toBeCalled(); }); + + it("should hide outside content while open by default", () => { + renderModalWithOutsideContent(); + expect(isElementHidden(screen.getByTestId("outside-content"))).toBe(true); + }); + + it("should not hide outside content when allowsThirdPartyOverlays is set", () => { + renderModalWithOutsideContent({ allowsThirdPartyOverlays: true }); + expect(isElementHidden(screen.getByTestId("outside-content"))).toBe(false); + }); }); const CustomSymbol = (props: object) => ; @@ -270,3 +280,31 @@ async function renderAndOpenModal(args = {}) { expect(screen.queryByRole("dialog")).toBeInTheDocument(); return response; } + +// react-aria's `ariaHideOutside` hides outside content via `inert` (or +// `aria-hidden` where `inert` is unsupported), applied to an ancestor. +function isElementHidden(element: Element) { + return Boolean( + element.closest("[inert]") || element.closest('[aria-hidden="true"]'), + ); +} + +function renderModalWithOutsideContent({ + allowsThirdPartyOverlays, +}: Partial = {}) { + return render( + <> +
Outside content
+ + + + Header + Content + + + , + ); +} diff --git a/easy-ui-react/src/Modal/ModalContainer.tsx b/easy-ui-react/src/Modal/ModalContainer.tsx index 741c4032..502a417f 100644 --- a/easy-ui-react/src/Modal/ModalContainer.tsx +++ b/easy-ui-react/src/Modal/ModalContainer.tsx @@ -15,6 +15,15 @@ type ModalContainerProps = { */ isDismissable?: boolean; + /** + * Disables focus trapping and background aria-hiding so the modal can host + * third-party overlays (e.g. Stripe Link/autofill, reCAPTCHA) that render + * outside the modal. Use sparingly — see `ModalUnderlay` for the tradeoffs. + * + * @default false + */ + allowsThirdPartyOverlays?: boolean; + /** * Handler that is called when the overlay is closed. */ @@ -30,7 +39,12 @@ type ModalContainerProps = { * element or when the trigger unmounts while the modal is open. */ export function ModalContainer(props: ModalContainerProps) { - const { children, isDismissable = true, onDismiss = () => {} } = props; + const { + children, + isDismissable = true, + allowsThirdPartyOverlays = false, + onDismiss = () => {}, + } = props; const childArray = React.Children.toArray(children); if (childArray.length > 1) { @@ -65,7 +79,11 @@ export function ModalContainer(props: ModalContainerProps) { return ( {state.isOpen && ( - + {lastChild ? cloneElement(lastChild, overlayProps) : null} )} diff --git a/easy-ui-react/src/Modal/ModalTrigger.tsx b/easy-ui-react/src/Modal/ModalTrigger.tsx index d603584f..d3256abd 100644 --- a/easy-ui-react/src/Modal/ModalTrigger.tsx +++ b/easy-ui-react/src/Modal/ModalTrigger.tsx @@ -19,9 +19,20 @@ export type ModalTriggerProps = { /** * Whether or not the modal can be dismissed. + * + * @default true */ isDismissable?: boolean; + /** + * Disables focus trapping and background aria-hiding so the modal can host + * third-party overlays (e.g. Stripe Link/autofill, reCAPTCHA) that render + * outside the modal. Use sparingly — see `ModalUnderlay` for the tradeoffs. + * + * @default false + */ + allowsThirdPartyOverlays?: boolean; + /** * Whether the modal is open by default (controlled). */ diff --git a/easy-ui-react/src/Modal/ModalUnderlay.tsx b/easy-ui-react/src/Modal/ModalUnderlay.tsx index ce65c4c8..912b7dfe 100644 --- a/easy-ui-react/src/Modal/ModalUnderlay.tsx +++ b/easy-ui-react/src/Modal/ModalUnderlay.tsx @@ -1,5 +1,11 @@ -import React, { ReactNode } from "react"; -import { Overlay, useModalOverlay } from "react-aria"; +import React, { ReactNode, RefObject, useRef } from "react"; +import { + Overlay, + useModalOverlay, + useOverlay, + usePreventScroll, +} from "react-aria"; +import { DOMAttributes } from "@react-types/shared"; import { OverlayTriggerState } from "react-stately"; import { classNames } from "../utilities/css"; import { useModalTriggerContext } from "./context"; @@ -21,22 +27,115 @@ type ModalUnderlayProps = { * Whether or not the modal is dismissable. */ isDismissable?: boolean; + + /** + * When `true`, the modal stops trapping focus and stops hiding the rest of + * the page from assistive technologies (`aria-hidden`/`inert`). Use this only + * for modals that intentionally host third-party overlays — e.g. Stripe + * Link/autofill or reCAPTCHA — which render themselves into the document + * *outside* the modal. react-aria's focus trap and `inert` would otherwise + * blur and lock up those overlays. This trades away the modal's focus + * containment and background hiding, so reach for it only when a third-party + * overlay requires it. + * + * @default false + */ + allowsThirdPartyOverlays?: boolean; +}; + +type ModalUnderlayContentProps = { + modalProps: DOMAttributes; + underlayProps: DOMAttributes; + modalRef: RefObject; + children: ReactNode; }; export function ModalUnderlay(props: ModalUnderlayProps) { - const { state, children, ...overlayProps } = props; - const { isDismissable = true } = overlayProps; + // Branch into sibling components so each calls its hooks unconditionally. + return props.allowsThirdPartyOverlays ? ( + + ) : ( + + ); +} + +/** + * Standard modal behavior: traps focus and hides the rest of the page via + * react-aria's `useModalOverlay` (which applies `aria-hidden`/`inert` outside + * the modal). + */ +function FocusTrappingUnderlay(props: ModalUnderlayProps) { + const { state, children, isDismissable = true } = props; - const ref = React.useRef(null); + const ref = useRef(null); const { modalProps, underlayProps } = useModalOverlay( { - ...overlayProps, - isDismissable: isDismissable, + isDismissable, isKeyboardDismissDisabled: !isDismissable, }, state, ref, ); + + return ( + + {children} + + ); +} + +/** + * Like `FocusTrappingUnderlay`, but built from the lower-level overlay hooks so + * it can omit the two behaviors that fight third-party overlays: + * - no `ariaHideOutside`, so overlays injected outside the modal aren't + * `inert`'d (which would make them unclickable), and + * - no forced focus containment, so focus can't be stolen back from them. + * + * `Overlay` still restores focus to the trigger when the modal closes. + * close-on-interact-outside is preserved; react-aria already ignores clicks on + * `[data-react-aria-top-layer]` elements, so a properly tagged third-party + * overlay won't dismiss the modal. + */ +function ThirdPartyOverlayUnderlay(props: ModalUnderlayProps) { + const { state, children, isDismissable = true } = props; + + const ref = useRef(null); + const { overlayProps, underlayProps } = useOverlay( + { + isOpen: state.isOpen, + onClose: state.close, + isDismissable, + isKeyboardDismissDisabled: !isDismissable, + }, + ref, + ); + usePreventScroll({ isDisabled: !state.isOpen }); + + return ( + + {children} + + ); +} + +/** + * Shared underlay markup for both underlay variants. The variants differ only + * in which react-aria hooks produce `modalProps`/`underlayProps`. + */ +function ModalUnderlayContent({ + modalProps, + underlayProps, + modalRef, + children, +}: ModalUnderlayContentProps) { const { hasOpenNestedModal } = useModalTriggerContext(); const className = classNames( @@ -47,7 +146,7 @@ export function ModalUnderlay(props: ModalUnderlayProps) { return (
-
+
{children}