-
Notifications
You must be signed in to change notification settings - Fork 350
refactor(modal): migrate Modal from Flow to TypeScript #4771
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
File renamed without changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<React.HTMLAttributes<HTMLDivElement>, '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<HTMLDivElement>; | ||
| /** Handler called when the modal requests to close */ | ||
| onRequestClose?: ( | ||
| event: | ||
| | React.KeyboardEvent<HTMLElement> | ||
| | React.MouseEvent<HTMLDivElement> | ||
| | React.MouseEvent<HTMLButtonElement>, | ||
| ) => 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<ModalProps> { | ||
| 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<HTMLElement>) => { | ||
| 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<HTMLDivElement>) => { | ||
| 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<HTMLElement>(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 ( | ||
| <WrapperComponent className={classNames('modal', className)} onKeyDown={this.onKeyDown} tabIndex={-1}> | ||
| {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} | ||
| <div className="modal-backdrop" onClick={this.onBackdropClick} style={style.backdrop} /> | ||
| <FocusTrap className="modal-dialog-container"> | ||
| {isLoading ? ( | ||
| <LoadingIndicator size={LoadingIndicatorSize.LARGE} /> | ||
| ) : ( | ||
| <ModalDialog | ||
| modalRef={modalEl => { | ||
| // 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} | ||
| /> | ||
| )} | ||
| </FocusTrap> | ||
| <style type="text/css">{bodyOverrideStyle}</style> | ||
| </WrapperComponent> | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export default Modal; |
File renamed without changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import * as React from 'react'; | ||
| import classNames from 'classnames'; | ||
|
|
||
| export interface ModalActionsProps extends React.HTMLAttributes<HTMLDivElement> { | ||
| /** 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) => ( | ||
| <div className={classNames('modal-actions', className)} {...rest} /> | ||
| ); | ||
|
|
||
| export default ModalActions; |
File renamed without changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<React.HTMLAttributes<HTMLDivElement>, '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<HTMLButtonElement>; | ||
| /** Internationalization object used to format accessible button labels */ | ||
| intl: IntlShape; | ||
| /** Ref callback for the modal dialog element */ | ||
| modalRef?: React.Ref<HTMLDivElement>; | ||
| /** Handler called when the back button is clicked */ | ||
| onRequestBack?: React.MouseEventHandler<HTMLButtonElement>; | ||
| /** Handler called when the close button is clicked */ | ||
| onRequestClose?: React.MouseEventHandler<HTMLButtonElement>; | ||
| /** Title displayed in the modal header */ | ||
| title?: React.ReactNode; | ||
| /** Dialog semantics used for accessibility */ | ||
| type?: 'alert' | 'dialog'; | ||
| } | ||
|
|
||
| class ModalDialog extends React.Component<ModalDialogProps> { | ||
| static defaultProps = { | ||
| type: DIALOG_TYPE, | ||
| closeButtonProps: {}, | ||
| }; | ||
|
|
||
| /** | ||
| * Handles clicking on the back button | ||
| * @param {SyntheticMouseEvent} event | ||
| * @return {void} | ||
| */ | ||
| onBackButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => { | ||
| const { onRequestBack } = this.props; | ||
| if (onRequestBack) { | ||
| onRequestBack(event); | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Handles clicking on the close button | ||
| * @param {SyntheticMouseEvent} event | ||
| * @return {void} | ||
| */ | ||
| onCloseButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => { | ||
| 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 ( | ||
| <button | ||
| aria-label={formatMessage(messages.backModalText)} | ||
| className="modal-back-button" | ||
| data-testid="modal-back-button" | ||
| onClick={this.onBackButtonClick} | ||
| type="button" | ||
| > | ||
| <IconBack height={18} width={18} /> | ||
| </button> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * 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 ( | ||
| <button | ||
| {...closeButtonProps} | ||
| aria-label={formatMessage(messages.closeModalText)} | ||
| className="modal-close-button" | ||
| onClick={this.onCloseButtonClick} | ||
| > | ||
| <IconClose height={18} width={18} /> | ||
|
bonchevskyi marked this conversation as resolved.
|
||
| </button> | ||
| ); | ||
| } | ||
|
|
||
| renderContent() { | ||
| const { children, type } = this.props; | ||
|
|
||
| if (type !== ALERT_TYPE) { | ||
| return <div className="modal-content">{children}</div>; | ||
| } | ||
|
|
||
| const elements = React.Children.toArray(children); | ||
| if (elements.length !== 2) { | ||
| throw new Error('Alert modal must have exactly two children: A message and <ModalActions>'); | ||
| } | ||
|
|
||
| return ( | ||
| <div className="modal-content"> | ||
| <p id={`${this.modalID}-desc`}>{elements[0]}</p> | ||
| {elements[1]} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| 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<HTMLDivElement>; | ||
|
|
||
| divProps.role = isAlertType ? 'alertdialog' : 'dialog'; | ||
| divProps['aria-modal'] = true; | ||
| divProps['aria-labelledby'] = `${this.modalID}-label`; | ||
| if (isAlertType) { | ||
| divProps['aria-describedby'] = `${this.modalID}-desc`; | ||
| } | ||
|
|
||
| return ( | ||
| <div ref={modalRef} className={classNames('modal-dialog', className)} {...divProps}> | ||
| <div className="modal-header-container"> | ||
| <div className="modal-header"> | ||
| {onRequestBack && this.renderBackButton()} | ||
| <h2 className="modal-title" id={`${this.modalID}-label`}> | ||
| {title} | ||
| </h2> | ||
| </div> | ||
| {onRequestClose && this.renderCloseButton()} | ||
| </div> | ||
| {this.renderContent()} | ||
| </div> | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export { ModalDialog as ModalDialogBase }; | ||
|
|
||
| export type InjectedModalDialogProps = Omit<ModalDialogProps, 'closeButtonProps' | 'intl'> & | ||
| Partial<Pick<ModalDialogProps, 'closeButtonProps'>>; | ||
|
|
||
| export default injectIntl(ModalDialog) as React.ComponentType<InjectedModalDialogProps>; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.