feat(overlays): release new overlay system

Co-authored-by: Thomas Allmer <Thomas.Allmer@ing.com>
Co-authored-by: Joren Broekema <Joren.Broekema@ing.com>
Co-authored-by: Mikhail Bashkirov <Mikhail.Bashkirov@ing.com>
This commit is contained in:
Thijs Louisse 2019-10-10 16:41:27 +02:00
parent e161c711e8
commit 364f185ad8
37 changed files with 2898 additions and 4263 deletions

View file

@ -33,3 +33,8 @@ const myCtrl = overlays.add(
// name OverlayTypeController is for illustration purpose only // name OverlayTypeController is for illustration purpose only
// please read below about existing classes for different types of overlays // 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 dont expect a dialog to open (at this moment in time) but expect a sub-menu. Until support for the dialog value has better implementation, its probably best to not use aria-haspopup on the element that opens the modal dialog.

View file

@ -1,11 +1,9 @@
export { DynamicOverlayController } from './src/DynamicOverlayController.js';
export { GlobalOverlayController } from './src/GlobalOverlayController.js';
export { globalOverlaysStyle } from './src/globalOverlaysStyle.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 { overlays } from './src/overlays.js';
export { OverlaysManager } from './src/OverlaysManager.js'; export { OverlaysManager } from './src/OverlaysManager.js';
export { OverlayController } from './src/OverlayController.js';
export { OverlayMixin } from './src/OverlayMixin.js';
// deprecated export { withBottomSheetConfig } from './src/configurations/withBottomSheetConfig.js';
export { BottomSheetController as BottomsheetController } from './src/BottomSheetController.js'; export { withModalDialogConfig } from './src/configurations/withModalDialogConfig.js';
export { withDropdownConfig } from './src/configurations/withDropdownConfig.js';

View file

@ -28,6 +28,7 @@
"src", "src",
"stories", "stories",
"test", "test",
"test-helpers",
"translations", "translations",
"*.js" "*.js"
], ],
@ -38,6 +39,7 @@
"devDependencies": { "devDependencies": {
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.3.4", "@open-wc/testing": "^2.3.4",
"@open-wc/testing-helpers": "^1.0.0",
"sinon": "^7.2.2" "sinon": "^7.2.2"
} }
} }

View file

@ -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;
}
}

View file

@ -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');
}
}

View file

@ -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);
});
}
}

View file

@ -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);
});
}
}

View file

@ -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;
}
}

View file

@ -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,
});
}
}

View file

@ -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);
});
}
}

View file

@ -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 }) {}
},
);

View file

