# Modal

A cross-platform React modal dialog system. A provider/context/hook trio drives a stack of dialogs supporting three presentation types (`popup`, `bottom-sheet`, `full-screen`), structured headers, focus trapping, and programmatic open/close on both web and React Native.

<!-- BEGIN:xui-mcp-instructions:modal -->
A layered overlay component that interrupts the current flow to present focused content requiring the user's attention or a decision. Composed of a header, text block, content area, and footer. Supports three presentation types — Popup, Bottom sheet, and Full screen — covering desktop and mobile contexts.

### When to use
- To confirm a destructive or irreversible action — delete, remove, revoke
- To collect a focused set of inputs without navigating to a new page — a short form, a settings change, a payment step
- To present critical information the user must acknowledge before continuing
- To display contextual content (a preview, a detail view) without losing the current page context
- When the interaction requires the user's undivided attention and the rest of the UI should be blocked

### When not to use
- For non-critical notifications or status updates — use a Toast
- When the information is supplementary and the user can ignore it — use a Tooltip or inline helper text
- When navigation to a dedicated page is more appropriate — open a new route instead
- For complex multi-step workflows that would make the modal very tall — break into a dedicated page or a stepper flow
- For simple one-line confirmations where a native browser confirm() or a small inline confirmation would suffice

### Content guidelines
- Header title — short and specific: *"Delete project"*, *"Add team member"*, *"Confirm payment"*. Use sentence case. Do not end with a period.
- Body title — only needed when the header title is insufficient to explain the content. Example: header is *"Confirm"*, body title is *"You are about to permanently delete 12 files."*
- Description — one or two sentences. Explain the consequence or what the user is being asked to do. Example: *"This action cannot be undone. All associated data will be removed."*
- Primary button label — imperative verb matching the action: *"Delete"*, *"Save changes"*, *"Confirm"*, *"Continue"*, *"Add"*. For destructive actions, use Tone=Alert on the button.
- Cancel / secondary button label — *"Cancel"*, *"Go back"*, *"Not now"*, *"Keep"* (for *"Keep / Delete"* confirmations).
- Avoid vague labels — do not use *"OK"*, *"Yes"*, or *"No"* as button labels. The label must describe the action being taken.
- Modal length — if the content requires scrolling more than one screen length, consider navigating to a dedicated page instead.

### Behaviour guidelines
- Opening — the modal opens programmatically in response to a user action (button click, link click, confirmation trigger). It must never open on its own without user intent. The opening should be animated (fade + scale for Popup; slide-up for Bottom sheet).
- Backdrop — a semi-transparent backdrop covers the page behind Popup and Bottom sheet. Clicking the backdrop closes the modal unless the action is destructive or critical (in which case, force explicit button dismissal).
- Closing — the modal closes on: clicking the close button (✕), clicking the backdrop (if allowed), pressing Escape, completing the primary action, or clicking Cancel. On close, the modal is removed from the DOM (not just hidden).
- Focus management — when the modal opens, focus must move into the modal (to the first interactive element — typically the primary button or the first input). When the modal closes, focus must return to the element that triggered it.
- Focus trap — while the modal is open, keyboard focus must be trapped inside it. Tab and Shift+Tab must cycle through focusable elements within the modal only. Focus must not escape to the background page.
- Scroll — if the content area overflows, the content area scrolls internally; the header and footer remain fixed (sticky) at the top and bottom of the modal. The page behind the modal must not scroll while the modal is open (body { overflow: hidden }).
- Stacking — avoid opening a second modal on top of the first. If a second modal is unavoidable (e.g. a confirmation within a form modal), ensure both have correct z-index ordering and the focus trap applies to the topmost modal only.
- Animation — Popup: fade in + scale from 95% to 100%. Bottom sheet: slide up from the bottom. Full screen: fade or slide based on product design. Keep animations under 300ms. Respect prefers-reduced-motion — use instant transitions for users who prefer reduced motion.

