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
|
||||
// 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 { LocalOverlayController } from './src/LocalOverlayController.js';
|
||||
export { BottomSheetController } from './src/BottomSheetController.js';
|
||||
export { ModalDialogController } from './src/ModalDialogController.js';
|
||||
export { overlays } from './src/overlays.js';
|
||||
export { OverlaysManager } from './src/OverlaysManager.js';
|
||||
export { OverlayController } from './src/OverlayController.js';
|
||||
export { OverlayMixin } from './src/OverlayMixin.js';
|
||||
|
||||
// deprecated
|
||||
export { BottomSheetController as BottomsheetController } from './src/BottomSheetController.js';
|
||||
export { withBottomSheetConfig } from './src/configurations/withBottomSheetConfig.js';
|
||||
export { withModalDialogConfig } from './src/configurations/withModalDialogConfig.js';
|
||||
export { withDropdownConfig } from './src/configurations/withDropdownConfig.js';
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"src",
|
||||
"stories",
|
||||
"test",
|
||||
"test-helpers",
|
||||
"translations",
|
||||
"*.js"
|
||||
],
|
||||
|
|
@ -38,6 +39,7 @@
|
|||
"devDependencies": {
|
||||
"@open-wc/demoing-storybook": "^0.2.0",
|
||||
"@open-wc/testing": "^2.3.4",
|
||||
"@open-wc/testing-helpers": "^1.0.0",
|
||||
"sinon": "^7.2.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { globalOverlaysStyle } from './globalOverlaysStyle.js';
|
||||
|
||||
const isIOS = navigator.userAgent.match(/iPhone|iPad|iPod/i);
|
||||
|
||||
/**
|
||||
* @typedef {object} OverlayController
|
||||
* @param {(object) => TemplateResult} contentTemplate the template function
|
||||
|
|
@ -37,7 +39,7 @@ export class OverlaysManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* no setter as .list is inteded to be read-only
|
||||
* no setter as .list is intended to be read-only
|
||||
* You can use .add or .remove to modify it
|
||||
*/
|
||||
get globalRootNode() {
|
||||
|
|
@ -49,7 +51,7 @@ export class OverlaysManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* no setter as .list is inteded to be read-only
|
||||
* no setter as .list is intended to be read-only
|
||||
* You can use .add or .remove to modify it
|
||||
*/
|
||||
get list() {
|
||||
|
|
@ -57,7 +59,7 @@ export class OverlaysManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* no setter as .shownList is inteded to be read-only
|
||||
* no setter as .shownList is intended to be read-only
|
||||
* You can use .show or .hide on individual controllers to modify
|
||||
*/
|
||||
get shownList() {
|
||||
|
|
@ -68,6 +70,7 @@ export class OverlaysManager {
|
|||
this.__list = [];
|
||||
this.__shownList = [];
|
||||
this.__siblingsInert = false;
|
||||
this.__blockingMap = new WeakMap();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -79,8 +82,6 @@ export class OverlaysManager {
|
|||
if (this.list.find(ctrl => ctrlToAdd === ctrl)) {
|
||||
throw new Error('controller instance is already added');
|
||||
}
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
ctrlToAdd.manager = this;
|
||||
this.list.push(ctrlToAdd);
|
||||
return ctrlToAdd;
|
||||
}
|
||||
|
|
@ -97,6 +98,14 @@ export class OverlaysManager {
|
|||
this.hide(ctrlToShow);
|
||||
}
|
||||
this.__shownList.unshift(ctrlToShow);
|
||||
|
||||
// make sure latest shown ctrl is visible
|
||||
Array.from(this.__shownList)
|
||||
.reverse()
|
||||
.forEach((ctrl, i) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
ctrl.elevation = i + 1;
|
||||
});
|
||||
}
|
||||
|
||||
hide(ctrlToHide) {
|
||||
|
|
@ -107,6 +116,10 @@ export class OverlaysManager {
|
|||
}
|
||||
|
||||
teardown() {
|
||||
this.list.forEach(ctrl => {
|
||||
ctrl.teardown();
|
||||
});
|
||||
|
||||
this.__list = [];
|
||||
this.__shownList = [];
|
||||
this.__siblingsInert = false;
|
||||
|
|
@ -159,4 +172,41 @@ export class OverlaysManager {
|
|||
this.__siblingsInert = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** PreventsScroll */
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
requestToPreventScroll() {
|
||||
// no check as classList will dedupe it anyways
|
||||
document.body.classList.add('global-overlays-scroll-lock');
|
||||
if (isIOS) {
|
||||
// iOS has issues with overlays with input fields. This is fixed by applying
|
||||
// position: fixed to the body. As a side effect, this will scroll the body to the top.
|
||||
document.body.classList.add('global-overlays-scroll-lock-ios-fix');
|
||||
}
|
||||
}
|
||||
|
||||
requestToEnableScroll() {
|
||||
if (!this.shownList.some(ctrl => ctrl.preventsScroll === true)) {
|
||||
document.body.classList.remove('global-overlays-scroll-lock');
|
||||
if (isIOS) {
|
||||
document.body.classList.remove('global-overlays-scroll-lock-ios-fix');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Blocking */
|
||||
requestToShowOnly(blockingCtrl) {
|
||||
const controllersToHide = this.shownList.filter(ctrl => ctrl !== blockingCtrl);
|
||||
|
||||
controllersToHide.map(ctrl => ctrl.hide());
|
||||
this.__blockingMap.set(blockingCtrl, controllersToHide);
|
||||
}
|
||||
|
||||
retractRequestToShowOnly(blockingCtrl) {
|
||||
if (this.__blockingMap.has(blockingCtrl)) {
|
||||
const controllersWhichGotHidden = this.__blockingMap.get(blockingCtrl);
|
||||
controllersWhichGotHidden.map(ctrl => ctrl.show());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
.global-overlays__overlay,
|
||||
.global-overlays__overlay--blocking {
|
||||
.global-overlays__overlay {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
|
|
@ -69,17 +68,7 @@ export const globalOverlaysStyle = css`
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.global-overlays.global-overlays--blocking-opened .global-overlays__overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.global-overlays.global-overlays--blocking-opened .global-overlays__backdrop {
|
||||
animation: global-overlays-backdrop-fade-out 300ms;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.global-overlays .global-overlays__backdrop,
|
||||
.global-overlays .global-overlays__backdrop--blocking {
|
||||
.global-overlays .global-overlays__backdrop {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
|
@ -89,6 +78,11 @@ export const globalOverlaysStyle = css`
|
|||
z-index: -1;
|
||||
background-color: #333333;
|
||||
opacity: 0.3;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.global-overlays .global-overlays__backdrop--visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.global-overlays .global-overlays__backdrop--fade-in {
|
||||
|
|
|
|||
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 { css } from '@lion/core';
|
||||
import { overlays, BottomSheetController } from '../index.js';
|
||||
import { fixtureSync } from '@open-wc/testing-helpers';
|
||||
import { OverlayController, withBottomSheetConfig } from '../index.js';
|
||||
|
||||
const bottomSheetDemoStyle = css`
|
||||
.demo-overlay {
|
||||
|
|
@ -12,16 +13,15 @@ const bottomSheetDemoStyle = css`
|
|||
`;
|
||||
|
||||
storiesOf('Global Overlay System|BottomSheet', module).add('Default', () => {
|
||||
const bottomSheetCtrl = overlays.add(
|
||||
new BottomSheetController({
|
||||
contentTemplate: () => html`
|
||||
const bottomSheetCtrl = new OverlayController({
|
||||
...withBottomSheetConfig(),
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-overlay">
|
||||
<p>BottomSheet</p>
|
||||
<button @click="${() => bottomSheetCtrl.hide()}">Close</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { storiesOf, html } from '@open-wc/demoing-storybook';
|
||||
import { fixtureSync } from '@open-wc/testing-helpers';
|
||||
|
||||
import { css } from '@lion/core';
|
||||
import {
|
||||
GlobalOverlayController,
|
||||
LocalOverlayController,
|
||||
DynamicOverlayController,
|
||||
BottomSheetController,
|
||||
OverlayController,
|
||||
withBottomSheetConfig,
|
||||
withModalDialogConfig,
|
||||
withDropdownConfig,
|
||||
} from '../index.js';
|
||||
import { overlays } from '../src/overlays.js';
|
||||
|
||||
const dynamicOverlayDemoStyle = css`
|
||||
.demo-overlay {
|
||||
|
|
@ -18,8 +18,8 @@ const dynamicOverlayDemoStyle = css`
|
|||
}
|
||||
|
||||
.demo-overlay__global--small {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
width: 100px;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
|
|
@ -41,211 +41,66 @@ const dynamicOverlayDemoStyle = css`
|
|||
}
|
||||
`;
|
||||
|
||||
storiesOf('Dynamic Overlay System|Switching Overlays', module)
|
||||
.add('Switch global overlays', () => {
|
||||
const invokerNode = document.createElement('button');
|
||||
invokerNode.innerHTML = 'Invoker Button';
|
||||
|
||||
const ctrl = new DynamicOverlayController();
|
||||
|
||||
const global1 = overlays.add(
|
||||
new BottomSheetController({
|
||||
contentTemplate: () => html`
|
||||
<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
|
||||
storiesOf('Dynamic Overlay System| Switching Overlays', module).add(
|
||||
'Switch overlays configuration',
|
||||
() => {
|
||||
const ctrl = new OverlayController({
|
||||
...withBottomSheetConfig(),
|
||||
hidesOnOutsideClick: true,
|
||||
trapsKeyboardFocus: true,
|
||||
invokerNode: fixtureSync(html`
|
||||
<button @click="${() => ctrl.toggle()}">
|
||||
Invoker
|
||||
</button>
|
||||
while overlay is hidden.
|
||||
</p>
|
||||
`;
|
||||
})
|
||||
.add('Switch local overlays', () => {
|
||||
const invokerNode = document.createElement('button');
|
||||
invokerNode.innerHTML = 'Invoker Button';
|
||||
|
||||
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 />
|
||||
`),
|
||||
contentNode: fixtureSync(html`
|
||||
<div
|
||||
style="background: #eee;"
|
||||
class="demo-overlay demo-overlay__global demo-overlay__global--small"
|
||||
>
|
||||
Content
|
||||
<button @click="${() => ctrl.hide()}">Close</button>
|
||||
</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) {
|
||||
if (x.matches) {
|
||||
// <= 600px
|
||||
ctrl.nextOpen = local1;
|
||||
} else {
|
||||
ctrl.nextOpen = local2;
|
||||
const ctrlType = document.createElement('div');
|
||||
function switchTo(type) {
|
||||
ctrlType.innerHTML = type;
|
||||
switch (type) {
|
||||
case 'bottom-sheet':
|
||||
ctrl.updateConfig(withBottomSheetConfig());
|
||||
break;
|
||||
case 'dropdown':
|
||||
ctrl.updateConfig({
|
||||
...withDropdownConfig(),
|
||||
hasBackdrop: false,
|
||||
viewportConfig: null,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
ctrl.updateConfig(withModalDialogConfig());
|
||||
}
|
||||
}
|
||||
const matchSmall = window.matchMedia('(max-width: 600px)');
|
||||
switchOnMediaChange(matchSmall); // call once manually to init
|
||||
matchSmall.addListener(switchOnMediaChange);
|
||||
|
||||
return html`
|
||||
<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`
|
||||
<style>
|
||||
${dynamicOverlayDemoStyle}
|
||||
</style>
|
||||
|
||||
<p>
|
||||
Shows "Buttom Sheet" for small (< 600px) screens and "Dropdown" for big (> 600px) screens
|
||||
</p>
|
||||
${ctrl.invoker}
|
||||
|
||||
<p>
|
||||
This button is indented to show the local positioning ${ctrl.invokerNode}${ctrl.content}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You can also
|
||||
<button @click="${() => ctrl.switchTo(ctrl.active === global ? local : global)}">
|
||||
force a switch
|
||||
<button @click="${() => switchTo('modal-dialog')}">
|
||||
as modal dialog
|
||||
</button>
|
||||
|
||||
<button @click="${() => switchTo('bottom-sheet')}">
|
||||
as bottom sheet
|
||||
</button>
|
||||
|
||||
<button @click="${() => switchTo('dropdown')}">
|
||||
as dropdown
|
||||
</button>
|
||||
while overlay is hidden.
|
||||
</p>
|
||||
`;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { storiesOf, html } from '@open-wc/demoing-storybook';
|
||||
|
||||
import { css } from '@lion/core';
|
||||
import { LionLitElement } from '@lion/core/src/LionLitElement.js';
|
||||
import { overlays, GlobalOverlayController } from '../index.js';
|
||||
import { css, LitElement } from '@lion/core';
|
||||
import { fixtureSync } from '@open-wc/testing-helpers';
|
||||
import { OverlayController } from '../index.js';
|
||||
|
||||
const globalOverlayDemoStyle = css`
|
||||
.demo-overlay {
|
||||
|
|
@ -12,36 +11,17 @@ const globalOverlayDemoStyle = css`
|
|||
}
|
||||
`;
|
||||
|
||||
let placement = 'center';
|
||||
const togglePlacement = overlayCtrl => {
|
||||
const placements = [
|
||||
'top-left',
|
||||
'top',
|
||||
'top-right',
|
||||
'right',
|
||||
'bottom-left',
|
||||
'bottom',
|
||||
'bottom-right',
|
||||
'left',
|
||||
'center',
|
||||
];
|
||||
placement = placements[(placements.indexOf(placement) + 1) % placements.length];
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
overlayCtrl.overlayContainerPlacementClass = `${overlayCtrl.overlayContainerClass}--${placement}`;
|
||||
};
|
||||
|
||||
storiesOf('Global Overlay System|Global Overlay', module)
|
||||
.add('Default', () => {
|
||||
const overlayCtrl = overlays.add(
|
||||
new GlobalOverlayController({
|
||||
contentTemplate: () => html`
|
||||
const overlayCtrl = new OverlayController({
|
||||
placementMode: 'global',
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-overlay">
|
||||
<p>Simple overlay</p>
|
||||
<button @click="${() => overlayCtrl.hide()}">Close</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
|
|
@ -64,17 +44,16 @@ storiesOf('Global Overlay System|Global Overlay', module)
|
|||
`;
|
||||
})
|
||||
.add('Option "preventsScroll"', () => {
|
||||
const overlayCtrl = overlays.add(
|
||||
new GlobalOverlayController({
|
||||
const overlayCtrl = new OverlayController({
|
||||
placementMode: 'global',
|
||||
preventsScroll: true,
|
||||
contentTemplate: () => html`
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-overlay">
|
||||
<p>Scrolling the body is blocked</p>
|
||||
<button @click="${() => overlayCtrl.hide()}">Close</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
|
|
@ -95,17 +74,16 @@ storiesOf('Global Overlay System|Global Overlay', module)
|
|||
`;
|
||||
})
|
||||
.add('Option "hasBackdrop"', () => {
|
||||
const overlayCtrl = overlays.add(
|
||||
new GlobalOverlayController({
|
||||
const overlayCtrl = new OverlayController({
|
||||
placementMode: 'global',
|
||||
hasBackdrop: true,
|
||||
contentTemplate: () => html`
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-overlay">
|
||||
<p>There is a backdrop</p>
|
||||
<button @click="${() => overlayCtrl.hide()}">Close</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
|
|
@ -121,10 +99,10 @@ storiesOf('Global Overlay System|Global Overlay', module)
|
|||
`;
|
||||
})
|
||||
.add('Option "trapsKeyboardFocus"', () => {
|
||||
const overlayCtrl = overlays.add(
|
||||
new GlobalOverlayController({
|
||||
const overlayCtrl = new OverlayController({
|
||||
placementMode: 'global',
|
||||
trapsKeyboardFocus: true,
|
||||
contentTemplate: () => html`
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-overlay">
|
||||
<p>Tab key is trapped within the overlay</p>
|
||||
|
||||
|
|
@ -139,9 +117,8 @@ storiesOf('Global Overlay System|Global Overlay', module)
|
|||
</select>
|
||||
<button @click="${() => overlayCtrl.hide()}">Close</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
|
|
@ -159,25 +136,24 @@ storiesOf('Global Overlay System|Global Overlay', module)
|
|||
`;
|
||||
})
|
||||
.add('Option "trapsKeyboardFocus" (multiple)', () => {
|
||||
const overlayCtrl2 = overlays.add(
|
||||
new GlobalOverlayController({
|
||||
const overlayCtrl2 = new OverlayController({
|
||||
placementMode: 'global',
|
||||
trapsKeyboardFocus: true,
|
||||
viewportConfig: {
|
||||
placement: 'left',
|
||||
},
|
||||
contentTemplate: () => html`
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-overlay">
|
||||
<p>Overlay 2. Tab key is trapped within the overlay</p>
|
||||
<button @click="${() => overlayCtrl2.hide()}">Close</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
});
|
||||
|
||||
const overlayCtrl1 = overlays.add(
|
||||
new GlobalOverlayController({
|
||||
const overlayCtrl1 = new OverlayController({
|
||||
placementMode: 'global',
|
||||
trapsKeyboardFocus: true,
|
||||
contentTemplate: () => html`
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-overlay">
|
||||
<p>Overlay 1. Tab key is trapped within the overlay</p>
|
||||
<button
|
||||
|
|
@ -189,9 +165,8 @@ storiesOf('Global Overlay System|Global Overlay', module)
|
|||
</button>
|
||||
<button @click="${() => overlayCtrl1.hide()}">Close</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
|
|
@ -209,24 +184,23 @@ storiesOf('Global Overlay System|Global Overlay', module)
|
|||
`;
|
||||
})
|
||||
.add('Option "isBlocking"', () => {
|
||||
const blockingOverlayCtrl = overlays.add(
|
||||
new GlobalOverlayController({
|
||||
const blockingOverlayCtrl = new OverlayController({
|
||||
placementMode: 'global',
|
||||
isBlocking: true,
|
||||
viewportConfig: {
|
||||
placement: 'left',
|
||||
},
|
||||
contentTemplate: () => html`
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-overlay">
|
||||
<p>Hides other overlays</p>
|
||||
<button @click="${() => blockingOverlayCtrl.hide()}">Close</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
});
|
||||
|
||||
const normalOverlayCtrl = overlays.add(
|
||||
new GlobalOverlayController({
|
||||
contentTemplate: () => html`
|
||||
const normalOverlayCtrl = new OverlayController({
|
||||
placementMode: 'global',
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-overlay">
|
||||
<p>Normal overlay</p>
|
||||
<button
|
||||
|
|
@ -238,9 +212,8 @@ storiesOf('Global Overlay System|Global Overlay', module)
|
|||
</button>
|
||||
<button @click="${() => normalOverlayCtrl.hide()}">Close</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
|
|
@ -256,192 +229,106 @@ storiesOf('Global Overlay System|Global Overlay', module)
|
|||
`;
|
||||
})
|
||||
.add('Option "viewportConfig:placement"', () => {
|
||||
const overlayCtrl = overlays.add(
|
||||
new GlobalOverlayController({
|
||||
viewportConfig: {
|
||||
placement: 'center',
|
||||
},
|
||||
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 {
|
||||
const tagName = 'lion-overlay-placement-demo';
|
||||
if (!customElements.get(tagName)) {
|
||||
customElements.define(
|
||||
tagName,
|
||||
class extends LitElement {
|
||||
static get properties() {
|
||||
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() {
|
||||
return html`
|
||||
<div>
|
||||
<button class="close-button" @click="${this._onClose}">X</button>
|
||||
<label for="usernameInput">Edit Username</label>
|
||||
<input id="usernameInput" value="${this.username}" />
|
||||
<button @click="${this._onUsernameEdited}">
|
||||
Save
|
||||
<p>Overlay placement: ${this.placement}</p>
|
||||
<button @click="${this._togglePlacement}">
|
||||
Toggle ${this.placement} position
|
||||
</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(
|
||||
new CustomEvent('edit-username-submitted', {
|
||||
detail: this.$id('usernameInput').value,
|
||||
new CustomEvent('toggle-placement', {
|
||||
detail: this.placement,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
_onClose() {
|
||||
this.dispatchEvent(new CustomEvent('edit-username-closed'));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!customElements.get('edit-username-overlay')) {
|
||||
customElements.define('edit-username-overlay', EditUsernameOverlay);
|
||||
}
|
||||
class MyComponent extends LionLitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
username: { type: String },
|
||||
_editingUsername: { type: Boolean },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.username = 'Steve';
|
||||
this._editingUsername = false;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._editOverlay.hide();
|
||||
}
|
||||
|
||||
render() {
|
||||
const initialPlacement = 'center';
|
||||
const overlayCtrl = new OverlayController({
|
||||
placementMode: 'global',
|
||||
viewportConfig: {
|
||||
placement: initialPlacement,
|
||||
},
|
||||
contentNode: fixtureSync(html`
|
||||
<lion-overlay-placement-demo class="demo-overlay"> </lion-overlay-placement-demo>
|
||||
`),
|
||||
});
|
||||
const element = overlayCtrl.content.querySelector(tagName);
|
||||
element.placement = initialPlacement;
|
||||
element.addEventListener('toggle-placement', e => {
|
||||
overlayCtrl.updateConfig({ viewportConfig: { placement: e.detail } });
|
||||
});
|
||||
element.addEventListener('close', () => {
|
||||
overlayCtrl.hide();
|
||||
});
|
||||
return html`
|
||||
<p>Your username is: ${this.username}</p>
|
||||
<button @click=${this._onStartEditUsername} aria-haspopup="dialog" aria-expanded="false">
|
||||
Edit username
|
||||
<style>
|
||||
${globalOverlayDemoStyle}
|
||||
</style>
|
||||
<button @click="${e => overlayCtrl.show(e.target)}">
|
||||
Open overlay
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this._editOverlay = overlays.add(
|
||||
new GlobalOverlayController({
|
||||
focusElementAfterHide: this.shadowRoot.querySelector('button'),
|
||||
contentTemplate: ({ username = 'standard' } = {}) => html`
|
||||
<edit-username-overlay
|
||||
username="${username}"
|
||||
@edit-username-submitted="${e => this._onEditSubmitted(e)}"
|
||||
@edit-username-closed="${() => this._onEditClosed()}"
|
||||
>
|
||||
</edit-username-overlay>
|
||||
`,
|
||||
}),
|
||||
})
|
||||
.add('Option "hidesOnOutsideClick"', () => {
|
||||
const shadowContent = document.createElement('div');
|
||||
shadowContent.attachShadow({ mode: 'open' });
|
||||
shadowContent.shadowRoot.appendChild(
|
||||
fixtureSync(html`
|
||||
<div style="width: 100px; padding: 10px; background: black; color: white;">
|
||||
Shadow area
|
||||
</div>
|
||||
`),
|
||||
);
|
||||
}
|
||||
|
||||
updated() {
|
||||
this._editOverlay.sync({
|
||||
isShown: this._editingUsername,
|
||||
data: { username: this.username },
|
||||
const ctrl = new OverlayController({
|
||||
placementMode: 'global',
|
||||
hidesOnOutsideClick: true,
|
||||
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`
|
||||
<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 { fixtureSync } from '@open-wc/testing-helpers';
|
||||
import { css } from '@lion/core';
|
||||
import { LocalOverlayController } from '../src/LocalOverlayController.js';
|
||||
import { overlays } from '../src/overlays.js';
|
||||
import { OverlayController } from '../index.js';
|
||||
|
||||
let placement = 'top';
|
||||
const togglePlacement = popupController => {
|
||||
|
|
@ -46,20 +46,16 @@ const popupPlacementDemoStyle = css`
|
|||
storiesOf('Local Overlay System|Local Overlay Placement', module)
|
||||
.addParameters({ options: { selectedPanel: 'storybook/actions/actions-panel' } })
|
||||
.add('Preferred placement overlay absolute', () => {
|
||||
let popup;
|
||||
const invokerNode = document.createElement('button');
|
||||
invokerNode.innerHTML = 'UK';
|
||||
invokerNode.addEventListener('click', () => popup.toggle());
|
||||
|
||||
popup = overlays.add(
|
||||
new LocalOverlayController({
|
||||
const popup = new OverlayController({
|
||||
placementMode: 'local',
|
||||
hidesOnEsc: true,
|
||||
contentTemplate: () => html`
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-popup">United Kingdom</div>
|
||||
`,
|
||||
invokerNode,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
invokerNode: fixtureSync(html`
|
||||
<button @click="${() => popup.toggle()}">UK</button>
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
|
|
@ -67,18 +63,13 @@ storiesOf('Local Overlay System|Local Overlay Placement', module)
|
|||
</style>
|
||||
<button @click=${() => togglePlacement(popup)}>Toggle placement</button>
|
||||
<div class="demo-box">
|
||||
${invokerNode} ${popup.content}
|
||||
${popup.invoker}${popup.content}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.add('Override the popper config', () => {
|
||||
let popup;
|
||||
const invokerNode = document.createElement('button');
|
||||
invokerNode.innerHTML = 'UK';
|
||||
invokerNode.addEventListener('click', () => popup.toggle());
|
||||
|
||||
popup = overlays.add(
|
||||
new LocalOverlayController({
|
||||
const popup = new OverlayController({
|
||||
placementMode: 'local',
|
||||
hidesOnEsc: true,
|
||||
popperConfig: {
|
||||
placement: 'bottom-start',
|
||||
|
|
@ -102,13 +93,13 @@ storiesOf('Local Overlay System|Local Overlay Placement', module)
|
|||
},
|
||||
},
|
||||
},
|
||||
contentTemplate: () =>
|
||||
html`
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-popup">United Kingdom</div>
|
||||
`,
|
||||
invokerNode,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
invokerNode: fixtureSync(html`
|
||||
<button @click="${() => popup.toggle()}">UK</button>
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
|
|
@ -120,7 +111,7 @@ storiesOf('Local Overlay System|Local Overlay Placement', module)
|
|||
</div>
|
||||
<button @click=${() => togglePlacement(popup)}>Toggle placement</button>
|
||||
<div class="demo-box">
|
||||
${invokerNode} ${popup.content}
|
||||
${popup.invoker} ${popup.content}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
|
@ -154,7 +145,7 @@ storiesOf('Local Overlay System|Local Overlay Placement', module)
|
|||
<button @click=${() => popup.hide()}>Close popup</button>
|
||||
</div>
|
||||
<div class="demo-box">
|
||||
${invokerNode} ${popup.content}
|
||||
${invoker} ${popup.content}
|
||||
</div>
|
||||
`;
|
||||
}); */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { storiesOf, html } from '@open-wc/demoing-storybook';
|
||||
import { fixtureSync } from '@open-wc/testing-helpers';
|
||||
import { css } from '@lion/core';
|
||||
import { overlays, LocalOverlayController } from '../index.js';
|
||||
import { OverlayController } from '../index.js';
|
||||
|
||||
const popupDemoStyle = css`
|
||||
.demo-box {
|
||||
|
|
@ -16,7 +17,6 @@ const popupDemoStyle = css`
|
|||
.demo-popup {
|
||||
display: block;
|
||||
max-width: 250px;
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
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);
|
||||
|
|
@ -26,156 +26,137 @@ const popupDemoStyle = css`
|
|||
|
||||
storiesOf('Local Overlay System|Local Overlay', module)
|
||||
.add('Basic', () => {
|
||||
let popup;
|
||||
const invokerNode = document.createElement('button');
|
||||
invokerNode.innerHTML = 'UK';
|
||||
invokerNode.addEventListener('click', () => popup.toggle());
|
||||
|
||||
popup = overlays.add(
|
||||
new LocalOverlayController({
|
||||
const popup = new OverlayController({
|
||||
placementMode: 'local',
|
||||
hidesOnEsc: true,
|
||||
hidesOnOutsideClick: true,
|
||||
contentTemplate: () => html`
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-popup">United Kingdom</div>
|
||||
`,
|
||||
invokerNode,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
invokerNode: fixtureSync(html`
|
||||
<button @click=${() => popup.toggle()}>UK</button>
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
${popupDemoStyle}
|
||||
</style>
|
||||
<div class="demo-box">
|
||||
In the ${invokerNode}${popup.content} the weather is nice.
|
||||
In the ${popup.invoker}${popup.content} the weather is nice.
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.add('Change preferred position', () => {
|
||||
let popup;
|
||||
const invokerNode = document.createElement('button');
|
||||
invokerNode.innerHTML = 'UK';
|
||||
invokerNode.addEventListener('click', () => popup.toggle());
|
||||
|
||||
popup = overlays.add(
|
||||
new LocalOverlayController({
|
||||
const popup = new OverlayController({
|
||||
placementMode: 'local',
|
||||
hidesOnEsc: true,
|
||||
hidesOnOutsideClick: true,
|
||||
popperConfig: {
|
||||
placement: 'top-end',
|
||||
},
|
||||
contentTemplate: () => html`
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-popup">United Kingdom</div>
|
||||
`,
|
||||
invokerNode,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
invokerNode: fixtureSync(html`
|
||||
<button @click=${() => popup.toggle()}>UK</button>
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
${popupDemoStyle}
|
||||
</style>
|
||||
<div class="demo-box">
|
||||
In the ${invokerNode}${popup.content} the weather is nice.
|
||||
In the ${popup.invoker}${popup.content} the weather is nice.
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.add('Single placement parameter', () => {
|
||||
let popup;
|
||||
const invokerNode = document.createElement('button');
|
||||
invokerNode.innerHTML = 'Click me';
|
||||
invokerNode.addEventListener('click', () => popup.toggle());
|
||||
|
||||
popup = overlays.add(
|
||||
new LocalOverlayController({
|
||||
const popup = new OverlayController({
|
||||
placementMode: 'local',
|
||||
hidesOnEsc: true,
|
||||
hidesOnOutsideClick: true,
|
||||
popperConfig: {
|
||||
placement: 'bottom',
|
||||
},
|
||||
contentTemplate: () => html`
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-popup">
|
||||
Supplying placement with a single parameter will assume 'center' for the other.
|
||||
</div>
|
||||
`,
|
||||
invokerNode,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
invokerNode: fixtureSync(html`
|
||||
<button @click=${() => popup.toggle()}>UK</button>
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
${popupDemoStyle}
|
||||
</style>
|
||||
<div class="demo-box">
|
||||
${invokerNode}${popup.content}
|
||||
${popup.invoker}${popup.content}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.add('On hover', () => {
|
||||
let popup;
|
||||
const invokerNode = document.createElement('button');
|
||||
invokerNode.innerHTML = 'UK';
|
||||
invokerNode.addEventListener('mouseenter', () => popup.show());
|
||||
invokerNode.addEventListener('mouseleave', () => popup.hide());
|
||||
|
||||
popup = overlays.add(
|
||||
new LocalOverlayController({
|
||||
const popup = new OverlayController({
|
||||
placementMode: 'local',
|
||||
hidesOnEsc: true,
|
||||
hidesOnOutsideClick: true,
|
||||
popperConfig: {
|
||||
placement: 'bottom',
|
||||
},
|
||||
contentTemplate: () =>
|
||||
html`
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-popup">United Kingdom</div>
|
||||
`,
|
||||
invokerNode,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
invokerNode: fixtureSync(html`
|
||||
<button @mouseenter=${() => popup.show()} @mouseleave=${() => popup.hide()}>UK</button>
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
${popupDemoStyle}
|
||||
</style>
|
||||
<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>
|
||||
`;
|
||||
})
|
||||
.add('On an input', () => {
|
||||
let popup;
|
||||
const invokerNode = document.createElement('input');
|
||||
invokerNode.id = 'input';
|
||||
invokerNode.type = 'text';
|
||||
invokerNode.addEventListener('focusin', () => popup.show());
|
||||
invokerNode.addEventListener('focusout', () => popup.hide());
|
||||
|
||||
popup = overlays.add(
|
||||
new LocalOverlayController({
|
||||
contentTemplate: () => html`
|
||||
const popup = new OverlayController({
|
||||
placementMode: 'local',
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-popup">United Kingdom</div>
|
||||
`,
|
||||
invokerNode,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
invokerNode: fixtureSync(html`
|
||||
<input
|
||||
id="input"
|
||||
type="text"
|
||||
@focusin=${() => popup.show()}
|
||||
@focusout=${() => popup.hide()}
|
||||
/>
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
${popupDemoStyle}
|
||||
</style>
|
||||
<div class="demo-box">
|
||||
<label for="input">Input with a dropdown</label>
|
||||
${invokerNode}${popup.content}
|
||||
${popup.invoker}${popup.content}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.add('trapsKeyboardFocus', () => {
|
||||
let popup;
|
||||
const invokerNode = document.createElement('button');
|
||||
invokerNode.innerHTML = 'Click me';
|
||||
invokerNode.addEventListener('click', () => popup.toggle());
|
||||
|
||||
popup = overlays.add(
|
||||
new LocalOverlayController({
|
||||
const popup = new OverlayController({
|
||||
placementMode: 'local',
|
||||
hidesOnEsc: true,
|
||||
hidesOnOutsideClick: true,
|
||||
trapsKeyboardFocus: true,
|
||||
contentTemplate: () => html`
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-popup">
|
||||
<button id="el1">Button</button>
|
||||
<a id="el2" href="#">Anchor</a>
|
||||
|
|
@ -187,50 +168,18 @@ storiesOf('Local Overlay System|Local Overlay', module)
|
|||
<option>1</option>
|
||||
</select>
|
||||
</div>
|
||||
`,
|
||||
invokerNode,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
invokerNode: fixtureSync(html`
|
||||
<button @click=${() => popup.toggle()}>UK</button>
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
${popupDemoStyle}
|
||||
</style>
|
||||
<div class="demo-box">
|
||||
${invokerNode}${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}
|
||||
${popup.invoker}${popup.content}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { storiesOf, html } from '@open-wc/demoing-storybook';
|
||||
|
||||
import { fixtureSync } from '@open-wc/testing-helpers';
|
||||
import { css } from '@lion/core';
|
||||
import { overlays, ModalDialogController } from '../index.js';
|
||||
import { OverlayController, withModalDialogConfig } from '../index.js';
|
||||
|
||||
const modalDialogDemoStyle = css`
|
||||
.demo-overlay {
|
||||
|
|
@ -13,20 +13,19 @@ const modalDialogDemoStyle = css`
|
|||
|
||||
storiesOf('Global Overlay System|Modal Dialog', module)
|
||||
.add('Default', () => {
|
||||
const nestedDialogCtrl = overlays.add(
|
||||
new ModalDialogController({
|
||||
contentTemplate: () => html`
|
||||
<div class="demo-overlay">
|
||||
const nestedDialogCtrl = new OverlayController({
|
||||
...withModalDialogConfig(),
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-overlay" style="margin-top: -100px;">
|
||||
<p>Nested modal dialog</p>
|
||||
<button @click="${() => nestedDialogCtrl.hide()}">Close</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
});
|
||||
|
||||
const dialogCtrl = overlays.add(
|
||||
new ModalDialogController({
|
||||
contentTemplate: () => html`
|
||||
const dialogCtrl = new OverlayController({
|
||||
...withModalDialogConfig(),
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-overlay">
|
||||
<p>Modal dialog</p>
|
||||
<button @click="${() => dialogCtrl.hide()}">Close</button>
|
||||
|
|
@ -38,9 +37,8 @@ storiesOf('Global Overlay System|Modal Dialog', module)
|
|||
Open nested dialog
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<style>
|
||||
|
|
@ -63,21 +61,23 @@ storiesOf('Global Overlay System|Modal Dialog', module)
|
|||
`;
|
||||
})
|
||||
.add('Option "isBlocking"', () => {
|
||||
const blockingDialogCtrl = overlays.add(
|
||||
new ModalDialogController({
|
||||
const blockingDialogCtrl = new OverlayController({
|
||||
...withModalDialogConfig(),
|
||||
isBlocking: true,
|
||||
contentTemplate: () => html`
|
||||
viewportConfig: {
|
||||
placement: 'top',
|
||||
},
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-overlay demo-overlay--2">
|
||||
<p>Hides other dialogs</p>
|
||||
<button @click="${() => blockingDialogCtrl.hide()}">Close</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
});
|
||||
|
||||
const normalDialogCtrl = overlays.add(
|
||||
new ModalDialogController({
|
||||
contentTemplate: () => html`
|
||||
const normalDialogCtrl = new OverlayController({
|
||||
...withModalDialogConfig(),
|
||||
contentNode: fixtureSync(html`
|
||||
<div class="demo-overlay">
|
||||
<p>Normal dialog</p>
|
||||
<button
|
||||
|
|
@ -89,9 +89,8 @@ storiesOf('Global Overlay System|Modal Dialog', module)
|
|||
</button>
|
||||
<button @click="${() => normalDialogCtrl.hide()}">Close</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
);
|
||||
`),
|
||||
});
|
||||
|
||||
return html`
|
||||
<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 { BaseOverlayController } from '../src/BaseOverlayController.js';
|
||||
import { OverlayController } from '../src/OverlayController.js';
|
||||
|
||||
describe('OverlaysManager', () => {
|
||||
let defaultOptions;
|
||||
let mngr;
|
||||
|
||||
before(() => {
|
||||
defaultOptions = {
|
||||
contentTemplate: () => html`
|
||||
before(async () => {
|
||||
const contentNode = await fixture(html`
|
||||
<p>my content</p>
|
||||
`,
|
||||
`);
|
||||
|
||||
defaultOptions = {
|
||||
placementMode: 'global',
|
||||
contentNode,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -43,16 +45,10 @@ describe('OverlaysManager', () => {
|
|||
expect(mngr.constructor.__globalStyleNode).to.be.undefined;
|
||||
});
|
||||
|
||||
it('returns the newly added overlay', () => {
|
||||
const myController = new BaseOverlayController(defaultOptions);
|
||||
expect(mngr.add(myController)).to.equal(myController);
|
||||
});
|
||||
|
||||
it('can add/remove controllers', () => {
|
||||
const dialog = new BaseOverlayController(defaultOptions);
|
||||
const popup = new BaseOverlayController(defaultOptions);
|
||||
mngr.add(dialog);
|
||||
mngr.add(popup);
|
||||
// OverlayControllers will add themselves
|
||||
const dialog = new OverlayController(defaultOptions, mngr);
|
||||
const popup = new OverlayController(defaultOptions, mngr);
|
||||
|
||||
expect(mngr.list).to.deep.equal([dialog, popup]);
|
||||
|
||||
|
|
@ -64,29 +60,25 @@ describe('OverlaysManager', () => {
|
|||
});
|
||||
|
||||
it('throws if you try to add the same controller', () => {
|
||||
const ctrl = new BaseOverlayController(defaultOptions);
|
||||
mngr.add(ctrl);
|
||||
const ctrl = new OverlayController(defaultOptions, mngr);
|
||||
expect(() => mngr.add(ctrl)).to.throw('controller instance is already added');
|
||||
});
|
||||
|
||||
it('throws if you try to remove a non existing controller', () => {
|
||||
const ctrl = new BaseOverlayController(defaultOptions);
|
||||
// we do not pass one our own manager so it will not be added to it
|
||||
const ctrl = new OverlayController(defaultOptions);
|
||||
expect(() => mngr.remove(ctrl)).to.throw('could not find controller to remove');
|
||||
});
|
||||
|
||||
it('adds a reference to the manager to the controller', () => {
|
||||
const dialog = new BaseOverlayController(defaultOptions);
|
||||
mngr.add(dialog);
|
||||
const dialog = new OverlayController(defaultOptions, mngr);
|
||||
|
||||
expect(dialog.manager).to.equal(mngr);
|
||||
});
|
||||
|
||||
it('has a .shownList which is ordered based on last shown', async () => {
|
||||
const dialog = new BaseOverlayController(defaultOptions);
|
||||
const dialog2 = new BaseOverlayController(defaultOptions);
|
||||
mngr.add(dialog);
|
||||
mngr.add(dialog2);
|
||||
|
||||
const dialog = new OverlayController(defaultOptions, mngr);
|
||||
const dialog2 = new OverlayController(defaultOptions, mngr);
|
||||
expect(mngr.shownList).to.deep.equal([]);
|
||||
|
||||
await dialog.show();
|
||||
|
|
|
|||
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