lion/packages/overlays/src/GlobalOverlayController.js
2019-07-23 16:40:19 +02:00

322 lines
11 KiB
JavaScript

import { render } from '@lion/core';
import { containFocus } from './utils/contain-focus.js';
import { globalOverlaysStyle } from './globalOverlaysStyle.js';
import { setSiblingsInert, unsetSiblingsInert } from './utils/inert-siblings.js';
const isIOS = navigator.userAgent.match(/iPhone|iPad|iPod/i);
const styleTag = document.createElement('style');
styleTag.textContent = globalOverlaysStyle.cssText;
export class GlobalOverlayController {
static _createRoot() {
if (!this._rootNode) {
this._rootNode = document.createElement('div');
this._rootNode.classList.add('global-overlays');
document.body.appendChild(this._rootNode);
document.head.appendChild(styleTag);
}
}
constructor(params) {
const finalParams = {
elementToFocusAfterHide: document.body,
hasBackdrop: false,
isBlocking: false,
preventsScroll: false,
trapsKeyboardFocus: false,
hidesOnEsc: false,
...params,
};
this.elementToFocusAfterHide = finalParams.elementToFocusAfterHide;
this.contentTemplate = finalParams.contentTemplate;
this.hasBackdrop = finalParams.hasBackdrop;
this.isBlocking = finalParams.isBlocking;
this.preventsScroll = finalParams.preventsScroll;
this.trapsKeyboardFocus = finalParams.trapsKeyboardFocus;
this.hidesOnEsc = finalParams.hidesOnEsc;
this._isShown = false;
this._data = {};
this._container = null;
}
get isShown() {
return this._isShown;
}
/**
* Syncs shown state and data.
* @param {object} options optioons to sync
* @param {boolean} [options.isShown] whether the overlay should be shown
* @param {object} [options.data] data to pass to the content template function
* @param {HTMLElement} [options.elementToFocusAfterHide] element to return focus when hiding
*/
sync(options) {
if (options.elementToFocusAfterHide) {
this.elementToFocusAfterHide = options.elementToFocusAfterHide;
}
this._createOrUpdateOverlay(
typeof options.isShown !== 'undefined' ? options.isShown : this._isShown,
typeof options.data !== 'undefined' ? options.data : this._data,
);
}
/**
* Shows the overlay.
* @param {HTMLElement} [elementToFocusAfterHide] element to return focus when hiding
*/
show(elementToFocusAfterHide) {
if (elementToFocusAfterHide) {
this.elementToFocusAfterHide = elementToFocusAfterHide;
}
this._createOrUpdateOverlay(true, this._data);
}
/**
* Hides the overlay.
*/
hide() {
this._createOrUpdateOverlay(false, this._data);
}
/**
* Updates an overlay's template. Creates a render container and appends it to the
* overlay manager if it wasn't rendered before. Otherwise updates the data.
*
* Removes the overlay from the DOM if it should be hidden.
*
* @param {boolean} isShown whether the overlay should be shown
* @param {object} data data to render
*/
_createOrUpdateOverlay(isShown, data) {
if (isShown) {
let firstShow = false;
if (!this.trapsKeyboardFocus && GlobalOverlayController._rootNode) {
const siblings = Array.from(GlobalOverlayController._rootNode.children).reverse();
const last = this._findWithFlag(siblings, 'trapsKeyboardFocus');
if (last && last.length > 0) {
last[0]._containFocusHandler.disconnect();
}
}
if (!this._container) {
firstShow = true;
GlobalOverlayController._createRoot();
this._initializeContainer();
}
// let lit-html manage the template and update the properties
render(this.contentTemplate(data), this._container);
if (firstShow) {
this._setupFlags();
}
} else if (this._container) {
GlobalOverlayController._rootNode.removeChild(this._container);
this._cleanupFlags();
this._container = null;
if (this.elementToFocusAfterHide) {
this.elementToFocusAfterHide.focus();
}
}
this._isShown = isShown;
this._data = data;
}
_initializeContainer() {
const container = document.createElement('div');
container.classList.add(`global-overlays__overlay${this.isBlocking ? '--blocking' : ''}`);
this._container = container;
container._overlayController = this;
GlobalOverlayController._rootNode.appendChild(container);
}
/**
* Sets up flags.
*/
_setupFlags() {
if (this.preventsScroll) {
document.body.classList.add('global-overlays-scroll-lock');
if (isIOS) {
// iOS has issues with overlays with input fields. This is fixed by applying
// position: fixed to the body. As a side effect, this will scroll the body to the top.
document.body.classList.add('global-overlays-scroll-lock-ios-fix');
}
}
if (this.hasBackdrop) {
this._setupHasBackdrop();
}
const siblings = Array.from(GlobalOverlayController._rootNode.children).reverse();
if (this.trapsKeyboardFocus) {
const last = this._findWithFlag(siblings, 'trapsKeyboardFocus');
if (last.length > 1) {
last[1]._containFocusHandler.disconnect();
}
this._setupTrapsKeyboardFocus();
}
if (this.isBlocking) {
GlobalOverlayController._rootNode.classList.add('global-overlays--blocking-opened');
}
if (this.hidesOnEsc) {
this._setupHidesOnEsc();
}
this._container.firstElementChild.addEventListener('dialog-close', () => this.hide());
}
/**
* Sets up backdrop on the given overlay. If there was a backdrop on another element
* it is removed. Otherwise this is the first time displaying a backdrop, so a fade-in
* animation is played.
* @param {OverlayController} overlay the overlay
* @param {boolean} noAnimation prevent an animatin from being displayed
*/
_setupHasBackdrop(overlay = this, noAnimation) {
const prevWithBackdrop = GlobalOverlayController._rootNode.querySelector(
'.global-overlays__backdrop',
);
if (prevWithBackdrop) {
prevWithBackdrop.classList.remove('global-overlays__backdrop');
prevWithBackdrop.classList.remove('global-overlays__backdrop--fade-in');
} else if (!noAnimation) {
overlay._container.classList.add('global-overlays__backdrop--fade-in');
}
overlay._container.classList.add('global-overlays__backdrop');
}
/**
* Sets up focus containment on the given overlay. If there was focus containment set up
* previously, it is disconnected. Otherwise this is the first time containing focus, so
* the overlay manager's siblings are set inert for accessibility.
* @param {OverlayController} overlay the overlay
*/
_setupTrapsKeyboardFocus(overlay = this) {
if (overlay._containFocusHandler) {
overlay._containFocusHandler.disconnect();
overlay._containFocusHandler = undefined; // eslint-disable-line no-param-reassign
} else {
// TODO: this shouldmonly be done when modal option is true?
setSiblingsInert(GlobalOverlayController._rootNode);
}
// eslint-disable-next-line no-param-reassign
overlay._containFocusHandler = containFocus(overlay._container.firstElementChild);
}
_setupHidesOnEsc(overlay = this) {
// TODO: add check if we have focus first? Since, theoratically we can have many overlays
// opened and we probably don't want to close them all
overlay._container.addEventListener('keyup', event => {
if (event.keyCode === 27) {
// Escape
overlay.hide();
}
});
}
/**
* Cleans up flags.
*/
_cleanupFlags() {
if (this.preventsScroll) {
document.body.classList.remove('global-overlays-scroll-lock');
if (isIOS) {
document.body.classList.remove('global-overlays-scroll-lock-ios-fix');
}
}
// iterate siblings in reverse order, as that is the order of importance
const siblings = Array.from(GlobalOverlayController._rootNode.children).reverse();
const overlays = this._findOverlays(siblings);
const nextTrapsKeyboardFocus = this._findNextWithFlag(siblings, 'trapsKeyboardFocus');
const nextHasTrapsKeyboardFocus = nextTrapsKeyboardFocus === overlays[0];
if (this.hasBackdrop) {
const next = this._findNextWithFlag(siblings, 'hasBackdrop');
// if there is another overlay which requires a backdrop, move it there
// otherwise, play a fade-out animation
if (next) {
this._setupHasBackdrop(next, true);
} else {
this._fadeOutBackdrop();
}
}
if (this.trapsKeyboardFocus || nextHasTrapsKeyboardFocus) {
// if there is another overlay which requires contain focus, set it up
// otherwise disconnect and removed inert from siblings
if (nextTrapsKeyboardFocus && nextHasTrapsKeyboardFocus) {
if (this._containFocusHandler) {
this._containFocusHandler.disconnect();
}
this._setupTrapsKeyboardFocus(nextTrapsKeyboardFocus);
} else {
if (this._containFocusHandler) {
this._containFocusHandler.disconnect();
this._containFocusHandler = undefined;
}
unsetSiblingsInert(GlobalOverlayController._rootNode);
}
}
if (this.isBlocking) {
const next = this._findNextWithFlag(siblings, 'isBlocking');
// if there are no other blocking overlays remaning, stop hiding regular overlays
if (!next) {
GlobalOverlayController._rootNode.classList.remove('global-overlays--blocking-opened');
}
}
}
/**
* Finds all overlays.
* @param {HTMLElement[]} containers
* @returns {[OverlayController] | []}
*/
// eslint-disable-next-line class-methods-use-this
_findOverlays(containers) {
return containers.map(container => container._overlayController);
}
/**
* Finds the overlay which has the given option enabled.
* @param {HTMLElement[]} containers
* @param {string} option
* @returns {[OverlayController] | []}
*/
_findWithFlag(containers, option) {
return this._findOverlays(containers).filter(container => container[option]);
}
/**
* Finds the next overlay which has the given option enabled.
* @param {HTMLElement[]} containers
* @param {string} option
* @returns {OverlayController | null}
*/
_findNextWithFlag(containers, option) {
return this._findOverlays(containers).find(controller => controller[option]);
}
/**
* Plays a backdrop fade out animation. This is applied to the overlay container as the
* overlay which had a backdrop is already removed at this point.
*/
// eslint-disable-next-line class-methods-use-this
_fadeOutBackdrop() {
GlobalOverlayController._rootNode.classList.add('global-overlays--backdrop-fade-out');
// a new overlay could be opened within 600ms, but it is only an animation
setTimeout(() => {
GlobalOverlayController._rootNode.classList.remove('global-overlays--backdrop-fade-out');
}, 600);
}
}