diff --git a/packages/overlays/README.md b/packages/overlays/README.md index dbfedcb7a..68a2b986d 100644 --- a/packages/overlays/README.md +++ b/packages/overlays/README.md @@ -33,3 +33,8 @@ const myCtrl = overlays.add( // name OverlayTypeController is for illustration purpose only // please read below about existing classes for different types of overlays ``` + +## Rationals + +- No `aria-controls`: as support for it is not quite there yet +- No `aria-haspopup` People knowing the haspop up and hear about it don’t expect a dialog to open (at this moment in time) but expect a sub-menu. Until support for the dialog value has better implementation, it’s probably best to not use aria-haspopup on the element that opens the modal dialog. diff --git a/packages/overlays/index.js b/packages/overlays/index.js index f915299db..7719aab23 100644 --- a/packages/overlays/index.js +++ b/packages/overlays/index.js @@ -1,11 +1,9 @@ -export { DynamicOverlayController } from './src/DynamicOverlayController.js'; -export { GlobalOverlayController } from './src/GlobalOverlayController.js'; export { globalOverlaysStyle } from './src/globalOverlaysStyle.js'; -export { LocalOverlayController } from './src/LocalOverlayController.js'; -export { BottomSheetController } from './src/BottomSheetController.js'; -export { ModalDialogController } from './src/ModalDialogController.js'; export { overlays } from './src/overlays.js'; export { OverlaysManager } from './src/OverlaysManager.js'; +export { OverlayController } from './src/OverlayController.js'; +export { OverlayMixin } from './src/OverlayMixin.js'; -// deprecated -export { BottomSheetController as BottomsheetController } from './src/BottomSheetController.js'; +export { withBottomSheetConfig } from './src/configurations/withBottomSheetConfig.js'; +export { withModalDialogConfig } from './src/configurations/withModalDialogConfig.js'; +export { withDropdownConfig } from './src/configurations/withDropdownConfig.js'; diff --git a/packages/overlays/package.json b/packages/overlays/package.json index 502180e8e..f4dccb579 100644 --- a/packages/overlays/package.json +++ b/packages/overlays/package.json @@ -28,6 +28,7 @@ "src", "stories", "test", + "test-helpers", "translations", "*.js" ], @@ -38,6 +39,7 @@ "devDependencies": { "@open-wc/demoing-storybook": "^0.2.0", "@open-wc/testing": "^2.3.4", + "@open-wc/testing-helpers": "^1.0.0", "sinon": "^7.2.2" } } diff --git a/packages/overlays/src/BaseOverlayController.js b/packages/overlays/src/BaseOverlayController.js deleted file mode 100644 index 5a1dc3c1a..000000000 --- a/packages/overlays/src/BaseOverlayController.js +++ /dev/null @@ -1,281 +0,0 @@ -import { render, html } from '@lion/core'; -import '@lion/core/src/differentKeyEventNamesShimIE.js'; -import { containFocus } from './utils/contain-focus.js'; - -/** - * This is the interface for a controller - */ -export class BaseOverlayController { - get _showHideMode() { - return this.__showHideMode; // dom, css - } - - get isShown() { - return this.__isShown; - } - - set isShown(value) { - this.__isShown = value; - } - - get content() { - return this.__content; - } - - set content(value) { - this.__content = value; - } - - get contentTemplate() { - return this.__contentTemplate; - } - - set contentTemplate(templateFunction) { - if (typeof templateFunction !== 'function') { - throw new Error('.contentTemplate needs to be a function'); - } - - const tmp = document.createElement('div'); - render(templateFunction(this.contentData), tmp); - if (tmp.children.length !== 1) { - throw new Error('The .contentTemplate needs to always return exactly one child node'); - } - - this.__contentTemplate = templateFunction; - this.__showHideViaDom(); - } - - get contentData() { - return this.__contentData; - } - - set contentData(value) { - if (!this.contentTemplate) { - throw new Error('.contentData can only be used if there is a .contentTemplate function'); - } - this.__contentData = value; - this.__showHideViaDom(); - } - - get contentNode() { - return this.__contentNode; - } - - set contentNode(node) { - this.__contentNode = node; - this.content = node; - // setting a contentNode means hide/show with css - this.__showHideMode = 'css'; - if (this.isShown === false) { - this.contentNode.style.display = 'none'; - } - } - - constructor(params = {}) { - this.__fakeExtendsEventTarget(); - this.__firstContentTemplateRender = false; - this.__showHideMode = 'dom'; - this.isShown = false; - - this.__setupContent(params); - - // Features initial state - this.__hasActiveTrapsKeyboardFocus = false; - this.__hasActiveHidesOnEsc = false; - } - - // TODO: add an ctrl.updateComplete e.g. when async show is done? - async show() { - if (this.manager) { - this.manager.show(this); - } - if (this.isShown === true) { - return; - } - this.isShown = true; - this.__handleShowChange(); - this.dispatchEvent(new Event('show')); - } - - async hide() { - if (this.manager) { - this.manager.hide(this); - } - if (this.isShown === false) { - return; - } - this.isShown = false; - if (!this.hideDone) { - this.defaultHideDone(); - } - } - - defaultHideDone() { - this.__handleShowChange(); - this.dispatchEvent(new Event('hide')); - } - - /** - * Toggles the overlay. - */ - async toggle() { - if (this.isShown === true) { - await this.hide(); - } else { - await this.show(); - } - } - - // eslint-disable-next-line class-methods-use-this - switchIn() {} - - // eslint-disable-next-line class-methods-use-this - switchOut() {} - - // eslint-disable-next-line class-methods-use-this - onContentUpdated() {} - - __setupContent(params) { - if (params.contentTemplate && params.contentNode) { - throw new Error('You can only provide a .contentTemplate or a .contentNode but not both'); - } - if (!params.contentTemplate && !params.contentNode) { - throw new Error('You need to provide a .contentTemplate or a .contentNode'); - } - if (params.contentTemplate) { - this.contentTemplate = params.contentTemplate; - } - if (params.contentNode) { - this.contentNode = params.contentNode; - } - } - - __handleShowChange() { - if (this._showHideMode === 'dom') { - this.__showHideViaDom(); - } - - if (this._showHideMode === 'css') { - if (this.contentTemplate && !this.__firstContentTemplateRender) { - this.__showHideViaDom(); - this.__firstContentTemplateRender = true; - } - this.__showHideViaCss(); - } - } - - __showHideViaDom() { - if (!this.contentTemplate) { - return; - } - if (!this.content) { - this.content = document.createElement('div'); - } - - if (this.isShown) { - render(this.contentTemplate(this.contentData), this.content); - this.__contentNode = this.content.firstElementChild; - this.onContentUpdated(); - } else { - render(html``, this.content); - this.__contentNode = undefined; - } - } - - // eslint-disable-next-line class-methods-use-this - __showHideViaCss() {} - - // 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); - }); - } - - __enableFeatures() { - if (this.trapsKeyboardFocus) { - this.enableTrapsKeyboardFocus(); - } - if (this.hidesOnEsc) { - this.enableHidesOnEsc(); - } - } - - __disableFeatures() { - if (this.trapsKeyboardFocus) { - this.disableTrapsKeyboardFocus(); - } - if (this.hidesOnEsc) { - this.disableHidesOnEsc(); - } - } - - // ********************************************************************************************** - // FEATURE - TrapsKeyboardFocus - // ********************************************************************************************** - get hasActiveTrapsKeyboardFocus() { - return this.__hasActiveTrapsKeyboardFocus; - } - - enableTrapsKeyboardFocus() { - if (this.__hasActiveTrapsKeyboardFocus === true) { - 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 === false) { - return; - } - this._containFocusHandler.disconnect(); - this._containFocusHandler = undefined; - - this.__hasActiveTrapsKeyboardFocus = false; - if (this.manager) { - this.manager.informTrapsKeyboardFocusGotDisabled({ disabledCtrl: this, findNewTrap }); - } - } - - // ********************************************************************************************** - // FEATURE - hideOnEsc - // ********************************************************************************************** - get hasActiveHidesOnEsc() { - return this.__hasActiveHidesOnEsc; - } - - enableHidesOnEsc() { - if (this.__hasHidesOnEsc === true) { - return; - } - this.__escKeyHandler = ev => { - if (ev.key === 'Escape') { - this.hide(); - } - }; - - this.contentNode.addEventListener('keyup', this.__escKeyHandler); - - this.__hasActiveHidesOnEsc = true; - } - - disableHidesOnEsc() { - if (this.__hasHidesOnEsc === false) { - return; - } - if (this.contentNode) { - this.contentNode.removeEventListener('keyup', this.__escKeyHandler); - } - - this.__hasActiveHidesOnEsc = false; - } -} diff --git a/packages/overlays/src/BottomSheetController.js b/packages/overlays/src/BottomSheetController.js deleted file mode 100644 index d55c24a28..000000000 --- a/packages/overlays/src/BottomSheetController.js +++ /dev/null @@ -1,21 +0,0 @@ -import { GlobalOverlayController } from './GlobalOverlayController.js'; - -export class BottomSheetController extends GlobalOverlayController { - constructor(params) { - super({ - hasBackdrop: true, - preventsScroll: true, - trapsKeyboardFocus: true, - hidesOnEsc: true, - viewportConfig: { - placement: 'bottom', - }, - ...params, - }); - } - - onContentUpdated() { - super.onContentUpdated(); - this.contentNode.classList.add('global-overlays__overlay--bottom-sheet'); - } -} diff --git a/packages/overlays/src/DynamicOverlayController.js b/packages/overlays/src/DynamicOverlayController.js deleted file mode 100644 index 02110b907..000000000 --- a/packages/overlays/src/DynamicOverlayController.js +++ /dev/null @@ -1,128 +0,0 @@ -import { LocalOverlayController } from './LocalOverlayController.js'; - -export class DynamicOverlayController { - /** - * no setter as .list is intended to be read-only - * You can use .add or .remove to modify it - */ - get list() { - return this.__list; - } - - /** - * no setter as .active is intended to be read-only - * You can use .switchTo to change it - */ - get active() { - return this.__active; - } - - get isShown() { - return this.active ? this.active.isShown : false; - } - - set isShown(value) { - if (this.active) { - this.active.isShown = value; - } - } - - constructor() { - this.__list = []; - this.__active = undefined; - this.nextOpen = undefined; - if (!this.content) { - this.content = document.createElement('div'); - } - this.__fakeExtendsEventTarget(); - this.__delegateEvent = this.__delegateEvent.bind(this); - } - - add(ctrlToAdd) { - if (this.list.find(ctrl => ctrlToAdd === ctrl)) { - throw new Error('controller instance is already added'); - } - this.list.push(ctrlToAdd); - - if (!this.active) { - this.__active = ctrlToAdd; - } - - if (this.active && ctrlToAdd instanceof LocalOverlayController) { - // eslint-disable-next-line no-param-reassign - ctrlToAdd.content = this.content; - } - - return ctrlToAdd; - } - - remove(ctrlToRemove) { - if (!this.list.find(ctrl => ctrlToRemove === ctrl)) { - throw new Error('could not find controller to remove'); - } - if (this.active === ctrlToRemove) { - throw new Error( - 'You can not remove the active controller. Please switch first to a different controller via ctrl.switchTo()', - ); - } - - this.__list = this.list.filter(ctrl => ctrl !== ctrlToRemove); - } - - switchTo(ctrlToSwitchTo) { - if (this.isShown === true) { - throw new Error('You can not switch overlays while being shown'); - } - const prevActive = this.active; - - this.active.switchOut(); - ctrlToSwitchTo.switchIn(); - this.__active = ctrlToSwitchTo; - - this._delegateEvents(this.__active, prevActive); - } - - async show() { - if (this.nextOpen) { - this.switchTo(this.nextOpen); - this.nextOpen = null; - } - await this.active.show(); - } - - async hide() { - await this.active.hide(); - } - - async toggle() { - if (this.isShown === true) { - await this.hide(); - } else { - await this.show(); - } - } - - get invokerNode() { - return this.active.invokerNode; - } - - _delegateEvents(active, prevActive) { - ['show', 'hide'].forEach(event => { - active.addEventListener(event, this.__delegateEvent); - prevActive.removeEventListener(event, this.__delegateEvent); - }); - } - - __delegateEvent(ev) { - ev.stopPropagation(); - this.dispatchEvent(new Event(ev.type)); - } - - // 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); - }); - } -} diff --git a/packages/overlays/src/GlobalOverlayController.js b/packages/overlays/src/GlobalOverlayController.js deleted file mode 100644 index b2994f1bc..000000000 --- a/packages/overlays/src/GlobalOverlayController.js +++ /dev/null @@ -1,293 +0,0 @@ -import { BaseOverlayController } from './BaseOverlayController.js'; - -const isIOS = navigator.userAgent.match(/iPhone|iPad|iPod/i); - -export class GlobalOverlayController extends BaseOverlayController { - constructor(params = {}) { - super(params); - - const finalParams = { - elementToFocusAfterHide: document.body, - hasBackdrop: false, - isBlocking: false, - preventsScroll: false, - trapsKeyboardFocus: false, - hidesOnEsc: false, - viewportConfig: { - placement: 'center', - }, - ...params, - }; - - this.__hasActiveBackdrop = false; - this.__hasActiveTrapsKeyboardFocus = false; - - this.elementToFocusAfterHide = finalParams.elementToFocusAfterHide; - this.hasBackdrop = finalParams.hasBackdrop; - this.isBlocking = finalParams.isBlocking; - this.preventsScroll = finalParams.preventsScroll; - this.trapsKeyboardFocus = finalParams.trapsKeyboardFocus; - this.hidesOnEsc = finalParams.hidesOnEsc; - this.invokerNode = finalParams.invokerNode; - this.overlayContainerClass = `global-overlays__overlay-container`; - this.overlayContainerPlacementClass = `${this.overlayContainerClass}--${finalParams.viewportConfig.placement}`; - } - - /** - * Syncs shown state and data. - * - * @param {object} options options 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 - */ - async sync(options) { - if (options.elementToFocusAfterHide) { - this.elementToFocusAfterHide = options.elementToFocusAfterHide; - } - - if (options.data) { - this.contentData = options.data; - } - - if (options.isShown === true) { - await this.show(); - } else if (options.isShown === false) { - await this.hide(); - } - } - - /** - * Shows the overlay. - * @param {HTMLElement} [elementToFocusAfterHide] element to return focus when hiding - */ - async show(elementToFocusAfterHide) { - if (!this.manager) { - throw new Error( - 'Could not find a manger did you use "overlays.add(new GlobalOverlayController())"?', - ); - } - - const oldIsShown = this.isShown; - await super.show(); - if (oldIsShown === true) { - return; - } - if (!this.content.isConnected) { - this.content.classList.add(this.overlayContainerClass); - this.content.classList.add(this.overlayContainerPlacementClass); - this.manager.globalRootNode.appendChild(this.content); - } - - if (elementToFocusAfterHide) { - this.elementToFocusAfterHide = elementToFocusAfterHide; - } - this.__enableFeatures(); - } - - onContentUpdated() { - this.contentNode.classList.add('global-overlays__overlay'); - } - - get contentNode() { - return this.__contentNode; - } - - set contentNode(node) { - const wrapper = document.createElement('div'); - wrapper.appendChild(node); - - this.__contentNode = node; - this.content = wrapper; - this.onContentUpdated(); - - // setting a contentNode means hide/show with css - this.__showHideMode = 'css'; - if (this.isShown === false) { - this.content.style.display = 'none'; - } - } - - /** - * Hides the overlay. - */ - async hide() { - const oldIsShown = this.isShown; - await super.hide(); - if (oldIsShown === false) { - return; - } - - if (this.elementToFocusAfterHide) { - this.elementToFocusAfterHide.focus(); - } - this.__disableFeatures(); - - this.hideDone(); - if (this.contentTemplate) { - this.content.classList.remove(this.overlayContainerPlacementClass); - this.manager.globalRootNode.removeChild(this.content); - } - } - - hideDone() { - this.defaultHideDone(); - } - - __showHideViaCss() { - if (!this.contentNode) { - return; - } - - if (this.isShown) { - this.content.style.display = ''; - } else { - this.content.style.display = 'none'; - } - } - - /** - * Sets up flags. - */ - __enableFeatures() { - super.__enableFeatures(); - - 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.enableBackdrop(); - } - - if (this.isBlocking) { - this.enableBlocking(); - } - } - - /** - * Cleans up flags. - */ - __disableFeatures() { - super.__disableFeatures(); - - if (this.preventsScroll) { - document.body.classList.remove('global-overlays-scroll-lock'); - if (isIOS) { - document.body.classList.remove('global-overlays-scroll-lock-ios-fix'); - } - } - - if (this.hasBackdrop) { - this.disableBackdrop(); - } - - if (this.isBlocking) { - this.disableBlocking(); - } - } - - // ********************************************************************************************** - // FEATURE - isBlocking - // ********************************************************************************************** - get hasActiveBlocking() { - return this.__hasActiveBlocking; - } - - enableBlocking() { - if (this.__hasActiveBlocking === true) { - return; - } - - this.contentNode.classList.remove('global-overlays__overlay'); - this.contentNode.classList.add('global-overlays__overlay--blocking'); - if (this.backdropNode) { - this.backdropNode.classList.remove('global-overlays__backdrop'); - this.backdropNode.classList.add('global-overlays__backdrop--blocking'); - } - - this.manager.globalRootNode.classList.add('global-overlays--blocking-opened'); - - this.__hasActiveBlocking = true; - } - - disableBlocking() { - if (this.__hasActiveBlocking === false) { - return; - } - - const blockingController = this.manager.shownList.find( - ctrl => ctrl !== this && ctrl.isBlocking === true, - ); - // if there are no other blocking overlays remaining, stop hiding regular overlays - if (!blockingController) { - this.manager.globalRootNode.classList.remove('global-overlays--blocking-opened'); - } - - this.__hasActiveBlocking = false; - } - - // ********************************************************************************************** - // FEATURE - hasBackdrop - // ********************************************************************************************** - get hasActiveBackdrop() { - return this.__hasActiveBackdrop; - } - - /** - * 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 animation from being displayed - */ - enableBackdrop({ animation = true } = {}) { - if (this.__hasActiveBackdrop === true) { - return; - } - - this.backdropNode = document.createElement('div'); - this.backdropNode.classList.add('global-overlays__backdrop'); - this.content.parentNode.insertBefore(this.backdropNode, this.content); - - if (animation === true) { - this.backdropNode.classList.add('global-overlays__backdrop--fade-in'); - } - this.__hasActiveBackdrop = true; - } - - disableBackdrop({ animation = true } = {}) { - if (this.__hasActiveBackdrop === false) { - return; - } - - if (animation) { - const { backdropNode } = this; - this.__removeFadeOut = () => { - backdropNode.classList.remove('global-overlays__backdrop--fade-out'); - backdropNode.removeEventListener('animationend', this.__removeFadeOut); - backdropNode.parentNode.removeChild(backdropNode); - }; - backdropNode.addEventListener('animationend', this.__removeFadeOut); - - backdropNode.classList.remove('global-overlays__backdrop--fade-in'); - backdropNode.classList.add('global-overlays__backdrop--fade-out'); - } - - this.__hasActiveBackdrop = false; - } - - // 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); - }); - } -} diff --git a/packages/overlays/src/LocalOverlayController.js b/packages/overlays/src/LocalOverlayController.js deleted file mode 100644 index 4dcef03eb..000000000 --- a/packages/overlays/src/LocalOverlayController.js +++ /dev/null @@ -1,306 +0,0 @@ -import { render } from '@lion/core'; -import { BaseOverlayController } from './BaseOverlayController.js'; - -async function __preloadPopper() { - return import('popper.js/dist/esm/popper.min.js'); -} -export class LocalOverlayController extends BaseOverlayController { - constructor(params = {}) { - super(params); - - this.__hasActiveHidesOnOutsideClick = false; - - // 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 - * - * @deprecated - please use .invokerNode instead - * - * @property {HTMLElement} - */ - this.invoker = document.createElement('div'); - this.invoker.style.display = 'inline-block'; - - /** - * @deprecated - please use .invokerNode instead - */ - 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; - } - - this.contentId = `overlay-content-${Math.random() - .toString(36) - .substr(2, 10)}`; - this.syncInvoker(); - } - - /** - * 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 - */ - async sync({ isShown, data } = {}) { - if (data) { - this.contentData = data; - } - - if (isShown === true) { - await this.show(); - } else if (isShown === false) { - await this.hide(); - } - } - - /** - * Syncs data for invoker. - * - * @deprecated please use .invokerNode instead - * @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() { - const oldIsShown = this.isShown; - await super.show(); - if (oldIsShown === true) { - return; - } - /* To display on top of elements with no z-index that are appear later in the DOM */ - this.contentNode.style.zIndex = 1; - /** - * 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. - */ - if (this.invokerNode && this.contentNode) { - await this.__createPopperInstance(); - this._popper.update(); - } - - this.__enableFeatures(); - } - - /** - * Hides the overlay. - */ - async hide() { - const oldIsShown = this.isShown; - await super.hide(); - if (oldIsShown === false) { - return; - } - - this.__disableFeatures(); - } - - __enableFeatures() { - super.__enableFeatures(); - - this.invokerNode.setAttribute('aria-expanded', 'true'); - if (this.inheritsReferenceObjectWidth) { - this.enableInheritsReferenceObjectWidth(); - } - if (this.hidesOnOutsideClick) { - this.enableHidesOnOutsideClick(); - } - } - - __disableFeatures() { - super.__disableFeatures(); - - this.invokerNode.setAttribute('aria-expanded', 'false'); - if (this.hidesOnOutsideClick) { - this.disableHidesOnOutsideClick(); - } - } - - enableInheritsReferenceObjectWidth() { - 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; - } - } - - // 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); - if (this.isShown) { - await this.__createPopperInstance(); - this._popper.update(); - } - } - - /** - * 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, - }); - } - - get contentTemplate() { - return super.contentTemplate; - } - - set contentTemplate(value) { - super.contentTemplate = value; - if (this.contentNode && this.invokerNode) { - this.disableHidesOnOutsideClick(); - this.enableHidesOnOutsideClick(); - } - } - - __showHideViaCss() { - if (!this.contentNode) { - return; - } - - if (this.isShown) { - this.contentNode.style.display = 'inline-block'; - } else { - this.contentNode.style.display = 'none'; - } - } - - // ********************************************************************************************** - // FEATURE - hidesOnOutsideClick - // ********************************************************************************************** - get hasActiveHidesOnOutsideClick() { - return this.__hasActiveHidesOnOutsideClick; - } - - enableHidesOnOutsideClick() { - if (this.hasActiveHidesOnOutsideClick === true) { - 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 === false) { - this.hide(); - } - }); - }; - - this.contentNode.addEventListener('click', this.__preventCloseOutsideClick, true); - this.invokerNode.addEventListener('click', this.__preventCloseOutsideClick, true); - document.documentElement.addEventListener('click', this.__onCaptureHtmlClick, true); - - this.__hasActiveHidesOnOutsideClick = true; - } - - disableHidesOnOutsideClick() { - if (this.hasActiveHidesOnOutsideClick === false) { - return; - } - - if (this.contentNode) { - 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; - - this.__hasActiveHidesOnOutsideClick = false; - } -} diff --git a/packages/overlays/src/ModalDialogController.js b/packages/overlays/src/ModalDialogController.js deleted file mode 100644 index 6097e7f0f..000000000 --- a/packages/overlays/src/ModalDialogController.js +++ /dev/null @@ -1,16 +0,0 @@ -import { GlobalOverlayController } from './GlobalOverlayController.js'; - -export class ModalDialogController extends GlobalOverlayController { - constructor(params) { - super({ - hasBackdrop: true, - preventsScroll: true, - trapsKeyboardFocus: true, - hidesOnEsc: true, - viewportConfig: { - placement: 'center', - }, - ...params, - }); - } -} diff --git a/packages/overlays/src/OverlayController.js b/packages/overlays/src/OverlayController.js new file mode 100644 index 000000000..e3f2be79d --- /dev/null +++ b/packages/overlays/src/OverlayController.js @@ -0,0 +1,617 @@ +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: document.body, + inheritsReferenceWidth: 'min', + hasBackdrop: false, + isBlocking: false, + preventsScroll: false, + trapsKeyboardFocus: false, + hidesOnEsc: false, + hidesOnOutsideClick: false, + 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(this.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); + } + } + + /** + * @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) { + 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.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); + } else if (phase === 'hide') { + this.contentNode.removeEventListener('keyup', this.__escKeyHandler); + } + } + + _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; + default: + this._contentNodeWrapper.style.minWidth = referenceWidth; + } + } + + _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'); + } + } + + // 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); + if (this.isShown) { + await this.__createPopperInstance(); + this._popper.update(); + } + } + + teardown() { + this._handleFeatures({ phase: 'teardown' }); + } + + /** + * 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 { default: Popper } = await this.constructor.popperModule; + this._popper = new Popper(this._referenceNode, this._contentNodeWrapper, { + ...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); + }); + } +} diff --git a/packages/overlays/src/OverlayMixin.js b/packages/overlays/src/OverlayMixin.js new file mode 100644 index 000000000..a80c497b3 --- /dev/null +++ b/packages/overlays/src/OverlayMixin.js @@ -0,0 +1,143 @@ +import { render, dedupeMixin } from '@lion/core'; + +/** + * @type {Function()} + * @polymerMixin + * @mixinFunction + */ +export const OverlayMixin = dedupeMixin( + superclass => + // eslint-disable-next-line no-shadow + class OverlayMixin extends superclass { + static get properties() { + return { + opened: { + type: Boolean, + reflect: true, + }, + popperConfig: Object, + }; + } + + get opened() { + return this._overlayCtrl.isShown; + } + + set opened(show) { + this._opened = show; // mainly captured for sync on connectedCallback + if (this._overlayCtrl) { + this.__syncOpened(); + } + } + + __syncOpened() { + if (this._opened) { + this._overlayCtrl.show(); + } else { + this._overlayCtrl.hide(); + } + } + + get popperConfig() { + return this._popperConfig; + } + + set popperConfig(config) { + this._popperConfig = { + ...this._popperConfig, + ...config, + }; + this.__syncPopper(); + } + + __syncPopper() { + if (this._overlayCtrl) { + this._overlayCtrl.updatePopperConfig(this._popperConfig); + } + } + + connectedCallback() { + if (super.connectedCallback) { + super.connectedCallback(); + } + this._createOverlay(); + this.__syncOpened(); + this.__syncPopper(); + } + + firstUpdated(c) { + super.firstUpdated(c); + this._createOutletForLocalOverlay(); + } + + updated(c) { + super.updated(c); + if (this.__managesOverlayViaTemplate) { + this._renderOverlayContent(); + } + } + + _renderOverlayContent() { + render(this._overlayTemplate(), this.__contentParent, { + scopeName: this.localName, + eventContext: this, + }); + } + + /** + * @desc Two options for a Subclasser: + * - 1: Define a template in `._overlayTemplate`. In this case the overlay content is + * predefined and thus belongs to the web component. Examples: datepicker. + * - 2: Define a getter `_overlayContentNode` that returns a node reference to a (content + * projected) node. Used when Application Developer is in charge of the content. Examples: + * popover, dialog, bottom sheet, dropdown, tooltip, select, combobox etc. + */ + get __managesOverlayViaTemplate() { + return Boolean(this._overlayTemplate); + } + + _createOverlay() { + let contentNode; + if (this.__managesOverlayViaTemplate) { + this.__contentParent = document.createElement('div'); + this._renderOverlayContent(); + contentNode = this.__contentParent.firstElementChild; + } else { + contentNode = this._overlayContentNode; + } + + // Why no template support for invokerNode? + // -> Because this node will always be managed by the Subclasser and should + // reside in the dom of the sub class. A reference to a rendered node suffices. + const invokerNode = this._overlayInvokerNode; + this._overlayCtrl = this._defineOverlay({ contentNode, invokerNode }); + } + + /** + * @desc Should be called by Subclasser for local overlay support in shadow roots + * Create an outlet slot in shadow dom that our local overlay can pass through + */ + _createOutletForLocalOverlay() { + const outlet = document.createElement('slot'); + outlet.name = '_overlay-shadow-outlet'; + this.shadowRoot.appendChild(outlet); + this._overlayCtrl._contentNodeWrapper.slot = '_overlay-shadow-outlet'; + } + + /** + * @overridable method `_overlayTemplate` + * Be aware that the overlay will be placed in a different shadow root. + * Therefore, style encapsulation should be provided by the contents of + * _overlayTemplate + * @return {TemplateResult} + */ + + /** + * @overridable method `_defineOverlay` + * @desc returns an instance of a (dynamic) overlay controller + * @returns {OverlayController} + */ + // eslint-disable-next-line + _defineOverlay({ contentNode, invokerNode }) {} + }, +); diff --git a/packages/overlays/src/OverlaysManager.js b/packages/overlays/src/OverlaysManager.js index 3fbe55848..8d0203eb6 100644 --- a/packages/overlays/src/OverlaysManager.js +++ b/packages/overlays/src/OverlaysManager.js @@ -1,6 +1,8 @@ import { unsetSiblingsInert, setSiblingsInert } from './utils/inert-siblings.js'; import { globalOverlaysStyle } from './globalOverlaysStyle.js'; +const isIOS = navigator.userAgent.match(/iPhone|iPad|iPod/i); + /** * @typedef {object} OverlayController * @param {(object) => TemplateResult} contentTemplate the template function @@ -37,7 +39,7 @@ export class OverlaysManager { } /** - * no setter as .list is inteded to be read-only + * no setter as .list is intended to be read-only * You can use .add or .remove to modify it */ get globalRootNode() { @@ -49,7 +51,7 @@ export class OverlaysManager { } /** - * no setter as .list is inteded to be read-only + * no setter as .list is intended to be read-only * You can use .add or .remove to modify it */ get list() { @@ -57,7 +59,7 @@ export class OverlaysManager { } /** - * no setter as .shownList is inteded to be read-only + * no setter as .shownList is intended to be read-only * You can use .show or .hide on individual controllers to modify */ get shownList() { @@ -68,6 +70,7 @@ export class OverlaysManager { this.__list = []; this.__shownList = []; this.__siblingsInert = false; + this.__blockingMap = new WeakMap(); } /** @@ -79,8 +82,6 @@ export class OverlaysManager { if (this.list.find(ctrl => ctrlToAdd === ctrl)) { throw new Error('controller instance is already added'); } - // eslint-disable-next-line no-param-reassign - ctrlToAdd.manager = this; this.list.push(ctrlToAdd); return ctrlToAdd; } @@ -97,6 +98,14 @@ export class OverlaysManager { this.hide(ctrlToShow); } this.__shownList.unshift(ctrlToShow); + + // make sure latest shown ctrl is visible + Array.from(this.__shownList) + .reverse() + .forEach((ctrl, i) => { + // eslint-disable-next-line no-param-reassign + ctrl.elevation = i + 1; + }); } hide(ctrlToHide) { @@ -107,6 +116,10 @@ export class OverlaysManager { } teardown() { + this.list.forEach(ctrl => { + ctrl.teardown(); + }); + this.__list = []; this.__shownList = []; this.__siblingsInert = false; @@ -159,4 +172,41 @@ export class OverlaysManager { this.__siblingsInert = false; } } + + /** PreventsScroll */ + + // eslint-disable-next-line class-methods-use-this + requestToPreventScroll() { + // no check as classList will dedupe it anyways + 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'); + } + } + + requestToEnableScroll() { + if (!this.shownList.some(ctrl => ctrl.preventsScroll === true)) { + document.body.classList.remove('global-overlays-scroll-lock'); + if (isIOS) { + document.body.classList.remove('global-overlays-scroll-lock-ios-fix'); + } + } + } + + /** Blocking */ + requestToShowOnly(blockingCtrl) { + const controllersToHide = this.shownList.filter(ctrl => ctrl !== blockingCtrl); + + controllersToHide.map(ctrl => ctrl.hide()); + this.__blockingMap.set(blockingCtrl, controllersToHide); + } + + retractRequestToShowOnly(blockingCtrl) { + if (this.__blockingMap.has(blockingCtrl)) { + const controllersWhichGotHidden = this.__blockingMap.get(blockingCtrl); + controllersWhichGotHidden.map(ctrl => ctrl.show()); + } + } } diff --git a/packages/overlays/src/configurations/withBottomSheetConfig.js b/packages/overlays/src/configurations/withBottomSheetConfig.js new file mode 100644 index 000000000..bf8c6b70e --- /dev/null +++ b/packages/overlays/src/configurations/withBottomSheetConfig.js @@ -0,0 +1,11 @@ +export const withBottomSheetConfig = () => ({ + hasBackdrop: true, + preventsScroll: true, + trapsKeyboardFocus: true, + hidesOnEsc: true, + placementMode: 'global', + viewportConfig: { + placement: 'bottom', + }, + handlesAccessibility: true, +}); diff --git a/packages/overlays/src/configurations/withDropdownConfig.js b/packages/overlays/src/configurations/withDropdownConfig.js new file mode 100644 index 000000000..a0a9d59b1 --- /dev/null +++ b/packages/overlays/src/configurations/withDropdownConfig.js @@ -0,0 +1,15 @@ +export const withDropdownConfig = () => ({ + placementMode: 'local', + + inheritsReferenceWidth: true, + hidesOnOutsideClick: true, + popperConfig: { + placement: 'bottom-start', + modifiers: { + offset: { + enabled: false, + }, + }, + }, + handlesAccessibility: true, +}); diff --git a/packages/overlays/src/configurations/withModalDialogConfig.js b/packages/overlays/src/configurations/withModalDialogConfig.js new file mode 100644 index 000000000..4dbbc742e --- /dev/null +++ b/packages/overlays/src/configurations/withModalDialogConfig.js @@ -0,0 +1,12 @@ +export const withModalDialogConfig = () => ({ + placementMode: 'global', + viewportConfig: { + placement: 'center', + }, + + hasBackdrop: true, + preventsScroll: true, + trapsKeyboardFocus: true, + hidesOnEsc: true, + handlesAccessibility: true, +}); diff --git a/packages/overlays/src/globalOverlaysStyle.js b/packages/overlays/src/globalOverlaysStyle.js index 8953ec3ae..d112f77fb 100644 --- a/packages/overlays/src/globalOverlaysStyle.js +++ b/packages/overlays/src/globalOverlaysStyle.js @@ -6,8 +6,7 @@ export const globalOverlaysStyle = css` z-index: 200; } - .global-overlays__overlay, - .global-overlays__overlay--blocking { + .global-overlays__overlay { pointer-events: auto; } @@ -69,17 +68,7 @@ export const globalOverlaysStyle = css` width: 100%; } - .global-overlays.global-overlays--blocking-opened .global-overlays__overlay { - display: none; - } - - .global-overlays.global-overlays--blocking-opened .global-overlays__backdrop { - animation: global-overlays-backdrop-fade-out 300ms; - opacity: 0; - } - - .global-overlays .global-overlays__backdrop, - .global-overlays .global-overlays__backdrop--blocking { + .global-overlays .global-overlays__backdrop { content: ''; position: fixed; top: 0; @@ -89,6 +78,11 @@ export const globalOverlaysStyle = css` z-index: -1; background-color: #333333; opacity: 0.3; + display: none; + } + + .global-overlays .global-overlays__backdrop--visible { + display: block; } .global-overlays .global-overlays__backdrop--fade-in { diff --git a/packages/overlays/src/utils/typedef.js b/packages/overlays/src/utils/typedef.js new file mode 100644 index 000000000..1dfd14499 --- /dev/null +++ b/packages/overlays/src/utils/typedef.js @@ -0,0 +1,32 @@ +/** + * @typedef {object} OverlayConfig + * @property {HTMLElement} [elementToFocusAfterHide=document.body] - the element that should be + * called `.focus()` on after dialog closes + * @property {boolean} [hasBackdrop=false] - whether it should have a backdrop (currently + * exclusive to globalOverlayController) + * @property {boolean} [isBlocking=false] - hides other overlays when mutiple are opened + * (currently exclusive to globalOverlayController) + * @property {boolean} [preventsScroll=false] - prevents scrolling body content when overlay + * opened (currently exclusive to globalOverlayController) + * @property {boolean} [trapsKeyboardFocus=false] - rotates tab, implicitly set when 'isModal' + * @property {boolean} [hidesOnEsc=false] - hides the overlay when pressing [ esc ] + * @property {boolean} [hidesOnOutsideClick=false] - hides the overlay when clicking next to it, + * exluding invoker. (currently exclusive to localOverlayController) + * https://github.com/ing-bank/lion/pull/61 + * @property {HTMLElement} invokerNode + * @property {HTMLElement} contentNode + * @property {boolean} [isModal=false] - sets aria-modal and/or aria-hidden="true" on siblings + * @property {boolean} [isGlobal=false] - determines the connection point in DOM (body vs next + * to invoker). This is what other libraries often refer to as 'portal'. TODO: rename to renderToBody? + * @property {boolean} [isTooltip=false] - has a totally different interaction- and accessibility pattern from all other overlays, so needed for internals. + * @property {boolean} [handlesUserInteraction] - sets toggle on click, or hover when `isTooltip` + * @property {boolean} [handlesAccessibility] - + * - For non `isTooltip`: + * - sets aria-expanded="true/false" and aria-haspopup="true" on invokerNode + * - sets aria-controls on invokerNode + * - returns focus to invokerNode on hide + * - sets focus to overlay content(?) + * - For `isTooltip`: + * - sets role="tooltip" and aria-labelledby/aria-describedby on the content + * @property {PopperConfig} popperConfig + */ diff --git a/packages/overlays/stories/bottom-sheet.stories.js b/packages/overlays/stories/bottom-sheet.stories.js index 20816d240..a4ed33915 100644 --- a/packages/overlays/stories/bottom-sheet.stories.js +++ b/packages/overlays/stories/bottom-sheet.stories.js @@ -1,7 +1,8 @@ import { storiesOf, html } from '@open-wc/demoing-storybook'; import { css } from '@lion/core'; -import { overlays, BottomSheetController } from '../index.js'; +import { fixtureSync } from '@open-wc/testing-helpers'; +import { OverlayController, withBottomSheetConfig } from '../index.js'; const bottomSheetDemoStyle = css` .demo-overlay { @@ -12,16 +13,15 @@ const bottomSheetDemoStyle = css` `; storiesOf('Global Overlay System|BottomSheet', module).add('Default', () => { - const bottomSheetCtrl = overlays.add( - new BottomSheetController({ - contentTemplate: () => html` -
-

BottomSheet

- -
- `, - }), - ); + const bottomSheetCtrl = new OverlayController({ + ...withBottomSheetConfig(), + contentNode: fixtureSync(html` +
+

BottomSheet

+ +
+ `), + }); return html` -

Shows "Bottom Sheet" for small (< 600px) screens and "Dialog" for big (> 600px) screens

- - ${ctrl.invokerNode} - -

- You can also - - while overlay is hidden. -

- `; - }) - .add('Switch local overlays', () => { - const invokerNode = document.createElement('button'); - invokerNode.innerHTML = 'Invoker Button'; - - const ctrl = new DynamicOverlayController(); - const local1 = new LocalOverlayController({ - contentTemplate: () => html` -
-

Small screen have a read more

- - Read more ... -
+ `), + contentNode: fixtureSync(html` +
+ Content
- `, - invokerNode, - }); - ctrl.add(local1); - - const local2 = new LocalOverlayController({ - contentTemplate: () => html` -
-

Big screens see all

- - -
- `, - invokerNode, - }); - ctrl.add(local2); - - invokerNode.addEventListener('click', () => { - ctrl.toggle(); + `), }); - function switchOnMediaChange(x) { - if (x.matches) { - // <= 600px - ctrl.nextOpen = local1; - } else { - ctrl.nextOpen = local2; + const ctrlType = document.createElement('div'); + function switchTo(type) { + ctrlType.innerHTML = type; + switch (type) { + case 'bottom-sheet': + ctrl.updateConfig(withBottomSheetConfig()); + break; + case 'dropdown': + ctrl.updateConfig({ + ...withDropdownConfig(), + hasBackdrop: false, + viewportConfig: null, + }); + break; + default: + ctrl.updateConfig(withModalDialogConfig()); } } - const matchSmall = window.matchMedia('(max-width: 600px)'); - switchOnMediaChange(matchSmall); // call once manually to init - matchSmall.addListener(switchOnMediaChange); - - return html` - -

Shows "read me..." for small (< 600px) screens and all for big (> 600px) screens

- - ${ctrl.invokerNode}${ctrl.content} - -

- You can also - - while overlay is hidden. -

- `; - }) - .add('Global & Local', () => { - const invokerNode = document.createElement('button'); - invokerNode.innerHTML = 'Invoker Button'; - const ctrl = new DynamicOverlayController(); - - const local = new LocalOverlayController({ - contentTemplate: () => html` -
-

My Local Overlay

- -
- `, - invokerNode, - }); - ctrl.add(local); - - const global = overlays.add( - new GlobalOverlayController({ - contentTemplate: () => html` -
-

My Global Overlay

- -
- `, - invokerNode, - }), - ); - ctrl.add(global); - - invokerNode.addEventListener('click', () => { - ctrl.toggle(); - }); - - function switchOnMediaChange(x) { - if (x.matches) { - // <= 600px - console.log('settig', global); - ctrl.nextOpen = global; - } else { - ctrl.nextOpen = local; - } - } - const matchSmall = window.matchMedia('(max-width: 600px)'); - switchOnMediaChange(matchSmall); // call once manually to init - matchSmall.addListener(switchOnMediaChange); return html` -

- Shows "Buttom Sheet" for small (< 600px) screens and "Dropdown" for big (> 600px) screens -

+ ${ctrl.invoker} -

- This button is indented to show the local positioning ${ctrl.invokerNode}${ctrl.content} -

+ -

- You can also - - while overlay is hidden. -

+ + + `; - }); + }, +); diff --git a/packages/overlays/stories/global-overlay.stories.js b/packages/overlays/stories/global-overlay.stories.js index 26576b045..c4a151401 100644 --- a/packages/overlays/stories/global-overlay.stories.js +++ b/packages/overlays/stories/global-overlay.stories.js @@ -1,8 +1,7 @@ import { storiesOf, html } from '@open-wc/demoing-storybook'; - -import { css } from '@lion/core'; -import { LionLitElement } from '@lion/core/src/LionLitElement.js'; -import { overlays, GlobalOverlayController } from '../index.js'; +import { css, LitElement } from '@lion/core'; +import { fixtureSync } from '@open-wc/testing-helpers'; +import { OverlayController } from '../index.js'; const globalOverlayDemoStyle = css` .demo-overlay { @@ -12,36 +11,17 @@ const globalOverlayDemoStyle = css` } `; -let placement = 'center'; -const togglePlacement = overlayCtrl => { - const placements = [ - 'top-left', - 'top', - 'top-right', - 'right', - 'bottom-left', - 'bottom', - 'bottom-right', - 'left', - 'center', - ]; - placement = placements[(placements.indexOf(placement) + 1) % placements.length]; - // eslint-disable-next-line no-param-reassign - overlayCtrl.overlayContainerPlacementClass = `${overlayCtrl.overlayContainerClass}--${placement}`; -}; - storiesOf('Global Overlay System|Global Overlay', module) .add('Default', () => { - const overlayCtrl = overlays.add( - new GlobalOverlayController({ - contentTemplate: () => html` -
-

Simple overlay

- -
- `, - }), - ); + const overlayCtrl = new OverlayController({ + placementMode: 'global', + contentNode: fixtureSync(html` +
+

Simple overlay

+ +
+ `), + }); return html` + + `; + }) + .add('Option "hidesOnOutsideClick"', () => { + const shadowContent = document.createElement('div'); + shadowContent.attachShadow({ mode: 'open' }); + shadowContent.shadowRoot.appendChild( + fixtureSync(html` +
+ Shadow area +
+ `), ); + const ctrl = new OverlayController({ + placementMode: 'global', + hidesOnOutsideClick: true, + contentNode: fixtureSync(html` +
+

Hides when clicked outside

+ ${shadowContent} + +
+ `), + }); + return html` - - `; - }) - .add('Sync', () => { - const overlayCtrl = overlays.add( - new GlobalOverlayController({ - contentTemplate: ({ title = 'default' } = {}) => html` -
-

${title}

- - - -
- `, - }), - ); - - return html` - - - `; - }) - .add('In web components', () => { - class EditUsernameOverlay extends LionLitElement { - static get properties() { - return { - username: { type: String }, - }; - } - - static get styles() { - return css` - :host { - position: fixed; - left: 20px; - top: 20px; - display: block; - width: 300px; - padding: 24px; - background-color: white; - border: 1px solid blue; - } - - .close-button { - position: absolute; - top: 8px; - right: 8px; - } - `; - } - - render() { - return html` -
- - - - -
- `; - } - - _onUsernameEdited() { - this.dispatchEvent( - new CustomEvent('edit-username-submitted', { - detail: this.$id('usernameInput').value, - }), - ); - } - - _onClose() { - this.dispatchEvent(new CustomEvent('edit-username-closed')); - } - } - if (!customElements.get('edit-username-overlay')) { - customElements.define('edit-username-overlay', EditUsernameOverlay); - } - class MyComponent extends LionLitElement { - static get properties() { - return { - username: { type: String }, - _editingUsername: { type: Boolean }, - }; - } - - constructor() { - super(); - - this.username = 'Steve'; - this._editingUsername = false; - } - - disconnectedCallback() { - super.disconnectedCallback(); - this._editOverlay.hide(); - } - - render() { - return html` -

Your username is: ${this.username}

- - `; - } - - firstUpdated() { - this._editOverlay = overlays.add( - new GlobalOverlayController({ - focusElementAfterHide: this.shadowRoot.querySelector('button'), - contentTemplate: ({ username = 'standard' } = {}) => html` - - - `, - }), - ); - } - - updated() { - this._editOverlay.sync({ - isShown: this._editingUsername, - data: { username: this.username }, - }); - } - - _onEditSubmitted(e) { - this.username = e.detail; - this._editingUsername = false; - } - - _onEditClosed() { - this._editingUsername = false; - } - - _onStartEditUsername() { - this._editingUsername = true; - } - } - if (!customElements.get('my-component')) { - customElements.define('my-component', MyComponent); - } - return html` - - `; }); diff --git a/packages/overlays/stories/local-overlay-placement.stories.js b/packages/overlays/stories/local-overlay-placement.stories.js index 50e119e3b..fdbddf5f6 100644 --- a/packages/overlays/stories/local-overlay-placement.stories.js +++ b/packages/overlays/stories/local-overlay-placement.stories.js @@ -1,7 +1,7 @@ import { storiesOf, html } from '@open-wc/demoing-storybook'; +import { fixtureSync } from '@open-wc/testing-helpers'; import { css } from '@lion/core'; -import { LocalOverlayController } from '../src/LocalOverlayController.js'; -import { overlays } from '../src/overlays.js'; +import { OverlayController } from '../index.js'; let placement = 'top'; const togglePlacement = popupController => { @@ -46,20 +46,16 @@ const popupPlacementDemoStyle = css` storiesOf('Local Overlay System|Local Overlay Placement', module) .addParameters({ options: { selectedPanel: 'storybook/actions/actions-panel' } }) .add('Preferred placement overlay absolute', () => { - let popup; - const invokerNode = document.createElement('button'); - invokerNode.innerHTML = 'UK'; - invokerNode.addEventListener('click', () => popup.toggle()); - - popup = overlays.add( - new LocalOverlayController({ - hidesOnEsc: true, - contentTemplate: () => html` -
United Kingdom
- `, - invokerNode, - }), - ); + const popup = new OverlayController({ + placementMode: 'local', + hidesOnEsc: true, + contentNode: fixtureSync(html` +
United Kingdom
+ `), + invokerNode: fixtureSync(html` + + `), + }); return html`
- ${invokerNode} ${popup.content} + ${popup.invoker}${popup.content}
`; }) .add('Override the popper config', () => { - let popup; - const invokerNode = document.createElement('button'); - invokerNode.innerHTML = 'UK'; - invokerNode.addEventListener('click', () => popup.toggle()); - - popup = overlays.add( - new LocalOverlayController({ - hidesOnEsc: true, - popperConfig: { - placement: 'bottom-start', - positionFixed: true, - modifiers: { - keepTogether: { - enabled: true /* Prevents detachment of content element from reference element */, - }, - preventOverflow: { - enabled: false /* disables shifting/sliding behavior on secondary axis */, - boundariesElement: 'viewport', - padding: 32 /* when enabled, this is the viewport-margin for shifting/sliding */, - }, - flip: { - boundariesElement: 'viewport', - padding: 16 /* viewport-margin for flipping on primary axis */, - }, - offset: { - enabled: true, - offset: `0, 16px` /* horizontal and vertical margin (distance between popper and referenceElement) */, - }, + const popup = new OverlayController({ + placementMode: 'local', + hidesOnEsc: true, + popperConfig: { + placement: 'bottom-start', + positionFixed: true, + modifiers: { + keepTogether: { + enabled: true /* Prevents detachment of content element from reference element */, + }, + preventOverflow: { + enabled: false /* disables shifting/sliding behavior on secondary axis */, + boundariesElement: 'viewport', + padding: 32 /* when enabled, this is the viewport-margin for shifting/sliding */, + }, + flip: { + boundariesElement: 'viewport', + padding: 16 /* viewport-margin for flipping on primary axis */, + }, + offset: { + enabled: true, + offset: `0, 16px` /* horizontal and vertical margin (distance between popper and referenceElement) */, }, }, - contentTemplate: () => - html` -
United Kingdom
- `, - invokerNode, - }), - ); + }, + contentNode: fixtureSync(html` +
United Kingdom
+ `), + invokerNode: fixtureSync(html` + + `), + }); return html`
- In the ${invokerNode}${popup.content} the weather is nice. + In the ${popup.invoker}${popup.content} the weather is nice.
`; }) .add('Change preferred position', () => { - let popup; - const invokerNode = document.createElement('button'); - invokerNode.innerHTML = 'UK'; - invokerNode.addEventListener('click', () => popup.toggle()); + const popup = new OverlayController({ + placementMode: 'local', + hidesOnEsc: true, + hidesOnOutsideClick: true, + popperConfig: { + placement: 'top-end', + }, + contentNode: fixtureSync(html` +
United Kingdom
+ `), + invokerNode: fixtureSync(html` + + `), + }); - popup = overlays.add( - new LocalOverlayController({ - hidesOnEsc: true, - hidesOnOutsideClick: true, - popperConfig: { - placement: 'top-end', - }, - contentTemplate: () => html` -
United Kingdom
- `, - invokerNode, - }), - ); return html`
- In the ${invokerNode}${popup.content} the weather is nice. + In the ${popup.invoker}${popup.content} the weather is nice.
`; }) .add('Single placement parameter', () => { - let popup; - const invokerNode = document.createElement('button'); - invokerNode.innerHTML = 'Click me'; - invokerNode.addEventListener('click', () => popup.toggle()); + const popup = new OverlayController({ + placementMode: 'local', + hidesOnEsc: true, + hidesOnOutsideClick: true, + popperConfig: { + placement: 'bottom', + }, + contentNode: fixtureSync(html` +
+ Supplying placement with a single parameter will assume 'center' for the other. +
+ `), + invokerNode: fixtureSync(html` + + `), + }); - popup = overlays.add( - new LocalOverlayController({ - hidesOnEsc: true, - hidesOnOutsideClick: true, - popperConfig: { - placement: 'bottom', - }, - contentTemplate: () => html` -
- Supplying placement with a single parameter will assume 'center' for the other. -
- `, - invokerNode, - }), - ); return html`
- ${invokerNode}${popup.content} + ${popup.invoker}${popup.content}
`; }) .add('On hover', () => { - let popup; - const invokerNode = document.createElement('button'); - invokerNode.innerHTML = 'UK'; - invokerNode.addEventListener('mouseenter', () => popup.show()); - invokerNode.addEventListener('mouseleave', () => popup.hide()); + const popup = new OverlayController({ + placementMode: 'local', + hidesOnEsc: true, + hidesOnOutsideClick: true, + popperConfig: { + placement: 'bottom', + }, + contentNode: fixtureSync(html` +
United Kingdom
+ `), + invokerNode: fixtureSync(html` + + `), + }); - popup = overlays.add( - new LocalOverlayController({ - hidesOnEsc: true, - hidesOnOutsideClick: true, - popperConfig: { - placement: 'bottom', - }, - contentTemplate: () => - html` -
United Kingdom
- `, - invokerNode, - }), - ); return html`
- In the beautiful ${invokerNode}${popup.content} the weather is nice. + In the beautiful ${popup.invoker}${popup.content} the weather is nice.
`; }) .add('On an input', () => { - let popup; - const invokerNode = document.createElement('input'); - invokerNode.id = 'input'; - invokerNode.type = 'text'; - invokerNode.addEventListener('focusin', () => popup.show()); - invokerNode.addEventListener('focusout', () => popup.hide()); + const popup = new OverlayController({ + placementMode: 'local', + contentNode: fixtureSync(html` +
United Kingdom
+ `), + invokerNode: fixtureSync(html` + popup.show()} + @focusout=${() => popup.hide()} + /> + `), + }); - popup = overlays.add( - new LocalOverlayController({ - contentTemplate: () => html` -
United Kingdom
- `, - invokerNode, - }), - ); return html`
- ${invokerNode}${popup.content} + ${popup.invoker}${popup.content}
`; }) .add('trapsKeyboardFocus', () => { - let popup; - const invokerNode = document.createElement('button'); - invokerNode.innerHTML = 'Click me'; - invokerNode.addEventListener('click', () => popup.toggle()); - - popup = overlays.add( - new LocalOverlayController({ - hidesOnEsc: true, - hidesOnOutsideClick: true, - trapsKeyboardFocus: true, - contentTemplate: () => html` -
- - Anchor -
Tabindex
- -
Content editable
- - -
- `, - invokerNode, - }), - ); - return html` - -
- ${invokerNode}${popup.content} -
- `; - }) - .add('trapsKeyboardFocus with nodes', () => { - const invokerNode = document.createElement('button'); - invokerNode.innerHTML = 'Invoker Button'; - - const contentNode = document.createElement('div'); - contentNode.classList.add('demo-popup'); - const contentButton = document.createElement('button'); - contentButton.innerHTML = 'Content Button'; - const contentInput = document.createElement('input'); - contentNode.appendChild(contentButton); - contentNode.appendChild(contentInput); - - const popup = overlays.add( - new LocalOverlayController({ - hidesOnEsc: true, - hidesOnOutsideClick: true, - trapsKeyboardFocus: true, - contentNode, - invokerNode, - }), - ); - - invokerNode.addEventListener('click', () => { - popup.toggle(); + const popup = new OverlayController({ + placementMode: 'local', + hidesOnEsc: true, + hidesOnOutsideClick: true, + trapsKeyboardFocus: true, + contentNode: fixtureSync(html` +
+ + Anchor +
Tabindex
+ +
Content editable
+ + +
+ `), + invokerNode: fixtureSync(html` + + `), }); + return html`
- ${invokerNode}${popup.content} + ${popup.invoker}${popup.content}
`; }); diff --git a/packages/overlays/stories/modal-dialog.stories.js b/packages/overlays/stories/modal-dialog.stories.js index 0bab1651a..491e9daab 100644 --- a/packages/overlays/stories/modal-dialog.stories.js +++ b/packages/overlays/stories/modal-dialog.stories.js @@ -1,7 +1,7 @@ import { storiesOf, html } from '@open-wc/demoing-storybook'; - +import { fixtureSync } from '@open-wc/testing-helpers'; import { css } from '@lion/core'; -import { overlays, ModalDialogController } from '../index.js'; +import { OverlayController, withModalDialogConfig } from '../index.js'; const modalDialogDemoStyle = css` .demo-overlay { @@ -13,34 +13,32 @@ const modalDialogDemoStyle = css` storiesOf('Global Overlay System|Modal Dialog', module) .add('Default', () => { - const nestedDialogCtrl = overlays.add( - new ModalDialogController({ - contentTemplate: () => html` -
-

Nested modal dialog

- -
- `, - }), - ); + const nestedDialogCtrl = new OverlayController({ + ...withModalDialogConfig(), + contentNode: fixtureSync(html` +
+

Nested modal dialog

+ +
+ `), + }); - const dialogCtrl = overlays.add( - new ModalDialogController({ - contentTemplate: () => html` -
-

Modal dialog

- - -
- `, - }), - ); + const dialogCtrl = new OverlayController({ + ...withModalDialogConfig(), + contentNode: fixtureSync(html` +
+

Modal dialog

+ + +
+ `), + }); return html` + I should be on top +
+ `); + } + if (mode === 'inline') { + contentNode = await fixture(html` +
+ I should be on top +
+ `); + } + return contentNode; + } + + it('sets a z-index to make sure overlay is painted on top of siblings', async () => { + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + contentNode: await createZNode('auto', { mode: 'global' }), + }); + await ctrl.show(); + expect(ctrl.content.style.zIndex).to.equal('1'); + ctrl.updateConfig({ contentNode: await createZNode('auto', { mode: 'inline' }) }); + await ctrl.show(); + expect(ctrl.content.style.zIndex).to.equal('1'); + + ctrl.updateConfig({ contentNode: await createZNode('0', { mode: 'global' }) }); + await ctrl.show(); + expect(ctrl.content.style.zIndex).to.equal('1'); + ctrl.updateConfig({ contentNode: await createZNode('0', { mode: 'inline' }) }); + await ctrl.show(); + expect(ctrl.content.style.zIndex).to.equal('1'); + }); + + it.skip("doesn't set a z-index when contentNode already has >= 1", async () => { + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + contentNode: await createZNode('1', { mode: 'global' }), + }); + await ctrl.show(); + expect(ctrl.content.style.zIndex).to.equal(''); + ctrl.updateConfig({ contentNode: await createZNode('auto', { mode: 'inline' }) }); + await ctrl.show(); + expect(ctrl.content.style.zIndex).to.equal(''); + + ctrl.updateConfig({ contentNode: await createZNode('2', { mode: 'global' }) }); + await ctrl.show(); + expect(ctrl.content.style.zIndex).to.equal(''); + ctrl.updateConfig({ contentNode: await createZNode('2', { mode: 'inline' }) }); + await ctrl.show(); + expect(ctrl.content.style.zIndex).to.equal(''); + }); + + it("doesn't touch the value of .contentNode", async () => { + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + contentNode: await createZNode('auto', { mode: 'global' }), + }); + expect(ctrl.contentNode.style.zIndex).to.equal(''); + }); + }); + + describe('Render target', () => { + it('creates global target for placement mode "global"', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + }); + expect(ctrl._renderTarget).to.equal(overlays.globalRootNode); + }); + + it.skip('creates local target next to sibling for placement mode "local"', async () => { + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + invokerNode: await fixture(html` + + `), + }); + expect(ctrl._renderTarget).to.be.undefined; + expect(ctrl.content).to.equal(ctrl.invokerNode.nextElementSibling); + }); + + it('keeps local target for placement mode "local" when already connected', async () => { + const parentNode = await fixture(html` +
+
Content
+
+ `); + const contentNode = parentNode.querySelector('#content'); + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + contentNode, + }); + expect(ctrl._renderTarget).to.equal(parentNode); + }); + }); + }); + + describe('Node Configuration', () => { + it('accepts an .contentNode to directly set content', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + contentNode: await fixture('

direct node

'), + }); + expect(ctrl.contentNode).to.have.trimmed.text('direct node'); + }); + + it('accepts an .invokerNode to directly set invoker', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + invokerNode: await fixture(''), + }); + expect(ctrl.invokerNode).to.have.trimmed.text('invoke'); + }); + }); + + describe('Feature Configuration', () => { + describe('trapsKeyboardFocus', () => { + it('offers an hasActiveTrapsKeyboardFocus flag', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + trapsKeyboardFocus: true, + }); + expect(ctrl.hasActiveTrapsKeyboardFocus).to.be.false; + + await ctrl.show(); + expect(ctrl.hasActiveTrapsKeyboardFocus).to.be.true; + }); + + it('focuses the overlay on show', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + trapsKeyboardFocus: true, + }); + await ctrl.show(); + expect(ctrl.contentNode).to.equal(document.activeElement); + }); + + it('keeps focus within the overlay e.g. you can not tab out by accident', async () => { + const contentNode = await fixture(html` +
+ `); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + trapsKeyboardFocus: true, + contentNode, + }); + await ctrl.show(); + + const elOutside = await fixture(html` + + `); + const input1 = ctrl.contentNode.querySelectorAll('input')[0]; + const input2 = ctrl.contentNode.querySelectorAll('input')[1]; + + input2.focus(); + // this mimics a tab within the contain-focus system used + const event = new CustomEvent('keydown', { detail: 0, bubbles: true }); + event.keyCode = keyCodes.tab; + window.dispatchEvent(event); + + expect(elOutside).to.not.equal(document.activeElement); + expect(input1).to.equal(document.activeElement); + }); + + it('allows to move the focus outside of the overlay if trapsKeyboardFocus is disabled', async () => { + const contentNode = await fixture(html` +
+ `); + + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + contentNode, + trapsKeyboardFocus: true, + }); + // add element to dom to allow focus + await fixture(html` + ${ctrl.content} + `); + await ctrl.show(); + + const elOutside = await fixture(html` + + `); + const input = ctrl.contentNode.querySelector('input'); + + input.focus(); + simulateTab(); + + expect(elOutside).to.equal(document.activeElement); + }); + + it('keeps focus within overlay with multiple overlays with all traps on true', async () => { + const ctrl0 = new OverlayController({ + ...withGlobalTestConfig(), + trapsKeyboardFocus: true, + }); + + const ctrl1 = new OverlayController({ + ...withGlobalTestConfig(), + trapsKeyboardFocus: true, + }); + + await ctrl0.show(); + await ctrl1.show(); + expect(ctrl0.hasActiveTrapsKeyboardFocus).to.be.false; + expect(ctrl1.hasActiveTrapsKeyboardFocus).to.be.true; + + await ctrl1.hide(); + expect(ctrl0.hasActiveTrapsKeyboardFocus).to.be.true; + expect(ctrl1.hasActiveTrapsKeyboardFocus).to.be.false; + }); + }); + + describe('hidesOnEsc', () => { + it('hides when [escape] is pressed', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + hidesOnEsc: true, + }); + await ctrl.show(); + ctrl.contentNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); + await aTimeout(); + expect(ctrl.isShown).to.be.false; + }); + + it('stays shown when [escape] is pressed on outside element', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + hidesOnEsc: true, + }); + await ctrl.show(); + document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); + expect(ctrl.isShown).to.be.true; + }); + }); + + describe('hidesOnOutsideClick', () => { + it('hides on outside click', async () => { + const contentNode = await fixture('
Content
'); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + hidesOnOutsideClick: true, + contentNode, + }); + await ctrl.show(); + + document.body.click(); + await aTimeout(); + expect(ctrl.isShown).to.be.false; + }); + + it('doesn\'t hide on "inside" click', async () => { + const invokerNode = await fixture(''); + const contentNode = await fixture('
Content
'); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + hidesOnOutsideClick: true, + contentNode, + invokerNode, + }); + await ctrl.show(); + + // Don't hide on invoker click + ctrl.invokerNode.click(); + await aTimeout(); + expect(ctrl.isShown).to.be.true; + + // Don't hide on inside (content) click + ctrl.contentNode.click(); + await aTimeout(); + expect(ctrl.isShown).to.be.true; + + // Important to check if it can be still shown after, because we do some hacks inside + await ctrl.hide(); + expect(ctrl.isShown).to.be.false; + await ctrl.show(); + expect(ctrl.isShown).to.be.true; + }); + + it('doesn\'t hide on "inside sub shadow dom" click', async () => { + const invokerNode = await fixture(''); + const contentNode = await fixture('
Content
'); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + hidesOnOutsideClick: true, + contentNode, + invokerNode, + }); + await ctrl.show(); + + // Works as well when clicked content element lives in shadow dom + const tagString = defineCE( + class extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.shadowRoot.innerHTML = '
'; + } + }, + ); + const tag = unsafeStatic(tagString); + ctrl.updateConfig({ + contentNode: await fixture(html` +
+
Content
+ <${tag}> +
+ `), + }); + await ctrl.show(); + + // Don't hide on inside shadowDom click + ctrl.contentNode + .querySelector(tagString) + .shadowRoot.querySelector('button') + .click(); + + await aTimeout(); + expect(ctrl.isShown).to.be.true; + + // Important to check if it can be still shown after, because we do some hacks inside + await ctrl.hide(); + expect(ctrl.isShown).to.be.false; + await ctrl.show(); + expect(ctrl.isShown).to.be.true; + }); + + it('works with 3rd party code using "event.stopPropagation()" on bubble phase', async () => { + const invokerNode = await fixture('
Invoker
'); + const contentNode = await fixture('
Content
'); + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + hidesOnOutsideClick: true, + contentNode, + invokerNode, + }); + const dom = await fixture(` +
+ +
+ + This element prevents our handlers from reaching the document click handler. + +
+ `); + + await ctrl.show(); + expect(ctrl.isShown).to.equal(true); + + dom.querySelector('third-party-noise').click(); + await aTimeout(); + expect(ctrl.isShown).to.equal(false); + + // Important to check if it can be still shown after, because we do some hacks inside + await ctrl.show(); + expect(ctrl.isShown).to.equal(true); + }); + + it('works with 3rd party code using "event.stopPropagation()" on capture phase', async () => { + const invokerNode = await fixture(html` +
Invoker
+ `); + const contentNode = await fixture('
Content
'); + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + hidesOnOutsideClick: true, + contentNode, + invokerNode, + }); + const dom = await fixture(` +
+ +
+ + This element prevents our handlers from reaching the document click handler. + +
+ `); + + dom.querySelector('third-party-noise').addEventListener( + 'click', + event => { + event.stopPropagation(); + }, + true, + ); + + await ctrl.show(); + expect(ctrl.isShown).to.equal(true); + + dom.querySelector('third-party-noise').click(); + await aTimeout(); + expect(ctrl.isShown).to.equal(false); + + // Important to check if it can be still shown after, because we do some hacks inside + await ctrl.show(); + expect(ctrl.isShown).to.equal(true); + }); + }); + + describe('elementToFocusAfterHide', () => { + it('focuses body when hiding by default', async () => { + const contentNode = await fixture('
'); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + viewportConfig: { + placement: 'top-left', + }, + contentNode, + }); + + await ctrl.show(); + const input = contentNode.querySelector('input'); + input.focus(); + expect(document.activeElement).to.equal(input); + + await ctrl.hide(); + await nextFrame(); // moving focus to body takes time? + expect(document.activeElement).to.equal(document.body); + }); + + it('supports elementToFocusAfterHide option to focus it when hiding', async () => { + const input = await fixture(''); + const contentNode = await fixture('
'); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + elementToFocusAfterHide: input, + contentNode, + }); + + await ctrl.show(); + const textarea = contentNode.querySelector('textarea'); + textarea.focus(); + expect(document.activeElement).to.equal(textarea); + + await ctrl.hide(); + expect(document.activeElement).to.equal(input); + }); + + it('allows to set elementToFocusAfterHide on show', async () => { + const input = await fixture(''); + const contentNode = await fixture('
'); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + viewportConfig: { + placement: 'top-left', + }, + contentNode, + }); + + await ctrl.show(input); + const textarea = contentNode.querySelector('textarea'); + textarea.focus(); + expect(document.activeElement).to.equal(textarea); + + await ctrl.hide(); + expect(document.activeElement).to.equal(input); + }); + }); + + describe('preventsScroll', () => { + it('prevent scrolling the background', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + preventsScroll: true, + }); + + await ctrl.show(); + expect(getComputedStyle(document.body).overflow).to.equal('hidden'); + + await ctrl.hide(); + expect(getComputedStyle(document.body).overflow).to.equal('visible'); + }); + + it('keeps preventing of scrolling when multiple overlays are opened and closed', async () => { + const ctrl0 = new OverlayController({ + ...withGlobalTestConfig(), + preventsScroll: true, + }); + const ctrl1 = new OverlayController({ + ...withGlobalTestConfig(), + preventsScroll: true, + }); + + await ctrl0.show(); + await ctrl1.show(); + await ctrl1.hide(); + expect(getComputedStyle(document.body).overflow).to.equal('hidden'); + }); + }); + + describe('hasBackdrop', () => { + it('has no backdrop by default', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + }); + await ctrl.show(); + expect(ctrl.backdropNode).to.be.undefined; + }); + + it('supports a backdrop option', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + hasBackdrop: false, + }); + await ctrl.show(); + expect(ctrl.backdropNode).to.be.undefined; + await ctrl.hide(); + + const controllerWithBackdrop = new OverlayController({ + ...withGlobalTestConfig(), + hasBackdrop: true, + }); + await controllerWithBackdrop.show(); + expect(controllerWithBackdrop.backdropNode).to.have.class('global-overlays__backdrop'); + }); + + it('reenables the backdrop when shown/hidden/shown', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + hasBackdrop: true, + }); + await ctrl.show(); + expect(ctrl.backdropNode).to.have.class('global-overlays__backdrop'); + await ctrl.hide(); + await ctrl.show(); + expect(ctrl.backdropNode).to.have.class('global-overlays__backdrop'); + }); + + it('adds and stacks backdrops if .hasBackdrop is enabled', async () => { + const ctrl0 = new OverlayController({ + ...withGlobalTestConfig(), + hasBackdrop: true, + }); + await ctrl0.show(); + expect(ctrl0.backdropNode).to.have.class('global-overlays__backdrop'); + + const ctrl1 = new OverlayController({ + ...withGlobalTestConfig(), + hasBackdrop: false, + }); + await ctrl1.show(); + expect(ctrl0.backdropNode).to.have.class('global-overlays__backdrop'); + expect(ctrl1.backdropNode).to.be.undefined; + + const ctrl2 = new OverlayController({ + ...withGlobalTestConfig(), + hasBackdrop: true, + }); + await ctrl2.show(); + + expect(ctrl0.backdropNode).to.have.class('global-overlays__backdrop'); + expect(ctrl1.backdropNode).to.be.undefined; + expect(ctrl2.backdropNode).to.have.class('global-overlays__backdrop'); + }); + }); + + describe('isBlocking', () => { + it('prevents showing of other overlays', async () => { + const ctrl0 = new OverlayController({ + ...withGlobalTestConfig(), + isBlocking: false, + }); + const ctrl1 = new OverlayController({ + ...withGlobalTestConfig(), + isBlocking: false, + }); + const ctrl2 = new OverlayController({ + ...withGlobalTestConfig(), + isBlocking: true, + }); + const ctrl3 = new OverlayController({ + ...withGlobalTestConfig(), + isBlocking: false, + }); + + await ctrl0.show(); + await ctrl1.show(); + await ctrl2.show(); // blocking + expect(ctrl0.content).to.not.be.displayed; + expect(ctrl1.content).to.not.be.displayed; + expect(ctrl2.content).to.be.displayed; + + await ctrl3.show(); + expect(ctrl3.content).to.be.displayed; + + await ctrl2.hide(); + expect(ctrl0.content).to.be.displayed; + expect(ctrl1.content).to.be.displayed; + + await ctrl2.show(); // blocking + expect(ctrl0.content).to.not.be.displayed; + expect(ctrl1.content).to.not.be.displayed; + expect(ctrl2.content).to.be.displayed; + expect(ctrl3.content).to.not.be.displayed; + }); + + it('keeps backdrop status when used in combination with blocking', async () => { + const ctrl0 = new OverlayController({ + ...withGlobalTestConfig(), + isBlocking: false, + hasBackdrop: true, + }); + await ctrl0.show(); + + const ctrl1 = new OverlayController({ + ...withGlobalTestConfig(), + isBlocking: false, + hasBackdrop: true, + }); + await ctrl1.show(); + await ctrl1.hide(); + expect(ctrl0.hasActiveBackdrop).to.be.true; + expect(ctrl1.hasActiveBackdrop).to.be.false; + + await ctrl1.show(); + expect(ctrl0.hasActiveBackdrop).to.be.true; + expect(ctrl1.hasActiveBackdrop).to.be.true; + }); + }); + }); + + describe('Show / Hide / Toggle', () => { + it('has .isShown which defaults to false', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + }); + expect(ctrl.isShown).to.be.false; + }); + + it('has async show() which shows the overlay', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + }); + await ctrl.show(); + expect(ctrl.isShown).to.be.true; + expect(ctrl.show()).to.be.instanceOf(Promise); + }); + + it('has async hide() which hides the overlay', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + }); + + await ctrl.hide(); + expect(ctrl.isShown).to.be.false; + expect(ctrl.hide()).to.be.instanceOf(Promise); + }); + + it('fires "show" event once overlay becomes shown', async () => { + const showSpy = sinon.spy(); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + }); + + ctrl.addEventListener('show', showSpy); + await ctrl.show(); + expect(showSpy.callCount).to.equal(1); + await ctrl.show(); + expect(showSpy.callCount).to.equal(1); + }); + + it('fires "before-show" event right before overlay becomes shown', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + }); + + const eventSpy = sinon.spy(); + + ctrl.addEventListener('before-show', eventSpy); + ctrl.addEventListener('show', eventSpy); + + await ctrl.show(); + expect(eventSpy.getCall(0).args[0].type).to.equal('before-show'); + expect(eventSpy.getCall(1).args[0].type).to.equal('show'); + + expect(eventSpy.callCount).to.equal(2); + await ctrl.show(); + expect(eventSpy.callCount).to.equal(2); + }); + + it('fires "hide" event once overlay becomes hidden', async () => { + const hideSpy = sinon.spy(); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + }); + + ctrl.addEventListener('hide', hideSpy); + await ctrl.show(); + await ctrl.hide(); + expect(hideSpy.callCount).to.equal(1); + await ctrl.hide(); + expect(hideSpy.callCount).to.equal(1); + }); + + it('fires "before-hide" event right before overlay becomes hidden', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + }); + + const eventSpy = sinon.spy(); + + ctrl.addEventListener('before-hide', eventSpy); + ctrl.addEventListener('hide', eventSpy); + + await ctrl.show(); + await ctrl.hide(); + expect(eventSpy.getCall(0).args[0].type).to.equal('before-hide'); + expect(eventSpy.getCall(1).args[0].type).to.equal('hide'); + + expect(eventSpy.callCount).to.equal(2); + await ctrl.hide(); + expect(eventSpy.callCount).to.equal(2); + }); + + it('can be toggled', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + }); + + await ctrl.toggle(); + expect(ctrl.isShown).to.be.true; + + await ctrl.toggle(); + expect(ctrl.isShown).to.be.false; + + await ctrl.toggle(); + expect(ctrl.isShown).to.be.true; + + // check for hide + expect(ctrl.toggle()).to.be.instanceOf(Promise); + // check for show + expect(ctrl.toggle()).to.be.instanceOf(Promise); + }); + + it('makes sure the latest shown overlay is visible', async () => { + const ctrl0 = new OverlayController({ + ...withGlobalTestConfig(), + }); + const ctrl1 = new OverlayController({ + ...withGlobalTestConfig(), + }); + await ctrl0.show(); + const rect = ctrl0.contentNode.getBoundingClientRect(); + const getTopEl = () => document.elementFromPoint(Math.ceil(rect.left), Math.ceil(rect.top)); + + await ctrl0.show(); + expect(getTopEl()).to.equal(ctrl0.contentNode); + + await ctrl1.show(); + expect(getTopEl()).to.equal(ctrl1.contentNode); + + await ctrl0.show(); + expect(getTopEl()).to.equal(ctrl0.contentNode); + }); + }); + + describe('Update Configuration', () => { + it('reinitializes content', async () => { + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + contentNode: await fixture( + html` +
content1
+ `, + ), + }); + await ctrl.show(); // Popper adds inline styles + expect(ctrl.content.style.transform).not.to.be.undefined; + expect(ctrl.contentNode.textContent).to.include('content1'); + + ctrl.updateConfig({ + placementMode: 'local', + contentNode: await fixture( + html` +
content2
+ `, + ), + }); + expect(ctrl.contentNode.textContent).to.include('content2'); + }); + + it('respects the inital config provided to new OverlayController(initialConfig)', async () => { + const contentNode = fixtureSync(html` +
my content
+ `); + + const ctrl = new OverlayController({ + // This is the shared config + placementMode: 'global', + handlesAccesibility: true, + contentNode, + }); + ctrl.updateConfig({ + // This is the added config + placementMode: 'local', + hidesOnEsc: true, + }); + expect(ctrl.placementMode).to.equal('local'); + expect(ctrl.handlesAccesibility).to.equal(true); + expect(ctrl.contentNode).to.equal(contentNode); + }); + }); + + describe('Accessibility', () => { + it('adds and removes [aria-expanded] on invoker', async () => { + const invokerNode = await fixture('
invoker
'); + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + handlesAccessibility: true, + invokerNode, + }); + expect(ctrl.invokerNode.getAttribute('aria-expanded')).to.equal('false'); + await ctrl.show(); + expect(ctrl.invokerNode.getAttribute('aria-expanded')).to.equal('true'); + await ctrl.hide(); + expect(ctrl.invokerNode.getAttribute('aria-expanded')).to.equal('false'); + }); + + it('creates unique id for content', async () => { + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + handlesAccessibility: true, + }); + expect(ctrl.contentNode.id).to.contain(ctrl._contentId); + }); + + it('preserves content id when present', async () => { + const contentNode = await fixture('
content
'); + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + handlesAccessibility: true, + contentNode, + }); + expect(ctrl.contentNode.id).to.contain('preserved'); + }); + + it('adds [role=dialog] on content', async () => { + const invokerNode = await fixture('
invoker
'); + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + handlesAccessibility: true, + invokerNode, + }); + expect(ctrl.contentNode.getAttribute('role')).to.equal('dialog'); + }); + + it('adds attributes inert and aria-hidden="true" on all siblings of rootNode if an overlay is shown', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + trapsKeyboardFocus: true, + }); + + const sibling1 = document.createElement('div'); + const sibling2 = document.createElement('div'); + document.body.insertBefore(sibling1, ctrl.manager.globalRootNode); + document.body.appendChild(sibling2); + + await ctrl.show(); + + [sibling1, sibling2].forEach(sibling => { + expect(sibling).to.have.attribute('aria-hidden', 'true'); + expect(sibling).to.have.attribute('inert'); + }); + expect(ctrl.content.hasAttribute('aria-hidden')).to.be.false; + expect(ctrl.content.hasAttribute('inert')).to.be.false; + + await ctrl.hide(); + + [sibling1, sibling2].forEach(sibling => { + expect(sibling).to.not.have.attribute('aria-hidden'); + expect(sibling).to.not.have.attribute('inert'); + }); + + // cleanup + document.body.removeChild(sibling1); + document.body.removeChild(sibling2); + }); + + /** + * style.userSelect: + * - chrome: 'none' + * - rest: undefined + * + * style.pointerEvents: + * - chrome: auto + * - IE11: visiblePainted + */ + it('disables pointer events and selection on inert elements', async () => { + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + trapsKeyboardFocus: true, + }); + + // show+hide are needed to create a root node + await ctrl.show(); + await ctrl.hide(); + + const sibling1 = document.createElement('div'); + const sibling2 = document.createElement('div'); + document.body.insertBefore(sibling1, ctrl.manager.globalRootNode); + document.body.appendChild(sibling2); + + await ctrl.show(); + + [sibling1, sibling2].forEach(sibling => { + expect(window.getComputedStyle(sibling).userSelect).to.be.oneOf(['none', undefined]); + expect(window.getComputedStyle(sibling).pointerEvents).to.equal('none'); + }); + + expect(window.getComputedStyle(ctrl.contentNode).userSelect).to.be.oneOf(['auto', undefined]); + expect(window.getComputedStyle(ctrl.contentNode).pointerEvents).to.be.oneOf([ + 'auto', + 'visiblePainted', + ]); + + await ctrl.hide(); + + [sibling1, sibling2].forEach(sibling => { + expect(window.getComputedStyle(sibling).userSelect).to.be.oneOf(['auto', undefined]); + expect(window.getComputedStyle(sibling).pointerEvents).to.be.oneOf([ + 'auto', + 'visiblePainted', + ]); + }); + + // cleanup + document.body.removeChild(sibling1); + document.body.removeChild(sibling2); + }); + + describe('Tooltip', () => { + it('adds [aria-describedby] on invoker', async () => { + const invokerNode = await fixture('
invoker
'); + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + handlesAccessibility: true, + isTooltip: true, + invokerNode, + }); + expect(ctrl.invokerNode.getAttribute('aria-describedby')).to.equal(ctrl._contentId); + }); + + it('adds [role=tooltip] on content', async () => { + const invokerNode = await fixture('
invoker
'); + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + handlesAccessibility: true, + isTooltip: true, + invokerNode, + }); + expect(ctrl.contentNode.getAttribute('role')).to.equal('tooltip'); + }); + }); + }); + + describe('Exception handling', () => { + it('throws if no .placementMode gets passed on', async () => { + expect(() => { + new OverlayController({ + contentNode: {}, + }); + }).to.throw('You need to provide a .placementMode ("global"|"local")'); + }); + + it('throws if invalid .placementMode gets passed on', async () => { + expect(() => { + new OverlayController({ + placementMode: 'invalid', + }); + }).to.throw('"invalid" is not a valid .placementMode, use ("global"|"local")'); + }); + + it('throws if no .contentNode gets passed on', async () => { + expect(() => { + new OverlayController({ + placementMode: 'global', + }); + }).to.throw('You need to provide a .contentNode'); + }); + }); +}); diff --git a/packages/overlays/test/OverlaysManager.test.js b/packages/overlays/test/OverlaysManager.test.js index db471accf..b6ef2a017 100644 --- a/packages/overlays/test/OverlaysManager.test.js +++ b/packages/overlays/test/OverlaysManager.test.js @@ -1,17 +1,19 @@ -import { expect, html } from '@open-wc/testing'; - +import { expect, html, fixture } from '@open-wc/testing'; import { OverlaysManager } from '../src/OverlaysManager.js'; -import { BaseOverlayController } from '../src/BaseOverlayController.js'; +import { OverlayController } from '../src/OverlayController.js'; describe('OverlaysManager', () => { let defaultOptions; let mngr; - before(() => { + before(async () => { + const contentNode = await fixture(html` +

my content

+ `); + defaultOptions = { - contentTemplate: () => html` -

my content

- `, + placementMode: 'global', + contentNode, }; }); @@ -43,16 +45,10 @@ describe('OverlaysManager', () => { expect(mngr.constructor.__globalStyleNode).to.be.undefined; }); - it('returns the newly added overlay', () => { - const myController = new BaseOverlayController(defaultOptions); - expect(mngr.add(myController)).to.equal(myController); - }); - it('can add/remove controllers', () => { - const dialog = new BaseOverlayController(defaultOptions); - const popup = new BaseOverlayController(defaultOptions); - mngr.add(dialog); - mngr.add(popup); + // OverlayControllers will add themselves + const dialog = new OverlayController(defaultOptions, mngr); + const popup = new OverlayController(defaultOptions, mngr); expect(mngr.list).to.deep.equal([dialog, popup]); @@ -64,29 +60,25 @@ describe('OverlaysManager', () => { }); it('throws if you try to add the same controller', () => { - const ctrl = new BaseOverlayController(defaultOptions); - mngr.add(ctrl); + const ctrl = new OverlayController(defaultOptions, mngr); expect(() => mngr.add(ctrl)).to.throw('controller instance is already added'); }); it('throws if you try to remove a non existing controller', () => { - const ctrl = new BaseOverlayController(defaultOptions); + // we do not pass one our own manager so it will not be added to it + const ctrl = new OverlayController(defaultOptions); expect(() => mngr.remove(ctrl)).to.throw('could not find controller to remove'); }); it('adds a reference to the manager to the controller', () => { - const dialog = new BaseOverlayController(defaultOptions); - mngr.add(dialog); + const dialog = new OverlayController(defaultOptions, mngr); expect(dialog.manager).to.equal(mngr); }); it('has a .shownList which is ordered based on last shown', async () => { - const dialog = new BaseOverlayController(defaultOptions); - const dialog2 = new BaseOverlayController(defaultOptions); - mngr.add(dialog); - mngr.add(dialog2); - + const dialog = new OverlayController(defaultOptions, mngr); + const dialog2 = new OverlayController(defaultOptions, mngr); expect(mngr.shownList).to.deep.equal([]); await dialog.show(); diff --git a/packages/overlays/test/global-positioning.test.js b/packages/overlays/test/global-positioning.test.js new file mode 100644 index 000000000..975a23b9b --- /dev/null +++ b/packages/overlays/test/global-positioning.test.js @@ -0,0 +1,79 @@ +import { expect, html } from '@open-wc/testing'; +import { fixtureSync } from '@open-wc/testing-helpers'; +import { OverlayController } from '../src/OverlayController.js'; +import { overlays } from '../src/overlays.js'; + +const withDefaultGlobalConfig = () => ({ + placementMode: 'global', + contentNode: fixtureSync(html` +

my content

+ `), +}); + +describe('Global Positioning', () => { + afterEach(() => { + overlays.teardown(); + }); + + describe('Basics', () => { + it('puts ".contentNode" in the body of the page', async () => { + const ctrl = new OverlayController({ + ...withDefaultGlobalConfig(), + }); + await ctrl.show(); + expect(overlays.globalRootNode.children.length).to.equal(1); + expect(overlays.globalRootNode.children[0]).to.have.trimmed.text('my content'); + }); + + // TODO: not implemented atm. Is this needed? If so, it should be covered in a css class + // on a wrapping element, since it may break user styling. + it.skip('sets ".contentNode" styling to display flex by default', async () => { + const ctrl = new OverlayController({ + ...withDefaultGlobalConfig(), + }); + await ctrl.show(); + expect( + window.getComputedStyle(overlays.globalRootNode.children[0]).getPropertyValue('display'), + ).to.equal('flex'); + }); + }); + + describe('viewportConfig', () => { + it('positions the overlay in center by default', async () => { + const ctrl = new OverlayController({ + ...withDefaultGlobalConfig(), + }); + await ctrl.show(); + expect(ctrl.content.classList.contains('global-overlays__overlay-container--center')).to.be + .true; + }); + + it('positions relative to the viewport ', async () => { + const placementMap = [ + 'top-left', + 'top', + 'top-right', + 'right', + 'bottom-right', + 'bottom', + 'bottom-left', + 'left', + 'center', + ]; + placementMap.forEach(async viewportPlacement => { + const ctrl = new OverlayController({ + ...withDefaultGlobalConfig(), + viewportConfig: { + placement: viewportPlacement, + }, + }); + await ctrl.show(); + expect( + ctrl.content.classList.contains( + `global-overlays__overlay-container--${viewportPlacement}`, + ), + ).to.be.true; + }); + }); + }); +}); diff --git a/packages/overlays/test/local-positioning.test.js b/packages/overlays/test/local-positioning.test.js new file mode 100644 index 000000000..3f2623b38 --- /dev/null +++ b/packages/overlays/test/local-positioning.test.js @@ -0,0 +1,352 @@ +import { expect, fixture, html, fixtureSync } from '@open-wc/testing'; +import Popper from 'popper.js/dist/esm/popper.min.js'; +import { OverlayController } from '../src/OverlayController.js'; +import { normalizeTransformStyle } from '../test-helpers/local-positioning-helpers.js'; + +const withLocalTestConfig = () => ({ + placementMode: 'local', + contentNode: fixtureSync(html` +
my content
+ `), + invokerNode: fixtureSync(html` +
Invoker
+ `), +}); + +describe('Local Positioning', () => { + describe('Nodes', () => { + // TODO: check if wanted/needed + it.skip('sets display to inline-block for contentNode by default', async () => { + const invokerNode = await fixture(html` +
Invoker
+ `); + + const node = document.createElement('div'); + node.innerHTML = '
Content
'; + + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + contentNode: node, + invokerNode, + }); + const el = await fixture(html` +
+ ${ctrl.invokerNode} ${ctrl.content} +
+ `); + + await ctrl.show(); + const contentWrapper = el.querySelector('#content').parentElement; + expect(contentWrapper.style.display).to.equal('inline-block'); + }); + }); + + // Please use absolute positions in the tests below to prevent the HTML generated by + // the test runner from interfering. + describe('Positioning', () => { + it('creates a Popper instance on the controller when shown, keeps it when hidden', async () => { + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + }); + await ctrl.show(); + expect(ctrl._popper).to.be.an.instanceof(Popper); + expect(ctrl._popper.modifiers).to.exist; + await ctrl.hide(); + expect(ctrl._popper).to.be.an.instanceof(Popper); + expect(ctrl._popper.modifiers).to.exist; + }); + + it('positions correctly', async () => { + // smoke test for integration of popper + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + contentNode: fixtureSync(html` +
+ `), + invokerNode: fixtureSync(html` +
+ `), + }); + await fixture(html` +
+ ${ctrl.invokerNode}${ctrl.content} +
+ `); + await ctrl.show(); + + expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( + 'translate3d(-30px, -38px, 0px)', + 'translate3d should be -30px [to center = (80 - 20)/2*-1] -38px [to place above = 30 + 8 default padding]', + ); + }); + + it('uses top as the default placement', async () => { + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + contentNode: fixtureSync(html` +
+ `), + invokerNode: fixtureSync(html` +
ctrl.show()}>
+ `), + }); + await fixture(html` +
+ ${ctrl.invokerNode}${ctrl.content} +
+ `); + await ctrl.show(); + expect(ctrl.content.getAttribute('x-placement')).to.equal('top'); + }); + + it('positions to preferred place if placement is set and space is available', async () => { + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + contentNode: fixtureSync(html` +
+ `), + invokerNode: fixtureSync(html` +
ctrl.show()}>
+ `), + popperConfig: { + placement: 'left-start', + }, + }); + await fixture(html` +
+ ${ctrl.invokerNode}${ctrl.content} +
+ `); + + await ctrl.show(); + expect(ctrl.content.getAttribute('x-placement')).to.equal('left-start'); + }); + + it('positions to different place if placement is set and no space is available', async () => { + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + contentNode: fixtureSync(html` +
invoker
+ `), + invokerNode: fixtureSync(html` +
ctrl.show()}> + content +
+ `), + popperConfig: { + placement: 'top-start', + }, + }); + await fixture(html` +
+ ${ctrl.invokerNode}${ctrl.content} +
+ `); + + await ctrl.show(); + expect(ctrl.content.getAttribute('x-placement')).to.equal('bottom-start'); + }); + + it('allows the user to override default Popper modifiers', async () => { + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + contentNode: fixtureSync(html` +
+ `), + invokerNode: fixtureSync(html` +
ctrl.show()}>
+ `), + popperConfig: { + modifiers: { + keepTogether: { + enabled: false, + }, + offset: { + enabled: true, + offset: `0, 16px`, + }, + }, + }, + }); + await fixture(html` +
+ ${ctrl.invokerNode}${ctrl.content} +
+ `); + + await ctrl.show(); + const keepTogether = ctrl._popper.modifiers.find(item => item.name === 'keepTogether'); + const offset = ctrl._popper.modifiers.find(item => item.name === 'offset'); + expect(keepTogether.enabled).to.be.false; + expect(offset.enabled).to.be.true; + expect(offset.offset).to.equal('0, 16px'); + }); + + it('positions the Popper element correctly on show', async () => { + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + contentNode: fixtureSync(html` +
+ `), + invokerNode: fixtureSync(html` +
ctrl.show()}>
+ `), + popperConfig: { + placement: 'top', + }, + }); + await fixture(html` +
+ ${ctrl.invokerNode}${ctrl.content} +
+ `); + await ctrl.show(); + expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( + 'translate3d(0px, -28px, 0px)', + 'Popper positioning values', + ); + + await ctrl.hide(); + await ctrl.show(); + expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( + 'translate3d(0px, -28px, 0px)', + 'Popper positioning values should be identical after hiding and showing', + ); + }); + + // TODO: dom get's removed when hidden so no dom node to update placement + it('updates placement properly even during hidden state', async () => { + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + contentNode: fixtureSync(html` +
+ `), + invokerNode: fixtureSync(html` +
ctrl.show()}>
+ `), + popperConfig: { + placement: 'top', + modifiers: { + offset: { + enabled: true, + offset: '0, 10px', + }, + }, + }, + }); + await fixture(html` +
+ ${ctrl.invokerNode} ${ctrl.content} +
+ `); + + await ctrl.show(); + expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( + 'translate3d(0px, -30px, 0px)', + 'Popper positioning values', + ); + + await ctrl.hide(); + await ctrl.updatePopperConfig({ + modifiers: { + offset: { + enabled: true, + offset: '0, 20px', + }, + }, + }); + await ctrl.show(); + expect(ctrl._popper.options.modifiers.offset.offset).to.equal('0, 20px'); + expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( + 'translate3d(0px, -40px, 0px)', + 'Popper positioning Y value should be 10 less than previous, due to the added extra 10px offset', + ); + }); + + it('updates positioning correctly during shown state when config gets updated', async () => { + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + contentNode: fixtureSync(html` +
+ `), + invokerNode: fixtureSync(html` +
ctrl.show()}> + Invoker +
+ `), + popperConfig: { + placement: 'top', + modifiers: { + offset: { + enabled: true, + offset: '0, 10px', + }, + }, + }, + }); + await fixture(html` +
+ ${ctrl.invokerNode} ${ctrl.content} +
+ `); + + await ctrl.show(); + expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( + 'translate3d(0px, -30px, 0px)', + 'Popper positioning values', + ); + + await ctrl.updatePopperConfig({ + modifiers: { + offset: { + enabled: true, + offset: '0, 20px', + }, + }, + }); + expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( + 'translate3d(0px, -40px, 0px)', + 'Popper positioning Y value should be 10 less than previous, due to the added extra 10px offset', + ); + }); + + it('can set the contentNode minWidth as the invokerNode width', async () => { + const invokerNode = await fixture(html` +
invoker
+ `); + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + inheritsReferenceWidth: 'min', + invokerNode, + }); + await ctrl.show(); + expect(ctrl.content.style.minWidth).to.equal('60px'); + }); + + it('can set the contentNode maxWidth as the invokerNode width', async () => { + const invokerNode = await fixture(html` +
invoker
+ `); + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + inheritsReferenceWidth: 'max', + invokerNode, + }); + await ctrl.show(); + expect(ctrl.content.style.maxWidth).to.equal('60px'); + }); + + it('can set the contentNode width as the invokerNode width', async () => { + const invokerNode = await fixture(html` +
invoker
+ `); + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + inheritsReferenceWidth: 'full', + invokerNode, + }); + await ctrl.show(); + expect(ctrl.content.style.width).to.equal('60px'); + }); + }); +});