feat: use dialog element for top layer functionality of all overlays

This commit is contained in:
Thijs Louisse 2022-12-06 10:15:54 +01:00 committed by Thijs Louisse
parent 00063d7350
commit 64c0e26c20
33 changed files with 1595 additions and 974 deletions

View file

@ -0,0 +1,28 @@
---
'@lion/ui': patch
---
Overlay System uses `<dialog>` for top layer functionality of all overlays.
This means overlays positioned relative to viewport won't be moved to the body.
This has many benefits for the App Developer:
- "context" will be kept:
- css variables and parts/theme will work
- events work without the need for "repropagation" (from body to original context)
- accessibility relations between overlay content and its context do not get lost
- initial renderings become more predictable (since we don't need multiple initializations on multiple connectedCallbacks)
- performance: less initialization, thus better performance
- maintainability: handling all edge cases involved in moving an overlay to the body grew out of hand
- developer experience:
- no extra container components like overlay-frame/calendar-frame needed that maintain styles
- adding a contentWrapperNode is not needed anymore
There could be small differences in timings though (usually we're done rendering quicker now).
Code that relies on side effects could be affected. Like:
- the existence of a global Root node)
- the fact that global styles would reach a dialog placed in the body
For most users using either OverlayController, OverlayMixin or an element that uses OverlayMixin (like LionInputDatepicker, LionRichSelect etc. etc.)
nothing will change in the public api.

View file

@ -76,9 +76,9 @@ export const closeOverlayFromComponent = () => html`
```js preview-story ```js preview-story
export const placementOverrides = () => { export const placementOverrides = () => {
const dialog = placement => html` const dialog = placement => html`
<lion-dialog .config=${{ viewportConfig: { placement } }}> <lion-dialog .config="${{ viewportConfig: { placement } }}">
<button slot="invoker">Dialog ${placement}</button> <button slot="invoker">Dialog ${placement}</button>
<div slot="content" class="dialog"> <div slot="content" class="dialog demo-box">
Hello! You can close this notification here: Hello! You can close this notification here:
<button <button
class="close-button" class="close-button"

View file

@ -1,3 +1,4 @@
/* eslint-disable import/no-extraneous-dependencies */
import { css } from 'lit'; import { css } from 'lit';
const applyDemoOverlayStyles = () => { const applyDemoOverlayStyles = () => {

View file

@ -1,21 +1,19 @@
/* eslint-disable import/no-extraneous-dependencies */
import { css, LitElement } from 'lit'; import { css, LitElement } from 'lit';
/** /**
* @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig * @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig
*/ */
class DemoOverlayBackdrop extends LitElement { class DemoOverlayBackdrop extends LitElement {
static get styles() { static get styles() {
return css` return css`
:host { :host {
position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 1;
background-color: grey; background-color: grey;
opacity: 0.3; opacity: 0.3;
display: none;
} }
:host(.local-overlays__backdrop--visible) { :host(.local-overlays__backdrop--visible) {

View file

@ -0,0 +1,538 @@
/* eslint-disable max-classes-per-file */
/* eslint-disable import/no-extraneous-dependencies */
import { html, LitElement, css } from 'lit';
import { ref, createRef } from 'lit/directives/ref.js';
import { OverlayMixin } from '@lion/ui/overlays.js';
import './demo-overlay-system.mjs';
/**
* @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig
* @typedef {import('@lion/ui/types/overlays.js').OverlayHost} OverlayHost
* @typedef {import('@lion/ui/button.js').LionButton} LionButton
* @typedef {'top-start'|'top'|'top-end'|'right-start'|'right'|'right-end'|'bottom-start'|'bottom'|'bottom-end'|'left-start'|'left'|'left-end'} LocalPlacement
* @typedef {'center'|'top-left'|'top'|'top-right'|'right'|'bottom-right'|'bottom'|'bottom-left'|'left'} ViewportPlacement
* @typedef {{refs: { contentNodeWrapper:{ref: {value:HTMLElement}}; closeButton: {ref: {value:HTMLButtonElement|LionButton}; label:string} }}} TemplateDataForOverlay
*/
class DemoOverlayEl extends OverlayMixin(LitElement) {
/** @type {any} */
static get properties() {
return {
simulateViewport: { type: Boolean, attribute: 'simulate-viewport', reflect: true },
noDialogEl: { type: Boolean, attribute: 'no-dialog-el' },
useAbsolute: { type: Boolean, attribute: 'use-absolute', reflect: true },
};
}
static get styles() {
return [
super.styles || [],
css`
:host([use-absolute]) dialog {
position: absolute !important;
}
:host([simulate-viewport]) {
position: absolute;
inset: 0;
z-index: -1;
}
:host([simulate-viewport]) dialog {
position: absolute !important;
inset: 0;
width: 100%;
height: 100%;
}
:host([simulate-viewport])
#overlay-content-node-wrapper.global-overlays__overlay-container {
position: absolute;
}
/*=== demo invoker and content ===*/
:host ::slotted([slot='invoker']) {
border: 4px dashed;
height: 24px;
min-width: 24px;
}
:host ::slotted([slot='content']) {
background-color: black;
color: white;
height: 54px;
min-width: 54px;
display: flex;
place-items: center;
padding: 20px;
text-align: center;
font-size: 0.8rem;
}
`,
];
}
constructor() {
super();
this.simulateViewport = false;
this._noDialogEl = false;
this.useAbsolute = false;
}
// eslint-disable-next-line class-methods-use-this
_defineOverlayConfig() {
return /** @type {OverlayConfig} */ ({
placementMode: 'local',
noDialogEl: this._noDialogEl,
popperConfig: { strategy: this.useAbsolute ? 'absolute' : 'fixed' },
});
}
_setupOpenCloseListeners() {
super._setupOpenCloseListeners();
if (this._overlayInvokerNode) {
this._overlayInvokerNode.addEventListener('click', this.toggle);
}
}
_teardownOpenCloseListeners() {
super._teardownOpenCloseListeners();
if (this._overlayInvokerNode) {
this._overlayInvokerNode.removeEventListener('click', this.toggle);
}
}
refs = {
invokerSlot: /** @type {{value: HTMLSlotElement}} */ (createRef()),
backdropSlot: /** @type {{value: HTMLSlotElement}} */ (createRef()),
contentSlot: /** @type {{value: HTMLSlotElement}} */ (createRef()),
closeButton: /** @type {{value: HTMLButtonElement|LionButton}} */ (createRef()),
contentNodeWrapper: /** @type {{value: HTMLElement}} */ (createRef()),
};
/**
* @overridable
* @type {TemplateDataForOverlay}
*/
get _templateData() {
return {
refs: {
contentNodeWrapper: {
ref: this.refs.contentNodeWrapper,
},
closeButton: {
ref: this.refs.closeButton,
label: 'close dialog',
},
},
};
}
static templates = {
main: (/** @type {TemplateDataForOverlay} */ { refs }) => html`
<slot name="invoker"></slot>
<slot name="backdrop"></slot>
<div ${ref(refs.contentNodeWrapper.ref)} id="overlay-content-node-wrapper">
<slot name="content"></slot>
</div>
`,
};
render() {
const ctor = /** @type {typeof DemoOverlayEl} */ (this.constructor);
const templates = this.templates || ctor.templates;
return templates.main(this._templateData);
}
}
customElements.define('demo-overlay-el', DemoOverlayEl);
class DemoOverlayPositioning extends LitElement {
static properties = {
placementMode: { attribute: 'placement-mode', type: String },
simulateViewport: { type: Boolean, attribute: 'simulate-viewport', reflect: true },
_activePos: { type: String, reflect: true, attribute: 'active-pos' },
_activeConfig: { type: Object, state: true },
};
static get styles() {
return [
css`
/*=== .pos-container ===*/
.pos-container {
padding: 0.5rem;
overflow: hidden;
place-items: center;
height: 20rem;
display: grid;
position: relative;
}
/*=== .pos-btn-wrapper ===*/
/**
* We need a wrapper for position transforms, so that we can apply scale transforms on .pos-btn hover
*/
.pos-btn-wrapper {
position: absolute;
}
.pos-container--local .pos-btn-wrapper--bottom-start,
.pos-container--local .pos-btn-wrapper--bottom-end,
.pos-container--local .pos-btn-wrapper--top-start,
.pos-container--local .pos-btn-wrapper--top-end,
.pos-container--local .pos-btn-wrapper--top,
.pos-container--local .pos-btn-wrapper--bottom {
left: 50%;
transform: translateX(-50%);
}
.pos-container--local .pos-btn-wrapper--top-start,
.pos-container--local .pos-btn-wrapper--top-end,
.pos-container--local .pos-btn-wrapper--top {
top: 0;
}
.pos-container--local .pos-btn-wrapper--bottom-start,
.pos-container--local .pos-btn-wrapper--bottom-end,
.pos-container--local .pos-btn-wrapper--bottom {
bottom: 0;
}
.pos-container--local .pos-btn-wrapper--left-start,
.pos-container--local .pos-btn-wrapper--left-end,
.pos-container--local .pos-btn-wrapper--right-start,
.pos-container--local .pos-btn-wrapper--right-end,
.pos-container--local .pos-btn-wrapper--left,
.pos-container--local .pos-btn-wrapper--right {
top: 50%;
transform: translateY(-50%);
}
.pos-container--local .pos-btn-wrapper--left-start,
.pos-container--local .pos-btn-wrapper--left-end,
.pos-container--local .pos-btn-wrapper--left {
left: 0;
}
.pos-container--local .pos-btn-wrapper--right-start,
.pos-container--local .pos-btn-wrapper--right-end,
.pos-container--local .pos-btn-wrapper--right {
right: 0;
}
.pos-container--local .pos-btn-wrapper--bottom-start,
.pos-container--local .pos-btn-wrapper--top-start {
transform: translateX(-50%) translateX(-48px);
}
.pos-container--local .pos-btn-wrapper--bottom-end,
.pos-container--local .pos-btn-wrapper--top-end {
transform: translateX(-50%) translateX(48px);
}
.pos-container--local .pos-btn-wrapper--left-start,
.pos-container--local .pos-btn-wrapper--right-start {
transform: translateY(calc(-50% - 48px));
}
.pos-container--local .pos-btn-wrapper--left-end,
.pos-container--local .pos-btn-wrapper--right-end {
transform: translateY(calc(-50% + 48px));
}
.pos-container--global .pos-btn-wrapper {
top: 50%;
left: 50%;
}
.pos-container--global .pos-btn-wrapper--center {
transform: translateY(-50%) translateX(-50%);
}
.pos-container--global .pos-btn-wrapper--top-left {
transform: translateY(-50%) translateX(-50%) translateY(-48px) translateX(-48px);
}
.pos-container--global .pos-btn-wrapper--top {
transform: translateY(-50%) translateX(-50%) translateY(-48px);
}
.pos-container--global .pos-btn-wrapper--top-right {
transform: translateY(-50%) translateX(-50%) translateY(-48px) translateX(48px);
}
.pos-container--global .pos-btn-wrapper--bottom-left {
transform: translateY(-50%) translateX(-50%) translateY(48px) translateX(-48px);
}
.pos-container--global .pos-btn-wrapper--bottom {
transform: translateY(-50%) translateX(-50%) translateY(48px);
}
.pos-container--global .pos-btn-wrapper--bottom-right {
transform: translateY(-50%) translateX(-50%) translateY(48px) translateX(48px);
}
.pos-container--global .pos-btn-wrapper--right {
transform: translateY(-50%) translateX(-50%) translateX(48px);
}
.pos-container--global .pos-btn-wrapper--left {
transform: translateY(-50%) translateX(-50%) translateX(-48px);
}
/*=== .pos-btn ===*/
.pos-btn {
padding: 1rem;
cursor: pointer;
-webkit-appearance: button;
background-color: transparent;
background-image: none;
text-transform: none;
font-family: inherit;
font-size: 100%;
line-height: inherit;
color: inherit;
margin: 0;
box-sizing: border-box;
border: 0 solid #bfc3d9;
}
.pos-btn__inner {
border-style: solid;
border-width: 2px;
border-radius: 100%;
width: 0.25rem;
height: 0.25rem;
}
.pos-btn:hover {
transform: scaleX(2) scaleY(2);
}
.pos-btn--active .pos-btn__inner {
background-color: black;
}
/*=== .reference-btn ===*/
.reference-btn {
background: white;
box-sizing: border-box;
border: 5px dashed black;
cursor: pointer;
padding: 1rem;
width: 8rem;
height: 8rem;
}
.pos-container--global .reference-btn {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: -1;
}
/*=== .close-btn ===*/
.close-btn {
background: transparent;
border: none;
position: absolute;
right: 0;
top: 0;
padding: 0.5rem;
}
.close-btn::after {
content: '';
clear: both;
}
/*=== .overlay-content ===*/
.overlay-content {
display: flex;
box-sizing: border-box;
border: 1px solid black;
align-items: center;
justify-items: center;
background-color: #000;
padding: 1rem;
width: 2rem;
height: 2rem;
}
:host([active-pos^='center']) .pos-container--global .overlay-content {
width: 14rem;
height: 16rem;
}
:host([active-pos^='bottom']) .pos-container--global .overlay-content,
:host([active-pos^='top']) .pos-container--global .overlay-content {
width: 50%;
}
:host([active-pos^='left']) .pos-container--global .overlay-content,
:host([active-pos^='right']) .pos-container--global .overlay-content {
height: 50%;
}
:host([active-pos^='center']) .pos-btn {
color: white;
}
`,
];
}
refs = {
overlay: /** @type {{value: OverlayHost}} */ (createRef()),
};
constructor() {
super();
this.placementMode = 'local';
/** @type {ViewportPlacement[]|LocalPlacement[]} */
this._placements = [];
this.simulateViewport = false;
this._activePos = 'top';
}
/**
*
* @param {{pos:ViewportPlacement|LocalPlacement}} opts
*/
async _updatePos({ pos }) {
this._activePos = pos;
const overlayEl = this.refs.overlay.value;
// @ts-ignore allow protected
if (overlayEl?._overlayCtrl) {
overlayEl.config = /** @type {Partial<OverlayConfig>} */ ({
popperConfig: { placement: pos },
viewportConfig: { placement: pos },
});
// TODO: these hacks below should not be needed. Fix when moving to floating-ui
// => animate different positions
// @ts-ignore allow protected
await overlayEl._overlayCtrl.hide();
// @ts-ignore allow protected
overlayEl._overlayCtrl.show();
overlayEl.config = /** @type {Partial<OverlayConfig>} */ ({
popperConfig: { placement: pos },
viewportConfig: { placement: pos },
});
}
}
/**
* @param {import('lit').PropertyValues } changedProperties
*/
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this._updatePos({ pos: this.placementMode === 'local' ? 'top' : 'center' });
}
/**
* @param {import('lit').PropertyValues } changedProperties
*/
update(changedProperties) {
if (changedProperties.has('placementMode')) {
if (this.placementMode === 'local') {
this._placements = [
`top-start`,
`top`,
`top-end`,
`right-start`,
`right`,
`right-end`,
`bottom-start`,
`bottom`,
`bottom-end`,
`left-start`,
`left`,
`left-end`,
];
} else {
this._placements = [
`center`,
`top-left`,
`top`,
`top-right`,
`right`,
`bottom-right`,
`bottom`,
`bottom-left`,
`left`,
];
}
}
super.update(changedProperties);
}
/**
* @param {import('lit').PropertyValues } changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('placementMode')) {
this._activeConfig = {
placementMode: this.placementMode,
};
}
}
/**
* @param {{pos:string}} opts
* @returns
*/
_isActivePosBtn({ pos }) {
return pos === this._activePos;
}
render() {
return html`
<div class="pos-container pos-container--${this.placementMode}">
${this._placements.map(
pos =>
html`
<div class="pos-btn-wrapper pos-btn-wrapper--${pos}">
<button
@click="${() => this._updatePos({ pos })}"
class="pos-btn ${this._isActivePosBtn({ pos }) ? 'pos-btn--active' : ''}"
aria-label="${pos}"
>
<div class="pos-btn__inner"></div>
</button>
</div>
`,
)}
<demo-overlay-el
opened
?simulate-viewport="${this.simulateViewport}"
${ref(
// @ts-ignore
this.refs.overlay,
)}
.config="${this._activeConfig}"
>
<button class="reference-btn" slot="invoker"></button>
<div class="overlay-content" slot="content"></div>
</demo-overlay-el>
</div>
`;
}
}
customElements.define('demo-overlay-positioning', DemoOverlayPositioning);

View file

@ -1,42 +0,0 @@
import { html, LitElement } from 'lit';
import { OverlayMixin } from '@lion/ui/overlays.js';
/**
* @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig
*/
class DemoOverlaySystem extends OverlayMixin(LitElement) {
// eslint-disable-next-line class-methods-use-this
_defineOverlayConfig() {
return /** @type {OverlayConfig} */ ({
placementMode: 'global',
});
}
_setupOpenCloseListeners() {
super._setupOpenCloseListeners();
if (this._overlayInvokerNode) {
this._overlayInvokerNode.addEventListener('click', this.toggle);
}
}
_teardownOpenCloseListeners() {
super._teardownOpenCloseListeners();
if (this._overlayInvokerNode) {
this._overlayInvokerNode.removeEventListener('click', this.toggle);
}
}
render() {
return html`
<slot name="invoker"></slot>
<slot name="backdrop"></slot>
<div id="overlay-content-node-wrapper">
<slot name="content"></slot>
</div>
<div>popup is ${this.opened ? 'opened' : 'closed'}</div>
`;
}
}
customElements.define('demo-overlay-system', DemoOverlaySystem);

View file

@ -0,0 +1,121 @@
/* eslint-disable max-classes-per-file */
/* eslint-disable import/no-extraneous-dependencies */
import { html, LitElement, css } from 'lit';
import { OverlayMixin } from '@lion/ui/overlays.js';
import { LionButton } from '@lion/ui/button.js';
/**
* @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig
*/
class DemoOverlaySystem extends OverlayMixin(LitElement) {
// eslint-disable-next-line class-methods-use-this
_defineOverlayConfig() {
return /** @type {OverlayConfig} */ ({
placementMode: 'global',
});
}
_setupOpenCloseListeners() {
super._setupOpenCloseListeners();
if (this._overlayInvokerNode) {
this._overlayInvokerNode.addEventListener('click', this.toggle);
}
}
_teardownOpenCloseListeners() {
super._teardownOpenCloseListeners();
if (this._overlayInvokerNode) {
this._overlayInvokerNode.removeEventListener('click', this.toggle);
}
}
render() {
return html`
<slot name="invoker"></slot>
<slot name="backdrop"></slot>
<slot name="content"></slot>
<div>popup is ${this.opened ? 'opened' : 'closed'}</div>
`;
}
}
customElements.define('demo-overlay-system', DemoOverlaySystem);
class DemoOverlay extends OverlayMixin(LitElement) {
static get styles() {
return [
css`
::slotted([slot='content']) {
background-color: #333;
color: white;
padding: 8px;
}
.close-button {
background: none;
border: none;
color: white;
font-weight: bold;
font-size: 16px;
padding: 4px;
}
`,
];
}
// eslint-disable-next-line class-methods-use-this
_defineOverlayConfig() {
return /** @type {OverlayConfig} */ ({
placementMode: 'global',
});
}
_setupOpenCloseListeners() {
super._setupOpenCloseListeners();
if (this._overlayInvokerNode) {
this._overlayInvokerNode.addEventListener('click', this.toggle);
}
}
_teardownOpenCloseListeners() {
super._teardownOpenCloseListeners();
if (this._overlayInvokerNode) {
this._overlayInvokerNode.removeEventListener('click', this.toggle);
}
}
render() {
return html`
<slot name="invoker"></slot>
<slot name="backdrop"></slot>
<div id="overlay-content-node-wrapper">
<slot name="content"></slot>
</div>
`;
}
}
customElements.define('demo-overlay', DemoOverlay);
class DemoCloseButton extends LionButton {
static get styles() {
return [
css`
::host {
background: none;
}
`,
];
}
connectedCallback() {
super.connectedCallback();
this.innerText = '';
this.setAttribute('aria-label', 'Close');
}
}
customElements.define('demo-close-button', DemoCloseButton);

View file

@ -2,8 +2,8 @@
```js script ```js script
import { html } from '@mdjs/mdjs-preview'; import { html } from '@mdjs/mdjs-preview';
import './assets/demo-overlay-system.js'; import './assets/demo-overlay-system.mjs';
import './assets/applyDemoOverlayStyles.js'; import './assets/applyDemoOverlayStyles.mjs';
``` ```
The `OverlayController` has many configuration options. The `OverlayController` has many configuration options.
@ -351,11 +351,11 @@ Options:
export const viewportConfig = () => { export const viewportConfig = () => {
const viewportConfig = { const viewportConfig = {
placementMode: 'global', placementMode: 'global',
viewportConfig: { placement: 'top-left' }, viewportConfig: { placement: 'bottom-left' },
}; };
return html` return html`
<demo-overlay-system .config=${viewportConfig}> <demo-overlay-system .config=${viewportConfig}>
<button slot="invoker">Click me to open the overlay in the top left corner!</button> <button slot="invoker">Click me to open the overlay in the bottom left corner!</button>
<div slot="content" class="demo-overlay"> <div slot="content" class="demo-overlay">
Hello! You can close this notification here: Hello! You can close this notification here:
<button <button

View file

@ -6,8 +6,8 @@ import '@lion/ui/define/lion-input-datepicker.js';
import '@lion/ui/define/lion-listbox.js'; import '@lion/ui/define/lion-listbox.js';
import '@lion/ui/define/lion-listbox.js'; import '@lion/ui/define/lion-listbox.js';
import '@lion/ui/define/lion-select-rich.js'; import '@lion/ui/define/lion-select-rich.js';
import './assets/demo-overlay-system.js'; import './assets/demo-overlay-system.mjs';
import './assets/applyDemoOverlayStyles.js'; import './assets/applyDemoOverlayStyles.mjs';
``` ```
## Select Rich ## Select Rich

View file

@ -0,0 +1,171 @@
# Systems >> Overlays >> Positioning ||10
```js script
import { html, render, LitElement } from '@mdjs/mdjs-preview';
import { ref, createRef } from 'lit/directives/ref.js';
import './assets/demo-overlay-positioning.mjs';
```
Overlays can have two different placement modes: relative to their anchor element and relative to the viewport.
Depending on screen size and viewing device, one placement mode might be suited better than the other.
> Note that, the placementMode option has the values 'local' (anchor) and 'global' (viewport). These refer to their
> legacy position in dom (global overlays were put in the body of the page). Since overlays are built with the native `<dialog>` element,
> no content is moved around anymore, so their names are a bit less intuitive.
## Relative to anchor
An anchor is usually the invoker button, it can also be a non interactive reference element.
Anchor placement uses Popper under the hood. It supports 9 positions:
`top-start`, `top`, `top-end`, `right-start`, `right`, `right-end`, `bottom-start`, `bottom`, `bottom-end`, `left-start`,`left`,`left-end`
```js story
export const localPositioning = () => html`<demo-overlay-positioning></demo-overlay-positioning>`;
```
## Relative to viewport
Viewport placement uses the flexbox layout mode, leveraging the best browser capabilities when
the content or screen size updates.
Supported modes:
`center`, `top-left`, `top`, `top-right`, `right`, `bottom-right`, `bottom`, `bottom-left`, `left`
```js story
export const globalPositioning = () =>
html`<demo-overlay-positioning
placement-mode="global"
simulate-viewport
></demo-overlay-positioning>`;
```
## Notorious edge cases
These edge cases are not so much related to the edges of the viewport or the anchor, but more with the difficulties involved with the dom context of the overlay.
That is:
- a parent that has `overflow: hidden` applied
- a surrounding stacking context that does not allow to paint on top
- a transform applied to a parent
Note that our overlay uses the `<dialog>` element under the hood. It paints to the [top layer](https://github.com/whatwg/html/issues/4633) when it is configured as a modal overlay (that is, it uses `.showModal()` to open the dialog.)
### Overflow
A positioned parent with `overflow: hidden` can cause the overlay to be invisible. This can mainly occur in non-modal overlays being absolutely
positioned relative to their anchor.
See [css-tricks: popping-hidden-overflow](https://css-tricks.com/popping-hidden-overflow/#aa-the-solution)
#### Overflow: the problem
```js preview-story
export const edgeCaseOverflowProblem = () =>
html`
<div style="padding: 54px 24px 36px;">
<div
style="overflow: hidden; border: 1px black dashed; padding-top: 44px; padding-bottom: 16px;"
>
<div style="display: flex; justify-content: space-evenly; position: relative;">
<demo-overlay-el opened use-absolute>
<button slot="invoker" aria-label="local, non modal"></button>
<div slot="content">absolute (for&nbsp;demo)</div>
</demo-overlay-el>
</div>
</div>
</div>
`;
```
#### Overflow: the solution
Two solutions are thinkable:
- use a modal overlay
- use the fixed positioning strategy for the non-modal overlay
Our overlay system makes sure that there's always a fixed layer that pops out of the hidden parent.
```js preview-story
export const edgeCaseOverflowSolution = () =>
html`
<div style="padding: 54px 24px 36px;">
<div
style="overflow: hidden; border: 1px black dashed; padding-top: 36px; padding-bottom: 16px;"
>
<div style="display: flex; justify-content: space-evenly; position: relative;">
<demo-overlay-el
opened
.config="${{ placementMode: 'local', trapsKeyboardFocus: false }}"
>
<button slot="invoker" aria-label="local, non modal"></button>
<div slot="content">no matter</div>
</demo-overlay-el>
<demo-overlay-el opened .config="${{ placementMode: 'local', trapsKeyboardFocus: true }}">
<button slot="invoker" aria-label="local, modal"></button>
<div slot="content">what configuration</div>
</demo-overlay-el>
<demo-overlay-el
opened
.config="${{ placementMode: 'local', popperConfig: { strategy: 'absolute' } }}"
>
<button slot="invoker" aria-label="local, absolute"></button>
<div slot="content">...it</div>
</demo-overlay-el>
<demo-overlay-el
opened
.config="${{ placementMode: 'local', popperConfig: { strategy: 'fixed' } }}"
>
<button slot="invoker" aria-label="local, fixed"></button>
<div slot="content">just</div>
</demo-overlay-el>
<demo-overlay-el opened .config="${{ placementMode: 'global' }}">
<button slot="invoker" aria-label="global"></button>
<div slot="content">works</div>
</demo-overlay-el>
</div>
</div>
</div>
`;
```
### Stacking context
When using non modal overlays, always make sure that the surrounding [stacking context](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context) does not paint on top of your overlay.
The example below shows the difference between a modal and non-modal overlay placed in a stacking context with a lower priority than its parent/sibling contexts.
#### Stacking context: the problem
```js preview-story
export const edgeCaseStackProblem = () =>
html`
<div style="width: 300px; height: 300px; position: relative;">
<div
id="stacking-context-a"
style="position: absolute; z-index: 2; top: 0; width: 100px; height: 200px;"
>
I am on top and I don't care about your 9999
</div>
<div
id="stacking-context-b"
style="position: absolute; z-index: 1; top: 0; width: 200px; height: 200px;"
>
<demo-overlay-el no-dialog-el style="overflow:hidden; position: relative;">
<button slot="invoker">invoke</button>
<div slot="content">
The overlay can never be in front, since the parent stacking context has a lower
priority than its sibling.
<div id="stacking-context-b-inner" style="position: absolute; z-index: 9999;">
So, even if we add a new stacking context in our overlay with z-index 9999, it will
never be painted on top.
</div>
</div>
</demo-overlay-el>
</div>
</div>
`;
```

View file

@ -1,176 +0,0 @@
# Systems >> Overlays >> Scope ||50
The goal of this document is to specify the goal and duties of the overlay system, mainly by
identifying all different appearances and types of overlays.
## What is an overlay manager?
An overlay is a visual element that is painted on top of a page, breaking out of the regular
document flow. An overlay manager is a global repository keeping track of all different types of overlays. The need for a global housekeeping mainly arises when multiple overlays are opened simultaneously. As opposed to a single overlay, the overlay manager stores knowledge about:
- Whether the scroll behaviour of the body element can be manipulated.
- What space is available in the window for drawing new overlays.
The manager is in charge of rendering an overlay to the DOM. Therefore, a developer should be able to control:
- Its physical position (where the dialog is attached). This can either be:
- globally: at root level of the DOM. This guarantees a total control over its painting, since the stacking context can be controlled from here and interfering parents (that set overflow values or transforms) cant be apparent. Additionally, making a modal dialog requiring all surroundings to have aria-hidden="true", will be easier when the overlay is attached on body level.
- locally: next to the invoking element. This gives advantages for accessibility and(performant) rendering of an overlay next to its invoker on scroll and window resizes
- Toggling of the shown state of the overlay
- Positioning preferences(for instance bottom-left) and strategies (ordered fallback preferences)
Presentation/styling of the overlay is out of the scope of the manager, except for its positioning in its context.
Accessibility is usually dependent on the type of overlay, its specific implementation and its browser/screen reader support (aria 1.0 vs 1.1). We strive for an optimum here by supporting 1.0 as a bare minimum and add 1.1 enrichments on top.
For every overlay, the manager has access to the overlay element and the invoker (and possible other elements that are needed for focus delegation as described in <https://www.w3.org/TR/wai-aria-practices/#dialog_modal> (notes).
## Defining different types of overlays
When browsing through the average ui library, one can encounter multiple names for occurrences of overlays. Here is a list of names encountered throughout the years:
- dialog
- modal
- popover
- popup
- popdown
- popper
- bubble
- balloon
- dropdown
- dropup
- tooltip
- layover
- overlay
- toast
- snackbar
- sheet (bottom, top, left, right)
- etc..
The problem with most of those terms is their lack of clear definition: what might be considered a tooltip in UI framework A, can be considered a popover in framework B. What can be called a modal in framework C, might actually be just a dialog. Etc etc…
### Official specifications
In order to avoid confusion and be as specification compliant as possible, its always a good idea to consult the W3C. This website shows a full list with specifications of accessible web widgets: <https://www.w3.org/TR/wai-aria-practices/>.
A great overview of all widget-, structure- and role relations can be found in the ontology diagram below:
<https://www.w3.org/WAI/PF/aria-1.1/rdf_model.svg>
Out of all the overlay names mentioned above, we can only identify the dialog and the tooltip as official roles. Lets take a closer look at their definitions...
### Dialog
The dialog is described as follows by the W3C:
> “A dialog is a window overlaid on either the primary window or another dialog window. Windows
> under a modal dialog are inert. That is, users cannot interact with content outside an active
> dialog window. Inert content outside an active dialog is typically visually obscured or dimmed so
> it is difficult to discern, and in some implementations, attempts to interact with the inert
> content cause the dialog to close.
> Like non-modal dialogs, modal dialogs contain their tab sequence. That is, Tab and Shift + Tab do
> not move focus outside the dialog. However, unlike most non-modal dialogs, modal dialogs do not
> provide means for moving keyboard focus outside the dialog window without closing the dialog.”
- specification: <https://www.w3.org/TR/wai-aria-1.1/#dialog>
- widget description: <https://www.w3.org/TR/wai-aria-practices/#dialog_modal>
### Tooltip
According to W3C, a tooltip is described by the following:
> “A tooltip is a popup that displays information related to an element when the element receives.
> keyboard focus or the mouse hovers over it. It typically appears after a small delay and disappears.
> when Escape is pressed or on mouse out.
> Tooltip widgets do not receive focus. A hover that contains focusable elements can be made using.
> a non-modal dialog.”
- specification: <https://www.w3.org/TR/wai-aria-1.1/#tooltip>
- widget description: <https://www.w3.org/TR/wai-aria-practices/#tooltip>
What needs to be mentioned is that the W3C taskforce didnt reach consensus yet about the above tooltip description. A good alternative resource:
<https://inclusive-components.design/tooltips-toggletips/>
### Dialog vs tooltip
Summarizing, the main differences between dialogs and tooltips are:
- Dialogs have a modal option, tooltips dont
- Dialogs have interactive content, tooltips dont
- Dialogs are opened via regular buttons (click/space/enter), tooltips act on focus/mouseover
### Other roles and concepts
Other roles worth mentioning are _alertdialog_ (a specific instance of the dialog for system alerts), select (an abstract role), _combobox_ and _menu_.
Also, the W3C document often refers to _popup_. This term is mentioned in the context of _combobox_, _listbox_, _grid_, _tree_, _dialog_ and _tooltip_. Therefore, one could say it could be a term
_aria-haspopup_ attribute needs to be mentioned: it can have values menu, listbox, grid, tree and dialog.
## Common Overlay Components
In our component library, we want to have the following overlay child components:
- Dialog
- Tooltip
- Popover
- Dropdown
- Toast
- Sheet (bottom, top, left, right)
- Select
- Combobox/autocomplete
- Application menu
### Dialog Component
The dialog is pretty much the dialog as described in the W3C spec, having the modal option applied by default. The flexibility in focus delegation (see <https://www.w3.org/TR/wai-aria-practices/#dialog_modal> notes) is not implemented, however.
Addressing these:
- The first focusable element in the content: although delegate this focus management to the
implementing developer is highly preferred, since this is highly dependent on the moment the dialog content has finished rendering. This is something the overlay manager or dialog widget should not be aware of in order to provide.a clean and reliable component.
- The focusable element after a close: by default, this is the invoker. For different
behaviour, a reference should be supplied to a more logical element in the particular workflow.
### Tooltip Component
The tooltip is always invoked on hover and has no interactive content. See <https://inclusive-components.design/tooltips-toggletips/> (the tooltip example, not the toggle tip).
### Popover Component
The popover looks like a crossover between the dialog and the tooltip. Popovers are invoked on click. For non interactive content, the <https://inclusive-components.design/tooltips-toggletips/> toggletip could be applied. Whenever there would be a close button present, the non interactiveness wouldnt apply.
An alternative implementation: <https://whatsock.com/tsg/Coding%20Arena/Popups/Popup%20(Internal%20Content)/demo.htm>
This looks more like a small dialog, except that the page flow is respected (no rotating tab).
### Dropdown Component
The dropdown is not an official aria-widget and thus cant be tied to a specific role. It exists in a lot of UI libraries and most of them share these properties:
- Preferred position is down.
- When no space at bottom, they show up (in which. Case it behaves a as a dropup).
- Unlike popovers and tooltips, it will never be positioned horizontally.
Aliases are popdown, pulldown and many others.
### Select Component
Implemented as a dropdown listbox with invoker button. Depending on the content of the options, the child list can either be of type listbox or grid.
See: <https://www.w3.org/TR/wai-aria-practices/examples/listbox/listbox-collapsible.html>
### Combobox Component
Implemented as a dropdown combobox with invoker input. Input is used as search filter and can contain autocomplete or autosuggest functionality:
See: <https://www.w3.org/TR/wai-aria-practices/#combobox>
### (Application) menu Component
Or sometimes called context-menu. Uses a dropdown to position its content.
See: <https://www.w3.org/WAI/tutorials/menus/flyout/>
Be aware not to use role=“menu”: <https://www.w3.org/WAI/tutorials/menus/application-menus/>
### Toast Component
See: <https://www.webcomponents.org/element/@polymer/paper-toast>. Should probably be implemented as an alertdialog.
### Sheet Component
See: <https://material.io/design/components/sheets-bottom.html>. Should probably be a global(modal) dialog.

View file

@ -11,9 +11,9 @@ import {
withModalDialogConfig, withModalDialogConfig,
} from '@lion/ui/overlays.js'; } from '@lion/ui/overlays.js';
import './assets/demo-overlay-system.js'; import './assets/demo-overlay-system.mjs';
import './assets/demo-overlay-backdrop.js'; import './assets/demo-overlay-backdrop.mjs';
import './assets/applyDemoOverlayStyles.js'; import './assets/applyDemoOverlayStyles.mjs';
import { ref, createRef } from 'lit/directives/ref.js'; import { ref, createRef } from 'lit/directives/ref.js';
``` ```
@ -329,52 +329,55 @@ _defineOverlay({ invokerNode, contentNode }) {
Below is another demo where you can toggle between configurations using buttons. Below is another demo where you can toggle between configurations using buttons.
```js preview-story ```js preview-story
export const responsiveSwitching2 = () => html` export const responsiveSwitching2 = () => {
<style> const overlayRef = createRef();
.demo-overlay { const selectRef = createRef();
background-color: white;
border: 1px solid black; const getConfig = selectValue => {
switch (selectValue) {
case 'modaldialog':
return { ...withModalDialogConfig(), hasBackdrop: true };
case 'bottomsheet':
return { ...withBottomSheetConfig() };
case 'dropdown':
return { ...withDropdownConfig(), hasBackdrop: false };
default:
return { ...withModalDialogConfig(), hasBackdrop: true };
} }
</style> };
Change config to: const onSelectChange = e => {
<button overlayRef.value.config = getConfig(e.target.value);
@click=${e => { };
e.target.parentElement.querySelector('#respSwitchOverlay').config = {
...withModalDialogConfig(), return html`
}; <style>
}} .demo-overlay {
> background-color: white;
Modal Dialog border: 1px solid black;
</button> }
<button </style>
@click=${e => { Change config to:
e.target.parentElement.querySelector('#respSwitchOverlay').config = {
...withBottomSheetConfig(), <select ${ref(selectRef)} @change="${onSelectChange}">
}; <option value="modaldialog">Modal Dialog</option>
}} <option value="bottomsheet">Bottom Sheet</option>
> <option value="dropdown">Dropdown</option>
Bottom Sheet </select>
</button>
<button <br />
@click=${e => { <demo-overlay-system ${ref(overlayRef)} .config=${getConfig(selectRef.value?.value)}>
e.target.parentElement.querySelector('#respSwitchOverlay').config = { <button slot="invoker">Click me to open the overlay!</button>
...withDropdownConfig(), <div slot="content" class="demo-overlay">
}; Hello! You can close this notification here:
}} <button
> @click=${e => e.target.dispatchEvent(new Event('close-overlay', { bubbles: true }))}
Dropdown >
</button>
<br /> </button>
<demo-overlay-system id="respSwitchOverlay" .config=${{ ...withBottomSheetConfig() }}> </div>
<button slot="invoker">Click me to open the overlay!</button> </demo-overlay-system>
<div slot="content" class="demo-overlay"> `;
Hello! You can close this notification here: };
<button @click=${e => e.target.dispatchEvent(new Event('close-overlay', { bubbles: true }))}>
</button>
</div>
</demo-overlay-system>
`;
``` ```
## Opened state ## Opened state

View file

@ -57,15 +57,11 @@ describe('lion-dialog', () => {
</lion-dialog> </lion-dialog>
`); `);
// @ts-expect-error you're not allowed to call protected _overlayInvokerNode in public context, but for testing it's okay // @ts-expect-error [allow-protected] in tests
el._overlayInvokerNode.click(); el._overlayInvokerNode.click();
expect(el.opened).to.be.true; expect(el.opened).to.be.true;
const overlaysContainer = /** @type {HTMLElement} */ ( const nestedDialog = /** @type {LionDialog} */ (el.querySelector('lion-dialog'));
document.querySelector('.global-overlays')
);
const wrapperNode = Array.from(overlaysContainer.children)[1];
const nestedDialog = /** @type {LionDialog} */ (wrapperNode.querySelector('lion-dialog'));
// @ts-expect-error you're not allowed to call protected _overlayInvokerNode in public context, but for testing it's okay // @ts-expect-error you're not allowed to call protected _overlayInvokerNode in public context, but for testing it's okay
nestedDialog?._overlayInvokerNode.click(); nestedDialog?._overlayInvokerNode.click();
expect(nestedDialog.opened).to.be.true; expect(nestedDialog.opened).to.be.true;

View file

@ -394,7 +394,7 @@ export function runListboxMixinSuite(customConfig = {}) {
await el.updateComplete; await el.updateComplete;
await el.updateComplete; // need 2 awaits as overlay.show is an async function await el.updateComplete; // need 2 awaits as overlay.show is an async function
await expect(el).to.be.accessible(); await expect(el).to.be.accessible({ ignoredRules: ['aria-allowed-role'] });
}); });
// NB: regular listbox is always 'opened', but needed for combobox and select-rich // NB: regular listbox is always 'opened', but needed for combobox and select-rich
@ -405,7 +405,7 @@ export function runListboxMixinSuite(customConfig = {}) {
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}> <${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
</${tag}> </${tag}>
`); `);
await expect(el).to.be.accessible(); await expect(el).to.be.accessible({ ignoredRules: ['aria-allowed-role'] });
}); });
it('does not have a tabindex', async () => { it('does not have a tabindex', async () => {
@ -430,7 +430,7 @@ export function runListboxMixinSuite(customConfig = {}) {
const el = await fixture(html` const el = await fixture(html`
<${tag} opened has-no-default-selected autocomplete="none" show-all-on-empty> <${tag} opened has-no-default-selected autocomplete="none" show-all-on-empty>
<${optionTag} .choiceValue=${'10'} id="first">Item 1</${optionTag}> <${optionTag} .choiceValue=${'10'} id="first">Item 1</${optionTag}>
<${optionTag} .choiceValue=${'20'} checked id="second">Item 2</${optionTag}> <${optionTag} .choiceValue=${'20'} id="second">Item 2</${optionTag}>
</${tag}> </${tag}>
`); `);
const { _activeDescendantOwnerNode } = getListboxMembers(el); const { _activeDescendantOwnerNode } = getListboxMembers(el);

View file

@ -1,34 +1,114 @@
import { EventTargetShim } from '@lion/ui/core.js'; import { EventTargetShim } from '@lion/ui/core.js';
import { adoptStyles } from 'lit';
import { overlays } from './singleton.js'; import { overlays } from './singleton.js';
import { containFocus } from './utils/contain-focus.js'; import { containFocus } from './utils/contain-focus.js';
import { globalOverlaysStyle } from './globalOverlaysStyle.js';
/** /**
* @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig * @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig
* @typedef {import('../types/OverlayConfig.js').ViewportConfig} ViewportConfig * @typedef {import('@lion/ui/types/overlays.js').ViewportConfig} ViewportConfig
* @typedef {import('@popperjs/core/lib/popper.js').createPopper} Popper * @typedef {import('@popperjs/core').createPopper} Popper
* @typedef {import('@popperjs/core/lib/popper.js').Options} PopperOptions * @typedef {import('@popperjs/core').Options} PopperOptions
* @typedef {import('@popperjs/core/lib/enums.js').Placement} Placement * @typedef {import('@popperjs/core').Placement} Placement
* @typedef {{ createPopper: Popper }} PopperModule * @typedef {{ createPopper: Popper }} PopperModule
* @typedef {'setup'|'init'|'teardown'|'before-show'|'show'|'hide'|'add'|'remove'} OverlayPhase * @typedef {'setup'|'init'|'teardown'|'before-show'|'show'|'hide'|'add'|'remove'} OverlayPhase
*/ */
/**
* From:
* - wrappingDialogNodeL1: `<dialog role="none"/>`
* - contentWrapperNodeL2: `<div id="content-wrapper-node"/>`
* - contentNodeL3: `<div slot="my-content"/>`
* To:
* ```html
* <dialog role="none">
* <div id="content-wrapper-node">
* <!-- this was the (slot for) original content node -->
* <slot name="my-content"></slot>
* </div>
* </dialog>
* ```
*
* `<slot name="my-content">` belonging to `<div slot="content"/>` will be wrapped with wrappingDialogNodeL1 and contentWrapperNodeL2
* inside shadow dom. With the help of temp markers, `<slot name="my-content">`'s original position will be respected.
*
* @param {{ wrappingDialogNodeL1:HTMLDialogElement|HTMLDivElement; contentWrapperNodeL2:Element; contentNodeL3: Element }} opts
*/
function rearrangeNodes({ wrappingDialogNodeL1, contentWrapperNodeL2, contentNodeL3 }) {
if (!(contentWrapperNodeL2.isConnected || contentNodeL3.isConnected)) {
throw new Error(
'[OverlayController] Could not find a render target, since the provided contentNode is not connected to the DOM. Make sure that it is connected, e.g. by doing "document.body.appendChild(contentNode)", before passing it on.',
);
}
let parentElement;
const tempMarker = document.createComment('temp-marker');
if (contentWrapperNodeL2.isConnected) {
// This is the case when contentWrapperNode (living in shadow dom, wrapping <slot name="my-content-outlet">) is already provided via controller.
parentElement = contentWrapperNodeL2.parentElement || contentWrapperNodeL2.getRootNode();
parentElement.insertBefore(tempMarker, contentWrapperNodeL2);
// Wrap...
wrappingDialogNodeL1.appendChild(contentWrapperNodeL2);
}
// if contentNodeL3.isConnected
else {
const contentIsProjected = contentNodeL3.assignedSlot;
if (contentIsProjected) {
parentElement =
contentNodeL3.assignedSlot.parentElement || contentNodeL3.assignedSlot.getRootNode();
parentElement.insertBefore(tempMarker, contentNodeL3.assignedSlot);
wrappingDialogNodeL1.appendChild(contentWrapperNodeL2);
// Important: we do not move around contentNodeL3, but the assigned slot
contentWrapperNodeL2.appendChild(contentNodeL3.assignedSlot);
} else {
parentElement = contentNodeL3.parentElement || contentNodeL3.getRootNode();
parentElement.insertBefore(tempMarker, contentNodeL3);
wrappingDialogNodeL1.appendChild(contentWrapperNodeL2);
contentWrapperNodeL2.appendChild(contentNodeL3);
}
}
/**
* From:
* ```html
* #shadow-root:
* <div>
* <!-- tempMarker -->
* <slot name="x"/>
* </div>
* ```
*
* To:
* ```html
* #shadow-root:
* <div>
* <!-- tempMarker -->
* <dialog role="none">
* <div id="content-wrapper-node">
* <slot name="x"/>
* </div>
* </dialog>
* </div>
* ```
*/
parentElement.insertBefore(wrappingDialogNodeL1, tempMarker);
parentElement?.removeChild(tempMarker);
}
/** /**
* @returns {Promise<PopperModule>} * @returns {Promise<PopperModule>}
*/ */
async function preloadPopper() { async function preloadPopper() {
/* eslint-disable import/no-extraneous-dependencies */ // -- [external]: import complains about untyped module, but we typecast it ourselves // @ts-ignore [external]: import complains about untyped module, but we typecast it ourselves
// @ts-ignore
return /** @type {* & Promise<PopperModule>} */ (import('@popperjs/core/dist/esm/popper.js')); return /** @type {* & Promise<PopperModule>} */ (import('@popperjs/core/dist/esm/popper.js'));
/* eslint-enable import/no-extraneous-dependencies */
} }
const GLOBAL_OVERLAYS_CONTAINER_CLASS = 'global-overlays__overlay-container';
const GLOBAL_OVERLAYS_CLASS = 'global-overlays__overlay';
// @ts-expect-error [external]: CSS not yet typed // @ts-expect-error [external]: CSS not yet typed
const supportsCSSTypedObject = window.CSS?.number && document.body.attributeStyleMap?.set; const supportsCSSTypedObject = window.CSS?.number && document.body.attributeStyleMap?.set;
/** /**
* @desc OverlayController is the fundament for every single type of overlay. With the right * OverlayController is the fundament for every single type of overlay. With the right
* configuration, it can be used to build (modal) dialogs, tooltips, dropdowns, popovers, * configuration, it can be used to build (modal) dialogs, tooltips, dropdowns, popovers,
* bottom/top/left/right sheets etc. * bottom/top/left/right sheets etc.
* *
@ -74,7 +154,7 @@ const supportsCSSTypedObject = window.CSS?.number && document.body.attributeStyl
* #### html structure for a content projected node * #### html structure for a content projected node
* <div id="contentWrapperNode"> * <div id="contentWrapperNode">
* <slot name="contentNode"></slot> * <slot name="contentNode"></slot>
* <div x-arrow></div> * <div data-popper-arrow></div>
* </div> * </div>
* *
* Structure above depicts [l4] * Structure above depicts [l4]
@ -97,6 +177,9 @@ export class OverlayController extends EventTargetShim {
/** @private */ /** @private */
this.__sharedConfig = config; this.__sharedConfig = config;
/** @type {OverlayConfig} */
this.config = {};
/** /**
* @type {OverlayConfig} * @type {OverlayConfig}
* @protected * @protected
@ -123,7 +206,7 @@ export class OverlayController extends EventTargetShim {
handlesAccessibility: false, handlesAccessibility: false,
popperConfig: { popperConfig: {
placement: 'top', placement: 'top',
strategy: 'absolute', strategy: 'fixed',
modifiers: [ modifiers: [
{ {
name: 'preventOverflow', name: 'preventOverflow',
@ -156,6 +239,8 @@ export class OverlayController extends EventTargetShim {
viewportConfig: { viewportConfig: {
placement: 'center', placement: 'center',
}, },
zIndex: 9999,
}; };
this.manager.add(this); this.manager.add(this);
@ -163,25 +248,11 @@ export class OverlayController extends EventTargetShim {
this._contentId = `overlay-content--${Math.random().toString(36).substr(2, 10)}`; this._contentId = `overlay-content--${Math.random().toString(36).substr(2, 10)}`;
/** @private */ /** @private */
this.__originalAttrs = new Map(); this.__originalAttrs = new Map();
if (this._defaultConfig.contentNode) {
if (!this._defaultConfig.contentNode.isConnected) {
throw new Error(
'[OverlayController] Could not find a render target, since the provided contentNode is not connected to the DOM. Make sure that it is connected, e.g. by doing "document.body.appendChild(contentNode)", before passing it on.',
);
}
this.__isContentNodeProjected = Boolean(this._defaultConfig.contentNode.assignedSlot);
}
this.updateConfig(config); this.updateConfig(config);
/** @private */ /** @private */
this.__hasActiveTrapsKeyboardFocus = false; this.__hasActiveTrapsKeyboardFocus = false;
/** @private */ /** @private */
this.__hasActiveBackdrop = true; this.__hasActiveBackdrop = true;
/**
* @type {HTMLElement | undefined}
* @private
*/
this.__backdropNodeToBeTornDown = undefined;
/** @private */ /** @private */
this.__escKeyHandler = this.__escKeyHandler.bind(this); this.__escKeyHandler = this.__escKeyHandler.bind(this);
} }
@ -196,10 +267,10 @@ export class OverlayController extends EventTargetShim {
/** /**
* The contentWrapperNode * The contentWrapperNode
* @type {HTMLElement} * @type {HTMLDialogElement | HTMLDivElement}
*/ */
get content() { get content() {
return /** @type {HTMLElement} */ (this.contentWrapperNode); return /** @type {HTMLDialogElement | HTMLDivElement} */ (this.__wrappingDialogNode);
} }
/** /**
@ -385,28 +456,6 @@ export class OverlayController extends EventTargetShim {
return /** @type {ViewportConfig} */ (this.config?.viewportConfig); return /** @type {ViewportConfig} */ (this.config?.viewportConfig);
} }
/**
* Usually the parent node of contentWrapperNode that either exists locally or globally.
* When a responsive scenario is created (in which we switch from global to local or vice versa)
* we need to know where we should reappend contentWrapperNode (or contentNode in case it's
* projected).
* @type {HTMLElement}
* @protected
*/
get _renderTarget() {
/** config [g1] */
if (this.placementMode === 'global') {
return this.manager.globalRootNode;
}
/** config [l2] or [l4] */
if (this.__isContentNodeProjected) {
// @ts-expect-error [external]: fix Node types
return this.__originalContentParent?.getRootNode().host;
}
/** config [l1] or [l3] */
return /** @type {HTMLElement} */ (this.__originalContentParent);
}
/** /**
* @desc The element our local overlay will be positioned relative to. * @desc The element our local overlay will be positioned relative to.
* @type {HTMLElement | undefined} * @type {HTMLElement | undefined}
@ -420,12 +469,8 @@ export class OverlayController extends EventTargetShim {
* @param {number} value * @param {number} value
*/ */
set elevation(value) { set elevation(value) {
if (this.contentWrapperNode) { // @ts-expect-error find out why config would/could be undfined
this.contentWrapperNode.style.zIndex = `${value}`; this.__wrappingDialogNode.style.zIndex = `${this.config.zIndex + value}`;
}
if (this.backdropNode) {
this.backdropNode.style.zIndex = `${value}`;
}
} }
/** /**
@ -472,9 +517,9 @@ export class OverlayController extends EventTargetShim {
}; };
/** @private */ /** @private */
this.__validateConfiguration(/** @type {OverlayConfig} */ (this.config)); this.__validateConfiguration(this.config);
/** @protected */ /** @protected */
this._init({ cfgToAdd }); this._init();
/** @private */ /** @private */
this.__elementToFocusAfterHide = undefined; this.__elementToFocusAfterHide = undefined;
} }
@ -498,16 +543,6 @@ export class OverlayController extends EventTargetShim {
if (!newConfig.contentNode) { if (!newConfig.contentNode) {
throw new Error('[OverlayController] You need to provide a .contentNode'); throw new Error('[OverlayController] You need to provide a .contentNode');
} }
if (this.__isContentNodeProjected && !newConfig.contentWrapperNode) {
throw new Error(
'[OverlayController] You need to provide a .contentWrapperNode when .contentNode is projected',
);
}
if (newConfig.isTooltip && newConfig.placementMode !== 'local') {
throw new Error(
'[OverlayController] .isTooltip should be configured with .placementMode "local"',
);
}
if (newConfig.isTooltip && !newConfig.handlesAccessibility) { if (newConfig.isTooltip && !newConfig.handlesAccessibility) {
throw new Error( throw new Error(
'[OverlayController] .isTooltip only takes effect when .handlesAccessibility is enabled', '[OverlayController] .isTooltip only takes effect when .handlesAccessibility is enabled',
@ -519,85 +554,95 @@ export class OverlayController extends EventTargetShim {
} }
/** /**
* @param {{ cfgToAdd: OverlayConfig }} options
* @protected * @protected
*/ */
_init({ cfgToAdd }) { _init() {
this.__initContentWrapperNode({ cfgToAdd }); if (!this.__contentHasBeenInitialized) {
this.__initConnectionTarget(); this.__initContentDomStructure();
this.__contentHasBeenInitialized = true;
}
// Reset all positioning styles (local, c.q. Popper) and classes (global)
this.contentWrapperNode.removeAttribute('style');
this.contentWrapperNode.removeAttribute('class');
if (this.placementMode === 'local') { if (this.placementMode === 'local') {
// Lazily load Popper if not done yet // Lazily load Popper if not done yet
if (!OverlayController.popperModule) { if (!OverlayController.popperModule) {
// a@ts-expect-error FIXME: for some reason createPopper is missing here
OverlayController.popperModule = preloadPopper(); OverlayController.popperModule = preloadPopper();
} }
} else {
const rootNode = /** @type {ShadowRoot} */ (this.contentWrapperNode.getRootNode());
adoptStyles(rootNode, [...(rootNode.adoptedStyleSheets || []), globalOverlaysStyle]);
} }
this._handleFeatures({ phase: 'init' }); this._handleFeatures({ phase: 'init' });
} }
/** @private */
__initConnectionTarget() {
// Now, add our node to the right place in dom (renderTarget)
if (this.contentWrapperNode !== this.__prevConfig?.contentWrapperNode) {
if (this.config?.placementMode === 'global' || !this.__isContentNodeProjected) {
/** @type {HTMLElement} */
(this.contentWrapperNode).appendChild(this.contentNode);
}
}
if (!this._renderTarget) {
return;
}
if (this.__isContentNodeProjected && this.placementMode === 'local') {
// We add the contentNode in its slot, so that it will be projected by contentWrapperNode
this._renderTarget.appendChild(this.contentNode);
} else {
const isInsideRenderTarget = this._renderTarget === this.contentWrapperNode.parentNode;
const nodeContainsTarget = this.contentWrapperNode.contains(this._renderTarget);
if (!isInsideRenderTarget && !nodeContainsTarget) {
// contentWrapperNode becomes the direct (non projected) parent of contentNode
this._renderTarget.appendChild(this.contentWrapperNode);
}
}
}
/** /**
* Cleanup ._contentWrapperNode. We do this, because creating a fresh wrapper * Here we arrange our content node via:
* can lead to problems with event listeners... * 1. HTMLDialogElement: the content will always be painted to the browser's top layer
* @param {{ cfgToAdd: OverlayConfig }} options * - no matter what context the contentNode lives in, the overlay will be painted correctly via the <dialog> element,
* even if 'overflow:hidden' or a css transform is applied in its parent hierarchy.
* - the dialog element will be unstyled, but will span the whole screen
* - a backdrop element will be a child of the dialog element, so it leverages the capabilities of the parent
* (filling the whole screen if wanted an always painted to top layer)
* 2. ContentWrapper: the content receives the right positioning styles in a clean/non conflicting way:
* - local positioning: receive inline (position) styling that can never conflict with the already existing computed styles
* - global positioning: receive flex (child) classes that position the content correctly relative to the viewport
*
* The resulting structure that will be created looks like this:
*
* ...
* <dialog role="none">
* <div id="optional-backdrop"></div>
* <div id="content-wrapper-node">
* <!-- this was the (slot for) original content node -->
* <slot name="content"></slot>
* </div>
* </dialog>
* ...
*
* @private * @private
*/ */
__initContentWrapperNode({ cfgToAdd }) { __initContentDomStructure() {
if (this.config?.contentWrapperNode && this.placementMode === 'local') { const wrappingDialogElement = document.createElement(
/** config [l2],[l3],[l4] */ this.config?._noDialogEl ? 'div' : 'dialog',
this.__contentWrapperNode = this.config.contentWrapperNode; );
} else { // We use a dialog for its visual capabilities: it renders to the top layer.
/** config [l1],[g1] */ // A11y will depend on the type of overlay and is arranged on contentNode level.
// Also see: https://www.scottohara.me/blog/2019/03/05/open-dialog.html
wrappingDialogElement.setAttribute('role', 'none');
// N.B. position: fixed is needed to escape out of 'overflow: hidden'
// We give a high z-index for non-modal dialogs, so that we at least win from all siblings of our
// parent stacking context
wrappingDialogElement.style.cssText = `display:none; background-image: none; border-style: none; padding: 0; z-index: ${this.config.zIndex};`;
this.__wrappingDialogNode = wrappingDialogElement;
/**
* Based on the configuration of the developer, multiple scenarios are accounted for
* A. We already have a contentWrapperNode ()
*/
if (!this.config?.contentWrapperNode) {
this.__contentWrapperNode = document.createElement('div'); this.__contentWrapperNode = document.createElement('div');
} }
this.contentWrapperNode.setAttribute('data-id', 'content-wrapper');
this.contentWrapperNode.style.cssText = ''; rearrangeNodes({
this.contentWrapperNode.style.display = 'none'; wrappingDialogNodeL1: wrappingDialogElement,
contentWrapperNodeL2: this.contentWrapperNode,
contentNodeL3: this.contentNode,
});
// @ts-ignore
wrappingDialogElement.open = true;
this.__wrappingDialogNode.style.display = 'none';
this.contentWrapperNode.style.zIndex = '1';
if (getComputedStyle(this.contentNode).position === 'absolute') { if (getComputedStyle(this.contentNode).position === 'absolute') {
// Having a _contWrapperNode and a contentNode with 'position:absolute' results in // Having a _contWrapperNode and a contentNode with 'position:absolute' results in
// computed height of 0... // computed height of 0...
this.contentNode.style.position = 'static'; this.contentNode.style.position = 'static';
} }
if (this.__isContentNodeProjected && this.contentWrapperNode.isConnected) {
// We need to keep track of the original local context.
/** config [l2], [l4] */
this.__originalContentParent = /** @type {HTMLElement} */ (
this.contentWrapperNode.parentNode
);
} else if (cfgToAdd.contentNode && cfgToAdd.contentNode.isConnected) {
// We need to keep track of the original local context.
/** config [l1], [l3], [g1] */
this.__originalContentParent = /** @type {HTMLElement} */ (this.contentNode?.parentNode);
}
} }
/** /**
@ -613,7 +658,7 @@ export class OverlayController extends EventTargetShim {
if (phase === 'setup') { if (phase === 'setup') {
const zIndexNumber = Number(getComputedStyle(this.contentNode).zIndex); const zIndexNumber = Number(getComputedStyle(this.contentNode).zIndex);
if (zIndexNumber < 1 || Number.isNaN(zIndexNumber)) { if (zIndexNumber < 1 || Number.isNaN(zIndexNumber)) {
this.contentWrapperNode.style.zIndex = '1'; this.contentNode.style.zIndex = '1';
} }
} }
} }
@ -686,7 +731,7 @@ export class OverlayController extends EventTargetShim {
} }
get isShown() { get isShown() {
return Boolean(this.contentWrapperNode.style.display !== 'none'); return Boolean(this.__wrappingDialogNode?.style.display !== 'none');
} }
/** /**
@ -717,7 +762,11 @@ export class OverlayController extends EventTargetShim {
const event = new CustomEvent('before-show', { cancelable: true }); const event = new CustomEvent('before-show', { cancelable: true });
this.dispatchEvent(event); this.dispatchEvent(event);
if (!event.defaultPrevented) { if (!event.defaultPrevented) {
this.contentWrapperNode.style.display = ''; if (this.__wrappingDialogNode instanceof HTMLDialogElement) {
this.__wrappingDialogNode.show();
}
// @ts-ignore
this.__wrappingDialogNode.style.display = '';
this._keepBodySize({ phase: 'before-show' }); this._keepBodySize({ phase: 'before-show' });
await this._handleFeatures({ phase: 'show' }); await this._handleFeatures({ phase: 'show' });
this._keepBodySize({ phase: 'show' }); this._keepBodySize({ phase: 'show' });
@ -739,11 +788,17 @@ export class OverlayController extends EventTargetShim {
*/ */
async _handlePosition({ phase }) { async _handlePosition({ phase }) {
if (this.placementMode === 'global') { if (this.placementMode === 'global') {
const addOrRemove = phase === 'show' ? 'add' : 'remove'; const placementClass = `global-overlays__overlay-container--${this.viewportConfig.placement}`;
const placementClass = `${GLOBAL_OVERLAYS_CONTAINER_CLASS}--${this.viewportConfig.placement}`;
this.contentWrapperNode.classList[addOrRemove](GLOBAL_OVERLAYS_CONTAINER_CLASS); if (phase === 'show') {
this.contentWrapperNode.classList[addOrRemove](placementClass); this.contentWrapperNode.classList.add('global-overlays__overlay-container');
this.contentNode.classList[addOrRemove](GLOBAL_OVERLAYS_CLASS); this.contentWrapperNode.classList.add(placementClass);
this.contentNode.classList.add('global-overlays__overlay');
} else if (phase === 'hide') {
this.contentWrapperNode.classList.remove('global-overlays__overlay-container');
this.contentWrapperNode.classList.remove(placementClass);
this.contentNode.classList.remove('global-overlays__overlay');
}
} else if (this.placementMode === 'local' && phase === 'show') { } else if (this.placementMode === 'local' && phase === 'show') {
/** /**
* Popper is weird about properly positioning the popper element when it is recreated so * Popper is weird about properly positioning the popper element when it is recreated so
@ -753,7 +808,7 @@ export class OverlayController extends EventTargetShim {
* This is however necessary for initial placement. * This is however necessary for initial placement.
*/ */
await this.__createPopperInstance(); await this.__createPopperInstance();
/** @type {Popper} */ (this._popper).forceUpdate(); this._popper.forceUpdate();
} }
} }
@ -829,7 +884,11 @@ export class OverlayController extends EventTargetShim {
contentNode: this.contentNode, contentNode: this.contentNode,
}); });
this.contentWrapperNode.style.display = 'none'; if (this.__wrappingDialogNode instanceof HTMLDialogElement) {
this.__wrappingDialogNode.close();
}
// @ts-ignore
this.__wrappingDialogNode.style.display = 'none';
this._handleFeatures({ phase: 'hide' }); this._handleFeatures({ phase: 'hide' });
this._keepBodySize({ phase: 'hide' }); this._keepBodySize({ phase: 'hide' });
this.dispatchEvent(new Event('hide')); this.dispatchEvent(new Event('hide'));
@ -843,6 +902,7 @@ export class OverlayController extends EventTargetShim {
* *
* @param {{backdropNode:HTMLElement, contentNode:HTMLElement}} hideConfig * @param {{backdropNode:HTMLElement, contentNode:HTMLElement}} hideConfig
*/ */
// @ts-ignore
// eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars // eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars
async transitionHide(hideConfig) {} async transitionHide(hideConfig) {}
@ -851,48 +911,27 @@ export class OverlayController extends EventTargetShim {
* @protected * @protected
*/ */
// eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars // eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars
async _transitionHide(hideConfig) { async _transitionHide({ backdropNode, contentNode }) {
// `this.transitionHide` is a hook for our users // `this.transitionHide` is a hook for our users
await this.transitionHide({ backdropNode: this.backdropNode, contentNode: this.contentNode }); await this.transitionHide({ backdropNode, contentNode });
this._handlePosition({ phase: 'hide' });
if (hideConfig.backdropNode) { if (!backdropNode) {
hideConfig.backdropNode.classList.remove( return;
`${this.placementMode}-overlays__backdrop--animation-in`,
);
/** @type {() => void} */
let afterFadeOut = () => {};
hideConfig.backdropNode.classList.add(
`${this.placementMode}-overlays__backdrop--animation-out`,
);
this.__backdropAnimation = new Promise(resolve => {
afterFadeOut = () => {
if (hideConfig.backdropNode) {
hideConfig.backdropNode.classList.remove(
`${this.placementMode}-overlays__backdrop--animation-out`,
);
hideConfig.backdropNode.classList.remove(
`${this.placementMode}-overlays__backdrop--visible`,
);
hideConfig.backdropNode.removeEventListener('animationend', afterFadeOut);
}
resolve(undefined);
};
});
hideConfig.backdropNode.addEventListener('animationend', afterFadeOut);
} }
backdropNode.classList.remove(`global-overlays__backdrop--animation-in`);
} }
/** /**
* To be overridden by subclassers * To be overridden by subclassers
* *
* @param {{backdropNode:HTMLElement, contentNode:HTMLElement}} showConfig * @param {{backdropNode:HTMLElement; contentNode:HTMLElement}} showConfig
*/ */
// @ts-ignore
// eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars // eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars
async transitionShow(showConfig) {} async transitionShow(showConfig) {}
/** /**
* @param {{backdropNode:HTMLElement, contentNode:HTMLElement}} showConfig * @param {{backdropNode:HTMLElement; contentNode:HTMLElement}} showConfig
*/ */
// eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars // eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars
async _transitionShow(showConfig) { async _transitionShow(showConfig) {
@ -900,23 +939,16 @@ export class OverlayController extends EventTargetShim {
await this.transitionShow({ backdropNode: this.backdropNode, contentNode: this.contentNode }); await this.transitionShow({ backdropNode: this.backdropNode, contentNode: this.contentNode });
if (showConfig.backdropNode) { if (showConfig.backdropNode) {
showConfig.backdropNode.classList.add( showConfig.backdropNode.classList.add(`global-overlays__backdrop--animation-in`);
`${this.placementMode}-overlays__backdrop--animation-in`,
);
} }
} }
/** @protected */ /** @protected */
_restoreFocus() { _restoreFocus() {
const { activeElement } = /** @type {* & ShadowRoot} */ ( const { activeElement } = /** @type {ShadowRoot} */ (this.contentWrapperNode.getRootNode());
this.__contentWrapperNode
).getRootNode();
// We only are allowed to move focus if we (still) 'own' it. // We only are allowed to move focus if we (still) 'own' it.
// Otherwise we assume the 'outside world' has, purposefully, taken over // Otherwise we assume the 'outside world' has, purposefully, taken over
if ( if (activeElement instanceof HTMLElement && this.contentWrapperNode.contains(activeElement)) {
activeElement &&
/** @type {HTMLElement} */ (this.__contentWrapperNode).contains(activeElement)
) {
if (this.elementToFocusAfterHide) { if (this.elementToFocusAfterHide) {
this.elementToFocusAfterHide.focus(); this.elementToFocusAfterHide.focus();
} else { } else {
@ -1010,55 +1042,28 @@ export class OverlayController extends EventTargetShim {
* @protected * @protected
*/ */
_handleBackdrop({ phase }) { _handleBackdrop({ phase }) {
// eslint-disable-next-line default-case
switch (phase) { switch (phase) {
case 'init': { case 'init': {
if (!this.backdropNode) { if (!this.__backdropInitialized) {
this.__backdropNode = document.createElement('div'); if (!this.config?.backdropNode) {
/** @type {HTMLElement} */ this.__backdropNode = document.createElement('div');
(this.backdropNode).slot = 'backdrop'; // If backdropNode existed in config, styles are applied by implementing party
/** @type {HTMLElement} */ this.__backdropNode.classList.add(`global-overlays__backdrop`);
(this.backdropNode).classList.add(`${this.placementMode}-overlays__backdrop`); }
// @ts-ignore
this.__wrappingDialogNode.prepend(this.backdropNode);
this.__backdropInitialized = true;
} }
let insertionAnchor = /** @type {HTMLElement} */ (this.contentNode.parentNode);
let insertionBefore = this.contentNode;
if (this.placementMode === 'global') {
insertionAnchor = /** @type {HTMLElement} */ (this.contentWrapperNode.parentElement);
insertionBefore = this.contentWrapperNode;
}
insertionAnchor.insertBefore(this.backdropNode, insertionBefore);
break; break;
} }
case 'show': case 'show':
this.backdropNode.classList.add(`${this.placementMode}-overlays__backdrop--visible`); this.backdropNode.classList.add(`global-overlays__backdrop--visible`);
this.__hasActiveBackdrop = true; this.__hasActiveBackdrop = true;
break; break;
case 'hide': case 'hide':
if (!this.backdropNode) {
return;
}
this.__hasActiveBackdrop = false; this.__hasActiveBackdrop = false;
break; break;
case 'teardown':
if (!this.backdropNode || !this.backdropNode.parentNode) {
return;
}
if (this.__backdropAnimation) {
this.__backdropNodeToBeTornDown = this.backdropNode;
this.__backdropAnimation.then(() => {
if (this.__backdropNodeToBeTornDown && this.__backdropNodeToBeTornDown.parentNode) {
this.__backdropNodeToBeTornDown.parentNode.removeChild(
this.__backdropNodeToBeTornDown,
);
}
});
} else {
this.backdropNode.parentNode.removeChild(this.backdropNode);
}
this.__backdropNode = undefined;
break;
/* no default */
} }
} }
@ -1072,7 +1077,16 @@ export class OverlayController extends EventTargetShim {
*/ */
_handleTrapsKeyboardFocus({ phase }) { _handleTrapsKeyboardFocus({ phase }) {
if (phase === 'show') { if (phase === 'show') {
// @ts-ignore
if ('showModal' in this.__wrappingDialogNode) {
// @ts-ignore
this.__wrappingDialogNode.close();
// @ts-ignore
this.__wrappingDialogNode.showModal();
}
// else {
this.enableTrapsKeyboardFocus(); this.enableTrapsKeyboardFocus();
// }
} else if (phase === 'hide' || phase === 'teardown') { } else if (phase === 'hide' || phase === 'teardown') {
this.disableTrapsKeyboardFocus(); this.disableTrapsKeyboardFocus();
} }
@ -1271,24 +1285,6 @@ export class OverlayController extends EventTargetShim {
teardown() { teardown() {
this._handleFeatures({ phase: 'teardown' }); this._handleFeatures({ phase: 'teardown' });
if (this.placementMode === 'global' && this.__isContentNodeProjected) {
/** @type {HTMLElement} */ (this.__originalContentParent).appendChild(this.contentNode);
}
// Remove the content node wrapper from the global rootnode
this._teardownContentWrapperNode();
}
/** @protected */
_teardownContentWrapperNode() {
if (
this.placementMode === 'global' &&
this.contentWrapperNode &&
this.contentWrapperNode.parentNode
) {
this.contentWrapperNode.parentNode.removeChild(this.contentWrapperNode);
}
} }
/** @private */ /** @private */

View file

@ -27,9 +27,8 @@ export const OverlayMixinImplementation = superclass =>
constructor() { constructor() {
super(); super();
this.opened = false; this.opened = false;
/** @private */
this.__needsSetup = true; /** @type {Partial<OverlayConfig>} */
/** @type {OverlayConfig} */
this.config = {}; this.config = {};
/** @type {EventListener} */ /** @type {EventListener} */
@ -163,16 +162,13 @@ export const OverlayMixinImplementation = superclass =>
} }
} }
connectedCallback() { /**
super.connectedCallback(); * @param {import('lit-element').PropertyValues } changedProperties
// we do a setup after every connectedCallback as firstUpdated will only be called once */
this.__needsSetup = true; firstUpdated(changedProperties) {
this.updateComplete.then(() => { super.firstUpdated(changedProperties);
if (this.__needsSetup) {
this._setupOverlayCtrl(); this._setupOverlayCtrl();
}
this.__needsSetup = false;
});
} }
disconnectedCallback() { disconnectedCallback() {
@ -197,9 +193,12 @@ export const OverlayMixinImplementation = superclass =>
} }
get _overlayBackdropNode() { get _overlayBackdropNode() {
return /** @type {HTMLElement | undefined} */ ( if (!this.__cachedOverlayBackdropNode) {
Array.from(this.children).find(child => child.slot === 'backdrop') this.__cachedOverlayBackdropNode = /** @type {HTMLElement | undefined} */ (
); Array.from(this.children).find(child => child.slot === 'backdrop')
);
}
return this.__cachedOverlayBackdropNode;
} }
get _overlayContentNode() { get _overlayContentNode() {

View file

@ -4,7 +4,6 @@
*/ */
import { globalOverlaysStyle } from './globalOverlaysStyle.js'; import { globalOverlaysStyle } from './globalOverlaysStyle.js';
import { setSiblingsInert, unsetSiblingsInert } from './utils/inert-siblings.js';
// Export this as protected var, so that we can easily mock it in tests // Export this as protected var, so that we can easily mock it in tests
// TODO: combine with browserDetection of core? // TODO: combine with browserDetection of core?
@ -23,13 +22,6 @@ export const _browserDetection = {
* `OverlaysManager` which manages overlays which are rendered into the body * `OverlaysManager` which manages overlays which are rendered into the body
*/ */
export class OverlaysManager { export class OverlaysManager {
static __createGlobalRootNode() {
const rootNode = document.createElement('div');
rootNode.classList.add('global-overlays');
document.body.appendChild(rootNode);
return rootNode;
}
static __createGlobalStyleNode() { static __createGlobalStyleNode() {
const styleTag = document.createElement('style'); const styleTag = document.createElement('style');
styleTag.setAttribute('data-global-overlays', ''); styleTag.setAttribute('data-global-overlays', '');
@ -38,19 +30,6 @@ export class OverlaysManager {
return styleTag; return styleTag;
} }
/**
* no setter as .list is intended to be read-only
* You can use .add or .remove to modify it
*/
// eslint-disable-next-line class-methods-use-this
get globalRootNode() {
if (!OverlaysManager.__globalRootNode) {
OverlaysManager.__globalRootNode = OverlaysManager.__createGlobalRootNode();
OverlaysManager.__globalStyleNode = OverlaysManager.__createGlobalStyleNode();
}
return OverlaysManager.__globalRootNode;
}
/** /**
* no setter as .list is intended to be read-only * no setter as .list is intended to be read-only
* You can use .add or .remove to modify it * You can use .add or .remove to modify it
@ -85,6 +64,10 @@ export class OverlaysManager {
* @private * @private
*/ */
this.__blockingMap = new WeakMap(); this.__blockingMap = new WeakMap();
if (!OverlaysManager.__globalStyleNode) {
OverlaysManager.__globalStyleNode = OverlaysManager.__createGlobalStyleNode();
}
} }
/** /**
@ -108,6 +91,7 @@ export class OverlaysManager {
throw new Error('could not find controller to remove'); throw new Error('could not find controller to remove');
} }
this.__list = this.list.filter(ctrl => ctrl !== ctrlToRemove); this.__list = this.list.filter(ctrl => ctrl !== ctrlToRemove);
this.__shownList = this.shownList.filter(ctrl => ctrl !== ctrlToRemove);
} }
/** /**
@ -147,13 +131,7 @@ export class OverlaysManager {
this.__shownList = []; this.__shownList = [];
this.__siblingsInert = false; this.__siblingsInert = false;
const rootNode = OverlaysManager.__globalRootNode; if (OverlaysManager.__globalStyleNode) {
if (rootNode) {
if (rootNode.parentElement) {
rootNode.parentElement.removeChild(rootNode);
}
OverlaysManager.__globalRootNode = undefined;
document.head.removeChild( document.head.removeChild(
/** @type {HTMLStyleElement} */ (OverlaysManager.__globalStyleNode), /** @type {HTMLStyleElement} */ (OverlaysManager.__globalStyleNode),
); );
@ -180,9 +158,6 @@ export class OverlaysManager {
*/ */
informTrapsKeyboardFocusGotEnabled(placementMode) { informTrapsKeyboardFocusGotEnabled(placementMode) {
if (this.siblingsInert === false && placementMode === 'global') { if (this.siblingsInert === false && placementMode === 'global') {
if (OverlaysManager.__globalRootNode) {
setSiblingsInert(this.globalRootNode);
}
this.__siblingsInert = true; this.__siblingsInert = true;
} }
} }
@ -199,9 +174,6 @@ export class OverlaysManager {
next.enableTrapsKeyboardFocus(); next.enableTrapsKeyboardFocus();
} }
} else if (this.siblingsInert === true) { } else if (this.siblingsInert === true) {
if (OverlaysManager.__globalRootNode) {
unsetSiblingsInert(this.globalRootNode);
}
this.__siblingsInert = false; this.__siblingsInert = false;
} }
} }
@ -242,8 +214,7 @@ export class OverlaysManager {
*/ */
requestToShowOnly(blockingCtrl) { requestToShowOnly(blockingCtrl) {
const controllersToHide = this.shownList.filter(ctrl => ctrl !== blockingCtrl); const controllersToHide = this.shownList.filter(ctrl => ctrl !== blockingCtrl);
controllersToHide.forEach(ctrl => ctrl.hide());
controllersToHide.map(ctrl => ctrl.hide());
this.__blockingMap.set(blockingCtrl, controllersToHide); this.__blockingMap.set(blockingCtrl, controllersToHide);
} }
@ -255,11 +226,10 @@ export class OverlaysManager {
const controllersWhichGotHidden = /** @type {OverlayController[]} */ ( const controllersWhichGotHidden = /** @type {OverlayController[]} */ (
this.__blockingMap.get(blockingCtrl) this.__blockingMap.get(blockingCtrl)
); );
controllersWhichGotHidden.map(ctrl => ctrl.show()); controllersWhichGotHidden.forEach(ctrl => ctrl.show());
} }
} }
} }
/** @type {HTMLElement | undefined} */
OverlaysManager.__globalRootNode = undefined;
/** @type {HTMLStyleElement | undefined} */ /** @type {HTMLStyleElement | undefined} */
OverlaysManager.__globalStyleNode = undefined; OverlaysManager.__globalStyleNode = undefined;

View file

@ -6,10 +6,6 @@ export const globalOverlaysStyle = css`
z-index: 200; z-index: 200;
} }
.global-overlays__overlay {
pointer-events: auto;
}
.global-overlays__overlay-container { .global-overlays__overlay-container {
display: flex; display: flex;
position: fixed; position: fixed;
@ -20,6 +16,10 @@ export const globalOverlaysStyle = css`
pointer-events: none; pointer-events: none;
} }
.global-overlays__overlay-container::backdrop {
display: none;
}
.global-overlays__overlay-container--top-left { .global-overlays__overlay-container--top-left {
justify-content: flex-start; justify-content: flex-start;
align-items: flex-start; align-items: flex-start;
@ -54,6 +54,7 @@ export const globalOverlaysStyle = css`
justify-content: flex-end; justify-content: flex-end;
align-items: flex-end; align-items: flex-end;
} }
.global-overlays__overlay-container--left { .global-overlays__overlay-container--left {
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
@ -68,7 +69,12 @@ export const globalOverlaysStyle = css`
width: 100%; width: 100%;
} }
.global-overlays .global-overlays__backdrop { ::slotted(.global-overlays__overlay),
.global-overlays__overlay {
pointer-events: auto;
}
.global-overlays__backdrop {
content: ''; content: '';
position: fixed; position: fixed;
top: 0; top: 0;
@ -81,15 +87,16 @@ export const globalOverlaysStyle = css`
display: none; display: none;
} }
.global-overlays .global-overlays__backdrop--visible { .global-overlays__backdrop--visible {
display: block; display: block;
} }
.global-overlays .global-overlays__backdrop--animation-in { .global-overlays__backdrop--animation-in {
animation: global-overlays-backdrop-fade-in 300ms; animation: global-overlays-backdrop-fade-in 300ms;
opacity: 0.3;
} }
.global-overlays .global-overlays__backdrop--animation-out { .global-overlays__backdrop--animation-out {
animation: global-overlays-backdrop-fade-out 300ms; animation: global-overlays-backdrop-fade-out 300ms;
opacity: 0; opacity: 0;
} }

