lion/packages/overlays/src/OverlayController.js
2019-12-01 16:03:04 +01:00

649 lines
20 KiB
JavaScript

import '@lion/core/src/differentKeyEventNamesShimIE.js';
import './utils/typedef.js';
import { overlays } from './overlays.js';
import { containFocus } from './utils/contain-focus.js';
async function preloadPopper() {
return import('popper.js/dist/esm/popper.min.js');
}
const GLOBAL_OVERLAYS_CONTAINER_CLASS = 'global-overlays__overlay-container';
const GLOBAL_OVERLAYS_CLASS = 'global-overlays__overlay';
export class OverlayController {
/**
* @constructor
* @param {OverlayConfig} config initial config. Will be remembered as shared config
* when `.updateConfig()` is called.
*/
constructor(config = {}, manager = overlays) {
this.__fakeExtendsEventTarget();
this.manager = manager;
this.__sharedConfig = config;
this._defaultConfig = {
placementMode: null,
contentNode: config.contentNode,
invokerNode: config.invokerNode,
referenceNode: null,
elementToFocusAfterHide: config.invokerNode,
inheritsReferenceWidth: '',
hasBackdrop: false,
isBlocking: false,
preventsScroll: false,
trapsKeyboardFocus: false,
hidesOnEsc: false,
hidesOnOutsideClick: false,
hidesOnHideEventInContentNode: true,
isTooltip: false,
handlesUserInteraction: false,
handlesAccessibility: false,
popperConfig: null,
viewportConfig: {
placement: 'center',
},
};
this.manager.add(this);
this._contentNodeWrapper = document.createElement('div');
this._contentId = `overlay-content--${Math.random()
.toString(36)
.substr(2, 10)}`;
this.updateConfig(config);
this.__hasActiveTrapsKeyboardFocus = false;
this.__hasActiveBackdrop = true;
}
get invoker() {
return this.invokerNode;
}
get content() {
return this._contentNodeWrapper;
}
/**
* @desc The element ._contentNodeWrapper will be appended to.
* If viewportConfig is configured, this will be OverlayManager.globalRootNode
* If popperConfig is configured, this will be a sibling node of invokerNode
*/
get _renderTarget() {
if (this.placementMode === 'global') {
return this.manager.globalRootNode;
}
return this.__originalContentParent;
}
/**
* @desc The element our local overlay will be positioned relative to.
*/
get _referenceNode() {
return this.referenceNode || this.invokerNode;
}
set elevation(value) {
if (this._contentNodeWrapper) {
this._contentNodeWrapper.style.zIndex = value;
}
if (this.backdropNode) {
this.backdropNode.style.zIndex = value;
}
}
get elevation() {
return this._contentNodeWrapper.zIndex;
}
/**
* @desc Allows to dynamically change the overlay configuration. Needed in case the
* presentation of the overlay changes depending on screen size.
* Note that this method is the only allowed way to update a configuration of an
* OverlayController instance.
* @param {OverlayConfig} cfgToAdd
*/
updateConfig(cfgToAdd) {
// Teardown all previous configs
this._handleFeatures({ phase: 'teardown' });
if (cfgToAdd.contentNode && cfgToAdd.contentNode.isConnected) {
// We need to keep track of the original local context.
this.__originalContentParent = cfgToAdd.contentNode.parentElement;
}
this.__prevConfig = this.config || {};
this.config = {
...this._defaultConfig, // our basic ingredients
...this.__sharedConfig, // the initial configured overlayController
...cfgToAdd, // the added config
};
this.__validateConfiguration(this.config);
Object.assign(this, this.config);
this._init({ cfgToAdd });
}
// eslint-disable-next-line class-methods-use-this
__validateConfiguration(newConfig) {
if (!newConfig.placementMode) {
throw new Error('You need to provide a .placementMode ("global"|"local")');
}
if (!['global', 'local'].includes(newConfig.placementMode)) {
throw new Error(
`"${newConfig.placementMode}" is not a valid .placementMode, use ("global"|"local")`,
);
}
if (!newConfig.contentNode) {
throw new Error('You need to provide a .contentNode');
}
}
async _init({ cfgToAdd }) {
this.__initContentNodeWrapper();
this.__initConnectionTarget();
if (this.handlesAccessibility) {
this.__initAccessibility({ cfgToAdd });
}
if (this.placementMode === 'local') {
// Now, it's time to lazily load Popper if not done yet
// Do we really want to add display: inline or is this up to user?
if (!this.constructor.popperModule) {
// TODO: Instead, prefetch it or use a preloader-manager to load it during idle time
this.constructor.popperModule = preloadPopper();
}
this.__mergePopperConfigs(cfgToAdd.popperConfig || {});
}
this._handleFeatures({ phase: 'init' });
}
__initConnectionTarget() {
// Now, add our node to the right place in dom (rendeTarget)
if (this.contentNode !== this.__prevConfig.contentNode) {
this._contentNodeWrapper.appendChild(this.contentNode);
}
if (this._renderTarget && this._renderTarget !== this._contentNodeWrapper.parentNode) {
this._renderTarget.appendChild(this._contentNodeWrapper);
}
}
// FIXME: Consider that state can also be shown (rather than only initial/closed), and don't hide in that case
/**
* @desc Cleanup ._contentNodeWrapper. We do this, because creating a fresh wrapper
* can lead to problems with event listeners...
*/
__initContentNodeWrapper() {
Array.from(this._contentNodeWrapper.attributes).forEach(attrObj => {
this._contentNodeWrapper.removeAttribute(attrObj.name);
});
this._contentNodeWrapper.style.cssText = null;
this._contentNodeWrapper.style.display = 'none';
// Make sure that your shadow dom contains this outlet, when we are adding to light dom
this._contentNodeWrapper.slot = '_overlay-shadow-outlet';
if (getComputedStyle(this.contentNode).position === 'absolute') {
// Having a _contWrapperNode and a contentNode with 'position:absolute' results in
// computed height of 0...
this.contentNode.style.position = 'static';
}
}
/**
* @desc Display local overlays on top of elements with no z-index that appear later in the DOM
*/
_handleZIndex({ phase }) {
if (this.placementMode !== 'local') {
return;
}
if (phase === 'setup') {
const zIndexNumber = Number(getComputedStyle(this.contentNode).zIndex);
if (zIndexNumber < 1 || Number.isNaN(zIndexNumber)) {
this._contentNodeWrapper.style.zIndex = 1;
}
}
}
__initAccessibility() {
// TODO: add setup props in object and restore on teardown
if (!this.contentNode.id) {
this.contentNode.setAttribute('id', this._contentId);
}
if (this.isTooltip) {
// TODO: this could also be labelledby
if (this.invokerNode) {
this.invokerNode.setAttribute('aria-describedby', this._contentId);
}
this.contentNode.setAttribute('role', 'tooltip');
} else {
if (this.invokerNode) {
this.invokerNode.setAttribute('aria-expanded', this.isShown);
}
if (!this.contentNode.hasAttribute('role')) {
this.contentNode.setAttribute('role', 'dialog');
}
}
}
get isShown() {
return Boolean(this._contentNodeWrapper.style.display !== 'none');
}
/**
* @event before-show right before the overlay shows. Used for animations and switching overlays
* @event show right after the overlay is shown
* @param {HTMLElement} elementToFocusAfterHide
*/
async show(elementToFocusAfterHide = this.elementToFocusAfterHide) {
if (this.manager) {
this.manager.show(this);
}
if (this.isShown) {
return;
}
this.dispatchEvent(new Event('before-show'));
this._contentNodeWrapper.style.display = this.placementMode === 'local' ? 'inline-block' : '';
await this._handleFeatures({ phase: 'show' });
await this._handlePosition({ phase: 'show' });
this.elementToFocusAfterHide = elementToFocusAfterHide;
this.dispatchEvent(new Event('show'));
}
async _handlePosition({ phase }) {
if (this.placementMode === 'global') {
const addOrRemove = phase === 'show' ? 'add' : 'remove';
const placementClass = `${GLOBAL_OVERLAYS_CONTAINER_CLASS}--${this.viewportConfig.placement}`;
this._contentNodeWrapper.classList[addOrRemove](GLOBAL_OVERLAYS_CONTAINER_CLASS);
this._contentNodeWrapper.classList[addOrRemove](placementClass);
this.contentNode.classList[addOrRemove](GLOBAL_OVERLAYS_CLASS);
} else if (this.placementMode === 'local' && phase === 'show') {
/**
* 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();
}
}
/**
* @event before-hide right before the overlay hides. Used for animations and switching overlays
* @event hide right after the overlay is hidden
*/
async hide() {
if (this.manager) {
this.manager.hide(this);
}
if (!this.isShown) {
return;
}
this.dispatchEvent(new Event('before-hide'));
// await this.transitionHide({ backdropNode: this.backdropNode, conentNode: this.contentNode });
this._contentNodeWrapper.style.display = 'none';
this._handleFeatures({ phase: 'hide' });
this.dispatchEvent(new Event('hide'));
this._restoreFocus();
}
// eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars
async transitionHide({ backdropNode, contentNode }) {}
_restoreFocus() {
// We only are allowed to move focus if we (still) 'own' it.
// Otherwise we assume the 'outside world' has, purposefully, taken over
// if (this._contentNodeWrapper.activeElement) {
if (this.elementToFocusAfterHide) {
this.elementToFocusAfterHide.focus();
}
// }
}
async toggle() {
return this.isShown ? this.hide() : this.show();
}
/**
* @desc All features are handled here. Every feature is set up on show
* and torn
* @param {object} config
* @param {'init'|'show'|'hide'|'teardown'} config.phase
*/
async _handleFeatures({ phase }) {
this._handleZIndex({ phase });
if (this.preventsScroll) {
this._handlePreventsScroll({ phase });
}
if (this.isBlocking) {
this._handleBlocking({ phase });
}
if (this.hasBackdrop) {
this._handleBackdrop({ phase });
}
if (this.trapsKeyboardFocus) {
this._handleTrapsKeyboardFocus({ phase });
}
if (this.hidesOnEsc) {
this._handleHidesOnEsc({ phase });
}
if (this.hidesOnOutsideClick) {
this._handleHidesOnOutsideClick({ phase });
}
if (this.hidesOnHideEventInContentNode) {
this._handleHidesOnHideEventInContentNode({ phase });
}
if (this.handlesAccessibility) {
this._handleAccessibility({ phase });
}
if (this.inheritsReferenceWidth) {
this._handleInheritsReferenceWidth();
}
}
_handlePreventsScroll({ phase }) {
switch (phase) {
case 'show':
this.manager.requestToPreventScroll();
break;
case 'hide':
this.manager.requestToEnableScroll();
break;
/* no default */
}
}
_handleBlocking({ phase }) {
switch (phase) {
case 'show':
this.manager.requestToShowOnly(this);
break;
case 'hide':
this.manager.retractRequestToShowOnly(this);
break;
/* no default */
}
}
get hasActiveBackdrop() {
return this.__hasActiveBackdrop;
}
/**
* @desc 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.
*/
_handleBackdrop({ animation = true, phase }) {
if (this.placementMode === 'local') {
return; // coming soon...
}
const { backdropNode } = this;
switch (phase) {
case 'init':
this.backdropNode = document.createElement('div');
this.backdropNode.classList.add('global-overlays__backdrop');
this.backdropNode.slot = '_overlay-shadow-outlet';
this._contentNodeWrapper.parentElement.insertBefore(
this.backdropNode,
this._contentNodeWrapper,
);
break;
case 'show':
backdropNode.classList.add('global-overlays__backdrop--visible');
if (animation === true) {
backdropNode.classList.add('global-overlays__backdrop--fade-in');
}
this.__hasActiveBackdrop = true;
break;
case 'hide':
if (!backdropNode) {
return;
}
backdropNode.classList.remove('global-overlays__backdrop--fade-in');
if (animation) {
let afterFadeOut;
backdropNode.classList.add('global-overlays__backdrop--fade-out');
this.__backDropAnimation = new Promise(resolve => {
afterFadeOut = () => {
backdropNode.classList.remove('global-overlays__backdrop--fade-out');
backdropNode.classList.remove('global-overlays__backdrop--visible');
backdropNode.removeEventListener('animationend', afterFadeOut);
resolve();
};
});
backdropNode.addEventListener('animationend', afterFadeOut);
} else {
backdropNode.classList.remove('global-overlays__backdrop--visible');
}
this.__hasActiveBackdrop = false;
break;
case 'teardown':
if (!backdropNode) {
return;
}
if (animation && this.__backDropAnimation) {
this.__backDropAnimation.then(() => {
backdropNode.parentNode.removeChild(backdropNode);
});
} else {
backdropNode.parentNode.removeChild(backdropNode);
}
break;
/* no default */
}
}
get hasActiveTrapsKeyboardFocus() {
return this.__hasActiveTrapsKeyboardFocus;
}
_handleTrapsKeyboardFocus({ phase }) {
if (phase === 'show') {
this.enableTrapsKeyboardFocus();
} else if (phase === 'hide') {
this.disableTrapsKeyboardFocus();
}
}
enableTrapsKeyboardFocus() {
if (this.__hasActiveTrapsKeyboardFocus) {
return;
}
if (this.manager) {
this.manager.disableTrapsKeyboardFocusForAll();
}
this._containFocusHandler = containFocus(this.contentNode);
this.__hasActiveTrapsKeyboardFocus = true;
if (this.manager) {
this.manager.informTrapsKeyboardFocusGotEnabled();
}
}
disableTrapsKeyboardFocus({ findNewTrap = true } = {}) {
if (!this.__hasActiveTrapsKeyboardFocus) {
return;
}
if (this._containFocusHandler) {
this._containFocusHandler.disconnect();
this._containFocusHandler = undefined;
}
this.__hasActiveTrapsKeyboardFocus = false;
if (this.manager) {
this.manager.informTrapsKeyboardFocusGotDisabled({ disabledCtrl: this, findNewTrap });
}
}
_handleHidesOnEsc({ phase }) {
if (phase === 'show') {
this.__escKeyHandler = ev => ev.key === 'Escape' && this.hide();
this.contentNode.addEventListener('keyup', this.__escKeyHandler);
if (this.invokerNode) {
this.invokerNode.addEventListener('keyup', this.__escKeyHandler);
}
} else if (phase === 'hide') {
this.contentNode.removeEventListener('keyup', this.__escKeyHandler);
if (this.invokerNode) {
this.invokerNode.removeEventListener('keyup', this.__escKeyHandler);
}
}
}
_handleHidesOnHideEventInContentNode({ phase }) {
if (phase === 'show') {
this.__hideEventInContentNodeHandler = ev => {
ev.stopPropagation();
this.hide();
};
this.contentNode.addEventListener('hide', this.__hideEventInContentNodeHandler);
} else if (phase === 'hide') {
this.contentNode.removeEventListener('keyup', this.__hideEventInContentNodeHandler);
}
}
_handleInheritsReferenceWidth() {
if (!this._referenceNode) {
return;
}
const referenceWidth = `${this._referenceNode.clientWidth}px`;
switch (this.inheritsReferenceWidth) {
case 'max':
this._contentNodeWrapper.style.maxWidth = referenceWidth;
break;
case 'full':
this._contentNodeWrapper.style.width = referenceWidth;
break;
case 'min':
this._contentNodeWrapper.style.minWidth = referenceWidth;
break;
/* no default */
}
}
_handleHidesOnOutsideClick({ phase }) {
const addOrRemoveListener = phase === 'show' ? 'addEventListener' : 'removeEventListener';
if (phase === 'show') {
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 === false) {
this.hide();
}
});
};
}
this._contentNodeWrapper[addOrRemoveListener]('click', this.__preventCloseOutsideClick, true);
if (this.invokerNode) {
this.invokerNode[addOrRemoveListener]('click', this.__preventCloseOutsideClick, true);
}
document.documentElement[addOrRemoveListener]('click', this.__onCaptureHtmlClick, true);
}
_handleAccessibility({ phase }) {
if (this.invokerNode && !this.isTooltip) {
this.invokerNode.setAttribute('aria-expanded', phase === 'show');
}
}
// TODO: Remove when no longer required by OverlayMixin (after updateConfig works properly while opened)
async updatePopperConfig(config = {}) {
this.__mergePopperConfigs(config);
if (this.isShown) {
await this.__createPopperInstance();
this._popper.update();
}
}
teardown() {
this._handleFeatures({ phase: 'teardown' });
this._contentNodeWrapper.remove();
}
/**
* 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 added config
*/
this.config.popperConfig = {
...defaultConfig,
...(this.config.popperConfig || {}),
...(config || {}),
modifiers: {
...defaultConfig.modifiers,
...((this.config.popperConfig && this.config.popperConfig.modifiers) || {}),
...((config && config.modifiers) || {}),
},
};
}
async __createPopperInstance() {
if (this._popper) {
this._popper.destroy();
this._popper = null;
}
const { default: Popper } = await this.constructor.popperModule;
this._popper = new Popper(this._referenceNode, this._contentNodeWrapper, {
...this.config.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);
});
}
}