### Accessibility
- The modal container must have role=*"dialog"* and aria-modal=*"true"*.
- The modal must have an accessible name via aria-labelledby pointing to the header title element's id, or aria-label if the title is not a visible text element.
- If the modal has a description, associate it via aria-describedby pointing to the description text's id.
- When the modal opens, move focus to the first interactive element inside the modal — typically the primary button, the close button, or the first form field.
- Implement a focus trap: Tab and Shift+Tab must cycle only within the modal's focusable elements.
- The close button (✕) must have aria-label=*"Close"* since it contains only an icon.
- The Button left back button must have aria-label=*"Go back"* or equivalent.
- When the modal closes, return focus to the element that triggered it.
- Announce the modal opening to screen readers — role=*"dialog"* with aria-labelledby handles this natively in most assistive technologies.
- Prevent background scroll and interaction while the modal is open: use aria-hidden=*"true"* on the <main> content behind the modal so screen readers do not read it.
<!-- END:xui-mcp-instructions:modal -->

## Installation

```bash
npm install @xsolla/xui-modal
```

## Imports

```tsx
import {
  Modal,
  ModalProvider,
  ModalContext,
  WorkArea,
  useModal,
  useModalId,
  type ModalProps,
  type ModalSize,
  type ModalVariant,
  type WorkAreaProps,
  type WorkAreaSize,
} from "@xsolla/xui-modal";
```

`useModalId` is re-exported from `@xsolla/xui-core` and is consumed by portaled descendants (e.g. `Select`, `Dropdown`) so click-outside detection ignores content that logically belongs to the modal.

## Quick start

```tsx
import * as React from "react";
import { Modal, ModalProvider, useModal } from "@xsolla/xui-modal";
import { Button } from "@xsolla/xui-button";

function Trigger() {
  const [open, close] = useModal(() => (
    <Modal onClose={close} title="Confirm action">
      <p>Are you sure you want to proceed?</p>
      <Button onPress={close}>Close</Button>
    </Modal>
  ));
  return <Button onPress={open}>Open modal</Button>;
}

export default function QuickStart() {
  return (
    <ModalProvider>
      <Trigger />
    </ModalProvider>
  );
}
```

## API Reference

### `<Modal>`

The dialog component with header, body, and footer zones. Accepts a `ref` forwarded to the inner `WorkArea`. Unknown props are spread onto the root `Box`. `size` is a typed `ModalProps` field but is never consumed by `Modal`'s internal layout; it passes through via `...rest` to the root `Box` with no built-in styling effect.

| Prop                | Type                                         | Default   | Description                                                                                                                                |
| ------------------- | -------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `testID`            | `string`                                     | —         | Test ID for testing frameworks. On web this renders as `data-testid`; on React Native it renders as `testID`.                              |
| `closeButtonTestId` | `string`                                     | `"modal-close-button"` | Test ID applied to the close (X) button. On web this renders as `data-testid` on the button element. The back button uses `"modal-back-button"`. |
| `closeButtonSize`   | `"xs" \| "sm" \| "md" \| "lg" \| "xl"`       | `"xl"`    | Size of the default header buttons. Maps to `FlexButton`'s size scale (icon sizes 12/14/16/18/20px, hit areas 20/22/28/32/36px). Applies to the back button too, so the pair stays visually matched. |
| `children`          | `ReactNode`                                  | —         | **Required.** Modal body content.                                                                                                          |
| `type`              | `"popup" \| "bottom-sheet" \| "full-screen"` | `"popup"` | Presentation type. Drives positioning, border-radius, and shadow.                                                                          |
| `align`             | `"left" \| "center"`                         | `"left"`  | Content alignment within the body.                                                                                                         |
| `onClose`           | `() => void`                                 | —         | Close callback. When provided, renders a close (X) button and enables Escape and click-outside dismissal.                                  |
| `onBack`            | `() => void`                                 | —         | Back callback. Renders a chevron-left button on the left of the header.                                                                    |
| `header`            | `ReactNode`                                  | —         | Custom header — replaces the default back/close row.                                                                                       |
| `footer`            | `ReactNode`                                  | —         | Footer content rendered below children.                                                                                                    |
| `openContent`       | `boolean`                                    | `false`   | Removes inner padding so children fill edge-to-edge.                                                                                       |
| `closeOutside`      | `boolean`                                    | `true`    | Whether clicking outside closes the modal (requires `onClose`).                                                                            |
| `maxWidth`          | `string \| number`                           | `680`     | Forced to `100%` for `bottom-sheet` and `full-screen`.                                                                                     |
| `minHeight`         | `string \| number`                           | —         | Minimum height of the modal.                                                                                                               |
| `styled`            | `CSSProperties`                              | —         | Inline overrides on the modal container.                                                                                                   |
| `title`             | `string`                                     | —         | Visually hidden accessible title — drives `aria-labelledby`. For a visible heading, render it inside `children` or pass a custom `header`. |
| `aria-label`        | `string`                                     | —         | Alternative accessible name when `title` is not used.                                                                                      |
| `aria-describedby`  | `string`                                     | —         | ID of an element describing modal content.                                                                                                 |
| `initialFocusRef`   | `RefObject<HTMLElement>`                     | —         | Element to focus on open. Falls back to the close button, then the first focusable child.                                                  |

Inherits `ThemeOverrideProps` (`themeMode`, `themeProductContext`).

### Modal types

| Type              | Positioning            | Border radius    | Shadow | Max width           |
| ----------------- | ---------------------- | ---------------- | ------ | ------------------- |
| `popup` (default) | Centred in the overlay | All corners      | Yes    | `680px` (or custom) |
| `bottom-sheet`    | Anchored to the bottom | Top corners only | Yes    | `100%` (forced)     |
| `full-screen`     | Fills the viewport     | `0`              | None   | `100%` (forced)     |

### `<ModalProvider>`

Wraps your app, manages the open-modal stack, and renders `ModalRoot`.

| Prop       | Type        | Description                        |
| ---------- | ----------- | ---------------------------------- |
| `children` | `ReactNode` | **Required.** Application content. |

### `<WorkArea>`

Internal chrome used by `Modal`. Can be rendered standalone for custom layouts.

| Prop          | Type                                         | Default   | Description                                         |
| ------------- | -------------------------------------------- | --------- | --------------------------------------------------- |
| `children`    | `ReactNode`                                  | —         | **Required.** Content.                              |
| `type`        | `"popup" \| "bottom-sheet" \| "full-screen"` | `"popup"` | Affects border-radius and shadow.                   |
| `align`       | `"left" \| "center"`                         | —         | Content alignment.                                  |
| `indent`      | `"sm" \| "md" \| "lg"`                       | —         | Padding hint passed alongside the work-area sizing. |
| `stretched`   | `boolean`                                    | `false`   | Stretch to full height.                             |
| `openContent` | `boolean`                                    | `false`   | Edge-to-edge mode.                                  |
| `fetching`    | `boolean`                                    | `false`   | Renders a loading placeholder when `true`.          |

Inherits `ThemeOverrideProps` (`themeMode`, `themeProductContext`).

### `useModal(renderFn)`

Returns `[open, close]` for a single modal. Must be called inside a `ModalProvider`.

```typescript
function useModal(modal: (props: any) => ReactNode): [() => void, () => void];
```

The hook generates a stable random key per component instance and keeps a ref to the latest render function — your closure values stay current without a dependency array.

### `useModalId()`

Returns the current modal's `data-modal-id`. Portaled descendants should set this attribute on their root so the modal recognises them as in-tree for click-outside checks.

### `ModalContext`

The raw React context. Most apps consume `useModal`, but advanced cases can call `onOpenModal(key, renderFn)` and `onCloseModal(key)` directly.

### Exported types

| Type                 | Members                                                      |
| -------------------- | ------------------------------------------------------------ |
| `ModalProps`         | Props for `<Modal>`.                                         |
| `ModalSize`          | `"sm" \| "md" \| "lg"`                                       |
| `ModalVariant`       | `"popup" \| "bottom-sheet" \| "full-screen"`                 |
| `ModalType`          | `(props: any) => ReactNode` — the render function signature. |
| `ModalContextType`   | `{ onOpenModal, onCloseModal }`                              |
| `ModalProviderProps` | Props for `<ModalProvider>`.                                 |
| `ModalRootProps`     | Props for the internal root.                                 |
| `WorkAreaProps`      | Props for `<WorkArea>`.                                      |
| `WorkAreaSize`       | `"sm" \| "md" \| "lg"`                                       |

## Examples

### Modal types

