Skip to the code
cf.

Modal dialog

The tea shop's cart has a Clear cart button that asks before it empties the cart. Build Modal, the dialog it opens, and export it by name. The default export, App, is a small demo that uses it; the starter has it ready.

Modal takes open, title, onClose, and children. While open is false it renders nothing. While it's true, render into document.body with createPortal, not inside the component's parent: a <div role="dialog" aria-modal="true"> holding an <h2> with the title, then the children, then a close button with aria-label="Close" (its text can be ×). The dialog is labelled by its heading through aria-labelledby.

When the dialog opens, move the focus to the first thing in it that can take focus (a button, link, or form field), in page order. With nothing focusable in children, that's the Close button.

Pressing Escape while the focus is in the dialog calls onClose, and so does the Close button. The parent decides what closing means: it sets open back to false.

When the dialog goes away, put the focus back on whatever had it before the dialog opened, usually the button that opened it.

While it's open, Tab and Shift+Tab stay inside the dialog: Tab on its last focusable element goes to its first, and Shift+Tab on its first goes to its last.

<button onClick={() => setOpen(true)}>Clear cart</button>
<Modal open={open} title="Clear the cart?" onClose={() => setOpen(false)}>
  <p>This removes every tea from your cart.</p>
  <button>Keep teas</button>
  <button>Clear</button>
</Modal>
 
// renders, at the end of document.body:
<div role="dialog" aria-modal="true" aria-labelledby=":r1:">
  <h2 id=":r1:">Clear the cart?</h2>
  <p>This removes every tea from your cart.</p>
  <button>Keep teas</button>
  <button>Clear</button>
  <button aria-label="Close">×</button>
</div>

