Merge pull request #288 from ing-bank/next-overlays2

merge next overlays to master
This commit is contained in:
Thijs Louisse 2019-09-25 13:08:28 +02:00 committed by GitHub
commit ca067fb8fc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
75 changed files with 3386 additions and 1845 deletions

View file

@ -11,9 +11,9 @@
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/eslint-config": "^1.0.0", "@open-wc/eslint-config": "^1.0.0",
"@open-wc/prettier-config": "^0.1.0", "@open-wc/prettier-config": "^0.1.0",
"@open-wc/testing": "^2.3.2", "@open-wc/testing": "^2.3.4",
"@open-wc/testing-karma": "^3.1.22", "@open-wc/testing-karma": "^3.1.33",
"@open-wc/testing-karma-bs": "^1.1.47", "@open-wc/testing-karma-bs": "^1.1.58",
"@open-wc/testing-wallaby": "^0.1.12", "@open-wc/testing-wallaby": "^0.1.12",
"@webcomponents/webcomponentsjs": "^2.2.5", "@webcomponents/webcomponentsjs": "^2.2.5",
"babel-eslint": "^8.2.6", "babel-eslint": "^8.2.6",

View file

@ -37,7 +37,7 @@
}, },
"devDependencies": { "devDependencies": {
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6", "@open-wc/testing": "^2.3.4",
"sinon": "^7.2.2" "sinon": "^7.2.2"
} }
} }

View file

@ -39,7 +39,7 @@
"@lion/icon": "^0.2.6", "@lion/icon": "^0.2.6",
"@lion/input": "^0.1.52", "@lion/input": "^0.1.52",
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6", "@open-wc/testing": "^2.3.4",
"@polymer/iron-test-helpers": "^3.0.1", "@polymer/iron-test-helpers": "^3.0.1",
"sinon": "^7.2.2" "sinon": "^7.2.2"
} }

View file

@ -39,7 +39,7 @@
"devDependencies": { "devDependencies": {
"@lion/button": "^0.3.16", "@lion/button": "^0.3.16",
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6", "@open-wc/testing": "^2.3.4",
"sinon": "^7.2.2" "sinon": "^7.2.2"
} }
} }

View file

@ -1,5 +1,6 @@
import { html, LitElement } from '@lion/core'; import { html, LitElement } from '@lion/core';
import { localize, getWeekdayNames, getMonthNames, LocalizeMixin } from '@lion/localize'; import { localize, getWeekdayNames, getMonthNames, LocalizeMixin } from '@lion/localize';
import '@lion/core/src/differentKeyEventNamesShimIE.js';
import { createMultipleMonth } from './utils/createMultipleMonth.js'; import { createMultipleMonth } from './utils/createMultipleMonth.js';
import { dayTemplate } from './utils/dayTemplate.js'; import { dayTemplate } from './utils/dayTemplate.js';
import { dataTemplate } from './utils/dataTemplate.js'; import { dataTemplate } from './utils/dataTemplate.js';
@ -7,7 +8,6 @@ import { getFirstDayNextMonth } from './utils/getFirstDayNextMonth.js';
import { getLastDayPreviousMonth } from './utils/getLastDayPreviousMonth.js'; import { getLastDayPreviousMonth } from './utils/getLastDayPreviousMonth.js';
import { isSameDate } from './utils/isSameDate.js'; import { isSameDate } from './utils/isSameDate.js';
import { calendarStyle } from './calendarStyle.js'; import { calendarStyle } from './calendarStyle.js';
import './utils/differentKeyNamesShimIE.js';
import { createDay } from './utils/createDay.js'; import { createDay } from './utils/createDay.js';
import { normalizeDateTime } from './utils/normalizeDateTime.js'; import { normalizeDateTime } from './utils/normalizeDateTime.js';

View file

@ -1,33 +0,0 @@
const event = KeyboardEvent.prototype;
const descriptor = Object.getOwnPropertyDescriptor(event, 'key');
if (descriptor) {
const keys = {
Win: 'Meta',
Scroll: 'ScrollLock',
Spacebar: ' ',
Down: 'ArrowDown',
Left: 'ArrowLeft',
Right: 'ArrowRight',
Up: 'ArrowUp',
Del: 'Delete',
Apps: 'ContextMenu',
Esc: 'Escape',
Multiply: '*',
Add: '+',
Subtract: '-',
Decimal: '.',
Divide: '/',
};
Object.defineProperty(event, 'key', {
// eslint-disable-next-line object-shorthand, func-names
get: function() {
const key = descriptor.get.call(this);
// eslint-disable-next-line no-prototype-builtins
return keys.hasOwnProperty(key) ? keys[key] : key;
},
});
}

View file

@ -1,4 +1,5 @@
import { expect, fixture } from '@open-wc/testing'; import { expect, fixture } from '@open-wc/testing';
import '@lion/core/test-helpers/keyboardEventShimIE.js';
import sinon from 'sinon'; import sinon from 'sinon';
import { html } from '@lion/core'; import { html } from '@lion/core';
@ -6,7 +7,6 @@ import { localize } from '@lion/localize';
import { localizeTearDown } from '@lion/localize/test-helpers.js'; import { localizeTearDown } from '@lion/localize/test-helpers.js';
import { CalendarObject, DayObject } from '../test-helpers.js'; import { CalendarObject, DayObject } from '../test-helpers.js';
import './keyboardEventShimIE.js';
import { isSameDate } from '../src/utils/isSameDate.js'; import { isSameDate } from '../src/utils/isSameDate.js';
import '../lion-calendar.js'; import '../lion-calendar.js';

View file

@ -40,7 +40,7 @@
"@lion/form": "^0.1.58", "@lion/form": "^0.1.58",
"@lion/localize": "^0.4.15", "@lion/localize": "^0.4.15",
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6", "@open-wc/testing": "^2.3.4",
"sinon": "^7.2.2" "sinon": "^7.2.2"
} }
} }

View file

@ -38,6 +38,6 @@
}, },
"devDependencies": { "devDependencies": {
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6" "@open-wc/testing": "^2.3.4"
} }
} }

View file

@ -38,7 +38,7 @@
"devDependencies": { "devDependencies": {
"@lion/input": "^0.1.52", "@lion/input": "^0.1.52",
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6", "@open-wc/testing": "^2.3.4",
"sinon": "^7.2.2" "sinon": "^7.2.2"
} }
} }

View file

@ -26,7 +26,7 @@
"docs", "docs",
"src", "src",
"stories", "stories",
"test", "test-helpers",
"translations", "translations",
"*.js" "*.js"
], ],
@ -36,7 +36,7 @@
}, },
"devDependencies": { "devDependencies": {
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6", "@open-wc/testing": "^2.3.4",
"sinon": "^7.2.2" "sinon": "^7.2.2"
} }
} }

View file

@ -0,0 +1,36 @@
if (typeof window.KeyboardEvent !== 'function') {
// e.g. is IE and needs "polyfill"
const event = KeyboardEvent.prototype;
const descriptor = Object.getOwnPropertyDescriptor(event, 'key');
if (descriptor) {
const keys = {
Win: 'Meta',
Scroll: 'ScrollLock',
Spacebar: ' ',
Down: 'ArrowDown',
Left: 'ArrowLeft',
Right: 'ArrowRight',
Up: 'ArrowUp',
Del: 'Delete',
Apps: 'ContextMenu',
Esc: 'Escape',
Multiply: '*',
Add: '+',
Subtract: '-',
Decimal: '.',
Divide: '/',
};
Object.defineProperty(event, 'key', {
// eslint-disable-next-line object-shorthand, func-names
get: function() {
const key = descriptor.get.call(this);
// eslint-disable-next-line no-prototype-builtins
return keys.hasOwnProperty(key) ? keys[key] : key;
},
});
}
}

View file

@ -39,7 +39,7 @@
"devDependencies": { "devDependencies": {
"@lion/localize": "^0.4.15", "@lion/localize": "^0.4.15",
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6", "@open-wc/testing": "^2.3.4",
"sinon": "^7.2.2" "sinon": "^7.2.2"
} }
} }

View file

@ -40,7 +40,7 @@
"@lion/input": "^0.1.52", "@lion/input": "^0.1.52",
"@lion/localize": "^0.4.15", "@lion/localize": "^0.4.15",
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6", "@open-wc/testing": "^2.3.4",
"sinon": "^7.2.2" "sinon": "^7.2.2"
} }
} }

View file

@ -49,6 +49,6 @@
"@lion/textarea": "^0.1.55", "@lion/textarea": "^0.1.55",
"@lion/validate": "^0.2.30", "@lion/validate": "^0.2.30",
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6" "@open-wc/testing": "^2.3.4"
} }
} }

View file

@ -41,7 +41,7 @@
"@lion/textarea": "^0.1.55", "@lion/textarea": "^0.1.55",
"@lion/validate": "^0.2.30", "@lion/validate": "^0.2.30",
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6", "@open-wc/testing": "^2.3.4",
"sinon": "^7.2.2" "sinon": "^7.2.2"
} }
} }

View file

@ -36,6 +36,6 @@
}, },
"devDependencies": { "devDependencies": {
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6" "@open-wc/testing": "^2.3.4"
} }
} }

View file

@ -40,6 +40,6 @@
}, },
"devDependencies": { "devDependencies": {
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6" "@open-wc/testing": "^2.3.4"
} }
} }

View file

@ -40,6 +40,6 @@
}, },
"devDependencies": { "devDependencies": {
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6" "@open-wc/testing": "^2.3.4"
} }
} }

View file

@ -47,8 +47,7 @@
"devDependencies": { "devDependencies": {
"@lion/button": "^0.3.16", "@lion/button": "^0.3.16",
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6", "@open-wc/testing": "^2.3.4",
"@polymer/iron-test-helpers": "^3.0.1",
"sinon": "^7.2.2" "sinon": "^7.2.2"
} }
} }

View file

@ -138,7 +138,7 @@ export class LionInputDatepicker extends LionInputDate {
} }
get _calendarOverlayElement() { get _calendarOverlayElement() {
return this._overlayCtrl._container.firstElementChild; return this._overlayCtrl.contentNode;
} }
get _calendarElement() { get _calendarElement() {
@ -202,7 +202,7 @@ export class LionInputDatepicker extends LionInputDate {
_calendarOverlayTemplate() { _calendarOverlayTemplate() {
return html` return html`
<lion-calendar-overlay-frame> <lion-calendar-overlay-frame @dialog-close=${() => this._overlayCtrl.hide()}>
<span slot="heading">${this.calendarHeading}</span> <span slot="heading">${this.calendarHeading}</span>
${this._calendarTemplate()} ${this._calendarTemplate()}
</lion-calendar-overlay-frame> </lion-calendar-overlay-frame>
@ -255,9 +255,9 @@ export class LionInputDatepicker extends LionInputDate {
render(this._invokerTemplate(), renderParent); render(this._invokerTemplate(), renderParent);
const invokerNode = renderParent.firstElementChild; const invokerNode = renderParent.firstElementChild;
// TODO: ModalDialogController should be replaced by a more flexible // TODO: ModalDialogController could be replaced by a more flexible
// overlay, allowing the overlay to switch on smaller screens, for instance from dropdown to // overlay, allowing the overlay to switch on smaller screens, for instance from dropdown to
// bottom sheet (working name for this controller: ResponsiveOverlayController) // bottom sheet via DynamicOverlayController
this._overlayCtrl = overlays.add( this._overlayCtrl = overlays.add(
new ModalDialogController({ new ModalDialogController({
contentTemplate: () => this._calendarOverlayTemplate(), contentTemplate: () => this._calendarOverlayTemplate(),

View file

@ -40,7 +40,7 @@ export class DatepickerInputObject {
} }
get overlayEl() { get overlayEl() {
return this.el._overlayCtrl._container && this.el._overlayCtrl._container.firstElementChild; return this.el._overlayCtrl.contentNode;
} }
get overlayHeadingEl() { get overlayHeadingEl() {

View file

@ -8,8 +8,6 @@ import {
minMaxDateValidator, minMaxDateValidator,
isDateDisabledValidator, isDateDisabledValidator,
} from '@lion/validate'; } from '@lion/validate';
import { keyCodes } from '@lion/overlays/src/utils/key-codes.js';
import { keyUpOn } from '@polymer/iron-test-helpers/mock-interactions.js';
import { LionCalendar } from '@lion/calendar'; import { LionCalendar } from '@lion/calendar';
import { isSameDate } from '@lion/calendar/src/utils/isSameDate.js'; import { isSameDate } from '@lion/calendar/src/utils/isSameDate.js';
import { DatepickerInputObject } from '../test-helpers.js'; import { DatepickerInputObject } from '../test-helpers.js';
@ -92,9 +90,22 @@ describe('<lion-input-datepicker>', () => {
const elObj = new DatepickerInputObject(el); const elObj = new DatepickerInputObject(el);
await elObj.openCalendar(); await elObj.openCalendar();
expect(elObj.overlayController.isShown).to.equal(true); expect(elObj.overlayController.isShown).to.equal(true);
// Mimic user input: should fire the 'selected-date-changed' event
// Make sure focus is inside the calendar/overlay elObj.overlayController.contentNode.dispatchEvent(
keyUpOn(elObj.calendarEl, keyCodes.escape); new KeyboardEvent('keyup', { key: 'Escape' }),
);
expect(elObj.overlayController.isShown).to.equal(false);
});
it('closes the calendar via close button', async () => {
const el = await fixture(html`
<lion-input-datepicker></lion-input-datepicker>
`);
const elObj = new DatepickerInputObject(el);
await elObj.openCalendar();
expect(elObj.overlayController.isShown).to.equal(true);
elObj.overlayCloseButtonEl.click();
expect(elObj.overlayController.isShown).to.equal(false); expect(elObj.overlayController.isShown).to.equal(false);
}); });

View file

@ -40,6 +40,6 @@
}, },
"devDependencies": { "devDependencies": {
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6" "@open-wc/testing": "^2.3.4"
} }
} }

View file

@ -41,6 +41,6 @@
"devDependencies": { "devDependencies": {
"@lion/validate": "^0.2.30", "@lion/validate": "^0.2.30",
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6" "@open-wc/testing": "^2.3.4"
} }
} }

View file

@ -39,6 +39,6 @@
"@lion/localize": "^0.4.15", "@lion/localize": "^0.4.15",
"@lion/validate": "^0.2.30", "@lion/validate": "^0.2.30",
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6" "@open-wc/testing": "^2.3.4"
} }
} }

View file

@ -39,7 +39,7 @@
"devDependencies": { "devDependencies": {
"@bundled-es-modules/fetch-mock": "^6.5.2", "@bundled-es-modules/fetch-mock": "^6.5.2",
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6", "@open-wc/testing": "^2.3.4",
"sinon": "^7.2.2" "sinon": "^7.2.2"
} }
} }

View file

@ -38,7 +38,7 @@
}, },
"devDependencies": { "devDependencies": {
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6", "@open-wc/testing": "^2.3.4",
"sinon": "^7.2.2" "sinon": "^7.2.2"
} }
} }

View file

@ -8,7 +8,7 @@ Manages their position on the screen relative to other elements, including other
## Features ## Features
- [**Overlays Manager**](./docs/OverlaysManager.md), a global repository keeping track of all different types of overlays. - [**Overlays Manager**](./docs/OverlaysManager.md), a global repository keeping track of all different types of overlays.
- [**Overlays Occurrences**](./docs/OverlayOccurrences.md), outline of all possible occurrences of overlays. Divided into two main types: - [**Overlays System: Scope**](./docs/OverlaySystemScope.md), outline of all possible occurrences of overlays. Divided into two main types:
- [**Global Overlay Controller**](./docs/GlobalOverlayController.md), controller for overlays relative to the viewport. - [**Global Overlay Controller**](./docs/GlobalOverlayController.md), controller for overlays relative to the viewport.
- [**Local Overlay Controller**](./docs/LocalOverlayController.md), controller for overlays positioned next to invokers they are related to. - [**Local Overlay Controller**](./docs/LocalOverlayController.md), controller for overlays positioned next to invokers they are related to.

View file

@ -1,7 +1,8 @@
# GlobalOverlayController # GlobalOverlayController
This is a base class for different global overlays (e.g. a dialog, see [Overlay Occurrences](./OverlayOccurrences.md) - the ones positioned relatively to the viewport. This is a base class for different global overlays (e.g. a dialog, see [Overlay System: Scope](./OverlaySystemScope.md) - the ones positioned relatively to the viewport).
You should not use this controller directly unless you want to create a unique type of global overlays which is not supported out of the box.
You should not use this controller directly unless you want to create a unique type of global overlays which is not supported out of the box. But for implementation details check out [Overlay System: Implementation](./OverlaySystemImplementation.md).
All supported types of global overlays are described below. All supported types of global overlays are described below.
@ -25,9 +26,17 @@ const myCtrl = overlays.add(
); );
``` ```
### BottomsheetController
A specific extension of GlobalOverlayController configured to create accessible dialogs at the bottom of the screen.
```js
import { BottomsheetController } from '@lion/overlays';
```
### ModalDialogController ### ModalDialogController
A specific extension of GlobalOverlayController configured to create accessible modal dialogs. A specific extension of GlobalOverlayController configured to create accessible modal dialogs placed in the center of the screen.
```js ```js
import { ModalDialogController } from '@lion/overlays'; import { ModalDialogController } from '@lion/overlays';

View file

