diff --git a/src/components/modal/Modal.js b/src/components/modal/Modal.js.flow similarity index 100% rename from src/components/modal/Modal.js rename to src/components/modal/Modal.js.flow diff --git a/src/components/modal/Modal.tsx b/src/components/modal/Modal.tsx new file mode 100644 index 0000000000..9ce4ced636 --- /dev/null +++ b/src/components/modal/Modal.tsx @@ -0,0 +1,187 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import tabbable from 'tabbable'; +import omit from 'lodash/omit'; + +import FocusTrap from '../focus-trap'; +import LoadingIndicator, { LoadingIndicatorSize } from '../loading-indicator'; +import Portal from '../portal'; +import ModalDialog from './ModalDialog'; + +import './Modal.scss'; + +export interface ModalProps extends Omit, 'children' | 'style' | 'title'> { + /** Contents of the modal dialog */ + children: React.ReactNode; + /** Additional CSS classname of the `.modal` element */ + className?: string; + /** CSS selector for the element that receives focus when the modal opens */ + focusElementSelector?: string; + /** Whether to display a loading indicator instead of the modal dialog */ + isLoading?: boolean; + /** Whether the modal is open */ + isOpen?: boolean; + /** Handler called when the backdrop is clicked */ + onBackdropClick?: React.MouseEventHandler; + /** Handler called when the modal requests to close */ + onRequestClose?: ( + event: + | React.KeyboardEvent + | React.MouseEvent + | React.MouseEvent, + ) => void; + /** Whether to render inline instead of using a portal */ + shouldNotUsePortal?: boolean; + /** Styles applied to the backdrop and dialog */ + style: { + backdrop?: React.CSSProperties; + dialog?: React.CSSProperties; + }; + /** Title displayed in the modal header */ + title?: React.ReactNode; +} + +class Modal extends React.Component { + static defaultProps = { + style: { + backdrop: {}, + dialog: {}, + }, + }; + + componentDidMount() { + const { isOpen } = this.props; + + if (isOpen) { + this.onModalOpen(); + } + } + + componentDidUpdate(prevProps: ModalProps) { + const { isLoading, isOpen } = this.props; + + // Set focus if modal is transitioning from closed -> open and/or loading -> not loading + if ((!prevProps.isOpen || prevProps.isLoading) && isOpen && !isLoading) { + this.onModalOpen(); + } + } + + /** + * Call props.onRequestClose when escape is pressed + * @param {SyntheticKeyboardEvent} event + */ + onKeyDown = (event: React.KeyboardEvent) => { + const { isOpen, onRequestClose } = this.props; + if (isOpen && onRequestClose && event.key === 'Escape') { + event.stopPropagation(); + onRequestClose(event); + } + }; + + /** + * Call props.onRequestClose when backdrop is clicked + * @param {SyntheticMouseEvent} event + */ + onBackdropClick = (event: React.MouseEvent) => { + const { onRequestClose, onBackdropClick } = this.props; + + if (onBackdropClick) { + onBackdropClick(event); + } else if (onRequestClose) { + onRequestClose(event); + } + }; + + /** + * Focuses on the correct element in the popup when it opens + */ + onModalOpen = () => { + setTimeout(() => { + const { focusElementSelector } = this.props; + const focusElementSelectorTrimmed = focusElementSelector && focusElementSelector.trim(); + if (focusElementSelectorTrimmed) { + this.focusElement(focusElementSelectorTrimmed); + } else { + this.focusFirstUsefulElement(); + } + }, 0); + }; + + dialog: HTMLDivElement | null = null; + + /** + * Focus the first useful element in the modal (i.e. not the close button, unless it's the only thing) + */ + focusFirstUsefulElement = () => { + if (!this.dialog) { + return; + } + const tabbableEls = tabbable(this.dialog); + if (tabbableEls.length > 1) { + tabbableEls[1].focus(); + } else if (tabbableEls.length > 0) { + tabbableEls[0].focus(); + } + }; + + /** + * Focus the element that matches the selector in the modal + * @throws {Error} When the elementSelector does not match any element + */ + focusElement = (elementSelector: string) => { + if (!this.dialog) { + return; + } + const el = this.dialog.querySelector(elementSelector); + if (el) { + el.focus(); + } else { + throw new Error(`Could not find element matching selector ${elementSelector} to focus on.`); + } + }; + + render() { + const { className, isLoading, isOpen, onRequestClose, shouldNotUsePortal, style, ...rest } = this.props; + + if (!isOpen) { + return null; + } + + const bodyOverrideStyle = ` + body { + overflow:hidden; + } + `; + + // used `omit` here to prevent certain key/value pairs from going into the spread on `ModalDialog` + const modalProps = omit(rest, ['onBackdropClick', 'focusElementSelector']); + + const WrapperComponent = (shouldNotUsePortal ? 'div' : Portal) as React.ElementType; + // Render a style tag to prevent body from scrolling as long as the Modal is open + return ( + + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} +
+ + {isLoading ? ( + + ) : ( + { + // This callback gets passed through as a regular prop since + // ModalDialog is wrapped in a HOC + this.dialog = modalEl; + }} + onRequestClose={onRequestClose} + style={style.dialog} + {...modalProps} + /> + )} + + + + ); + } +} + +export default Modal; diff --git a/src/components/modal/ModalActions.js b/src/components/modal/ModalActions.js.flow similarity index 100% rename from src/components/modal/ModalActions.js rename to src/components/modal/ModalActions.js.flow diff --git a/src/components/modal/ModalActions.tsx b/src/components/modal/ModalActions.tsx new file mode 100644 index 0000000000..5fd1c781cc --- /dev/null +++ b/src/components/modal/ModalActions.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import classNames from 'classnames'; + +export interface ModalActionsProps extends React.HTMLAttributes { + /** Contents of the modal action area */ + children?: React.ReactNode; + /** Additional CSS class name for the modal action area */ + className?: string; +} + +const ModalActions = ({ className, ...rest }: ModalActionsProps) => ( +
+); + +export default ModalActions; diff --git a/src/components/modal/ModalDialog.js b/src/components/modal/ModalDialog.js.flow similarity index 100% rename from src/components/modal/ModalDialog.js rename to src/components/modal/ModalDialog.js.flow diff --git a/src/components/modal/ModalDialog.tsx b/src/components/modal/ModalDialog.tsx new file mode 100644 index 0000000000..2fb64e9d68 --- /dev/null +++ b/src/components/modal/ModalDialog.tsx @@ -0,0 +1,187 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import omit from 'lodash/omit'; +import uniqueId from 'lodash/uniqueId'; +import { defineMessages, injectIntl } from 'react-intl'; +import type { IntlShape } from 'react-intl'; + +import IconBack from '../../icon/fill/Arrow16'; +import IconClose from '../../icon/fill/X16'; + +const ALERT_TYPE = 'alert' as const; +const DIALOG_TYPE = 'dialog' as const; + +const messages = defineMessages({ + backModalText: { + defaultMessage: 'Back', + description: 'Button to get back inside modal', + id: 'boxui.modalDialog.backModalText', + }, + closeModalText: { + defaultMessage: 'Close Modal', + description: 'Button to close modal', + id: 'boxui.modalDialog.closeModalText', + }, +}); + +export interface ModalDialogProps extends Omit, 'title'> { + /** Contents of the modal dialog */ + children: React.ReactNode; + /** Additional CSS class name for the modal dialog */ + className?: string; + /** Props applied to the close button */ + closeButtonProps: React.ButtonHTMLAttributes; + /** Internationalization object used to format accessible button labels */ + intl: IntlShape; + /** Ref callback for the modal dialog element */ + modalRef?: React.Ref; + /** Handler called when the back button is clicked */ + onRequestBack?: React.MouseEventHandler; + /** Handler called when the close button is clicked */ + onRequestClose?: React.MouseEventHandler; + /** Title displayed in the modal header */ + title?: React.ReactNode; + /** Dialog semantics used for accessibility */ + type?: 'alert' | 'dialog'; +} + +class ModalDialog extends React.Component { + static defaultProps = { + type: DIALOG_TYPE, + closeButtonProps: {}, + }; + + /** + * Handles clicking on the back button + * @param {SyntheticMouseEvent} event + * @return {void} + */ + onBackButtonClick = (event: React.MouseEvent) => { + const { onRequestBack } = this.props; + if (onRequestBack) { + onRequestBack(event); + } + }; + + /** + * Handles clicking on the close button + * @param {SyntheticMouseEvent} event + * @return {void} + */ + onCloseButtonClick = (event: React.MouseEvent) => { + const { onRequestClose } = this.props; + if (onRequestClose) { + onRequestClose(event); + } + }; + + modalID: string = uniqueId('modal'); + + /** + * Renders a button if onRequestBack is passed in + * @return {ReactElement|null} - Returns the button, or null if the button shouldn't be rendered + */ + renderBackButton() { + const { intl } = this.props; + const { formatMessage } = intl; + return ( + + ); + } + + /** + * Renders a button if onRequestClose is passed in + * @return {ReactElement|null} - Returns the button, or null if the button shouldn't be rendered + */ + renderCloseButton() { + const { closeButtonProps, intl } = this.props; + const { formatMessage } = intl; + + return ( + + ); + } + + renderContent() { + const { children, type } = this.props; + + if (type !== ALERT_TYPE) { + return
{children}
; + } + + const elements = React.Children.toArray(children); + if (elements.length !== 2) { + throw new Error('Alert modal must have exactly two children: A message and '); + } + + return ( +
+

{elements[0]}

+ {elements[1]} +
+ ); + } + + render() { + const { + className, + modalRef, + onRequestBack, + onRequestClose, + title, + type, + ...rest // Useful for resin tagging, and other misc tags such as a11y + } = this.props; + const isAlertType = type === ALERT_TYPE; + const divProps = omit(rest, [ + 'children', + 'closeButtonProps', + 'onRequestClose', + 'intl', + ]) as React.HTMLAttributes; + + divProps.role = isAlertType ? 'alertdialog' : 'dialog'; + divProps['aria-modal'] = true; + divProps['aria-labelledby'] = `${this.modalID}-label`; + if (isAlertType) { + divProps['aria-describedby'] = `${this.modalID}-desc`; + } + + return ( +
+
+
+ {onRequestBack && this.renderBackButton()} +

+ {title} +

+
+ {onRequestClose && this.renderCloseButton()} +
+ {this.renderContent()} +
+ ); + } +} + +export { ModalDialog as ModalDialogBase }; + +export type InjectedModalDialogProps = Omit & + Partial>; + +export default injectIntl(ModalDialog) as React.ComponentType; diff --git a/src/components/modal/__tests__/Modal.test.js b/src/components/modal/__tests__/Modal.test.tsx similarity index 95% rename from src/components/modal/__tests__/Modal.test.js rename to src/components/modal/__tests__/Modal.test.tsx index 85caaa6d45..d340b4d857 100644 --- a/src/components/modal/__tests__/Modal.test.js +++ b/src/components/modal/__tests__/Modal.test.tsx @@ -1,17 +1,15 @@ -/* eslint-disable react/button-has-type */ - import * as React from 'react'; -import { shallow, mount } from 'enzyme'; -import sinon from 'sinon'; +import { mount, ReactWrapper, shallow, ShallowWrapper } from 'enzyme'; +import sinon, { SinonFakeTimers, SinonSpy } from 'sinon'; import Modal from '../Modal'; const sandbox = sinon.sandbox.create(); describe('components/modal/Modal', () => { - let onRequestClose; - let clock; - let wrapper; + let onRequestClose: SinonSpy; + let clock: SinonFakeTimers; + let wrapper: ReactWrapper | ShallowWrapper; beforeEach(() => { onRequestClose = sinon.spy(); @@ -74,7 +72,7 @@ describe('components/modal/Modal', () => { const event = { key: 'Escape', stopPropagation: jest.fn(), - }; + } as const; wrapper.simulate('keyDown', event); sinon.assert.calledOnce(onRequestClose); @@ -88,8 +86,8 @@ describe('components/modal/Modal', () => { }); test('should pass styles in to children components when style prop is passed in', () => { - const backdrop = { backgroundColor: 'red' }; - const dialog = { color: 'red' }; + const backdrop = { backgroundColor: 'red' } as const; + const dialog = { color: 'red' } as const; wrapper.setProps({ style: { backdrop, dialog }, isOpen: true, diff --git a/src/components/modal/__tests__/ModalActions.test.js b/src/components/modal/__tests__/ModalActions.test.tsx similarity index 93% rename from src/components/modal/__tests__/ModalActions.test.js rename to src/components/modal/__tests__/ModalActions.test.tsx index 0d7bc6796b..d6affbfb66 100644 --- a/src/components/modal/__tests__/ModalActions.test.js +++ b/src/components/modal/__tests__/ModalActions.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { shallow } from 'enzyme'; import ModalActions from '../ModalActions'; diff --git a/src/components/modal/__tests__/ModalDialog.test.js b/src/components/modal/__tests__/ModalDialog.test.tsx similarity index 87% rename from src/components/modal/__tests__/ModalDialog.test.js rename to src/components/modal/__tests__/ModalDialog.test.tsx index 2d0e391d2e..8cc0840603 100644 --- a/src/components/modal/__tests__/ModalDialog.test.js +++ b/src/components/modal/__tests__/ModalDialog.test.tsx @@ -1,21 +1,19 @@ import * as React from 'react'; -import sinon from 'sinon'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { createIntl } from 'react-intl'; +import sinon, { SinonSpy } from 'sinon'; import { ModalDialogBase } from '../ModalDialog'; -const sandbox = sinon.sandbox.create(); - describe('components/modal/ModalDialog', () => { - let onRequestBack; - let onRequestClose; - let wrapper; - let instance; + let onRequestBack: SinonSpy; + let onRequestClose: SinonSpy; + let wrapper: ShallowWrapper; + let instance: InstanceType; const title = 'hello'; beforeEach(() => { - const intlShape = { - formatMessage: message => message.id, - }; + const intlShape = createIntl({ locale: 'en', messages: {} }); onRequestClose = sinon.spy(); onRequestBack = sinon.spy(); wrapper = shallow( @@ -28,11 +26,7 @@ describe('components/modal/ModalDialog', () => { children , ); - instance = wrapper.instance(); - }); - - afterEach(() => { - sandbox.verifyAndRestore(); + instance = wrapper.instance() as InstanceType; }); test('should set aria props on modal dialog when rendered', () => { diff --git a/src/components/modal/index.js b/src/components/modal/index.js.flow similarity index 100% rename from src/components/modal/index.js rename to src/components/modal/index.js.flow diff --git a/src/components/modal/index.ts b/src/components/modal/index.ts new file mode 100644 index 0000000000..ff8b68692a --- /dev/null +++ b/src/components/modal/index.ts @@ -0,0 +1,6 @@ +export { default as Modal } from './Modal'; +export type { ModalProps } from './Modal'; +export { default as ModalActions } from './ModalActions'; +export type { ModalActionsProps } from './ModalActions'; +export { default as ModalDialog } from './ModalDialog'; +export type { InjectedModalDialogProps, ModalDialogProps } from './ModalDialog'; diff --git a/src/components/modal/stories/Modal.stories.js b/src/components/modal/stories/Modal.stories.tsx similarity index 99% rename from src/components/modal/stories/Modal.stories.js rename to src/components/modal/stories/Modal.stories.tsx index 76966ceb9a..6742229dc2 100644 --- a/src/components/modal/stories/Modal.stories.js +++ b/src/components/modal/stories/Modal.stories.tsx @@ -1,4 +1,3 @@ -// @flow /* eslint-disable react-hooks/rules-of-hooks */ import * as React from 'react'; diff --git a/src/components/modal/stories/ModalActions.stories.js b/src/components/modal/stories/ModalActions.stories.tsx similarity index 98% rename from src/components/modal/stories/ModalActions.stories.js rename to src/components/modal/stories/ModalActions.stories.tsx index 5e012aaa07..51537f3c30 100644 --- a/src/components/modal/stories/ModalActions.stories.js +++ b/src/components/modal/stories/ModalActions.stories.tsx @@ -1,4 +1,3 @@ -// @flow import * as React from 'react'; import { IntlProvider } from 'react-intl'; diff --git a/src/components/modal/stories/ModalDialog.stories.js b/src/components/modal/stories/ModalDialog.stories.tsx similarity index 98% rename from src/components/modal/stories/ModalDialog.stories.js rename to src/components/modal/stories/ModalDialog.stories.tsx index 3b5616bab7..1d0a38e678 100644 --- a/src/components/modal/stories/ModalDialog.stories.js +++ b/src/components/modal/stories/ModalDialog.stories.tsx @@ -1,4 +1,3 @@ -// @flow import * as React from 'react'; import { IntlProvider } from 'react-intl';