@ -1,6 +1,8 @@
import { unsetSiblingsInert, setSiblingsInert } from './utils/inert-siblings.js'; import { unsetSiblingsInert, setSiblingsInert } from './utils/inert-siblings.js';
import { globalOverlaysStyle } from './globalOverlaysStyle.js'; import { globalOverlaysStyle } from './globalOverlaysStyle.js';
const isIOS = navigator.userAgent.match(/iPhone|iPad|iPod/i);
/** /**
* @typedef {object} OverlayController * @typedef {object} OverlayController
* @param {(object) => TemplateResult} contentTemplate the template function * @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 * You can use .add or .remove to modify it
*/ */
get globalRootNode() { 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 * You can use .add or .remove to modify it
*/ */
get list() { 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 * You can use .show or .hide on individual controllers to modify
*/ */
get shownList() { get shownList() {
@ -68,6 +70,7 @@ export class OverlaysManager {
this.__list = []; this.__list = [];
this.__shownList = []; this.__shownList = [];
this.__siblingsInert = false; this.__siblingsInert = false;
this.__blockingMap = new WeakMap();
} }
/** /**
@ -79,8 +82,6 @@ export class OverlaysManager {
if (this.list.find(ctrl => ctrlToAdd === ctrl)) { if (this.list.find(ctrl => ctrlToAdd === ctrl)) {
throw new Error('controller instance is already added'); throw new Error('controller instance is already added');
} }
// eslint-disable-next-line no-param-reassign
ctrlToAdd.manager = this;
this.list.push(ctrlToAdd); this.list.push(ctrlToAdd);
return ctrlToAdd; return ctrlToAdd;
} }
@ -97,6 +98,14 @@ export class OverlaysManager {
this.hide(ctrlToShow); this.hide(ctrlToShow);
} }
this.__shownList.unshift(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) { hide(ctrlToHide) {
@ -107,6 +116,10 @@ export class OverlaysManager {
} }
teardown() { teardown() {
this.list.forEach(ctrl => {
ctrl.teardown();
});
this.__list = []; this.__list = [];
this.__shownList = []; this.__shownList = [];
this.__siblingsInert = false; this.__siblingsInert = false;
@ -159,4 +172,41 @@ export class OverlaysManager {
this.__siblingsInert = false; 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());
}
}
} }

View file

@ -0,0 +1,11 @@
export const withBottomSheetConfig = () => ({
hasBackdrop: true,
preventsScroll: true,
trapsKeyboardFocus: true,
hidesOnEsc: true,
placementMode: 'global',
viewportConfig: {
placement: 'bottom',
},
handlesAccessibility: true,
});

View file

@ -0,0 +1,15 @@
export const withDropdownConfig = () => ({
placementMode: 'local',
inheritsReferenceWidth: true,
hidesOnOutsideClick: true,
popperConfig: {
placement: 'bottom-start',
modifiers: {
offset: {
enabled: false,
},
},
},
handlesAccessibility: true,
});

View file

@ -0,0 +1,12 @@
export const withModalDialogConfig = () => ({
placementMode: 'global',
viewportConfig: {
placement: 'center',
},
hasBackdrop: true,
preventsScroll: true,
trapsKeyboardFocus: true,
hidesOnEsc: true,
handlesAccessibility: true,
});

View file

@ -6,8 +6,7 @@ export const globalOverlaysStyle = css`
z-index: 200; z-index: 200;
} }
.global-overlays__overlay, .global-overlays__overlay {
.global-overlays__overlay--blocking {
pointer-events: auto; pointer-events: auto;
} }
@ -69,17 +68,7 @@ export const globalOverlaysStyle = css`
width: 100%; width: 100%;
} }
.global-overlays.global-overlays--blocking-opened .global-overlays__overlay { .global-overlays .global-overlays__backdrop {
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 {
content: ''; content: '';
position: fixed; position: fixed;
top: 0; top: 0;
@ -89,6 +78,11 @@ export const globalOverlaysStyle = css`
z-index: -1; z-index: -1;
background-color: #333333; background-color: #333333;
opacity: 0.3; opacity: 0.3;
display: none;
}
.global-overlays .global-overlays__backdrop--visible {
display: block;
} }
.global-overlays .global-overlays__backdrop--fade-in { .global-overlays .global-overlays__backdrop--fade-in {

View file

@ -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
*/

View file

@ -1,7 +1,8 @@
import { storiesOf, html } from '@open-wc/demoing-storybook'; import { storiesOf, html } from '@open-wc/demoing-storybook';
import { css } from '@lion/core'; 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` const bottomSheetDemoStyle = css`
.demo-overlay { .demo-overlay {
@ -12,16 +13,15 @@ const bottomSheetDemoStyle = css`
`; `;
storiesOf('Global Overlay System|BottomSheet', module).add('Default', () => { storiesOf('Global Overlay System|BottomSheet', module).add('Default', () => {
const bottomSheetCtrl = overlays.add( const bottomSheetCtrl = new OverlayController({
new BottomSheetController({ ...withBottomSheetConfig(),
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-overlay"> <div class="demo-overlay">
<p>BottomSheet</p> <p>BottomSheet</p>
<button @click="${() => bottomSheetCtrl.hide()}">Close</button> <button @click="${() => bottomSheetCtrl.hide()}">Close</button>
</div> </div>
`, `),
}), });
);
return html` return html`
<style> <style>

View file

@ -1,13 +1,13 @@
import { storiesOf, html } from '@open-wc/demoing-storybook'; import { storiesOf, html } from '@open-wc/demoing-storybook';
import { fixtureSync } from '@open-wc/testing-helpers';
import { css } from '@lion/core'; import { css } from '@lion/core';
import { import {
GlobalOverlayController, OverlayController,
LocalOverlayController, withBottomSheetConfig,
DynamicOverlayController, withModalDialogConfig,
BottomSheetController, withDropdownConfig,
} from '../index.js'; } from '../index.js';
import { overlays } from '../src/overlays.js';
const dynamicOverlayDemoStyle = css` const dynamicOverlayDemoStyle = css`
.demo-overlay { .demo-overlay {
@ -18,8 +18,8 @@ const dynamicOverlayDemoStyle = css`
} }
.demo-overlay__global--small { .demo-overlay__global--small {
height: 400px; height: 100px;
width: 100%; width: 100px;
background: #eee; background: #eee;
} }
@ -41,211 +41,66 @@ const dynamicOverlayDemoStyle = css`
} }
`; `;
storiesOf('Dynamic Overlay System|Switching Overlays', module) storiesOf('Dynamic Overlay System| Switching Overlays', module).add(
.add('Switch global overlays', () => { 'Switch overlays configuration',
const invokerNode = document.createElement('button'); () => {
invokerNode.innerHTML = 'Invoker Button'; const ctrl = new OverlayController({
...withBottomSheetConfig(),
const ctrl = new DynamicOverlayController(); hidesOnOutsideClick: true,
trapsKeyboardFocus: true,
const global1 = overlays.add( invokerNode: fixtureSync(html`
new BottomSheetController({ <button @click="${() => ctrl.toggle()}">
contentTemplate: () => html` Invoker
<div class="demo-overlay demo-overlay__global demo-overlay__global--small">
<p>I am for small screens < 600px</p>
<button @click="${() => ctrl.hide()}">Close</button>
</div>
`,
invokerNode,
}),
);
ctrl.add(global1);
const global2 = overlays.add(
new GlobalOverlayController({
contentTemplate: () => html`
<div class="demo-overlay demo-overlay__global demo-overlay__global--big">
<p>I am for big screens > 600px</p>
<button @click="${() => ctrl.hide()}">Close</button>
</div>
`,
invokerNode,
}),
);
ctrl.add(global2);
invokerNode.addEventListener('click', event => {
ctrl.show(event.target);
});
function switchOnMediaChange(x) {
if (x.matches) {
// <= 600px
ctrl.nextOpen = global1;
} else {
ctrl.nextOpen = global2;
}
}
const matchSmall = window.matchMedia('(max-width: 600px)');
switchOnMediaChange(matchSmall); // call once manually to init
matchSmall.addListener(switchOnMediaChange);
return html`
<style>
${dynamicOverlayDemoStyle}
</style>
<p>Shows "Bottom Sheet" for small (< 600px) screens and "Dialog" for big (> 600px) screens</p>
${ctrl.invokerNode}
<p>
You can also
<button @click="${() => ctrl.switchTo(ctrl.active === global1 ? global2 : global1)}">
force a switch
</button> </button>
while overlay is hidden. `),
</p> contentNode: fixtureSync(html`
`; <div
}) style="background: #eee;"
.add('Switch local overlays', () => { class="demo-overlay demo-overlay__global demo-overlay__global--small"
const invokerNode = document.createElement('button'); >
invokerNode.innerHTML = 'Invoker Button'; Content
const ctrl = new DynamicOverlayController();
const local1 = new LocalOverlayController({
contentTemplate: () => html`
<div class="demo-overlay demo-overlay__local">
<p>Small screen have a read more</p>
<ul>
<li>Red</li>
<li>Green</li>
</ul>
<a href="">Read more ...</a>
<br />
<button @click="${() => ctrl.hide()}">Close</button> <button @click="${() => ctrl.hide()}">Close</button>
</div> </div>
`, `),
invokerNode,
});
ctrl.add(local1);
const local2 = new LocalOverlayController({
contentTemplate: () => html`
<div class="demo-overlay demo-overlay__local">
<p>Big screens see all</p>
<ul>
<li>Red</li>
<li>Green</li>
<li>Ornage</li>
<li>Blue</li>
<li>Yellow</li>
<li>Pink</li>
</ul>
<button @click="${() => ctrl.hide()}">Close</button>
</div>
`,
invokerNode,
});
ctrl.add(local2);
invokerNode.addEventListener('click', () => {
ctrl.toggle();
}); });
function switchOnMediaChange(x) { const ctrlType = document.createElement('div');
if (x.matches) { function switchTo(type) {
// <= 600px ctrlType.innerHTML = type;
ctrl.nextOpen = local1; switch (type) {
} else { case 'bottom-sheet':
ctrl.nextOpen = local2; 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`
<style>
${dynamicOverlayDemoStyle}
</style>
<p>Shows "read me..." for small (< 600px) screens and all for big (> 600px) screens</p>
${ctrl.invokerNode}${ctrl.content}
<p>
You can also
<button @click="${() => ctrl.switchTo(ctrl.active === local1 ? local2 : local1)}">
force a switch
</button>
while overlay is hidden.
</p>
`;
})
.add('Global & Local', () => {
const invokerNode = document.createElement('button');
invokerNode.innerHTML = 'Invoker Button';
const ctrl = new DynamicOverlayController();
const local = new LocalOverlayController({
contentTemplate: () => html`
<div class="demo-overlay demo-overlay__local">
<p>My Local Overlay</p>
<button @click="${() => ctrl.hide()}">Close</button>
</div>
`,
invokerNode,
});
ctrl.add(local);
const global = overlays.add(
new GlobalOverlayController({
contentTemplate: () => html`
<div class="demo-overlay demo-overlay__global demo-overlay__global--small">
<p>My Global Overlay</p>
<button @click="${() => ctrl.hide()}">Close</button>
</div>
`,
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` return html`
<style> <style>
${dynamicOverlayDemoStyle} ${dynamicOverlayDemoStyle}
</style> </style>
<p> ${ctrl.invoker}
Shows "Buttom Sheet" for small (< 600px) screens and "Dropdown" for big (> 600px) screens
</p>
<p> <button @click="${() => switchTo('modal-dialog')}">
This button is indented to show the local positioning ${ctrl.invokerNode}${ctrl.content} as modal dialog
</p> </button>
<p> <button @click="${() => switchTo('bottom-sheet')}">
You can also as bottom sheet
<button @click="${() => ctrl.switchTo(ctrl.active === global ? local : global)}"> </button>
force a switch
<button @click="${() => switchTo('dropdown')}">
as dropdown
</button> </button>
while overlay is hidden.
</p>
`; `;
}); },
);

View file

@ -1,8 +1,7 @@
import { storiesOf, html } from '@open-wc/demoing-storybook'; import { storiesOf, html } from '@open-wc/demoing-storybook';
import { css, LitElement } from '@lion/core';
import { css } from '@lion/core'; import { fixtureSync } from '@open-wc/testing-helpers';
import { LionLitElement } from '@lion/core/src/LionLitElement.js'; import { OverlayController } from '../index.js';
import { overlays, GlobalOverlayController } from '../index.js';
const globalOverlayDemoStyle = css` const globalOverlayDemoStyle = css`
.demo-overlay { .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) storiesOf('Global Overlay System|Global Overlay', module)
.add('Default', () => { .add('Default', () => {
const overlayCtrl = overlays.add( const overlayCtrl = new OverlayController({
new GlobalOverlayController({ placementMode: 'global',
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-overlay"> <div class="demo-overlay">
<p>Simple overlay</p> <p>Simple overlay</p>
<button @click="${() => overlayCtrl.hide()}">Close</button> <button @click="${() => overlayCtrl.hide()}">Close</button>
</div> </div>
`, `),
}), });
);
return html` return html`
<style> <style>
@ -64,17 +44,16 @@ storiesOf('Global Overlay System|Global Overlay', module)
`; `;
}) })
.add('Option "preventsScroll"', () => { .add('Option "preventsScroll"', () => {
const overlayCtrl = overlays.add( const overlayCtrl = new OverlayController({
new GlobalOverlayController({ placementMode: 'global',
preventsScroll: true, preventsScroll: true,
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-overlay"> <div class="demo-overlay">
<p>Scrolling the body is blocked</p> <p>Scrolling the body is blocked</p>
<button @click="${() => overlayCtrl.hide()}">Close</button> <button @click="${() => overlayCtrl.hide()}">Close</button>
</div> </div>
`, `),
}), });
);
return html` return html`
<style> <style>
@ -95,17 +74,16 @@ storiesOf('Global Overlay System|Global Overlay', module)
`; `;
}) })
.add('Option "hasBackdrop"', () => { .add('Option "hasBackdrop"', () => {
const overlayCtrl = overlays.add( const overlayCtrl = new OverlayController({
new GlobalOverlayController({ placementMode: 'global',
hasBackdrop: true, hasBackdrop: true,
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-overlay"> <div class="demo-overlay">
<p>There is a backdrop</p> <p>There is a backdrop</p>
<button @click="${() => overlayCtrl.hide()}">Close</button> <button @click="${() => overlayCtrl.hide()}">Close</button>
</div> </div>
`, `),
}), });
);
return html` return html`
<style> <style>
@ -121,10 +99,10 @@ storiesOf('Global Overlay System|Global Overlay', module)
`; `;
}) })
.add('Option "trapsKeyboardFocus"', () => { .add('Option "trapsKeyboardFocus"', () => {
const overlayCtrl = overlays.add( const overlayCtrl = new OverlayController({
new GlobalOverlayController({ placementMode: 'global',
trapsKeyboardFocus: true, trapsKeyboardFocus: true,
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-overlay"> <div class="demo-overlay">
<p>Tab key is trapped within the overlay</p> <p>Tab key is trapped within the overlay</p>
@ -139,9 +117,8 @@ storiesOf('Global Overlay System|Global Overlay', module)
</select> </select>
<button @click="${() => overlayCtrl.hide()}">Close</button> <button @click="${() => overlayCtrl.hide()}">Close</button>
</div> </div>
`, `),
}), });
);
return html` return html`
<style> <style>
@ -159,25 +136,24 @@ storiesOf('Global Overlay System|Global Overlay', module)
`; `;
}) })
.add('Option "trapsKeyboardFocus" (multiple)', () => { .add('Option "trapsKeyboardFocus" (multiple)', () => {
const overlayCtrl2 = overlays.add( const overlayCtrl2 = new OverlayController({
new GlobalOverlayController({ placementMode: 'global',
trapsKeyboardFocus: true, trapsKeyboardFocus: true,
viewportConfig: { viewportConfig: {
placement: 'left', placement: 'left',
}, },
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-overlay"> <div class="demo-overlay">
<p>Overlay 2. Tab key is trapped within the overlay</p> <p>Overlay 2. Tab key is trapped within the overlay</p>
<button @click="${() => overlayCtrl2.hide()}">Close</button> <button @click="${() => overlayCtrl2.hide()}">Close</button>
</div> </div>
`, `),
}), });
);
const overlayCtrl1 = overlays.add( const overlayCtrl1 = new OverlayController({
new GlobalOverlayController({ placementMode: 'global',
trapsKeyboardFocus: true, trapsKeyboardFocus: true,
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-overlay"> <div class="demo-overlay">
<p>Overlay 1. Tab key is trapped within the overlay</p> <p>Overlay 1. Tab key is trapped within the overlay</p>
<button <button
@ -189,9 +165,8 @@ storiesOf('Global Overlay System|Global Overlay', module)
</button> </button>
<button @click="${() => overlayCtrl1.hide()}">Close</button> <button @click="${() => overlayCtrl1.hide()}">Close</button>
</div> </div>
`, `),
}), });
);
return html` return html`
<style> <style>
@ -209,24 +184,23 @@ storiesOf('Global Overlay System|Global Overlay', module)
`; `;
}) })
.add('Option "isBlocking"', () => { .add('Option "isBlocking"', () => {
const blockingOverlayCtrl = overlays.add( const blockingOverlayCtrl = new OverlayController({
new GlobalOverlayController({ placementMode: 'global',
isBlocking: true, isBlocking: true,
viewportConfig: { viewportConfig: {
placement: 'left', placement: 'left',
}, },
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-overlay"> <div class="demo-overlay">
<p>Hides other overlays</p> <p>Hides other overlays</p>
<button @click="${() => blockingOverlayCtrl.hide()}">Close</button> <button @click="${() => blockingOverlayCtrl.hide()}">Close</button>
</div> </div>
`, `),
}), });
);
const normalOverlayCtrl = overlays.add( const normalOverlayCtrl = new OverlayController({
new GlobalOverlayController({ placementMode: 'global',
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-overlay"> <div class="demo-overlay">
<p>Normal overlay</p> <p>Normal overlay</p>
<button <button
@ -238,9 +212,8 @@ storiesOf('Global Overlay System|Global Overlay', module)
</button> </button>
<button @click="${() => normalOverlayCtrl.hide()}">Close</button> <button @click="${() => normalOverlayCtrl.hide()}">Close</button>
</div> </div>
`, `),
}), });
);
return html` return html`
<style> <style>
@ -256,192 +229,106 @@ storiesOf('Global Overlay System|Global Overlay', module)
`; `;
}) })
.add('Option "viewportConfig:placement"', () => { .add('Option "viewportConfig:placement"', () => {
const overlayCtrl = overlays.add( const tagName = 'lion-overlay-placement-demo';
new GlobalOverlayController({ if (!customElements.get(tagName)) {
viewportConfig: { customElements.define(
placement: 'center', tagName,
}, class extends LitElement {
hasBackdrop: true,
trapsKeyboardFocus: true,
contentTemplate: () => html`
<div class="demo-overlay">
<p>Overlay placement: ${placement}</p>
<button @click="${() => overlayCtrl.hide()}">Close</button>
</div>
`,
}),
);
return html`
<style>
${globalOverlayDemoStyle}
</style>
<button @click=${() => togglePlacement(overlayCtrl)}>Change placement</button>
<button
@click="${event => overlayCtrl.show(event.target)}"
aria-haspopup="dialog"
aria-expanded="false"
>
Open overlay
</button>
`;
})
.add('Sync', () => {
const overlayCtrl = overlays.add(
new GlobalOverlayController({
contentTemplate: ({ title = 'default' } = {}) => html`
<div class="demo-overlay">
<p>${title}</p>
<label>Edit title:</label>
<input
value="${title}"
@input="${e => overlayCtrl.sync({ isShown: true, data: { title: e.target.value } })}"
/>
<button @click="${() => overlayCtrl.hide()}">Close</button>
</div>
`,
}),
);
return html`
<style>
${globalOverlayDemoStyle}
</style>
<button
@click="${() => overlayCtrl.sync({ isShown: true, data: { title: 'My title' } })}"
aria-haspopup="dialog"
aria-expanded="false"
>
Open overlay
</button>
`;
})
.add('In web components', () => {
class EditUsernameOverlay extends LionLitElement {
static get properties() { static get properties() {
return { return {
username: { type: String }, // controller: { type: Object },
placement: { 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() { render() {
return html` return html`
<div> <p>Overlay placement: ${this.placement}</p>
<button class="close-button" @click="${this._onClose}">X</button> <button @click="${this._togglePlacement}">
<label for="usernameInput">Edit Username</label> Toggle ${this.placement} position
<input id="usernameInput" value="${this.username}" />
<button @click="${this._onUsernameEdited}">
Save
</button> </button>
</div> <button @click="${() => this.dispatchEvent(new CustomEvent('close'))}">Close</button>
`; `;
} }
_onUsernameEdited() { _togglePlacement() {
const options = [
'top',
'top-right',
'right',
'bottom-right',
'bottom',
'bottom-left',
'left',
'top-left',
'center',
];
this.placement = options[(options.indexOf(this.placement) + 1) % options.length];
this.dispatchEvent( this.dispatchEvent(
new CustomEvent('edit-username-submitted', { new CustomEvent('toggle-placement', {
detail: this.$id('usernameInput').value, detail: this.placement,
}), }),
); );
} }
},
_onClose() { );
this.dispatchEvent(new CustomEvent('edit-username-closed'));
} }
} const initialPlacement = 'center';
if (!customElements.get('edit-username-overlay')) { const overlayCtrl = new OverlayController({
customElements.define('edit-username-overlay', EditUsernameOverlay); placementMode: 'global',
} viewportConfig: {
class MyComponent extends LionLitElement { placement: initialPlacement,
static get properties() { },
return { contentNode: fixtureSync(html`
username: { type: String }, <lion-overlay-placement-demo class="demo-overlay"> </lion-overlay-placement-demo>
_editingUsername: { type: Boolean }, `),
}; });
} const element = overlayCtrl.content.querySelector(tagName);
element.placement = initialPlacement;
constructor() { element.addEventListener('toggle-placement', e => {
super(); overlayCtrl.updateConfig({ viewportConfig: { placement: e.detail } });
});
this.username = 'Steve'; element.addEventListener('close', () => {
this._editingUsername = false; overlayCtrl.hide();
} });
disconnectedCallback() {
super.disconnectedCallback();
this._editOverlay.hide();
}
render() {
return html` return html`
<p>Your username is: ${this.username}</p> <style>
<button @click=${this._onStartEditUsername} aria-haspopup="dialog" aria-expanded="false"> ${globalOverlayDemoStyle}
Edit username </style>
<button @click="${e => overlayCtrl.show(e.target)}">
Open overlay
</button> </button>
`; `;
} })
.add('Option "hidesOnOutsideClick"', () => {
firstUpdated() { const shadowContent = document.createElement('div');
this._editOverlay = overlays.add( shadowContent.attachShadow({ mode: 'open' });
new GlobalOverlayController({ shadowContent.shadowRoot.appendChild(
focusElementAfterHide: this.shadowRoot.querySelector('button'), fixtureSync(html`
contentTemplate: ({ username = 'standard' } = {}) => html` <div style="width: 100px; padding: 10px; background: black; color: white;">
<edit-username-overlay Shadow area
username="${username}" </div>
@edit-username-submitted="${e => this._onEditSubmitted(e)}" `),
@edit-username-closed="${() => this._onEditClosed()}"
>
</edit-username-overlay>
`,
}),
); );
}
updated() { const ctrl = new OverlayController({
this._editOverlay.sync({ placementMode: 'global',
isShown: this._editingUsername, hidesOnOutsideClick: true,
data: { username: this.username }, contentNode: fixtureSync(html`
<div class="demo-overlay">
<p>Hides when clicked outside</p>
${shadowContent}
<button @click="${() => ctrl.hide()}">Close</button>
</div>
`),
}); });
}
_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` return html`
<my-component></my-component> <style>
${globalOverlayDemoStyle}
</style>
<button @click="${event => ctrl.show(event.target)}">
Open overlay
</button>
`; `;
}); });

View file

@ -1,7 +1,7 @@
import { storiesOf, html } from '@open-wc/demoing-storybook'; import { storiesOf, html } from '@open-wc/demoing-storybook';
import { fixtureSync } from '@open-wc/testing-helpers';
import { css } from '@lion/core'; import { css } from '@lion/core';
import { LocalOverlayController } from '../src/LocalOverlayController.js'; import { OverlayController } from '../index.js';
import { overlays } from '../src/overlays.js';
let placement = 'top'; let placement = 'top';
const togglePlacement = popupController => { const togglePlacement = popupController => {
@ -46,20 +46,16 @@ const popupPlacementDemoStyle = css`
storiesOf('Local Overlay System|Local Overlay Placement', module) storiesOf('Local Overlay System|Local Overlay Placement', module)
.addParameters({ options: { selectedPanel: 'storybook/actions/actions-panel' } }) .addParameters({ options: { selectedPanel: 'storybook/actions/actions-panel' } })
.add('Preferred placement overlay absolute', () => { .add('Preferred placement overlay absolute', () => {
let popup; const popup = new OverlayController({
const invokerNode = document.createElement('button'); placementMode: 'local',
invokerNode.innerHTML = 'UK';
invokerNode.addEventListener('click', () => popup.toggle());
popup = overlays.add(
new LocalOverlayController({
hidesOnEsc: true, hidesOnEsc: true,
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-popup">United Kingdom</div> <div class="demo-popup">United Kingdom</div>
`, `),
invokerNode, invokerNode: fixtureSync(html`
}), <button @click="${() => popup.toggle()}">UK</button>
); `),
});
return html` return html`
<style> <style>
@ -67,18 +63,13 @@ storiesOf('Local Overlay System|Local Overlay Placement', module)
</style> </style>
<button @click=${() => togglePlacement(popup)}>Toggle placement</button> <button @click=${() => togglePlacement(popup)}>Toggle placement</button>
<div class="demo-box"> <div class="demo-box">
${invokerNode} ${popup.content} ${popup.invoker}${popup.content}
</div> </div>
`; `;
}) })
.add('Override the popper config', () => { .add('Override the popper config', () => {
let popup; const popup = new OverlayController({
const invokerNode = document.createElement('button'); placementMode: 'local',
invokerNode.innerHTML = 'UK';
invokerNode.addEventListener('click', () => popup.toggle());
popup = overlays.add(
new LocalOverlayController({
hidesOnEsc: true, hidesOnEsc: true,
popperConfig: { popperConfig: {
placement: 'bottom-start', placement: 'bottom-start',
@ -102,13 +93,13 @@ storiesOf('Local Overlay System|Local Overlay Placement', module)
}, },
}, },
}, },
contentTemplate: () => contentNode: fixtureSync(html`
html`
<div class="demo-popup">United Kingdom</div> <div class="demo-popup">United Kingdom</div>
`, `),
invokerNode, invokerNode: fixtureSync(html`
}), <button @click="${() => popup.toggle()}">UK</button>
); `),
});
return html` return html`
<style> <style>
@ -120,7 +111,7 @@ storiesOf('Local Overlay System|Local Overlay Placement', module)
</div> </div>
<button @click=${() => togglePlacement(popup)}>Toggle placement</button> <button @click=${() => togglePlacement(popup)}>Toggle placement</button>
<div class="demo-box"> <div class="demo-box">
${invokerNode} ${popup.content} ${popup.invoker} ${popup.content}
</div> </div>
`; `;
}); });
@ -154,7 +145,7 @@ storiesOf('Local Overlay System|Local Overlay Placement', module)
<button @click=${() => popup.hide()}>Close popup</button> <button @click=${() => popup.hide()}>Close popup</button>
</div> </div>
<div class="demo-box"> <div class="demo-box">
${invokerNode} ${popup.content} ${invoker} ${popup.content}
</div> </div>
`; `;
}); */ }); */