@ -1,7 +1,10 @@
# LocalOverlayController # LocalOverlayController
This is a base class for different local overlays (e.g. a [tooltip](../../tooltip/), see [Overlay System Implementation](./OverlaySystemImplementation.md) - the ones positioned next to invokers they are related to. For more information strictly about the positioning of the content element to the reference element (invoker), please refer to the [positioning documentation](./LocalOverlayPositioning.md) This is a base class for different local overlays (e.g. a [tooltip](../../tooltip/), see [Overlay System: Scope](./OverlaySystemScope.md) - the ones positioned next to invokers they are related to).
You should not use this controller directly unless you want to create a unique type of local overlays which is not supported out of the box.
For more information strictly about the positioning of the content element to the reference element (invoker), please refer to the [positioning documentation](./LocalOverlayPositioning.md).
You should not use this controller directly unless you want to create a unique type of local overlays which is not supported out of the box. But for implementation details check out [Overlay System: Implementation](./OverlaySystemImplementation.md).
All supported types of local overlays are described below. All supported types of local overlays are described below.

View file

@ -1,126 +1,98 @@
# Overlay System: Implementation # Overlay System: Implementation
This document provides an outline of all possible occurrences of overlays found in applications in This document provides an outline of all possible occurrences of overlays found in applications in general and thus provided by Lion. For all concepts referred to in this document, please read [Overlay System Scope](./OverlaySystemScope.md).
general and thus provided by Lion.
For all concepts referred to in this document, please read [Overlay System Scope](./OverlaySystemScope.md). ## Base controller
The BaseController handles the basics of all controllers, and has the following public functions:
- **show()**, to show the overlay.
- **hide()**, to hide the overlay.
- **toggle()**, to toggle between show and hide.
All overlays exists of an invoker and a content
- **invoker**, the element that can trigger showing (and hiding) the overlay.
- invokerNode
- **content**, the toggleable overlays content
- contentTemplate, in most cases the content will be placed inside a template as one of the controller configuration options.
- contentNode, a node can also be used as the content for local overlays (see next section), such as is done in the [popup](../../popup/).
## Local and global overlay controllers ## Local and global overlay controllers
Currently, we have a global and a local overlay controller, as two separate entities. Currently, we have a global and a local overlay controller, as two separate entities.
Based on provided config, they handle all positioning logic, accessibility and interaction patterns. Based on provided config, they handle all positioning logic, accessibility and interaction patterns.
All of their configuration options will be described below as part of the _Responsive overlay_ section.
### Connection points and placement contexts - [GlobalOverlayController](./GlobalOverlayController.md), the ones positioned relatively to the viewport.
- [LocalOverlayController](./LocalOverlayController.md), the ones positioned next to invokers they are related to.
It's currently not clear where the border between global and local overlays lie. They seem to be All of their configuration options will be described below as part of the _Configuration options_ section.
separated based on their 'dom connection point' (body vs 'page cursor'(usually invoker sibling)).
However, there is no required relationship here: we can create a modal dialog from
local context('page cursor') as well.
Only, we would have a few concerns when creating global overlays from a local connection point: ### DynamicOverlayController
- Accessibility will be harder to implement. When wai-aria 1.0 needs to be supported, all siblings
need to have aria-hidden="true" and all parents role="presentation". Not always straightforward
in shadow dom. If we only need to support wai-aria 1.1, we could use aria-modal="true" on the
element with role="dialog". (we basically need to test our supported browsers and screen readers
for compatibility with aria-modal).
- Stacking context need to be managed: the whole 'z-index chain' should win (it's a battle between
parents in the hierarchy). This would require some complex code to cover all edge cases.
- Side effects of parents adding transforms or clipping become a risk. This is hard to detect and
'counter'.
When the dom connection point is 'body', content projection will not work, but a template that
can be rendered without being dependent on its context will be required.
There usually also is a correllation with their relative positioning context: invoker/other
relative element for local(tooltip/popover/dropdown) vs. 'window/viewport level' for global
(dialog/toast/sheet).
For responsive overlays (see below for an elaborate explanation), we need to switch from global to
local. When we switch the dom connection point, (think of rotating a mobile or tablet), we
will loose the current focus, which can be an a11y concern. This can eventually be 'catched' by
syncing the activeElement (we would only loose the screenreader active element (for instance,
focused cell in table mode))
For maximum flexibility, it should be up to the developer to decide how overlays should be rendered,
per instance of an overlay.
### Responsive overlay
Based on screen size, we might want to switch the appearance of an overlay. Based on screen size, we might want to switch the appearance of an overlay.
For instance: an application menu can be displayed as a dropdown on desktop, For instance: an application menu can be displayed as a dropdown on desktop,
but as a bottom sheet on mobile. but as a bottom sheet on mobile.
Similarly, a dialog can be displayed as a popover on desktop, but as a (global) dialog on mobile. Similarly, a dialog can be displayed as a popover on desktop, but as a (global) dialog on mobile.
To implement such a flexible overlay, we need an 'umbrella' layer that allows for switching between The DynamicOverlayController is a flexible overlay that can switch between different controllers, also between the connection point in dom (global and local). The switch is only done when the overlay is closed, so the focus isn't lost while switching from one overlay to another.
different configuration options, also between the connection point in dom (global and local).
Luckily, interfaces of Global and OverlayControllers are very similar. ### Configuration options
Therefore we can make a wrapping ResponsiveOverlayController.
### Configuration options for local and global overlays
In total, we should end up with configuration options as depicted below, for all possible overlays. In total, we should end up with configuration options as depicted below, for all possible overlays.
All boolean flags default to 'false'. All boolean flags default to 'false'.
Some options are mutually exclusive, in which case their dependent options and requirement will be Some options are mutually exclusive, in which case their dependent options and requirement will be mentioned.
mentioned.
Note: a more generic and precise term for all mentionings of `invoker` below would actually be > Note: a more generic and precise term for all mentionings of `invoker` below would actually be `relative positioning element`.
`relative positioning element`.
#### Shared configuration options
```text ```text
- {Element} elementToFocusAfterHide - the element that should be called `.focus()` on after dialog closes - {Boolean} trapsKeyboardFocus - rotates tab, implicitly set when 'isModal'.
- {Boolean} hasBackdrop - whether it should have a backdrop (currently exclusive to globalOverlayController) - {Boolean} hidesOnEsc - hides the overlay when pressing [esc].
- {Boolean} isBlocking - hides other overlays when mutiple are opened (currently exclusive to globalOverlayController)
- {Boolean} preventsScroll - prevents scrolling body content when overlay opened (currently exclusive to globalOverlayController)
- {Boolean} trapsKeyboardFocus - rotates tab, implicitly set when 'isModal'
- {Boolean} hidesOnEsc - hides the overlay when pressing [esc]
- {Boolean} hidesOnOutsideClick - hides the overlay when clicking next to it, exluding invoker. (currently exclusive to localOverlayController)
- {String} cssPosition - 'absolute' or 'fixed'. TODO: choose name that cannot be mistaken for placement like cssPosition or positioningTechnique: https://github.com/ing-bank/lion/pull/61
- {TemplateResult} contentTemplate
- {TemplateResult} invokerTemplate (currently exclusive to LocalOverlayController)
- {Element} invokerNode (currently exclusive to LocalOverlayController)
- {Element} contentNode (currently exclusive to LocalOverlayController)
``` ```
These options are suggested to be added to the current ones: #### Global specific configuration options
```text ```text
- {Boolean} isModal - sets aria-modal and/or aria-hidden="true" on siblings - {Element} elementToFocusAfterHide - the element that should be called `.focus()` on after dialog closes.
- {Boolean} isGlobal - determines the connection point in DOM (body vs handled by user) TODO: rename to renderToBody? - {Boolean} hasBackdrop - whether it should have a backdrop.
- {Boolean} isTooltip - has a totally different interaction - and accessibility pattern from all - {Boolean} isBlocking - hides other overlays when multiple are opened.
other overlays, so needed for internals. - {Boolean} preventsScroll - prevents scrolling body content when overlay opened.
- {Object} viewportConfig
- {String} placement: 'top-left' | 'top' | 'top-right' | 'right' | 'bottom-left' |'bottom' |'bottom-right' |'left' | 'center'
```
#### Local specific configuration options
```text
- {Boolean} hidesOnOutsideClick - hides the overlay when clicking next to it, excluding invoker.
- {String} cssPosition - 'absolute' or 'fixed'. TODO: choose name that cannot be mistaken for placement like cssPosition or positioningTechnique: <https://github.com/ing-bank/lion/pull/61>.
- For positioning checkout [localOverlayPositioning](./localOverlayPositioning.md).
```
#### Suggested additions
```text
- {Boolean} isModal - sets [aria-modal] and/or [aria-hidden="true"] on siblings
- {Boolean} isTooltip - has a totally different interaction - and accessibility pattern from all other overlays, so needed for internals.
- {Boolean} handlesUserInteraction - sets toggle on click, or hover when `isTooltip` - {Boolean} handlesUserInteraction - sets toggle on click, or hover when `isTooltip`
- {Boolean} handlesAccessibility - - {Boolean} handlesAccessibility -
- For non `isTooltip`: - For non `isTooltip`:
- sets aria-expanded="true/false" and aria-haspopup="true" on invokerNode - sets [aria-expanded="true/false"] and [aria-haspopup="true"] on invokerNode
- sets aria-controls on invokerNode - sets [aria-controls] on invokerNode
- returns focus to invokerNode on hide - returns focus to invokerNode on hide
- sets focus to overlay content(?) - sets focus to overlay content(?)
- For `isTooltip`: - For `isTooltip`:
- sets role="tooltip" and aria-labelledby/aria-describedby on the content - sets [role="tooltip"] and [aria-labelledby]/[aria-describedby] on the content
- {Object} popperConfig
- {String} placement - vertical/horizontal position to be supplied to `managePosition`. See https://github.com/ing-bank/lion/pull/61 for current api. Consists of a primary part (where the overlay is located relative from invoker) and secondary alignnment part (how the overlay 'snaps' to the perpendicular boundary of the invoker), separated via '-'.
- primary : 'bottom' | 'top' | 'left' | 'right' | 'over' (this means the overlay will be positioned on top of the invoker. Think for instance of a select dropdown that opens a selected option on top of the invoker (default behavior of `<select>` on iOS))
- secondary : 'start' | 'end' | 'fill' (occupies width of invoker) | 'middle' (implicit option that will be choosen by default when none of the previous are specified)
- all other configuration as described by [Popper.js](https://popper.js.org/)
``` ```
What we should think about more properly is a global placement option (positioned relative to window instead of invoker) ## Specific Controllers
```text
// TODO: namings very much under construction (we should also reconsider 'placement' names, see: https://github.com/ing-bank/lion/pull/61)
// Something like the drawing Joren made: https://github.com/ing-bank/lion/issues/36#issuecomment-491855381
- {String} viewportPlacement - consists of a vertical alignment part and an horizontal alignment part,
separated via '-'
- vertical align : 'center' | 'bottom' | 'top' | 'left' | 'right' | 'fullheight'
- horizontal align: 'middle' | 'start' | 'end' | 'fullwidth'
Examples: 'center-middle' (dialog, alertdialog), 'top-fullwidth' (top sheet)
```
## Controllers/behaviors
Controllers/behaviors provide preconfigured configuration objects for the global/local Controllers/behaviors provide preconfigured configuration objects for the global/local
overlay controllers. overlay controllers.
They provide an imperative and very flexible api for creating overlays and should be used by They provide an imperative and very flexible api for creating overlays and should be used by
Subclassers, inside webcomponents. Subclassers, inside webcomponents.
@ -128,7 +100,6 @@ Subclassers, inside webcomponents.
```js ```js
{ {
isGlobal: true,
isModal: true, isModal: true,
hasBackdrop: true, hasBackdrop: true,
preventsScroll: true, preventsScroll: true,
@ -136,7 +107,9 @@ Subclassers, inside webcomponents.
hidesOnEsc: true, hidesOnEsc: true,
handlesUserInteraction: true, handlesUserInteraction: true,
handlesAccessibility: true, handlesAccessibility: true,
viewportPlacement: 'center-middle', viewportConfig: {
placement: 'center',
},
} }
``` ```
@ -181,17 +154,18 @@ TODO:
```js ```js
{ {
...Dialog, viewportconfig: {
viewportPlacement: 'top-right', (?) placement: 'top-right',
} },
``` ```
### Sheet Controller (bottom, top, left, right) ### Bottomsheet Controller
```js ```js
{ {
...Dialog, viewportConfig: {
viewportPlacement: '{top|bottom|left|right}-fullwidth', (?) placement: 'bottom',
},
} }
``` ```
@ -212,11 +186,9 @@ config based on media query from Dropdown to BottomSheet/CenteredDialog
## Web components ## Web components
Web components provide a declaritive, developer friendly interface with a prewconfigured styling Web components provide a declarative, developer friendly interface with a preconfigured styling that fits the Design System and makes it really easy for Application Developers to build user interfaces.
that fits the Design System and makes it really easy for Application Developers to build
user interfaces. Web components should use the ground layers for the webcomponents in Lion are the following:
Web components should use
The ground layers for the webcomponents in Lion are the following:
### Dialog Component ### Dialog Component
@ -242,7 +214,7 @@ Imperative might be better here? We can add a web component later if needed.
### Dropdown Component ### Dropdown Component
Like the name suggests, the default placement will be button Like the name suggests, the default placement will be bottom
```html ```html
<lion-dropdown> <lion-dropdown>
@ -267,6 +239,6 @@ Imperative might be better here?
### Select, Combobox/autocomplete, Application menu ### Select, Combobox/autocomplete, Application menu
Those will be separate web components with a lot of form and a11y logic that will be described Those will be separate web components with a lot of form and a11y logic that will be described in detail in different sections.
in detail in different sections.
They will imoplement the Overlay configuration as described above under 'Controllers/behaviors'. They will implement the Overlay configuration as described above under 'Controllers/behaviors'.

View file

@ -0,0 +1,5 @@
# Overlay Manager
An overlay manager is a global repository keeping track of all different types of overlays. The need for a global housekeeping mainly arises when multiple overlays are opened simultaneously.
The overlay manager keeps track of all registered overlays and controls which one to show.

View file

@ -1,6 +1,8 @@
export { DynamicOverlayController } from './src/DynamicOverlayController.js';
export { GlobalOverlayController } from './src/GlobalOverlayController.js'; export { GlobalOverlayController } from './src/GlobalOverlayController.js';
export { globalOverlaysStyle } from './src/globalOverlaysStyle.js'; export { globalOverlaysStyle } from './src/globalOverlaysStyle.js';
export { LocalOverlayController } from './src/LocalOverlayController.js'; export { LocalOverlayController } from './src/LocalOverlayController.js';
export { BottomsheetController } from './src/BottomsheetController.js';
export { ModalDialogController } from './src/ModalDialogController.js'; export { ModalDialogController } from './src/ModalDialogController.js';
export { overlays } from './src/overlays.js'; export { overlays } from './src/overlays.js';
export { OverlaysManager } from './src/OverlaysManager.js'; export { OverlaysManager } from './src/OverlaysManager.js';

View file

@ -37,8 +37,7 @@
}, },
"devDependencies": { "devDependencies": {
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6", "@open-wc/testing": "^2.3.4",
"@polymer/iron-test-helpers": "^3.0.1",
"sinon": "^7.2.2" "sinon": "^7.2.2"
} }
} }

View file

@ -0,0 +1,289 @@
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
contentTemplateUpdated() {}
__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.contentTemplateUpdated();
} else {
render(html``, this.content);
this.__contentNode = undefined;
}
}
__showHideViaCss() {
if (!this.contentNode) {
return;
}
if (this.isShown) {
this.contentNode.style.display = 'inline-block';
} else {
this.contentNode.style.display = 'none';
}
}
// TODO: this method has to be removed when EventTarget polyfill is available on IE11
__fakeExtendsEventTarget() {
const delegate = document.createDocumentFragment();
['addEventListener', 'dispatchEvent', 'removeEventListener'].forEach(funcName => {
this[funcName] = (...args) => delegate[funcName](...args);
});
}
__enableFeatures() {
if (this.trapsKeyboardFocus) {
this.enableTrapsKeyboardFocus();
}
if (this.hidesOnEsc) {
this.enableHidesOnEsc();
}
}
__disableFeatures() {
if (this.trapsKeyboardFocus) {
this.disableTrapsKeyboardFocus();
}
if (this.hidesOnEsc) {
this.disableHidesOnEsc();
}
}
// **********************************************************************************************
// FEATURE - TrapsKeyboardFocus
// **********************************************************************************************
get hasActiveTrapsKeyboardFocus() {
return this.__hasActiveTrapsKeyboardFocus;
}
enableTrapsKeyboardFocus() {
if (this.__hasActiveTrapsKeyboardFocus === true) {
return;
}
if (this.manager) {
this.manager.disableTrapsKeyboardFocusForAll();
}
this._containFocusHandler = containFocus(this.contentNode);
this.__hasActiveTrapsKeyboardFocus = true;
if (this.manager) {
this.manager.informTrapsKeyboardFocusGotEnabled();
}
}
disableTrapsKeyboardFocus({ findNewTrap = true } = {}) {
if (this.__hasActiveTrapsKeyboardFocus === false) {
return;
}
this._containFocusHandler.disconnect();
this._containFocusHandler = undefined;
this.__hasActiveTrapsKeyboardFocus = false;
if (this.manager) {
this.manager.informTrapsKeyboardFocusGotDisabled({ disabledCtrl: this, findNewTrap });
}
}
// **********************************************************************************************
// FEATURE - hideOnEsc
// **********************************************************************************************
get hasActiveHidesOnEsc() {
return this.__hasActiveHidesOnEsc;
}
enableHidesOnEsc() {
if (this.__hasHidesOnEsc === true) {
return;
}
this.__escKeyHandler = ev => {
if (ev.key === 'Escape') {
this.hide();
}
};
this.contentNode.addEventListener('keyup', this.__escKeyHandler);
this.__hasActiveHidesOnEsc = true;
}
disableHidesOnEsc() {
if (this.__hasHidesOnEsc === false) {
return;
}
if (this.contentNode) {
this.contentNode.removeEventListener('keyup', this.__escKeyHandler);
}
this.__hasActiveHidesOnEsc = false;
}
}

View file

@ -0,0 +1,16 @@
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,
});
}
}

View file

@ -0,0 +1,102 @@
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');
}
}
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');
}
this.active.switchOut();
ctrlToSwitchTo.switchIn();
this.__active = ctrlToSwitchTo;
}
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;
}
}

View file

@ -1,24 +1,11 @@
import { render } from '@lion/core'; import { BaseOverlayController } from './BaseOverlayController.js';
import { containFocus } from './utils/contain-focus.js';
import { globalOverlaysStyle } from './globalOverlaysStyle.js';
import { setSiblingsInert, unsetSiblingsInert } from './utils/inert-siblings.js';
const isIOS = navigator.userAgent.match(/iPhone|iPad|iPod/i); const isIOS = navigator.userAgent.match(/iPhone|iPad|iPod/i);
const styleTag = document.createElement('style'); export class GlobalOverlayController extends BaseOverlayController {
styleTag.textContent = globalOverlaysStyle.cssText; constructor(params = {}) {
super(params);
export class GlobalOverlayController {
static _createRoot() {
if (!this._rootNode) {
this._rootNode = document.createElement('div');
this._rootNode.classList.add('global-overlays');
document.body.appendChild(this._rootNode);
document.head.appendChild(styleTag);
}
}
constructor(params) {
const finalParams = { const finalParams = {
elementToFocusAfterHide: document.body, elementToFocusAfterHide: document.body,
hasBackdrop: false, hasBackdrop: false,
@ -26,119 +13,114 @@ export class GlobalOverlayController {
preventsScroll: false, preventsScroll: false,
trapsKeyboardFocus: false, trapsKeyboardFocus: false,
hidesOnEsc: false, hidesOnEsc: false,
viewportConfig: {
placement: 'center',
},
...params, ...params,
}; };
this.__hasActiveBackdrop = false;
this.__hasActiveTrapsKeyboardFocus = false;
this.elementToFocusAfterHide = finalParams.elementToFocusAfterHide; this.elementToFocusAfterHide = finalParams.elementToFocusAfterHide;
this.contentTemplate = finalParams.contentTemplate;
this.hasBackdrop = finalParams.hasBackdrop; this.hasBackdrop = finalParams.hasBackdrop;
this.isBlocking = finalParams.isBlocking; this.isBlocking = finalParams.isBlocking;
this.preventsScroll = finalParams.preventsScroll; this.preventsScroll = finalParams.preventsScroll;
this.trapsKeyboardFocus = finalParams.trapsKeyboardFocus; this.trapsKeyboardFocus = finalParams.trapsKeyboardFocus;
this.hidesOnEsc = finalParams.hidesOnEsc; this.hidesOnEsc = finalParams.hidesOnEsc;
this.invokerNode = finalParams.invokerNode;
this._isShown = false; this.overlayContainerClass = `global-overlays__overlay-container`;
this._data = {}; this.overlayContainerPlacementClass = `${this.overlayContainerClass}--${finalParams.viewportConfig.placement}`;
this._container = null;
}
get isShown() {
return this._isShown;
} }
/** /**
* Syncs shown state and data. * Syncs shown state and data.
* @param {object} options optioons to sync *
* @param {object} options options to sync
* @param {boolean} [options.isShown] whether the overlay should be shown * @param {boolean} [options.isShown] whether the overlay should be shown
* @param {object} [options.data] data to pass to the content template function * @param {object} [options.data] data to pass to the content template function
* @param {HTMLElement} [options.elementToFocusAfterHide] element to return focus when hiding * @param {HTMLElement} [options.elementToFocusAfterHide] element to return focus when hiding
*/ */
sync(options) { async sync(options) {
if (options.elementToFocusAfterHide) { if (options.elementToFocusAfterHide) {
this.elementToFocusAfterHide = options.elementToFocusAfterHide; this.elementToFocusAfterHide = options.elementToFocusAfterHide;
} }
this._createOrUpdateOverlay(
typeof options.isShown !== 'undefined' ? options.isShown : this._isShown, if (options.data) {
typeof options.data !== 'undefined' ? options.data : this._data, this.contentData = options.data;
); }
if (options.isShown === true) {
await this.show();
} else if (options.isShown === false) {
await this.hide();
}
} }
/** /**
* Shows the overlay. * Shows the overlay.
* @param {HTMLElement} [elementToFocusAfterHide] element to return focus when hiding * @param {HTMLElement} [elementToFocusAfterHide] element to return focus when hiding
*/ */
show(elementToFocusAfterHide) { 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) { if (elementToFocusAfterHide) {
this.elementToFocusAfterHide = elementToFocusAfterHide; this.elementToFocusAfterHide = elementToFocusAfterHide;
} }
this._createOrUpdateOverlay(true, this._data); this.__enableFeatures();
}
contentTemplateUpdated() {
this.contentNode.classList.add('global-overlays__overlay');
} }
/** /**
* Hides the overlay. * Hides the overlay.
*/ */
hide() { async hide() {
this._createOrUpdateOverlay(false, this._data); const oldIsShown = this.isShown;
} await super.hide();
if (oldIsShown === false) {
/** return;
* Updates an overlay's template. Creates a render container and appends it to the }
* overlay manager if it wasn't rendered before. Otherwise updates the data.
* if (this.elementToFocusAfterHide) {
* Removes the overlay from the DOM if it should be hidden. this.elementToFocusAfterHide.focus();
* }
* @param {boolean} isShown whether the overlay should be shown this.__disableFeatures();
* @param {object} data data to render
*/ this.hideDone();
_createOrUpdateOverlay(isShown, data) { if (this.contentTemplate) {
if (isShown) { this.content.classList.remove(this.overlayContainerPlacementClass);
let firstShow = false; this.manager.globalRootNode.removeChild(this.content);
if (!this.trapsKeyboardFocus && GlobalOverlayController._rootNode) {
const siblings = Array.from(GlobalOverlayController._rootNode.children).reverse();
const last = this._findWithFlag(siblings, 'trapsKeyboardFocus');
if (last && last.length > 0) {
last[0]._containFocusHandler.disconnect();
}
}
if (!this._container) {
firstShow = true;
GlobalOverlayController._createRoot();
this._initializeContainer();
}
// let lit-html manage the template and update the properties
render(this.contentTemplate(data), this._container);
if (firstShow) {
this._setupFlags();
}
} else if (this._container) {
GlobalOverlayController._rootNode.removeChild(this._container);
this._cleanupFlags();
this._container = null;
if (this.elementToFocusAfterHide) {
this.elementToFocusAfterHide.focus();
}
} }
this._isShown = isShown;
this._data = data;
} }
_initializeContainer() { hideDone() {
const container = document.createElement('div'); this.defaultHideDone();
container.classList.add(`global-overlays__overlay${this.isBlocking ? '--blocking' : ''}`);
this._container = container;
container._overlayController = this;
GlobalOverlayController._rootNode.appendChild(container);
} }
/** /**
* Sets up flags. * Sets up flags.
*/ */
_setupFlags() { __enableFeatures() {
super.__enableFeatures();
if (this.preventsScroll) { if (this.preventsScroll) {
document.body.classList.add('global-overlays-scroll-lock'); document.body.classList.add('global-overlays-scroll-lock');
@ -150,81 +132,20 @@ export class GlobalOverlayController {
} }
if (this.hasBackdrop) { if (this.hasBackdrop) {
this._setupHasBackdrop(); this.enableBackdrop();
}
const siblings = Array.from(GlobalOverlayController._rootNode.children).reverse();
if (this.trapsKeyboardFocus) {
const last = this._findWithFlag(siblings, 'trapsKeyboardFocus');
if (last.length > 1) {
last[1]._containFocusHandler.disconnect();
}
this._setupTrapsKeyboardFocus();
} }
if (this.isBlocking) { if (this.isBlocking) {
GlobalOverlayController._rootNode.classList.add('global-overlays--blocking-opened'); this.enableBlocking();
} }
if (this.hidesOnEsc) {
this._setupHidesOnEsc();
}
this._container.firstElementChild.addEventListener('dialog-close', () => this.hide());
}
/**
* 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 animatin from being displayed
*/
_setupHasBackdrop(overlay = this, noAnimation) {
const prevWithBackdrop = GlobalOverlayController._rootNode.querySelector(
'.global-overlays__backdrop',
);
if (prevWithBackdrop) {
prevWithBackdrop.classList.remove('global-overlays__backdrop');
prevWithBackdrop.classList.remove('global-overlays__backdrop--fade-in');
} else if (!noAnimation) {
overlay._container.classList.add('global-overlays__backdrop--fade-in');
}
overlay._container.classList.add('global-overlays__backdrop');
}
/**
* Sets up focus containment on the given overlay. If there was focus containment set up
* previously, it is disconnected. Otherwise this is the first time containing focus, so
* the overlay manager's siblings are set inert for accessibility.
* @param {OverlayController} overlay the overlay
*/
_setupTrapsKeyboardFocus(overlay = this) {
if (overlay._containFocusHandler) {
overlay._containFocusHandler.disconnect();
overlay._containFocusHandler = undefined; // eslint-disable-line no-param-reassign
} else {
// TODO: this shouldmonly be done when modal option is true?
setSiblingsInert(GlobalOverlayController._rootNode);
}
// eslint-disable-next-line no-param-reassign
overlay._containFocusHandler = containFocus(overlay._container.firstElementChild);
}
_setupHidesOnEsc(overlay = this) {
// TODO: add check if we have focus first? Since, theoratically we can have many overlays
// opened and we probably don't want to close them all
overlay._container.addEventListener('keyup', event => {
if (event.keyCode === 27) {
// Escape
overlay.hide();
}
});
} }
/** /**
* Cleans up flags. * Cleans up flags.
*/ */
_cleanupFlags() { __disableFeatures() {
super.__disableFeatures();
if (this.preventsScroll) { if (this.preventsScroll) {
document.body.classList.remove('global-overlays-scroll-lock'); document.body.classList.remove('global-overlays-scroll-lock');
if (isIOS) { if (isIOS) {
@ -232,91 +153,110 @@ export class GlobalOverlayController {
} }
} }
// iterate siblings in reverse order, as that is the order of importance
const siblings = Array.from(GlobalOverlayController._rootNode.children).reverse();
const overlays = this._findOverlays(siblings);
const nextTrapsKeyboardFocus = this._findNextWithFlag(siblings, 'trapsKeyboardFocus');
const nextHasTrapsKeyboardFocus = nextTrapsKeyboardFocus === overlays[0];
if (this.hasBackdrop) { if (this.hasBackdrop) {
const next = this._findNextWithFlag(siblings, 'hasBackdrop'); this.disableBackdrop();
// if there is another overlay which requires a backdrop, move it there
// otherwise, play a fade-out animation
if (next) {
this._setupHasBackdrop(next, true);
} else {
this._fadeOutBackdrop();
}
}
if (this.trapsKeyboardFocus || nextHasTrapsKeyboardFocus) {
// if there is another overlay which requires contain focus, set it up
// otherwise disconnect and removed inert from siblings
if (nextTrapsKeyboardFocus && nextHasTrapsKeyboardFocus) {
if (this._containFocusHandler) {
this._containFocusHandler.disconnect();
}
this._setupTrapsKeyboardFocus(nextTrapsKeyboardFocus);
} else {
if (this._containFocusHandler) {
this._containFocusHandler.disconnect();
this._containFocusHandler = undefined;
}
unsetSiblingsInert(GlobalOverlayController._rootNode);
}
} }
if (this.isBlocking) { if (this.isBlocking) {
const next = this._findNextWithFlag(siblings, 'isBlocking'); this.disableBlocking();
// if there are no other blocking overlays remaning, stop hiding regular overlays
if (!next) {
GlobalOverlayController._rootNode.classList.remove('global-overlays--blocking-opened');
}
} }
} }
/** // **********************************************************************************************
* Finds all overlays. // FEATURE - isBlocking
* @param {HTMLElement[]} containers // **********************************************************************************************
* @returns {[OverlayController] | []} get hasActiveBlocking() {
*/ return this.__hasActiveBlocking;
// eslint-disable-next-line class-methods-use-this }
_findOverlays(containers) {
return containers.map(container => container._overlayController); 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;
} }
/** /**
* Finds the overlay which has the given option enabled. * Sets up backdrop on the given overlay. If there was a backdrop on another element
* @param {HTMLElement[]} containers * it is removed. Otherwise this is the first time displaying a backdrop, so a fade-in
* @param {string} option * animation is played.
* @returns {[OverlayController] | []} * @param {OverlayController} overlay the overlay
* @param {boolean} noAnimation prevent an animation from being displayed
*/ */
_findWithFlag(containers, option) { enableBackdrop({ animation = true } = {}) {
return this._findOverlays(containers).filter(container => container[option]); 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 } = {}) {
* Finds the next overlay which has the given option enabled. if (this.__hasActiveBackdrop === false) {
* @param {HTMLElement[]} containers return;
* @param {string} option }
* @returns {OverlayController | null}
*/ if (animation) {
_findNextWithFlag(containers, option) { const { backdropNode } = this;
return this._findOverlays(containers).find(controller => controller[option]); 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
* Plays a backdrop fade out animation. This is applied to the overlay container as the __fakeExtendsEventTarget() {
* overlay which had a backdrop is already removed at this point. const delegate = document.createDocumentFragment();
*/ ['addEventListener', 'dispatchEvent', 'removeEventListener'].forEach(funcName => {
// eslint-disable-next-line class-methods-use-this this[funcName] = (...args) => delegate[funcName](...args);
_fadeOutBackdrop() { });
GlobalOverlayController._rootNode.classList.add('global-overlays--backdrop-fade-out');
// a new overlay could be opened within 600ms, but it is only an animation
setTimeout(() => {
GlobalOverlayController._rootNode.classList.remove('global-overlays--backdrop-fade-out');
}, 600);
} }
} }

View file

@ -1,13 +1,14 @@
import { render, html } from '@lion/core'; import { render } from '@lion/core';
import { containFocus } from './utils/contain-focus.js'; import { BaseOverlayController } from './BaseOverlayController.js';
import { keyCodes } from './utils/key-codes.js';
async function __preloadPopper() { async function __preloadPopper() {
return import('popper.js/dist/esm/popper.min.js'); return import('popper.js/dist/esm/popper.min.js');
} }
export class LocalOverlayController { export class LocalOverlayController extends BaseOverlayController {
constructor(params = {}) { constructor(params = {}) {
this.__fakeExtendsEventTarget(); super(params);
this.__hasActiveHidesOnOutsideClick = false;
// TODO: Instead of in constructor, prefetch it or use a preloader-manager to load it during idle time // TODO: Instead of in constructor, prefetch it or use a preloader-manager to load it during idle time
this.constructor.popperModule = __preloadPopper(); this.constructor.popperModule = __preloadPopper();
@ -21,10 +22,16 @@ export class LocalOverlayController {
/** /**
* A wrapper to render into the invokerTemplate * A wrapper to render into the invokerTemplate
* *
* @deprecated - please use .invokerNode instead
*
* @property {HTMLElement} * @property {HTMLElement}
*/ */
this.invoker = document.createElement('div'); this.invoker = document.createElement('div');
this.invoker.style.display = 'inline-block'; this.invoker.style.display = 'inline-block';
/**
* @deprecated - please use .invokerNode instead
*/
this.invokerTemplate = params.invokerTemplate; this.invokerTemplate = params.invokerTemplate;
/** /**
@ -38,35 +45,10 @@ export class LocalOverlayController {
this.invoker = this.invokerNode; this.invoker = this.invokerNode;
} }
/**
* A wrapper the contentTemplate renders into
*
* @property {HTMLElement}
*/
this.content = document.createElement('div');
this.content.style.display = 'inline-block';
this.contentTemplate = params.contentTemplate;
this.contentNode = this.content;
if (params.contentNode) {
this.contentNode = params.contentNode;
this.content = this.contentNode;
}
this.contentId = `overlay-content-${Math.random() this.contentId = `overlay-content-${Math.random()
.toString(36) .toString(36)
.substr(2, 10)}`; .substr(2, 10)}`;
this._contentData = {};
this.syncInvoker(); this.syncInvoker();
this._updateContent();
this._prevShown = false;
this._prevData = {};
this.__boundEscKeyHandler = this.__escKeyHandler.bind(this);
}
get isShown() {
return this.contentTemplate
? Boolean(this.content.children.length)
: Boolean(this.contentNode.style.display === 'inline-block');
} }
/** /**
@ -75,12 +57,22 @@ export class LocalOverlayController {
* @param {boolean} [options.isShown] whether the overlay should be shown * @param {boolean} [options.isShown] whether the overlay should be shown
* @param {object} [options.data] overlay data to pass to the content template function * @param {object} [options.data] overlay data to pass to the content template function
*/ */
sync({ isShown, data } = {}) { async sync({ isShown, data } = {}) {
this._createOrUpdateOverlay(isShown, data); if (data) {
this.contentData = data;
}
if (isShown === true) {
await this.show();
} else if (isShown === false) {
await this.hide();
}
} }
/** /**
* Syncs data for invoker. * Syncs data for invoker.
*
* @deprecated please use .invokerNode instead
* @param {object} options * @param {object} options
* @param {object} [options.data] overlay data to pass to the invoker template function * @param {object} [options.data] overlay data to pass to the invoker template function
*/ */
@ -99,7 +91,13 @@ export class LocalOverlayController {
* Shows the overlay. * Shows the overlay.
*/ */
async show() { async show() {
this._createOrUpdateOverlay(true, this._prevData); 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 * 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. * we just recreate the popper instance to make it behave like it should.
@ -107,155 +105,72 @@ export class LocalOverlayController {
* calling just the .update() function on the popper instance sadly does not resolve this. * calling just the .update() function on the popper instance sadly does not resolve this.
* This is however necessary for initial placement. * This is however necessary for initial placement.
*/ */
await this.__createPopperInstance(); if (this.invokerNode && this.contentNode) {
this._popper.update(); await this.__createPopperInstance();
this._popper.update();
}
this.__enableFeatures();
} }
/** /**
* Hides the overlay. * Hides the overlay.
*/ */
hide() { async hide() {
this._createOrUpdateOverlay(false, this._prevData); const oldIsShown = this.isShown;
await super.hide();
if (oldIsShown === false) {
return;
}
this.__disableFeatures();
} }
/** __enableFeatures() {
* Toggles the overlay. super.__enableFeatures();
*/
toggle() { this.invokerNode.setAttribute('aria-expanded', 'true');
// eslint-disable-next-line no-unused-expressions if (this.inheritsReferenceObjectWidth) {
this.isShown ? this.hide() : this.show(); 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. // 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. // 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 = {}) { async updatePopperConfig(config = {}) {
this.__mergePopperConfigs(config); this.__mergePopperConfigs(config);
await this.__createPopperInstance();
if (this.isShown) { if (this.isShown) {
await this.__createPopperInstance();
this._popper.update(); this._popper.update();
} }
} }
_createOrUpdateOverlay(shown = this._prevShown, data = this._prevData) {
if (shown) {
this._contentData = { ...this._contentData, ...data };
// let lit-html manage the template and update the properties
if (this.contentTemplate) {
render(this.contentTemplate(this._contentData), this.content);
this.contentNode = this.content.firstElementChild;
}
this.contentNode.id = this.contentId;
this.contentNode.style.display = 'inline-block';
/* To display on top of elements with no z-index that are appear later in the DOM */
this.contentNode.style.zIndex = 1;
this.invokerNode.setAttribute('aria-expanded', true);
if (this.inheritsReferenceObjectWidth) {
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;
}
}
if (this.trapsKeyboardFocus) this._setupTrapsKeyboardFocus();
if (this.hidesOnOutsideClick) this._setupHidesOnOutsideClick();
if (this.hidesOnEsc) this._setupHidesOnEsc();
if (this._prevShown === false) {
this.dispatchEvent(new Event('show'));
}
} else {
this._updateContent();
this.invokerNode.setAttribute('aria-expanded', false);
if (this.hidesOnOutsideClick) this._teardownHidesOnOutsideClick();
if (this.hidesOnEsc) this._teardownHidesOnEsc();
if (this._prevShown === true) {
this.dispatchEvent(new Event('hide'));
}
}
this._prevShown = shown;
this._prevData = data;
}
/**
* Sets up focus containment on the given overlay. If there was focus containment set up
* previously, it is disconnected.
*/
_setupTrapsKeyboardFocus() {
if (this._containFocusHandler) {
this._containFocusHandler.disconnect();
this._containFocusHandler = undefined; // eslint-disable-line no-param-reassign
}
this._containFocusHandler = containFocus(this.contentNode);
}
_setupHidesOnEsc() {
this.contentNode.addEventListener('keyup', this.__boundEscKeyHandler);
}
_teardownHidesOnEsc() {
this.contentNode.removeEventListener('keyup', this.__boundEscKeyHandler);
}
_setupHidesOnOutsideClick() {
if (this.__preventCloseOutsideClick) {
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) {
this.hide();
}
});
};
this.contentNode.addEventListener('click', this.__preventCloseOutsideClick, true);
this.invokerNode.addEventListener('click', this.__preventCloseOutsideClick, true);
document.documentElement.addEventListener('click', this.__onCaptureHtmlClick, true);
}
_teardownHidesOnOutsideClick() {
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;
}
_updateContent() {
if (this.contentTemplate) {
render(html``, this.content);
} else {
this.contentNode.style.display = 'none';
}
}
__escKeyHandler(e) {
if (e.keyCode === keyCodes.escape) {
this.hide();
}
}
/** /**
* Merges the default config with the current config, and finally with the user supplied config * Merges the default config with the current config, and finally with the user supplied config
* @param {Object} config user supplied configuration * @param {Object} config user supplied configuration
@ -312,11 +227,68 @@ export class LocalOverlayController {
}); });
} }
// TODO: this method has to be removed when EventTarget polyfill is available on IE11 get contentTemplate() {
__fakeExtendsEventTarget() { return super.contentTemplate;
const delegate = document.createDocumentFragment(); }
['addEventListener', 'dispatchEvent', 'removeEventListener'].forEach(funcName => {
this[funcName] = (...args) => delegate[funcName](...args); set contentTemplate(value) {
}); super.contentTemplate = value;
if (this.contentNode && this.invokerNode) {
this.disableHidesOnOutsideClick();
this.enableHidesOnOutsideClick();
}
}
// **********************************************************************************************
// FEATURE - hidesOnOutsideClick
// **********************************************************************************************
get hasActiveHidesOnOutsideClick() {
return this.__hasActiveHidesOnOutsideClick;
}
enableHidesOnOutsideClick() {
if (this.hasActiveHidesOnOutsideClick === true) {
return;
}
let wasClickInside = false;
// handle on capture phase and remember till the next task that there was an inside click
this.__preventCloseOutsideClick = () => {
wasClickInside = true;
setTimeout(() => {
wasClickInside = false;
});
};
// handle on capture phase and schedule the hide if needed
this.__onCaptureHtmlClick = () => {
setTimeout(() => {
if (wasClickInside === false) {
this.hide();
}
});
};
this.contentNode.addEventListener('click', this.__preventCloseOutsideClick, true);
this.invokerNode.addEventListener('click', this.__preventCloseOutsideClick, true);
document.documentElement.addEventListener('click', this.__onCaptureHtmlClick, true);
this.__hasActiveHidesOnOutsideClick = true;
}
disableHidesOnOutsideClick() {
if (this.hasActiveHidesOnOutsideClick === false) {
return;
}
if (this.contentNode) {
this.contentNode.removeEventListener('click', this.__preventCloseOutsideClick, true);
}
this.invokerNode.removeEventListener('click', this.__preventCloseOutsideClick, true);
document.documentElement.removeEventListener('click', this.__onCaptureHtmlClick, true);
this.__preventCloseOutsideClick = null;
this.__onCaptureHtmlClick = null;
this.__hasActiveHidesOnOutsideClick = false;
} }
} }

View file

@ -7,6 +7,9 @@ export class ModalDialogController extends GlobalOverlayController {
preventsScroll: true, preventsScroll: true,
trapsKeyboardFocus: true, trapsKeyboardFocus: true,
hidesOnEsc: true, hidesOnEsc: true,
viewportConfig: {
placement: 'center',
},
...params, ...params,
}); });
} }

View file

@ -1,4 +1,5 @@
/* eslint-disable class-methods-use-this */ import { unsetSiblingsInert, setSiblingsInert } from './utils/inert-siblings.js';
import { globalOverlaysStyle } from './globalOverlaysStyle.js';
/** /**
* @typedef {object} OverlayController * @typedef {object} OverlayController
@ -20,19 +21,142 @@
* `OverlaysManager` which manages overlays which are rendered into the body * `OverlaysManager` which manages overlays which are rendered into the body
*/ */
export class OverlaysManager { export class OverlaysManager {
static __createGlobalRootNode() {
const rootNode = document.createElement('div');
rootNode.classList.add('global-overlays');
document.body.appendChild(rootNode);
return rootNode;
}
static __createGlobalStyleNode() {
const styleTag = document.createElement('style');
styleTag.setAttribute('data-global-overlays', '');
styleTag.textContent = globalOverlaysStyle.cssText;
document.head.appendChild(styleTag);
return styleTag;
}
/**
* no setter as .list is inteded to be read-only
* You can use .add or .remove to modify it
*/
get globalRootNode() {
if (!this.constructor.__globalRootNode) {
this.constructor.__globalRootNode = this.constructor.__createGlobalRootNode();
this.constructor.__globalStyleNode = this.constructor.__createGlobalStyleNode();
}
return this.constructor.__globalRootNode;
}
/**
* no setter as .list is inteded to be read-only
* You can use .add or .remove to modify it
*/
get list() {
return this.__list;
}
/**
* no setter as .shownList is inteded to be read-only
* You can use .show or .hide on individual controllers to modify
*/
get shownList() {
return this.__shownList;
}
constructor() {
this.__list = [];
this.__shownList = [];
this.__siblingsInert = false;
}
/** /**
* Registers an overlay controller. * Registers an overlay controller.
* @param {OverlayController} controller controller of the newly added overlay * @param {OverlayController} ctrlToAdd controller of the newly added overlay
* @returns {OverlayController} same controller after adding to the manager * @returns {OverlayController} same controller after adding to the manager
*/ */
add(controller) { add(ctrlToAdd) {
// TODO: hopefully there will be an event-driven system (which will be implemented here) if (this.list.find(ctrl => ctrlToAdd === ctrl)) {
// and controllers will just be notified about other controllers being shown/hidden throw new Error('controller instance is already added');
// so that we: }
// 1. don't need to store a stack of overlays which leads to memory leaks // eslint-disable-next-line no-param-reassign
// (unfortunately WeakSet/WeakMap is not an option because we need to iterate over them) ctrlToAdd.manager = this;
// 2. make overlay controllers more independent this.list.push(ctrlToAdd);
// (otherwise there will be a tight coupling between the manager and different types) return ctrlToAdd;
return controller; }
remove(ctrlToRemove) {
if (!this.list.find(ctrl => ctrlToRemove === ctrl)) {
throw new Error('could not find controller to remove');
}
this.__list = this.list.filter(ctrl => ctrl !== ctrlToRemove);
}
show(ctrlToShow) {
if (this.list.find(ctrl => ctrlToShow === ctrl)) {
this.hide(ctrlToShow);
}
this.__shownList.unshift(ctrlToShow);
}
hide(ctrlToHide) {
if (!this.list.find(ctrl => ctrlToHide === ctrl)) {
throw new Error('could not find controller to hide');
}
this.__shownList = this.shownList.filter(ctrl => ctrl !== ctrlToHide);
}
teardown() {
this.__list = [];
this.__shownList = [];
this.__siblingsInert = false;
const rootNode = this.constructor.__globalRootNode;
if (rootNode) {
rootNode.parentElement.removeChild(rootNode);
this.constructor.__globalRootNode = undefined;
document.head.removeChild(this.constructor.__globalStyleNode);
this.constructor.__globalStyleNode = undefined;
}
}
/** Features right now only for Global Overlay Manager */
get siblingsInert() {
return this.__siblingsInert;
}
disableTrapsKeyboardFocusForAll() {
this.shownList.forEach(ctrl => {
if (ctrl.trapsKeyboardFocus === true && ctrl.disableTrapsKeyboardFocus) {
ctrl.disableTrapsKeyboardFocus({ findNewTrap: false });
}
});
}
informTrapsKeyboardFocusGotEnabled() {
if (this.siblingsInert === false) {
if (this.constructor.__globalRootNode) {
setSiblingsInert(this.globalRootNode);
}
this.__siblingsInert = true;
}
}
informTrapsKeyboardFocusGotDisabled({ disabledCtrl, findNewTrap = true } = {}) {
const next = this.shownList.find(
ctrl => ctrl !== disabledCtrl && ctrl.trapsKeyboardFocus === true,
);
if (next) {
if (findNewTrap) {
next.enableTrapsKeyboardFocus();
}
} else if (this.siblingsInert === true) {
if (this.constructor.__globalRootNode) {
unsetSiblingsInert(this.globalRootNode);
}
this.__siblingsInert = false;
}
} }
} }

View file

@ -6,36 +6,94 @@ export const globalOverlaysStyle = css`
z-index: 200; z-index: 200;
} }
.global-overlays__overlay,
.global-overlays__overlay--blocking {
pointer-events: auto;
}
.global-overlays__overlay-container {
display: flex;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.global-overlays__overlay-container--top-left {
justify-content: flex-start;
align-items: flex-start;
}
.global-overlays__overlay-container--top {
justify-content: center;
align-items: flex-start;
}
.global-overlays__overlay-container--top-right {
justify-content: flex-end;
align-items: flex-start;
}
.global-overlays__overlay-container--right {
justify-content: flex-end;
align-items: center;
}
.global-overlays__overlay-container--bottom-left {
justify-content: flex-start;
align-items: flex-end;
}
.global-overlays__overlay-container--bottom {
justify-content: center;
align-items: flex-end;
}
.global-overlays__overlay-container--bottom-right {
justify-content: flex-end;
align-items: flex-end;
}
.global-overlays__overlay-container--left {
justify-content: flex-start;
align-items: center;
}
.global-overlays__overlay-container--center {
justify-content: center;
align-items: center;
}
.global-overlays.global-overlays--blocking-opened .global-overlays__overlay { .global-overlays.global-overlays--blocking-opened .global-overlays__overlay {
display: none; display: none;
} }
.global-overlays .global-overlays__backdrop::before { .global-overlays.global-overlays--blocking-opened .global-overlays__backdrop {
animation: global-overlays-backdrop-fade-out 300ms;
opacity: 0;
}
.global-overlays .global-overlays__backdrop,
.global-overlays .global-overlays__backdrop--blocking {
content: ''; content: '';
position: fixed; position: fixed;
top: 0; top: 0;
right: 0;
bottom: 0;
left: 0; left: 0;
width: 100%;
height: 100%;
z-index: -1;
background-color: #333333; background-color: #333333;
opacity: 0.3; opacity: 0.3;
} }
.global-overlays .global-overlays__backdrop--fade-in::before { .global-overlays .global-overlays__backdrop--fade-in {
animation: global-overlays-backdrop-fade-in 300ms; animation: global-overlays-backdrop-fade-in 300ms;
} }
.global-overlays.global-overlays--backdrop-fade-out::before { .global-overlays .global-overlays__backdrop--fade-out {
content: '';
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: #333333;
opacity: 0;
pointer-events: none;
animation: global-overlays-backdrop-fade-out 300ms; animation: global-overlays-backdrop-fade-out 300ms;
opacity: 0;
} }
@keyframes global-overlays-backdrop-fade-in { @keyframes global-overlays-backdrop-fade-in {

View file

@ -0,0 +1,46 @@
import { storiesOf, html } from '@open-wc/demoing-storybook';
import { css } from '@lion/core';
import { overlays, BottomsheetController } from '../index.js';
const bottomsheetDemoStyle = css`
.demo-overlay {
width: 100%;
background-color: white;
border: 1px solid lightgrey;
text-align: center;
}
`;
storiesOf('Global Overlay System|Bottomsheet', module).add('Default', () => {
const bottomsheetCtrl = overlays.add(
new BottomsheetController({
contentTemplate: () => html`
<div class="demo-overlay">
<p>Bottomsheet</p>
<button @click="${() => bottomsheetCtrl.hide()}">Close</button>
</div>
`,
}),
);
return html`
<style>
${bottomsheetDemoStyle}
</style>
<a href="#">Anchor 1</a>
<button
@click="${event => bottomsheetCtrl.show(event.target)}"
aria-haspopup="dialog"
aria-expanded="false"
>
Open dialog
</button>
<a href="#">Anchor 2</a>
${Array(50).fill(
html`
<p>Lorem ipsum</p>
`,
)}
`;
});

View file

@ -0,0 +1,252 @@
import { storiesOf, html } from '@open-wc/demoing-storybook';
import { css } from '@lion/core';
import {
GlobalOverlayController,
LocalOverlayController,
DynamicOverlayController,
} from '../index.js';
import { overlays } from '../src/overlays.js';
const dynamicOverlayDemoStyle = css`
.demo-overlay {
display: block;
position: absolute;
background-color: white;
padding: 8px;
}
.demo-overlay__global--small {
bottom: 0;
left: 0;
width: 100vw;
height: 90%;
background: #ccc;
}
.demo-overlay__global--big {
left: 50px;
top: 30px;
width: 200px;
max-width: 250px;
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);
}
.demo-overlay__local {
display: block;
position: absolute;
max-width: 250px;
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);
}
`;
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 GlobalOverlayController({
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
</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 />
<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 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>
<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>
while overlay is hidden.
</p>
`;
});

