Repository: adobe/react-spectrum
File: packages/react-aria-components/stories/Modal.stories.tsx (TypeScript)

Code before:
/*
 * Copyright 2022 Adobe. All rights reserved.
 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License. You may obtain a copy
 * of the License at http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under
 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
 * OF ANY KIND, either express or implied. See the License for the specific language
 * governing permissions and limitations under the License.
 */

import {Button, Dialog, DialogTrigger, Heading, Modal, ModalOverlay} from 'react-aria-components';
import {Meta, StoryFn} from '@storybook/react';
import React, {useEffect} from 'react';
import './styles.css';

export default {
  title: 'React Aria Components/Modal',
  component: Modal
} as Meta<typeof Modal>;

export type ModalStory = StoryFn<typeof Modal>;

export const ModalExample: ModalStory = () => (
  <DialogTrigger>
    <Button>Open modal</Button>
    <ModalOverlay
      style={{
        position: 'fixed',
        zIndex: 100,
        top: 0,
        left: 0,
        bottom: 0,
        right: 0,
        background: 'rgba(0, 0, 0, 0.5)',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center'
      }}>
      <Modal
        style={{
          background: 'Canvas',
          color: 'CanvasText',
          border: '1px solid gray',
          padding: 30
        }}>
        <Dialog>
          {({close}) => (
            <form style={{display: 'flex', flexDirection: 'column'}}>
              <Heading slot="title" style={{marginTop: 0}}>Sign up</Heading>
              <label>
                First Name: <input placeholder="John" />
              </label>
              <label>
                Last Name: <input placeholder="Smith" />
              </label>
              <Button onPress={close} style={{marginTop: 10}}>
                Submit
              </Button>
            </form>
          )}
        </Dialog>
      </Modal>
    </ModalOverlay>
  </DialogTrigger>
);

export const ModalInteractionOutsideExample: ModalStory = () => {

  useEffect(() => {
    let button = document.createElement('button');
    button.id = 'test-button';
    button.textContent = 'Click to close';
    button.style.position = 'fixed';
    button.style.top = '0';
    button.style.right = '0';
    button.style.zIndex = '200';
    document.body.appendChild(button);

    return () => {
      document.body.removeChild(button);
    };
  }, []);

  return (
    <DialogTrigger>
      <Button>Open modal</Button>
      <ModalOverlay
        isDismissable
        shouldCloseOnInteractOutside={el => {
          if (el.id === 'test-button') {return true;}
          return false;
        }}
        style={{
          position: 'fixed',
          zIndex: 100,
          top: 0,
          left: 0,
          bottom: 0,
          right: 0,
          background: 'rgba(0, 0, 0, 0.5)',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center'
        }}>
        <Modal
          style={{
            background: 'Canvas',
            color: 'CanvasText',
            border: '1px solid gray',
            padding: 30
          }}>
          <Dialog>
            {({close}) => (
              <form style={{display: 'flex', flexDirection: 'column'}}>
                <Heading slot="title" style={{marginTop: 0}}>Sign up</Heading>
                <label>
                  First Name: <input placeholder="John" />
                </label>
                <label>
                  Last Name: <input placeholder="Smith" />
                </label>
                <Button onPress={close} style={{marginTop: 10}}>
                  Submit
                </Button>
              </form>
        )}
          </Dialog>
        </Modal>
      </ModalOverlay>
    </DialogTrigger>
  );
};

export const ModalInteractionOutsideDefaultOverlayExample: ModalStory = () => {

  useEffect(() => {
    let button = document.createElement('button');
    button.id = 'test-button';
    button.textContent = 'Click to close';
    button.style.position = 'fixed';
    button.style.top = '0';
    button.style.right = '0';
    button.style.zIndex = '200';
    document.body.appendChild(button);
    return () => {
      document.body.removeChild(button);
    };
  }, []);

  return (
    <DialogTrigger>
      <Button>Open modal</Button>
      <Modal
        isDismissable
        shouldCloseOnInteractOutside={el => {
          if (el.id === 'test-button') {return true;}
          return false;
        }}
        style={{
          position: 'fixed',
          top: '50%',
          left: '50%',
          transform: 'translate(-50%, -50%)',
          background: 'Canvas',
          color: 'CanvasText',
          border: '1px solid gray',
          padding: 30
        }}>
        <Dialog>
          {({close}) => (
            <form style={{display: 'flex', flexDirection: 'column'}}>
              <Heading slot="title" style={{marginTop: 0}}>Sign up</Heading>
              <label>
                First Name: <input placeholder="John" />
              </label>
              <label>
                Last Name: <input placeholder="Smith" />
              </label>
              <Button onPress={close} style={{marginTop: 10}}>
                Submit
              </Button>
            </form>
        )}
        </Dialog>
      </Modal>
    </DialogTrigger>
  );
};


Review comment (refactor):
Note that this ListBox is NOT wrapped in a Popover. This setup is quite intentional since wrapping it in a Popover means it won't dismiss when you click outside into the root level Dialog (~~which is odd but is probably because everything outside the subdialog is made inert~~ probably due to https://github.com/adobe/react-spectrum/issues/5338).

By having the combobox and its parent dialog close when clicking into the root dialog, ariaHideOutside's cleanup will run multiple times (once per popover) with the combobox's [ariaHideOutside](https://github.com/adobe/react-spectrum/blob/f821086f93125db3a8ea0ca5bb86cadbac3818ad/packages/%40react-aria/combobox/src/useComboBox.ts#L343) being the last cleanup. Previously this wouldn't cause the `inert` set by its parent dialog's ariaHideOutside to get cleared because the combobox's own call doesn't have `shouldUseInert` but now we always clean up `aria-hidden` and `inert`

Code after:
          <Dialog>
            {() => (
              <>
                <TextField>
                  <Label>First name</Label>
                  <Input />
                </TextField>
                <DialogTrigger>
                  <Button>Combobox Trigger</Button>
                  <Popover placement="bottom start">
                    <Dialog>
                      {() => (
                        <ComboBox
                          menuTrigger="focus"
                          autoFocus
                          name="combo-box-example"
                          data-testid="combo-box-example"
                          allowsEmptyCollection>
                          <Label style={{display: 'block'}}>Test</Label>
                          <div style={{display: 'flex'}}>
                            <Input />
                            <Button>
                              <span aria-hidden="true" style={{padding: '0 2px'}}>▼</span>
                            </Button>
                          </div>
                          <ListBox
                            className={styles.menu}>
                            <MyListBoxItem>Foo</MyListBoxItem>
                            <MyListBoxItem>Bar</MyListBoxItem>
                            <MyListBoxItem>Baz</MyListBoxItem>
                            <MyListBoxItem href="http://google.com">Google</MyListBoxItem>
                          </ListBox>
                        </ComboBox>
                      )}
                    </Dialog>
                  </Popover>
                </DialogTrigger>
              </>
            )}
          </Dialog>
        </Modal>
      </ModalOverlay>
    </DialogTrigger>
  );
}

export const InertTestStory = {
  render: () => <InertTest />,
  parameters: {
    description: {
      data: 'You should be able to click "Combobox Trigger" and then click on the textfield, closing the subdialog. A second click should move focus into the textfield'