View file

@ -1,4 +1,6 @@
import { singletonManager } from 'singleton-manager'; import { singletonManager } from 'singleton-manager';
import { OverlaysManager } from './OverlaysManager.js'; import { OverlaysManager } from './OverlaysManager.js';
export const overlays = singletonManager.get('@lion/ui::overlays::0.x') || new OverlaysManager(); export const overlays =
/** @type {OverlaysManager} */
(singletonManager.get('@lion/ui::overlays::0.x')) || new OverlaysManager();

View file

@ -1,7 +1,7 @@
import { expect, fixture, html, nextFrame, aTimeout } from '@open-wc/testing'; import { expect, fixture, html, nextFrame, aTimeout } from '@open-wc/testing';
import sinon from 'sinon'; import sinon from 'sinon';
import { overlays, OverlayController } from '@lion/ui/overlays.js'; import { overlays as overlaysManager, OverlayController } from '@lion/ui/overlays.js';
import '@lion/ui/define/lion-dialog.js'; import '@lion/ui/define/lion-dialog.js';
import { _browserDetection } from '../src/OverlaysManager.js'; import { _browserDetection } from '../src/OverlaysManager.js';
@ -15,10 +15,12 @@ import { _browserDetection } from '../src/OverlaysManager.js';
* @typedef {LitElement & OverlayHost & {_overlayCtrl:OverlayController}} OverlayEl * @typedef {LitElement & OverlayHost & {_overlayCtrl:OverlayController}} OverlayEl
*/ */
function getGlobalOverlayNodes() { function getGlobalOverlayCtrls() {
return Array.from(overlays.globalRootNode.children).filter( return overlaysManager.list;
child => !child.classList.contains('global-overlays__backdrop'), }
);
function resetOverlaysManager() {
overlaysManager.list.forEach(overlayCtrl => overlaysManager.remove(overlayCtrl));
} }
/** /**
@ -220,23 +222,21 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
function sendCloseEvent(/** @type {Event} */ e) { function sendCloseEvent(/** @type {Event} */ e) {
e.target?.dispatchEvent(new Event('close-overlay', { bubbles: true })); e.target?.dispatchEvent(new Event('close-overlay', { bubbles: true }));
} }
const closeBtn = /** @type {OverlayEl} */ (
await fixture(html` <button @click=${sendCloseEvent}>close</button> `)
);
const el = /** @type {OverlayEl} */ ( const el = /** @type {OverlayEl} */ (
await fixture(html` await fixture(html`
<${tag} opened> <${tag} opened>
<div slot="content"> <div slot="content">
content of the overlay content of the overlay
${closeBtn} <button @click=${sendCloseEvent}>close</button>
</div> </div>
<button slot="invoker">invoker button</button> <button slot="invoker">invoker button</button>
</${tag}> </${tag}>
`) `)
); );
closeBtn.click(); // @ts-ignore
await nextFrame(); // hide takes at least a frame el.querySelector('[slot=content] button').click();
await el._overlayCtrl._hideComplete;
expect(el.opened).to.be.false; expect(el.opened).to.be.false;
}); });
@ -337,10 +337,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
// For some reason, globalRootNode is not cleared properly on disconnectedCallback from previous overlay test fixtures... // For some reason, globalRootNode is not cleared properly on disconnectedCallback from previous overlay test fixtures...
// Not sure why this "bug" happens... // Not sure why this "bug" happens...
beforeEach(() => { beforeEach(() => {
const globalRootNode = document.querySelector('.global-overlays'); resetOverlaysManager();
if (globalRootNode) {
globalRootNode.innerHTML = '';
}
}); });
it('supports nested overlays', async () => { it('supports nested overlays', async () => {
@ -362,7 +359,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
); );
if (el._overlayCtrl.placementMode === 'global') { if (el._overlayCtrl.placementMode === 'global') {
expect(getGlobalOverlayNodes().length).to.equal(2); expect(getGlobalOverlayCtrls().length).to.equal(2);
} }
el.opened = true; el.opened = true;
@ -386,12 +383,12 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
`) `)
); );
if (el._overlayCtrl.placementMode === 'global') { if (el._overlayCtrl.placementMode === 'global') {
expect(getGlobalOverlayNodes().length).to.equal(1); expect(getGlobalOverlayCtrls().length).to.equal(1);
const moveTarget = /** @type {OverlayEl} */ (await fixture('<div id="target"></div>')); const moveTarget = /** @type {OverlayEl} */ (await fixture('<div id="target"></div>'));
moveTarget.appendChild(el); moveTarget.appendChild(el);
await el.updateComplete; await el.updateComplete;
expect(getGlobalOverlayNodes().length).to.equal(1); expect(getGlobalOverlayCtrls().length).to.equal(1);
} }
}); });
@ -419,14 +416,9 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
if (el._overlayCtrl.placementMode === 'global') { if (el._overlayCtrl.placementMode === 'global') {
// Find the outlets that are not backdrop outlets // Find the outlets that are not backdrop outlets
const overlayContainerNodes = getGlobalOverlayNodes(); expect(getGlobalOverlayCtrls().length).to.equal(2);
expect(overlayContainerNodes.length).to.equal(2);
const lastContentNodeInContainer = overlayContainerNodes[1];
// Check that the last container is the nested one with the intended content // Check that the last container is the nested one with the intended content
expect(lastContentNodeInContainer.firstElementChild.firstChild.textContent).to.equal( expect(el.contains(nestedEl)).to.be.true;
'content of the nested overlay',
);
expect(lastContentNodeInContainer.firstElementChild.slot).to.equal('content');
} else { } else {
const contentNode = /** @type {HTMLElement} */ ( const contentNode = /** @type {HTMLElement} */ (
// @ts-ignore [allow-protected] in tests // @ts-ignore [allow-protected] in tests

View file

@ -58,7 +58,6 @@ describe('ArrowMixin', () => {
`) `)
); );
expect(el.hasAttribute('has-arrow')).to.be.true; expect(el.hasAttribute('has-arrow')).to.be.true;
const arrowNode = /** @type {Element} */ (el._arrowNode); const arrowNode = /** @type {Element} */ (el._arrowNode);
expect(window.getComputedStyle(arrowNode).getPropertyValue('display')).to.equal('block'); expect(window.getComputedStyle(arrowNode).getPropertyValue('display')).to.equal('block');
}); });