View file

@ -7,23 +7,29 @@ import { overlays, GlobalOverlayController } from '../index.js';
const globalOverlayDemoStyle = css` const globalOverlayDemoStyle = css`
.demo-overlay { .demo-overlay {
background-color: white; background-color: white;
position: fixed;
top: 20px;
left: 20px;
width: 200px; width: 200px;
border: 1px solid blue; border: 1px solid lightgrey;
}
.demo-overlay--2 {
left: 240px;
}
.demo-overlay--toast {
left: initial;
right: 20px;
} }
`; `;
let placement = 'center';
const togglePlacement = overlayCtrl => {
const placements = [
'top-left',
'top',
'top-right',
'right',
'bottom-left',
'bottom',
'bottom-right',
'left',
'center',
];
placement = placements[(placements.indexOf(placement) + 1) % placements.length];
// eslint-disable-next-line no-param-reassign
overlayCtrl.overlayContainerPlacementClass = `${overlayCtrl.overlayContainerClass}--${placement}`;
};
storiesOf('Global Overlay System|Global Overlay', module) storiesOf('Global Overlay System|Global Overlay', module)
.add('Default', () => { .add('Default', () => {
const overlayCtrl = overlays.add( const overlayCtrl = overlays.add(
@ -126,7 +132,7 @@ storiesOf('Global Overlay System|Global Overlay', module)
<a id="el2" href="#">Anchor</a> <a id="el2" href="#">Anchor</a>
<div id="el3" tabindex="0">Tabindex</div> <div id="el3" tabindex="0">Tabindex</div>
<input id="el4" placeholder="Input" /> <input id="el4" placeholder="Input" />
<div id="el5" contenteditable>Contenteditable</div> <div id="el5" contenteditable="true">Contenteditable</div>
<textarea id="el6">Textarea</textarea> <textarea id="el6">Textarea</textarea>
<select id="el7"> <select id="el7">
<option>1</option> <option>1</option>
@ -156,8 +162,11 @@ storiesOf('Global Overlay System|Global Overlay', module)
const overlayCtrl2 = overlays.add( const overlayCtrl2 = overlays.add(
new GlobalOverlayController({ new GlobalOverlayController({
trapsKeyboardFocus: true, trapsKeyboardFocus: true,
viewportConfig: {
placement: 'left',
},
contentTemplate: () => html` contentTemplate: () => html`
<div class="demo-overlay demo-overlay--2"> <div class="demo-overlay">
<p>Overlay 2. Tab key is trapped within the overlay</p> <p>Overlay 2. Tab key is trapped within the overlay</p>
<button @click="${() => overlayCtrl2.hide()}">Close</button> <button @click="${() => overlayCtrl2.hide()}">Close</button>
</div> </div>
@ -188,6 +197,7 @@ storiesOf('Global Overlay System|Global Overlay', module)
<style> <style>
${globalOverlayDemoStyle} ${globalOverlayDemoStyle}
</style> </style>
<a href="#">Anchor 1</a>
<button <button
@click="${event => overlayCtrl1.show(event.target)}" @click="${event => overlayCtrl1.show(event.target)}"
aria-haspopup="dialog" aria-haspopup="dialog"
@ -195,14 +205,18 @@ storiesOf('Global Overlay System|Global Overlay', module)
> >
Open overlay 1 Open overlay 1
</button> </button>
<a href="#">Anchor 2</a>
`; `;
}) })
.add('Option "isBlocking"', () => { .add('Option "isBlocking"', () => {
const blockingOverlayCtrl = overlays.add( const blockingOverlayCtrl = overlays.add(
new GlobalOverlayController({ new GlobalOverlayController({
isBlocking: true, isBlocking: true,
viewportConfig: {
placement: 'left',
},
contentTemplate: () => html` contentTemplate: () => html`
<div class="demo-overlay demo-overlay--2"> <div class="demo-overlay">
<p>Hides other overlays</p> <p>Hides other overlays</p>
<button @click="${() => blockingOverlayCtrl.hide()}">Close</button> <button @click="${() => blockingOverlayCtrl.hide()}">Close</button>
</div> </div>
@ -241,15 +255,46 @@ storiesOf('Global Overlay System|Global Overlay', module)
</button> </button>
`; `;
}) })
.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', () => { .add('Sync', () => {
const overlayCtrl = overlays.add( const overlayCtrl = overlays.add(
new GlobalOverlayController({ new GlobalOverlayController({
contentTemplate: data => html` contentTemplate: ({ title = 'default' } = {}) => html`
<div class="demo-overlay"> <div class="demo-overlay">
<p>${data.title}</p> <p>${title}</p>
<label>Edit title:</label> <label>Edit title:</label>
<input <input
value="${data.title}" value="${title}"
@input="${e => overlayCtrl.sync({ isShown: true, data: { title: e.target.value } })}" @input="${e => overlayCtrl.sync({ isShown: true, data: { title: e.target.value } })}"
/> />
<button @click="${() => overlayCtrl.hide()}">Close</button> <button @click="${() => overlayCtrl.hide()}">Close</button>
@ -271,42 +316,6 @@ storiesOf('Global Overlay System|Global Overlay', module)
</button> </button>
`; `;
}) })
.add('Toast', () => {
let counter = 0;
function openInfo() {
const toastCtrl = overlays.add(
new GlobalOverlayController({
contentTemplate: data => html`
<div class="demo-overlay demo-overlay--toast" style="top: ${data.counter * 90}px;">
<strong>Title ${data.counter}</strong>
<p>Lorem ipsum ${data.counter}</p>
</div>
`,
}),
);
toastCtrl.sync({
isShown: true,
data: { counter },
});
counter += 1;
setTimeout(() => {
toastCtrl.hide();
counter -= 1;
}, 2000);
}
return html`
<style>
${globalOverlayDemoStyle}
</style>
<button @click="${openInfo}">
Open info
</button>
<p>Very naive toast implementation</p>
<p>It does not handle adding new while toasts are getting hidden</p>
`;
})
.add('In web components', () => { .add('In web components', () => {
class EditUsernameOverlay extends LionLitElement { class EditUsernameOverlay extends LionLitElement {
static get properties() { static get properties() {
@ -397,9 +406,9 @@ storiesOf('Global Overlay System|Global Overlay', module)
this._editOverlay = overlays.add( this._editOverlay = overlays.add(
new GlobalOverlayController({ new GlobalOverlayController({
focusElementAfterHide: this.shadowRoot.querySelector('button'), focusElementAfterHide: this.shadowRoot.querySelector('button'),
contentTemplate: data => html` contentTemplate: ({ username = 'standard' } = {}) => html`
<edit-username-overlay <edit-username-overlay
username="${data.username}" username="${username}"
@edit-username-submitted="${e => this._onEditSubmitted(e)}" @edit-username-submitted="${e => this._onEditSubmitted(e)}"
@edit-username-closed="${() => this._onEditClosed()}" @edit-username-closed="${() => this._onEditClosed()}"
> >

View file

@ -1,4 +1,6 @@
import './global-overlay.stories.js'; import './global-overlay.stories.js';
import './modal-dialog.stories.js'; import './modal-dialog.stories.js';
import './bottomsheet.stories.js';
import './local-overlay.stories.js'; import './local-overlay.stories.js';
import './local-overlay-placement.stories.js'; import './local-overlay-placement.stories.js';
import './dynamic-overlay.stories.js';

View file

@ -46,17 +46,18 @@ const popupPlacementDemoStyle = css`
storiesOf('Local Overlay System|Local Overlay Placement', module) storiesOf('Local Overlay System|Local Overlay Placement', module)
.addParameters({ options: { selectedPanel: 'storybook/actions/actions-panel' } }) .addParameters({ options: { selectedPanel: 'storybook/actions/actions-panel' } })
.add('Preferred placement overlay absolute', () => { .add('Preferred placement overlay absolute', () => {
const popupController = overlays.add( let popup;
const invokerNode = document.createElement('button');
invokerNode.innerHTML = 'UK';
invokerNode.addEventListener('click', () => popup.toggle());
popup = overlays.add(
new LocalOverlayController({ new LocalOverlayController({
hidesOnEsc: true, hidesOnEsc: true,
contentTemplate: () => contentTemplate: () => html`
html` <div class="demo-popup">United Kingdom</div>
<div class="demo-popup">United Kingdom</div> `,
`, invokerNode,
invokerTemplate: () =>
html`
<button style="border: none" @click=${() => popupController.toggle()}>UK</button>
`,
}), }),
); );
@ -64,14 +65,19 @@ storiesOf('Local Overlay System|Local Overlay Placement', module)
<style> <style>
${popupPlacementDemoStyle} ${popupPlacementDemoStyle}
</style> </style>
<button @click=${() => togglePlacement(popupController)}>Toggle placement</button> <button @click=${() => togglePlacement(popup)}>Toggle placement</button>
<div class="demo-box"> <div class="demo-box">
${popupController.invoker} ${popupController.content} ${invokerNode} ${popup.content}
</div> </div>
`; `;
}) })
.add('Override the popper config', () => { .add('Override the popper config', () => {
const popupController = overlays.add( let popup;
const invokerNode = document.createElement('button');
invokerNode.innerHTML = 'UK';
invokerNode.addEventListener('click', () => popup.toggle());
popup = overlays.add(
new LocalOverlayController({ new LocalOverlayController({
hidesOnEsc: true, hidesOnEsc: true,
popperConfig: { popperConfig: {
@ -100,10 +106,7 @@ storiesOf('Local Overlay System|Local Overlay Placement', module)
html` html`
<div class="demo-popup">United Kingdom</div> <div class="demo-popup">United Kingdom</div>
`, `,
invokerTemplate: () => invokerNode,
html`
<button style="border: none" @click=${() => popupController.toggle()}>UK</button>
`,
}), }),
); );
@ -115,19 +118,22 @@ storiesOf('Local Overlay System|Local Overlay Placement', module)
The API is aligned with Popper.js, visit their documentation for more information: The API is aligned with Popper.js, visit their documentation for more information:
<a href="https://popper.js.org/popper-documentation.html">Popper.js Docs</a> <a href="https://popper.js.org/popper-documentation.html">Popper.js Docs</a>
</div> </div>
<button @click=${() => togglePlacement(popupController)}>Toggle placement</button> <button @click=${() => togglePlacement(popup)}>Toggle placement</button>
<div class="demo-box"> <div class="demo-box">
${popupController.invoker} ${popupController.content} ${invokerNode} ${popup.content}
</div> </div>
`; `;
}); });
/* TODO: Add this when we have a feature in place that adds scrollbars / overflow when no space is available */ /* TODO: Add this when we have a feature in place that adds scrollbars / overflow when no space is available */
/* .add('Space not available', () => { /* .add('Space not available', () => {
const popupController = overlays.add( let popup;
const invokerNode = document.createElement('button');
invokerNode.innerHTML = 'UK';
invokerNode.addEventListener('click', () => popup.toggle());
let popup = overlays.add(
new LocalOverlayController({ new LocalOverlayController({
hidesOnEsc: true, hidesOnEsc: true,
contentTemplate: () => contentTemplate: () => html`
html`
<div class="demo-popup"> <div class="demo-popup">
Toggle the placement of this overlay with the buttons. Since there is not enough space Toggle the placement of this overlay with the buttons. Since there is not enough space
available on the vertical center or the top for this popup, the popup will get available on the vertical center or the top for this popup, the popup will get
@ -135,10 +141,7 @@ storiesOf('Local Overlay System|Local Overlay Placement', module)
increase/decrease space see the behavior of this. increase/decrease space see the behavior of this.
</div> </div>
`, `,
invokerTemplate: () => invokerNode,
html`
<button style="border: none" @click=${() => popupController.show()}>UK</button>
`,
}), }),
); );
@ -147,11 +150,11 @@ storiesOf('Local Overlay System|Local Overlay Placement', module)
${popupPlacementDemoStyle} ${popupPlacementDemoStyle}
</style> </style>
<div> <div>
<button @click=${() => togglePlacement(popupController)}>Toggle placement</button> <button @click=${() => togglePlacement(popup)}>Toggle placement</button>
<button @click=${() => popupController.hide()}>Close popup</button> <button @click=${() => popup.hide()}>Close popup</button>
</div> </div>
<div class="demo-box"> <div class="demo-box">
${popupController.invoker} ${popupController.content} ${invokerNode} ${popup.content}
</div> </div>
`; `;
}); */ }); */

View file

@ -26,18 +26,19 @@ const popupDemoStyle = css`
storiesOf('Local Overlay System|Local Overlay', module) storiesOf('Local Overlay System|Local Overlay', module)
.add('Basic', () => { .add('Basic', () => {
const popup = overlays.add( let popup;
const invokerNode = document.createElement('button');
invokerNode.innerHTML = 'UK';
invokerNode.addEventListener('click', () => popup.toggle());
popup = overlays.add(
new LocalOverlayController({ new LocalOverlayController({
hidesOnEsc: true, hidesOnEsc: true,
hidesOnOutsideClick: true, hidesOnOutsideClick: true,
contentTemplate: () => contentTemplate: () => html`
html` <div class="demo-popup">United Kingdom</div>
<div class="demo-popup">United Kingdom</div> `,
`, invokerNode,
invokerTemplate: () =>
html`
<button style="border: none" @click=${() => popup.toggle()}>UK</button>
`,
}), }),
); );
return html` return html`
@ -45,26 +46,27 @@ storiesOf('Local Overlay System|Local Overlay', module)
${popupDemoStyle} ${popupDemoStyle}
</style> </style>
<div class="demo-box"> <div class="demo-box">
In the ${popup.invoker}${popup.content} the weather is nice. In the ${invokerNode}${popup.content} the weather is nice.
</div> </div>
`; `;
}) })
.add('Change preferred position', () => { .add('Change preferred position', () => {
const popup = overlays.add( let popup;
const invokerNode = document.createElement('button');
invokerNode.innerHTML = 'UK';
invokerNode.addEventListener('click', () => popup.toggle());
popup = overlays.add(
new LocalOverlayController({ new LocalOverlayController({
hidesOnEsc: true, hidesOnEsc: true,
hidesOnOutsideClick: true, hidesOnOutsideClick: true,
popperConfig: { popperConfig: {
placement: 'top-end', placement: 'top-end',
}, },
contentTemplate: () => contentTemplate: () => html`
html` <div class="demo-popup">United Kingdom</div>
<div class="demo-popup">United Kingdom</div> `,
`, invokerNode,
invokerTemplate: () =>
html`
<button @click=${() => popup.toggle()}>UK</button>
`,
}), }),
); );
return html` return html`
@ -72,12 +74,17 @@ storiesOf('Local Overlay System|Local Overlay', module)
${popupDemoStyle} ${popupDemoStyle}
</style> </style>
<div class="demo-box"> <div class="demo-box">
In the ${popup.invoker}${popup.content} the weather is nice. In the ${invokerNode}${popup.content} the weather is nice.
</div> </div>
`; `;
}) })
.add('Single placement parameter', () => { .add('Single placement parameter', () => {
const popup = overlays.add( let popup;
const invokerNode = document.createElement('button');
invokerNode.innerHTML = 'Click me';
invokerNode.addEventListener('click', () => popup.toggle());
popup = overlays.add(
new LocalOverlayController({ new LocalOverlayController({
hidesOnEsc: true, hidesOnEsc: true,
hidesOnOutsideClick: true, hidesOnOutsideClick: true,
@ -89,10 +96,7 @@ storiesOf('Local Overlay System|Local Overlay', module)
Supplying placement with a single parameter will assume 'center' for the other. Supplying placement with a single parameter will assume 'center' for the other.
</div> </div>
`, `,
invokerTemplate: () => invokerNode,
html`
<button @click=${() => popup.toggle()}>Click me</button>
`,
}), }),
); );
return html` return html`
@ -100,12 +104,18 @@ storiesOf('Local Overlay System|Local Overlay', module)
${popupDemoStyle} ${popupDemoStyle}
</style> </style>
<div class="demo-box"> <div class="demo-box">
${popup.invoker}${popup.content} ${invokerNode}${popup.content}
</div> </div>
`; `;
}) })
.add('On hover', () => { .add('On hover', () => {
const popup = overlays.add( 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({ new LocalOverlayController({
hidesOnEsc: true, hidesOnEsc: true,
hidesOnOutsideClick: true, hidesOnOutsideClick: true,
@ -116,9 +126,7 @@ storiesOf('Local Overlay System|Local Overlay', module)
html` html`
<div class="demo-popup">United Kingdom</div> <div class="demo-popup">United Kingdom</div>
`, `,
invokerTemplate: () => html` invokerNode,
<button @mouseenter=${() => popup.show()} @mouseleave=${() => popup.hide()}>UK</button>
`,
}), }),
); );
return html` return html`
@ -126,26 +134,24 @@ storiesOf('Local Overlay System|Local Overlay', module)
${popupDemoStyle} ${popupDemoStyle}
</style> </style>
<div class="demo-box"> <div class="demo-box">
In the beautiful ${popup.invoker}${popup.content} the weather is nice. In the beautiful ${invokerNode}${popup.content} the weather is nice.
</div> </div>
`; `;
}) })
.add('On an input', () => { .add('On an input', () => {
const popup = overlays.add( 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({ new LocalOverlayController({
contentTemplate: () => contentTemplate: () => html`
html` <div class="demo-popup">United Kingdom</div>
<div class="demo-popup">United Kingdom</div> `,
`, invokerNode,
invokerTemplate: () =>
html`
<input
id="input"
type="text"
@focus=${() => popup.show()}
@blur=${() => popup.hide()}
/>
`,
}), }),
); );
return html` return html`
@ -154,12 +160,17 @@ storiesOf('Local Overlay System|Local Overlay', module)
</style> </style>
<div class="demo-box"> <div class="demo-box">
<label for="input">Input with a dropdown</label> <label for="input">Input with a dropdown</label>
${popup.invoker}${popup.content} ${invokerNode}${popup.content}
</div> </div>
`; `;
}) })
.add('trapsKeyboardFocus', () => { .add('trapsKeyboardFocus', () => {
const popup = overlays.add( let popup;
const invokerNode = document.createElement('button');
invokerNode.innerHTML = 'Click me';
invokerNode.addEventListener('click', () => popup.toggle());
popup = overlays.add(
new LocalOverlayController({ new LocalOverlayController({
hidesOnEsc: true, hidesOnEsc: true,
hidesOnOutsideClick: true, hidesOnOutsideClick: true,
@ -177,10 +188,7 @@ storiesOf('Local Overlay System|Local Overlay', module)
</select> </select>
</div> </div>
`, `,
invokerTemplate: () => invokerNode,
html`
<button @click=${() => popup.toggle()}>UK</button>
`,
}), }),
); );
return html` return html`
@ -188,7 +196,7 @@ storiesOf('Local Overlay System|Local Overlay', module)
${popupDemoStyle} ${popupDemoStyle}
</style> </style>
<div class="demo-box"> <div class="demo-box">
${popup.invoker}${popup.content} ${invokerNode}${popup.content}
</div> </div>
`; `;
}) })
@ -222,7 +230,7 @@ storiesOf('Local Overlay System|Local Overlay', module)
${popupDemoStyle} ${popupDemoStyle}
</style> </style>
<div class="demo-box"> <div class="demo-box">
${popup.invoker}${popup.content} ${invokerNode}${popup.content}
</div> </div>
`; `;
}); });

View file

@ -6,26 +6,37 @@ import { overlays, ModalDialogController } from '../index.js';
const modalDialogDemoStyle = css` const modalDialogDemoStyle = css`
.demo-overlay { .demo-overlay {
background-color: white; background-color: white;
position: fixed;
top: 20px;
left: 20px;
width: 200px; width: 200px;
border: 1px solid blue; border: 1px solid lightgrey;
}
.demo-overlay--2 {
left: 240px;
} }
`; `;
storiesOf('Global Overlay System|Modal Dialog', module) storiesOf('Global Overlay System|Modal Dialog', module)
.add('Default', () => { .add('Default', () => {
const nestedDialogCtrl = overlays.add(
new ModalDialogController({
contentTemplate: () => html`
<div class="demo-overlay">
<p>Nested modal dialog</p>
<button @click="${() => nestedDialogCtrl.hide()}">Close</button>
</div>
`,
}),
);
const dialogCtrl = overlays.add( const dialogCtrl = overlays.add(
new ModalDialogController({ new ModalDialogController({
contentTemplate: () => html` contentTemplate: () => html`
<div class="demo-overlay"> <div class="demo-overlay">
<p>Modal dialog</p> <p>Modal dialog</p>
<button @click="${() => dialogCtrl.hide()}">Close</button> <button @click="${() => dialogCtrl.hide()}">Close</button>
<button
@click="${event => nestedDialogCtrl.show(event.target)}"
aria-haspopup="dialog"
aria-expanded="false"
>
Open nested dialog
</button>
</div> </div>
`, `,
}), }),

View file

@ -0,0 +1,368 @@
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)', () => {
it('hides .contentNode via css on hide', async () => {
const ctrl = createCtrlFn({
contentNode: await fixture('<p>direct node</p>'),
});
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;
});
// do we even want to support contentTemplate?
it.skip('hides .contentNode from a .contentTemplate via css on hide', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>direct node</p>
`,
});
ctrl.__showHideMode = 'css';
await ctrl.show();
expect(ctrl.contentNode).to.be.displayed;
await ctrl.hide();
expect(ctrl.contentNode).not.to.be.displayed;
await ctrl.show();
expect(ctrl.contentNode).to.be.displayed;
});
it.skip('does not put a style display on .content when using a .contentTemplate', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>direct node</p>
`,
});
ctrl.__showHideMode = 'css';
await ctrl.show();
expect(ctrl.content.style.display).to.be.empty;
await ctrl.hide();
expect(ctrl.content.style.display).to.be.empty;
await ctrl.show();
expect(ctrl.content.style.display).to.be.empty;
});
});
describe('setup', () => {
it('throws if .contentTemplate and .contentNode get passed on', async () => {
const node = await fixture('<p>direct node</p>');
expect(() => {
createCtrlFn({
contentTemplate: () => '',
contentNode: node,
});
}).to.throw('You can only provide a .contentTemplate or a .contentNode but not both');
});
it('throws if neither .contentTemplate or .contentNode get passed on', async () => {
expect(() => {
createCtrlFn();
}).to.throw('You need to provide a .contentTemplate or a .contentNode');
});
});
describe('invoker', () => {
// same as content just with invoker
});
describe('switching', () => {
it('has a switchOut/In function', () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>my content</p>
`,
});
expect(ctrl.switchIn).to.be.a('function');
expect(ctrl.switchOut).to.be.a('function');
});
});
describe('trapsKeyboardFocus (for a11y)', () => {
it('focuses the overlay on show', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>Content</p>
`,
});
// add element to dom to allow focus
await fixture(html`
${ctrl.content}
`);
await ctrl.show();
ctrl.enableTrapsKeyboardFocus();
expect(ctrl.contentNode).to.equal(document.activeElement);
});
it('keeps focus within the overlay e.g. you can not tab out by accident', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<div><input /><input /></div>
`,
});
// add element to dom to allow focus
await fixture(html`
${ctrl.content}
`);
await ctrl.show();
ctrl.enableTrapsKeyboardFocus();
const elOutside = await fixture(html`
<button>click me</button>
`);
const input1 = ctrl.contentNode.querySelectorAll('input')[0];
const input2 = ctrl.contentNode.querySelectorAll('input')[1];
input2.focus();
// this mimics a tab within the contain-focus system used
const event = new CustomEvent('keydown', { detail: 0, bubbles: true });
event.keyCode = keyCodes.tab;
window.dispatchEvent(event);
expect(elOutside).to.not.equal(document.activeElement);
expect(input1).to.equal(document.activeElement);
});
it('allows to move the focus outside of the overlay if trapsKeyboardFocus is disabled', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<div><input /></div>
`,
});
// add element to dom to allow focus
await fixture(html`
${ctrl.content}
`);
await ctrl.show();
ctrl.enableTrapsKeyboardFocus();
const elOutside = await fixture(html`
<input />
`);
const input = ctrl.contentNode.querySelector('input');
input.focus();
simulateTab();
expect(elOutside).to.equal(document.activeElement);
});
});
describe('hidesOnEsc', () => {
it('hides when [escape] is pressed', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>Content</p>
`,
});
await ctrl.show();
ctrl.enableHidesOnEsc();
ctrl.contentNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
expect(ctrl.isShown).to.be.false;
});
it('stays shown when [escape] is pressed on outside element', async () => {
const ctrl = createCtrlFn({
contentTemplate: () => html`
<p>Content</p>
`,
});
await ctrl.show();
ctrl.enableHidesOnEsc();
document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
expect(ctrl.isShown).to.be.true;
});
});
};

View file

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

View file

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

View file

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

View file

@ -0,0 +1,136 @@
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);
});
});

View file

@ -1,10 +1,8 @@
import { expect, fixture, html } from '@open-wc/testing'; import { expect, fixture, html } from '@open-wc/testing';
import { keyUpOn } from '@polymer/iron-test-helpers/mock-interactions.js';
import { keyCodes } from '../src/utils/key-codes.js';
import { simulateTab } from '../src/utils/simulate-tab.js';
import { getDeepActiveElement } from '../src/utils/get-deep-active-element.js';
import { GlobalOverlayController } from '../src/GlobalOverlayController.js'; import { GlobalOverlayController } from '../src/GlobalOverlayController.js';
import { overlays } from '../src/overlays.js';
import { runBaseOverlaySuite } from '../test-suites/BaseOverlayController.suite.js';
function getRootNode() { function getRootNode() {
return document.querySelector('.global-overlays'); return document.querySelector('.global-overlays');
@ -51,642 +49,299 @@ function getRenderedOverlay(index) {
function cleanup() { function cleanup() {
document.body.removeAttribute('style'); document.body.removeAttribute('style');
if (GlobalOverlayController._rootNode) { overlays.teardown();
GlobalOverlayController._rootNode.parentElement.removeChild(GlobalOverlayController._rootNode);
GlobalOverlayController._rootNode = undefined;
}
} }
describe('GlobalOverlayController', () => { describe('GlobalOverlayController', () => {
afterEach(cleanup); afterEach(cleanup);
describe('extends BaseOverlayController', () => {
runBaseOverlaySuite((...args) => overlays.add(new GlobalOverlayController(...args)));
});
describe('basics', () => { describe('basics', () => {
it('creates a controller with methods: show, hide, sync', () => { it('renders an overlay from the lit-html based contentTemplate when showing', async () => {
const controller = new GlobalOverlayController(); const ctrl = overlays.add(
expect(controller.show).to.be.a('function'); new GlobalOverlayController({
expect(controller.hide).to.be.a('function'); contentTemplate: () => html`
expect(controller.sync).to.be.a('function'); <p>my content</p>
});
it('creates a root node in body when first controller is shown', () => {
const controller = new GlobalOverlayController({
contentTemplate: () =>
html`
<p>Content</p>
`, `,
}); }),
expect(document.body.querySelectorAll('.global-overlays').length).to.equal(0);
controller.show();
expect(document.body.querySelectorAll('.global-overlays').length).to.equal(1);
expect(document.body.querySelector('.global-overlays')).to.equal(
GlobalOverlayController._rootNode,
); );
expect(document.body.querySelector('.global-overlays').parentElement).to.equal(document.body); await ctrl.show();
expect(GlobalOverlayController._rootNode.children.length).to.equal(1);
});
it('renders an overlay from the lit-html based contentTemplate when showing', () => {
const controller = new GlobalOverlayController({
contentTemplate: () =>
html`
<p>Content</p>
`,
});
controller.show();
expect(getRootNode().children.length).to.equal(1); expect(getRootNode().children.length).to.equal(1);
expect(getRootNode().children[0].classList.contains('global-overlays__overlay')).to.be.true; expect(getRootNode().children[0]).to.have.trimmed.text('my content');
expect(getRootNode().children[0].children.length).to.equal(1);
expect(getRootNode().children[0].children[0].tagName).to.equal('P');
expect(getRootNode().children[0].children[0].textContent).to.equal('Content');
}); });
it('removes the overlay from DOM when hiding', () => { it('removes the overlay from DOM when hiding', async () => {
const controller = new GlobalOverlayController({ const ctrl = overlays.add(
contentTemplate: () => new GlobalOverlayController({
html` viewportConfig: {
<p>Content</p> placement: 'top-left',
},
contentTemplate: () => html`
<div>Content</div>
`, `,
}); }),
);
controller.show(); await ctrl.show();
expect(getRenderedContainers().length).to.equal(1); expect(getRenderedContainers().length).to.equal(1);
expect(getRenderedOverlay(0).tagName).to.equal('P'); expect(getRenderedOverlay(0).tagName).to.equal('DIV');
expect(getRenderedOverlay(0).textContent).to.equal('Content'); expect(getRenderedOverlay(0).textContent).to.equal('Content');
expect(getTopContainer()).to.equal(getRenderedContainer(0)); expect(getTopContainer()).to.equal(getRenderedContainer(0));
controller.hide(); await ctrl.hide();
expect(getRenderedContainers().length).to.equal(0); expect(getRenderedContainers().length).to.equal(0);
expect(getTopContainer()).to.not.exist; expect(getTopContainer()).to.not.exist;
}); });
it('exposes isShown state for reading', () => { it('exposes isShown state for reading', async () => {
const controller = new GlobalOverlayController({ const ctrl = overlays.add(
contentTemplate: () => new GlobalOverlayController({
html` contentTemplate: () => html`
<p>Content</p> <p>Content</p>
`, `,
}); }),
);
expect(controller.isShown).to.equal(false); expect(ctrl.isShown).to.equal(false);
controller.show(); await ctrl.show();
expect(controller.isShown).to.equal(true); expect(ctrl.isShown).to.equal(true);
controller.hide(); await ctrl.hide();
expect(controller.isShown).to.equal(false); expect(ctrl.isShown).to.equal(false);
}); });
it('puts the latest shown overlay always on top', () => { it('does not recreate the overlay elements when calling show multiple times', async () => {
const controller0 = new GlobalOverlayController({ const ctrl = overlays.add(
contentTemplate: () => new GlobalOverlayController({
html` contentTemplate: () => html`
<p>Content0</p>
`,
});
const controller1 = new GlobalOverlayController({
contentTemplate: () =>
html`
<p>Content1</p>
`,
});
controller0.show();
controller1.show();
controller0.show();
expect(getRenderedContainers().length).to.equal(2);
expect(getRenderedOverlay(0).tagName).to.equal('P');
expect(getRenderedOverlay(0).textContent).to.equal('Content0');
expect(getRenderedOverlay(1).tagName).to.equal('P');
expect(getRenderedOverlay(1).textContent).to.equal('Content1');
expect(getTopOverlay().textContent).to.equal('Content0');
});
it('does not recreate the overlay elements when calling show multiple times', () => {
const controller = new GlobalOverlayController({
contentTemplate: () =>
html`
<p>Content</p> <p>Content</p>
`, `,
}); }),
);
controller.show(); await ctrl.show();
expect(getRenderedContainers().length).to.equal(1); expect(getRenderedContainers().length).to.equal(1);
const initialContainer = getRenderedContainer(0); const initialContainer = getRenderedContainer(0);
const initialOverlay = getRenderedOverlay(0); const initialOverlay = getRenderedOverlay(0);
controller.show(); await ctrl.show();
expect(getRenderedContainers().length).to.equal(1); expect(getRenderedContainers().length).to.equal(1);
expect(getRenderedContainer(0)).to.equal(initialContainer); expect(getRenderedContainer(0)).to.equal(initialContainer);
expect(getRenderedOverlay(0)).to.equal(initialOverlay); expect(getRenderedOverlay(0)).to.equal(initialOverlay);
}); });
it('recreates the overlay elements when hiding and showing again', () => { it('supports .sync(isShown, data)', async () => {
const controller = new GlobalOverlayController({ const ctrl = overlays.add(
contentTemplate: () => new GlobalOverlayController({
html` contentTemplate: ({ text = 'default' } = {}) => html`
<p>Content</p> <p>${text}</p>
`, `,
}); }),
);
controller.show(); await ctrl.sync({ isShown: true, data: { text: 'hello world' } });
expect(getRenderedContainers().length).to.equal(1);
const initialContainer = getRenderedContainer(0);
const initialOverlay = getRenderedOverlay(0);
controller.hide();
controller.show();
expect(getRenderedContainers().length).to.equal(1);
expect(getRenderedContainer(0)).to.not.equal(initialContainer);
expect(getRenderedOverlay(0)).to.not.equal(initialOverlay);
});
it('supports syncing of shown state, data', () => {
const controller = new GlobalOverlayController({
contentTemplate: data =>
html`
<p>${data.text}</p>
`,
});
controller.sync({ isShown: true, data: { text: 'hello world' } });
expect(getRenderedContainers().length).to.equal(1); expect(getRenderedContainers().length).to.equal(1);
expect(getRenderedOverlay(0).textContent).to.equal('hello world'); expect(getRenderedOverlay(0).textContent).to.equal('hello world');
controller.sync({ isShown: true, data: { text: 'goodbye world' } }); await ctrl.sync({ isShown: true, data: { text: 'goodbye world' } });
expect(getRenderedContainers().length).to.equal(1); expect(getRenderedContainers().length).to.equal(1);
expect(getRenderedOverlay(0).textContent).to.equal('goodbye world'); expect(getRenderedOverlay(0).textContent).to.equal('goodbye world');
controller.sync({ isShown: false, data: { text: 'goodbye world' } }); await ctrl.sync({ isShown: false, data: { text: 'goodbye world' } });
expect(getRenderedContainers().length).to.equal(0); expect(getRenderedContainers().length).to.equal(0);
}); });
}); });
describe('elementToFocusAfterHide', () => { describe('elementToFocusAfterHide', () => {
it('focuses body when hiding by default', () => { it('focuses body when hiding by default', async () => {
const controller = new GlobalOverlayController({ const ctrl = overlays.add(
contentTemplate: () => new GlobalOverlayController({
html` viewportConfig: {
placement: 'top-left',
},
contentTemplate: () => html`
<div><input />=</div> <div><input />=</div>
`, `,
}); }),
);
controller.show(); await ctrl.show();
const input = getTopOverlay().querySelector('input'); const input = getTopOverlay().querySelector('input');
input.focus(); input.focus();
expect(document.activeElement).to.equal(input); expect(document.activeElement).to.equal(input);
controller.hide(); await ctrl.hide();
expect(document.activeElement).to.equal(document.body); expect(document.activeElement).to.equal(document.body);
}); });
it('supports elementToFocusAfterHide option to focus it when hiding', async () => { it('supports elementToFocusAfterHide option to focus it when hiding', async () => {
const input = await fixture( const input = await fixture(html`
html` <input />
<input /> `);
`,
);
const controller = new GlobalOverlayController({ const ctrl = overlays.add(
elementToFocusAfterHide: input, new GlobalOverlayController({
contentTemplate: () => elementToFocusAfterHide: input,
html` viewportConfig: {
placement: 'top-left',
},
contentTemplate: () => html`
<div><textarea></textarea></div> <div><textarea></textarea></div>
`, `,
}); }),
);
controller.show(); await ctrl.show();
const textarea = getTopOverlay().querySelector('textarea'); const textarea = getTopOverlay().querySelector('textarea');
textarea.focus(); textarea.focus();
expect(document.activeElement).to.equal(textarea); expect(document.activeElement).to.equal(textarea);
controller.hide(); await ctrl.hide();
expect(document.activeElement).to.equal(input); expect(document.activeElement).to.equal(input);
}); });
it('allows to set elementToFocusAfterHide on show', async () => { it('allows to set elementToFocusAfterHide on show', async () => {
const input = await fixture( const input = await fixture(html`
html` <input />
<input /> `);
`,
);
const controller = new GlobalOverlayController({ const ctrl = overlays.add(
contentTemplate: () => new GlobalOverlayController({
html` viewportConfig: {
placement: 'top-left',
},
contentTemplate: () => html`
<div><textarea></textarea></div> <div><textarea></textarea></div>
`, `,
}); }),
);
controller.show(input); await ctrl.show(input);
const textarea = getTopOverlay().querySelector('textarea'); const textarea = getTopOverlay().querySelector('textarea');
textarea.focus(); textarea.focus();
expect(document.activeElement).to.equal(textarea); expect(document.activeElement).to.equal(textarea);
controller.hide(); await ctrl.hide();
expect(document.activeElement).to.equal(input); expect(document.activeElement).to.equal(input);
}); });
it('allows to set elementToFocusAfterHide on sync', async () => { it('allows to set elementToFocusAfterHide on sync', async () => {
const input = await fixture( const input = await fixture(html`
html` <input />
<input /> `);
`,
);
const controller = new GlobalOverlayController({ const ctrl = overlays.add(
contentTemplate: () => new GlobalOverlayController({
html` viewportConfig: {
placement: 'top-left',
},
contentTemplate: () => html`
<div><textarea></textarea></div> <div><textarea></textarea></div>
`, `,
}); }),
);
controller.sync({ isShown: true, elementToFocusAfterHide: input }); await ctrl.sync({ isShown: true, elementToFocusAfterHide: input });
const textarea = getTopOverlay().querySelector('textarea'); const textarea = getTopOverlay().querySelector('textarea');
textarea.focus(); textarea.focus();
expect(document.activeElement).to.equal(textarea); expect(document.activeElement).to.equal(textarea);
controller.hide(); await ctrl.hide();
expect(document.activeElement).to.equal(input); expect(document.activeElement).to.equal(input);
controller.sync({ isShown: true, elementToFocusAfterHide: input }); await ctrl.sync({ isShown: true, elementToFocusAfterHide: input });
const textarea2 = getTopOverlay().querySelector('textarea'); const textarea2 = getTopOverlay().querySelector('textarea');
textarea2.focus(); textarea2.focus();
expect(document.activeElement).to.equal(textarea2); expect(document.activeElement).to.equal(textarea2);
controller.sync({ isShown: false }); await ctrl.sync({ isShown: false });
expect(document.activeElement).to.equal(input); expect(document.activeElement).to.equal(input);
}); });
}); });
describe('hasBackdrop', () => {
it('has no backdrop by default', () => {
const controllerWithoutBackdrop = new GlobalOverlayController({
contentTemplate: () =>
html`
<p>Content</p>
`,
});
controllerWithoutBackdrop.show();
expect(getRenderedContainer(0).classList.contains('global-overlays__backdrop')).to.be.false;
});
it('supports a backdrop option', () => {
const controllerWithoutBackdrop = new GlobalOverlayController({
hasBackdrop: false,
contentTemplate: () =>
html`
<p>Content</p>
`,
});
controllerWithoutBackdrop.show();
expect(getRenderedContainer(0).classList.contains('global-overlays__backdrop')).to.be.false;
controllerWithoutBackdrop.hide();
const controllerWithBackdrop = new GlobalOverlayController({
hasBackdrop: true,
contentTemplate: () =>
html`
<p>Content</p>
`,
});
controllerWithBackdrop.show();
expect(getRenderedContainer(0).classList.contains('global-overlays__backdrop')).to.be.true;
});
it('adds a backdrop to the top most overlay with hasBackdrop enabled', () => {
const controller0 = new GlobalOverlayController({
hasBackdrop: true,
contentTemplate: () =>
html`
<p>Content0</p>
`,
});
controller0.show();
expect(getRenderedContainer(0).classList.contains('global-overlays__backdrop')).to.be.true;
const controller1 = new GlobalOverlayController({
hasBackdrop: false,
contentTemplate: () =>
html`
<p>Content1</p>
`,
});
controller1.show();
expect(getRenderedContainer(0).classList.contains('global-overlays__backdrop')).to.be.true;
expect(getRenderedContainer(1).classList.contains('global-overlays__backdrop')).to.be.false;
const controller2 = new GlobalOverlayController({
hasBackdrop: true,
contentTemplate: () =>
html`
<p>Content2</p>
`,
});
controller2.show();
expect(getRenderedContainer(0).classList.contains('global-overlays__backdrop')).to.be.false;
expect(getRenderedContainer(1).classList.contains('global-overlays__backdrop')).to.be.false;
expect(getRenderedContainer(2).classList.contains('global-overlays__backdrop')).to.be.true;
});
it('restores the backdrop to the next element with hasBackdrop when hiding', () => {
const controller0 = new GlobalOverlayController({
hasBackdrop: true,
contentTemplate: () =>
html`
<p>Content0</p>
`,
});
controller0.show();
const controller1 = new GlobalOverlayController({
hasBackdrop: false,
contentTemplate: () =>
html`
<p>Content1</p>
`,
});
controller1.show();
const controller2 = new GlobalOverlayController({
hasBackdrop: true,
contentTemplate: () =>
html`
<p>Content2</p>
`,
});
controller2.show();
controller2.hide();
expect(getRenderedContainer(0).classList.contains('global-overlays__backdrop')).to.be.true;
expect(getRenderedContainer(1).classList.contains('global-overlays__backdrop')).to.be.false;
});
});
describe('isBlocking', () => {
it('prevents showing of other overlays', () => {
const controller0 = new GlobalOverlayController({
isBlocking: false,
contentTemplate: () =>
html`
<p>Content0</p>
`,
});
controller0.show();
const controller1 = new GlobalOverlayController({
isBlocking: false,
contentTemplate: () =>
html`
<p>Content1</p>
`,
});
controller1.show();
const controller2 = new GlobalOverlayController({
isBlocking: true,
contentTemplate: () =>
html`
<p>Content2</p>
`,
});
controller2.show();
const controller3 = new GlobalOverlayController({
isBlocking: false,
contentTemplate: () =>
html`
<p>Content3</p>
`,
});
controller3.show();
expect(window.getComputedStyle(getRenderedContainer(0)).display).to.equal('none');
expect(window.getComputedStyle(getRenderedContainer(1)).display).to.equal('none');
expect(window.getComputedStyle(getRenderedContainer(2)).display).to.equal('block');
expect(window.getComputedStyle(getRenderedContainer(3)).display).to.equal('none');
});
});
describe('trapsKeyboardFocus (for a11y)', () => {
it('adds attributes inert and aria-hidden="true" on all siblings of rootNode if an overlay is shown', () => {
const controller = new GlobalOverlayController({
trapsKeyboardFocus: true,
contentTemplate: () =>
html`
<p>Content</p>
`,
});
// show+hide are needed to create a root node
controller.show();
controller.hide();
const sibling1 = document.createElement('div');
const sibling2 = document.createElement('div');
document.body.insertBefore(sibling1, getRootNode());
document.body.appendChild(sibling2);
controller.show();
[sibling1, sibling2].forEach(sibling => {
expect(sibling.getAttribute('aria-hidden')).to.equal('true');
expect(sibling.hasAttribute('inert')).to.be.true;
});
expect(getRenderedOverlay(0).hasAttribute('aria-hidden')).to.be.false;
expect(getRenderedOverlay(0).hasAttribute('inert')).to.be.false;
controller.hide();
[sibling1, sibling2].forEach(sibling => {
expect(sibling.hasAttribute('aria-hidden')).to.be.false;
expect(sibling.hasAttribute('inert')).to.be.false;
});
// 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 controller = new GlobalOverlayController({
trapsKeyboardFocus: true,
contentTemplate: () =>
html`
<p>Content</p>
`,
});
// show+hide are needed to create a root node
controller.show();
controller.hide();
const sibling1 = document.createElement('div');
const sibling2 = document.createElement('div');
document.body.insertBefore(sibling1, getRootNode());
document.body.appendChild(sibling2);
controller.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',
]);
controller.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('focuses the overlay on show', () => {
const controller = new GlobalOverlayController({
trapsKeyboardFocus: true,
contentTemplate: () =>
html`
<p>Content</p>
`,
});
controller.show();
expect(getRenderedOverlay(0)).to.equal(document.activeElement);
});
it('keeps focus within the overlay e.g. you can not tab out by accident', async () => {
const controller = new GlobalOverlayController({
trapsKeyboardFocus: true,
contentTemplate: () =>
html`
<div><input /><input /></div>
`,
});
controller.show();
const elOutside = await fixture(
html`
<button>click me</button>
`,
);
const input1 = getRenderedOverlay(0).querySelectorAll('input')[0];
const input2 = getRenderedOverlay(0).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 controller = new GlobalOverlayController({
trapsKeyboardFocus: false,
contentTemplate: () =>
html`
<div><input /></div>
`,
});
controller.show();
const elOutside = await fixture(
html`
<input />
`,
);
const input = getRenderedOverlay(0).querySelector('input');
input.focus();
simulateTab();
expect(elOutside).to.equal(document.activeElement);
});
it.skip('keeps focus within overlay with multiple overlays with all traps on true', async () => {
// TODO: find a way to test it
const controller0 = new GlobalOverlayController({
trapsKeyboardFocus: true,
contentTemplate: () =>
html`
<div>
<input id="input0" /><button id="button0">Button0</button><a id="a0">Link0</a>
</div>
`,
});
const controller1 = new GlobalOverlayController({
trapsKeyboardFocus: true,
contentTemplate: () =>
html`
<div>
<input id="input1" /><button id="button1">Button1</button><a id="a1">Link1</a>
</div>
`,
});
controller0.show();
controller1.show();
simulateTab();
expect(getDeepActiveElement().id).to.equal('input1');
simulateTab();
expect(getDeepActiveElement().id).to.equal('button1');
simulateTab();
expect(getDeepActiveElement().id).to.equal('input1');
});
});
describe('preventsScroll', () => { describe('preventsScroll', () => {
it('prevent scrolling the background', async () => { it('prevent scrolling the background', async () => {
const controller = new GlobalOverlayController({ const ctrl = overlays.add(
preventsScroll: true, new GlobalOverlayController({
contentTemplate: () => preventsScroll: true,
html` contentTemplate: () => html`
<p>Content</p> <p>Content</p>
`, `,
}); }),
);
controller.show(); await ctrl.show();
controller.updateComplete; ctrl.updateComplete;
expect(getComputedStyle(document.body).overflow).to.equal('hidden'); expect(getComputedStyle(document.body).overflow).to.equal('hidden');
controller.hide(); await ctrl.hide();
controller.updateComplete; ctrl.updateComplete;
expect(getComputedStyle(document.body).overflow).to.equal('visible'); expect(getComputedStyle(document.body).overflow).to.equal('visible');
}); });
}); });
describe('hidesOnEsc', () => { describe('hasBackdrop', () => {
it('hides when Escape is pressed', async () => { 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({ const controller = new GlobalOverlayController({
hidesOnEsc: true,
contentTemplate: () => contentTemplate: () =>
html` html`
<p>Content</p> <p>Content</p>
@ -694,10 +349,38 @@ describe('GlobalOverlayController', () => {
}); });
controller.show(); controller.show();
expect(getRenderedContainers().length).to.equal(1); expect(controller.overlayContainerPlacementClass).to.equal(
'global-overlays__overlay-container--center',
);
});
keyUpOn(getRenderedContainer(0), keyCodes.escape); it('can set the placement relative to the viewport ', async () => {
expect(getRenderedContainers().length).to.equal(0); 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}`,
);
});
}); });
}); });
}); });

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -1,21 +1,107 @@
import { expect } from '@open-wc/testing'; import { expect, html } from '@open-wc/testing';
import sinon from 'sinon';
import { OverlaysManager } from '../src/OverlaysManager.js'; import { OverlaysManager } from '../src/OverlaysManager.js';
import { BaseOverlayController } from '../src/BaseOverlayController.js';
function createGlobalOverlayControllerMock() {
return {
sync: sinon.spy(),
update: sinon.spy(),
show: sinon.spy(),
hide: sinon.spy(),
};
}
describe('OverlaysManager', () => { describe('OverlaysManager', () => {
let defaultOptions;
let mngr;
before(() => {
defaultOptions = {
contentTemplate: () => html`
<p>my content</p>
`,
};
});
beforeEach(() => {
mngr = new OverlaysManager();
});
afterEach(() => {
mngr.teardown();
});
it('provides .globalRootNode as a render target on first access', () => {
expect(document.body.querySelectorAll('.global-overlays').length).to.equal(0);
const rootNode = mngr.globalRootNode;
expect(document.body.querySelector('.global-overlays')).to.equal(rootNode);
});
it('provides .teardown() for cleanup', () => {
const rootNode = mngr.globalRootNode;
expect(document.body.querySelector('.global-overlays')).to.equal(rootNode);
expect(document.head.querySelector('[data-global-overlays=""]')).not.be.undefined;
mngr.teardown();
expect(document.body.querySelectorAll('.global-overlays').length).to.equal(0);
expect(document.head.querySelector('[data-global-overlays=""]')).be.null;
// safety check via private access (do not use this)
expect(mngr.constructor.__globalRootNode).to.be.undefined;
expect(mngr.constructor.__globalStyleNode).to.be.undefined;
});
it('returns the newly added overlay', () => { it('returns the newly added overlay', () => {
const myOverlays = new OverlaysManager(); const myController = new BaseOverlayController(defaultOptions);
const myController = createGlobalOverlayControllerMock(); expect(mngr.add(myController)).to.equal(myController);
expect(myOverlays.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);
expect(mngr.list).to.deep.equal([dialog, popup]);
mngr.remove(popup);
expect(mngr.list).to.deep.equal([dialog]);
mngr.remove(dialog);
expect(mngr.list).to.deep.equal([]);
});
it('throws if you try to add the same controller', () => {
const ctrl = new BaseOverlayController(defaultOptions);
mngr.add(ctrl);
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);
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);
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);
expect(mngr.shownList).to.deep.equal([]);
await dialog.show();
expect(mngr.shownList).to.deep.equal([dialog]);
await dialog2.show();
expect(mngr.shownList).to.deep.equal([dialog2, dialog]);
await dialog.show();
expect(mngr.shownList).to.deep.equal([dialog, dialog2]);
await dialog.hide();
expect(mngr.shownList).to.deep.equal([dialog2]);
await dialog2.hide();
expect(mngr.shownList).to.deep.equal([]);
}); });
}); });

View file

@ -53,7 +53,7 @@ const lightDomAutofocusTemplate = `
`; `;
describe('containFocus()', () => { describe('containFocus()', () => {
it('starts focus at the root element when there is no element with autofocus', async () => { it('starts focus at the root element when there is no element with [autofocus]', async () => {
await fixture(lightDomTemplate); await fixture(lightDomTemplate);
const root = document.getElementById('rootElement'); const root = document.getElementById('rootElement');
containFocus(root); containFocus(root);
@ -63,7 +63,7 @@ describe('containFocus()', () => {
expect(root.style.getPropertyValue('outline-style')).to.equal('none'); expect(root.style.getPropertyValue('outline-style')).to.equal('none');
}); });
it('starts focus at the element with the autofocus attribute', async () => { it('starts focus at the element with [autofocus] attribute', async () => {
await fixture(lightDomAutofocusTemplate); await fixture(lightDomAutofocusTemplate);
const el = document.querySelector('input[autofocus]'); const el = document.querySelector('input[autofocus]');
containFocus(el); containFocus(el);

View file

@ -39,6 +39,6 @@
"@lion/button": "^0.3.16", "@lion/button": "^0.3.16",
"@lion/icon": "^0.2.6", "@lion/icon": "^0.2.6",
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6" "@open-wc/testing": "^2.3.4"
} }
} }

View file

@ -39,6 +39,6 @@
"@lion/form": "^0.1.58", "@lion/form": "^0.1.58",
"@lion/radio": "^0.1.53", "@lion/radio": "^0.1.53",
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6" "@open-wc/testing": "^2.3.4"
} }
} }

View file

@ -38,6 +38,6 @@
}, },
"devDependencies": { "devDependencies": {
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6" "@open-wc/testing": "^2.3.4"
} }
} }

View file

@ -46,6 +46,6 @@
"devDependencies": { "devDependencies": {
"@lion/form": "^0.1.58", "@lion/form": "^0.1.58",
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6" "@open-wc/testing": "^2.3.4"
} }
} }

View file

@ -280,7 +280,9 @@ describe('lion-select-rich interactions', () => {
describe('Disabled', () => { describe('Disabled', () => {
it('cannot be focused if disabled', async () => { it('cannot be focused if disabled', async () => {
const el = await fixture(html` const el = await fixture(html`
<lion-select-rich disabled></lion-select-rich> <lion-select-rich disabled>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`); `);
expect(el._invokerNode.tabIndex).to.equal(-1); expect(el._invokerNode.tabIndex).to.equal(-1);
}); });
@ -312,7 +314,9 @@ describe('lion-select-rich interactions', () => {
it('cannot be opened via click if disabled', async () => { it('cannot be opened via click if disabled', async () => {
const el = await fixture(html` const el = await fixture(html`
<lion-select-rich disabled> </lion-select-rich> <lion-select-rich disabled>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`); `);
el._invokerNode.click(); el._invokerNode.click();
expect(el.opened).to.be.false; expect(el.opened).to.be.false;
@ -320,7 +324,9 @@ describe('lion-select-rich interactions', () => {
it('reflects disabled attribute to invoker', async () => { it('reflects disabled attribute to invoker', async () => {
const el = await fixture(html` const el = await fixture(html`
<lion-select-rich disabled> </lion-select-rich> <lion-select-rich disabled>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`); `);
expect(el._invokerNode.hasAttribute('disabled')).to.be.true; expect(el._invokerNode.hasAttribute('disabled')).to.be.true;
el.removeAttribute('disabled'); el.removeAttribute('disabled');

View file

@ -8,7 +8,9 @@ import '../lion-select-rich.js';
describe('lion-select-rich', () => { describe('lion-select-rich', () => {
it('does not have a tabindex', async () => { it('does not have a tabindex', async () => {
const el = await fixture(html` const el = await fixture(html`
<lion-select-rich></lion-select-rich> <lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`); `);
expect(el.hasAttribute('tabindex')).to.be.false; expect(el.hasAttribute('tabindex')).to.be.false;
}); });
@ -16,7 +18,9 @@ describe('lion-select-rich', () => {
describe('Invoker', () => { describe('Invoker', () => {
it('generates an lion-select-invoker if no invoker is provided', async () => { it('generates an lion-select-invoker if no invoker is provided', async () => {
const el = await fixture(html` const el = await fixture(html`
<lion-select-rich></lion-select-rich> <lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`); `);
expect(el._invokerNode).to.exist; expect(el._invokerNode).to.exist;
@ -43,7 +47,9 @@ describe('lion-select-rich', () => {
describe('overlay', () => { describe('overlay', () => {
it('should be closed by default', async () => { it('should be closed by default', async () => {
const el = await fixture(html` const el = await fixture(html`
<lion-select-rich></lion-select-rich> <lion-select-rich>
<lion-options slot="input"></lion-options>
</lion-select-rich>
`); `);
expect(el.opened).to.be.false; expect(el.opened).to.be.false;
}); });
@ -117,7 +123,9 @@ describe('lion-select-rich', () => {
describe('interaction-mode', () => { describe('interaction-mode', () => {
it('allows to specify an interaction-mode which determines other behaviors', async () => { it('allows to specify an interaction-mode which determines other behaviors', async () => {
const el = await fixture(html` const el = await fixture(html`
<lion-select-rich interaction-mode="mac"></lion-select-rich> <lion-select-rich interaction-mode="mac">
<lion-options slot="input"></lion-options>
</lion-select-rich>
`); `);
expect(el.interactionMode).to.equal('mac'); expect(el.interactionMode).to.equal('mac');
}); });
@ -279,6 +287,8 @@ describe('lion-select-rich', () => {
expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('false'); expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('false');
el.opened = true; el.opened = true;
await el.updateComplete; await el.updateComplete;
await el.updateComplete; // need 2 awaits as overlay.show is an async function
expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('true'); expect(el._invokerNode.getAttribute('aria-expanded')).to.equal('true');
}); });
}); });

View file

@ -37,6 +37,6 @@
}, },
"devDependencies": { "devDependencies": {
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6" "@open-wc/testing": "^2.3.4"
} }
} }

View file

@ -36,7 +36,7 @@
}, },
"devDependencies": { "devDependencies": {
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6", "@open-wc/testing": "^2.3.4",
"sinon": "^7.2.2" "sinon": "^7.2.2"
} }
} }

View file

@ -38,6 +38,6 @@
}, },
"devDependencies": { "devDependencies": {
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6" "@open-wc/testing": "^2.3.4"
} }
} }

View file

@ -40,6 +40,6 @@
"@lion/button": "^0.3.16", "@lion/button": "^0.3.16",
"@lion/icon": "^0.2.6", "@lion/icon": "^0.2.6",
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6" "@open-wc/testing": "^2.3.4"
} }
} }

View file

@ -1,5 +1,4 @@
import { LionPopup } from '@lion/popup'; import { LionPopup } from '@lion/popup';
import { overlays, LocalOverlayController } from '@lion/overlays';
export class LionTooltip extends LionPopup { export class LionTooltip extends LionPopup {
constructor() { constructor() {
@ -10,20 +9,8 @@ export class LionTooltip extends LionPopup {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.contentNode = this.querySelector('[slot="content"]');
this.invokerNode = this.querySelector('[slot="invoker"]');
this.contentNode.setAttribute('role', 'tooltip'); this.contentNode.setAttribute('role', 'tooltip');
this._controller = overlays.add(
new LocalOverlayController({
hidesOnEsc: true,
hidesOnOutsideClick: true,
popperConfig: this.popperConfig,
contentNode: this.contentNode,
invokerNode: this.invokerNode,
}),
);
this.__resetActive = () => { this.__resetActive = () => {
this.mouseActive = false; this.mouseActive = false;
this.keyActive = false; this.keyActive = false;

View file

@ -38,7 +38,7 @@
}, },
"devDependencies": { "devDependencies": {
"@open-wc/demoing-storybook": "^0.2.0", "@open-wc/demoing-storybook": "^0.2.0",
"@open-wc/testing": "^2.0.6", "@open-wc/testing": "^2.3.4",
"sinon": "^7.2.2" "sinon": "^7.2.2"
} }
} }

110
yarn.lock
View file

@ -777,11 +777,6 @@
resolved "https://registry.yarnpkg.com/@bundled-es-modules/axios/-/axios-0.18.1.tgz#8beedbc92e9b0ed7df7c6cbdc6dfce84d306d80b" resolved "https://registry.yarnpkg.com/@bundled-es-modules/axios/-/axios-0.18.1.tgz#8beedbc92e9b0ed7df7c6cbdc6dfce84d306d80b"
integrity sha512-7c389uGe0dmfdedi9PQ3Om4vKg1HFzm/IntaqZ4FbXOo+gNiiPIM4He8MIkuRpgqUitbm1km0jOQ8p+tSpUp4Q== integrity sha512-7c389uGe0dmfdedi9PQ3Om4vKg1HFzm/IntaqZ4FbXOo+gNiiPIM4He8MIkuRpgqUitbm1km0jOQ8p+tSpUp4Q==
"@bundled-es-modules/chai@^4.2.0":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@bundled-es-modules/chai/-/chai-4.2.2.tgz#88dffebd7cd1e87397738107c3c54b9b4ea56e5e"
integrity sha512-iGmVYw2/zJCoqyKTtWEYCtFmMyi8WmACQKtky0lpNyEKWX0YIOpKWGD7saMXL+tPpllss0otilxV0SLwyi3Ytg==
"@bundled-es-modules/fetch-mock@^6.5.2": "@bundled-es-modules/fetch-mock@^6.5.2":
version "6.5.2" version "6.5.2"
resolved "https://registry.yarnpkg.com/@bundled-es-modules/fetch-mock/-/fetch-mock-6.5.2.tgz#f68d78dba49ffcb5b58bede5974c8a9dd035a6fb" resolved "https://registry.yarnpkg.com/@bundled-es-modules/fetch-mock/-/fetch-mock-6.5.2.tgz#f68d78dba49ffcb5b58bede5974c8a9dd035a6fb"
@ -1909,10 +1904,10 @@
universal-user-agent "^3.0.0" universal-user-agent "^3.0.0"
url-template "^2.0.8" url-template "^2.0.8"
"@open-wc/building-utils@^2.8.0": "@open-wc/building-utils@^2.8.2":
version "2.8.0" version "2.8.2"
resolved "https://registry.yarnpkg.com/@open-wc/building-utils/-/building-utils-2.8.0.tgz#e3c11ac844d2a150136fd41017dbeed263217638" resolved "https://registry.yarnpkg.com/@open-wc/building-utils/-/building-utils-2.8.2.tgz#0ccc3302add7d2e2cbd5b8328764369e506b50b8"
integrity sha512-Kf1T6b29ETi5r5wzrQNcNnQQuYmRd2YrKXk54YuTm2nErFbYdXWlPoBQrDjBtlo0STOgoY59HOUpWRbGVIbI2g== integrity sha512-DAJbvt1A2ACkUqpR/cVE7bTb89o0fEf6MTlpmmd869Zkn6+x+9qR02T+E/xPw2awFv2MhMihHlZWTaZgQ9Xs+w==
dependencies: dependencies:
"@babel/core" "^7.3.3" "@babel/core" "^7.3.3"
"@babel/plugin-syntax-dynamic-import" "^7.2.0" "@babel/plugin-syntax-dynamic-import" "^7.2.0"
@ -1985,15 +1980,16 @@
eslint-plugin-import "^2.18.0" eslint-plugin-import "^2.18.0"
eslint-plugin-wc "^1.2.0" eslint-plugin-wc "^1.2.0"
"@open-wc/karma-esm@^2.4.1": "@open-wc/karma-esm@^2.5.8":
version "2.4.1" version "2.5.8"
resolved "https://registry.yarnpkg.com/@open-wc/karma-esm/-/karma-esm-2.4.1.tgz#bc241240f0b39baa1d55b132bc6df76a430fc8b8" resolved "https://registry.yarnpkg.com/@open-wc/karma-esm/-/karma-esm-2.5.8.tgz#ed7e6739bd07a658324fa92bd304eaef88e78ea6"
integrity sha512-sL/Jf8krPqPmSuTyqdjTtR7IHmChPsk5sU/6BSW6XYYrHQv0ubDyRHRcpfQpUp3zfi35mWiznb7CJKrcq+nQKg== integrity sha512-4FtYOAdzQGvDnVJ27Xvfm6y1ZG1VybF84Y9tWCTJhkNFQgD5eY9DY3Ofw+2vmVEQYH0vKVm4hj2zk6cNjEpaUQ==
dependencies: dependencies:
"@open-wc/building-utils" "^2.8.0" "@open-wc/building-utils" "^2.8.2"
babel-plugin-istanbul "^5.1.4" babel-plugin-istanbul "^5.1.4"
chokidar "^3.0.2"
deepmerge "^3.3.0" deepmerge "^3.3.0"
es-dev-server "^1.14.0" es-dev-server "^1.18.0"
minimatch "^3.0.4" minimatch "^3.0.4"
portfinder "^1.0.21" portfinder "^1.0.21"
request "^2.88.0" request "^2.88.0"
@ -2006,36 +2002,36 @@
eslint-config-prettier "^3.3.0" eslint-config-prettier "^3.3.0"
prettier "^1.15.0" prettier "^1.15.0"
"@open-wc/semantic-dom-diff@^0.13.16", "@open-wc/semantic-dom-diff@^0.13.21": "@open-wc/semantic-dom-diff@^0.13.16":
version "0.13.21" version "0.13.21"
resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.13.21.tgz#718b9ec5f9a98935fc775e577ad094ae8d8b7dea" resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.13.21.tgz#718b9ec5f9a98935fc775e577ad094ae8d8b7dea"
integrity sha512-BONpjHcGX2zFa9mfnwBCLEmlDsOHzT+j6Qt1yfK3MzFXFtAykfzFjAgaxPetu0YbBlCfXuMlfxI4vlRGCGMvFg== integrity sha512-BONpjHcGX2zFa9mfnwBCLEmlDsOHzT+j6Qt1yfK3MzFXFtAykfzFjAgaxPetu0YbBlCfXuMlfxI4vlRGCGMvFg==
"@open-wc/semantic-dom-diff@^0.14.1": "@open-wc/semantic-dom-diff@^0.14.2":
version "0.14.1" version "0.14.2"
resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.14.1.tgz#c1362fac09a46390584a857387024137687bd39a" resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.14.2.tgz#4ad2a6fedc22992c6048729fbf7104e472f590d3"
integrity sha512-VmrJn8MloBnLx0LmKKASKBL7Y/hj3ci4tugWcRGcm3VE7SdUF7bZz9g4VkHaus18cfLQonziwRouw2Insx7I7w== integrity sha512-+ENGbkgoruTtuNGUVLi9hCC6+IJVBM/lnFDGjKBUt7T6RyAgidBTV+GPMzpQtfHhLRasVJmcjRW8D2him8HvAA==
"@open-wc/testing-helpers@^1.1.7", "@open-wc/testing-helpers@^1.2.1": "@open-wc/testing-helpers@^1.2.1":
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-1.2.1.tgz#eecba5ccfe808f9667caf149e68cd80d781f28e0" resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-1.2.1.tgz#eecba5ccfe808f9667caf149e68cd80d781f28e0"
integrity sha512-FZBjqM81GQc+Q8W4YdWNwwk64+PW6frCvHyeov5ivCR2K7SJXwTPRcQIi0qU/6VVRVDlQeQ39PH8oSnjnIYpvQ== integrity sha512-FZBjqM81GQc+Q8W4YdWNwwk64+PW6frCvHyeov5ivCR2K7SJXwTPRcQIi0qU/6VVRVDlQeQ39PH8oSnjnIYpvQ==
"@open-wc/testing-karma-bs@^1.1.47": "@open-wc/testing-karma-bs@^1.1.58":
version "1.1.47" version "1.1.58"
resolved "https://registry.yarnpkg.com/@open-wc/testing-karma-bs/-/testing-karma-bs-1.1.47.tgz#8e8ceab7e9fea751703c7aedea22a45889642d7b" resolved "https://registry.yarnpkg.com/@open-wc/testing-karma-bs/-/testing-karma-bs-1.1.58.tgz#d2c46d0cedc2927bc69689a0c25ef906e7505ba5"
integrity sha512-TcINS7scTBO3c7FkXySrZbmOqY/+XgaJ0EV4ns4YchHpSl5B48Xvdx6VThMx2Xf2/yMMz/GB2sjH8UO3tX0k5A== integrity sha512-zj8BbozAIm7HEOYly594rzvEiENjH06R7RM5fbdkwbTxvcC4m/TmvGpVmwXX37XgxqKo5WxIA7wXeQkReCA8xw==
dependencies: dependencies:
"@open-wc/testing-karma" "^3.1.22" "@open-wc/testing-karma" "^3.1.33"
"@types/node" "^11.13.0" "@types/node" "^11.13.0"
karma-browserstack-launcher "^1.0.0" karma-browserstack-launcher "^1.0.0"
"@open-wc/testing-karma@^3.1.22": "@open-wc/testing-karma@^3.1.33":
version "3.1.22" version "3.1.33"
resolved "https://registry.yarnpkg.com/@open-wc/testing-karma/-/testing-karma-3.1.22.tgz#6c0e14317880151622a8eab0f7ae166fd035a418" resolved "https://registry.yarnpkg.com/@open-wc/testing-karma/-/testing-karma-3.1.33.tgz#3e0ff02cc2db0cce01ac4a9fe1e979f94c82d79c"
integrity sha512-oDLCBqm8HbMnwok5/COVWmubW+UYDaWrokMOfmwmLq6eMlNnEkr3PNuKW0nII6RY5UkWaefbC4BuIWD/S6vt3w== integrity sha512-TzzS0CyN62ZxJ2SmDt9LIcT8lRb/jO+mv7U1Lg69iu0KLRJZTrFILstTEVHww0UHgrbOE8N+LbmdYiH8ryBIUQ==
dependencies: dependencies:
"@open-wc/karma-esm" "^2.4.1" "@open-wc/karma-esm" "^2.5.8"
axe-core "^3.3.1" axe-core "^3.3.1"
istanbul-instrumenter-loader "^3.0.0" istanbul-instrumenter-loader "^3.0.0"
karma "^4.0.0" karma "^4.0.0"
@ -2057,27 +2053,13 @@
wallaby-webpack "^3.0.0" wallaby-webpack "^3.0.0"
webpack "^4.28.0" webpack "^4.28.0"
"@open-wc/testing@^2.0.6": "@open-wc/testing@^2.3.4":
version "2.2.8" version "2.3.4"
resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-2.2.8.tgz#ed4d3621601512eeccb0a759127589776da1d279" resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-2.3.4.tgz#cb29bbba14208c5ddfcb95119aff31ab1dfe2793"
integrity sha512-z+eRA6luH7TR8iewZJnZBN1JOmDGViFOQqdMoHkQPvQ0C2aBZNW/IrdbYUa+YY4rC+5jtWXHMf9gYdxw/B16bg== integrity sha512-Jne5opdKgH1EvySXdMv5VO+c/+v1aL8EQQUqe7vggmrf9cEUdQ/fvLBP4qEeUF7kJdzVvvOjfEzPuA5Y/NEUmQ==
dependencies:
"@bundled-es-modules/chai" "^4.2.0"
"@open-wc/chai-dom-equals" "^0.12.36"
"@open-wc/semantic-dom-diff" "^0.13.21"
"@open-wc/testing-helpers" "^1.1.7"
"@types/chai" "^4.1.7"
"@types/mocha" "^5.0.0"
chai-a11y-axe "^1.1.1"
mocha "^5.0.0"
"@open-wc/testing@^2.3.2":
version "2.3.2"
resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-2.3.2.tgz#3199d4049d2163d4e74b9390fafbbb8dc8e51eeb"
integrity sha512-fdFXO3TVib/3+67BLwMYPqQgs7FaUajoMzazjtFNSUxFZgvqdHNz8gMBUYyD49wdCxBb9RAUtjQTjaLXEY+uWQ==
dependencies: dependencies:
"@open-wc/chai-dom-equals" "^0.12.36" "@open-wc/chai-dom-equals" "^0.12.36"
"@open-wc/semantic-dom-diff" "^0.14.1" "@open-wc/semantic-dom-diff" "^0.14.2"
"@open-wc/testing-helpers" "^1.2.1" "@open-wc/testing-helpers" "^1.2.1"
"@types/chai" "^4.1.7" "@types/chai" "^4.1.7"
"@types/chai-dom" "^0.0.8" "@types/chai-dom" "^0.0.8"
@ -4299,11 +4281,6 @@ caseless@~0.12.0:
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
chai-a11y-axe@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/chai-a11y-axe/-/chai-a11y-axe-1.1.1.tgz#5e61e8c2d4b1a5b1b039658d8a21d147c18f612d"
integrity sha512-pLrVkN39GUSZl6Yd+HRJGo4XgXdwiSO2pt0NItb5vENRvh+0rTTCQK2ptBlMqtxOw/RCfFNi7avJjAEzGaa66A==
chai-a11y-axe@^1.2.1: chai-a11y-axe@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/chai-a11y-axe/-/chai-a11y-axe-1.2.1.tgz#173c9bb5cd534ccc9039edc3cdeed7692ab60f15" resolved "https://registry.yarnpkg.com/chai-a11y-axe/-/chai-a11y-axe-1.2.1.tgz#173c9bb5cd534ccc9039edc3cdeed7692ab60f15"
@ -4420,7 +4397,7 @@ chokidar@^2.0.2:
optionalDependencies: optionalDependencies:
fsevents "^1.2.7" fsevents "^1.2.7"
chokidar@^3.0.0: chokidar@^3.0.0, chokidar@^3.0.2:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.0.2.tgz#0d1cd6d04eb2df0327446188cd13736a3367d681" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.0.2.tgz#0d1cd6d04eb2df0327446188cd13736a3367d681"
integrity sha512-c4PR2egjNjI1um6bamCQ6bUNPDiyofNQruHvKgHQ4gDUP/ITSVSzNsiI5OWtHOsX323i5ha/kk4YmOZ1Ktg7KA== integrity sha512-c4PR2egjNjI1um6bamCQ6bUNPDiyofNQruHvKgHQ4gDUP/ITSVSzNsiI5OWtHOsX323i5ha/kk4YmOZ1Ktg7KA==
@ -5896,15 +5873,15 @@ es-abstract@^1.10.0, es-abstract@^1.11.0, es-abstract@^1.12.0, es-abstract@^1.13
is-regex "^1.0.4" is-regex "^1.0.4"
object-keys "^1.0.12" object-keys "^1.0.12"
es-dev-server@^1.14.0: es-dev-server@^1.18.0:
version "1.14.0" version "1.18.0"
resolved "https://registry.yarnpkg.com/es-dev-server/-/es-dev-server-1.14.0.tgz#165c332915c1494b0a518417720eaa4460822c56" resolved "https://registry.yarnpkg.com/es-dev-server/-/es-dev-server-1.18.0.tgz#56dd48868c4fa1f2b5029886a35351e385830651"
integrity sha512-6HhP4GlYyn9y7ptPU8Yset+dFDdGiA/2pVLIu7W+wg+HWPIqKIrBenlxYoGvLfR4MzYryG320BAJNW/W4ofYkw== integrity sha512-cw6NyLxim8WB5EeAJOJA4wEq87ZC3NTIx7earYVtR4k3ihJMuLm2pLFpMqovWao7zWZ1WQgoETNsdxSx+0uj3A==
dependencies: dependencies:
"@babel/core" "^7.4.5" "@babel/core" "^7.4.5"
"@babel/plugin-syntax-import-meta" "^7.2.0" "@babel/plugin-syntax-import-meta" "^7.2.0"
"@babel/preset-env" "^7.4.5" "@babel/preset-env" "^7.4.5"
"@open-wc/building-utils" "^2.8.0" "@open-wc/building-utils" "^2.8.2"
"@types/minimatch" "^3.0.3" "@types/minimatch" "^3.0.3"
babel-plugin-bare-import-rewrite "^1.5.1" babel-plugin-bare-import-rewrite "^1.5.1"
camelcase "^5.3.1" camelcase "^5.3.1"
@ -5913,7 +5890,7 @@ es-dev-server@^1.14.0:
command-line-usage "^5.0.5" command-line-usage "^5.0.5"
debounce "^1.2.0" debounce "^1.2.0"
deepmerge "^3.3.0" deepmerge "^3.3.0"
es-module-lexer "^0.2.0" es-module-lexer "0.3.9"
get-stream "^5.1.0" get-stream "^5.1.0"
is-stream "^2.0.0" is-stream "^2.0.0"
koa "^2.7.0" koa "^2.7.0"
@ -5925,12 +5902,13 @@ es-dev-server@^1.14.0:
path-is-inside "^1.0.2" path-is-inside "^1.0.2"
portfinder "^1.0.21" portfinder "^1.0.21"
resolve "^1.12.0" resolve "^1.12.0"
strip-ansi "^5.2.0"
whatwg-url "^7.0.0" whatwg-url "^7.0.0"
es-module-lexer@^0.2.0: es-module-lexer@0.3.9:
version "0.2.0" version "0.3.9"
resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.2.0.tgz#b0e88d22dc270dc5f8c2d426e63a42252188eee9" resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.3.9.tgz#fbdbb35e1ad434fc71f5d83a887781fadbaca83a"
integrity sha512-HAcAIT+A5ugnSgC1pS+tgVWjlaIx8HZo8fI7xa90WdAjrJqXEZO0oszJQV3q04wCD4/ZQDlgu8nOLPxMWlaQ8g== integrity sha512-beowXiBsaVS208GHoJckwOxlG/RpWLLz2ioJHwkUJ3RGFmW508E5RfntV21bMjzO3hQcwZOTHewUFwnGzrBtIg==
es-module-shims@^0.2.13: es-module-shims@^0.2.13:
version "0.2.15" version "0.2.15"