View file

@ -1,6 +1,7 @@
import { storiesOf, html } from '@open-wc/demoing-storybook'; import { storiesOf, html } from '@open-wc/demoing-storybook';
import { fixtureSync } from '@open-wc/testing-helpers';
import { css } from '@lion/core'; import { css } from '@lion/core';
import { overlays, LocalOverlayController } from '../index.js'; import { OverlayController } from '../index.js';
const popupDemoStyle = css` const popupDemoStyle = css`
.demo-box { .demo-box {
@ -16,7 +17,6 @@ const popupDemoStyle = css`
.demo-popup { .demo-popup {
display: block; display: block;
max-width: 250px; max-width: 250px;
position: absolute;
background-color: white; background-color: white;
border-radius: 2px; border-radius: 2px;
box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.12), 0 6px 6px 0 rgba(0, 0, 0, 0.24); box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.12), 0 6px 6px 0 rgba(0, 0, 0, 0.24);
@ -26,156 +26,137 @@ const popupDemoStyle = css`
storiesOf('Local Overlay System|Local Overlay', module) storiesOf('Local Overlay System|Local Overlay', module)
.add('Basic', () => { .add('Basic', () => {
let popup; const popup = new OverlayController({
const invokerNode = document.createElement('button'); placementMode: 'local',
invokerNode.innerHTML = 'UK';
invokerNode.addEventListener('click', () => popup.toggle());
popup = overlays.add(
new LocalOverlayController({
hidesOnEsc: true, hidesOnEsc: true,
hidesOnOutsideClick: true, hidesOnOutsideClick: true,
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-popup">United Kingdom</div> <div class="demo-popup">United Kingdom</div>
`, `),
invokerNode, invokerNode: fixtureSync(html`
}), <button @click=${() => popup.toggle()}>UK</button>
); `),
});
return html` return html`
<style> <style>
${popupDemoStyle} ${popupDemoStyle}
</style> </style>
<div class="demo-box"> <div class="demo-box">
In the ${invokerNode}${popup.content} the weather is nice. In the ${popup.invoker}${popup.content} the weather is nice.
</div> </div>
`; `;
}) })
.add('Change preferred position', () => { .add('Change preferred position', () => {
let popup; const popup = new OverlayController({
const invokerNode = document.createElement('button'); placementMode: 'local',
invokerNode.innerHTML = 'UK';
invokerNode.addEventListener('click', () => popup.toggle());
popup = overlays.add(
new LocalOverlayController({
hidesOnEsc: true, hidesOnEsc: true,
hidesOnOutsideClick: true, hidesOnOutsideClick: true,
popperConfig: { popperConfig: {
placement: 'top-end', placement: 'top-end',
}, },
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-popup">United Kingdom</div> <div class="demo-popup">United Kingdom</div>
`, `),
invokerNode, invokerNode: fixtureSync(html`
}), <button @click=${() => popup.toggle()}>UK</button>
); `),
});
return html` return html`
<style> <style>
${popupDemoStyle} ${popupDemoStyle}
</style> </style>
<div class="demo-box"> <div class="demo-box">
In the ${invokerNode}${popup.content} the weather is nice. In the ${popup.invoker}${popup.content} the weather is nice.
</div> </div>
`; `;
}) })
.add('Single placement parameter', () => { .add('Single placement parameter', () => {
let popup; const popup = new OverlayController({
const invokerNode = document.createElement('button'); placementMode: 'local',
invokerNode.innerHTML = 'Click me';
invokerNode.addEventListener('click', () => popup.toggle());
popup = overlays.add(
new LocalOverlayController({
hidesOnEsc: true, hidesOnEsc: true,
hidesOnOutsideClick: true, hidesOnOutsideClick: true,
popperConfig: { popperConfig: {
placement: 'bottom', placement: 'bottom',
}, },
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-popup"> <div class="demo-popup">
Supplying placement with a single parameter will assume 'center' for the other. Supplying placement with a single parameter will assume 'center' for the other.
</div> </div>
`, `),
invokerNode, invokerNode: fixtureSync(html`
}), <button @click=${() => popup.toggle()}>UK</button>
); `),
});
return html` return html`
<style> <style>
${popupDemoStyle} ${popupDemoStyle}
</style> </style>
<div class="demo-box"> <div class="demo-box">
${invokerNode}${popup.content} ${popup.invoker}${popup.content}
</div> </div>
`; `;
}) })
.add('On hover', () => { .add('On hover', () => {
let popup; const popup = new OverlayController({
const invokerNode = document.createElement('button'); placementMode: 'local',
invokerNode.innerHTML = 'UK';
invokerNode.addEventListener('mouseenter', () => popup.show());
invokerNode.addEventListener('mouseleave', () => popup.hide());
popup = overlays.add(
new LocalOverlayController({
hidesOnEsc: true, hidesOnEsc: true,
hidesOnOutsideClick: true, hidesOnOutsideClick: true,
popperConfig: { popperConfig: {
placement: 'bottom', placement: 'bottom',
}, },
contentTemplate: () => contentNode: fixtureSync(html`
html`
<div class="demo-popup">United Kingdom</div> <div class="demo-popup">United Kingdom</div>
`, `),
invokerNode, invokerNode: fixtureSync(html`
}), <button @mouseenter=${() => popup.show()} @mouseleave=${() => popup.hide()}>UK</button>
); `),
});
return html` return html`
<style> <style>
${popupDemoStyle} ${popupDemoStyle}
</style> </style>
<div class="demo-box"> <div class="demo-box">
In the beautiful ${invokerNode}${popup.content} the weather is nice. In the beautiful ${popup.invoker}${popup.content} the weather is nice.
</div> </div>
`; `;
}) })
.add('On an input', () => { .add('On an input', () => {
let popup; const popup = new OverlayController({
const invokerNode = document.createElement('input'); placementMode: 'local',
invokerNode.id = 'input'; contentNode: fixtureSync(html`
invokerNode.type = 'text';
invokerNode.addEventListener('focusin', () => popup.show());
invokerNode.addEventListener('focusout', () => popup.hide());
popup = overlays.add(
new LocalOverlayController({
contentTemplate: () => html`
<div class="demo-popup">United Kingdom</div> <div class="demo-popup">United Kingdom</div>
`, `),
invokerNode, invokerNode: fixtureSync(html`
}), <input
); id="input"
type="text"
@focusin=${() => popup.show()}
@focusout=${() => popup.hide()}
/>
`),
});
return html` return html`
<style> <style>
${popupDemoStyle} ${popupDemoStyle}
</style> </style>
<div class="demo-box"> <div class="demo-box">
<label for="input">Input with a dropdown</label> <label for="input">Input with a dropdown</label>
${invokerNode}${popup.content} ${popup.invoker}${popup.content}
</div> </div>
`; `;
}) })
.add('trapsKeyboardFocus', () => { .add('trapsKeyboardFocus', () => {
let popup; const popup = new OverlayController({
const invokerNode = document.createElement('button'); placementMode: 'local',
invokerNode.innerHTML = 'Click me';
invokerNode.addEventListener('click', () => popup.toggle());
popup = overlays.add(
new LocalOverlayController({
hidesOnEsc: true, hidesOnEsc: true,
hidesOnOutsideClick: true, hidesOnOutsideClick: true,
trapsKeyboardFocus: true, trapsKeyboardFocus: true,
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-popup"> <div class="demo-popup">
<button id="el1">Button</button> <button id="el1">Button</button>
<a id="el2" href="#">Anchor</a> <a id="el2" href="#">Anchor</a>
@ -187,50 +168,18 @@ storiesOf('Local Overlay System|Local Overlay', module)
<option>1</option> <option>1</option>
</select> </select>
</div> </div>
`, `),
invokerNode, invokerNode: fixtureSync(html`
}), <button @click=${() => popup.toggle()}>UK</button>
); `),
});
return html` return html`
<style> <style>
${popupDemoStyle} ${popupDemoStyle}
</style> </style>
<div class="demo-box"> <div class="demo-box">
${invokerNode}${popup.content} ${popup.invoker}${popup.content}
</div>
`;
})
.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();
});
return html`
<style>
${popupDemoStyle}
</style>
<div class="demo-box">
${invokerNode}${popup.content}
</div> </div>
`; `;
}); });

View file

@ -1,7 +1,7 @@
import { storiesOf, html } from '@open-wc/demoing-storybook'; import { storiesOf, html } from '@open-wc/demoing-storybook';
import { fixtureSync } from '@open-wc/testing-helpers';
import { css } from '@lion/core'; import { css } from '@lion/core';
import { overlays, ModalDialogController } from '../index.js'; import { OverlayController, withModalDialogConfig } from '../index.js';
const modalDialogDemoStyle = css` const modalDialogDemoStyle = css`
.demo-overlay { .demo-overlay {
@ -13,20 +13,19 @@ const modalDialogDemoStyle = css`
storiesOf('Global Overlay System|Modal Dialog', module) storiesOf('Global Overlay System|Modal Dialog', module)
.add('Default', () => { .add('Default', () => {
const nestedDialogCtrl = overlays.add( const nestedDialogCtrl = new OverlayController({
new ModalDialogController({ ...withModalDialogConfig(),
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-overlay"> <div class="demo-overlay" style="margin-top: -100px;">
<p>Nested modal dialog</p> <p>Nested modal dialog</p>
<button @click="${() => nestedDialogCtrl.hide()}">Close</button> <button @click="${() => nestedDialogCtrl.hide()}">Close</button>
</div> </div>
`, `),
}), });
);
const dialogCtrl = overlays.add( const dialogCtrl = new OverlayController({
new ModalDialogController({ ...withModalDialogConfig(),
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-overlay"> <div class="demo-overlay">
<p>Modal dialog</p> <p>Modal dialog</p>
<button @click="${() => dialogCtrl.hide()}">Close</button> <button @click="${() => dialogCtrl.hide()}">Close</button>
@ -38,9 +37,8 @@ storiesOf('Global Overlay System|Modal Dialog', module)
Open nested dialog Open nested dialog
</button> </button>
</div> </div>
`, `),
}), });
);
return html` return html`
<style> <style>
@ -63,21 +61,23 @@ storiesOf('Global Overlay System|Modal Dialog', module)
`; `;
}) })
.add('Option "isBlocking"', () => { .add('Option "isBlocking"', () => {
const blockingDialogCtrl = overlays.add( const blockingDialogCtrl = new OverlayController({
new ModalDialogController({ ...withModalDialogConfig(),
isBlocking: true, isBlocking: true,
contentTemplate: () => html` viewportConfig: {
placement: 'top',
},
contentNode: fixtureSync(html`
<div class="demo-overlay demo-overlay--2"> <div class="demo-overlay demo-overlay--2">
<p>Hides other dialogs</p> <p>Hides other dialogs</p>
<button @click="${() => blockingDialogCtrl.hide()}">Close</button> <button @click="${() => blockingDialogCtrl.hide()}">Close</button>
</div> </div>
`, `),
}), });
);
const normalDialogCtrl = overlays.add( const normalDialogCtrl = new OverlayController({
new ModalDialogController({ ...withModalDialogConfig(),
contentTemplate: () => html` contentNode: fixtureSync(html`
<div class="demo-overlay"> <div class="demo-overlay">
<p>Normal dialog</p> <p>Normal dialog</p>
<button <button
@ -89,9 +89,8 @@ storiesOf('Global Overlay System|Modal Dialog', module)
</button> </button>
<button @click="${() => normalDialogCtrl.hide()}">Close</button> <button @click="${() => normalDialogCtrl.hide()}">Close</button>
</div> </div>
`, `),
}), });
);
return html` return html`
<style> <style>

View file

@ -0,0 +1,16 @@
/**
* @desc Compensates for browsers that use floats in output
* - from: 'transform3d(12.25px, 6.75px, 0px)'
* - to: 'transform3d(12px, 7px, 0px)'
* @param {string} cssValue
*/
export function normalizeTransformStyle(cssValue) {
// eslint-disable-next-line no-unused-vars
const [_, transformType, positionPart] = cssValue.match(/(.*)\((.*?)\)/);
const normalizedNumbers = positionPart
.split(',')
.map(p => Math.round(Number(p.replace('px', ''))));
return `${transformType}(${normalizedNumbers
.map((n, i) => `${n}px${normalizedNumbers.length - 1 === i ? '' : ', '}`)
.join('')})`;
}

View file

@ -1,353 +0,0 @@
import { expect, html, fixture } from '@open-wc/testing';
import '@lion/core/test-helpers/keyboardEventShimIE.js';
import sinon from 'sinon';
import { keyCodes } from '../src/utils/key-codes.js';
import { simulateTab } from '../src/utils/simulate-tab.js';
export const runBaseOverlaySuite = createCtrlFn => {
describe('shown', () => {
it('has .isShown which defaults to false', () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>my content</p>
`,
});
expect(ctrl.isShown).to.be.false;
});
it('has async show() which shows the overlay', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>my content</p>
`,
});
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 = createCtrlFn({
contentTemplate: () => html`
<p>my content</p>
`,
});
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 = createCtrlFn({
contentTemplate: () => html`
<p>my content</p>
`,
});
ctrl.addEventListener('show', showSpy);
await ctrl.show();
expect(showSpy.callCount).to.equal(1);
await ctrl.show();
expect(showSpy.callCount).to.equal(1);
});
it('fires "hide" event once overlay becomes hidden', async () => {
const hideSpy = sinon.spy();
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>my content</p>
`,
});
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);
});
});
describe('.contentTemplate', () => {
it('has .content<Node> as a wrapper for a render target', () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>my content</p>
`,
});
expect(ctrl.content.tagName).to.equal('DIV');
});
it('throws if trying to assign a non function value to .contentTemplate', () => {
expect(() =>
createCtrlFn({
contentTemplate: 'foo',
}),
).to.throw('.contentTemplate needs to be a function');
});
it('has .contentTemplate<Function> to render into .content', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>my content</p>
`,
});
await ctrl.show();
expect(ctrl.content).to.have.trimmed.text('my content');
});
it('throws if .contentTemplate does not return a single child node', async () => {
expect(() => {
createCtrlFn({
contentTemplate: () => html``,
});
}).to.throw('The .contentTemplate needs to always return exactly one child node');
expect(() => {
createCtrlFn({
contentTemplate: () => html`
<p>one</p>
<p>two</p>
`,
});
}).to.throw('The .contentTemplate needs to always return exactly one child node');
});
it('allows to change the .contentTemplate<Function>', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<div><p>my content</p></div>
`,
});
await ctrl.show();
expect(ctrl.contentNode).to.have.trimmed.text('my content');
ctrl.contentTemplate = () => html`
<div>
<p>new content</p>
<p>my adjusted content</p>
</div>
`;
expect(ctrl.contentNode).lightDom.to.equal(`
<p>new content</p>
<p>my adjusted content</p>
`);
});
it('has .contentData which triggers a updates of the overlay content', async () => {
const ctrl = createCtrlFn({
contentTemplate: ({ username = 'default user' } = {}) => html`
<p>my content - ${username}</p>
`,
});
await ctrl.show();
expect(ctrl.content).to.have.trimmed.text('my content - default user');
ctrl.contentData = { username: 'foo user' };
expect(ctrl.content).to.have.trimmed.text('my content - foo user');
});
});
describe('.contentNode', () => {
it('accepts an .contentNode<Node> to directly set content', async () => {
const ctrl = createCtrlFn({
contentNode: await fixture('<p>direct node</p>'),
});
expect(ctrl.content).to.have.trimmed.text('direct node');
});
it('throws if .contentData gets used without a .contentTemplate', async () => {
const ctrl = createCtrlFn({
contentNode: await fixture('<p>direct node</p>'),
});
expect(() => {
ctrl.contentData = {};
}).to.throw('.contentData can only be used if there is a .contentTemplate');
});
});
describe('_showHideMode="dom" (auto selected with .contentTemplate)', () => {
it('removes dom content on hide', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>my content</p>
`,
});
await ctrl.show();
expect(ctrl.content).to.have.trimmed.text('my content');
await ctrl.hide();
expect(ctrl.content).to.be.empty;
});
});
describe('_showHideMode="css" (auto selected with .contentNode)', () => {
// do we even want to support contentTemplate?
it.skip('hides .contentNode from a .contentTemplate via css on hide', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>direct node</p>
`,
});
ctrl.__showHideMode = 'css';
await ctrl.show();
expect(ctrl.contentNode).to.be.displayed;
await ctrl.hide();
expect(ctrl.contentNode).not.to.be.displayed;
await ctrl.show();
expect(ctrl.contentNode).to.be.displayed;
});
it.skip('does not put a style display on .content when using a .contentTemplate', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>direct node</p>
`,
});
ctrl.__showHideMode = 'css';
await ctrl.show();
expect(ctrl.content.style.display).to.be.empty;
await ctrl.hide();
expect(ctrl.content.style.display).to.be.empty;
await ctrl.show();
expect(ctrl.content.style.display).to.be.empty;
});
});
describe('setup', () => {
it('throws if .contentTemplate and .contentNode get passed on', async () => {
const node = await fixture('<p>direct node</p>');
expect(() => {
createCtrlFn({
contentTemplate: () => '',
contentNode: node,
});
}).to.throw('You can only provide a .contentTemplate or a .contentNode but not both');
});
it('throws if neither .contentTemplate or .contentNode get passed on', async () => {
expect(() => {
createCtrlFn();
}).to.throw('You need to provide a .contentTemplate or a .contentNode');
});
});
describe('invoker', () => {
// same as content just with invoker
});
describe('switching', () => {
it('has a switchOut/In function', () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>my content</p>
`,
});
expect(ctrl.switchIn).to.be.a('function');
expect(ctrl.switchOut).to.be.a('function');
});
});
describe('trapsKeyboardFocus (for a11y)', () => {
it('focuses the overlay on show', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>Content</p>
`,
});
// add element to dom to allow focus
await fixture(html`
${ctrl.content}
`);
await ctrl.show();
ctrl.enableTrapsKeyboardFocus();
expect(ctrl.contentNode).to.equal(document.activeElement);
});
it('keeps focus within the overlay e.g. you can not tab out by accident', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<div><input /><input /></div>
`,
});
// add element to dom to allow focus
await fixture(html`
${ctrl.content}
`);
await ctrl.show();
ctrl.enableTrapsKeyboardFocus();
const elOutside = await fixture(html`
<button>click me</button>
`);
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 ctrl = createCtrlFn({
contentTemplate: () => html`
<div><input /></div>
`,
});
// add element to dom to allow focus
await fixture(html`
${ctrl.content}
`);
await ctrl.show();
ctrl.enableTrapsKeyboardFocus();
const elOutside = await fixture(html`
<input />
`);
const input = ctrl.contentNode.querySelector('input');
input.focus();
simulateTab();
expect(elOutside).to.equal(document.activeElement);
});
});
describe('hidesOnEsc', () => {
it('hides when [escape] is pressed', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>Content</p>
`,
});
await ctrl.show();
ctrl.enableHidesOnEsc();
ctrl.contentNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
expect(ctrl.isShown).to.be.false;
});
it('stays shown when [escape] is pressed on outside element', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>Content</p>
`,
});
await ctrl.show();
ctrl.enableHidesOnEsc();
document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
expect(ctrl.isShown).to.be.true;
});
});
};

View file

@ -1,6 +0,0 @@
import { runBaseOverlaySuite } from '../test-suites/BaseOverlayController.suite.js';
import { BaseOverlayController } from '../src/BaseOverlayController.js';
describe('BaseOverlayController', () => {
runBaseOverlaySuite((...args) => new BaseOverlayController(...args));
});

View file

@ -1,32 +0,0 @@
import { expect, html } from '@open-wc/testing';
import { GlobalOverlayController } from '../src/GlobalOverlayController.js';
import { BottomSheetController } from '../src/BottomSheetController.js';
describe('BottomSheetController', () => {
let defaultOptions;
before(() => {
defaultOptions = {
contentTemplate: () => html`
<p>my content</p>
`,
};
});
it('extends GlobalOverlayController', () => {
expect(new BottomSheetController(defaultOptions)).to.be.instanceof(GlobalOverlayController);
});
it('has correct defaults', () => {
const controller = new BottomSheetController(defaultOptions);
expect(controller.hasBackdrop).to.equal(true);
expect(controller.isBlocking).to.equal(false);
expect(controller.preventsScroll).to.equal(true);
expect(controller.trapsKeyboardFocus).to.equal(true);
expect(controller.hidesOnEsc).to.equal(true);
expect(controller.overlayContainerPlacementClass).to.equal(
'global-overlays__overlay-container--bottom',
);
});
});

View file

@ -1,234 +0,0 @@
import { expect, html, fixture } from '@open-wc/testing';
import { DynamicOverlayController } from '../src/DynamicOverlayController.js';
import { GlobalOverlayController } from '../src/GlobalOverlayController.js';
import { LocalOverlayController } from '../src/LocalOverlayController.js';
import { overlays } from '../src/overlays.js';
function expectGlobalShown(ctrl) {
const allOverlays = Array.from(document.body.querySelectorAll('.global-overlays__overlay'));
expect(allOverlays).to.contain(ctrl.contentNode);
expect(ctrl.contentNode).dom.to.equal('<p>Content</p>', { ignoreAttributes: ['class'] });
}
function expectDomHidden(ctrl) {
const allOverlays = Array.from(document.body.querySelectorAll('.global-overlays__overlay'));
expect(allOverlays).to.not.contain(ctrl.contentNode);
}
function expectLocalShown(ctrl) {
expect(ctrl.contentNode).dom.to.equal('<p>Content</p>', {
ignoreAttributes: ['x-placement', 'style'],
});
expect(ctrl.contentNode).to.be.displayed;
}
function expectCssHidden(ctrl) {
expect(ctrl.contentNode).dom.to.equal('<p>Content</p>', {
ignoreAttributes: ['style', 'x-placement'],
});
expect(ctrl.contentNode).to.not.be.displayed;
}
function expectToBeHidden(what) {
if (what._showHideMode === 'css') {
expectCssHidden(what);
} else {
expectDomHidden(what);
}
}
function expectToBeShown(what) {
if (what instanceof GlobalOverlayController) {
expectGlobalShown(what);
} else {
expectLocalShown(what);
}
}
async function canSwitchBetween(from, to) {
const ctrl = new DynamicOverlayController();
ctrl.add(from);
ctrl.add(to);
// setup: we show/hide to make sure everything is nicely rendered
await from.show();
await from.hide();
await to.show();
await to.hide();
expect(from.isShown).to.be.false;
expect(to.isShown).to.be.false;
expectToBeHidden(from);
expectToBeHidden(to);
ctrl.switchTo(to);
await ctrl.show();
expect(from.isShown).to.be.false;
expect(to.isShown).to.be.true;
expectToBeHidden(from);
expectToBeShown(to);
await ctrl.hide();
ctrl.switchTo(from);
await ctrl.show();
expect(from.isShown).to.be.true;
expect(to.isShown).to.be.false;
expectToBeShown(from);
expectToBeHidden(to);
}
describe('Dynamic Global and Local Overlay Controller switching', () => {
describe('.contentTemplate switches', () => {
let globalWithTemplate;
let globalWithTemplate1;
let localWithTemplate;
let localWithTemplate1;
beforeEach(async () => {
const invokerNode = await fixture('<button>Invoker</button>');
globalWithTemplate = overlays.add(
new GlobalOverlayController({
contentTemplate: () => html`
<p>Content</p>
`,
}),
);
globalWithTemplate1 = overlays.add(
new GlobalOverlayController({
contentTemplate: () => html`
<p>Content</p>
`,
}),
);
localWithTemplate = new LocalOverlayController({
contentTemplate: () => html`
<p>Content</p>
`,
invokerNode,
});
localWithTemplate1 = new LocalOverlayController({
contentTemplate: () => html`
<p>Content</p>
`,
invokerNode,
});
});
afterEach(() => {
overlays.teardown();
});
it(`can switch from localWithTemplate to globalWithTemplate and back`, async () => {
await canSwitchBetween(localWithTemplate, globalWithTemplate);
});
it(`can switch from localWithTemplate to localWithTemplate1 and back`, async () => {
await canSwitchBetween(localWithTemplate, localWithTemplate1);
});
it(`can switch from globalWithTemplate to localWithTemplate and back`, async () => {
await canSwitchBetween(globalWithTemplate, localWithTemplate);
});
it(`can switch from globalWithTemplate to globalWithTemplate1 and back`, async () => {
await canSwitchBetween(globalWithTemplate, globalWithTemplate1);
});
});
// do we want to support this?
describe.skip('.contentNode switches', () => {
let globalWithNodes;
let globalWithNodes1;
let localWithNodes;
let localWithNodes1;
beforeEach(async () => {
const invokerNode = await fixture('<button>Invoker</button>');
const contentNode = await fixture(`<p>Content</p>`);
globalWithNodes = new GlobalOverlayController({
contentNode,
});
globalWithNodes1 = new GlobalOverlayController({
contentNode,
});
localWithNodes = new LocalOverlayController({
contentNode,
invokerNode,
});
localWithNodes1 = new LocalOverlayController({
contentNode,
invokerNode,
});
});
afterEach(() => {
overlays.teardown();
});
it(`can switch from localWithNodes to globalWithNodes and back`, async () => {
await canSwitchBetween(localWithNodes, globalWithNodes);
});
it(`can switch from localWithNodes to localWithNodes1 and back`, async () => {
await canSwitchBetween(localWithNodes, localWithNodes1);
});
it(`can switch from globalWithNodes to localWithNodes and back`, async () => {
await canSwitchBetween(globalWithNodes, localWithNodes);
});
it(`can switch from globalWithNodes to globalWithNodes1 and back`, async () => {
await canSwitchBetween(globalWithNodes, globalWithNodes1);
});
});
// do we want to support this?
describe.skip('.contentTemplate/.contentNode switches', () => {
let globalWithTemplate;
let localWithTemplate;
let globalWithNodes;
let localWithNodes;
beforeEach(async () => {
const invokerNode = await fixture('<button>Invoker</button>');
const contentNode = await fixture(`<p>Content</p>`);
globalWithTemplate = new GlobalOverlayController({
contentTemplate: () => html`
<p>Content</p>
`,
});
localWithTemplate = new LocalOverlayController({
contentTemplate: () => html`
<p>Content</p>
`,
invokerNode,
});
globalWithNodes = new GlobalOverlayController({
contentNode,
});
localWithNodes = new LocalOverlayController({
contentNode,
invokerNode,
});
});
afterEach(() => {
overlays.teardown();
});
it(`can switch from localWithNodes to globalWithTemplate and back`, async () => {
await canSwitchBetween(localWithNodes, globalWithTemplate);
});
it(`can switch from localWithTemplate to globalWithNodes and back`, async () => {
await canSwitchBetween(localWithTemplate, globalWithNodes);
});
it(`can switch from globalWithTemplate to localWithNodes and back`, async () => {
await canSwitchBetween(globalWithTemplate, localWithNodes);
});
it(`can switch from globalWithNodes to localWithTemplate and back`, async () => {
await canSwitchBetween(globalWithNodes, localWithTemplate);
});
});
});

View file

@ -1,166 +0,0 @@
import { expect, html } from '@open-wc/testing';
import sinon from 'sinon';
import { DynamicOverlayController } from '../src/DynamicOverlayController.js';
import { BaseOverlayController } from '../src/BaseOverlayController.js';
describe('DynamicOverlayController', () => {
class FakeLocalCtrl extends BaseOverlayController {}
class FakeGlobalCtrl extends BaseOverlayController {}
const defaultOptions = {
contentTemplate: () => html`
<p>my content</p>
`,
};
it('can add/remove controllers', () => {
const ctrl = new DynamicOverlayController();
const global = new FakeGlobalCtrl(defaultOptions);
const local = new FakeLocalCtrl(defaultOptions);
const local2 = new FakeLocalCtrl(defaultOptions);
ctrl.add(global);
ctrl.add(local);
ctrl.add(local2);
expect(ctrl.list).to.deep.equal([global, local, local2]);
ctrl.remove(local2);
expect(ctrl.list).to.deep.equal([global, local]);
ctrl.remove(local);
expect(ctrl.list).to.deep.equal([global]);
});
it('throws if you try to add the same controller twice', () => {
const ctrl = new DynamicOverlayController();
const global = new FakeGlobalCtrl(defaultOptions);
ctrl.add(global);
expect(() => ctrl.add(global)).to.throw('controller instance is already added');
});
it('will set the first added controller as active', () => {
const ctrl = new DynamicOverlayController();
expect(ctrl.active).to.be.undefined;
const global = new FakeGlobalCtrl(defaultOptions);
ctrl.add(global);
expect(ctrl.active).to.equal(global);
});
it('throws if you try to remove a non existing controller', () => {
const ctrl = new DynamicOverlayController();
const global = new BaseOverlayController(defaultOptions);
expect(() => ctrl.remove(global)).to.throw('could not find controller to remove');
});
it('will throw if you try to remove the active controller', () => {
const ctrl = new DynamicOverlayController();
const global = new FakeGlobalCtrl(defaultOptions);
ctrl.add(global);
expect(() => ctrl.remove(global)).to.throw(
'You can not remove the active controller. Please switch first to a different controller via ctrl.switchTo()',
);
});
it('can switch the active controller', () => {
const ctrl = new DynamicOverlayController();
const global = new FakeGlobalCtrl(defaultOptions);
const local = new FakeLocalCtrl(defaultOptions);
ctrl.add(global);
ctrl.add(local);
expect(ctrl.active).to.equal(global);
ctrl.switchTo(local);
expect(ctrl.active).to.equal(local);
ctrl.switchTo(global);
expect(ctrl.active).to.equal(global);
});
it('will call the active controllers show/hide when using .show() / .hide()', async () => {
const ctrl = new DynamicOverlayController();
const global = new FakeGlobalCtrl(defaultOptions);
ctrl.add(global);
const showSpy = sinon.spy(global, 'show');
const hideSpy = sinon.spy(global, 'hide');
await ctrl.show();
expect(showSpy).to.has.callCount(1);
await ctrl.hide();
expect(hideSpy).to.has.callCount(1);
});
it('will throw when trying to switch while overlay is shown', async () => {
const ctrl = new DynamicOverlayController();
const global = new FakeGlobalCtrl(defaultOptions);
const local = new FakeLocalCtrl(defaultOptions);
ctrl.add(global);
ctrl.add(local);
await ctrl.show();
expect(() => {
ctrl.switchTo(local);
}).to.throw('You can not switch overlays while being shown');
});
it('will call switchIn/Out functions of controllers', () => {
const ctrl = new DynamicOverlayController();
const global = new FakeGlobalCtrl(defaultOptions);
const local = new FakeLocalCtrl(defaultOptions);
ctrl.add(global);
ctrl.add(local);
const globalOutSpy = sinon.spy(global, 'switchOut');
const globalInSpy = sinon.spy(global, 'switchIn');
const localOutSpy = sinon.spy(local, 'switchOut');
const localInSpy = sinon.spy(local, 'switchIn');
ctrl.switchTo(local);
expect(globalOutSpy).to.have.callCount(1);
expect(localInSpy).to.have.callCount(1);
ctrl.switchTo(global);
expect(globalInSpy).to.have.callCount(1);
expect(localOutSpy).to.have.callCount(1);
// sanity check that wrong functions are not called
expect(globalOutSpy).to.have.callCount(1);
expect(localInSpy).to.have.callCount(1);
});
describe('API abstraction for active overlay controller', () => {
describe('Events', () => {
it('delegates "show/hide" event', async () => {
const ctrl = new DynamicOverlayController();
const global = new FakeGlobalCtrl(defaultOptions);
const local = new FakeLocalCtrl(defaultOptions);
ctrl.add(global);
ctrl.add(local);
ctrl.switchTo(local);
const showSpy = sinon.spy();
const hideSpy = sinon.spy();
ctrl.addEventListener('show', showSpy);
ctrl.addEventListener('hide', hideSpy);
await ctrl.show();
expect(showSpy.callCount).to.equal(1);
await ctrl.hide();
expect(hideSpy.callCount).to.equal(1);
ctrl.switchTo(global);
await ctrl.show();
expect(showSpy.callCount).to.equal(2);
await ctrl.hide();
expect(hideSpy.callCount).to.equal(2);
});
});
});
});

View file

@ -1,430 +0,0 @@
import { expect, fixture, html } from '@open-wc/testing';
import { GlobalOverlayController } from '../src/GlobalOverlayController.js';
import { overlays } from '../src/overlays.js';
import { runBaseOverlaySuite } from '../test-suites/BaseOverlayController.suite.js';
function getRootNode() {
return document.querySelector('.global-overlays');
}
function getRenderedContainers() {
const rootNode = getRootNode();
return rootNode ? Array.from(rootNode.children) : [];
}
function isEqualOrHasParent(element, parentElement) {
if (!parentElement) {
return false;
}
if (element === parentElement) {
return true;
}
return isEqualOrHasParent(element, parentElement.parentElement);
}
function getTopContainer() {
return getRenderedContainers().find(container => {
const rect = container.getBoundingClientRect();
const topElement = document.elementFromPoint(Math.ceil(rect.left), Math.ceil(rect.top));
return isEqualOrHasParent(container, topElement);
});
}
function getTopOverlay() {
const topContainer = getTopContainer();
return topContainer ? topContainer.children[0] : null;
}
function getRenderedContainer(index) {
return getRenderedContainers()[index];
}
function getRenderedOverlay(index) {
const container = getRenderedContainer(index);
return container ? container.children[0] : null;
}
function cleanup() {
document.body.removeAttribute('style');
overlays.teardown();
}
describe('GlobalOverlayController', () => {
afterEach(cleanup);
describe('extends BaseOverlayController', () => {
runBaseOverlaySuite((...args) => overlays.add(new GlobalOverlayController(...args)));
});
describe('basics', () => {
it('renders an overlay from the lit-html based contentTemplate when showing', async () => {
const ctrl = overlays.add(
new GlobalOverlayController({
contentTemplate: () => html`
<p>my content</p>
`,
}),
);
await ctrl.show();
expect(getRootNode().children.length).to.equal(1);
expect(getRootNode().children[0]).to.have.trimmed.text('my content');
});
it('removes the overlay from DOM when hiding', async () => {
const ctrl = overlays.add(
new GlobalOverlayController({
viewportConfig: {
placement: 'top-left',
},
contentTemplate: () => html`
<div>Content</div>
`,
}),
);
await ctrl.show();
expect(getRenderedContainers().length).to.equal(1);
expect(getRenderedOverlay(0).tagName).to.equal('DIV');
expect(getRenderedOverlay(0).textContent).to.equal('Content');
expect(getTopContainer()).to.equal(getRenderedContainer(0));
await ctrl.hide();
expect(getRenderedContainers().length).to.equal(0);
expect(getTopContainer()).to.not.exist;
});
it('exposes isShown state for reading', async () => {
const ctrl = overlays.add(
new GlobalOverlayController({
contentTemplate: () => html`
<p>Content</p>
`,
}),
);
expect(ctrl.isShown).to.equal(false);
await ctrl.show();
expect(ctrl.isShown).to.equal(true);
await ctrl.hide();
expect(ctrl.isShown).to.equal(false);
});
it('does not recreate the overlay elements when calling show multiple times', async () => {
const ctrl = overlays.add(
new GlobalOverlayController({
contentTemplate: () => html`
<p>Content</p>
`,
}),
);
await ctrl.show();
expect(getRenderedContainers().length).to.equal(1);
const initialContainer = getRenderedContainer(0);
const initialOverlay = getRenderedOverlay(0);
await ctrl.show();
expect(getRenderedContainers().length).to.equal(1);
expect(getRenderedContainer(0)).to.equal(initialContainer);
expect(getRenderedOverlay(0)).to.equal(initialOverlay);
});
it('supports .sync(isShown, data)', async () => {
const ctrl = overlays.add(
new GlobalOverlayController({
contentTemplate: ({ text = 'default' } = {}) => html`
<p>${text}</p>
`,
}),
);
await ctrl.sync({ isShown: true, data: { text: 'hello world' } });
expect(getRenderedContainers().length).to.equal(1);
expect(getRenderedOverlay(0).textContent).to.equal('hello world');
await ctrl.sync({ isShown: true, data: { text: 'goodbye world' } });
expect(getRenderedContainers().length).to.equal(1);
expect(getRenderedOverlay(0).textContent).to.equal('goodbye world');
await ctrl.sync({ isShown: false, data: { text: 'goodbye world' } });
expect(getRenderedContainers().length).to.equal(0);
});
describe('contentNode instead of a lit template', () => {
it('accepts HTML Element (contentNode) as content', async () => {
const contentNode = await fixture(
html`
<p>my content</p>
`,
);
const ctrl = overlays.add(
new GlobalOverlayController({
contentNode,
}),
);
await ctrl.show();
// container, which contains only the contentNode and nothing more
expect(getRootNode().children.length).to.equal(1);
expect(getRootNode().children[0].classList.contains('global-overlays__overlay-container'))
.to.be.true;
expect(getRootNode().children[0]).to.have.trimmed.text('my content');
// overlay (the contentNode)
expect(getRootNode().children[0].children[0].classList.contains('global-overlays__overlay'))
.to.be.true;
});
it('sets contentNode styling to display flex by default', async () => {
const contentNode = await fixture(
html`
<p>my content</p>
`,
);
const ctrl = overlays.add(
new GlobalOverlayController({
contentNode,
}),
);
await ctrl.show();
expect(
window.getComputedStyle(getRootNode().children[0]).getPropertyValue('display'),
).to.equal('flex');
});
});
});
describe('elementToFocusAfterHide', () => {
it('focuses body when hiding by default', async () => {
const ctrl = overlays.add(
new GlobalOverlayController({
viewportConfig: {
placement: 'top-left',
},
contentTemplate: () => html`
<div><input />=</div>
`,
}),
);
await ctrl.show();
const input = getTopOverlay().querySelector('input');
input.focus();
expect(document.activeElement).to.equal(input);
await ctrl.hide();
expect(document.activeElement).to.equal(document.body);
});
it('supports elementToFocusAfterHide option to focus it when hiding', async () => {
const input = await fixture(html`
<input />
`);
const ctrl = overlays.add(
new GlobalOverlayController({
elementToFocusAfterHide: input,
viewportConfig: {
placement: 'top-left',
},
contentTemplate: () => html`
<div><textarea></textarea></div>
`,
}),
);
await ctrl.show();
const textarea = getTopOverlay().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(html`
<input />
`);
const ctrl = overlays.add(
new GlobalOverlayController({
viewportConfig: {
placement: 'top-left',
},
contentTemplate: () => html`
<div><textarea></textarea></div>
`,
}),
);
await ctrl.show(input);
const textarea = getTopOverlay().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 sync', async () => {
const input = await fixture(html`
<input />
`);
const ctrl = overlays.add(
new GlobalOverlayController({
viewportConfig: {
placement: 'top-left',
},
contentTemplate: () => html`
<div><textarea></textarea></div>
`,
}),
);
await ctrl.sync({ isShown: true, elementToFocusAfterHide: input });
const textarea = getTopOverlay().querySelector('textarea');
textarea.focus();
expect(document.activeElement).to.equal(textarea);
await ctrl.hide();
expect(document.activeElement).to.equal(input);
await ctrl.sync({ isShown: true, elementToFocusAfterHide: input });
const textarea2 = getTopOverlay().querySelector('textarea');
textarea2.focus();
expect(document.activeElement).to.equal(textarea2);
await ctrl.sync({ isShown: false });
expect(document.activeElement).to.equal(input);
});
});
describe('preventsScroll', () => {
it('prevent scrolling the background', async () => {
const ctrl = overlays.add(
new GlobalOverlayController({
preventsScroll: true,
contentTemplate: () => html`
<p>Content</p>
`,
}),
);
await ctrl.show();
ctrl.updateComplete;
expect(getComputedStyle(document.body).overflow).to.equal('hidden');
await ctrl.hide();
ctrl.updateComplete;
expect(getComputedStyle(document.body).overflow).to.equal('visible');
});
});
describe('hasBackdrop', () => {
it('has no backdrop by default', async () => {
const ctrl = overlays.add(
new GlobalOverlayController({
contentTemplate: () => html`
<p>Content</p>
`,
}),
);
await ctrl.show();
expect(ctrl.backdropNode).to.be.undefined;
});
it('supports a backdrop option', async () => {
const ctrl = overlays.add(
new GlobalOverlayController({
hasBackdrop: false,
contentTemplate: () => html`
<p>Content</p>
`,
}),
);
await ctrl.show();
expect(ctrl.backdropNode).to.be.undefined;
await ctrl.hide();
const controllerWithBackdrop = overlays.add(
new GlobalOverlayController({
hasBackdrop: true,
contentTemplate: () => html`
<p>Content</p>
`,
}),
);
await controllerWithBackdrop.show();
expect(controllerWithBackdrop.backdropNode).to.have.class('global-overlays__backdrop');
});
it('reenables the backdrop when shown/hidden/shown', async () => {
const ctrl = overlays.add(
new GlobalOverlayController({
hasBackdrop: true,
contentTemplate: () => html`
<p>Content</p>
`,
}),
);
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');
});
});
describe('viewportConfig', () => {
it('places the overlay in center by default', async () => {
const controller = new GlobalOverlayController({
contentTemplate: () =>
html`
<p>Content</p>
`,
});
controller.show();
expect(controller.overlayContainerPlacementClass).to.equal(
'global-overlays__overlay-container--center',
);
});
it('can set the placement relative to the viewport ', async () => {
const placementMap = [
'top-left',
'top',
'top-right',
'right',
'bottom-right',
'bottom',
'bottom-left',
'left',
'center',
];
placementMap.forEach(viewportPlacement => {
const controller = new GlobalOverlayController({
viewportConfig: {
placement: viewportPlacement,
},
contentTemplate: () =>
html`
<p>Content</p>
`,
});
controller.show();
expect(controller.overlayContainerPlacementClass).to.equal(
`global-overlays__overlay-container--${viewportPlacement}`,
);
});
});
});
});

View file

@ -1,847 +0,0 @@
import { expect, fixture, html, aTimeout, defineCE, unsafeStatic } from '@open-wc/testing';
import Popper from 'popper.js/dist/esm/popper.min.js';
import { keyCodes } from '../src/utils/key-codes.js';
import { simulateTab } from '../src/utils/simulate-tab.js';
import { LocalOverlayController } from '../src/LocalOverlayController.js';
import { runBaseOverlaySuite } from '../test-suites/BaseOverlayController.suite.js';
/**
* @desc Compensates for browsers that use floats in output
* - from: 'transform3d(12.25px, 6.75px, 0px)'
* - to: 'transform3d(12px, 7px, 0px)'
* @param {string} cssValue
*/
export function normalizeTransformStyle(cssValue) {
// eslint-disable-next-line no-unused-vars
const [_, transformType, positionPart] = cssValue.match(/(.*)\((.*?)\)/);
const normalizedNumbers = positionPart
.split(',')
.map(p => Math.round(Number(p.replace('px', ''))));
return `${transformType}(${normalizedNumbers
.map((n, i) => `${n}px${normalizedNumbers.length - 1 === i ? '' : ', '}`)
.join('')})`;
}
describe('LocalOverlayController', () => {
describe('extends BaseOverlayController', () => {
runBaseOverlaySuite((...args) => new LocalOverlayController(...args));
});
describe('templates', () => {
it('creates a controller with methods: show, hide, sync and syncInvoker', async () => {
const invokerNode = await fixture(html`
<div role="button">Invoker</div>
`);
const ctrl = new LocalOverlayController({
contentTemplate: () => html`
<div>Content</div>
`,
invokerNode,
});
expect(ctrl.show).to.be.a('function');
expect(ctrl.hide).to.be.a('function');
expect(ctrl.sync).to.be.a('function');
expect(ctrl.syncInvoker).to.be.a('function');
});
it('renders holders for invoker and content', async () => {
const invokerNode = await fixture(html`
<div role="button" id="invoker">Invoker</div>
`);
const ctrl = new LocalOverlayController({
contentTemplate: () => html`
<div id="content">Content</div>
`,
invokerNode,
});
const el = await fixture(html`
<div>
${ctrl.invoker} ${ctrl.content}
</div>
`);
expect(el.querySelector('#invoker').textContent.trim()).to.equal('Invoker');
await ctrl.show();
expect(el.querySelector('#content').textContent.trim()).to.equal('Content');
});
it('exposes isShown state for reading', async () => {
const invokerNode = await fixture('<div role="button">Invoker</div>');
const ctrl = new LocalOverlayController({
contentTemplate: () => html`
<div>Content</div>
`,
invokerNode,
});
await fixture(html`
<div>
${ctrl.invoker} ${ctrl.content}
</div>
`);
expect(ctrl.isShown).to.equal(false);
await ctrl.show();
expect(ctrl.isShown).to.equal(true);
await ctrl.hide();
expect(ctrl.isShown).to.equal(false);
});
// deprecated
it('@deprecated can use a .invokerTemplate and .syncInvoker', async () => {
const ctrl = new LocalOverlayController({
contentTemplate: () => html`
<div>Content</div>
`,
invokerTemplate: (data = { text: 'foo' }) => html`
<div role="button">${data.text}</div>
`,
});
expect(ctrl.invoker.textContent.trim()).to.equal('foo');
ctrl.syncInvoker({ data: { text: 'bar' } });
expect(ctrl.invoker.textContent.trim()).to.equal('bar');
});
it('can synchronize the content data', async () => {
const invokerNode = await fixture('<div role="button">Invoker</div>');
const ctrl = new LocalOverlayController({
contentTemplate: ({ text = 'fallback' } = {}) => html`
<div>${text}</div>
`,
invokerNode,
});
await ctrl.show();
await ctrl.sync({ data: { text: 'foo' } });
expect(ctrl.content.textContent.trim()).to.equal('foo');
await ctrl.sync({ data: { text: 'bar' } });
expect(ctrl.content.textContent.trim()).to.equal('bar');
});
});
describe('nodes', () => {
it('accepts HTML Elements (contentNode) to render content', async () => {
const invokerNode = await fixture(html`
<div role="button" id="invoker">Invoker</div>
`);
const node = document.createElement('div');
node.innerHTML = '<div id="content">Content</div>';
const ctrl = new LocalOverlayController({
contentNode: node,
invokerNode,
});
const el = await fixture(html`
<div>
${ctrl.invoker} ${ctrl.content}
</div>
`);
expect(el.querySelector('#invoker').textContent.trim()).to.equal('Invoker');
await ctrl.show();
expect(el.querySelector('#content').textContent.trim()).to.equal('Content');
});
it('sets display to inline-block for contentNode by default', async () => {
const invokerNode = await fixture(html`
<div role="button" id="invoker">Invoker</div>
`);
const node = document.createElement('div');
node.innerHTML = '<div id="content">Content</div>';
const ctrl = new LocalOverlayController({
contentNode: node,
invokerNode,
});
const el = await fixture(html`
<div>
${ctrl.invoker} ${ctrl.content}
</div>
`);
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 invokerNode = await fixture(
html`
<div role="button" style="width: 100px; height: 20px;"></div>
`,
);
const ctrl = new LocalOverlayController({
contentTemplate: () => html`
<div style="width: 80px; height: 20px;"></div>
`,
invokerNode,
});
await ctrl.show();
expect(ctrl._popper)
.to.be.an.instanceof(Popper)
.and.have.property('modifiers');
await ctrl.hide();
expect(ctrl._popper)
.to.be.an.instanceof(Popper)
.and.have.property('modifiers');
});
it('positions correctly', async () => {
// smoke test for integration of popper
const invokerNode = await fixture(html`
<div role="button" style="width: 100px; height: 20px;">Invoker</div>
`);
const ctrl = new LocalOverlayController({
contentTemplate: () => html`
<div style="width: 80px; height: 20px; margin: 0;">my content</div>
`,
invokerNode,
});
await fixture(html`
${invokerNode}${ctrl.content}
`);
await ctrl.show();
expect(normalizeTransformStyle(ctrl.contentNode.style.transform)).to.equal(
// TODO: check if 'translate3d(16px, 16px, 0px)' would be more appropriate
'translate3d(16px, 28px, 0px)',
'16px displacement is expected due to both horizontal and vertical viewport margin',
);
});
it('uses top as the default placement', async () => {
let ctrl;
const invokerNode = await fixture(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`);
ctrl = new LocalOverlayController({
contentTemplate: () => html`
<div style="width: 80px; height: 20px;"></div>
`,
invokerNode,
});
await fixture(html`
<div style="position: absolute; left: 100px; top: 100px;">
${ctrl.invoker} ${ctrl.content}
</div>
`);
await ctrl.show();
const contentChild = ctrl.content.firstElementChild;
expect(contentChild.getAttribute('x-placement')).to.equal('top');
});
it('positions to preferred place if placement is set and space is available', async () => {
let controller;
const invokerNode = await fixture(html`
<div
role="button"
style="width: 100px; height: 20px;"
@click=${() => controller.show()}
></div>
`);
controller = new LocalOverlayController({
contentTemplate: () => html`
<div style="width: 80px; height: 20px;"></div>
`,
invokerNode,
popperConfig: {
placement: 'left-start',
},
});
await fixture(html`
<div style="position: absolute; left: 100px; top: 50px;">
${controller.invoker} ${controller.content}
</div>
`);
await controller.show();
const contentChild = controller.content.firstElementChild;
expect(contentChild.getAttribute('x-placement')).to.equal('left-start');
});
it('positions to different place if placement is set and no space is available', async () => {
let ctrl;
const invokerNode = await fixture(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`);
ctrl = new LocalOverlayController({
contentTemplate: () => html`
<div style="width: 80px; height: 20px;"></div>
`,
invokerNode,
popperConfig: {
placement: 'top-start',
},
});
await fixture(`
<div style="position: absolute; top: 0;">
${ctrl.invoker} ${ctrl.content}
</div>
`);
await ctrl.show();
const contentChild = ctrl.content.firstElementChild;
expect(contentChild.getAttribute('x-placement')).to.equal('bottom-start');
});
it('allows the user to override default Popper modifiers', async () => {
let controller;
const invokerNode = await fixture(html`
<div
role="button"
style="width: 100px; height: 20px;"
@click=${() => controller.show()}
></div>
`);
controller = new LocalOverlayController({
contentTemplate: () => html`
<div style="width: 80px; height: 20px;"></div>
`,
invokerNode,
popperConfig: {
modifiers: {
keepTogether: {
enabled: false,
},
offset: {
enabled: true,
offset: `0, 16px`,
},
},
},
});
await fixture(html`
<div style="position: absolute; left: 100px; top: 50px;">
${controller.invoker} ${controller.content}
</div>
`);
await controller.show();
const keepTogether = controller._popper.modifiers.find(item => item.name === 'keepTogether');
const offset = controller._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 () => {
let controller;
const invokerNode = await fixture(html`
<div
role="button"
style="width: 100px; height: 20px;"
@click=${() => controller.show()}
></div>
`);
controller = new LocalOverlayController({
contentTemplate: () => html`
<div style="width: 80px; height: 20px;"></div>
`,
invokerNode,
popperConfig: {
placement: 'top',
},
});
await fixture(html`
<div style="position: absolute; top: 300px; left: 100px;">
${controller.invoker} ${controller.content}
</div>
`);
await controller.show();
let contentChild = controller.content.firstElementChild;
expect(normalizeTransformStyle(contentChild.style.transform)).to.equal(
'translate3d(10px, -28px, 0px)',
'Popper positioning values',
);
await controller.hide();
await controller.show();
contentChild = controller.content.firstElementChild;
expect(normalizeTransformStyle(contentChild.style.transform)).to.equal(
'translate3d(10px, -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 () => {
let controller;
const invokerNode = await fixture(html`
<div
role="button"
style="width: 100px; height: 20px;"
@click=${() => controller.show()}
></div>
`);
controller = new LocalOverlayController({
contentTemplate: () => html`
<div style="width: 80px; height: 20px;"></div>
`,
invokerNode,
popperConfig: {
placement: 'top',
modifiers: {
offset: {
enabled: true,
offset: '0, 10px',
},
},
},
});
await fixture(html`
<div style="position: absolute; top: 300px; left: 100px;">
${controller.invoker} ${controller.content}
</div>
`);
await controller.show();
let contentChild = controller.content.firstElementChild;
expect(normalizeTransformStyle(contentChild.style.transform)).to.equal(
'translate3d(10px, -30px, 0px)',
'Popper positioning values',
);
await controller.hide();
await controller.updatePopperConfig({
modifiers: {
offset: {
enabled: true,
offset: '0, 20px',
},
},
});
await controller.show();
contentChild = controller.content.firstElementChild;
expect(controller._popper.options.modifiers.offset.offset).to.equal('0, 20px');
expect(normalizeTransformStyle(contentChild.style.transform)).to.equal(
'translate3d(10px, -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 () => {
let controller;
const invokerNode = await fixture(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => controller.show()}>
Invoker
</div>
`);
controller = new LocalOverlayController({
contentTemplate: () => html`
<div style="width: 80px; height: 20px;"></div>
`,
invokerNode,
popperConfig: {
placement: 'top',
modifiers: {
offset: {
enabled: true,
offset: '0, 10px',
},
},
},
});
await fixture(html`
<div style="position: absolute; top: 300px; left: 100px;">
${controller.invoker} ${controller.content}
</div>
`);
await controller.show();
const contentChild = controller.content.firstElementChild;
expect(normalizeTransformStyle(contentChild.style.transform)).to.equal(
'translate3d(10px, -30px, 0px)',
'Popper positioning values',
);
await controller.updatePopperConfig({
modifiers: {
offset: {
enabled: true,
offset: '0, 20px',
},
},
});
expect(normalizeTransformStyle(contentChild.style.transform)).to.equal(
'translate3d(10px, -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(
'<div role="button" style="width: 60px; border: none;">invoker</div>',
);
const ctrl = new LocalOverlayController({
inheritsReferenceObjectWidth: 'min',
contentTemplate: () =>
html`
<div>content</div>
`,
invokerNode,
});
await ctrl.show();
expect(ctrl.contentNode.style.minWidth).to.equal('60px');
});
it('can set the contentNode maxWidth as the invokerNode width', async () => {
const invokerNode = await fixture(
'<div role="button" style="width: 60px; border: none;">invoker</div>',
);
const ctrl = new LocalOverlayController({
inheritsReferenceObjectWidth: 'max',
contentTemplate: () =>
html`
<div>content</div>
`,
invokerNode,
});
await ctrl.show();
expect(ctrl.contentNode.style.maxWidth).to.equal('60px');
});
it('can set the contentNode width as the invokerNode width', async () => {
const invokerNode = await fixture(
'<div role="button" style="width: 60px; border: none;">invoker</div>',
);
const ctrl = new LocalOverlayController({
inheritsReferenceObjectWidth: 'full',
contentTemplate: () =>
html`
<div>content</div>
`,
invokerNode,
});
await ctrl.show();
expect(ctrl.contentNode.style.width).to.equal('60px');
});
});
describe('a11y', () => {
it('adds and removes [aria-expanded] on invoker', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>');
const ctrl = new LocalOverlayController({
contentTemplate: () =>
html`
<div>Content</div>
`,
invokerNode,
});
expect(ctrl.invokerNode.getAttribute('aria-controls')).to.contain(ctrl.content.id);
expect(ctrl.invokerNode).to.have.attribute('aria-expanded', 'false');
await ctrl.show();
expect(ctrl.invokerNode).to.have.attribute('aria-expanded', 'true');
await ctrl.hide();
expect(ctrl.invokerNode).to.have.attribute('aria-expanded', 'false');
});
it('traps the focus via option { trapsKeyboardFocus: true }', async () => {
const invokerNode = await fixture('<button>invoker</button>');
const ctrl = new LocalOverlayController({
contentTemplate: () => html`
<div>
<button id="el1">Button</button>
<a id="el2" href="#">Anchor</a>
</div>
`,
invokerNode,
trapsKeyboardFocus: true,
});
// make sure we're connected to the dom
await fixture(html`
${invokerNode}${ctrl.content}
`);
await ctrl.show();
const elOutside = await fixture(`<div role="button">click me</div>`);
const [el1, el2] = [].slice.call(ctrl.contentNode.querySelectorAll('[id]'));
el2.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(el1).to.equal(document.activeElement);
});
it('traps the focus via option { trapsKeyboardFocus: true } when using contentNode', async () => {
const invokerNode = await fixture('<button>Invoker</button>');
const contentNode = await fixture(html`
<div>
<button id="el1">Button</button>
<a id="el2" href="#">Anchor</a>
</div>
`);
const ctrl = new LocalOverlayController({
contentNode,
invokerNode,
trapsKeyboardFocus: true,
});
// make sure we're connected to the dom
await fixture(html`
${ctrl.invoker}${ctrl.content}
`);
await ctrl.show();
const elOutside = await fixture(`<div role="button">click me</div>`);
const [el1, el2] = [].slice.call(ctrl.contentNode.querySelectorAll('[id]'));
el2.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(el1).to.equal(document.activeElement);
});
it('allows to move the focus outside of the overlay if trapsKeyboardFocus is disabled', async () => {
const invokerNode = await fixture('<button>Invoker</button>');
const ctrl = new LocalOverlayController({
contentTemplate: () => html`
<div>
<button id="el1">Button</button>
</div>
`,
invokerNode,
trapsKeyboardFocus: false,
});
// make sure we're connected to the dom
await fixture(html`
${ctrl.invoker}${ctrl.content}
`);
const elOutside = await fixture(`<button>click me</button>`);
await ctrl.show();
const el1 = ctrl.content.querySelector('button');
el1.focus();
simulateTab();
expect(elOutside).to.equal(document.activeElement);
});
});
describe('hidesOnOutsideClick', () => {
it('hides on outside click', async () => {
const invokerNode = await fixture('<div role="button">Invoker</div>');
const ctrl = new LocalOverlayController({
hidesOnOutsideClick: true,
contentTemplate: () => html`
<div>Content</div>
`,
invokerNode,
});
await fixture(html`
${invokerNode}${ctrl.content}
`);
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(html`
<button>Invoker</button>
`);
const ctrl = new LocalOverlayController({
hidesOnOutsideClick: true,
contentTemplate: () => html`
<div>Content</div>
`,
invokerNode,
});
await fixture(html`
${invokerNode}${ctrl.content}
`);
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;
// 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 = '<div><button>click me</button></div>';
}
},
);
const tag = unsafeStatic(tagString);
ctrl.contentTemplate = () =>
html`
<div>
<div>Content</div>
<${tag}></${tag}>
</div>
`;
// Don't hide on inside shadowDom click
ctrl.content
.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(html`
<div role="button">Invoker</div>
`);
const ctrl = new LocalOverlayController({
hidesOnOutsideClick: true,
contentTemplate: () =>
html`
<div>Content</div>
`,
invokerNode,
});
const dom = await fixture(`
<div>
<div id="popup">${invokerNode}${ctrl.content}</div>
<div
id="regular-sibling"
@click="${() => {
/* propagates */
}}"
></div>
<third-party-noise @click="${e => e.stopPropagation()}">
This element prevents our handlers from reaching the document click handler.
</third-party-noise>
</div>
`);
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`
<div role="button">Invoker</div>
`);
const ctrl = new LocalOverlayController({
hidesOnOutsideClick: true,
contentTemplate: () =>
html`
<div>Content</div>
`,
invokerNode,
});
const dom = await fixture(`
<div>
<div id="popup">${invokerNode}${ctrl.content}</div>
<div
id="regular-sibling"
@click="${() => {
/* propagates */
}}"
></div>
<third-party-noise>
This element prevents our handlers from reaching the document click handler.
</third-party-noise>
</div>
`);
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('toggles', () => {
it('toggles on clicks', async () => {
let ctrl;
const invokerNode = await fixture(html`
<button @click="${() => ctrl.toggle()}">Invoker</button>
`);
ctrl = new LocalOverlayController({
hidesOnOutsideClick: true,
contentTemplate: () =>
html`
<div>Content</div>
`,
invokerNode,
});
const { content, invoker, invokerNode: iNode } = ctrl;
await fixture(
html`
${invoker}${content}
`,
);
// Show content on first invoker click
iNode.click();
await aTimeout();
expect(ctrl.isShown).to.equal(true);
// Hide content on click when shown
iNode.click();
await aTimeout();
expect(ctrl.isShown).to.equal(false);
// Show content on invoker click when hidden
iNode.click();
await aTimeout();
expect(ctrl.isShown).to.equal(true);
});
});
});