View file

@ -20,6 +20,18 @@ import { simulateTab } from '../src/utils/simulate-tab.js';
* @typedef {import('../types/OverlayConfig.js').ViewportPlacement} ViewportPlacement * @typedef {import('../types/OverlayConfig.js').ViewportPlacement} ViewportPlacement
*/ */
const wrappingDialogNodeStyle =
'display: none; background-image: none; border-style: none; padding: 0px; z-index: 9999;';
/**
* @param {HTMLElement} node
*/
function normalizeOverlayContentWapper(node) {
if (node.hasAttribute('style') && !node.style.cssText) {
node.removeAttribute('style');
}
}
/** /**
* @param {OverlayController} overlayControllerEl * @param {OverlayController} overlayControllerEl
*/ */
@ -66,7 +78,7 @@ describe('OverlayController', () => {
...withGlobalTestConfig(), ...withGlobalTestConfig(),
}); });
expect(ctrl.content).not.to.be.undefined; expect(ctrl.content).not.to.be.undefined;
expect(ctrl.contentNode.parentElement).to.equal(ctrl.content); expect(ctrl.contentNode.parentElement).to.equal(ctrl.contentWrapperNode);
}); });
describe('Z-index on local overlays', () => { describe('Z-index on local overlays', () => {
@ -106,17 +118,21 @@ describe('OverlayController', () => {
contentNode: await createZNode('auto', { mode: 'global' }), contentNode: await createZNode('auto', { mode: 'global' }),
}); });
await ctrl.show(); await ctrl.show();
expect(ctrl.content.style.zIndex).to.equal('1'); // @ts-expect-error find out why config would/could be undfined
expect(ctrl.content.style.zIndex).to.equal(`${ctrl.config.zIndex + 1}`);
ctrl.updateConfig({ contentNode: await createZNode('auto', { mode: 'inline' }) }); ctrl.updateConfig({ contentNode: await createZNode('auto', { mode: 'inline' }) });
await ctrl.show(); await ctrl.show();
expect(ctrl.content.style.zIndex).to.equal('1'); // @ts-expect-error find out why config would/could be undfined
expect(ctrl.content.style.zIndex).to.equal(`${ctrl.config.zIndex + 1}`);
ctrl.updateConfig({ contentNode: await createZNode('0', { mode: 'global' }) }); ctrl.updateConfig({ contentNode: await createZNode('0', { mode: 'global' }) });
await ctrl.show(); await ctrl.show();
expect(ctrl.content.style.zIndex).to.equal('1'); // @ts-expect-error find out why config would/could be undfined
expect(ctrl.content.style.zIndex).to.equal(`${ctrl.config.zIndex + 1}`);
ctrl.updateConfig({ contentNode: await createZNode('0', { mode: 'inline' }) }); ctrl.updateConfig({ contentNode: await createZNode('0', { mode: 'inline' }) });
await ctrl.show(); await ctrl.show();
expect(ctrl.content.style.zIndex).to.equal('1'); // @ts-expect-error find out why config would/could be undfined
expect(ctrl.content.style.zIndex).to.equal(`${ctrl.config.zIndex + 1}`);
}); });
it.skip("doesn't set a z-index when contentNode already has >= 1", async () => { it.skip("doesn't set a z-index when contentNode already has >= 1", async () => {
@ -147,42 +163,7 @@ describe('OverlayController', () => {
}); });
}); });
describe('Render target', () => { describe('Offline content', () => {
it('creates global target for placement mode "global"', async () => {
const ctrl = new OverlayController({
...withGlobalTestConfig(),
});
const { renderTarget } = getProtectedMembers(ctrl);
expect(renderTarget).to.equal(overlays.globalRootNode);
});
it.skip('creates local target next to sibling for placement mode "local"', async () => {
const ctrl = new OverlayController({
...withLocalTestConfig(),
invokerNode: /** @type {HTMLElement} */ (await fixture(html`<button>Invoker</button>`)),
});
const { renderTarget } = getProtectedMembers(ctrl);
expect(renderTarget).to.be.undefined;
expect(ctrl.content).to.equal(ctrl.invokerNode?.nextElementSibling);
});
it('keeps local target for placement mode "local" when already connected', async () => {
const parentNode = /** @type {HTMLElement} */ (
await fixture(html`
<div id="parent">
<div id="content">Content</div>
</div>
`)
);
const contentNode = /** @type {HTMLElement} */ (parentNode.querySelector('#content'));
const ctrl = new OverlayController({
...withLocalTestConfig(),
contentNode,
});
const { renderTarget } = getProtectedMembers(ctrl);
expect(renderTarget).to.equal(parentNode);
});
it('throws when passing a content node that was created "offline"', async () => { it('throws when passing a content node that was created "offline"', async () => {
const contentNode = document.createElement('div'); const contentNode = document.createElement('div');
const createOverlayController = () => { const createOverlayController = () => {
@ -203,107 +184,245 @@ describe('OverlayController', () => {
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode, contentNode,
}); });
const { renderTarget } = getProtectedMembers(overlay);
expect(overlay.contentNode.isConnected).to.be.true; expect(overlay.contentNode.isConnected).to.be.true;
expect(renderTarget).to.not.be.undefined;
}); });
}); });
}); });
// TODO: Add teardown feature tests // TODO: Add teardown feature tests
describe('Teardown', () => { describe('Teardown', () => {});
it('removes the contentWrapperNode from global rootnode upon teardown', async () => {
const ctrl = new OverlayController({
...withGlobalTestConfig(),
});
expect(ctrl.manager.globalRootNode.children.length).to.equal(1);
ctrl.teardown();
expect(ctrl.manager.globalRootNode.children.length).to.equal(0);
});
it('[global] restores contentNode if it was/is a projected node', async () => {
const shadowHost = document.createElement('div');
shadowHost.id = 'shadowHost';
shadowHost.attachShadow({ mode: 'open' });
/** @type {ShadowRoot} */
(shadowHost.shadowRoot).innerHTML = `
<div id="contentWrapperNode">
<slot name="contentNode"></slot>
<my-arrow></my-arrow>
</div>
`;
const contentNode = document.createElement('div');
contentNode.slot = 'contentNode';
shadowHost.appendChild(contentNode);
const wrapper = /** @type {HTMLElement} */ (await fixture('<div id="wrapper"></div>'));
// Ensure the contentNode is connected to DOM
wrapper.appendChild(shadowHost);
// has one child = <div slot="contentNode"></div>
expect(shadowHost.children.length).to.equal(1);
const ctrl = new OverlayController({
...withLocalTestConfig(),
placementMode: 'global',
contentNode,
contentWrapperNode: shadowHost,
});
// has no children as content gets moved to the body
expect(shadowHost.children.length).to.equal(0);
ctrl.teardown();
// restores original light dom in teardown
expect(shadowHost.children.length).to.equal(1);
});
});
describe('Node Configuration', () => { describe('Node Configuration', () => {
it('accepts an .contentNode<Node> to directly set content', async () => { describe('Content', async () => {
const ctrl = new OverlayController({ it('accepts a .contentNode for displaying content of the overlay', async () => {
...withGlobalTestConfig(), const myContentNode = /** @type {HTMLElement} */ (fixtureSync('<p>direct node</p>'));
contentNode: /** @type {HTMLElement} */ (await fixture('<p>direct node</p>')),
});
expect(ctrl.contentNode).to.have.trimmed.text('direct node');
});
it('accepts an .invokerNode<Node> to directly set invoker', async () => {
const ctrl = new OverlayController({
...withGlobalTestConfig(),
invokerNode: /** @type {HTMLElement} */ (await fixture('<button>invoke</button>')),
});
expect(ctrl.invokerNode).to.have.trimmed.text('invoke');
});
describe('When contentWrapperNode projects contentNode', () => {
it('recognizes projected contentNode', async () => {
const shadowHost = document.createElement('div');
shadowHost.attachShadow({ mode: 'open' });
/** @type {ShadowRoot} */ (shadowHost.shadowRoot).innerHTML = `
<div id="contentWrapperNode">
<slot name="contentNode"></slot>
<my-arrow></my-arrow>
</div>
`;
const contentNode = document.createElement('div');
contentNode.slot = 'contentNode';
shadowHost.appendChild(contentNode);
// Ensure the contentNode is connected to DOM
document.body.appendChild(shadowHost);
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withGlobalTestConfig(),
contentNode, contentNode: myContentNode,
contentWrapperNode: /** @type {HTMLElement} */ ( });
/** @type {ShadowRoot} */ (shadowHost.shadowRoot).getElementById('contentWrapperNode') expect(ctrl.contentNode).to.have.trimmed.text('direct node');
), expect(ctrl.contentNode).to.equal(myContentNode);
});
describe('Embedded dom structure', async () => {
describe('When projected in shadow dom', async () => {
it('wraps a .contentWrapperNode for style application and a <dialog role="none"> for top layer paints', async () => {
const tagString = defineCE(
class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
/** @type {ShadowRoot} */
(this.shadowRoot).innerHTML = '<slot name="content"></slot>';
this.innerHTML = '<div slot="content">projected</div>';
}
},
);
const el = /** @type {HTMLElement} */ (fixtureSync(`<${tagString}></${tagString}>`));
const myContentNode = /** @type {HTMLElement} */ (el.querySelector('[slot=content]'));
const ctrl = new OverlayController({
...withGlobalTestConfig(),
contentNode: myContentNode,
});
expect(ctrl.contentNode.assignedSlot?.parentElement).to.equal(ctrl.contentWrapperNode);
expect(ctrl.contentWrapperNode.parentElement?.tagName).to.equal('DIALOG');
normalizeOverlayContentWapper(ctrl.contentWrapperNode);
// The total dom structure created...
expect(el).shadowDom.to.equal(`
<dialog open="" role="none" style="${wrappingDialogNodeStyle}">
<div data-id="content-wrapper">
<slot name="content">
</slot>
</div>
</dialog>
`);
expect(el).lightDom.to.equal(`<div slot="content">projected</div>`);
});
}); });
expect(ctrl.__isContentNodeProjected).to.be.true; describe('When in light dom', async () => {
it('wraps a .contentWrapperNode for style application and a <dialog role="none"> for top layer paints', async () => {
const el = fixtureSync('<section><div id="content">non projected</div></section>');
const myContentNode = /** @type {HTMLElement} */ (el.querySelector('#content'));
const ctrl = new OverlayController({
...withGlobalTestConfig(),
contentNode: myContentNode,
});
expect(ctrl.contentNode.parentElement).to.equal(ctrl.contentWrapperNode);
expect(ctrl.contentWrapperNode.parentElement?.tagName).to.equal('DIALOG');
normalizeOverlayContentWapper(ctrl.contentWrapperNode);
// The total dom structure created...
expect(el).lightDom.to.equal(`
<dialog open="" role="none" style="${wrappingDialogNodeStyle}">
<div data-id="content-wrapper">
<div id="content">non projected</div>
</div>
</dialog>
`);
});
});
describe('When .contenWrapperNode provided', async () => {
it('keeps the .contentWrapperNode for style application and wraps a <dialog role="none"> for top layer paints', async () => {
const tagString = defineCE(
class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
/** @type {ShadowRoot} */
(this.shadowRoot).innerHTML = '<div><slot name="content"></slot></div>';
this.innerHTML = '<div slot="content">projected</div>';
}
},
);
const el = /** @type {HTMLElement} */ (fixtureSync(`<${tagString}></${tagString}>`));
const myContentNode = /** @type {HTMLElement} */ (el.querySelector('[slot=content]'));
const myContentWrapper = /** @type {HTMLElement} */ (
el.shadowRoot?.querySelector('div')
);
const ctrl = new OverlayController({
...withGlobalTestConfig(),
contentNode: myContentNode,
contentWrapperNode: myContentWrapper,
});
normalizeOverlayContentWapper(ctrl.contentWrapperNode);
// The total dom structure created...
expect(el).shadowDom.to.equal(`
<dialog open="" role="none" style="${wrappingDialogNodeStyle}">
<div data-id="content-wrapper">
<slot name="content"></slot>
</div>
</dialog>
`);
});
it("uses the .contentWrapperNode as container for Popper's arrow", async () => {
const tagString = defineCE(
class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
/** @type {ShadowRoot} */
(this.shadowRoot).innerHTML = `
<div>
<div id="arrow"></div>
<slot name="content"></slot>
</div>`;
this.innerHTML = '<div slot="content">projected</div>';
}
},
);
const el = /** @type {HTMLElement} */ (fixtureSync(`<${tagString}></${tagString}>`));
const myContentNode = /** @type {HTMLElement} */ (el.querySelector('[slot=content]'));
const myContentWrapper = /** @type {HTMLElement} */ (
el.shadowRoot?.querySelector('div')
);
const ctrl = new OverlayController({
...withLocalTestConfig(),
contentNode: myContentNode,
contentWrapperNode: myContentWrapper,
});
normalizeOverlayContentWapper(ctrl.contentWrapperNode);
// The total dom structure created...
expect(el).shadowDom.to.equal(`
<dialog open="" role="none" style="${wrappingDialogNodeStyle}">
<div data-id="content-wrapper">
<div id="arrow"></div>
<slot name="content"></slot>
</div>
</dialog>
`);
});
});
});
});
describe('Invoker / Reference', async () => {
it('accepts a .invokerNode to directly set invoker', async () => {
const myInvokerNode = /** @type {HTMLElement} */ (fixtureSync('<button>invoke</button>'));
const ctrl = new OverlayController({
...withGlobalTestConfig(),
invokerNode: myInvokerNode,
});
expect(ctrl.invokerNode).to.equal(myInvokerNode);
expect(ctrl.referenceNode).to.equal(undefined);
});
it('accepts a .referenceNode as positioning anchor different from .invokerNode', async () => {
const myInvokerNode = /** @type {HTMLElement} */ (fixtureSync('<button>invoke</button>'));
const myReferenceNode = /** @type {HTMLElement} */ (fixtureSync('<div>anchor</div>'));
const ctrl = new OverlayController({
...withGlobalTestConfig(),
invokerNode: myInvokerNode,
referenceNode: myReferenceNode,
});
expect(ctrl.referenceNode).to.equal(myReferenceNode);
expect(ctrl.invokerNode).to.not.equal(ctrl.referenceNode);
});
});
describe('Backdrop', () => {
it('creates a .backdropNode inside <dialog> for guaranteed top layer paints and positioning opportunities', async () => {
const tagString = defineCE(
class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
/** @type {ShadowRoot} */
(this.shadowRoot).innerHTML = '<slot name="content"></slot>';
this.innerHTML = '<div slot="content">projected</div>';
}
},
);
const el = /** @type {HTMLElement} */ (fixtureSync(`<${tagString}></${tagString}>`));
const myContentNode = /** @type {HTMLElement} */ (el.querySelector('[slot=content]'));
const ctrl = new OverlayController({
...withGlobalTestConfig(),
contentNode: myContentNode,
hasBackdrop: true,
});
normalizeOverlayContentWapper(ctrl.contentWrapperNode);
// The total dom structure created...
expect(el).shadowDom.to.equal(
`
<dialog open="" role="none" style="${wrappingDialogNodeStyle}">
<div class="global-overlays__backdrop"></div>
<div data-id="content-wrapper">
<slot name="content">
</slot>
</div>
</dialog>
`,
);
}); });
}); });
@ -1003,24 +1122,24 @@ describe('OverlayController', () => {
await ctrl0.show(); await ctrl0.show();
await ctrl1.show(); await ctrl1.show();
await ctrl2.show(); // blocking await ctrl2.show(); // blocking
expect(ctrl0.content).to.not.be.displayed; expect(ctrl0.__wrappingDialogNode).to.not.be.displayed;
expect(ctrl1.content).to.not.be.displayed; expect(ctrl1.__wrappingDialogNode).to.not.be.displayed;
expect(ctrl2.content).to.be.displayed; expect(ctrl2.__wrappingDialogNode).to.be.displayed;
await ctrl3.show(); await ctrl3.show();
await ctrl3._showComplete; await ctrl3._showComplete;
expect(ctrl3.content).to.be.displayed; expect(ctrl3.__wrappingDialogNode).to.be.displayed;
await ctrl2.hide(); await ctrl2.hide();
await ctrl2._hideComplete; await ctrl2._hideComplete;
expect(ctrl0.content).to.be.displayed; expect(ctrl0.__wrappingDialogNode).to.be.displayed;
expect(ctrl1.content).to.be.displayed; expect(ctrl1.__wrappingDialogNode).to.be.displayed;
await ctrl2.show(); // blocking await ctrl2.show(); // blocking
expect(ctrl0.content).to.not.be.displayed; expect(ctrl0.__wrappingDialogNode).to.not.be.displayed;
expect(ctrl1.content).to.not.be.displayed; expect(ctrl1.__wrappingDialogNode).to.not.be.displayed;
expect(ctrl2.content).to.be.displayed; expect(ctrl2.__wrappingDialogNode).to.be.displayed;
expect(ctrl3.content).to.not.be.displayed; expect(ctrl3.__wrappingDialogNode).to.not.be.displayed;
}); });
it('keeps backdrop status when used in combination with blocking', async () => { it('keeps backdrop status when used in combination with blocking', async () => {
@ -1197,11 +1316,11 @@ describe('OverlayController', () => {
ctrl.hide(); ctrl.hide();
expect(getComputedStyle(ctrl.contentWrapperNode).display).to.equal('block'); expect(getComputedStyle(ctrl.content).display).to.equal('block');
setTimeout(() => { setTimeout(() => {
hideTransitionFinished(); hideTransitionFinished();
setTimeout(() => { setTimeout(() => {
expect(getComputedStyle(ctrl.contentWrapperNode).display).to.equal('none'); expect(getComputedStyle(ctrl.content).display).to.equal('none');
done(); done();
}, 0); }, 0);
}, 0); }, 0);
@ -1379,89 +1498,9 @@ describe('OverlayController', () => {
expect(properlyInstantiated).to.be.true; expect(properlyInstantiated).to.be.true;
}); });
it('adds attributes inert and aria-hidden="true" on all siblings of rootNode if an overlay is shown', async () => { // TODO: check if we covered all functionality. "Inertness" should be handled by the platform with a modal overlay...
const ctrl = new OverlayController({ it.skip('adds attributes inert and aria-hidden="true" on all siblings of rootNode if an overlay is shown', async () => {});
...withGlobalTestConfig(), it.skip('disables pointer events and selection on inert elements', async () => {});
trapsKeyboardFocus: true,
});
const sibling1 = document.createElement('div');
const sibling2 = document.createElement('div');
document.body.insertBefore(sibling1, ctrl.manager.globalRootNode);
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(ctrl.content.hasAttribute('aria-hidden')).to.be.false;
expect(ctrl.content.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 = new OverlayController({
...withGlobalTestConfig(),
trapsKeyboardFocus: true,
});
// 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, ctrl.manager.globalRootNode);
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(ctrl.contentNode).userSelect).to.be.oneOf(['auto', undefined]);
expect(window.getComputedStyle(ctrl.contentNode).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);
});
describe('Tooltip', () => { describe('Tooltip', () => {
it('adds [aria-describedby] on invoker', async () => { it('adds [aria-describedby] on invoker', async () => {
@ -1616,47 +1655,6 @@ describe('OverlayController', () => {
}).to.throw('[OverlayController] You need to provide a .contentNode'); }).to.throw('[OverlayController] You need to provide a .contentNode');
}); });
it('throws if contentNodeWrapper is not provided for projected contentNode', async () => {
const shadowHost = document.createElement('div');
shadowHost.attachShadow({ mode: 'open' });
/** @type {ShadowRoot} */ (shadowHost.shadowRoot).innerHTML = `
<div id="contentWrapperNode">
<slot name="contentNode"></slot>
<my-arrow></my-arrow>
</div>
`;
const contentNode = document.createElement('div');
contentNode.slot = 'contentNode';
shadowHost.appendChild(contentNode);
// Ensure the contentNode is connected to DOM
document.body.appendChild(shadowHost);
expect(() => {
new OverlayController({
...withLocalTestConfig(),
contentNode,
});
}).to.throw(
'[OverlayController] You need to provide a .contentWrapperNode when .contentNode is projected',
);
});
it('throws if placementMode is global for a tooltip', async () => {
const contentNode = document.createElement('div');
document.body.appendChild(contentNode);
expect(() => {
new OverlayController({
placementMode: 'global',
contentNode,
isTooltip: true,
handlesAccessibility: true,
});
}).to.throw(
'[OverlayController] .isTooltip should be configured with .placementMode "local"',
);
});
it('throws if handlesAccessibility is false for a tooltip', async () => { it('throws if handlesAccessibility is false for a tooltip', async () => {
const contentNode = document.createElement('div'); const contentNode = document.createElement('div');
document.body.appendChild(contentNode); document.body.appendChild(contentNode);

View file

@ -27,24 +27,25 @@ describe('OverlaysManager', () => {
mngr.teardown(); mngr.teardown();
}); });
it('provides .globalRootNode as a render target on first access', () => { it('provides global stylesheet for arrangement of body scroll', () => {
expect(document.body.querySelectorAll('.global-overlays').length).to.equal(0); expect(document.head.querySelectorAll('[data-global-overlays]').length).to.equal(1);
const rootNode = mngr.globalRootNode;
expect(document.body.querySelector('.global-overlays')).to.equal(rootNode);
}); });
it('provides .teardown() for cleanup', () => { 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; expect(document.head.querySelector('[data-global-overlays=""]')).not.be.undefined;
mngr.teardown(); mngr.teardown();
expect(document.body.querySelectorAll('.global-overlays').length).to.equal(0);
expect(document.head.querySelector('[data-global-overlays=""]')).be.null; expect(document.head.querySelector('[data-global-overlays=""]')).be.null;
// safety check via private access (do not use this) // safety check via private access (do not use this)
expect(OverlaysManager.__globalRootNode).to.be.undefined;
expect(OverlaysManager.__globalStyleNode).to.be.undefined; expect(OverlaysManager.__globalStyleNode).to.be.undefined;
// @ts-ignore [allow-private-in-test]
expect(mngr.__list).to.be.empty;
// @ts-ignore [allow-private-in-test]
expect(mngr.__shownList).to.be.empty;
// @ts-ignore [allow-private-in-test]
expect(mngr.__siblingsInert).to.be.false;
}); });
it('can add/remove controllers', () => { it('can add/remove controllers', () => {

View file

@ -18,25 +18,15 @@ describe('Global Positioning', () => {
overlays.teardown(); overlays.teardown();
}); });
describe('Basics', () => {
it('puts ".contentNode" in the body of the page', async () => {
const ctrl = new OverlayController({
...withDefaultGlobalConfig(),
});
await ctrl.show();
expect(overlays.globalRootNode.children.length).to.equal(1);
expect(overlays.globalRootNode.children[0]).to.have.trimmed.text('my content');
});
});
describe('viewportConfig', () => { describe('viewportConfig', () => {
it('positions the overlay in center by default', async () => { it('positions the overlay in center by default', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withDefaultGlobalConfig(), ...withDefaultGlobalConfig(),
}); });
await ctrl.show(); await ctrl.show();
expect(ctrl.content.classList.contains('global-overlays__overlay-container--center')).to.be expect(
.true; ctrl.contentWrapperNode.classList.contains('global-overlays__overlay-container--center'),
).to.be.true;
}); });
it('positions relative to the viewport ', async () => { it('positions relative to the viewport ', async () => {
@ -60,7 +50,7 @@ describe('Global Positioning', () => {
}); });
await ctrl.show(); await ctrl.show();
expect( expect(
ctrl.content.classList.contains( ctrl.contentWrapperNode.classList.contains(
`global-overlays__overlay-container--${viewportPlacement}`, `global-overlays__overlay-container--${viewportPlacement}`,
), ),
).to.be.true; ).to.be.true;

View file

@ -9,6 +9,39 @@ import { normalizeTransformStyle } from './utils-tests/local-positioning-helpers
* @typedef {import('../types/OverlayConfig.js').ViewportPlacement} ViewportPlacement * @typedef {import('../types/OverlayConfig.js').ViewportPlacement} ViewportPlacement
*/ */
/**
* Make sure we never use a native button element, since its dimensions
* are not cross browser consistent
* For debugging purposes, add colors...
* @param {{clickHandler?: function; width?: number; height?: number}} opts
*/
function createInvokerSync({ clickHandler = () => {}, width = 100, height = 20 }) {
return /** @type {HTMLDivElement} */ (
fixtureSync(html`
<div
role="button"
style="width: ${width}px; height: ${height}px; background: red; color: white;"
@click=${clickHandler}
>
Invoker
</div>
`)
);
}
/**
* @param {{ width?: number; height?: number }} opts
*/
function createContentSync({ width = 80, height = 20 }) {
return /** @type {HTMLDivElement} */ (
fixtureSync(html`
<div style="width: ${width}px; height: ${height}px; background: green; color: white;">
Content
</div>
`)
);
}
const withLocalTestConfig = () => const withLocalTestConfig = () =>
/** @type {OverlayConfig} */ ({ /** @type {OverlayConfig} */ ({
placementMode: 'local', placementMode: 'local',
@ -47,14 +80,13 @@ describe('Local Positioning', () => {
}); });
await fixture(html` await fixture(html`
<div style="position: fixed; left: 100px; top: 100px;"> <div style="position: fixed; left: 100px; top: 100px;">
${ctrl.invokerNode}${ctrl.content} ${ctrl.invokerNode}${ctrl.__wrappingDialogNode}
</div> </div>
`); `);
await ctrl.show(); await ctrl.show();
expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( expect(normalizeTransformStyle(ctrl.contentWrapperNode.style.transform)).to.equal(
'translate(-30px, -18px)', 'translate(70px, -508px)',
'translate should be -30px [to center = (80 - 20)/2*-1], -18px [to place above = 10 invoker height + 8 default padding]',
); );
}); });
@ -76,11 +108,11 @@ describe('Local Positioning', () => {
}); });
await fixture(html` await fixture(html`
<div style="position: fixed; left: 100px; top: 100px;"> <div style="position: fixed; left: 100px; top: 100px;">
${ctrl.invokerNode}${ctrl.content} ${ctrl.invokerNode}${ctrl.contentWrapperNode}
</div> </div>
`); `);
await ctrl.show(); await ctrl.show();
expect(ctrl.content.getAttribute('data-popper-placement')).to.equal('top'); expect(ctrl.contentWrapperNode.getAttribute('data-popper-placement')).to.equal('top');
}); });
it('positions to preferred place if placement is set and space is available', async () => { it('positions to preferred place if placement is set and space is available', async () => {
@ -104,12 +136,12 @@ describe('Local Positioning', () => {
}); });
await fixture(html` await fixture(html`
<div style="position: absolute; left: 120px; top: 50px;"> <div style="position: absolute; left: 120px; top: 50px;">
${ctrl.invokerNode}${ctrl.content} ${ctrl.invokerNode}${ctrl.contentWrapperNode}
</div> </div>
`); `);
await ctrl.show(); await ctrl.show();
expect(ctrl.content.getAttribute('data-popper-placement')).to.equal('left-start'); expect(ctrl.contentWrapperNode.getAttribute('data-popper-placement')).to.equal('left-start');
}); });
it('positions to different place if placement is set and no space is available', async () => { it('positions to different place if placement is set and no space is available', async () => {
@ -130,11 +162,13 @@ describe('Local Positioning', () => {
}, },
}); });
await fixture(html` await fixture(html`
<div style="position: absolute; top: 50px;">${ctrl.invokerNode}${ctrl.content}</div> <div style="position: absolute; top: 50px;">
${ctrl.invokerNode}${ctrl.contentWrapperNode}
</div>
`); `);
await ctrl.show(); await ctrl.show();
expect(ctrl.content.getAttribute('data-popper-placement')).to.equal('right'); expect(ctrl.contentWrapperNode.getAttribute('data-popper-placement')).to.equal('right');
}); });
it('allows the user to override default Popper modifiers', async () => { it('allows the user to override default Popper modifiers', async () => {
@ -164,7 +198,7 @@ describe('Local Positioning', () => {
}); });
await fixture(html` await fixture(html`
<div style="position: absolute; left: 100px; top: 50px;"> <div style="position: absolute; left: 100px; top: 50px;">
${ctrl.invokerNode}${ctrl.content} ${ctrl.invokerNode}${ctrl.contentWrapperNode}
</div> </div>
`); `);
@ -175,37 +209,30 @@ describe('Local Positioning', () => {
it('positions the Popper element correctly on show', async () => { it('positions the Popper element correctly on show', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode: /** @type {HTMLElement} */ ( contentNode: createContentSync({ width: 80, height: 20 }),
fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `) invokerNode: createInvokerSync({ clickHandler: () => ctrl.show(), width: 100, height: 20 }),
),
invokerNode: /** @type {HTMLElement} */ (
fixtureSync(html`
<div
role="button"
style="width: 100px; height: 20px;"
@click=${() => ctrl.show()}
></div>
`)
),
popperConfig: { popperConfig: {
placement: 'top', placement: 'top',
}, },
}); });
await fixture(html` await fixture(html`
<div style="position: absolute; top: 300px; left: 100px;"> <div style="position: absolute; top: 300px; left: 100px;">
${ctrl.invokerNode}${ctrl.content} ${ctrl.invokerNode}${ctrl.__wrappingDialogNode}
</div> </div>
`); `);
await ctrl.show(); await ctrl.show();
expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal(
'translate(10px, -28px)', // N.B. margin between invoker and content = 8px
'Popper positioning values', expect(normalizeTransformStyle(ctrl.contentWrapperNode.style.transform)).to.equal(
'translate(110px, -308px)',
'110 = (100 + (100-80)/2); -308= 300 + 8',
); );
await ctrl.hide(); await ctrl.hide();
await ctrl.show(); await ctrl.show();
expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( expect(normalizeTransformStyle(ctrl.contentWrapperNode.style.transform)).to.equal(
'translate(10px, -28px)', 'translate(110px, -308px)',
'Popper positioning values should be identical after hiding and showing', 'Popper positioning values should be identical after hiding and showing',
); );
}); });
@ -241,12 +268,12 @@ describe('Local Positioning', () => {
}); });
await fixture(html` await fixture(html`
<div style="position: absolute; top: 300px; left: 100px;"> <div style="position: absolute; top: 300px; left: 100px;">
${ctrl.invokerNode} ${ctrl.content} ${ctrl.invokerNode} ${ctrl.contentWrapperNode}
</div> </div>
`); `);
await ctrl.show(); await ctrl.show();
expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( expect(normalizeTransformStyle(ctrl.contentWrapperNode.style.transform)).to.equal(
'translate3d(10px, -30px, 0px)', 'translate3d(10px, -30px, 0px)',
'Popper positioning values', 'Popper positioning values',
); );
@ -267,7 +294,7 @@ describe('Local Positioning', () => {
}); });
await ctrl.show(); await ctrl.show();
expect(ctrl._popper.options.modifiers.offset.offset).to.equal('0, 20px'); expect(ctrl._popper.options.modifiers.offset.offset).to.equal('0, 20px');
expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( expect(normalizeTransformStyle(ctrl.contentWrapperNode.style.transform)).to.equal(
'translate3d(10px, -40px, 0px)', 'translate3d(10px, -40px, 0px)',
'Popper positioning Y value should be 10 less than previous, due to the added extra 10px offset', 'Popper positioning Y value should be 10 less than previous, due to the added extra 10px offset',
); );
@ -302,12 +329,12 @@ describe('Local Positioning', () => {
}); });
await fixture(html` await fixture(html`
<div style="position: absolute; top: 300px; left: 100px;"> <div style="position: absolute; top: 300px; left: 100px;">
${ctrl.invokerNode} ${ctrl.content} ${ctrl.invokerNode} ${ctrl.contentWrapperNode}
</div> </div>
`); `);
await ctrl.show(); await ctrl.show();
expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( expect(normalizeTransformStyle(ctrl.contentWrapperNode.style.transform)).to.equal(
'translate3d(10px, -30px, 0px)', 'translate3d(10px, -30px, 0px)',
'Popper positioning values', 'Popper positioning values',
); );
@ -317,7 +344,7 @@ describe('Local Positioning', () => {
modifiers: [{ name: 'offset', enabled: true, options: { offset: [0, 20] } }], modifiers: [{ name: 'offset', enabled: true, options: { offset: [0, 20] } }],
}, },
}); });
expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( expect(normalizeTransformStyle(ctrl.contentWrapperNode.style.transform)).to.equal(
'translate3d(10px, -40px, 0px)', 'translate3d(10px, -40px, 0px)',
'Popper positioning Y value should be 10 less than previous, due to the added extra 10px offset', 'Popper positioning Y value should be 10 less than previous, due to the added extra 10px offset',
); );
@ -333,7 +360,7 @@ describe('Local Positioning', () => {
invokerNode, invokerNode,
}); });
await ctrl.show(); await ctrl.show();
expect(ctrl.content.style.minWidth).to.equal('60px'); expect(ctrl.contentWrapperNode.style.minWidth).to.equal('60px');
}); });
it('can set the contentNode maxWidth as the invokerNode width', async () => { it('can set the contentNode maxWidth as the invokerNode width', async () => {
@ -346,7 +373,7 @@ describe('Local Positioning', () => {
invokerNode, invokerNode,
}); });
await ctrl.show(); await ctrl.show();
expect(ctrl.content.style.maxWidth).to.equal('60px'); expect(ctrl.contentWrapperNode.style.maxWidth).to.equal('60px');
}); });
it('can set the contentNode width as the invokerNode width', async () => { it('can set the contentNode width as the invokerNode width', async () => {
@ -359,7 +386,7 @@ describe('Local Positioning', () => {
invokerNode, invokerNode,
}); });
await ctrl.show(); await ctrl.show();
expect(ctrl.content.style.width).to.equal('60px'); expect(ctrl.contentWrapperNode.style.width).to.equal('60px');
}); });
}); });
}); });

View file

@ -17,7 +17,7 @@ export interface OverlayConfig {
elementToFocusAfterHide?: HTMLElement; elementToFocusAfterHide?: HTMLElement;
/** Whether it should have a backdrop (currently exclusive to globalOverlayController) */ /** Whether it should have a backdrop (currently exclusive to globalOverlayController) */
hasBackdrop?: boolean; hasBackdrop?: boolean;
/** Hides other overlays when mutiple are opened (currently exclusive to globalOverlayController) */ /** Hides other overlays when multiple are opened (currently exclusive to globalOverlayController) */
isBlocking?: boolean; isBlocking?: boolean;
/** Prevents scrolling body content when overlay opened (currently exclusive to globalOverlayController) */ /** Prevents scrolling body content when overlay opened (currently exclusive to globalOverlayController) */
preventsScroll?: boolean; preventsScroll?: boolean;
@ -50,6 +50,12 @@ export interface OverlayConfig {
popperConfig?: Partial<Options>; popperConfig?: Partial<Options>;
/** Viewport configuration. Will be used when placementMode is 'global' */ /** Viewport configuration. Will be used when placementMode is 'global' */
viewportConfig?: ViewportConfig; viewportConfig?: ViewportConfig;
/** Change the default of 9999 */
zIndex?: number;
/** render a div instead of dialog */
_noDialogEl?: Boolean;
} }
export type ViewportPlacement = export type ViewportPlacement =
@ -61,8 +67,7 @@ export type ViewportPlacement =
| 'bottom-right' | 'bottom-right'
| 'bottom' | 'bottom'
| 'bottom-left' | 'bottom-left'
| 'left' | 'left';
| 'center';
export interface ViewportConfig { export interface ViewportConfig {
placement: ViewportPlacement; placement: ViewportPlacement;

View file

@ -18,8 +18,8 @@ export interface DefineOverlayConfig {
export declare class OverlayHost { export declare class OverlayHost {
opened: Boolean; opened: Boolean;
get config(): OverlayConfig; get config(): Partial<OverlayConfig>;
set config(value: OverlayConfig); set config(value: Partial<OverlayConfig>);
open(): Promise<void>; open(): Promise<void>;
close(): Promise<void>; close(): Promise<void>;

View file

@ -15,8 +15,7 @@ import { SlotMixin } from '@lion/ui/core.js';
export class LionSelectInvoker extends SlotMixin(LionButton) { export class LionSelectInvoker extends SlotMixin(LionButton) {
static get styles() { static get styles() {
return [ return [
// TODO switch back to ...super.styles once fixed https://github.com/lit/lit.dev/pull/535 ...super.styles,
...LionButton.styles,
css` css`
:host { :host {
justify-content: space-between; justify-content: space-between;
@ -31,9 +30,9 @@ export class LionSelectInvoker extends SlotMixin(LionButton) {
]; ];
} }
/** @type {any} */
static get properties() { static get properties() {
return { return {
...super.properties,
selectedElement: { type: Object }, selectedElement: { type: Object },
hostElement: { type: Object }, hostElement: { type: Object },
readOnly: { type: Boolean, reflect: true, attribute: 'readonly' }, readOnly: { type: Boolean, reflect: true, attribute: 'readonly' },

View file

@ -408,27 +408,29 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
* @protected * @protected
*/ */
async _alignInvokerWidth() { async _alignInvokerWidth() {
if (this._overlayCtrl && this._overlayCtrl.content) { if (!this._overlayCtrl?.content) {
await this.updateComplete; return;
const initContentDisplay = this._overlayCtrl.content.style.display;
const initContentMinWidth = this._overlayCtrl.content.style.minWidth;
const initContentWidth = this._overlayCtrl.content.style.width;
this._overlayCtrl.content.style.display = '';
this._overlayCtrl.content.style.minWidth = 'auto';
this._overlayCtrl.content.style.width = 'auto';
const contentWidth = this._overlayCtrl.content.getBoundingClientRect().width;
/**
* TODO when inside an overlay the current solution doesn't work.
* Since that dialog is still hidden, open and close the select-rich
* doesn't have any effect so the contentWidth returns 0
*/
if (contentWidth > 0) {
this._invokerNode.style.width = `${contentWidth + this._arrowWidth}px`;
}
this._overlayCtrl.content.style.display = initContentDisplay;
this._overlayCtrl.content.style.minWidth = initContentMinWidth;
this._overlayCtrl.content.style.width = initContentWidth;
} }
await this.updateComplete;
const initContentDisplay = this._overlayCtrl.content.style.display;
const initContentMinWidth = this._overlayCtrl.contentWrapperNode.style.minWidth;
const initContentWidth = this._overlayCtrl.contentWrapperNode.style.width;
this._overlayCtrl.content.style.display = '';
this._overlayCtrl.contentWrapperNode.style.minWidth = 'auto';
this._overlayCtrl.contentWrapperNode.style.width = 'auto';
const contentWidth = this._overlayCtrl.contentWrapperNode.getBoundingClientRect().width;
/**
* TODO when inside an overlay the current solution doesn't work.
* Since that dialog is still hidden, open and close the select-rich
* doesn't have any effect so the contentWidth returns 0
*/
if (contentWidth > 0) {
this._invokerNode.style.width = `${contentWidth + this._arrowWidth}px`;
}
this._overlayCtrl.content.style.display = initContentDisplay;
this._overlayCtrl.contentWrapperNode.style.minWidth = initContentMinWidth;
this._overlayCtrl.contentWrapperNode.style.width = initContentWidth;
} }
/** /**

View file

@ -1,5 +1,4 @@
import { LitElement } from 'lit'; import { LitElement } from 'lit';
import { renderLitAsNode } from '@lion/ui/helpers.js';
import { LionOption } from '@lion/ui/listbox.js'; import { LionOption } from '@lion/ui/listbox.js';
import { getListboxMembers } from '@lion/ui/listbox-test-helpers.js'; import { getListboxMembers } from '@lion/ui/listbox-test-helpers.js';
import { OverlayController } from '@lion/ui/overlays.js'; import { OverlayController } from '@lion/ui/overlays.js';
@ -152,7 +151,7 @@ describe('lion-select-rich', () => {
}); });
it('updates the invoker when the selected element is the same but the modelValue was updated asynchronously', async () => { it('updates the invoker when the selected element is the same but the modelValue was updated asynchronously', async () => {
const tag = defineCE( const tagString = defineCE(
class LionCustomOption extends LionOption { class LionCustomOption extends LionOption {
render() { render() {
return html`${this.modelValue.value}`; return html`${this.modelValue.value}`;
@ -163,27 +162,26 @@ describe('lion-select-rich', () => {
} }
}, },
); );
const tagString = unsafeStatic(tag); const tag = unsafeStatic(tagString);
const firstOption = /** @type {LionOption} */ ( const firstOption = /** @type {LionOption} */ (
renderLitAsNode(html`<${tagString} checked .choiceValue=${10}></${tagString}>`) await _fixture(html`<${tag} checked .choiceValue=${10}></${tag}>`)
); );
const el = await fixture(html` const el = await fixture(html`
<lion-select-rich> <lion-select-rich>
${firstOption} ${firstOption}
<${tagString} .choiceValue=${20}></${tagString}> <${tag} .choiceValue=${20}></${tag}>
</lion-select-rich> </lion-select-rich>
`); `);
const { _invokerNode } = getSelectRichMembers(el); const { _invokerNode } = getSelectRichMembers(el);
const firstChild = /** @type {HTMLElement} */ ( const firstChild = /** @type {HTMLElement} */ (
/** @type {ShadowRoot} */ (_invokerNode.shadowRoot).firstElementChild /** @type {ShadowRoot} */ (_invokerNode.shadowRoot).firstElementChild
); );
expect(firstChild.textContent).to.equal('10'); expect(firstChild.textContent).to.equal('10');
firstOption.modelValue = { value: 30, checked: true }; firstOption.modelValue = { value: 30, checked: true };
await firstOption.updateComplete;
await el.updateComplete; await el.updateComplete;
expect(firstChild.textContent).to.equal('30'); expect(firstChild.textContent).to.equal('30');
}); });

View file

@ -1,21 +1,18 @@
import { css, LitElement } from 'lit'; import { css, LitElement } from 'lit';
/** /**
* @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig * @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig
*/ */
class DemoOverlayBackdrop extends LitElement { class DemoOverlayBackdrop extends LitElement {
static get styles() { static get styles() {
return css` return css`
:host { :host {
position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 1;
background-color: grey; background-color: grey;
opacity: 0.3; opacity: 0.3;
display: none;
} }
:host(.local-overlays__backdrop--visible) { :host(.local-overlays__backdrop--visible) {

View file

@ -2,7 +2,7 @@ import { html, LitElement } from 'lit';
import { OverlayMixin } from '@lion/ui/overlays.js'; import { OverlayMixin } from '@lion/ui/overlays.js';
/** /**
* @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig * @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig
*/ */
class DemoOverlaySystem extends OverlayMixin(LitElement) { class DemoOverlaySystem extends OverlayMixin(LitElement) {
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this

View file

@ -1,4 +1,5 @@
export { OverlayConfig } from '../../components/overlays/types/OverlayConfig.js'; export { OverlayConfig } from '../../components/overlays/types/OverlayConfig.js';
export { ViewportConfig } from '../../components/overlays/types/OverlayConfig.js';
export { DefineOverlayConfig } from '../../components/overlays/types/OverlayMixinTypes.js'; export { DefineOverlayConfig } from '../../components/overlays/types/OverlayMixinTypes.js';
export { OverlayHost } from '../../components/overlays/types/OverlayMixinTypes.js'; export { OverlayHost } from '../../components/overlays/types/OverlayMixinTypes.js';
export { ArrowHost } from '../../components/overlays/types/ArrowMixinTypes.js'; export { ArrowHost } from '../../components/overlays/types/ArrowMixinTypes.js';