```tsx
import * as React from "react";
import { Modal, ModalProvider, useModal } from "@xsolla/xui-modal";
import { Button } from "@xsolla/xui-button";

function Demo() {
  const [openPopup, closePopup] = useModal(() => (
    <Modal type="popup" onClose={closePopup} title="Popup">
      <p>Centred with border-radius and shadow.</p>
    </Modal>
  ));
  const [openSheet, closeSheet] = useModal(() => (
    <Modal type="bottom-sheet" onClose={closeSheet} title="Bottom sheet">
      <p>Anchored to the bottom.</p>
    </Modal>
  ));
  const [openFull, closeFull] = useModal(() => (
    <Modal type="full-screen" onClose={closeFull} title="Full screen">
      <p>Fills the viewport.</p>
    </Modal>
  ));
  return (
    <div style={{ display: "flex", gap: 12 }}>
      <Button onPress={openPopup}>Popup</Button>
      <Button onPress={openSheet}>Bottom sheet</Button>
      <Button onPress={openFull}>Full screen</Button>
    </div>
  );
}

export default function Example() {
  return (
    <ModalProvider>
      <Demo />
    </ModalProvider>
  );
}
```

### Confirmation dialog

```tsx
import * as React from "react";
import { Modal, ModalProvider, useModal } from "@xsolla/xui-modal";
import { Button, ButtonGroup } from "@xsolla/xui-button";

function ConfirmTrigger() {
  const [confirmed, setConfirmed] = React.useState(false);
  const [open, close] = useModal(() => (
    <Modal
      onClose={close}
      closeOutside={false}
      maxWidth={400}
      title="Delete item?"
      footer={
        <ButtonGroup orientation="horizontal" size="xl">
          <Button variant="secondary" tone="mono" onPress={close}>
            Cancel
          </Button>
          <Button
            tone="alert"
            onPress={() => {
              setConfirmed(true);
              close();
            }}
          >
            Delete
          </Button>
        </ButtonGroup>
      }
    >
      <p>This action cannot be undone.</p>
    </Modal>
  ));

  return (
    <div>
      <Button tone="alert" onPress={open}>
        Delete
      </Button>
      {confirmed && <p>Deleted.</p>}
    </div>
  );
}

export default function ConfirmDialog() {
  return (
    <ModalProvider>
      <ConfirmTrigger />
    </ModalProvider>
  );
}
```

### Multi-step modal

```tsx
import * as React from "react";
import { Modal, ModalProvider, useModal } from "@xsolla/xui-modal";
import { Button } from "@xsolla/xui-button";

function MultiStep() {
  const [step, setStep] = React.useState(1);
  const [open, close] = useModal(() => (
    <Modal
      onClose={() => {
        setStep(1);
        close();
      }}
      onBack={step > 1 ? () => setStep((s) => s - 1) : undefined}
      title={`Step ${step} of 3`}
    >
      <p>Step {step} content.</p>
      {step < 3 ? (
        <Button onPress={() => setStep((s) => s + 1)}>Next</Button>
      ) : (
        <Button
          onPress={() => {
            setStep(1);
            close();
          }}
        >
          Finish
        </Button>
      )}
    </Modal>
  ));
  return <Button onPress={open}>Open multi-step</Button>;
}

export default function MultiStepExample() {
  return (
    <ModalProvider>
      <MultiStep />
    </ModalProvider>
  );
}
```

### Dropdowns inside modal

```tsx
import * as React from "react";
import { Button } from "@xsolla/xui-button";
import { Modal, ModalProvider, useModal } from "@xsolla/xui-modal";
import { MultiSelect } from "@xsolla/xui-multi-select";
import { Select } from "@xsolla/xui-select";

const paymentOptions = [
  { value: "abn-amro", label: "ABN AMRO" },
  { value: "asn-bank", label: "ASN Bank" },
  { value: "bancomat-pay", label: "BANCOMAT Pay" },
  { value: "blik", label: "BLIK" },
];

function DropdownModalTrigger() {
  const [paymentMethod, setPaymentMethod] = React.useState<string>();
  const [regions, setRegions] = React.useState<(string | number)[]>([]);
  const [open, close] = useModal(() => (
    <Modal onClose={close} title="Payment settings" maxWidth={420}>
      <div
        style={{
          display: "grid",
          gap: 16,
          minHeight: 260,
          alignContent: "end",
        }}
      >
        <Select
          label="Payment method"
          value={paymentMethod}
          options={paymentOptions}
          placeholder="Choose payment method"
          onChange={setPaymentMethod}
        />
        <MultiSelect
          label="Available regions"
          value={regions}
          options={[
            { value: "eu", label: "Europe" },
            { value: "na", label: "North America" },
            { value: "apac", label: "Asia Pacific" },
          ]}
          placeholder="Choose regions"
          onChange={setRegions}
        />
      </div>
    </Modal>
  ));

  return <Button onPress={open}>Open payment settings</Button>;
}

export default function DropdownModalExample() {
  return (
    <ModalProvider>
      <DropdownModalTrigger />
    </ModalProvider>
  );
}
```