View file

@ -1,281 +0,0 @@
import { expect, html } from '@open-wc/testing';
import { GlobalOverlayController } from '../src/GlobalOverlayController.js';
import { overlays } from '../src/overlays.js';
function getRootNode() {
return document.querySelector('.global-overlays');
}
function getRenderedContainers() {
const rootNode = getRootNode();
return rootNode ? Array.from(rootNode.children) : [];
}
function getRenderedContainer(index) {
return getRenderedContainers()[index];
}
function getRenderedOverlay(index) {
const container = getRenderedContainer(index);
return container ? container.children[0] : null;
}
function cleanup() {
document.body.removeAttribute('style');
overlays.teardown();
}
describe('Managed GlobalOverlayController', () => {
afterEach(cleanup);
describe('hasBackdrop', () => {
it('adds and stacks backdrops if .hasBackdrop is enabled', async () => {
const ctrl0 = overlays.add(
new GlobalOverlayController({
hasBackdrop: true,
contentTemplate: () => html`
<p>Content0</p>
`,
}),
);
await ctrl0.show();
expect(ctrl0.backdropNode).to.have.class('global-overlays__backdrop');
const ctrl1 = overlays.add(
new GlobalOverlayController({
hasBackdrop: false,
contentTemplate: () => html`
<p>Content1</p>
`,
}),
);
await ctrl1.show();
expect(ctrl0.backdropNode).to.have.class('global-overlays__backdrop');
expect(ctrl1.backdropNode).to.be.undefined;
const ctrl2 = overlays.add(
new GlobalOverlayController({
hasBackdrop: true,
contentTemplate: () => html`
<p>Content2</p>
`,
}),
);
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 = overlays.add(
new GlobalOverlayController({
isBlocking: false,
contentTemplate: () => html`
<p>Content0</p>
`,
}),
);
await ctrl0.show();
const ctrl1 = overlays.add(
new GlobalOverlayController({
isBlocking: false,
contentTemplate: () => html`
<p>Content1</p>
`,
}),
);
await ctrl1.show();
const ctrl2 = overlays.add(
new GlobalOverlayController({
isBlocking: true,
contentTemplate: () => html`
<p>Content2</p>
`,
}),
);
await ctrl2.show();
const ctrl3 = overlays.add(
new GlobalOverlayController({
isBlocking: false,
contentTemplate: () => html`
<p>Content3</p>
`,
}),
);
await ctrl3.show();
expect(getRenderedOverlay(0)).to.not.be.displayed;
expect(getRenderedOverlay(1)).to.not.be.displayed;
expect(getRenderedOverlay(2)).to.be.displayed;
expect(getRenderedOverlay(3)).to.not.be.displayed;
});
it('keeps backdrop status when used in combination with blocking', async () => {
const ctrl0 = overlays.add(
new GlobalOverlayController({
isBlocking: false,
hasBackdrop: true,
contentTemplate: () => html`
<p>Content0</p>
`,
}),
);
await ctrl0.show();
const ctrl1 = overlays.add(
new GlobalOverlayController({
isBlocking: false,
hasBackdrop: true,
contentTemplate: () => html`
<p>Content1</p>
`,
}),
);
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('trapsKeyboardFocus (for a11y)', () => {
it('adds attributes inert and aria-hidden="true" on all siblings of rootNode if an overlay is shown', async () => {
const ctrl = overlays.add(
new GlobalOverlayController({
trapsKeyboardFocus: true,
contentTemplate: () => html`
<p>Content</p>
`,
}),
);
const sibling1 = document.createElement('div');
const sibling2 = document.createElement('div');
document.body.insertBefore(sibling1, getRootNode());
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(getRenderedOverlay(0).hasAttribute('aria-hidden')).to.be.false;
expect(getRenderedOverlay(0).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 = overlays.add(
new GlobalOverlayController({
trapsKeyboardFocus: true,
contentTemplate: () => html`
<p>Content</p>
`,
}),
);
// 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, getRootNode());
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(getRenderedOverlay(0)).userSelect).to.be.oneOf([
'auto',
undefined,
]);
expect(window.getComputedStyle(getRenderedOverlay(0)).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);
});
it('keeps focus within overlay with multiple overlays with all traps on true', async () => {
const ctrl0 = overlays.add(
new GlobalOverlayController({
trapsKeyboardFocus: true,
contentTemplate: () => html`
<div>
<input id="input0" /><button id="button0">Button0</button><a id="a0">Link0</a>
</div>
`,
}),
);
const ctrl1 = overlays.add(
new GlobalOverlayController({
trapsKeyboardFocus: true,
contentTemplate: () => html`
<div>
<input id="input1" /><button id="button1">Button1</button><a id="a1">Link1</a>
</div>
`,
}),
);
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;
});
});
});

View file

@ -1,32 +0,0 @@
import { expect, html } from '@open-wc/testing';
import { GlobalOverlayController } from '../src/GlobalOverlayController.js';
import { ModalDialogController } from '../src/ModalDialogController.js';
describe('ModalDialogController', () => {
let defaultOptions;
before(() => {
defaultOptions = {
contentTemplate: () => html`
<p>my content</p>
`,
};
});
it('extends GlobalOverlayController', () => {
expect(new ModalDialogController(defaultOptions)).to.be.instanceof(GlobalOverlayController);
});
it('has correct defaults', () => {
const ctrl = new ModalDialogController(defaultOptions);
expect(ctrl.hasBackdrop).to.be.true;
expect(ctrl.isBlocking).to.be.false;
expect(ctrl.preventsScroll).to.be.true;
expect(ctrl.trapsKeyboardFocus).to.be.true;
expect(ctrl.hidesOnEsc).to.be.true;
expect(ctrl.overlayContainerPlacementClass).to.equal(
'global-overlays__overlay-container--center',
);
});
});

File diff suppressed because it is too large Load diff

View file

@ -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 { OverlaysManager } from '../src/OverlaysManager.js';
import { BaseOverlayController } from '../src/BaseOverlayController.js'; import { OverlayController } from '../src/OverlayController.js';
describe('OverlaysManager', () => { describe('OverlaysManager', () => {
let defaultOptions; let defaultOptions;
let mngr; let mngr;
before(() => { before(async () => {
defaultOptions = { const contentNode = await fixture(html`
contentTemplate: () => html`
<p>my content</p> <p>my content</p>
`, `);
defaultOptions = {
placementMode: 'global',
contentNode,
}; };
}); });
@ -43,16 +45,10 @@ describe('OverlaysManager', () => {
expect(mngr.constructor.__globalStyleNode).to.be.undefined; 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', () => { it('can add/remove controllers', () => {
const dialog = new BaseOverlayController(defaultOptions); // OverlayControllers will add themselves
const popup = new BaseOverlayController(defaultOptions); const dialog = new OverlayController(defaultOptions, mngr);
mngr.add(dialog); const popup = new OverlayController(defaultOptions, mngr);
mngr.add(popup);
expect(mngr.list).to.deep.equal([dialog, popup]); expect(mngr.list).to.deep.equal([dialog, popup]);
@ -64,29 +60,25 @@ describe('OverlaysManager', () => {
}); });
it('throws if you try to add the same controller', () => { it('throws if you try to add the same controller', () => {
const ctrl = new BaseOverlayController(defaultOptions); const ctrl = new OverlayController(defaultOptions, mngr);
mngr.add(ctrl);
expect(() => mngr.add(ctrl)).to.throw('controller instance is already added'); expect(() => mngr.add(ctrl)).to.throw('controller instance is already added');
}); });
it('throws if you try to remove a non existing controller', () => { 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'); expect(() => mngr.remove(ctrl)).to.throw('could not find controller to remove');
}); });
it('adds a reference to the manager to the controller', () => { it('adds a reference to the manager to the controller', () => {
const dialog = new BaseOverlayController(defaultOptions); const dialog = new OverlayController(defaultOptions, mngr);
mngr.add(dialog);
expect(dialog.manager).to.equal(mngr); expect(dialog.manager).to.equal(mngr);
}); });
it('has a .shownList which is ordered based on last shown', async () => { it('has a .shownList which is ordered based on last shown', async () => {
const dialog = new BaseOverlayController(defaultOptions); const dialog = new OverlayController(defaultOptions, mngr);
const dialog2 = new BaseOverlayController(defaultOptions); const dialog2 = new OverlayController(defaultOptions, mngr);
mngr.add(dialog);
mngr.add(dialog2);
expect(mngr.shownList).to.deep.equal([]); expect(mngr.shownList).to.deep.equal([]);
await dialog.show(); await dialog.show();

View file

@ -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`
<p>my content</p>
`),
});
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;
});
});
});
});

