lion/packages/overlays/src/LocalOverlayController.js
2019-07-23 10:22:52 +02:00

322 lines
10 KiB
JavaScript

import { render, html } from '@lion/core';
import { containFocus } from './utils/contain-focus.js';
import { keyCodes } from './utils/key-codes.js';
async function __preloadPopper() {
return import('popper.js/dist/esm/popper.min.js');
}
export class LocalOverlayController {
constructor(params = {}) {
this.__fakeExtendsEventTarget();
// TODO: Instead of in constructor, prefetch it or use a preloader-manager to load it during idle time
this.constructor.popperModule = __preloadPopper();
this.__mergePopperConfigs(params.popperConfig || {});
this.inheritsReferenceObjectWidth = params.inheritsReferenceObjectWidth;
this.hidesOnEsc = params.hidesOnEsc;
this.hidesOnOutsideClick = params.hidesOnOutsideClick;
this.trapsKeyboardFocus = params.trapsKeyboardFocus;
/**
* A wrapper to render into the invokerTemplate
*
* @property {HTMLElement}
*/
this.invoker = document.createElement('div');
this.invoker.style.display = 'inline-block';
this.invokerTemplate = params.invokerTemplate;
/**
* The actual invoker element we work with - it get's all the events and a11y
*
* @property {HTMLElement}
*/
this.invokerNode = this.invoker;
if (params.invokerNode) {
this.invokerNode = params.invokerNode;
this.invoker = this.invokerNode;
}
/**
* A wrapper the contentTemplate renders into
*
* @property {HTMLElement}
*/
this.content = document.createElement('div');
this.content.style.display = 'inline-block';
this.contentTemplate = params.contentTemplate;
this.contentNode = this.content;
if (params.contentNode) {
this.contentNode = params.contentNode;
this.content = this.contentNode;
}
this.contentId = `overlay-content-${Math.random()
.toString(36)
.substr(2, 10)}`;
this._contentData = {};
this.syncInvoker();
this._updateContent();
this._prevShown = false;
this._prevData = {};
this.__boundEscKeyHandler = this.__escKeyHandler.bind(this);
}
get isShown() {
return this.contentTemplate
? Boolean(this.content.children.length)
: Boolean(this.contentNode.style.display === 'inline-block');
}
/**
* Syncs shown state and data for content.
* @param {object} options
* @param {boolean} [options.isShown] whether the overlay should be shown
* @param {object} [options.data] overlay data to pass to the content template function
*/
sync({ isShown, data } = {}) {
this._createOrUpdateOverlay(isShown, data);
}
/**
* Syncs data for invoker.
* @param {object} options
* @param {object} [options.data] overlay data to pass to the invoker template function
*/
syncInvoker({ data } = {}) {
if (this.invokerTemplate) {
render(this.invokerTemplate(data), this.invoker);
this.invokerNode = this.invoker.firstElementChild;
}
this.invokerNode.setAttribute('aria-expanded', this.isShown);
this.invokerNode.setAttribute('aria-controls', this.contentId);
this.invokerNode.setAttribute('aria-describedby', this.contentId);
}
/**
* Shows the overlay.
*/
async show() {
this._createOrUpdateOverlay(true, this._prevData);
/**
* Popper is weird about properly positioning the popper element when it is recreated so
* we just recreate the popper instance to make it behave like it should.
* Probably related to this issue: https://github.com/FezVrasta/popper.js/issues/796
* calling just the .update() function on the popper instance sadly does not resolve this.
* This is however necessary for initial placement.
*/
await this.__createPopperInstance();
this._popper.update();
}
/**
* Hides the overlay.
*/
hide() {
this._createOrUpdateOverlay(false, this._prevData);
}
/**
* Toggles the overlay.
*/
toggle() {
// eslint-disable-next-line no-unused-expressions
this.isShown ? this.hide() : this.show();
}
// Popper does not export a nice method to update an existing instance with a new config. Therefore we recreate the instance.
// TODO: Send a merge request to Popper to abstract their logic in the constructor to an exposed method which takes in the user config.
async updatePopperConfig(config = {}) {
this.__mergePopperConfigs(config);
await this.__createPopperInstance();
if (this.isShown) {
this._popper.update();
}
}
_createOrUpdateOverlay(shown = this._prevShown, data = this._prevData) {
if (shown) {
this._contentData = { ...this._contentData, ...data };
// let lit-html manage the template and update the properties
if (this.contentTemplate) {
render(this.contentTemplate(this._contentData), this.content);
this.contentNode = this.content.firstElementChild;
}
this.contentNode.id = this.contentId;
this.contentNode.style.display = 'inline-block';
/* To display on top of elements with no z-index that are appear later in the DOM */
this.contentNode.style.zIndex = 1;
this.invokerNode.setAttribute('aria-expanded', true);
if (this.inheritsReferenceObjectWidth) {
const referenceObjectWidth = `${this.invokerNode.clientWidth}px`;
switch (this.inheritsReferenceObjectWidth) {
case 'max':
this.contentNode.style.maxWidth = referenceObjectWidth;
break;
case 'full':
this.contentNode.style.width = referenceObjectWidth;
break;
default:
this.contentNode.style.minWidth = referenceObjectWidth;
}
}
if (this.trapsKeyboardFocus) this._setupTrapsKeyboardFocus();
if (this.hidesOnOutsideClick) this._setupHidesOnOutsideClick();
if (this.hidesOnEsc) this._setupHidesOnEsc();
if (this._prevShown === false) {
this.dispatchEvent(new Event('show'));
}
} else {
this._updateContent();
this.invokerNode.setAttribute('aria-expanded', false);
if (this.hidesOnOutsideClick) this._teardownHidesOnOutsideClick();
if (this.hidesOnEsc) this._teardownHidesOnEsc();
if (this._prevShown === true) {
this.dispatchEvent(new Event('hide'));
}
}
this._prevShown = shown;
this._prevData = data;
}
/**
* Sets up focus containment on the given overlay. If there was focus containment set up
* previously, it is disconnected.
*/
_setupTrapsKeyboardFocus() {
if (this._containFocusHandler) {
this._containFocusHandler.disconnect();
this._containFocusHandler = undefined; // eslint-disable-line no-param-reassign
}
this._containFocusHandler = containFocus(this.contentNode);
}
_setupHidesOnEsc() {
this.contentNode.addEventListener('keyup', this.__boundEscKeyHandler);
}
_teardownHidesOnEsc() {
this.contentNode.removeEventListener('keyup', this.__boundEscKeyHandler);
}
_setupHidesOnOutsideClick() {
if (this.__preventCloseOutsideClick) {
return;
}
let wasClickInside = false;
// handle on capture phase and remember till the next task that there was an inside click
this.__preventCloseOutsideClick = () => {
wasClickInside = true;
setTimeout(() => {
wasClickInside = false;
});
};
// handle on capture phase and schedule the hide if needed
this.__onCaptureHtmlClick = () => {
setTimeout(() => {
if (!wasClickInside) {
this.hide();
}
});
};
this.contentNode.addEventListener('click', this.__preventCloseOutsideClick, true);
this.invokerNode.addEventListener('click', this.__preventCloseOutsideClick, true);
document.documentElement.addEventListener('click', this.__onCaptureHtmlClick, true);
}
_teardownHidesOnOutsideClick() {
this.contentNode.removeEventListener('click', this.__preventCloseOutsideClick, true);
this.invokerNode.removeEventListener('click', this.__preventCloseOutsideClick, true);
document.documentElement.removeEventListener('click', this.__onCaptureHtmlClick, true);
this.__preventCloseOutsideClick = null;
this.__onCaptureHtmlClick = null;
}
_updateContent() {
if (this.contentTemplate) {
render(html``, this.content);
} else {
this.contentNode.style.display = 'none';
}
}
__escKeyHandler(e) {
if (e.keyCode === keyCodes.escape) {
this.hide();
}
}
/**
* Merges the default config with the current config, and finally with the user supplied config
* @param {Object} config user supplied configuration
*/
__mergePopperConfigs(config = {}) {
const defaultConfig = {
placement: 'top',
positionFixed: false,
modifiers: {
keepTogether: {
enabled: false,
},
preventOverflow: {
enabled: true,
boundariesElement: 'viewport',
padding: 16, // viewport-margin for shifting/sliding
},
flip: {
boundariesElement: 'viewport',
padding: 16, // viewport-margin for flipping
},
offset: {
enabled: true,
offset: `0, 8px`, // horizontal and vertical margin (distance between popper and referenceElement)
},
arrow: {
enabled: false,
},
},
};
// Deep merging default config, previously configured user config, new user config
this.popperConfig = {
...defaultConfig,
...(this.popperConfig || {}),
...(config || {}),
modifiers: {
...defaultConfig.modifiers,
...((this.popperConfig && this.popperConfig.modifiers) || {}),
...((config && config.modifiers) || {}),
},
};
}
async __createPopperInstance() {
if (this._popper) {
this._popper.destroy();
this._popper = null;
}
const mod = await this.constructor.popperModule;
const Popper = mod.default;
this._popper = new Popper(this.invokerNode, this.contentNode, {
...this.popperConfig,
});
}
// TODO: this method has to be removed when EventTarget polyfill is available on IE11
__fakeExtendsEventTarget() {
const delegate = document.createDocumentFragment();
['addEventListener', 'dispatchEvent', 'removeEventListener'].forEach(funcName => {
this[funcName] = (...args) => delegate[funcName](...args);
});
}
}