Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
187 changes: 187 additions & 0 deletions src/components/modal/Modal.tsx
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;
15 changes: 15 additions & 0 deletions src/components/modal/ModalActions.tsx
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;
187 changes: 187 additions & 0 deletions src/components/modal/ModalDialog.tsx
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}
>
Comment thread
bonchevskyi marked this conversation as resolved.
<IconClose height={18} width={18} />
Comment thread
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>;
Loading
Loading