### Edge-to-edge content

```tsx
import * as React from "react";
import { Modal, ModalProvider, useModal } from "@xsolla/xui-modal";
import { Button } from "@xsolla/xui-button";

function HeroTrigger() {
  const [open, close] = useModal(() => (
    <Modal openContent onClose={close} title="Gallery">
      <img
        src="/hero-image.jpg"
        alt="Hero"
        style={{ width: "100%", display: "block" }}
      />
    </Modal>
  ));
  return <Button onPress={open}>Open hero</Button>;
}

export default function EdgeToEdge() {
  return (
    <ModalProvider>
      <HeroTrigger />
    </ModalProvider>
  );
}
```

## Platform Support

| Feature       | Web                                       | React Native                                   |
| ------------- | ----------------------------------------- | ---------------------------------------------- |
| Provider      | `ModalProvider` (with `ModalRoot` portal) | `ModalProvider` (root is a no-op stub)         |
| Hook          | `useModal()` → `[open, close]`            | Same signature; provider stack does not render |
| Overlay       | Fixed-positioned backdrop                 | N/A                                            |
| Focus trap    | Keyboard Tab trapping                     | N/A                                            |
| Escape key    | Closes modal                              | N/A                                            |
| Click outside | Dismisses when `closeOutside={true}`      | N/A                                            |

## Keyboard Interaction

| Key           | Action                                                   |
| ------------- | -------------------------------------------------------- |
| `Escape`      | Closes the modal (when `onClose` is provided).           |
| `Tab`         | Moves focus to the next focusable element (trapped).     |
| `Shift + Tab` | Moves focus to the previous focusable element (trapped). |
| `Enter`       | Activates the focused button.                            |

## Accessibility

- Renders with `role="dialog"` and `aria-modal="true"`.
- `title` creates a visually hidden labelled element via `aria-labelledby`; otherwise `aria-label` is used.
- Focus is trapped within the modal; on open it moves to `initialFocusRef`, the close button, or the first focusable element. On close, focus returns to the previously active element.
- The default close button has `aria-label="Close modal"`; the back button has `aria-label="Go back"`.
- Both default header buttons are `FlexButton`s (`variant="secondary"`, `background`), so their hover, press, and focus-ring behaviour comes from the shared button primitive. Size is controlled by `closeButtonSize` (default `"xl"` → 20px icon in a 36×36 hit area). The 36×36 hit area matches Figma and the bespoke header button it replaces; the glyph itself is 20px, per FEP-836 and `FlexButton`'s shared icon scale.
- `data-modal-id` lets click-outside detection ignore portaled content (e.g. `Select`, `Dropdown`) belonging to the modal.

## Troubleshooting

### "useModal must be used within a ModalProvider"

`useModal` (or direct `ModalContext` access) ran outside a `ModalProvider`. Ensure your component tree has a `ModalProvider` ancestor.

### Modal appears behind other content

`ModalRoot` uses `z-index: 1000` via portal into `document.body`. Toast notifications use `z-index: 9999` and will appear above modals by default.

### Click-outside not working for portaled dropdowns

Portaled descendants must set `data-modal-id` (read via `useModalId`) on their root so the modal recognises them as in-tree.

### Bottom-sheet or full-screen modal not filling width

`maxWidth` is forced to `100%` for these types — check that no parent container is restricting width.

### Focus not returning after close

The modal saves `document.activeElement` on mount. If the trigger element is removed from the DOM while the modal is open, focus cannot be restored.