View file

@ -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`
<div>my content</div>
`),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;">Invoker</div>
`),
});
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`
<div role="button" id="invoker">Invoker</div>
`);
const node = document.createElement('div');
node.innerHTML = '<div id="content">Content</div>';
const ctrl = new OverlayController({
...withLocalTestConfig(),
contentNode: node,
invokerNode,
});
const el = await fixture(html`
<div>
${ctrl.invokerNode} ${ctrl.content}
</div>
`);
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`
<div style="width: 80px; height: 30px; background: green;"></div>
`),
invokerNode: fixtureSync(html`
<div role="button" style="width: 20px; height: 10px; background: orange;"></div>
`),
});
await fixture(html`
<div style="position: fixed; left: 100px; top: 100px;">
${ctrl.invokerNode}${ctrl.content}
</div>
`);
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`
<div style="width: 80px; height: 20px;"></div>
`),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`),
});
await fixture(html`
<div style="position: fixed; left: 100px; top: 100px;">
${ctrl.invokerNode}${ctrl.content}
</div>
`);
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`
<div style="width: 80px; height: 20px;"></div>
`),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`),
popperConfig: {
placement: 'left-start',
},
});
await fixture(html`
<div style="position: absolute; left: 120px; top: 50px;">
${ctrl.invokerNode}${ctrl.content}
</div>
`);
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`
<div style="width: 80px; height: 20px;">invoker</div>
`),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}>
content
</div>
`),
popperConfig: {
placement: 'top-start',
},
});
await fixture(html`
<div style="position: absolute; top: 0;">
${ctrl.invokerNode}${ctrl.content}
</div>
`);
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`
<div style="width: 80px; height: 20px;"></div>
`),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`),
popperConfig: {
modifiers: {
keepTogether: {
enabled: false,
},
offset: {
enabled: true,
offset: `0, 16px`,
},
},
},
});
await fixture(html`
<div style="position: absolute; left: 100px; top: 50px;">
${ctrl.invokerNode}${ctrl.content}
</div>
`);
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`
<div style="width: 80px; height: 20px;"></div>
`),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`),
popperConfig: {
placement: 'top',
},
});
await fixture(html`
<div style="position: absolute; top: 300px; left: 100px;">
${ctrl.invokerNode}${ctrl.content}
</div>
`);
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`
<div style="width: 80px; height: 20px;"></div>
`),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`),
popperConfig: {
placement: 'top',
modifiers: {
offset: {
enabled: true,
offset: '0, 10px',
},
},
},
});
await fixture(html`
<div style="position: absolute; top: 300px; left: 100px;">
${ctrl.invokerNode} ${ctrl.content}
</div>
`);
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`
<div style="width: 80px; height: 20px;"></div>
`),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}>
Invoker
</div>
`),
popperConfig: {
placement: 'top',
modifiers: {
offset: {
enabled: true,
offset: '0, 10px',
},
},
},
});
await fixture(html`
<div style="position: absolute; top: 300px; left: 100px;">
${ctrl.invokerNode} ${ctrl.content}
</div>
`);
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`
<div role="button" style="width: 60px;">invoker</div>
`);
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`
<div role="button" style="width: 60px;">invoker</div>
`);
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`
<div role="button" style="width: 60px;">invoker</div>
`);
const ctrl = new OverlayController({
...withLocalTestConfig(),
inheritsReferenceWidth: 'full',
invokerNode,
});
await ctrl.show();
expect(ctrl.content.style.width).to.equal('60px');
});
});
});