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:
parent
e161c711e8
commit
364f185ad8
37 changed files with 2898 additions and 4263 deletions
|
|
@ -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 don’t expect a dialog to open (at this moment in time) but expect a sub-menu. Until support for the dialog value has better implementation, it’s probably best to not use aria-haspopup on the element that opens the modal dialog.
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
617
packages/overlays/src/OverlayController.js
Normal file
617
packages/overlays/src/OverlayController.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
143
packages/overlays/src/OverlayMixin.js
Normal file
143
packages/overlays/src/OverlayMixin.js
Normal 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 }) {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export const withBottomSheetConfig = () => ({
|
||||||
|
hasBackdrop: true,
|
||||||
|
preventsScroll: true,
|
||||||
|
trapsKeyboardFocus: true,
|
||||||
|
hidesOnEsc: true,
|
||||||
|
placementMode: 'global',
|
||||||
|
viewportConfig: {
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
handlesAccessibility: true,
|
||||||
|
});
|
||||||
15
packages/overlays/src/configurations/withDropdownConfig.js
Normal file
15
packages/overlays/src/configurations/withDropdownConfig.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
export const withDropdownConfig = () => ({
|
||||||
|
placementMode: 'local',
|
||||||
|
|
||||||
|
inheritsReferenceWidth: true,
|
||||||
|
hidesOnOutsideClick: true,
|
||||||
|
popperConfig: {
|
||||||
|
placement: 'bottom-start',
|
||||||
|
modifiers: {
|
||||||
|
offset: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handlesAccessibility: true,
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
export const withModalDialogConfig = () => ({
|
||||||
|
placementMode: 'global',
|
||||||
|
viewportConfig: {
|
||||||
|
placement: 'center',
|
||||||
|
},
|
||||||
|
|
||||||
|
hasBackdrop: true,
|
||||||
|
preventsScroll: true,
|
||||||
|
trapsKeyboardFocus: true,
|
||||||
|
hidesOnEsc: true,
|
||||||
|
handlesAccessibility: true,
|
||||||
|
});
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
32
packages/overlays/src/utils/typedef.js
Normal file
32
packages/overlays/src/utils/typedef.js
Normal 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
|
||||||
|
*/
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
`;
|
`;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
`;
|
`;
|
||||||
}); */
|
}); */
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
16
packages/overlays/test-helpers/local-positioning-helpers.js
Normal file
16
packages/overlays/test-helpers/local-positioning-helpers.js
Normal 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('')})`;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -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));
|
|
||||||
});
|
|
||||||
|
|
@ -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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
1062
packages/overlays/test/OverlayController.test.js
Normal file
1062
packages/overlays/test/OverlayController.test.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||||
|
|
|
||||||
79
packages/overlays/test/global-positioning.test.js
Normal file
79
packages/overlays/test/global-positioning.test.js
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
352
packages/overlays/test/local-positioning.test.js
Normal file
352
packages/overlays/test/local-positioning.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue