Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/stripe-third-party-overlays.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions easy-ui-react/src/Modal/Modal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,24 @@ A `<ModalContainer />` accepts a single `<Modal />` as a child. If no child is p

<Canvas of={ModalStories.MenuTrigger} />

## Third-party overlays

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.

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
<Modal.Trigger allowsThirdPartyOverlays>
<Button>Add card</Button>
<Modal>
<Modal.Header>New Credit Card</Modal.Header>
<Modal.Body>{/* Stripe CardElement, etc. */}</Modal.Body>
</Modal>
</Modal.Trigger>
```

## Properties

### Modal.Trigger
Expand Down
38 changes: 38 additions & 0 deletions easy-ui-react/src/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,16 @@ 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 not hide outside content when allowsThirdPartyOverlays is set", () => {
renderModalWithOutsideContent({ allowsThirdPartyOverlays: true });
expect(isElementHidden(screen.getByTestId("outside-content"))).toBe(false);
});
});

const CustomSymbol = (props: object) => <span {...props} />;
Expand Down Expand Up @@ -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<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>
</>,
);
}
22 changes: 20 additions & 2 deletions easy-ui-react/src/Modal/ModalContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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) {
Expand Down Expand Up @@ -65,7 +79,11 @@ export function ModalContainer(props: ModalContainerProps) {
return (
<ModalTriggerProvider state={state} isDismissable={isDismissable}>
{state.isOpen && (
<ModalUnderlay state={state} isDismissable={isDismissable}>
<ModalUnderlay
state={state}
isDismissable={isDismissable}
allowsThirdPartyOverlays={allowsThirdPartyOverlays}
>
{lastChild ? cloneElement(lastChild, overlayProps) : null}
</ModalUnderlay>
)}
Expand Down
11 changes: 11 additions & 0 deletions easy-ui-react/src/Modal/ModalTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down
115 changes: 107 additions & 8 deletions easy-ui-react/src/Modal/ModalUnderlay.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<HTMLDivElement | null>;
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 ? (
<ThirdPartyOverlayUnderlay {...props} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overlay underlay lol

) : (
<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 ref = React.useRef(null);
const ref = useRef<HTMLDivElement>(null);
const { modalProps, underlayProps } = useModalOverlay(
{
...overlayProps,
isDismissable: 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 @@ -47,7 +146,7 @@ export function ModalUnderlay(props: ModalUnderlayProps) {
return (
<Overlay>
<div className={className} {...underlayProps}>
<div {...modalProps} ref={ref} className={styles.underlayBox}>
<div {...modalProps} ref={modalRef} className={styles.underlayBox}>
<div className={styles.underlayEdge} />
{children}
<div className={styles.underlayEdge} />
Expand Down
Loading