@odopod/odo-dialog
Version:
Responsive window dialog component focused on a11y and extendibility
730 lines (634 loc) • 21.4 kB
JavaScript
/**
* @fileoverview UI Component for universal dialogs.
* Notes
* * The transition is on the main `element` so that `scale()` transforms do not
* cause the calculation of `scrollHeight` to be artificially increased.
* * The backdrop is a sibling to the dialog so that it does not cover the
* scrollbar of the dialog and so that it doesn't jitter in iOS.
*
* @author Glen Cheney <glen@odopod.com>
*/
import TinyEmitter from 'tiny-emitter';
import { pull, onTransitionEnd } from '@odopod/odo-helpers';
import ScrollFix from './scroll-fix';
const FOCUSABLE_ELEMENTS = [
'a[href]',
'area[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable]',
'[tabindex]:not([tabindex^="-"])',
].join(',');
class Dialog extends TinyEmitter {
/**
* Dialog that can contain static images, carousels, or videos
* @param {HTMLElement} element Main element.
* @param {object} [opts] Instance options.
* @constructor
*/
constructor(element, opts) {
super();
if (!(element instanceof HTMLElement)) {
throw new TypeError(`OdoDialog requires an element. Got: "${element}"`);
}
/**
* Base Element.
* @type {HTMLElement}
*/
this.element = element;
/**
* Options object.
* @type {object}
*/
this.options = Object.assign({}, Dialog.Defaults, opts);
/**
* Dialog Id.
* @type {string}
*/
this.id = element.getAttribute('id');
/**
* Dialog backdrop
* @type {HTMLElement}
* @protected
*/
this.backdrop = document.createElement('div');
this.backdrop.className = Dialog.Classes.BACKDROP;
/**
* Dialog content (role=document).
* @type {HTMLElement}
* @protected
*/
this.content = this.getByClass(Dialog.Classes.CONTENT);
/**
* Elements which, when clicked, close the dialog.
* @type {HTMLElement[]}
* @private
*/
this._closers = Array.from(this.element.querySelectorAll('[data-odo-dialog-close]'));
/**
* Window resize Id
* @type {string}
* @private
*/
this._resizeId = null;
/**
* ScrollFix id
* @type {?string}
* @private
*/
this._scrollFixId = null;
/**
* Whether the dialog is open.
* @type {boolean}
*/
this.isOpen = false;
/**
* Is the dialog currently animating.
* @type {boolean}
* @protected
*/
this.isAnimating = false;
/**
* Whether the body has a scrollbar.
* @type {?boolean}
* @private
*/
this._hasBodyScrollbar = null;
/**
* Padding on the body.
* @type {number}
* @private
*/
this._originalBodyPadding = -1;
/**
* Whether this is a fullscreen dialog. Fullscreen dialogs should not have
* paddingRight applied to them.
* @type {?boolean}
* @private
*/
this._isFullscreen = null;
this.z = Dialog.Z_BASE;
Dialog.Instances.push(this);
if (Dialog.Instances.length === 1) {
document.body.addEventListener('click', Dialog._handleTriggerClick);
}
// If this browser does not support auto margins for flexbox, add a class
// so that it can be centered differently.
this.element.classList.toggle(Dialog.Classes.NO_AUTO_MARGIN, !Dialog.SUPPORTS_AUTO_MARGINS);
this._bindContexts();
this.onResize();
this._addA11yAttributes();
this._ensureBodyChild();
}
/**
* Find descendent element by class.
* @param {string} name Name of the class to find.
* @return {Element|undefined} The element or undefined.
*/
getByClass(name) {
return this.element.getElementsByClassName(name)[0];
}
/**
* Bind `this` context to event handlers.
*/
_bindContexts() {
this.onKeyPress = this.onKeyPress.bind(this);
this.onClick = this.onClick.bind(this);
this.close = this.close.bind(this);
this.closeWithAnimation = this.close.bind(this, false);
// Bind undefined as the first parameter so that the event object will be
// the second parameter and the optional viewportHeight parameter will work.
this.onWindowResize = this.onResize.bind(this, undefined);
}
/**
* Add static accessibility attributes so that the implementor can leave them
* off or in case they forget.
*/
_addA11yAttributes() {
this.element.tabIndex = -1;
this.element.setAttribute('aria-hidden', true);
this.element.setAttribute('role', 'dialog');
this.content.setAttribute('role', 'document');
}
/**
* If the dialog element is not a direct descendent of the <body>, make it so.
*/
_ensureBodyChild() {
if (this.element.parentNode !== document.body) {
document.body.appendChild(this.element);
}
}
/**
* Determine the correct element to scroll fix and fix it.
*/
_applyScrollFix() {
// Allow the scrollable element to be something inside the dialog.
if (this.options.scrollableElement) {
const element = this.element.matches(this.options.scrollableElement) ?
this.element :
this.element.querySelector(this.options.scrollableElement);
this._scrollFixId = ScrollFix.add(element);
}
}
/**
* If the page already has a scrollbar, adding overflow: hidden will remove it,
* shifting the content to the right. To avoid this, there needs to be padding
* on the body that's the same width as the scrollbar, but only when the dialog
* will not have a scrollbar to take the page scrollbar's place.
* @return {number}
*/
_getScrollbarOffset() {
const hasDialogScrollbar = this.element.scrollHeight > document.documentElement.clientHeight;
return this._hasBodyScrollbar && !hasDialogScrollbar ? Dialog.SCROLLBAR_WIDTH : 0;
}
/**
* Click handler on the main element. When the dialog is dismissable and the
* user clicked outside the content (i.e. the backdrop), close it.
* @param {MouseEvent} evt Event object.
* @protected
*/
onClick(evt) {
if (this.options.dismissable && evt.target === this.element) {
this.close();
}
}
/**
* Keypress event handler
* @param {KeyboardEvent} evt Event object
* @protected
*/
onKeyPress(evt) {
// Only react to keys when this dialog is the top-most one.
if (this.z === Dialog.getTopLayer()) {
// If 'ESC' is pressed, close the dialog
if (this.options.dismissable && evt.which === Dialog.Keys.ESC) {
this.close();
}
// If the TAB key is being pressed, make sure the focus stays trapped within
// the dialog element.
if (evt.which === Dialog.Keys.TAB) {
Dialog._trapTabKey(this.element, evt);
}
}
}
/**
* The dialog has a height of 100vh, which, in mobile safari, is incorrect
* when the toolbars are visible, not allowing the user to scroll the full
* height of the content within it.
* The viewportHeight parameter is optional so that it can be read in the open()
* method with all the other DOM reads. This avoids read->write->read #perfmatters.
* @param {number} [viewportHeight=window.innerHeight] Height of the viewport.
* @protected
*/
onResize(viewportHeight = window.innerHeight) {
this.element.style.height = viewportHeight + 'px';
}
/**
* Checks to see if a dialog is already open or animating If not, opens dialog.
* @param {boolean} [sync=false] Whether to open with transitions or not.
*/
open(sync = false) {
if (this.isAnimating || this.isOpen) {
return;
}
const viewportHeight = window.innerHeight;
Dialog.focusedBeforeDialog = document.activeElement;
this._hasBodyScrollbar = document.body.clientWidth < window.innerWidth;
this._isFullscreen = this.element.classList.contains(Dialog.Classes.FULLSCREEN);
// Add aria-hidden to other top-level things.
const siblings = Dialog._getSiblings(this.element);
const originals = siblings.map(element => element.getAttribute('aria-hidden'));
siblings.forEach((element, i) => {
if (originals[i]) {
element.setAttribute('data-odo-dialog-original', originals[i]);
}
element.setAttribute('aria-hidden', true);
});
// If there is already an open dialog, increase the z-index of this dialog's
// main element and backdrop above the open one.
if (Dialog.getOpenDialogCount() > 0) {
this.handleOtherOpenDialogs();
}
this.isOpen = true;
this.onResize(viewportHeight);
this.element.removeAttribute('aria-hidden');
this.element.classList.add(Dialog.Classes.OPEN);
this.element.classList.add(Dialog.Classes.ENTER);
if (Dialog.SCROLLBAR_WIDTH) {
document.body.style.paddingRight = Dialog.SCROLLBAR_WIDTH + 'px';
}
document.body.classList.add(Dialog.Classes.BODY_OPEN);
document.body.insertBefore(this.backdrop, this.element.nextSibling);
this.element.scrollTop = 0;
this._applyScrollFix();
this.element.focus();
document.addEventListener('keydown', this.onKeyPress);
window.addEventListener('resize', this.onWindowResize);
this.element.addEventListener('click', this.onClick);
this._closers.forEach((element) => {
element.addEventListener('click', this.closeWithAnimation);
});
if (sync === true) {
this._openNext();
this._opened();
} else {
Dialog._nextFrame(() => {
this._openNext();
onTransitionEnd(this.element, this._opened, this, null, 1000);
});
}
}
/**
* Start the transition for opening the dialog.
*/
_openNext() {
this.isAnimating = true;
// Now that the dialog is no longer display:none, the scrollHeight can be measured.
const scrollbarOffset = this._getScrollbarOffset();
if (!this._isFullscreen && scrollbarOffset > 0) {
this.element.style.paddingRight = scrollbarOffset + 'px';
}
this.element.classList.remove(Dialog.Classes.ENTER);
this.element.classList.add(Dialog.Classes.ENTERING);
}
/**
* Handle the end of the open transition. Emits OPENED event.
*/
_opened() {
this.element.classList.remove(Dialog.Classes.ENTERING);
this.element.classList.add(Dialog.Classes.VISIBLE);
this.isAnimating = false;
this.emit(Dialog.EventType.OPENED);
}
/**
* Hides dialog
* @param {boolean} [sync=false] Whether to close with transitions or not.
*/
close(sync = false) {
if (this.isAnimating || !this.isOpen) {
return;
}
// Remove aria-hidden to other top-level things.
const siblings = Dialog._getSiblings(this.element);
const originals = siblings.map(element => element.getAttribute('data-odo-dialog-original'));
siblings.forEach((element, i) => {
if (originals[i]) {
element.setAttribute('aria-hidden', originals[i]);
element.removeAttribute('data-odo-dialog-original');
} else {
element.removeAttribute('aria-hidden');
}
});
this.isOpen = false;
this.element.classList.add(Dialog.Classes.LEAVE);
this.element.classList.remove(Dialog.Classes.VISIBLE);
ScrollFix.remove(this._scrollFixId);
// Support: IE11
// Clicking on an SVG element inside an <a> will set the `focusedBeforeDialog`
// to the SVG, but SVG doesn't have a `focus()` method in IE.
if (Dialog.focusedBeforeDialog && typeof Dialog.focusedBeforeDialog.focus === 'function') {
Dialog.focusedBeforeDialog.focus();
}
document.removeEventListener('keydown', this.onKeyPress);
window.removeEventListener('resize', this.onWindowResize);
this.element.removeEventListener('click', this.onClick);
this._closers.forEach((element) => {
element.removeEventListener('click', this.closeWithAnimation);
});
if (sync === true) {
this._closeNext();
this._closed();
} else {
Dialog._nextFrame(() => {
this._closeNext();
onTransitionEnd(this.element, this._closed, this, null, 1000);
});
}
}
/**
* Start the transition for closing the dialog.
*/
_closeNext() {
this.isAnimating = true;
this.element.classList.remove(Dialog.Classes.LEAVE);
this.element.classList.add(Dialog.Classes.LEAVING);
}
/**
* Handle the end of the close transition. Emits the CLOSED event.
*/
_closed() {
this.isAnimating = false;
this.element.style.paddingRight = '';
this.element.setAttribute('aria-hidden', true);
this.element.classList.remove(Dialog.Classes.OPEN);
this.element.classList.remove(Dialog.Classes.LEAVING);
if (Dialog.getOpenDialogCount() === 0) {
document.body.style.paddingRight = '';
document.body.classList.remove(Dialog.Classes.BODY_OPEN);
}
document.body.removeChild(this.backdrop);
this.emit(Dialog.EventType.CLOSED);
}
/**
* Modify dialog z-indices and more because there are about to be multiple
* dialogs open at the same time.
* @protected
*/
handleOtherOpenDialogs() {
this.z = Dialog.getTopLayer() + 20;
this.element.style.zIndex = this.z;
this.backdrop.style.zIndex = this.z - 5;
// When this dialog is closed, revert the z-index back to its original value.
this.once(Dialog.EventType.CLOSED, () => {
this.z = Dialog.Z_BASE;
this.element.style.zIndex = '';
this.backdrop.style.zIndex = '';
// Find new top dialog.
const zTop = Dialog.getTopLayer();
Dialog.Instances.forEach((instance) => {
if (instance.isOpen && instance.z === zTop) {
instance.didEnterForeground();
}
});
});
// Tell other dialogs they're going into the background.
Dialog.Instances.forEach((instance) => {
if (instance.isOpen && instance.id !== this.id) {
instance.didEnterBackground();
}
});
}
/**
* Dialog went into the background and has another dialog open above it.
* @protected
*/
didEnterBackground() {
ScrollFix.remove(this._scrollFixId);
}
/**
* Dialog came back into the foreground after being in the background.
* @protected
*/
didEnterForeground() {
this._applyScrollFix();
}
/**
* Close the dialog, remove event listeners and element references.
*/
dispose() {
if (this.isOpen) {
this.close(true);
}
this.element = null;
this.content = null;
this.backdrop = null;
this._closers.length = 0;
pull(Dialog.Instances, this);
// If this is the last dialog (being disposed), remove the body listener.
if (Dialog.Instances.length === 0) {
document.body.removeEventListener('click', Dialog._handleTriggerClick);
}
}
/**
* Call a function after two animation frames. Using just one is unreliable
* when using animations to/from display:none elements or ones that are not
* yet in the DOM.
* @param {function} fn Function to call on the next frame.
*/
static _nextFrame(fn) {
window.requestAnimationFrame(window.requestAnimationFrame.bind(null, fn));
}
/**
* Open the correct dialog when an element with `data-odo-dialog-open` attribute
* is clicked.
* @param {Event} evt Event object.
*/
static _handleTriggerClick(evt) {
const trigger = evt.target.closest('[data-odo-dialog-open]');
if (trigger !== null) {
evt.preventDefault();
const id = trigger.getAttribute('data-odo-dialog-open');
const instance = Dialog.getDialogById(id);
instance.emit(Dialog.EventType.TRIGGER_CLICKED, trigger);
instance.open();
}
}
/**
* Trap the focus inside the given element.
* @param {Element} node
* @param {KeyboardEvent} evt
*/
static _trapTabKey(node, evt) {
const focusableChildren = Dialog._getFocusableChildren(node);
const focusedItemIndex = focusableChildren.indexOf(document.activeElement);
// If the SHIFT key is being pressed while tabbing (moving backwards) and
// the currently focused item is the first one, move the focus to the last
// focusable item from the dialog element
if (evt.shiftKey && focusedItemIndex === 0) {
focusableChildren[focusableChildren.length - 1].focus();
evt.preventDefault();
// If the SHIFT key is not being pressed (moving forwards) and the currently
// focused item is the last one, move the focus to the first focusable item
// from the dialog element
} else if (!evt.shiftKey && focusedItemIndex === focusableChildren.length - 1) {
focusableChildren[0].focus();
evt.preventDefault();
}
}
/**
* Get the focusable children of the given element.
* @param {Element} element
* @return {Element[]}
*/
static _getFocusableChildren(element) {
return Array.from(element.querySelectorAll(FOCUSABLE_ELEMENTS))
.filter(Dialog._isVisibleElement);
}
/**
* Whether an element is visible (and therefore can receive focus). Uses
* `getClientRects` due to this issue:
* https://github.com/jquery/jquery/issues/2227
* http://jsfiddle.net/2tgw2yr3/
* @param {HTMLElement} el Element.
* @return {boolean}
*/
static _isVisibleElement(el) {
return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
}
/**
* Retrieve the siblings of an element.
* @param {Element} element Element to get siblings for.
* @return {Array.<Element>}
*/
static _getSiblings(element) {
const children = Array.from(element.parentElement.children);
const ignore = ['script', 'link', 'meta'];
return children.filter(node =>
node !== element && !ignore.includes(node.nodeName.toLowerCase()));
}
/**
* Calculate the width of the scrollbar because when the body has overflow:hidden,
* the scrollbar disappears.
* https://davidwalsh.name/detect-scrollbar-width
* @return {number}
*/
static _getScrollbarWidth() {
// Create measurement node.
const scrollDiv = document.createElement('div');
scrollDiv.style.cssText = 'width:50px;height:50px;overflow:scroll;position:absolute;top:-9999px;';
document.body.appendChild(scrollDiv);
// Calculate the scrollbar width.
const scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth;
// Remove test element.
document.body.removeChild(scrollDiv);
return scrollbarWidth;
}
/**
* Unfortunately, the auto margins do not work for flex children in IE11 and
* below because the content element does have an explicit height set on it.
* @return {boolean}
*/
static _autoMarginTest() {
const parent = document.createElement('div');
const child = document.createElement('div');
parent.style.cssText = 'display:flex;height:50px;width:50px;position:absolute;';
child.style.cssText = 'margin:auto;';
child.innerHTML = 'a';
parent.appendChild(child);
document.body.appendChild(parent);
const ret = child.offsetTop > 0;
document.body.removeChild(parent);
return ret;
}
/**
* Instantiates all instances of dialogs with the same settings
* @param {object} options Object of all dialog options. Is optional.
* @return {Dialog[]}
*/
static initializeAll(options) {
Dialog.disposeAll();
return Array.from(
document.querySelectorAll('.' + Dialog.Classes.BASE),
dialog => new Dialog(dialog, options),
);
}
/**
* Clear all references to dialogs so there are no duplicates.
*/
static disposeAll() {
const clone = Dialog.Instances.slice();
clone.forEach((dialog) => {
dialog.dispose();
});
}
/**
* Retrieve a dialog instance by its id.
* @param {string} id Id of the dialog.
* @return {?Dialog} The dialog or undefined if there is no dialog with the given id.
*/
static getDialogById(id) {
return Dialog.Instances.find(instance => instance.id === id);
}
/**
* Count how many dialogs are currently open.
* @return {number}
*/
static getOpenDialogCount() {
return Dialog.Instances.filter(instance => instance.isOpen).length;
}
/**
* Find the z index of the top-most dialog instance.
* @return {number}
*/
static getTopLayer() {
// eslint-disable-next-line prefer-spread
return Math.max.apply(Math, Dialog.Instances.map(instance => instance.z));
}
}
/** @enum {string} */
Dialog.Classes = {
BODY_OPEN: 'odo-dialog-open',
BASE: 'odo-dialog',
OPEN: 'odo-dialog--open',
ENTER: 'odo-dialog--enter',
ENTERING: 'odo-dialog--enter-active',
LEAVE: 'odo-dialog--leave',
LEAVING: 'odo-dialog--leave-active',
VISIBLE: 'odo-dialog--visible',
FULLSCREEN: 'odo-dialog--full',
NO_AUTO_MARGIN: 'odo-dialog--no-auto-margin',
BACKDROP: 'odo-dialog-backdrop',
CONTENT: 'odo-dialog__content',
};
/** @enum {string} */
Dialog.EventType = {
OPENED: 'ododialog:open',
CLOSED: 'ododialog:closed',
TRIGGER_CLICKED: 'ododialog:triggerclicked',
};
/** @enum {number} */
Dialog.Keys = {
ESC: 27,
TAB: 9,
};
Dialog.Defaults = {
dismissable: true,
scrollableElement: '.odo-dialog',
};
/** @type {Dialog[]} */
Dialog.Instances = [];
Dialog.Z_BASE = 1050;
Dialog.ScrollFix = ScrollFix;
/**
* Element which had focus before the dialog opened.
* @type {Element}
*/
Dialog.focusedBeforeDialog = null;
Dialog.SUPPORTS_AUTO_MARGINS = Dialog._autoMarginTest();
Dialog.SCROLLBAR_WIDTH = Dialog._getScrollbarWidth();
export default Dialog;