Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/stripe-third-party-overlays.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@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.
Add `Modal.ThirdPartyOverlayBoundary` for hosting third-party overlays that render outside the modal (e.g. Stripe Link/autofill, reCAPTCHA). Wrap a `Modal` with it and pass a `selector` matching the overlay nodes the widget appends to `document.body`; the boundary exempts those nodes from the modal's focus trap and `aria-hidden`/`inert` so they stay focusable and clickable instead of locking up. The modal stays mounted and keeps its focus containment for everything else.
18 changes: 11 additions & 7 deletions easy-ui-react/src/Modal/Modal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -119,17 +119,21 @@ A `<ModalContainer />` accepts a single `<Modal />` as a child. If no child is p

By default, a `<Modal />` 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 `<Modal.Trigger />` or `<ModalContainer />` 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.
Wrap the `<Modal />` in a `<Modal.ThirdPartyOverlayBoundary />` and pass a `selector` matching the nodes the widget appends to `document.body`. The boundary exempts those nodes from the modal's focus trap and background hiding so they stay usable, while the modal stays mounted and keeps its focus containment for everything else. 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.
The boundary doesn't rely on a ref or its position in the treeβ€”it watches `document.body` for matching nodes directlyβ€”so you can wrap the `<Modal />` itself or wrap the surrounding `<Modal.Trigger />` / `<ModalContainer />`. Wrapping the `<Modal />` directly keeps the boundary scoped to that one modal.

Use this sparingly. Reach for it only when a modal must host a third-party overlay that requires it, and give each boundary a selector specific to the widget that modal hosts.

```tsx
<Modal.Trigger allowsThirdPartyOverlays>
<Modal.Trigger>
<Button>Add card</Button>
<Modal>
<Modal.Header>New Credit Card</Modal.Header>
<Modal.Body>{/* Stripe CardElement, etc. */}</Modal.Body>
</Modal>
<Modal.ThirdPartyOverlayBoundary selector='iframe[name^="__privateStripe"]'>
<Modal>
<Modal.Header>New Credit Card</Modal.Header>
<Modal.Body>{/* Stripe CardElement, etc. */}</Modal.Body>
</Modal>
</Modal.ThirdPartyOverlayBoundary>
</Modal.Trigger>
```

Expand Down
72 changes: 38 additions & 34 deletions easy-ui-react/src/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,14 +203,46 @@ describe("<Modal />", () => {
expect(handleSecondaryAction).toBeCalled();
});

it("should hide outside content while open by default", () => {
renderModalWithOutsideContent();
expect(isElementHidden(screen.getByTestId("outside-content"))).toBe(true);
it("should render Modal.ThirdPartyOverlayBoundary's children", () => {
render(
<Modal.ThirdPartyOverlayBoundary selector='iframe[name^="__privateStripe"]'>
<span>card form</span>
</Modal.ThirdPartyOverlayBoundary>,
);
expect(screen.getByText("card form")).toBeInTheDocument();
});

it("should not hide outside content when allowsThirdPartyOverlays is set", () => {
renderModalWithOutsideContent({ allowsThirdPartyOverlays: true });
expect(isElementHidden(screen.getByTestId("outside-content"))).toBe(false);
it("should render a Modal wrapped directly by Modal.ThirdPartyOverlayBoundary", () => {
render(
<Modal.Trigger defaultOpen>
<Button>Open modal</Button>
<Modal.ThirdPartyOverlayBoundary selector='iframe[name^="__privateStripe"]'>
<Modal>
<Modal.Header>Header</Modal.Header>
<Modal.Body>Direct wrap content</Modal.Body>
</Modal>
</Modal.ThirdPartyOverlayBoundary>
</Modal.Trigger>,
);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Direct wrap content")).toBeInTheDocument();
});

it("should forward parent-injected props onto a directly-wrapped child", () => {
// Mirrors how Modal.Trigger / ModalContainer clone their modal child to
// inject overlay props: the boundary must pass them through transparently.
const element = (
<Modal.ThirdPartyOverlayBoundary selector='iframe[name^="__privateStripe"]'>
<div data-testid="wrapped" />
</Modal.ThirdPartyOverlayBoundary>
);
render(
React.cloneElement(
element as React.ReactElement<Record<string, unknown>>,
{ id: "injected" },
),
);
expect(screen.getByTestId("wrapped")).toHaveAttribute("id", "injected");
});
});

Expand Down Expand Up @@ -280,31 +312,3 @@ 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<ModalTriggerProps> = {}) {
return render(
<>
<div data-testid="outside-content">Outside content</div>
<Modal.Trigger
defaultOpen
allowsThirdPartyOverlays={allowsThirdPartyOverlays}
>
<Button>Open modal</Button>
<Modal>
<Modal.Header>Header</Modal.Header>
<Modal.Body>Content</Modal.Body>
</Modal>
</Modal.Trigger>
</>,
);
}
6 changes: 6 additions & 0 deletions easy-ui-react/src/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ModalContext } from "./context";
import { useIntersectionDetection } from "./useIntersectionDetection";
import { ModalContainer } from "./ModalContainer";
import { useModalTrigger } from "./context";
import { ThirdPartyOverlayBoundary } from "./ThirdPartyOverlayBoundary";

import styles from "./Modal.module.scss";

Expand Down Expand Up @@ -121,4 +122,9 @@ Modal.Body = ModalBody;
*/
Modal.Footer = ModalFooter;

/**
* Represents a boundary for third-party overlays within a `<Modal />`.
*/
Modal.ThirdPartyOverlayBoundary = ThirdPartyOverlayBoundary;

