diff --git a/packages/core/package.json b/packages/core/package.json index 55308f5fe..996d6d00c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,7 +26,7 @@ "docs", "src", "stories", - "test", + "test-helpers", "translations", "*.js" ], diff --git a/packages/core/src/differentKeyEventNamesShimIE.js b/packages/core/src/differentKeyEventNamesShimIE.js new file mode 100644 index 000000000..ad27b941c --- /dev/null +++ b/packages/core/src/differentKeyEventNamesShimIE.js @@ -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; + }, + }); + } +} diff --git a/packages/core/test-helpers/keyboardEventShimIE.js b/packages/core/test-helpers/keyboardEventShimIE.js new file mode 100644 index 000000000..45e834217 --- /dev/null +++ b/packages/core/test-helpers/keyboardEventShimIE.js @@ -0,0 +1,49 @@ +if (typeof window.KeyboardEvent !== 'function') { + // e.g. is IE and needs "polyfill" + const KeyboardEvent = (event, _params) => { + // current spec for it https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/KeyboardEvent + const params = { + bubbles: false, + cancelable: false, + view: document.defaultView, + key: false, + location: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + repeat: false, + ..._params, + }; + const modifiersListArray = []; + if (params.ctrlKey) { + modifiersListArray.push('Control'); + } + if (params.shiftKey) { + modifiersListArray.push('Shift'); + } + if (params.altKey) { + modifiersListArray.push('Alt'); + } + if (params.metaKey) { + modifiersListArray.push('Meta'); + } + + const ev = document.createEvent('KeyboardEvent'); + // IE Spec for it https://technet.microsoft.com/en-us/windows/ff975297(v=vs.60) + ev.initKeyboardEvent( + event, + params.bubbles, + params.cancelable, + params.view, + params.key, + params.location, + modifiersListArray.join(' '), + params.repeat ? 1 : 0, + params.locale, + ); + return ev; + }; + KeyboardEvent.prototype = window.Event.prototype; + window.KeyboardEvent = KeyboardEvent; +} diff --git a/packages/overlays/README.md b/packages/overlays/README.md index 4575d3299..dbfedcb7a 100644 --- a/packages/overlays/README.md +++ b/packages/overlays/README.md @@ -8,7 +8,7 @@ Manages their position on the screen relative to other elements, including other ## Features - [**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. - [**Local Overlay Controller**](./docs/LocalOverlayController.md), controller for overlays positioned next to invokers they are related to. diff --git a/packages/overlays/docs/GlobalOverlayController.md b/packages/overlays/docs/GlobalOverlayController.md index 222d1926f..86d913845 100644 --- a/packages/overlays/docs/GlobalOverlayController.md +++ b/packages/overlays/docs/GlobalOverlayController.md @@ -1,7 +1,8 @@ # 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. -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. +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. But for implementation details check out [Overlay System: Implementation](./OverlaySystemImplementation.md). All supported types of global overlays are described below. diff --git a/packages/overlays/docs/LocalOverlayController.md b/packages/overlays/docs/LocalOverlayController.md index 24aa6fcf1..5ef81a742 100644 --- a/packages/overlays/docs/LocalOverlayController.md +++ b/packages/overlays/docs/LocalOverlayController.md @@ -1,7 +1,10 @@ # 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) -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. +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). + +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. diff --git a/packages/overlays/docs/OverlaySystemImplementation.md b/packages/overlays/docs/OverlaySystemImplementation.md index c464b12cb..57992a5c7 100644 --- a/packages/overlays/docs/OverlaySystemImplementation.md +++ b/packages/overlays/docs/OverlaySystemImplementation.md @@ -1,126 +1,98 @@ # Overlay System: Implementation -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). +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). + +## 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 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. -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 -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. +All of their configuration options will be described below as part of the _Configuration options_ section. -Only, we would have a few concerns when creating global overlays from a local connection point: - -- 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 +### DynamicOverlayController 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, 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. -To implement such a flexible overlay, we need an 'umbrella' layer that allows for switching between -different configuration options, also between the connection point in dom (global and local). +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. -Luckily, interfaces of Global and OverlayControllers are very similar. -Therefore we can make a wrapping ResponsiveOverlayController. - -### Configuration options for local and global overlays +### Configuration options In total, we should end up with configuration options as depicted below, for all possible overlays. All boolean flags default to 'false'. -Some options are mutually exclusive, in which case their dependent options and requirement will be -mentioned. -Note: a more generic and precise term for all mentionings of `invoker` below would actually be -`relative positioning element`. +Some options are mutually exclusive, in which case their dependent options and requirement will be mentioned. + +> Note: a more generic and precise term for all mentionings of `invoker` below would actually be `relative positioning element`. + +#### Shared configuration options ```text -- {Element} elementToFocusAfterHide - the element that should be called `.focus()` on after dialog closes -- {Boolean} hasBackdrop - whether it should have a backdrop (currently exclusive to globalOverlayController) -- {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) +- {Boolean} trapsKeyboardFocus - rotates tab, implicitly set when 'isModal'. +- {Boolean} hidesOnEsc - hides the overlay when pressing [esc]. ``` -These options are suggested to be added to the current ones: +#### Global specific configuration options ```text -- {Boolean} isModal - sets aria-modal and/or aria-hidden="true" on siblings -- {Boolean} isGlobal - determines the connection point in DOM (body vs handled by user) TODO: rename to renderToBody? -- {Boolean} isTooltip - has a totally different interaction - and accessibility pattern from all -other overlays, so needed for internals. +- {Element} elementToFocusAfterHide - the element that should be called `.focus()` on after dialog closes. +- {Boolean} hasBackdrop - whether it should have a backdrop. +- {Boolean} isBlocking - hides other overlays when multiple are opened. +- {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: . +- 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} handlesAccessibility - - For non `isTooltip`: - - sets aria-expanded="true/false" and aria-haspopup="true" on invokerNode - - sets aria-controls on invokerNode + - sets [aria-expanded="true/false"] and [aria-haspopup="true"] on invokerNode + - sets [aria-controls] on invokerNode - returns focus to invokerNode on hide - sets focus to overlay content(?) - For `isTooltip`: - - sets role="tooltip" and aria-labelledby/aria-describedby on the content -- {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 ` @@ -271,42 +273,6 @@ storiesOf('Global Overlay System|Global Overlay', module) `; }) - .add('Toast', () => { - let counter = 0; - - function openInfo() { - const toastCtrl = overlays.add( - new GlobalOverlayController({ - contentTemplate: data => html` -
- Title ${data.counter} -

Lorem ipsum ${data.counter}

-
- `, - }), - ); - toastCtrl.sync({ - isShown: true, - data: { counter }, - }); - counter += 1; - setTimeout(() => { - toastCtrl.hide(); - counter -= 1; - }, 2000); - } - - return html` - - -

Very naive toast implementation

-

It does not handle adding new while toasts are getting hidden

- `; - }) .add('In web components', () => { class EditUsernameOverlay extends LionLitElement { static get properties() { @@ -397,9 +363,9 @@ storiesOf('Global Overlay System|Global Overlay', module) this._editOverlay = overlays.add( new GlobalOverlayController({ focusElementAfterHide: this.shadowRoot.querySelector('button'), - contentTemplate: data => html` + contentTemplate: ({ username = 'standard' } = {}) => html` diff --git a/packages/overlays/stories/index.stories.js b/packages/overlays/stories/index.stories.js index 69897cec5..68ba08504 100644 --- a/packages/overlays/stories/index.stories.js +++ b/packages/overlays/stories/index.stories.js @@ -2,3 +2,4 @@ import './global-overlay.stories.js'; import './modal-dialog.stories.js'; import './local-overlay.stories.js'; import './local-overlay-placement.stories.js'; +import './dynamic-overlay.stories.js'; diff --git a/packages/overlays/stories/local-overlay-placement.stories.js b/packages/overlays/stories/local-overlay-placement.stories.js index 19cce5ad9..50e119e3b 100644 --- a/packages/overlays/stories/local-overlay-placement.stories.js +++ b/packages/overlays/stories/local-overlay-placement.stories.js @@ -46,17 +46,18 @@ const popupPlacementDemoStyle = css` storiesOf('Local Overlay System|Local Overlay Placement', module) .addParameters({ options: { selectedPanel: 'storybook/actions/actions-panel' } }) .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({ hidesOnEsc: true, - contentTemplate: () => - html` -
United Kingdom
- `, - invokerTemplate: () => - html` - - `, + contentTemplate: () => html` +
United Kingdom
+ `, + invokerNode, }), ); @@ -64,14 +65,19 @@ storiesOf('Local Overlay System|Local Overlay Placement', module) - +
- ${popupController.invoker} ${popupController.content} + ${invokerNode} ${popup.content}
`; }) .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({ hidesOnEsc: true, popperConfig: { @@ -100,10 +106,7 @@ storiesOf('Local Overlay System|Local Overlay Placement', module) html`
United Kingdom
`, - invokerTemplate: () => - html` - - `, + invokerNode, }), ); @@ -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: Popper.js Docs - +
- ${popupController.invoker} ${popupController.content} + ${invokerNode} ${popup.content}
`; }); /* TODO: Add this when we have a feature in place that adds scrollbars / overflow when no space is 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({ hidesOnEsc: true, - contentTemplate: () => - html` + contentTemplate: () => html`
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 @@ -135,10 +141,7 @@ storiesOf('Local Overlay System|Local Overlay Placement', module) increase/decrease space see the behavior of this.
`, - invokerTemplate: () => - html` - - `, + invokerNode, }), ); @@ -147,11 +150,11 @@ storiesOf('Local Overlay System|Local Overlay Placement', module) ${popupPlacementDemoStyle}
- - + +
- ${popupController.invoker} ${popupController.content} + ${invokerNode} ${popup.content}
`; }); */ diff --git a/packages/overlays/stories/local-overlay.stories.js b/packages/overlays/stories/local-overlay.stories.js index e1ce170cc..a517cd0b7 100644 --- a/packages/overlays/stories/local-overlay.stories.js +++ b/packages/overlays/stories/local-overlay.stories.js @@ -26,18 +26,19 @@ const popupDemoStyle = css` storiesOf('Local Overlay System|Local Overlay', module) .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({ hidesOnEsc: true, hidesOnOutsideClick: true, - contentTemplate: () => - html` -
United Kingdom
- `, - invokerTemplate: () => - html` - - `, + contentTemplate: () => html` +
United Kingdom
+ `, + invokerNode, }), ); return html` @@ -45,26 +46,27 @@ storiesOf('Local Overlay System|Local Overlay', module) ${popupDemoStyle}
- In the ${popup.invoker}${popup.content} the weather is nice. + In the ${invokerNode}${popup.content} the weather is nice.
`; }) .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({ hidesOnEsc: true, hidesOnOutsideClick: true, popperConfig: { placement: 'top-end', }, - contentTemplate: () => - html` -
United Kingdom
- `, - invokerTemplate: () => - html` - - `, + contentTemplate: () => html` +
United Kingdom
+ `, + invokerNode, }), ); return html` @@ -72,12 +74,17 @@ storiesOf('Local Overlay System|Local Overlay', module) ${popupDemoStyle}
- In the ${popup.invoker}${popup.content} the weather is nice. + In the ${invokerNode}${popup.content} the weather is nice.
`; }) .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({ hidesOnEsc: 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. `, - invokerTemplate: () => - html` - - `, + invokerNode, }), ); return html` @@ -100,12 +104,18 @@ storiesOf('Local Overlay System|Local Overlay', module) ${popupDemoStyle}
- ${popup.invoker}${popup.content} + ${invokerNode}${popup.content}
`; }) .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({ hidesOnEsc: true, hidesOnOutsideClick: true, @@ -116,9 +126,7 @@ storiesOf('Local Overlay System|Local Overlay', module) html`
United Kingdom
`, - invokerTemplate: () => html` - - `, + invokerNode, }), ); return html` @@ -126,26 +134,24 @@ storiesOf('Local Overlay System|Local Overlay', module) ${popupDemoStyle}
- In the beautiful ${popup.invoker}${popup.content} the weather is nice. + In the beautiful ${invokerNode}${popup.content} the weather is nice.
`; }) .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({ - contentTemplate: () => - html` -
United Kingdom
- `, - invokerTemplate: () => - html` - popup.show()} - @blur=${() => popup.hide()} - /> - `, + contentTemplate: () => html` +
United Kingdom
+ `, + invokerNode, }), ); return html` @@ -154,12 +160,17 @@ storiesOf('Local Overlay System|Local Overlay', module)
- ${popup.invoker}${popup.content} + ${invokerNode}${popup.content}
`; }) .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({ hidesOnEsc: true, hidesOnOutsideClick: true, @@ -177,10 +188,7 @@ storiesOf('Local Overlay System|Local Overlay', module) `, - invokerTemplate: () => - html` - - `, + invokerNode, }), ); return html` @@ -188,7 +196,7 @@ storiesOf('Local Overlay System|Local Overlay', module) ${popupDemoStyle}
- ${popup.invoker}${popup.content} + ${invokerNode}${popup.content}
`; }) @@ -222,7 +230,7 @@ storiesOf('Local Overlay System|Local Overlay', module) ${popupDemoStyle}
- ${popup.invoker}${popup.content} + ${invokerNode}${popup.content}
`; }); diff --git a/packages/overlays/stories/modal-dialog.stories.js b/packages/overlays/stories/modal-dialog.stories.js index 7edcb2032..3b8d7c34b 100644 --- a/packages/overlays/stories/modal-dialog.stories.js +++ b/packages/overlays/stories/modal-dialog.stories.js @@ -6,7 +6,7 @@ import { overlays, ModalDialogController } from '../index.js'; const modalDialogDemoStyle = css` .demo-overlay { background-color: white; - position: fixed; + position: absolute; top: 20px; left: 20px; width: 200px; @@ -20,12 +20,30 @@ const modalDialogDemoStyle = css` storiesOf('Global Overlay System|Modal Dialog', module) .add('Default', () => { + const nestedDialogCtrl = overlays.add( + new ModalDialogController({ + contentTemplate: () => html` +
+

Nested modal dialog

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

Modal dialog

+
`, }), diff --git a/packages/overlays/test-suites/BaseOverlayController.suite.js b/packages/overlays/test-suites/BaseOverlayController.suite.js new file mode 100644 index 000000000..ff0217b7b --- /dev/null +++ b/packages/overlays/test-suites/BaseOverlayController.suite.js @@ -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` +

my content

+ `, + }); + expect(ctrl.isShown).to.be.false; + }); + + it('has async show() which shows the overlay', async () => { + const ctrl = createCtrlFn({ + contentTemplate: () => html` +

my content

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

my content

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

my content

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

my content

+ `, + }); + 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 as a wrapper for a render target', () => { + const ctrl = createCtrlFn({ + contentTemplate: () => html` +

my content

+ `, + }); + 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 to render into .content', async () => { + const ctrl = createCtrlFn({ + contentTemplate: () => html` +

my content

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

one

+

two

+ `, + }); + }).to.throw('The .contentTemplate needs to always return exactly one child node'); + }); + + it('allows to change the .contentTemplate', async () => { + const ctrl = createCtrlFn({ + contentTemplate: () => html` +

my content

+ `, + }); + await ctrl.show(); + expect(ctrl.contentNode).to.have.trimmed.text('my content'); + + ctrl.contentTemplate = () => html` +
+

new content

+

my adjusted content

+
+ `; + expect(ctrl.contentNode).lightDom.to.equal(` +

new content

+

my adjusted content

+ `); + }); + + it('has .contentData which triggers a updates of the overlay content', async () => { + const ctrl = createCtrlFn({ + contentTemplate: ({ username = 'default user' } = {}) => html` +

my content - ${username}

+ `, + }); + 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 to directly set content', async () => { + const ctrl = createCtrlFn({ + contentNode: await fixture('

direct node

'), + }); + 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('

direct node

'), + }); + 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` +

my content

+ `, + }); + + 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('

direct node

'), + }); + + 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` +

direct node

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

direct node

+ `, + }); + 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('

direct node

'); + 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` +

my content

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

Content

+ `, + }); + // 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` +
+ `, + }); + // add element to dom to allow focus + await fixture(html` + ${ctrl.content} + `); + await ctrl.show(); + ctrl.enableTrapsKeyboardFocus(); + + const elOutside = await fixture(html` + + `); + const input1 = ctrl.contentNode.querySelectorAll('input')[0]; + const input2 = ctrl.contentNode.querySelectorAll('input')[1]; + + input2.focus(); + // this mimics a tab within the contain-focus system used + const event = new CustomEvent('keydown', { detail: 0, bubbles: true }); + event.keyCode = keyCodes.tab; + window.dispatchEvent(event); + + expect(elOutside).to.not.equal(document.activeElement); + expect(input1).to.equal(document.activeElement); + }); + + it('allows to move the focus outside of the overlay if trapsKeyboardFocus is disabled', async () => { + const ctrl = createCtrlFn({ + contentTemplate: () => html` +
+ `, + }); + // add element to dom to allow focus + await fixture(html` + ${ctrl.content} + `); + await ctrl.show(); + ctrl.enableTrapsKeyboardFocus(); + + const elOutside = await fixture(html` + + `); + 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` +

Content

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

Content

+ `, + }); + await ctrl.show(); + ctrl.enableHidesOnEsc(); + + document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); + expect(ctrl.isShown).to.be.true; + }); + }); +}; diff --git a/packages/overlays/test/BaseOverlayController.test.js b/packages/overlays/test/BaseOverlayController.test.js new file mode 100644 index 000000000..1bcd597de --- /dev/null +++ b/packages/overlays/test/BaseOverlayController.test.js @@ -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)); +}); diff --git a/packages/overlays/test/DynamicGlobalLocal.test.js b/packages/overlays/test/DynamicGlobalLocal.test.js new file mode 100644 index 000000000..e1591e2eb --- /dev/null +++ b/packages/overlays/test/DynamicGlobalLocal.test.js @@ -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('

Content

', { 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('

Content

', { + ignoreAttributes: ['x-placement', 'style'], + }); + expect(ctrl.contentNode).to.be.displayed; +} +function expectCssHidden(ctrl) { + expect(ctrl.contentNode).dom.to.equal('

Content

', { + 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(''); + globalWithTemplate = overlays.add( + new GlobalOverlayController({ + contentTemplate: () => html` +

Content

+ `, + }), + ); + globalWithTemplate1 = overlays.add( + new GlobalOverlayController({ + contentTemplate: () => html` +

Content

+ `, + }), + ); + localWithTemplate = new LocalOverlayController({ + contentTemplate: () => html` +

Content

+ `, + invokerNode, + }); + localWithTemplate1 = new LocalOverlayController({ + contentTemplate: () => html` +

Content

+ `, + 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(''); + const contentNode = await fixture(`

Content

`); + 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(''); + const contentNode = await fixture(`

Content

`); + globalWithTemplate = new GlobalOverlayController({ + contentTemplate: () => html` +

Content

+ `, + }); + localWithTemplate = new LocalOverlayController({ + contentTemplate: () => html` +

Content

+ `, + 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); + }); + }); +}); diff --git a/packages/overlays/test/DynamicOverlayController.test.js b/packages/overlays/test/DynamicOverlayController.test.js new file mode 100644 index 000000000..07a8daf4a --- /dev/null +++ b/packages/overlays/test/DynamicOverlayController.test.js @@ -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` +

my content

+ `, + }; + + 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); + }); +}); diff --git a/packages/overlays/test/GlobalOverlayController.test.js b/packages/overlays/test/GlobalOverlayController.test.js index 9a9447970..b6b5cc5b0 100644 --- a/packages/overlays/test/GlobalOverlayController.test.js +++ b/packages/overlays/test/GlobalOverlayController.test.js @@ -1,10 +1,8 @@ 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 { overlays } from '../src/overlays.js'; +import { runBaseOverlaySuite } from '../test-suites/BaseOverlayController.suite.js'; function getRootNode() { return document.querySelector('.global-overlays'); @@ -51,108 +49,87 @@ function getRenderedOverlay(index) { function cleanup() { document.body.removeAttribute('style'); - if (GlobalOverlayController._rootNode) { - GlobalOverlayController._rootNode.parentElement.removeChild(GlobalOverlayController._rootNode); - GlobalOverlayController._rootNode = undefined; - } + overlays.teardown(); } describe('GlobalOverlayController', () => { afterEach(cleanup); + describe('extends BaseOverlayController', () => { + runBaseOverlaySuite((...args) => overlays.add(new GlobalOverlayController(...args))); + }); + describe('basics', () => { - it('creates a controller with methods: show, hide, sync', () => { - const controller = new GlobalOverlayController(); - expect(controller.show).to.be.a('function'); - expect(controller.hide).to.be.a('function'); - expect(controller.sync).to.be.a('function'); - }); - - it('creates a root node in body when first controller is shown', () => { - const controller = new GlobalOverlayController({ - contentTemplate: () => - html` -

Content

+ it('renders an overlay from the lit-html based contentTemplate when showing', async () => { + const ctrl = overlays.add( + new GlobalOverlayController({ + contentTemplate: () => html` +

my content

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

Content

- `, - }); - controller.show(); + await ctrl.show(); expect(getRootNode().children.length).to.equal(1); - expect(getRootNode().children[0].classList.contains('global-overlays__overlay')).to.be.true; - 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'); + expect(getRootNode().children[0]).to.have.trimmed.text('my content'); }); - it('removes the overlay from DOM when hiding', () => { - const controller = new GlobalOverlayController({ - contentTemplate: () => - html` + it('removes the overlay from DOM when hiding', async () => { + const ctrl = overlays.add( + new GlobalOverlayController({ + contentTemplate: () => html`

Content

`, - }); + }), + ); - controller.show(); + await ctrl.show(); expect(getRenderedContainers().length).to.equal(1); expect(getRenderedOverlay(0).tagName).to.equal('P'); expect(getRenderedOverlay(0).textContent).to.equal('Content'); expect(getTopContainer()).to.equal(getRenderedContainer(0)); - controller.hide(); + await ctrl.hide(); expect(getRenderedContainers().length).to.equal(0); expect(getTopContainer()).to.not.exist; }); - it('exposes isShown state for reading', () => { - const controller = new GlobalOverlayController({ - contentTemplate: () => - html` + it('exposes isShown state for reading', async () => { + const ctrl = overlays.add( + new GlobalOverlayController({ + contentTemplate: () => html`

Content

`, - }); + }), + ); - expect(controller.isShown).to.equal(false); + expect(ctrl.isShown).to.equal(false); - controller.show(); - expect(controller.isShown).to.equal(true); + await ctrl.show(); + expect(ctrl.isShown).to.equal(true); - controller.hide(); - expect(controller.isShown).to.equal(false); + await ctrl.hide(); + expect(ctrl.isShown).to.equal(false); }); - it('puts the latest shown overlay always on top', () => { - const controller0 = new GlobalOverlayController({ - contentTemplate: () => - html` + it('puts the latest shown overlay always on top', async () => { + const controller0 = overlays.add( + new GlobalOverlayController({ + contentTemplate: () => html`

Content0

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

Content1

`, - }); + }), + ); - controller0.show(); - controller1.show(); - controller0.show(); + await controller0.show(); + await controller1.show(); + await controller0.show(); expect(getRenderedContainers().length).to.equal(2); expect(getRenderedOverlay(0).tagName).to.equal('P'); @@ -162,542 +139,216 @@ describe('GlobalOverlayController', () => { expect(getTopOverlay().textContent).to.equal('Content0'); }); - it('does not recreate the overlay elements when calling show multiple times', () => { - const controller = new GlobalOverlayController({ - contentTemplate: () => - html` + it('does not recreate the overlay elements when calling show multiple times', async () => { + const ctrl = overlays.add( + new GlobalOverlayController({ + contentTemplate: () => html`

Content

`, - }); + }), + ); - controller.show(); + await ctrl.show(); expect(getRenderedContainers().length).to.equal(1); const initialContainer = getRenderedContainer(0); const initialOverlay = getRenderedOverlay(0); - controller.show(); + await ctrl.show(); expect(getRenderedContainers().length).to.equal(1); expect(getRenderedContainer(0)).to.equal(initialContainer); expect(getRenderedOverlay(0)).to.equal(initialOverlay); }); - it('recreates the overlay elements when hiding and showing again', () => { - const controller = new GlobalOverlayController({ - contentTemplate: () => - html` -

Content

+ it('supports .sync(isShown, data)', async () => { + const ctrl = overlays.add( + new GlobalOverlayController({ + contentTemplate: ({ text = 'default' } = {}) => html` +

${text}

`, - }); + }), + ); - controller.show(); - 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` -

${data.text}

- `, - }); - - controller.sync({ isShown: true, data: { text: 'hello world' } }); + await ctrl.sync({ isShown: true, data: { text: 'hello world' } }); expect(getRenderedContainers().length).to.equal(1); 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(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); }); }); describe('elementToFocusAfterHide', () => { - it('focuses body when hiding by default', () => { - const controller = new GlobalOverlayController({ - contentTemplate: () => - html` + it('focuses body when hiding by default', async () => { + const ctrl = overlays.add( + new GlobalOverlayController({ + contentTemplate: () => html`
=
`, - }); + }), + ); - controller.show(); + await ctrl.show(); const input = getTopOverlay().querySelector('input'); input.focus(); expect(document.activeElement).to.equal(input); - controller.hide(); + await ctrl.hide(); expect(document.activeElement).to.equal(document.body); }); it('supports elementToFocusAfterHide option to focus it when hiding', async () => { - const input = await fixture( - html` - - `, - ); + const input = await fixture(html` + + `); - const controller = new GlobalOverlayController({ - elementToFocusAfterHide: input, - contentTemplate: () => - html` + const ctrl = overlays.add( + new GlobalOverlayController({ + elementToFocusAfterHide: input, + contentTemplate: () => html`
`, - }); + }), + ); - controller.show(); + await ctrl.show(); const textarea = getTopOverlay().querySelector('textarea'); textarea.focus(); expect(document.activeElement).to.equal(textarea); - controller.hide(); + await ctrl.hide(); expect(document.activeElement).to.equal(input); }); it('allows to set elementToFocusAfterHide on show', async () => { - const input = await fixture( - html` - - `, - ); + const input = await fixture(html` + + `); - const controller = new GlobalOverlayController({ - contentTemplate: () => - html` + const ctrl = overlays.add( + new GlobalOverlayController({ + contentTemplate: () => html`
`, - }); + }), + ); - controller.show(input); + await ctrl.show(input); const textarea = getTopOverlay().querySelector('textarea'); textarea.focus(); expect(document.activeElement).to.equal(textarea); - controller.hide(); + await ctrl.hide(); expect(document.activeElement).to.equal(input); }); it('allows to set elementToFocusAfterHide on sync', async () => { - const input = await fixture( - html` - - `, - ); + const input = await fixture(html` + + `); - const controller = new GlobalOverlayController({ - contentTemplate: () => - html` + const ctrl = overlays.add( + new GlobalOverlayController({ + contentTemplate: () => html`
`, - }); + }), + ); - controller.sync({ isShown: true, elementToFocusAfterHide: input }); + await ctrl.sync({ isShown: true, elementToFocusAfterHide: input }); const textarea = getTopOverlay().querySelector('textarea'); textarea.focus(); expect(document.activeElement).to.equal(textarea); - controller.hide(); + await ctrl.hide(); expect(document.activeElement).to.equal(input); - controller.sync({ isShown: true, elementToFocusAfterHide: input }); + await ctrl.sync({ isShown: true, elementToFocusAfterHide: input }); const textarea2 = getTopOverlay().querySelector('textarea'); textarea2.focus(); expect(document.activeElement).to.equal(textarea2); - controller.sync({ isShown: false }); + await ctrl.sync({ isShown: false }); expect(document.activeElement).to.equal(input); }); }); - describe('hasBackdrop', () => { - it('has no backdrop by default', () => { - const controllerWithoutBackdrop = new GlobalOverlayController({ - contentTemplate: () => - html` -

Content

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

Content

- `, - }); - controllerWithoutBackdrop.show(); - expect(getRenderedContainer(0).classList.contains('global-overlays__backdrop')).to.be.false; - controllerWithoutBackdrop.hide(); - - const controllerWithBackdrop = new GlobalOverlayController({ - hasBackdrop: true, - contentTemplate: () => - html` -

Content

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

Content0

- `, - }); - controller0.show(); - expect(getRenderedContainer(0).classList.contains('global-overlays__backdrop')).to.be.true; - - const controller1 = new GlobalOverlayController({ - hasBackdrop: false, - contentTemplate: () => - html` -

Content1

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

Content2

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

Content0

- `, - }); - controller0.show(); - - const controller1 = new GlobalOverlayController({ - hasBackdrop: false, - contentTemplate: () => - html` -

Content1

- `, - }); - controller1.show(); - - const controller2 = new GlobalOverlayController({ - hasBackdrop: true, - contentTemplate: () => - html` -

Content2

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

Content0

- `, - }); - controller0.show(); - - const controller1 = new GlobalOverlayController({ - isBlocking: false, - contentTemplate: () => - html` -

Content1

- `, - }); - controller1.show(); - - const controller2 = new GlobalOverlayController({ - isBlocking: true, - contentTemplate: () => - html` -

Content2

- `, - }); - controller2.show(); - - const controller3 = new GlobalOverlayController({ - isBlocking: false, - contentTemplate: () => - html` -

Content3

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

Content

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

Content

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

Content

- `, - }); - 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` -
- `, - }); - controller.show(); - - const elOutside = await fixture( - html` - - `, - ); - 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` -
- `, - }); - controller.show(); - - const elOutside = await fixture( - html` - - `, - ); - 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` -
- Link0 -
- `, - }); - - const controller1 = new GlobalOverlayController({ - trapsKeyboardFocus: true, - contentTemplate: () => - html` -
- Link1 -
- `, - }); - - 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', () => { it('prevent scrolling the background', async () => { - const controller = new GlobalOverlayController({ - preventsScroll: true, - contentTemplate: () => - html` + const ctrl = overlays.add( + new GlobalOverlayController({ + preventsScroll: true, + contentTemplate: () => html`

Content

`, - }); + }), + ); - controller.show(); - controller.updateComplete; + await ctrl.show(); + ctrl.updateComplete; expect(getComputedStyle(document.body).overflow).to.equal('hidden'); - controller.hide(); - controller.updateComplete; + await ctrl.hide(); + ctrl.updateComplete; expect(getComputedStyle(document.body).overflow).to.equal('visible'); }); }); - describe('hidesOnEsc', () => { - it('hides when Escape is pressed', async () => { - const controller = new GlobalOverlayController({ - hidesOnEsc: true, - contentTemplate: () => - html` + describe('hasBackdrop', () => { + it('has no backdrop by default', async () => { + const ctrl = overlays.add( + new GlobalOverlayController({ + contentTemplate: () => html`

Content

`, - }); + }), + ); + await ctrl.show(); + expect(ctrl.backdropNode).to.be.undefined; + }); - controller.show(); - expect(getRenderedContainers().length).to.equal(1); + it('supports a backdrop option', async () => { + const ctrl = overlays.add( + new GlobalOverlayController({ + hasBackdrop: false, + contentTemplate: () => html` +

Content

+ `, + }), + ); + await ctrl.show(); + expect(ctrl.backdropNode).to.be.undefined; + await ctrl.hide(); - keyUpOn(getRenderedContainer(0), keyCodes.escape); - expect(getRenderedContainers().length).to.equal(0); + const controllerWithBackdrop = overlays.add( + new GlobalOverlayController({ + hasBackdrop: true, + contentTemplate: () => html` +

Content

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

Content

+ `, + }), + ); + 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'); }); }); }); diff --git a/packages/overlays/test/LocalOverlayController.test.js b/packages/overlays/test/LocalOverlayController.test.js index cc7416c81..6a31a9d52 100644 --- a/packages/overlays/test/LocalOverlayController.test.js +++ b/packages/overlays/test/LocalOverlayController.test.js @@ -1,195 +1,124 @@ import { expect, fixture, html, aTimeout, defineCE, unsafeStatic } from '@open-wc/testing'; -import sinon from 'sinon'; import Popper from 'popper.js/dist/esm/popper.min.js'; -import { keyUpOn } from '@polymer/iron-test-helpers/mock-interactions.js'; -import { LionLitElement } from '@lion/core/src/LionLitElement.js'; import { keyCodes } from '../src/utils/key-codes.js'; import { simulateTab } from '../src/utils/simulate-tab.js'; import { LocalOverlayController } from '../src/LocalOverlayController.js'; -import { overlays } from '../src/overlays.js'; +import { runBaseOverlaySuite } from '../test-suites/BaseOverlayController.suite.js'; + +/** + * @desc Compensates for browsers that use floats in output + * - from: 'transform3d(12.25px, 6.75px, 0px)' + * - to: 'transform3d(12px, 7px, 0px)' + * @param {string} cssValue + */ +export function normalizeTransformStyle(cssValue) { + // eslint-disable-next-line no-unused-vars + const [_, transformType, positionPart] = cssValue.match(/(.*)\((.*?)\)/); + const normalizedNumbers = positionPart + .split(',') + .map(p => Math.round(Number(p.replace('px', '')))); + return `${transformType}(${normalizedNumbers + .map((n, i) => `${n}px${normalizedNumbers.length - 1 === i ? '' : ', '}`) + .join('')})`; +} describe('LocalOverlayController', () => { + describe('extends BaseOverlayController', () => { + runBaseOverlaySuite((...args) => new LocalOverlayController(...args)); + }); + describe('templates', () => { - it('creates a controller with methods: show, hide, sync and syncInvoker', () => { - const controller = new LocalOverlayController({ - contentTemplate: () => - html` -

Content

- `, - invokerTemplate: () => - html` - - `, + it('creates a controller with methods: show, hide, sync and syncInvoker', async () => { + const invokerNode = await fixture(html` +
Invoker
+ `); + const ctrl = new LocalOverlayController({ + contentTemplate: () => html` +
Content
+ `, + invokerNode, }); - expect(controller.show).to.be.a('function'); - expect(controller.hide).to.be.a('function'); - expect(controller.sync).to.be.a('function'); - expect(controller.syncInvoker).to.be.a('function'); + expect(ctrl.show).to.be.a('function'); + expect(ctrl.hide).to.be.a('function'); + expect(ctrl.sync).to.be.a('function'); + expect(ctrl.syncInvoker).to.be.a('function'); }); - it('will render holders for invoker and content', async () => { - const controller = new LocalOverlayController({ - contentTemplate: () => - html` -

Content

- `, - invokerTemplate: () => - html` - - `, + it('renders holders for invoker and content', async () => { + const invokerNode = await fixture(html` +
Invoker
+ `); + const ctrl = new LocalOverlayController({ + contentTemplate: () => html` +
Content
+ `, + invokerNode, }); const el = await fixture(html`
- ${controller.invoker} ${controller.content} -
- `); - expect(el.querySelectorAll('div')[0].textContent.trim()).to.equal('Invoker'); - controller.show(); - expect(el.querySelectorAll('div')[1].textContent.trim()).to.equal('Content'); - }); - - it('will add/remove the content on show/hide', async () => { - const controller = new LocalOverlayController({ - contentTemplate: () => - html` -

Content

- `, - invokerTemplate: () => - html` - - `, - }); - const el = await fixture(html` -
- ${controller.invoker} ${controller.content} + ${ctrl.invoker} ${ctrl.content}
`); - expect(el.querySelectorAll('div')[1].textContent.trim()).to.equal(''); - - controller.show(); - expect(el.querySelectorAll('div')[1].textContent.trim()).to.equal('Content'); - - controller.hide(); - expect(el.querySelectorAll('div')[1].textContent.trim()).to.equal(''); - }); - - it('will hide and show html nodes provided to overlay', async () => { - const tagString = defineCE( - class extends LionLitElement { - render() { - return html` - - `; - } - }, - ); - - const element = unsafeStatic(tagString); - const elem = await fixture(html` - <${element}> -
content
-
- <${element}> - `); - - const controller = overlays.add( - new LocalOverlayController({ - hidesOnEsc: true, - hidesOnOutsideClick: true, - contentNode: elem.querySelector('[slot="content"]'), - invokerNode: elem.querySelector('[slot="invoker"]'), - }), - ); - - expect(elem.querySelector('[slot="content"]').style.display).to.equal('none'); - controller.show(); - expect(elem.querySelector('[slot="content"]').style.display).to.equal('inline-block'); - controller.hide(); - expect(elem.querySelector('[slot="content"]').style.display).to.equal('none'); + expect(el.querySelector('#invoke').textContent.trim()).to.equal('Invoker'); + await ctrl.show(); + expect(el.querySelector('#content').textContent.trim()).to.equal('Content'); }); it('exposes isShown state for reading', async () => { - const controller = new LocalOverlayController({ - contentTemplate: () => - html` -

Content

- `, - invokerTemplate: () => - html` - - `, + const invokerNode = await fixture('
Invoker
'); + const ctrl = new LocalOverlayController({ + contentTemplate: () => html` +
Content
+ `, + invokerNode, }); await fixture(html`
- ${controller.invoker} ${controller.content} + ${ctrl.invoker} ${ctrl.content}
`); - expect(controller.isShown).to.equal(false); - controller.show(); - expect(controller.isShown).to.equal(true); - controller.hide(); - expect(controller.isShown).to.equal(false); + expect(ctrl.isShown).to.equal(false); + await ctrl.show(); + expect(ctrl.isShown).to.equal(true); + await ctrl.hide(); + expect(ctrl.isShown).to.equal(false); }); - it('can update the invoker data', async () => { - const controller = new LocalOverlayController({ - contentTemplate: () => - html` -

Content

- `, - invokerTemplate: (data = { text: 'foo' }) => - html` - - `, + // deprecated + it('@deprecated can use a .invokerTemplate and .syncInvoker', async () => { + const ctrl = new LocalOverlayController({ + contentTemplate: () => html` +
Content
+ `, + invokerTemplate: (data = { text: 'foo' }) => html` +
${data.text}
+ `, }); - expect(controller.invoker.textContent.trim()).to.equal('foo'); - controller.syncInvoker({ data: { text: 'bar' } }); - expect(controller.invoker.textContent.trim()).to.equal('bar'); + expect(ctrl.invoker.textContent.trim()).to.equal('foo'); + ctrl.syncInvoker({ data: { text: 'bar' } }); + expect(ctrl.invoker.textContent.trim()).to.equal('bar'); }); it('can synchronize the content data', async () => { - const controller = new LocalOverlayController({ - contentTemplate: data => - html` -

${data.text}

- `, - invokerTemplate: () => - html` - - `, + const invokerNode = await fixture('
Invoker
'); + const ctrl = new LocalOverlayController({ + contentTemplate: ({ text = 'fallback' } = {}) => html` +
${text}
+ `, + invokerNode, }); - await controller.show(); - controller.sync({ data: { text: 'foo' } }); - expect(controller.content.textContent.trim()).to.equal('foo'); + await ctrl.show(); + await ctrl.sync({ data: { text: 'foo' } }); + expect(ctrl.content.textContent.trim()).to.equal('foo'); - controller.sync({ data: { text: 'bar' } }); - expect(controller.content.textContent.trim()).to.equal('bar'); - }); - - it.skip('can reuse an existing node for the invoker (disables syncInvoker())', async () => { - const controller = new LocalOverlayController({ - contentTemplate: () => - html` -

Content

- `, - invokerReference: null, // TODO: invokerReference - }); - await fixture(` -
- - ${controller.content} -
- `); - expect(controller.invoker.textContent.trim()).to.equal('Invoker'); - controller.show(); - expect(controller.content.textContent.trim()).to.equal('Content'); + await ctrl.sync({ data: { text: 'bar' } }); + expect(ctrl.content.textContent.trim()).to.equal('bar'); }); }); @@ -197,69 +126,87 @@ describe('LocalOverlayController', () => { // the test runner from interfering. describe('positioning', () => { it('creates a popper instance on the controller when shown, keeps it when hidden', async () => { - const controller = new LocalOverlayController({ + const invokerNode = await fixture( + html` +
+ `, + ); + const ctrl = new LocalOverlayController({ contentTemplate: () => html` -

- `, - invokerTemplate: () => html` - +
`, + invokerNode, }); - await controller.show(); - expect(controller._popper) + await ctrl.show(); + expect(ctrl._popper) .to.be.an.instanceof(Popper) .and.have.property('modifiers'); - controller.hide(); - expect(controller._popper) + await ctrl.hide(); + expect(ctrl._popper) .to.be.an.instanceof(Popper) .and.have.property('modifiers'); }); it('positions correctly', async () => { // smoke test for integration of popper - const controller = new LocalOverlayController({ + const invokerNode = await fixture(html` +
Invoker
+ `); + const ctrl = new LocalOverlayController({ contentTemplate: () => html` -

- `, - invokerTemplate: () => html` - +
my content
`, + invokerNode, }); + await fixture(html` + ${invokerNode}${ctrl.content} + `); - await controller.show(); - expect(controller.content.firstElementChild.style.transform).to.equal( - 'translate3d(16px, 16px, 0px)', + await ctrl.show(); + + expect(normalizeTransformStyle(ctrl.contentNode.style.transform)).to.equal( + // TODO: check if 'translate3d(16px, 16px, 0px)' would be more appropriate + 'translate3d(16px, 28px, 0px)', '16px displacement is expected due to both horizontal and vertical viewport margin', ); }); it('uses top as the default placement', async () => { - const controller = new LocalOverlayController({ + let ctrl; + const invokerNode = await fixture(html` +
ctrl.show()}>
+ `); + ctrl = new LocalOverlayController({ contentTemplate: () => html` -

- `, - invokerTemplate: () => html` - +
`, + invokerNode, }); await fixture(html`
- ${controller.invoker} ${controller.content} + ${ctrl.invoker} ${ctrl.content}
`); - await controller.show(); - const contentChild = controller.content.firstElementChild; + await ctrl.show(); + const contentChild = ctrl.content.firstElementChild; expect(contentChild.getAttribute('x-placement')).to.equal('top'); }); it('positions to preferred place if placement is set and space is available', async () => { - const controller = new LocalOverlayController({ + let controller; + const invokerNode = await fixture(html` +
controller.show()} + >
+ `); + + controller = new LocalOverlayController({ contentTemplate: () => html` -

- `, - invokerTemplate: () => html` - +
`, + invokerNode, popperConfig: { placement: 'left-start', }, @@ -276,36 +223,44 @@ describe('LocalOverlayController', () => { }); it('positions to different place if placement is set and no space is available', async () => { - const controller = new LocalOverlayController({ + let ctrl; + const invokerNode = await fixture(html` +
ctrl.show()}>
+ `); + ctrl = new LocalOverlayController({ contentTemplate: () => html` -

- `, - invokerTemplate: () => html` - +
`, + invokerNode, popperConfig: { placement: 'top-start', }, }); await fixture(`
- ${controller.invoker} ${controller.content} + ${ctrl.invoker} ${ctrl.content}
`); - await controller.show(); - const contentChild = controller.content.firstElementChild; + await ctrl.show(); + const contentChild = ctrl.content.firstElementChild; expect(contentChild.getAttribute('x-placement')).to.equal('bottom-start'); }); it('allows the user to override default Popper modifiers', async () => { - const controller = new LocalOverlayController({ + let controller; + const invokerNode = await fixture(html` +
controller.show()} + >
+ `); + controller = new LocalOverlayController({ contentTemplate: () => html` -

- `, - invokerTemplate: () => html` - +
`, + invokerNode, popperConfig: { modifiers: { keepTogether: { @@ -332,44 +287,20 @@ describe('LocalOverlayController', () => { expect(offset.offset).to.equal('0, 16px'); }); - it('updates popperConfig even when overlay is closed', async () => { - const controller = new LocalOverlayController({ - contentTemplate: () => html` -

- `, - invokerTemplate: () => html` - - `, - popperConfig: { - placement: 'top', - }, - }); - await fixture(html` -
- ${controller.invoker} ${controller.content} -
- `); - await controller.show(); - const contentChild = controller.content.firstElementChild; - expect(contentChild.getAttribute('x-placement')).to.equal('top'); - - controller.hide(); - await controller.updatePopperConfig({ placement: 'bottom' }); - await controller.show(); - expect(controller._popper.options.placement).to.equal('bottom'); - }); - it('positions the popper element correctly on show', async () => { - const controller = new LocalOverlayController({ + let controller; + const invokerNode = await fixture(html` +
controller.show()} + >
+ `); + controller = new LocalOverlayController({ contentTemplate: () => html` -

- `, - invokerTemplate: () => html` - +
`, + invokerNode, popperConfig: { placement: 'top', }, @@ -381,29 +312,37 @@ describe('LocalOverlayController', () => { `); await controller.show(); + let contentChild = controller.content.firstElementChild; - expect(contentChild.style.transform).to.equal( - 'translate3d(10px, -60px, 0px)', + expect(normalizeTransformStyle(contentChild.style.transform)).to.equal( + 'translate3d(10px, -28px, 0px)', 'Popper positioning values', ); - controller.hide(); + await controller.hide(); await controller.show(); contentChild = controller.content.firstElementChild; - expect(contentChild.style.transform).to.equal( - 'translate3d(10px, -60px, 0px)', + expect(normalizeTransformStyle(contentChild.style.transform)).to.equal( + 'translate3d(10px, -28px, 0px)', 'Popper positioning values should be identical after hiding and showing', ); }); + // TODO: dom get's removed when hidden so no dom node to update placement it('updates placement properly even during hidden state', async () => { - const controller = new LocalOverlayController({ + let controller; + const invokerNode = await fixture(html` +
controller.show()} + >
+ `); + controller = new LocalOverlayController({ contentTemplate: () => html` -

- `, - invokerTemplate: () => html` - +
`, + invokerNode, popperConfig: { placement: 'top', modifiers: { @@ -422,12 +361,12 @@ describe('LocalOverlayController', () => { await controller.show(); let contentChild = controller.content.firstElementChild; - expect(contentChild.style.transform).to.equal( - 'translate3d(10px, -62px, 0px)', + expect(normalizeTransformStyle(contentChild.style.transform)).to.equal( + 'translate3d(10px, -30px, 0px)', 'Popper positioning values', ); - controller.hide(); + await controller.hide(); await controller.updatePopperConfig({ modifiers: { offset: { @@ -439,22 +378,24 @@ describe('LocalOverlayController', () => { await controller.show(); contentChild = controller.content.firstElementChild; expect(controller._popper.options.modifiers.offset.offset).to.equal('0, 20px'); - expect(contentChild.style.transform).to.equal( - 'translate3d(10px, -72px, 0px)', + expect(normalizeTransformStyle(contentChild.style.transform)).to.equal( + 'translate3d(10px, -40px, 0px)', 'Popper positioning Y value should be 10 less than previous, due to the added extra 10px offset', ); }); it('updates positioning correctly during shown state when config gets updated', async () => { - const controller = new LocalOverlayController({ + let controller; + const invokerNode = await fixture(html` +
controller.show()}> + Invoker +
+ `); + controller = new LocalOverlayController({ contentTemplate: () => html` -

- `, - invokerTemplate: () => html` - +
`, + invokerNode, popperConfig: { placement: 'top', modifiers: { @@ -473,8 +414,8 @@ describe('LocalOverlayController', () => { await controller.show(); const contentChild = controller.content.firstElementChild; - expect(contentChild.style.transform).to.equal( - 'translate3d(10px, -62px, 0px)', + expect(normalizeTransformStyle(contentChild.style.transform)).to.equal( + 'translate3d(10px, -30px, 0px)', 'Popper positioning values', ); @@ -486,79 +427,99 @@ describe('LocalOverlayController', () => { }, }, }); - expect(contentChild.style.transform).to.equal( - 'translate3d(10px, -72px, 0px)', + expect(normalizeTransformStyle(contentChild.style.transform)).to.equal( + 'translate3d(10px, -40px, 0px)', 'Popper positioning Y value should be 10 less than previous, due to the added extra 10px offset', ); }); - it('can set the contentNode minWidth as the invokerNode width', () => { - const controller = new LocalOverlayController({ + it('can set the contentNode minWidth as the invokerNode width', async () => { + const invokerNode = await fixture( + '
invoker
', + ); + const ctrl = new LocalOverlayController({ inheritsReferenceObjectWidth: 'min', + contentTemplate: () => + html` +
content
+ `, + invokerNode, }); - expect(controller.contentNode.style.minWidth).to.equal(controller.invokerNode.style.width); + await ctrl.show(); + expect(ctrl.contentNode.style.minWidth).to.equal('60px'); }); - it('can set the contentNode maxWidth as the invokerNode width', () => { - const controller = new LocalOverlayController({ + it('can set the contentNode maxWidth as the invokerNode width', async () => { + const invokerNode = await fixture( + '
invoker
', + ); + const ctrl = new LocalOverlayController({ inheritsReferenceObjectWidth: 'max', + contentTemplate: () => + html` +
content
+ `, + invokerNode, }); - expect(controller.contentNode.style.maxWidth).to.equal(controller.invokerNode.style.width); + await ctrl.show(); + expect(ctrl.contentNode.style.maxWidth).to.equal('60px'); }); - it('can set the contentNode width as the invokerNode width', () => { - const controller = new LocalOverlayController({ + it('can set the contentNode width as the invokerNode width', async () => { + const invokerNode = await fixture( + '
invoker
', + ); + const ctrl = new LocalOverlayController({ inheritsReferenceObjectWidth: 'full', + contentTemplate: () => + html` +
content
+ `, + invokerNode, }); - expect(controller.contentNode.style.width).to.equal(controller.invokerNode.style.width); + await ctrl.show(); + expect(ctrl.contentNode.style.width).to.equal('60px'); }); }); describe('a11y', () => { - it('adds and removes aria-expanded on invoker', async () => { - const controller = new LocalOverlayController({ + it('adds and removes [aria-expanded] on invoker', async () => { + const invokerNode = await fixture('
invoker
'); + const ctrl = new LocalOverlayController({ contentTemplate: () => html` -

Content

- `, - invokerTemplate: () => - html` - +
Content
`, + invokerNode, }); - - expect(controller.invokerNode.getAttribute('aria-controls')).to.contain( - controller.content.id, - ); - expect(controller.invokerNode.getAttribute('aria-expanded')).to.equal('false'); - controller.show(); - expect(controller.invokerNode.getAttribute('aria-expanded')).to.equal('true'); - controller.hide(); - expect(controller.invokerNode.getAttribute('aria-expanded')).to.equal('false'); + expect(ctrl.invokerNode.getAttribute('aria-controls')).to.contain(ctrl.content.id); + expect(ctrl.invokerNode).to.have.attribute('aria-expanded', 'false'); + await ctrl.show(); + expect(ctrl.invokerNode).to.have.attribute('aria-expanded', 'true'); + await ctrl.hide(); + expect(ctrl.invokerNode).to.have.attribute('aria-expanded', 'false'); }); it('traps the focus via option { trapsKeyboardFocus: true }', async () => { - const controller = new LocalOverlayController({ + const invokerNode = await fixture(''); + const ctrl = new LocalOverlayController({ contentTemplate: () => html`
Anchor
`, - invokerTemplate: () => - html` - - `, + invokerNode, trapsKeyboardFocus: true, }); // make sure we're connected to the dom await fixture(html` - ${controller.invoker}${controller.content} + ${invokerNode}${ctrl.content} `); - controller.show(); + await ctrl.show(); - const elOutside = await fixture(``); - const [el1, el2] = [].slice.call(controller.contentNode.querySelectorAll('[id]')); + const elOutside = await fixture(`
click me
`); + const [el1, el2] = [].slice.call(ctrl.contentNode.querySelectorAll('[id]')); el2.focus(); // this mimics a tab within the contain-focus system used const event = new CustomEvent('keydown', { detail: 0, bubbles: true }); @@ -571,26 +532,26 @@ describe('LocalOverlayController', () => { it('traps the focus via option { trapsKeyboardFocus: true } when using contentNode', async () => { const invokerNode = await fixture(''); - const contentNode = await fixture(` + const contentNode = await fixture(html`
Anchor
`); - const controller = new LocalOverlayController({ + const ctrl = new LocalOverlayController({ contentNode, invokerNode, trapsKeyboardFocus: true, }); // make sure we're connected to the dom await fixture(html` - ${controller.invoker}${controller.content} + ${ctrl.invoker}${ctrl.content} `); - controller.show(); + await ctrl.show(); - const elOutside = await fixture(``); - const [el1, el2] = [].slice.call(controller.contentNode.querySelectorAll('[id]')); + const elOutside = await fixture(`
click me
`); + const [el1, el2] = [].slice.call(ctrl.contentNode.querySelectorAll('[id]')); el2.focus(); // this mimics a tab within the contain-focus system used @@ -603,24 +564,23 @@ describe('LocalOverlayController', () => { }); it('allows to move the focus outside of the overlay if trapsKeyboardFocus is disabled', async () => { - const controller = new LocalOverlayController({ + const invokerNode = await fixture(''); + const ctrl = new LocalOverlayController({ contentTemplate: () => html`
`, - invokerTemplate: () => html` - - `, + invokerNode, trapsKeyboardFocus: false, }); // make sure we're connected to the dom await fixture(html` - ${controller.invoker}${controller.content} + ${ctrl.invoker}${ctrl.content} `); const elOutside = await fixture(``); - controller.show(); - const el1 = controller.content.querySelector('button'); + await ctrl.show(); + const el1 = ctrl.content.querySelector('button'); el1.focus(); simulateTab(); @@ -628,119 +588,54 @@ describe('LocalOverlayController', () => { }); }); - describe('hidesOnEsc', () => { - it('hides when [escape] is pressed', async () => { - const ctrl = new LocalOverlayController({ - hidesOnEsc: true, - contentTemplate: () => - html` -

Content

- `, - invokerTemplate: () => - html` - - `, - }); - await fixture( - html` - ${ctrl.invoker}${ctrl.content} - `, - ); - ctrl.show(); - - keyUpOn(ctrl.contentNode, keyCodes.escape); - ctrl.updateComplete; - expect(ctrl.isShown).to.equal(false); - }); - - it('stays shown when [escape] is pressed on outside element', async () => { - const ctrl = new LocalOverlayController({ - hidesOnEsc: true, - contentTemplate: () => - html` -

Content

- `, - invokerTemplate: () => - html` - - `, - }); - await fixture( - html` - ${ctrl.invoker}${ctrl.content} - `, - ); - ctrl.show(); - - keyUpOn(document, keyCodes.escape); - ctrl.updateComplete; - expect(ctrl.isShown).to.equal(true); - }); - }); - describe('hidesOnOutsideClick', () => { it('hides on outside click', async () => { - const controller = new LocalOverlayController({ + const invokerNode = await fixture('
Invoker
'); + const ctrl = new LocalOverlayController({ hidesOnOutsideClick: true, - contentTemplate: () => - html` -

Content

- `, - invokerTemplate: () => - html` - - `, - }); - await fixture( - html` - ${controller.invoker}${controller.content} + contentTemplate: () => html` +
Content
`, - ); - const { content } = controller; - controller.show(); - expect(content.textContent.trim()).to.equal('Content'); + invokerNode, + }); + await fixture(html` + ${invokerNode}${ctrl.content} + `); + await ctrl.show(); document.body.click(); await aTimeout(); - expect(content.textContent.trim()).to.equal(''); + expect(ctrl.isShown).to.be.false; }); it('doesn\'t hide on "inside" click', async () => { + const invokerNode = await fixture(html` + + `); const ctrl = new LocalOverlayController({ hidesOnOutsideClick: true, - contentTemplate: () => - html` -

Content

- `, - invokerTemplate: () => - html` - - `, + contentTemplate: () => html` +
Content
+ `, + invokerNode, }); - const { content, invoker } = ctrl; await fixture(html` - ${invoker}${content} + ${invokerNode}${ctrl.content} `); + await ctrl.show(); - // Don't hide on first invoker click + // Don't hide on invoker click ctrl.invokerNode.click(); await aTimeout(); - expect(ctrl.isShown).to.equal(true); + expect(ctrl.isShown).to.be.true; // Don't hide on inside (content) click ctrl.contentNode.click(); await aTimeout(); - expect(ctrl.isShown).to.equal(true); - - // Don't hide on invoker click when shown - ctrl.invokerNode.click(); - await aTimeout(); - expect(ctrl.isShown).to.equal(true); + expect(ctrl.isShown).to.be.true; // Works as well when clicked content element lives in shadow dom - ctrl.show(); - await aTimeout(); - const tag = defineCE( + const tagString = defineCE( class extends HTMLElement { constructor() { super(); @@ -752,35 +647,46 @@ describe('LocalOverlayController', () => { } }, ); - const shadowEl = document.createElement(tag); - content.appendChild(shadowEl); - shadowEl.shadowRoot.querySelector('button').click(); + const tag = unsafeStatic(tagString); + ctrl.contentTemplate = () => + html` +
+
Content
+ <${tag}> +
+ `; + + // Don't hide on inside shadowDom click + ctrl.content + .querySelector(tagString) + .shadowRoot.querySelector('button') + .click(); + await aTimeout(); - expect(ctrl.isShown).to.equal(true); + expect(ctrl.isShown).to.be.true; // Important to check if it can be still shown after, because we do some hacks inside - ctrl.hide(); - expect(ctrl.isShown).to.equal(true); - ctrl.show(); - expect(ctrl.isShown).to.equal(true); + await ctrl.hide(); + expect(ctrl.isShown).to.be.false; + await ctrl.show(); + expect(ctrl.isShown).to.be.true; }); it('works with 3rd party code using "event.stopPropagation()" on bubble phase', async () => { + const invokerNode = await fixture(html` +
Invoker
+ `); const ctrl = new LocalOverlayController({ hidesOnOutsideClick: true, contentTemplate: () => html` -

Content

- `, - invokerTemplate: () => - html` - +
Content
`, + invokerNode, }); - const { content, invoker } = ctrl; const dom = await fixture(`
- +
{ + const invokerNode = await fixture(html` +
Invoker
+ `); const ctrl = new LocalOverlayController({ hidesOnOutsideClick: true, contentTemplate: () => html` -

Content

- `, - invokerTemplate: () => - html` - +
Content
`, + invokerNode, }); - const { content, invoker } = ctrl; const dom = await fixture(`
- +
ctrl.toggle()}">Invoker + `); + ctrl = new LocalOverlayController({ hidesOnOutsideClick: true, contentTemplate: () => html` -

Content

- `, - invokerTemplate: () => - html` - +
Content
`, + invokerNode, }); - const { content, invoker, invokerNode } = ctrl; + const { content, invoker, invokerNode: iNode } = ctrl; await fixture( html` ${invoker}${content} @@ -875,42 +781,19 @@ describe('LocalOverlayController', () => { ); // Show content on first invoker click - invokerNode.click(); + iNode.click(); await aTimeout(); expect(ctrl.isShown).to.equal(true); // Hide content on click when shown - invokerNode.click(); + iNode.click(); await aTimeout(); expect(ctrl.isShown).to.equal(false); - // Show contnet on invoker click when hidden - invokerNode.click(); + // Show content on invoker click when hidden + iNode.click(); await aTimeout(); expect(ctrl.isShown).to.equal(true); }); }); - - describe('events', () => { - it('fires "show" event once overlay becomes shown', async () => { - const showSpy = sinon.spy(); - const ctrl = new LocalOverlayController(); - 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 = new LocalOverlayController(); - ctrl.addEventListener('hide', hideSpy); - ctrl.hide(); - expect(hideSpy.callCount).to.equal(0); - await ctrl.show(); - ctrl.hide(); - expect(hideSpy.callCount).to.equal(1); - }); - }); }); diff --git a/packages/overlays/test/ManagedGlobalOverlayController.test.js b/packages/overlays/test/ManagedGlobalOverlayController.test.js new file mode 100644 index 000000000..86462d8c2 --- /dev/null +++ b/packages/overlays/test/ManagedGlobalOverlayController.test.js @@ -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` +

Content0

+ `, + }), + ); + await ctrl0.show(); + expect(ctrl0.backdropNode).to.have.class('global-overlays__backdrop'); + + const ctrl1 = overlays.add( + new GlobalOverlayController({ + hasBackdrop: false, + contentTemplate: () => html` +

Content1

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

Content2

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

Content0

+ `, + }), + ); + await ctrl0.show(); + + const ctrl1 = overlays.add( + new GlobalOverlayController({ + isBlocking: false, + contentTemplate: () => html` +

Content1

+ `, + }), + ); + await ctrl1.show(); + + const ctrl2 = overlays.add( + new GlobalOverlayController({ + isBlocking: true, + contentTemplate: () => html` +

Content2

+ `, + }), + ); + await ctrl2.show(); + + const ctrl3 = overlays.add( + new GlobalOverlayController({ + isBlocking: false, + contentTemplate: () => html` +

Content3

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

Content0

+ `, + }), + ); + await ctrl0.show(); + + const ctrl1 = overlays.add( + new GlobalOverlayController({ + isBlocking: false, + hasBackdrop: true, + contentTemplate: () => html` +

Content1

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

Content

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

Content

+ `, + }), + ); + + // 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` +
+ Link0 +
+ `, + }), + ); + + const ctrl1 = overlays.add( + new GlobalOverlayController({ + trapsKeyboardFocus: true, + contentTemplate: () => html` +
+ Link1 +
+ `, + }), + ); + + 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; + }); + }); +}); diff --git a/packages/overlays/test/ModalDialogController.test.js b/packages/overlays/test/ModalDialogController.test.js index ad3574e3c..ebf109b6d 100644 --- a/packages/overlays/test/ModalDialogController.test.js +++ b/packages/overlays/test/ModalDialogController.test.js @@ -1,19 +1,29 @@ -import { expect } from '@open-wc/testing'; +import { expect, html } from '@open-wc/testing'; import { GlobalOverlayController } from '../src/GlobalOverlayController.js'; import { ModalDialogController } from '../src/ModalDialogController.js'; describe('ModalDialogController', () => { + let defaultOptions; + + before(() => { + defaultOptions = { + contentTemplate: () => html` +

my content

+ `, + }; + }); + it('extends GlobalOverlayController', () => { - expect(new ModalDialogController()).to.be.instanceof(GlobalOverlayController); + expect(new ModalDialogController(defaultOptions)).to.be.instanceof(GlobalOverlayController); }); it('has correct defaults', () => { - const controller = new ModalDialogController(); - 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); + const ctrl = new ModalDialogController(defaultOptions); + expect(ctrl.hasBackdrop).to.be.true; + expect(ctrl.isBlocking).to.be.false; + expect(ctrl.preventsScroll).to.be.true; + expect(ctrl.trapsKeyboardFocus).to.be.true; + expect(ctrl.hidesOnEsc).to.be.true; }); }); diff --git a/packages/overlays/test/OverlaysManager.test.js b/packages/overlays/test/OverlaysManager.test.js index 404467b25..db471accf 100644 --- a/packages/overlays/test/OverlaysManager.test.js +++ b/packages/overlays/test/OverlaysManager.test.js @@ -1,21 +1,107 @@ -import { expect } from '@open-wc/testing'; -import sinon from 'sinon'; +import { expect, html } from '@open-wc/testing'; import { OverlaysManager } from '../src/OverlaysManager.js'; - -function createGlobalOverlayControllerMock() { - return { - sync: sinon.spy(), - update: sinon.spy(), - show: sinon.spy(), - hide: sinon.spy(), - }; -} +import { BaseOverlayController } from '../src/BaseOverlayController.js'; describe('OverlaysManager', () => { + let defaultOptions; + let mngr; + + before(() => { + defaultOptions = { + contentTemplate: () => html` +

my content

+ `, + }; + }); + + 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', () => { - const myOverlays = new OverlaysManager(); - const myController = createGlobalOverlayControllerMock(); - expect(myOverlays.add(myController)).to.equal(myController); + const myController = new BaseOverlayController(defaultOptions); + expect(mngr.add(myController)).to.equal(myController); + }); + + it('can add/remove controllers', () => { + const dialog = new BaseOverlayController(defaultOptions); + const popup = new BaseOverlayController(defaultOptions); + mngr.add(dialog); + mngr.add(popup); + + 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([]); }); }); diff --git a/packages/overlays/test/utils-tests/contain-focus.test.js b/packages/overlays/test/utils-tests/contain-focus.test.js index 8ba0b1259..679d03da7 100644 --- a/packages/overlays/test/utils-tests/contain-focus.test.js +++ b/packages/overlays/test/utils-tests/contain-focus.test.js @@ -53,7 +53,7 @@ const lightDomAutofocusTemplate = ` `; 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); const root = document.getElementById('rootElement'); containFocus(root); @@ -63,7 +63,7 @@ describe('containFocus()', () => { 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); const el = document.querySelector('input[autofocus]'); containFocus(el);