What the tests check

  1. renders nothing while it's closed
    const onClose = spy();
    function Page() {
      const [open, setOpen] = React.useState(false);
      return (
        <div>
          <button onClick={() => setOpen(true)}>Open</button>
          <Modal open={open} title="Clear the cart?" onClose={() => { onClose(); setOpen(false); }}>
            <p>This removes every tea from your cart.</p>
            <button>Keep teas</button>
            <button>Clear</button>
          </Modal>
        </div>
      );
    }
    render(<Page />);
    expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
    
  2. opens as a modal dialog named by its title
    const onClose = spy();
    function Page() {
      const [open, setOpen] = React.useState(false);
      return (
        <div>
          <button onClick={() => setOpen(true)}>Open</button>
          <Modal open={open} title="Clear the cart?" onClose={() => { onClose(); setOpen(false); }}>
            <p>This removes every tea from your cart.</p>
            <button>Keep teas</button>
            <button>Clear</button>
          </Modal>
        </div>
      );
    }
    render(<Page />);
    await user.click(screen.getByRole("button", { name: "Open" }));
    const dialog = screen.getByRole("dialog", { name: "Clear the cart?" });
    expect(dialog).toHaveAttribute("aria-modal", "true");
    expect(within(dialog).getByRole("heading", { level: 2, name: "Clear the cart?" })).toBeInTheDocument();
    expect(within(dialog).getByRole("button", { name: "Close" })).toBeInTheDocument();
    
  3. renders into document.body, outside its parent
    const onClose = spy();
    function Page() {
      const [open, setOpen] = React.useState(false);
      return (
        <div>
          <button onClick={() => setOpen(true)}>Open</button>
          <Modal open={open} title="Clear the cart?" onClose={() => { onClose(); setOpen(false); }}>
            <p>This removes every tea from your cart.</p>
            <button>Keep teas</button>
            <button>Clear</button>
          </Modal>
        </div>
      );
    }
    const { container } = render(<Page />);
    await user.click(screen.getByRole("button", { name: "Open" }));
    const dialog = screen.getByRole("dialog");
    expect(container.contains(dialog)).toBe(false);
    expect(document.body.contains(dialog)).toBe(true);
    
  4. moves the focus to the first focusable element inside
    const onClose = spy();
    function Page() {
      const [open, setOpen] = React.useState(false);
      return (
        <div>
          <button onClick={() => setOpen(true)}>Open</button>
          <Modal open={open} title="Clear the cart?" onClose={() => { onClose(); setOpen(false); }}>
            <p>This removes every tea from your cart.</p>
            <button>Keep teas</button>
            <button>Clear</button>
          </Modal>
        </div>
      );
    }
    render(<Page />);
    await user.click(screen.getByRole("button", { name: "Open" }));
    expect(screen.getByRole("button", { name: "Keep teas" })).toHaveFocus();
    
  5. focuses the Close button when the content has nothing to focus
    function Page() {
      const [open, setOpen] = React.useState(false);
      return (
        <div>
          <button onClick={() => setOpen(true)}>Open</button>
          <Modal open={open} title="Saved" onClose={() => setOpen(false)}>
            <p>Your order is saved.</p>
          </Modal>
        </div>
      );
    }
    render(<Page />);
    await user.click(screen.getByRole("button", { name: "Open" }));
    expect(screen.getByRole("button", { name: "Close" })).toHaveFocus();
    
  6. Escape calls onClose and the focus goes back to the opener
    const onClose = spy();
    function Page() {
      const [open, setOpen] = React.useState(false);
      return (
        <div>
          <button onClick={() => setOpen(true)}>Open</button>
          <Modal open={open} title="Clear the cart?" onClose={() => { onClose(); setOpen(false); }}>
            <p>This removes every tea from your cart.</p>
            <button>Keep teas</button>
            <button>Clear</button>
          </Modal>
        </div>
      );
    }
    render(<Page />);
    await user.click(screen.getByRole("button", { name: "Open" }));
    await user.keyboard("{Escape}");
    expect(onClose).toHaveBeenCalledTimes(1);
    expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Open" })).toHaveFocus();
    
  7. the Close button calls onClose and the focus goes back to the opener
    const onClose = spy();
    function Page() {
      const [open, setOpen] = React.useState(false);
      return (
        <div>
          <button onClick={() => setOpen(true)}>Open</button>
          <Modal open={open} title="Clear the cart?" onClose={() => { onClose(); setOpen(false); }}>
            <p>This removes every tea from your cart.</p>
            <button>Keep teas</button>
            <button>Clear</button>
          </Modal>
        </div>
      );
    }
    render(<Page />);
    await user.click(screen.getByRole("button", { name: "Open" }));
    await user.click(screen.getByRole("button", { name: "Close" }));
    expect(onClose).toHaveBeenCalledTimes(1);
    expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Open" })).toHaveFocus();
    
  8. Tab on the last element wraps to the first
    const onClose = spy();
    function Page() {
      const [open, setOpen] = React.useState(false);
      return (
        <div>
          <button onClick={() => setOpen(true)}>Open</button>
          <Modal open={open} title="Clear the cart?" onClose={() => { onClose(); setOpen(false); }}>
            <p>This removes every tea from your cart.</p>
            <button>Keep teas</button>
            <button>Clear</button>
          </Modal>
        </div>
      );
    }
    render(<Page />);
    await user.click(screen.getByRole("button", { name: "Open" }));
    await user.tab();
    expect(screen.getByRole("button", { name: "Clear" })).toHaveFocus();
    await user.tab();
    expect(screen.getByRole("button", { name: "Close" })).toHaveFocus();
    await user.tab();
    expect(screen.getByRole("button", { name: "Keep teas" })).toHaveFocus();
    
  9. Shift+Tab on the first element wraps to the last
    const onClose = spy();
    function Page() {
      const [open, setOpen] = React.useState(false);
      return (
        <div>
          <button onClick={() => setOpen(true)}>Open</button>
          <Modal open={open} title="Clear the cart?" onClose={() => { onClose(); setOpen(false); }}>
            <p>This removes every tea from your cart.</p>
            <button>Keep teas</button>
            <button>Clear</button>
          </Modal>
        </div>
      );
    }
    render(<Page />);
    await user.click(screen.getByRole("button", { name: "Open" }));
    await user.tab({ shift: true });
    expect(screen.getByRole("button", { name: "Close" })).toHaveFocus();
    
  10. the demo opens from Clear cart and Clear empties the cart
    render(<App />);
    await user.click(screen.getByRole("button", { name: "Clear cart" }));
    await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Clear" }));
    expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
    expect(screen.getByText("0 teas in your cart")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Clear cart" })).toHaveFocus();
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).