export { ModalContainer, useModalTrigger };
22 changes: 2 additions & 20 deletions easy-ui-react/src/Modal/ModalContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,6 @@ 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.
*/
Expand All @@ -39,12 +30,7 @@ type ModalContainerProps = {
* element or when the trigger unmounts while the modal is open.
*/
export function ModalContainer(props: ModalContainerProps) {
const {
children,
isDismissable = true,
allowsThirdPartyOverlays = false,
onDismiss = () => {},
} = props;
const { children, isDismissable = true, onDismiss = () => {} } = props;

const childArray = React.Children.toArray(children);
if (childArray.length > 1) {
Expand Down Expand Up @@ -79,11 +65,7 @@ export function ModalContainer(props: ModalContainerProps) {
return (
<ModalTriggerProvider state={state} isDismissable={isDismissable}>
{state.isOpen && (
<ModalUnderlay
state={state}
isDismissable={isDismissable}
allowsThirdPartyOverlays={allowsThirdPartyOverlays}
>
<ModalUnderlay state={state} isDismissable={isDismissable}>
{lastChild ? cloneElement(lastChild, overlayProps) : null}
</ModalUnderlay>
)}
Expand Down
11 changes: 0 additions & 11 deletions easy-ui-react/src/Modal/ModalTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,9 @@ 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).
*/
Expand Down
115 changes: 8 additions & 107 deletions easy-ui-react/src/Modal/ModalUnderlay.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import React, { ReactNode, RefObject, useRef } from "react";
import {
Overlay,
useModalOverlay,
useOverlay,
usePreventScroll,
} from "react-aria";
import { DOMAttributes } from "@react-types/shared";
import React, { ReactNode } from "react";
import { Overlay, useModalOverlay } from "react-aria";
import { OverlayTriggerState } from "react-stately";
import { classNames } from "../utilities/css";
import { useModalTriggerContext } from "./context";
Expand All @@ -27,115 +21,22 @@ 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<HTMLDivElement | null>;
children: ReactNode;
};

export function ModalUnderlay(props: ModalUnderlayProps) {
// Branch into sibling components so each calls its hooks unconditionally.
return props.allowsThirdPartyOverlays ? (
<ThirdPartyOverlayUnderlay {...props} />
) : (
<FocusTrappingUnderlay {...props} />
);
}

/**
* 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 { state, children, ...overlayProps } = props;
const { isDismissable = true } = overlayProps;

const ref = useRef<HTMLDivElement>(null);
const ref = React.useRef(null);
const { modalProps, underlayProps } = useModalOverlay(
{
isDismissable,
...overlayProps,
isDismissable: isDismissable,
isKeyboardDismissDisabled: !isDismissable,
},
state,
ref,
);

return (
<ModalUnderlayContent
modalProps={modalProps}
underlayProps={underlayProps}
modalRef={ref}
>
{children}
</ModalUnderlayContent>
);
}

/**
* 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<HTMLDivElement>(null);
const { overlayProps, underlayProps } = useOverlay(
{
isOpen: state.isOpen,
onClose: state.close,
isDismissable,
isKeyboardDismissDisabled: !isDismissable,
},
ref,
);
usePreventScroll({ isDisabled: !state.isOpen });

return (
<ModalUnderlayContent
modalProps={overlayProps}
underlayProps={underlayProps}
modalRef={ref}
>
{children}
</ModalUnderlayContent>
);
}

/**
* 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(
Expand All @@ -146,7 +47,7 @@ function ModalUnderlayContent({
return (
<Overlay>
<div className={className} {...underlayProps}>
<div {...modalProps} ref={modalRef} className={styles.underlayBox}>
<div {...modalProps} ref={ref} className={styles.underlayBox}>
<div className={styles.underlayEdge} />
{children}
<div className={styles.underlayEdge} />
Expand Down
41 changes: 41 additions & 0 deletions easy-ui-react/src/Modal/ThirdPartyOverlayBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as React from "react";
import {
UseThirdPartyOverlaysOptions,
useThirdPartyOverlays,
} from "./useThirdPartyOverlays";

export type ThirdPartyOverlayBoundaryProps = {
/** The `Modal` (or any subtree) hosting the third-party widget. */
children: React.ReactNode;
} & Omit<UseThirdPartyOverlaysOptions, "ignoreWithin">;

/**
* Declarative wrapper around {@link useThirdPartyOverlays}. Wrap a `Modal` with
* it so a widget's body-level overlays stay interactive. Because `Modal` portals
* its content out of this subtree, the boundary doesn't rely on a ref β€” the hook
* tells inline widgets (inside the dialog) apart from escaped overlays
* automatically, so it works wherever it sits relative to the modal.
*
* It can wrap a `Modal` directly (as the modal child of `Modal.Trigger` /
* `ModalContainer`) or wrap the trigger/container itself β€” both work.
*/
export const ThirdPartyOverlayBoundary = ({
children,
selector,
filter,
minVisibleSizePx,
...injectedProps
}: ThirdPartyOverlayBoundaryProps) => {
useThirdPartyOverlays({ selector, filter, minVisibleSizePx });

// `Modal.Trigger` and `ModalContainer` clone their modal child to inject
// overlay props onto it. When the boundary *is* that child β€” i.e. it wraps a
// `Modal` directly β€” forward those injected props to the wrapped element so
// the boundary stays transparent and the `Modal` receives exactly what it
// would have without the wrapper.
if (React.isValidElement(children) && Object.keys(injectedProps).length > 0) {
return React.cloneElement(children, injectedProps);
}

return <>{children}</>;
};
Loading
Loading