322 lines
11 KiB
JavaScript
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);
|
|
}
|
|
}
|