fix: types for overlays, tooltip, button

This commit is contained in:
Thijs Louisse 2020-09-07 12:20:13 +02:00 committed by Thomas Allmer
parent 75107a4b6a
commit e42071d8dc
31 changed files with 1556 additions and 847 deletions

View file

@ -0,0 +1,7 @@
---
'@lion/button': patch
'@lion/overlays': patch
'@lion/tooltip': patch
---
Types for overlays, tooltip and button

View file

@ -7,8 +7,11 @@ import {
SlotMixin, SlotMixin,
} from '@lion/core'; } from '@lion/core';
const isKeyboardClickEvent = e => e.keyCode === 32 /* space */ || e.keyCode === 13; /* enter */ const isKeyboardClickEvent = (/** @type {KeyboardEvent} */ e) =>
const isSpaceKeyboardClickEvent = e => e.keyCode === 32 || e.key === 32; /* space */ e.keyCode === 32 /* space */ || e.keyCode === 13; /* enter */
const isSpaceKeyboardClickEvent = (/** @type {KeyboardEvent} */ e) =>
// @ts-expect-error
e.keyCode === 32 || e.key === 32; /* space */
export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement)) { export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement)) {
static get properties() { static get properties() {
@ -131,8 +134,11 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
]; ];
} }
/** @type {HTMLButtonElement} */
get _nativeButtonNode() { get _nativeButtonNode() {
return Array.from(this.children).find(child => child.slot === '_button'); return /** @type {HTMLButtonElement} */ (Array.from(this.children).find(
child => child.slot === '_button',
));
} }
get _form() { get _form() {
@ -143,12 +149,11 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
return { return {
...super.slots, ...super.slots,
_button: () => { _button: () => {
if (!this.constructor._button) { /** @type {HTMLButtonElement} */
this.constructor._button = document.createElement('button'); const buttonEl = document.createElement('button');
this.constructor._button.setAttribute('tabindex', '-1'); buttonEl.setAttribute('tabindex', '-1');
this.constructor._button.setAttribute('aria-hidden', 'true'); buttonEl.setAttribute('aria-hidden', 'true');
} return buttonEl;
return this.constructor._button.cloneNode();
}, },
}; };
} }
@ -176,6 +181,9 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
this.__teardownEvents(); this.__teardownEvents();
} }
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) { updated(changedProperties) {
super.updated(changedProperties); super.updated(changedProperties);
if (changedProperties.has('type')) { if (changedProperties.has('type')) {
@ -193,6 +201,7 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
* Delegate click, by flashing a native button as a direct child * Delegate click, by flashing a native button as a direct child
* of the form, and firing click on this button. This will fire the form submit * of the form, and firing click on this button. This will fire the form submit
* without side effects caused by the click bubbling back up to lion-button. * without side effects caused by the click bubbling back up to lion-button.
* @param {Event} e
*/ */
__clickDelegationHandler(e) { __clickDelegationHandler(e) {
if ((this.type === 'submit' || this.type === 'reset') && e.target === this) { if ((this.type === 'submit' || this.type === 'reset') && e.target === this) {
@ -235,6 +244,9 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
this.addEventListener('mouseup', mouseupHandler); this.addEventListener('mouseup', mouseupHandler);
} }
/**
* @param {KeyboardEvent} e
*/
__keydownHandler(e) { __keydownHandler(e) {
if (this.active || !isKeyboardClickEvent(e)) { if (this.active || !isKeyboardClickEvent(e)) {
if (isSpaceKeyboardClickEvent(e)) { if (isSpaceKeyboardClickEvent(e)) {
@ -248,6 +260,9 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
} }
this.active = true; this.active = true;
/**
* @param {KeyboardEvent} keyupEvent
*/
const keyupHandler = keyupEvent => { const keyupHandler = keyupEvent => {
if (isKeyboardClickEvent(keyupEvent)) { if (isKeyboardClickEvent(keyupEvent)) {
this.active = false; this.active = false;
@ -257,6 +272,9 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
document.addEventListener('keyup', keyupHandler, true); document.addEventListener('keyup', keyupHandler, true);
} }
/**
* @param {KeyboardEvent} e
*/
__keyupHandler(e) { __keyupHandler(e) {
if (isKeyboardClickEvent(e)) { if (isKeyboardClickEvent(e)) {
// Fixes IE11 double submit/click. Enter keypress somehow triggers the __keyUpHandler on the native <button> // Fixes IE11 double submit/click. Enter keypress somehow triggers the __keyUpHandler on the native <button>

View file

@ -3,6 +3,13 @@ import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
import sinon from 'sinon'; import sinon from 'sinon';
import '../lion-button.js'; import '../lion-button.js';
/**
* @typedef {import('@lion/button/src/LionButton').LionButton} LionButton
*/
/**
* @param {HTMLElement} el
*/
function getClickArea(el) { function getClickArea(el) {
if (el.shadowRoot) { if (el.shadowRoot) {
return el.shadowRoot.querySelector('.click-area'); return el.shadowRoot.querySelector('.click-area');
@ -12,13 +19,13 @@ function getClickArea(el) {
describe('lion-button', () => { describe('lion-button', () => {
it('behaves like native `button` in terms of a11y', async () => { it('behaves like native `button` in terms of a11y', async () => {
const el = await fixture(`<lion-button>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
expect(el.getAttribute('role')).to.equal('button'); expect(el.getAttribute('role')).to.equal('button');
expect(el.getAttribute('tabindex')).to.equal('0'); expect(el.getAttribute('tabindex')).to.equal('0');
}); });
it('has .type="submit" and type="submit" by default', async () => { it('has .type="submit" and type="submit" by default', async () => {
const el = await fixture(`<lion-button>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
expect(el.type).to.equal('submit'); expect(el.type).to.equal('submit');
expect(el.getAttribute('type')).to.be.equal('submit'); expect(el.getAttribute('type')).to.be.equal('submit');
expect(el._nativeButtonNode.type).to.equal('submit'); expect(el._nativeButtonNode.type).to.equal('submit');
@ -26,7 +33,9 @@ describe('lion-button', () => {
}); });
it('sync type down to the native button', async () => { it('sync type down to the native button', async () => {
const el = await fixture(`<lion-button type="button">foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(
`<lion-button type="button">foo</lion-button>`,
));
expect(el.type).to.equal('button'); expect(el.type).to.equal('button');
expect(el.getAttribute('type')).to.be.equal('button'); expect(el.getAttribute('type')).to.be.equal('button');
expect(el._nativeButtonNode.type).to.equal('button'); expect(el._nativeButtonNode.type).to.equal('button');
@ -34,18 +43,18 @@ describe('lion-button', () => {
}); });
it('hides the native button in the UI', async () => { it('hides the native button in the UI', async () => {
const el = await fixture(`<lion-button>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
expect(el._nativeButtonNode.getAttribute('tabindex')).to.equal('-1'); expect(el._nativeButtonNode.getAttribute('tabindex')).to.equal('-1');
expect(window.getComputedStyle(el._nativeButtonNode).clip).to.equal('rect(0px, 0px, 0px, 0px)'); expect(window.getComputedStyle(el._nativeButtonNode).clip).to.equal('rect(0px, 0px, 0px, 0px)');
}); });
it('is hidden when attribute hidden is true', async () => { it('is hidden when attribute hidden is true', async () => {
const el = await fixture(`<lion-button hidden>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(`<lion-button hidden>foo</lion-button>`));
expect(el).not.to.be.displayed; expect(el).not.to.be.displayed;
}); });
it('can be disabled imperatively', async () => { it('can be disabled imperatively', async () => {
const el = await fixture(`<lion-button disabled>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(`<lion-button disabled>foo</lion-button>`));
expect(el.getAttribute('tabindex')).to.equal('-1'); expect(el.getAttribute('tabindex')).to.equal('-1');
expect(el.getAttribute('aria-disabled')).to.equal('true'); expect(el.getAttribute('aria-disabled')).to.equal('true');
@ -64,7 +73,7 @@ describe('lion-button', () => {
describe('active', () => { describe('active', () => {
it('updates "active" attribute on host when mousedown/mouseup on button', async () => { it('updates "active" attribute on host when mousedown/mouseup on button', async () => {
const el = await fixture(`<lion-button>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
el.dispatchEvent(new Event('mousedown')); el.dispatchEvent(new Event('mousedown'));
expect(el.active).to.be.true; expect(el.active).to.be.true;
@ -78,7 +87,7 @@ describe('lion-button', () => {
}); });
it('updates "active" attribute on host when mousedown on button and mouseup anywhere else', async () => { it('updates "active" attribute on host when mousedown on button and mouseup anywhere else', async () => {
const el = await fixture(`<lion-button>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
el.dispatchEvent(new Event('mousedown')); el.dispatchEvent(new Event('mousedown'));
expect(el.active).to.be.true; expect(el.active).to.be.true;
@ -92,7 +101,7 @@ describe('lion-button', () => {
}); });
it('updates "active" attribute on host when space keydown/keyup on button', async () => { it('updates "active" attribute on host when space keydown/keyup on button', async () => {
const el = await fixture(`<lion-button>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
el.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 32 })); el.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 32 }));
expect(el.active).to.be.true; expect(el.active).to.be.true;
@ -106,7 +115,7 @@ describe('lion-button', () => {
}); });
it('updates "active" attribute on host when space keydown on button and space keyup anywhere else', async () => { it('updates "active" attribute on host when space keydown on button and space keyup anywhere else', async () => {
const el = await fixture(`<lion-button>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
el.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 32 })); el.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 32 }));
expect(el.active).to.be.true; expect(el.active).to.be.true;
@ -120,7 +129,7 @@ describe('lion-button', () => {
}); });
it('updates "active" attribute on host when enter keydown/keyup on button', async () => { it('updates "active" attribute on host when enter keydown/keyup on button', async () => {
const el = await fixture(`<lion-button>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
el.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 13 })); el.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 13 }));
expect(el.active).to.be.true; expect(el.active).to.be.true;
@ -134,7 +143,7 @@ describe('lion-button', () => {
}); });
it('updates "active" attribute on host when enter keydown on button and space keyup anywhere else', async () => { it('updates "active" attribute on host when enter keydown on button and space keyup anywhere else', async () => {
const el = await fixture(`<lion-button>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
el.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 13 })); el.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 13 }));
expect(el.active).to.be.true; expect(el.active).to.be.true;
@ -150,7 +159,7 @@ describe('lion-button', () => {
describe('a11y', () => { describe('a11y', () => {
it('has a role="button" by default', async () => { it('has a role="button" by default', async () => {
const el = await fixture(`<lion-button>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
expect(el.getAttribute('role')).to.equal('button'); expect(el.getAttribute('role')).to.equal('button');
el.role = 'foo'; el.role = 'foo';
await el.updateComplete; await el.updateComplete;
@ -158,17 +167,21 @@ describe('lion-button', () => {
}); });
it('does not override user provided role', async () => { it('does not override user provided role', async () => {
const el = await fixture(`<lion-button role="foo">foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(
`<lion-button role="foo">foo</lion-button>`,
));
expect(el.getAttribute('role')).to.equal('foo'); expect(el.getAttribute('role')).to.equal('foo');
}); });
it('has a tabindex="0" by default', async () => { it('has a tabindex="0" by default', async () => {
const el = await fixture(`<lion-button>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
expect(el.getAttribute('tabindex')).to.equal('0'); expect(el.getAttribute('tabindex')).to.equal('0');
}); });
it('has a tabindex="-1" when disabled', async () => { it('has a tabindex="-1" when disabled', async () => {
const el = await fixture(`<lion-button disabled>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(
`<lion-button disabled>foo</lion-button>`,
));
expect(el.getAttribute('tabindex')).to.equal('-1'); expect(el.getAttribute('tabindex')).to.equal('-1');
el.disabled = false; el.disabled = false;
await el.updateComplete; await el.updateComplete;
@ -179,12 +192,16 @@ describe('lion-button', () => {
}); });
it('does not override user provided tabindex', async () => { it('does not override user provided tabindex', async () => {
const el = await fixture(`<lion-button tabindex="5">foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(
`<lion-button tabindex="5">foo</lion-button>`,
));
expect(el.getAttribute('tabindex')).to.equal('5'); expect(el.getAttribute('tabindex')).to.equal('5');
}); });
it('disabled does not override user provided tabindex', async () => { it('disabled does not override user provided tabindex', async () => {
const el = await fixture(`<lion-button tabindex="5" disabled>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(
`<lion-button tabindex="5" disabled>foo</lion-button>`,
));
expect(el.getAttribute('tabindex')).to.equal('-1'); expect(el.getAttribute('tabindex')).to.equal('-1');
el.disabled = false; el.disabled = false;
await el.updateComplete; await el.updateComplete;
@ -193,7 +210,7 @@ describe('lion-button', () => {
it('has an aria-labelledby and wrapper element in IE11', async () => { it('has an aria-labelledby and wrapper element in IE11', async () => {
const browserDetectionStub = sinon.stub(browserDetection, 'isIE11').value(true); const browserDetectionStub = sinon.stub(browserDetection, 'isIE11').value(true);
const el = await fixture(`<lion-button>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
expect(el.hasAttribute('aria-labelledby')).to.be.true; expect(el.hasAttribute('aria-labelledby')).to.be.true;
const wrapperId = el.getAttribute('aria-labelledby'); const wrapperId = el.getAttribute('aria-labelledby');
expect(el.shadowRoot.querySelector(`#${wrapperId}`)).to.exist; expect(el.shadowRoot.querySelector(`#${wrapperId}`)).to.exist;
@ -204,18 +221,20 @@ describe('lion-button', () => {
}); });
it('has a native button node with aria-hidden set to true', async () => { it('has a native button node with aria-hidden set to true', async () => {
const el = await fixture('<lion-button></lion-button>'); const el = /** @type {LionButton} */ (await fixture('<lion-button></lion-button>'));
expect(el._nativeButtonNode.getAttribute('aria-hidden')).to.equal('true'); expect(el._nativeButtonNode.getAttribute('aria-hidden')).to.equal('true');
}); });
it('is accessible', async () => { it('is accessible', async () => {
const el = await fixture(`<lion-button>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(`<lion-button>foo</lion-button>`));
await expect(el).to.be.accessible(); await expect(el).to.be.accessible();
}); });
it('is accessible when disabled', async () => { it('is accessible when disabled', async () => {
const el = await fixture(`<lion-button disabled>foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(
`<lion-button disabled>foo</lion-button>`,
));
await expect(el).to.be.accessible({ ignoredRules: ['color-contrast'] }); await expect(el).to.be.accessible({ ignoredRules: ['color-contrast'] });
}); });
}); });
@ -223,7 +242,7 @@ describe('lion-button', () => {
describe('form integration', () => { describe('form integration', () => {
describe('with submit event', () => { describe('with submit event', () => {
it('behaves like native `button` when clicked', async () => { it('behaves like native `button` when clicked', async () => {
const formSubmitSpy = sinon.spy(e => e.preventDefault()); const formSubmitSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const form = await fixture(html` const form = await fixture(html`
<form @submit="${formSubmitSpy}"> <form @submit="${formSubmitSpy}">
<lion-button type="submit">foo</lion-button> <lion-button type="submit">foo</lion-button>
@ -237,7 +256,7 @@ describe('lion-button', () => {
}); });
it('behaves like native `button` when interacted with keyboard space', async () => { it('behaves like native `button` when interacted with keyboard space', async () => {
const formSubmitSpy = sinon.spy(e => e.preventDefault()); const formSubmitSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const form = await fixture(html` const form = await fixture(html`
<form @submit="${formSubmitSpy}"> <form @submit="${formSubmitSpy}">
<lion-button type="submit">foo</lion-button> <lion-button type="submit">foo</lion-button>
@ -253,7 +272,7 @@ describe('lion-button', () => {
}); });
it('behaves like native `button` when interacted with keyboard enter', async () => { it('behaves like native `button` when interacted with keyboard enter', async () => {
const formSubmitSpy = sinon.spy(e => e.preventDefault()); const formSubmitSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const form = await fixture(html` const form = await fixture(html`
<form @submit="${formSubmitSpy}"> <form @submit="${formSubmitSpy}">
<lion-button type="submit">foo</lion-button> <lion-button type="submit">foo</lion-button>
@ -294,7 +313,7 @@ describe('lion-button', () => {
// input "enter" keypress mock doesn't seem to work right now, but should be tested in the future (maybe with Selenium) // input "enter" keypress mock doesn't seem to work right now, but should be tested in the future (maybe with Selenium)
it.skip('works with implicit form submission on-enter inside an input', async () => { it.skip('works with implicit form submission on-enter inside an input', async () => {
const formSubmitSpy = sinon.spy(e => e.preventDefault()); const formSubmitSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const form = await fixture(html` const form = await fixture(html`
<form @submit="${formSubmitSpy}"> <form @submit="${formSubmitSpy}">
<input name="foo" /> <input name="foo" />
@ -315,7 +334,7 @@ describe('lion-button', () => {
describe('with click event', () => { describe('with click event', () => {
it('behaves like native `button` when clicked', async () => { it('behaves like native `button` when clicked', async () => {
const formButtonClickedSpy = sinon.spy(); const formButtonClickedSpy = /** @type {EventListener} */ (sinon.spy());
const form = await fixture(html` const form = await fixture(html`
<form @submit=${ev => ev.preventDefault()}> <form @submit=${ev => ev.preventDefault()}>
<lion-button @click="${formButtonClickedSpy}" type="submit">foo</lion-button> <lion-button @click="${formButtonClickedSpy}" type="submit">foo</lion-button>
@ -329,7 +348,7 @@ describe('lion-button', () => {
}); });
it('behaves like native `button` when interacted with keyboard space', async () => { it('behaves like native `button` when interacted with keyboard space', async () => {
const formButtonClickedSpy = sinon.spy(); const formButtonClickedSpy = /** @type {EventListener} */ (sinon.spy());
const form = await fixture(html` const form = await fixture(html`
<form @submit=${ev => ev.preventDefault()}> <form @submit=${ev => ev.preventDefault()}>
<lion-button @click="${formButtonClickedSpy}" type="submit">foo</lion-button> <lion-button @click="${formButtonClickedSpy}" type="submit">foo</lion-button>
@ -346,7 +365,7 @@ describe('lion-button', () => {
}); });
it('behaves like native `button` when interacted with keyboard enter', async () => { it('behaves like native `button` when interacted with keyboard enter', async () => {
const formButtonClickedSpy = sinon.spy(); const formButtonClickedSpy = /** @type {EventListener} */ (sinon.spy());
const form = await fixture(html` const form = await fixture(html`
<form @submit=${ev => ev.preventDefault()}> <form @submit=${ev => ev.preventDefault()}>
<lion-button @click="${formButtonClickedSpy}" type="submit">foo</lion-button> <lion-button @click="${formButtonClickedSpy}" type="submit">foo</lion-button>
@ -364,7 +383,7 @@ describe('lion-button', () => {
// input "enter" keypress mock doesn't seem to work right now, but should be tested in the future (maybe with Selenium) // input "enter" keypress mock doesn't seem to work right now, but should be tested in the future (maybe with Selenium)
it.skip('works with implicit form submission on-enter inside an input', async () => { it.skip('works with implicit form submission on-enter inside an input', async () => {
const formButtonClickedSpy = sinon.spy(); const formButtonClickedSpy = /** @type {EventListener} */ (sinon.spy());
const form = await fixture(html` const form = await fixture(html`
<form @submit=${ev => ev.preventDefault()}> <form @submit=${ev => ev.preventDefault()}>
<input name="foo" /> <input name="foo" />
@ -386,8 +405,10 @@ describe('lion-button', () => {
describe('click event', () => { describe('click event', () => {
it('is fired once', async () => { it('is fired once', async () => {
const clickSpy = sinon.spy(); const clickSpy = /** @type {EventListener} */ (sinon.spy());
const el = await fixture(html`<lion-button @click="${clickSpy}">foo</lion-button>`); const el = /** @type {LionButton} */ (await fixture(
html`<lion-button @click="${clickSpy}">foo</lion-button>`,
));
getClickArea(el).click(); getClickArea(el).click();
@ -414,8 +435,10 @@ describe('lion-button', () => {
let lionButtonEvent; let lionButtonEvent;
before(async () => { before(async () => {
const nativeButtonEl = await fixture('<button>foo</button>'); const nativeButtonEl = /** @type {LionButton} */ (await fixture('<button>foo</button>'));
const lionButtonEl = await fixture('<lion-button>foo</lion-button>'); const lionButtonEl = /** @type {LionButton} */ (await fixture(
'<lion-button>foo</lion-button>',
));
nativeButtonEvent = await prepareClickEvent(nativeButtonEl); nativeButtonEvent = await prepareClickEvent(nativeButtonEl);
lionButtonEvent = await prepareClickEvent(lionButtonEl); lionButtonEvent = await prepareClickEvent(lionButtonEl);
}); });
@ -436,7 +459,7 @@ describe('lion-button', () => {
}); });
it('has host in the target property', async () => { it('has host in the target property', async () => {
const el = await fixture('<lion-button>foo</lion-button>'); const el = /** @type {LionButton} */ (await fixture('<lion-button>foo</lion-button>'));
const event = await prepareClickEvent(el); const event = await prepareClickEvent(el);
expect(event.target).to.equal(el); expect(event.target).to.equal(el);
}); });

View file

@ -1,19 +1,28 @@
import { html, LitElement } from '@lion/core'; import { html, LitElement } from '@lion/core';
import { OverlayMixin } from '../src/OverlayMixin.js'; import { OverlayMixin } from '../src/OverlayMixin.js';
/**
* @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig
*/
class DemoOverlaySystem extends OverlayMixin(LitElement) { class DemoOverlaySystem extends OverlayMixin(LitElement) {
constructor() {
super();
this.__toggle = this.__toggle.bind(this);
}
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
_defineOverlayConfig() { _defineOverlayConfig() {
return { return /** @type {OverlayConfig} */ ({
placementMode: 'global', placementMode: 'global',
}; });
}
__toggle() {
this.opened = !this.opened;
} }
_setupOpenCloseListeners() { _setupOpenCloseListeners() {
super._setupOpenCloseListeners(); super._setupOpenCloseListeners();
this.__toggle = () => {
this.opened = !this.opened;
};
if (this._overlayInvokerNode) { if (this._overlayInvokerNode) {
this._overlayInvokerNode.addEventListener('click', this.__toggle); this._overlayInvokerNode.addEventListener('click', this.__toggle);

View file

@ -1,6 +1,11 @@
import { directive } from '@lion/core'; import { directive } from '@lion/core';
const cache = new WeakMap(); /**
* @typedef {import('lit-html').PropertyPart} PropertyPart
*/
/** @type {WeakSet<Element>} */
const cache = new WeakSet();
/** /**
* @desc Allows to have references to different parts of your lit template. * @desc Allows to have references to different parts of your lit template.
@ -21,11 +26,11 @@ const cache = new WeakMap();
* *
* @param {object} refObj will be used to store reference to attribute names like #myElement * @param {object} refObj will be used to store reference to attribute names like #myElement
*/ */
export const ref = directive(refObj => part => { export const ref = directive(refObj => (/** @type {PropertyPart} */ part) => {
if (cache.has(part.committer.element)) { if (cache.has(part.committer.element)) {
return; return;
} }
cache.set(part.committer.element); cache.add(part.committer.element);
const attrName = part.committer.name; const attrName = part.committer.name;
const key = attrName.replace(/^#/, ''); const key = attrName.replace(/^#/, '');
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign

View file

@ -1,14 +1,29 @@
import '@lion/core/src/differentKeyEventNamesShimIE.js'; import '@lion/core/src/differentKeyEventNamesShimIE.js';
import { EventTargetShim } from '@lion/core';
// eslint-disable-next-line import/no-cycle
import { overlays } from './overlays.js'; import { overlays } from './overlays.js';
import { containFocus } from './utils/contain-focus.js'; import { containFocus } from './utils/contain-focus.js';
import './utils/typedef.js';
/**
* @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig
* @typedef {import('../types/OverlayConfig').ViewportConfig} ViewportConfig
* @typedef {import('popper.js').default} Popper
* @typedef {import('popper.js').PopperOptions} PopperOptions
* @typedef {{ default: Popper }} PopperModule
* @typedef {'setup'|'init'|'teardown'|'before-show'|'show'|'hide'|'add'|'remove'} OverlayPhase
*/
/**
* @returns {Promise<PopperModule>}
*/
async function preloadPopper() { async function preloadPopper() {
return import('popper.js/dist/esm/popper.min.js'); // @ts-ignore
return /** @type {Promise<PopperModule>} */ (import('popper.js/dist/esm/popper.min.js'));
} }
const GLOBAL_OVERLAYS_CONTAINER_CLASS = 'global-overlays__overlay-container'; const GLOBAL_OVERLAYS_CONTAINER_CLASS = 'global-overlays__overlay-container';
const GLOBAL_OVERLAYS_CLASS = 'global-overlays__overlay'; const GLOBAL_OVERLAYS_CLASS = 'global-overlays__overlay';
// @ts-expect-error CSS not yet typed
const supportsCSSTypedObject = window.CSS && CSS.number; const supportsCSSTypedObject = window.CSS && CSS.number;
/** /**
@ -69,26 +84,25 @@ const supportsCSSTypedObject = window.CSS && CSS.number;
* In case of a local overlay or a responsive overlay switching from placementMode, one should * In case of a local overlay or a responsive overlay switching from placementMode, one should
* always configure as if it were a local overlay. * always configure as if it were a local overlay.
*/ */
export class OverlayController extends EventTargetShim {
export class OverlayController {
/** /**
* @constructor * @constructor
* @param {OverlayConfig} config initial config. Will be remembered as shared config * @param {OverlayConfig} config initial config. Will be remembered as shared config
* when `.updateConfig()` is called. * when `.updateConfig()` is called.
*/ */
constructor(config = {}, manager = overlays) { constructor(config = {}, manager = overlays) {
this.__fakeExtendsEventTarget(); super();
this.manager = manager; this.manager = manager;
this.__sharedConfig = config; this.__sharedConfig = config;
/** @type {OverlayConfig} */ /** @type {OverlayConfig} */
this._defaultConfig = { this._defaultConfig = {
placementMode: null, placementMode: undefined,
contentNode: config.contentNode, contentNode: config.contentNode,
contentWrapperNode: config.contentWrapperNode, contentWrapperNode: config.contentWrapperNode,
invokerNode: config.invokerNode, invokerNode: config.invokerNode,
backdropNode: config.backdropNode, backdropNode: config.backdropNode,
referenceNode: null, referenceNode: undefined,
elementToFocusAfterHide: config.invokerNode, elementToFocusAfterHide: config.invokerNode,
inheritsReferenceWidth: 'none', inheritsReferenceWidth: 'none',
hasBackdrop: false, hasBackdrop: false,
@ -100,7 +114,7 @@ export class OverlayController {
hidesOnOutsideClick: false, hidesOnOutsideClick: false,
isTooltip: false, isTooltip: false,
invokerRelation: 'description', invokerRelation: 'description',
handlesUserInteraction: false, // handlesUserInteraction: false,
handlesAccessibility: false, handlesAccessibility: false,
popperConfig: { popperConfig: {
placement: 'top', placement: 'top',
@ -146,18 +160,209 @@ export class OverlayController {
this.updateConfig(config); this.updateConfig(config);
this.__hasActiveTrapsKeyboardFocus = false; this.__hasActiveTrapsKeyboardFocus = false;
this.__hasActiveBackdrop = true; this.__hasActiveBackdrop = true;
this.__escKeyHandler = this.__escKeyHandler.bind(this);
} }
/**
* The invokerNode
* @type {HTMLElement | undefined}
*/
get invoker() { get invoker() {
return this.invokerNode; return this.invokerNode;
} }
/**
* The contentWrapperNode
* @type {HTMLElement}
*/
get content() { get content() {
return this._contentWrapperNode; return /** @type {HTMLElement} */ (this.contentWrapperNode);
} }
/** /**
* @desc Usually the parent node of contentWrapperNode that either exists locally or globally. * Determines the connection point in DOM (body vs next to invoker).
* @type {'global' | 'local' | undefined}
*/
get placementMode() {
return this.config?.placementMode;
}
/**
* The interactive element (usually a button) invoking the dialog or tooltip
* @type {HTMLElement | undefined}
*/
get invokerNode() {
return this.config?.invokerNode;
}
/**
* The element that is used to position the overlay content relative to. Usually,
* this is the same element as invokerNode. Should only be provided when invokerNode should not
* be positioned against.
* @type {HTMLElement}
*/
get referenceNode() {
return /** @type {HTMLElement} */ (this.config?.referenceNode);
}
/**
* The most important element: the overlay itself
* @type {HTMLElement}
*/
get contentNode() {
return /** @type {HTMLElement} */ (this.config?.contentNode);
}
/**
* The wrapper element of contentNode, used to supply inline positioning styles. When a Popper
* arrow is needed, it acts as parent of the arrow node. Will be automatically created for global
* and non projected contentNodes. Required when used in shadow dom mode or when Popper arrow is
* supplied. Essential for allowing webcomponents to style their projected contentNodes
* @type {HTMLElement}
*/
get contentWrapperNode() {
return /** @type {HTMLElement} */ (this.__contentWrapperNode ||
this.config?.contentWrapperNode);
}
/**
* The element that is placed behin the contentNode. When not provided and `hasBackdrop` is true,
* a backdropNode will be automatically created
* @type {HTMLElement}
*/
get backdropNode() {
return /** @type {HTMLElement} */ (this.__backdropNode || this.config?.backdropNode);
}
/**
* The element that should be called `.focus()` on after dialog closes
* @type {HTMLElement}
*/
get elementToFocusAfterHide() {
return /** @type {HTMLElement} */ (this.__elementToFocusAfterHide ||
this.config?.elementToFocusAfterHide);
}
/**
* Whether it should have a backdrop (currently exclusive to globalOverlayController)
* @type {boolean}
*/
get hasBackdrop() {
return /** @type {boolean} */ (this.config?.hasBackdrop);
}
/**
* Hides other overlays when mutiple are opened (currently exclusive to globalOverlayController)
* @type {boolean}
*/
get isBlocking() {
return /** @type {boolean} */ (this.config?.isBlocking);
}
/**
* Hides other overlays when mutiple are opened (currently exclusive to globalOverlayController)
* @type {boolean}
*/
get preventsScroll() {
return /** @type {boolean} */ (this.config?.preventsScroll);
}
/**
* Rotates tab, implicitly set when 'isModal'
* @type {boolean}
*/
get trapsKeyboardFocus() {
return /** @type {boolean} */ (this.config?.trapsKeyboardFocus);
}
/**
* Hides the overlay when pressing [ esc ]
* @type {boolean}
*/
get hidesOnEsc() {
return /** @type {boolean} */ (this.config?.hidesOnEsc);
}
/**
* Hides the overlay when clicking next to it, exluding invoker
* @type {boolean}
*/
get hidesOnOutsideClick() {
return /** @type {boolean} */ (this.config?.hidesOnOutsideClick);
}
/**
* Hides the overlay when pressing esc, even when contentNode has no focus
* @type {boolean}
*/
get hidesOnOutsideEsc() {
return /** @type {boolean} */ (this.config?.hidesOnOutsideEsc);
}
/**
* Will align contentNode with referenceNode (invokerNode by default) for local overlays.
* Usually needed for dropdowns. 'max' will prevent contentNode from exceeding width of
* referenceNode, 'min' guarantees that contentNode will be at least as wide as referenceNode.
* 'full' will make sure that the invoker width always is the same.
* @type {'max' | 'full' | 'min' | 'none' | undefined }
*/
get inheritsReferenceWidth() {
return this.config?.inheritsReferenceWidth;
}
/**
* For non `isTooltip`:
* - sets aria-expanded="true/false" and aria-haspopup="true" on invokerNode
* - sets aria-controls on invokerNode
* - returns focus to invokerNode on hide
* - sets focus to overlay content(?)
*
* For `isTooltip`:
* - sets role="tooltip" and aria-labelledby/aria-describedby on the content
*
* @type {boolean}
*/
get handlesAccessibility() {
return /** @type {boolean} */ (this.config?.handlesAccessibility);
}
/**
* Has a totally different interaction- and accessibility pattern from all other overlays.
* Will behave as role="tooltip" element instead of a role="dialog" element
* @type {boolean}
*/
get isTooltip() {
return /** @type {boolean} */ (this.config?.isTooltip);
}
/**
* By default, the tooltip content is a 'description' for the invoker (uses aria-describedby).
* Setting this property to 'label' makes the content function as a label (via aria-labelledby)
* @type {'label' | 'description'| undefined}
*/
get invokerRelation() {
return this.config?.invokerRelation;
}
/**
* Popper configuration. Will be used when placementMode is 'local'
* @type {PopperOptions}
*/
get popperConfig() {
return /** @type {PopperOptions} */ (this.config?.popperConfig);
}
/**
* Viewport configuration. Will be used when placementMode is 'global'
* @type {ViewportConfig}
*/
get 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) * 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 * we need to know where we should reappend contentWrapperNode (or contentNode in case it's
* projected). * projected).
@ -170,35 +375,42 @@ export class OverlayController {
} }
/** config [l2] or [l4] */ /** config [l2] or [l4] */
if (this.__isContentNodeProjected) { if (this.__isContentNodeProjected) {
return this.__originalContentParent.getRootNode().host; // @ts-expect-error
return this.__originalContentParent?.getRootNode().host;
} }
/** config [l1] or [l3] */ /** config [l1] or [l3] */
return this.__originalContentParent; 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} * @type {HTMLElement | undefined}
*/ */
get _referenceNode() { get _referenceNode() {
return this.referenceNode || this.invokerNode; return this.referenceNode || this.invokerNode;
} }
/**
* @param {string} value
*/
set elevation(value) { set elevation(value) {
if (this._contentWrapperNode) { if (this.contentWrapperNode) {
this._contentWrapperNode.style.zIndex = value; this.contentWrapperNode.style.zIndex = value;
} }
if (this.backdropNode) { if (this.backdropNode) {
this.backdropNode.style.zIndex = value; this.backdropNode.style.zIndex = value;
} }
} }
/**
* @type {number}
*/
get elevation() { get elevation() {
return this._contentWrapperNode.zIndex; return Number(this.contentWrapperNode?.style.zIndex);
} }
/** /**
* @desc Allows to dynamically change the overlay configuration. Needed in case the * Allows to dynamically change the overlay configuration. Needed in case the
* presentation of the overlay changes depending on screen size. * presentation of the overlay changes depending on screen size.
* Note that this method is the only allowed way to update a configuration of an * Note that this method is the only allowed way to update a configuration of an
* OverlayController instance. * OverlayController instance.
@ -208,6 +420,7 @@ export class OverlayController {
// Teardown all previous configs // Teardown all previous configs
this._handleFeatures({ phase: 'teardown' }); this._handleFeatures({ phase: 'teardown' });
/** @type {OverlayConfig} */
this.__prevConfig = this.config || {}; this.__prevConfig = this.config || {};
this.config = { this.config = {
@ -229,10 +442,15 @@ export class OverlayController {
}; };
this.__validateConfiguration(this.config); this.__validateConfiguration(this.config);
Object.assign(this, this.config); // TODO: remove this, so we only have the getters (no setters)
// Object.assign(this, this.config);
this._init({ cfgToAdd }); this._init({ cfgToAdd });
this.__elementToFocusAfterHide = undefined;
} }
/**
* @param {OverlayConfig} newConfig
*/
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
__validateConfiguration(newConfig) { __validateConfiguration(newConfig) {
if (!newConfig.placementMode) { if (!newConfig.placementMode) {
@ -268,14 +486,18 @@ export class OverlayController {
// } // }
} }
/**
* @param {{ cfgToAdd: OverlayConfig }} options
*/
_init({ cfgToAdd }) { _init({ cfgToAdd }) {
this.__initContentWrapperNode({ cfgToAdd }); this.__initContentWrapperNode({ cfgToAdd });
this.__initConnectionTarget(); this.__initConnectionTarget();
if (this.placementMode === 'local') { if (this.placementMode === 'local') {
// Lazily load Popper if not done yet // Lazily load Popper if not done yet
if (!this.constructor.popperModule) { if (!OverlayController.popperModule) {
this.constructor.popperModule = preloadPopper(); // @ts-expect-error
OverlayController.popperModule = preloadPopper();
} }
} }
this._handleFeatures({ phase: 'init' }); this._handleFeatures({ phase: 'init' });
@ -283,9 +505,10 @@ export class OverlayController {
__initConnectionTarget() { __initConnectionTarget() {
// Now, add our node to the right place in dom (renderTarget) // Now, add our node to the right place in dom (renderTarget)
if (this._contentWrapperNode !== this.__prevConfig._contentWrapperNode) { if (this.contentWrapperNode !== this.__prevConfig?.contentWrapperNode) {
if (this.config.placementMode === 'global' || !this.__isContentNodeProjected) { if (this.config?.placementMode === 'global' || !this.__isContentNodeProjected) {
this._contentWrapperNode.appendChild(this.contentNode); /** @type {HTMLElement} */
(this.contentWrapperNode).appendChild(this.contentNode);
} }
} }
@ -297,30 +520,31 @@ export class OverlayController {
// We add the contentNode in its slot, so that it will be projected by contentWrapperNode // We add the contentNode in its slot, so that it will be projected by contentWrapperNode
this._renderTarget.appendChild(this.contentNode); this._renderTarget.appendChild(this.contentNode);
} else { } else {
const isInsideRenderTarget = this._renderTarget === this._contentWrapperNode.parentNode; const isInsideRenderTarget = this._renderTarget === this.contentWrapperNode.parentNode;
const nodeContainsTarget = this._contentWrapperNode.contains(this._renderTarget); const nodeContainsTarget = this.contentWrapperNode.contains(this._renderTarget);
if (!isInsideRenderTarget && !nodeContainsTarget) { if (!isInsideRenderTarget && !nodeContainsTarget) {
// contentWrapperNode becomes the direct (non projected) parent of contentNode // contentWrapperNode becomes the direct (non projected) parent of contentNode
this._renderTarget.appendChild(this._contentWrapperNode); this._renderTarget.appendChild(this.contentWrapperNode);
} }
} }
} }
/** /**
* @desc Cleanup ._contentWrapperNode. We do this, because creating a fresh wrapper * Cleanup ._contentWrapperNode. We do this, because creating a fresh wrapper
* can lead to problems with event listeners... * can lead to problems with event listeners...
* @param {{ cfgToAdd: OverlayConfig }} options
*/ */
__initContentWrapperNode({ cfgToAdd }) { __initContentWrapperNode({ cfgToAdd }) {
if (this.config.contentWrapperNode && this.placementMode === 'local') { if (this.config?.contentWrapperNode && this.placementMode === 'local') {
/** config [l2],[l3],[l4] */ /** config [l2],[l3],[l4] */
this._contentWrapperNode = this.config.contentWrapperNode; this.__contentWrapperNode = this.config.contentWrapperNode;
} else { } else {
/** config [l1],[g1] */ /** config [l1],[g1] */
this._contentWrapperNode = document.createElement('div'); this.__contentWrapperNode = document.createElement('div');
} }
this._contentWrapperNode.style.cssText = null; this.contentWrapperNode.style.cssText = '';
this._contentWrapperNode.style.display = 'none'; this.contentWrapperNode.style.display = 'none';
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
@ -328,19 +552,21 @@ export class OverlayController {
this.contentNode.style.position = 'static'; this.contentNode.style.position = 'static';
} }
if (this.__isContentNodeProjected && this._contentWrapperNode.isConnected) { if (this.__isContentNodeProjected && this.contentWrapperNode.isConnected) {
// We need to keep track of the original local context. // We need to keep track of the original local context.
/** config [l2], [l4] */ /** config [l2], [l4] */
this.__originalContentParent = this._contentWrapperNode.parentNode; this.__originalContentParent = /** @type {HTMLElement} */ (this.contentWrapperNode
.parentNode);
} else if (cfgToAdd.contentNode && cfgToAdd.contentNode.isConnected) { } else if (cfgToAdd.contentNode && cfgToAdd.contentNode.isConnected) {
// We need to keep track of the original local context. // We need to keep track of the original local context.
/** config [l1], [l3], [g1] */ /** config [l1], [l3], [g1] */
this.__originalContentParent = this.contentNode.parentNode; this.__originalContentParent = /** @type {HTMLElement} */ (this.contentNode?.parentNode);
} }
} }
/** /**
* @desc Display local overlays on top of elements with no z-index that appear later in the DOM * Display local overlays on top of elements with no z-index that appear later in the DOM
* @param {{ phase: OverlayPhase }} config
*/ */
_handleZIndex({ phase }) { _handleZIndex({ phase }) {
if (this.placementMode !== 'local') { if (this.placementMode !== 'local') {
@ -350,11 +576,14 @@ export class OverlayController {
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.contentWrapperNode.style.zIndex = '1';
} }
} }
} }
/**
* @param {{ phase: OverlayPhase }} config
*/
__setupTeardownAccessibility({ phase }) { __setupTeardownAccessibility({ phase }) {
if (phase === 'init') { if (phase === 'init') {
this.__storeOriginalAttrs(this.contentNode, ['role', 'id']); this.__storeOriginalAttrs(this.contentNode, ['role', 'id']);
@ -380,7 +609,7 @@ export class OverlayController {
this.contentNode.setAttribute('role', 'tooltip'); this.contentNode.setAttribute('role', 'tooltip');
} else { } else {
if (this.invokerNode) { if (this.invokerNode) {
this.invokerNode.setAttribute('aria-expanded', this.isShown); this.invokerNode.setAttribute('aria-expanded', `${this.isShown}`);
} }
if (!this.contentNode.getAttribute('role')) { if (!this.contentNode.getAttribute('role')) {
this.contentNode.setAttribute('role', 'dialog'); this.contentNode.setAttribute('role', 'dialog');
@ -391,6 +620,10 @@ export class OverlayController {
} }
} }
/**
* @param {HTMLElement} node
* @param {string[]} attrs
*/
__storeOriginalAttrs(node, attrs) { __storeOriginalAttrs(node, attrs) {
const attrMap = {}; const attrMap = {};
attrs.forEach(attrName => { attrs.forEach(attrName => {
@ -413,7 +646,7 @@ export class OverlayController {
} }
get isShown() { get isShown() {
return Boolean(this._contentWrapperNode.style.display !== 'none'); return Boolean(this.contentWrapperNode.style.display !== 'none');
} }
/** /**
@ -431,30 +664,33 @@ export class OverlayController {
} }
if (this.isShown) { if (this.isShown) {
this._showResolve(); /** @type {function} */ (this._showResolve)();
return; return;
} }
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 = ''; this.contentWrapperNode.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' });
await this._handlePosition({ phase: 'show' }); await this._handlePosition({ phase: 'show' });
this.elementToFocusAfterHide = elementToFocusAfterHide; this.__elementToFocusAfterHide = elementToFocusAfterHide;
this.dispatchEvent(new Event('show')); this.dispatchEvent(new Event('show'));
} }
this._showResolve(); /** @type {function} */ (this._showResolve)();
} }
/**
* @param {{ phase: OverlayPhase }} config
*/
async _handlePosition({ phase }) { async _handlePosition({ phase }) {
if (this.placementMode === 'global') { if (this.placementMode === 'global') {
const addOrRemove = phase === 'show' ? 'add' : 'remove'; const addOrRemove = phase === 'show' ? 'add' : 'remove';
const placementClass = `${GLOBAL_OVERLAYS_CONTAINER_CLASS}--${this.viewportConfig.placement}`; const placementClass = `${GLOBAL_OVERLAYS_CONTAINER_CLASS}--${this.viewportConfig.placement}`;
this._contentWrapperNode.classList[addOrRemove](GLOBAL_OVERLAYS_CONTAINER_CLASS); this.contentWrapperNode.classList[addOrRemove](GLOBAL_OVERLAYS_CONTAINER_CLASS);
this._contentWrapperNode.classList[addOrRemove](placementClass); this.contentWrapperNode.classList[addOrRemove](placementClass);
this.contentNode.classList[addOrRemove](GLOBAL_OVERLAYS_CLASS); this.contentNode.classList[addOrRemove](GLOBAL_OVERLAYS_CLASS);
} else if (this.placementMode === 'local' && phase === 'show') { } else if (this.placementMode === 'local' && phase === 'show') {
/** /**
@ -465,10 +701,13 @@ export class OverlayController {
* This is however necessary for initial placement. * This is however necessary for initial placement.
*/ */
await this.__createPopperInstance(); await this.__createPopperInstance();
this._popper.update(); /** @type {Popper} */ (this._popper).update();
} }
} }
/**
* @param {{ phase: OverlayPhase }} config
*/
_keepBodySize({ phase }) { _keepBodySize({ phase }) {
switch (phase) { switch (phase) {
case 'before-show': case 'before-show':
@ -479,7 +718,9 @@ export class OverlayController {
break; break;
case 'show': { case 'show': {
if (supportsCSSTypedObject) { if (supportsCSSTypedObject) {
// @ts-expect-error types attributeStyleMap not available yet
this.__bodyMarginRight = document.body.computedStyleMap().get('margin-right').value; this.__bodyMarginRight = document.body.computedStyleMap().get('margin-right').value;
// @ts-expect-error types computedStyleMap not available yet
this.__bodyMarginBottom = document.body.computedStyleMap().get('margin-bottom').value; this.__bodyMarginBottom = document.body.computedStyleMap().get('margin-bottom').value;
} else if (window.getComputedStyle) { } else if (window.getComputedStyle) {
const bodyStyle = window.getComputedStyle(document.body); const bodyStyle = window.getComputedStyle(document.body);
@ -488,12 +729,16 @@ export class OverlayController {
this.__bodyMarginBottom = parseInt(bodyStyle.getPropertyValue('margin-bottom'), 10); this.__bodyMarginBottom = parseInt(bodyStyle.getPropertyValue('margin-bottom'), 10);
} }
} }
const scrollbarWidth = document.body.clientWidth - this.__bodyClientWidth; const scrollbarWidth =
const scrollbarHeight = document.body.clientHeight - this.__bodyClientHeight; document.body.clientWidth - /** @type {number} */ (this.__bodyClientWidth);
const scrollbarHeight =
document.body.clientHeight - /** @type {number} */ (this.__bodyClientHeight);
const newMarginRight = this.__bodyMarginRight + scrollbarWidth; const newMarginRight = this.__bodyMarginRight + scrollbarWidth;
const newMarginBottom = this.__bodyMarginBottom + scrollbarHeight; const newMarginBottom = this.__bodyMarginBottom + scrollbarHeight;
if (supportsCSSTypedObject) { if (supportsCSSTypedObject) {
// @ts-expect-error types attributeStyleMap + CSS.px not available yet
document.body.attributeStyleMap.set('margin-right', CSS.px(newMarginRight)); document.body.attributeStyleMap.set('margin-right', CSS.px(newMarginRight));
// @ts-expect-error types attributeStyleMap + CSS.px not available yet
document.body.attributeStyleMap.set('margin-bottom', CSS.px(newMarginBottom)); document.body.attributeStyleMap.set('margin-bottom', CSS.px(newMarginBottom));
} else { } else {
document.body.style.marginRight = `${newMarginRight}px`; document.body.style.marginRight = `${newMarginRight}px`;
@ -503,7 +748,9 @@ export class OverlayController {
} }
case 'hide': case 'hide':
if (supportsCSSTypedObject) { if (supportsCSSTypedObject) {
// @ts-expect-error types attributeStyleMap + CSS.px not available yet
document.body.attributeStyleMap.set('margin-right', CSS.px(this.__bodyMarginRight)); document.body.attributeStyleMap.set('margin-right', CSS.px(this.__bodyMarginRight));
// @ts-expect-error types attributeStyleMap + CSS.px not available yet
document.body.attributeStyleMap.set('margin-bottom', CSS.px(this.__bodyMarginBottom)); document.body.attributeStyleMap.set('margin-bottom', CSS.px(this.__bodyMarginBottom));
} else { } else {
document.body.style.marginRight = `${this.__bodyMarginRight}px`; document.body.style.marginRight = `${this.__bodyMarginRight}px`;
@ -528,7 +775,7 @@ export class OverlayController {
} }
if (!this.isShown) { if (!this.isShown) {
this._hideResolve(); /** @type {function} */ (this._hideResolve)();
return; return;
} }
@ -536,26 +783,27 @@ export class OverlayController {
this.dispatchEvent(event); this.dispatchEvent(event);
if (!event.defaultPrevented) { if (!event.defaultPrevented) {
// await this.transitionHide({ backdropNode: this.backdropNode, contentNode: this.contentNode }); // await this.transitionHide({ backdropNode: this.backdropNode, contentNode: this.contentNode });
this._contentWrapperNode.style.display = 'none'; this.contentWrapperNode.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'));
this._restoreFocus(); this._restoreFocus();
} }
this._hideResolve(); /** @type {function} */ (this._hideResolve)();
} }
/**
* @param {{backdropNode:HTMLElement, contentNode:HTMLElement}} config
*/
// 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({ backdropNode, contentNode }) {} async transitionHide(config) {}
_restoreFocus() { _restoreFocus() {
// 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 (this._contentWrapperNode.activeElement) {
if (this.elementToFocusAfterHide) { if (this.elementToFocusAfterHide) {
this.elementToFocusAfterHide.focus(); this.elementToFocusAfterHide.focus();
} }
// }
} }
async toggle() { async toggle() {
@ -563,10 +811,8 @@ export class OverlayController {
} }
/** /**
* @desc All features are handled here. Every feature is set up on show * All features are handled here.
* and torn * @param {{ phase: OverlayPhase }} config
* @param {object} config
* @param {'init'|'show'|'hide'|'teardown'} config.phase
*/ */
_handleFeatures({ phase }) { _handleFeatures({ phase }) {
this._handleZIndex({ phase }); this._handleZIndex({ phase });
@ -600,6 +846,9 @@ export class OverlayController {
} }
} }
/**
* @param {{ phase: OverlayPhase }} config
*/
_handlePreventsScroll({ phase }) { _handlePreventsScroll({ phase }) {
switch (phase) { switch (phase) {
case 'show': case 'show':
@ -612,6 +861,9 @@ export class OverlayController {
} }
} }
/**
* @param {{ phase: OverlayPhase }} config
*/
_handleBlocking({ phase }) { _handleBlocking({ phase }) {
switch (phase) { switch (phase) {
case 'show': case 'show':
@ -629,20 +881,23 @@ export class OverlayController {
} }
/** /**
* @desc Sets up backdrop on the given overlay. If there was a backdrop on another element * Sets up backdrop on the given overlay. If there was a backdrop on another element
* it is removed. Otherwise this is the first time displaying a backdrop, so a fade-in * it is removed. Otherwise this is the first time displaying a backdrop, so a fade-in
* animation is played. * animation is played.
* @param {{ animation?: boolean, phase: OverlayPhase }} config
*/ */
_handleBackdrop({ animation = true, phase }) { _handleBackdrop({ animation = true, phase }) {
if (this.placementMode === 'local') { if (this.placementMode === 'local') {
switch (phase) { switch (phase) {
case 'init': case 'init':
if (!this.backdropNode) { if (!this.backdropNode) {
this.backdropNode = document.createElement('div'); this.__backdropNode = document.createElement('div');
this.backdropNode.classList.add('local-overlays__backdrop'); /** @type {HTMLElement} */
(this.backdropNode).classList.add('local-overlays__backdrop');
} }
this.backdropNode.slot = '_overlay-shadow-outlet'; this.backdropNode.slot = '_overlay-shadow-outlet';
this.contentNode.parentNode.insertBefore(this.backdropNode, this.contentNode); /** @type {HTMLElement} */
(this.contentNode.parentNode).insertBefore(this.backdropNode, this.contentNode);
break; break;
case 'show': case 'show':
this.__hasActiveBackdrop = true; this.__hasActiveBackdrop = true;
@ -658,61 +913,65 @@ export class OverlayController {
return; return;
} }
this.backdropNode.parentNode.removeChild(this.backdropNode); this.backdropNode.parentNode.removeChild(this.backdropNode);
this.__backdropNode = undefined;
break; break;
/* no default */ /* no default */
} }
return; return;
} }
const { backdropNode } = this;
switch (phase) { switch (phase) {
case 'init': case 'init':
this.backdropNode = document.createElement('div'); this.__backdropNode = document.createElement('div');
this.backdropNode.classList.add('global-overlays__backdrop'); this.backdropNode.classList.add('global-overlays__backdrop');
this._contentWrapperNode.parentElement.insertBefore( /** @type {HTMLElement} */
(this.contentWrapperNode.parentElement).insertBefore(
this.backdropNode, this.backdropNode,
this._contentWrapperNode, this.contentWrapperNode,
); );
break; break;
case 'show': case 'show':
backdropNode.classList.add('global-overlays__backdrop--visible'); this.backdropNode.classList.add('global-overlays__backdrop--visible');
if (animation === true) { if (animation === true) {
backdropNode.classList.add('global-overlays__backdrop--fade-in'); this.backdropNode.classList.add('global-overlays__backdrop--fade-in');
} }
this.__hasActiveBackdrop = true; this.__hasActiveBackdrop = true;
break; break;
case 'hide': case 'hide':
if (!backdropNode) { if (!this.backdropNode) {
return; return;
} }
backdropNode.classList.remove('global-overlays__backdrop--fade-in'); this.backdropNode.classList.remove('global-overlays__backdrop--fade-in');
if (animation) { if (animation) {
/** @type {(ev:AnimationEvent) => void} */
let afterFadeOut; let afterFadeOut;
backdropNode.classList.add('global-overlays__backdrop--fade-out'); this.backdropNode.classList.add('global-overlays__backdrop--fade-out');
this.__backDropAnimation = new Promise(resolve => { this.__backDropAnimation = new Promise(resolve => {
afterFadeOut = () => { afterFadeOut = () => {
backdropNode.classList.remove('global-overlays__backdrop--fade-out'); this.backdropNode.classList.remove('global-overlays__backdrop--fade-out');
backdropNode.classList.remove('global-overlays__backdrop--visible'); this.backdropNode.classList.remove('global-overlays__backdrop--visible');
backdropNode.removeEventListener('animationend', afterFadeOut); this.backdropNode.removeEventListener('animationend', afterFadeOut);
resolve(); resolve();
}; };
}); });
backdropNode.addEventListener('animationend', afterFadeOut); // @ts-expect-error
this.backdropNode.addEventListener('animationend', afterFadeOut);
} else { } else {
backdropNode.classList.remove('global-overlays__backdrop--visible'); this.backdropNode.classList.remove('global-overlays__backdrop--visible');
} }
this.__hasActiveBackdrop = false; this.__hasActiveBackdrop = false;
break; break;
case 'teardown': case 'teardown':
if (!backdropNode || !backdropNode.parentNode) { if (!this.backdropNode || !this.backdropNode.parentNode) {
return; return;
} }
if (animation && this.__backDropAnimation) { if (animation && this.__backDropAnimation) {
this.__backDropAnimation.then(() => { this.__backDropAnimation.then(() => {
backdropNode.parentNode.removeChild(backdropNode); /** @type {HTMLElement} */
(this.backdropNode.parentNode).removeChild(this.backdropNode);
}); });
} else { } else {
backdropNode.parentNode.removeChild(backdropNode); this.backdropNode.parentNode.removeChild(this.backdropNode);
} }
break; break;
/* no default */ /* no default */
@ -723,6 +982,9 @@ export class OverlayController {
return this.__hasActiveTrapsKeyboardFocus; return this.__hasActiveTrapsKeyboardFocus;
} }
/**
* @param {{ phase: OverlayPhase }} config
*/
_handleTrapsKeyboardFocus({ phase }) { _handleTrapsKeyboardFocus({ phase }) {
if (phase === 'show') { if (phase === 'show') {
this.enableTrapsKeyboardFocus(); this.enableTrapsKeyboardFocus();
@ -759,9 +1021,15 @@ export class OverlayController {
} }
} }
__escKeyHandler(/** @type {KeyboardEvent} */ ev) {
return ev.key === 'Escape' && this.hide();
}
/**
* @param {{ phase: OverlayPhase }} config
*/
_handleHidesOnEsc({ phase }) { _handleHidesOnEsc({ phase }) {
if (phase === 'show') { if (phase === 'show') {
this.__escKeyHandler = ev => ev.key === 'Escape' && this.hide();
this.contentNode.addEventListener('keyup', this.__escKeyHandler); this.contentNode.addEventListener('keyup', this.__escKeyHandler);
if (this.invokerNode) { if (this.invokerNode) {
this.invokerNode.addEventListener('keyup', this.__escKeyHandler); this.invokerNode.addEventListener('keyup', this.__escKeyHandler);
@ -774,9 +1042,13 @@ export class OverlayController {
} }
} }
/**
* @param {{ phase: OverlayPhase }} config
*/
_handleHidesOnOutsideEsc({ phase }) { _handleHidesOnOutsideEsc({ phase }) {
if (phase === 'show') { if (phase === 'show') {
this.__escKeyHandler = ev => ev.key === 'Escape' && this.hide(); this.__escKeyHandler = (/** @type {KeyboardEvent} */ ev) =>
ev.key === 'Escape' && this.hide();
document.addEventListener('keyup', this.__escKeyHandler); document.addEventListener('keyup', this.__escKeyHandler);
} else if (phase === 'hide') { } else if (phase === 'hide') {
document.removeEventListener('keyup', this.__escKeyHandler); document.removeEventListener('keyup', this.__escKeyHandler);
@ -791,19 +1063,22 @@ export class OverlayController {
const referenceWidth = `${this._referenceNode.clientWidth}px`; const referenceWidth = `${this._referenceNode.clientWidth}px`;
switch (this.inheritsReferenceWidth) { switch (this.inheritsReferenceWidth) {
case 'max': case 'max':
this._contentWrapperNode.style.maxWidth = referenceWidth; this.contentWrapperNode.style.maxWidth = referenceWidth;
break; break;
case 'full': case 'full':
this._contentWrapperNode.style.width = referenceWidth; this.contentWrapperNode.style.width = referenceWidth;
break; break;
case 'min': case 'min':
this._contentWrapperNode.style.minWidth = referenceWidth; this.contentWrapperNode.style.minWidth = referenceWidth;
this._contentWrapperNode.style.width = 'auto'; this.contentWrapperNode.style.width = 'auto';
break; break;
/* no default */ /* no default */
} }
} }
/**
* @param {{ phase: OverlayPhase }} config
*/
_handleHidesOnOutsideClick({ phase }) { _handleHidesOnOutsideClick({ phase }) {
const addOrRemoveListener = phase === 'show' ? 'addEventListener' : 'removeEventListener'; const addOrRemoveListener = phase === 'show' ? 'addEventListener' : 'removeEventListener';
@ -811,6 +1086,7 @@ export class OverlayController {
let wasClickInside = false; let wasClickInside = false;
let wasIndirectSynchronousClick = false; let wasIndirectSynchronousClick = false;
// Handle on capture phase and remember till the next task that there was an inside click // Handle on capture phase and remember till the next task that there was an inside click
/** @type {EventListenerOrEventListenerObject} */
this.__preventCloseOutsideClick = () => { this.__preventCloseOutsideClick = () => {
if (wasClickInside) { if (wasClickInside) {
// This occurs when a synchronous new click is triggered from a previous click. // This occurs when a synchronous new click is triggered from a previous click.
@ -828,6 +1104,7 @@ export class OverlayController {
}); });
}; };
// handle on capture phase and schedule the hide if needed // handle on capture phase and schedule the hide if needed
/** @type {EventListenerOrEventListenerObject} */
this.__onCaptureHtmlClick = () => { this.__onCaptureHtmlClick = () => {
setTimeout(() => { setTimeout(() => {
if (wasClickInside === false && !wasIndirectSynchronousClick) { if (wasClickInside === false && !wasIndirectSynchronousClick) {
@ -837,19 +1114,37 @@ export class OverlayController {
}; };
} }
this._contentWrapperNode[addOrRemoveListener]('click', this.__preventCloseOutsideClick, true); this.contentWrapperNode[addOrRemoveListener](
'click',
/** @type {EventListenerOrEventListenerObject} */
(this.__preventCloseOutsideClick),
true,
);
if (this.invokerNode) { if (this.invokerNode) {
this.invokerNode[addOrRemoveListener]('click', this.__preventCloseOutsideClick, true); this.invokerNode[addOrRemoveListener](
'click',
/** @type {EventListenerOrEventListenerObject} */
(this.__preventCloseOutsideClick),
true,
);
} }
document.documentElement[addOrRemoveListener]('click', this.__onCaptureHtmlClick, true); document.documentElement[addOrRemoveListener](
'click',
/** @type {EventListenerOrEventListenerObject} */
(this.__onCaptureHtmlClick),
true,
);
} }
/**
* @param {{ phase: OverlayPhase }} config
*/
_handleAccessibility({ phase }) { _handleAccessibility({ phase }) {
if (phase === 'init' || phase === 'teardown') { if (phase === 'init' || phase === 'teardown') {
this.__setupTeardownAccessibility({ phase }); this.__setupTeardownAccessibility({ phase });
} }
if (this.invokerNode && !this.isTooltip) { if (this.invokerNode && !this.isTooltip) {
this.invokerNode.setAttribute('aria-expanded', phase === 'show'); this.invokerNode.setAttribute('aria-expanded', `${phase === 'show'}`);
} }
} }
@ -857,7 +1152,7 @@ export class OverlayController {
this._handleFeatures({ phase: 'teardown' }); this._handleFeatures({ phase: 'teardown' });
if (this.placementMode === 'global' && this.__isContentNodeProjected) { if (this.placementMode === 'global' && this.__isContentNodeProjected) {
this.__originalContentParent.appendChild(this.contentNode); /** @type {HTMLElement} */ (this.__originalContentParent).appendChild(this.contentNode);
} }
// Remove the content node wrapper from the global rootnode // Remove the content node wrapper from the global rootnode
@ -867,28 +1162,25 @@ export class OverlayController {
_teardownContentWrapperNode() { _teardownContentWrapperNode() {
if ( if (
this.placementMode === 'global' && this.placementMode === 'global' &&
this._contentWrapperNode && this.contentWrapperNode &&
this._contentWrapperNode.parentNode this.contentWrapperNode.parentNode
) { ) {
this._contentWrapperNode.parentNode.removeChild(this._contentWrapperNode); this.contentWrapperNode.parentNode.removeChild(this.contentWrapperNode);
} }
} }
async __createPopperInstance() { async __createPopperInstance() {
if (this._popper) { if (this._popper) {
this._popper.destroy(); this._popper.destroy();
this._popper = null; this._popper = undefined;
} }
const { default: Popper } = await this.constructor.popperModule; // @ts-expect-error
this._popper = new Popper(this._referenceNode, this._contentWrapperNode, { const { default: Popper } = await OverlayController.popperModule;
...this.config.popperConfig, /** @type {Popper} */
}); this._popper = new Popper(this._referenceNode, this.contentWrapperNode, {
} ...this.config?.popperConfig,
__fakeExtendsEventTarget() {
const delegate = document.createDocumentFragment();
['addEventListener', 'dispatchEvent', 'removeEventListener'].forEach(funcName => {
this[funcName] = (...args) => delegate[funcName](...args);
}); });
} }
} }
/** @type {PopperModule | undefined} */
OverlayController.popperModule = undefined;

View file

@ -2,255 +2,298 @@ import { dedupeMixin } from '@lion/core';
import { OverlayController } from './OverlayController.js'; import { OverlayController } from './OverlayController.js';
/** /**
* @type {Function()} * @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig
* @polymerMixinOverlayMixin * @typedef {import('../types/OverlayMixinTypes').DefineOverlayConfig} DefineOverlayConfig
* @mixinFunction * @typedef {import('../types/OverlayMixinTypes').OverlayHost} OverlayHost
* @typedef {import('../types/OverlayMixinTypes').OverlayMixin} OverlayMixin
*/ */
export const OverlayMixin = dedupeMixin(
superclass => /**
// eslint-disable-next-line no-shadow * @type {OverlayMixin}
class OverlayMixin extends superclass { */
static get properties() { export const OverlayMixinImplementation = superclass =>
return { class OverlayMixin extends superclass {
opened: { static get properties() {
type: Boolean, return {
reflect: true, opened: {
type: Boolean,
reflect: true,
},
};
}
constructor() {
super();
this.opened = false;
this.__needsSetup = true;
/** @type {OverlayConfig} */
this.config = {};
}
get config() {
return /** @type {OverlayConfig} */ (this.__config);
}
/** @param {OverlayConfig} value */
set config(value) {
if (this._overlayCtrl) {
this._overlayCtrl.updateConfig(value);
}
this.__config = value;
}
/**
* @override
* @param {string} name
* @param {any} oldValue
*/
requestUpdateInternal(name, oldValue) {
super.requestUpdateInternal(name, oldValue);
if (name === 'opened') {
this.dispatchEvent(new Event('opened-changed'));
}
}
/**
* @overridable method `_defineOverlay`
* @desc returns an instance of a (dynamic) overlay controller
* In case overriding _defineOverlayConfig is not enough
* @param {DefineOverlayConfig} config
* @returns {OverlayController}
*/
// eslint-disable-next-line
_defineOverlay({ contentNode, invokerNode, backdropNode, contentWrapperNode }) {
return new OverlayController({
contentNode,
invokerNode,
backdropNode,
contentWrapperNode,
...this._defineOverlayConfig(), // wc provided in the class as defaults
...this.config, // user provided (e.g. in template)
popperConfig: {
...(this._defineOverlayConfig().popperConfig || {}),
...(this.config.popperConfig || {}),
modifiers: {
...((this._defineOverlayConfig().popperConfig &&
this._defineOverlayConfig()?.popperConfig?.modifiers) ||
{}),
...((this.config.popperConfig && this.config.popperConfig.modifiers) || {}),
}, },
}; },
} });
}
constructor() { /**
super(); * @overridable method `_defineOverlay`
this.opened = false; * @desc returns an object with default configuration options for your overlay component.
this.__needsSetup = true; * This is generally speaking easier to override than _defineOverlay method entirely.
this.config = {}; * @returns {OverlayConfig}
} */
// eslint-disable-next-line
_defineOverlayConfig() {
return {
placementMode: 'local',
};
}
get config() { /**
return this.__config; * @param {{ has: (arg0: string) => any; }} changedProperties
} */
updated(changedProperties) {
super.updated(changedProperties);
set config(value) { if (changedProperties.has('opened') && this._overlayCtrl && !this.__blockSyncToOverlayCtrl) {
if (this._overlayCtrl) {
this._overlayCtrl.updateConfig(value);
}
this.__config = value;
}
requestUpdateInternal(name, oldValue) {
super.requestUpdateInternal(name, oldValue);
if (name === 'opened') {
this.dispatchEvent(new Event('opened-changed'));
}
}
/**
* @overridable method `_defineOverlay`
* @desc returns an instance of a (dynamic) overlay controller
* In case overriding _defineOverlayConfig is not enough
* @returns {OverlayController}
*/
// eslint-disable-next-line
_defineOverlay({ contentNode, invokerNode, backdropNode, contentWrapperNode }) {
return new OverlayController({
contentNode,
invokerNode,
backdropNode,
contentWrapperNode,
...this._defineOverlayConfig(), // wc provided in the class as defaults
...this.config, // user provided (e.g. in template)
popperConfig: {
...(this._defineOverlayConfig().popperConfig || {}),
...(this.config.popperConfig || {}),
modifiers: {
...((this._defineOverlayConfig().popperConfig &&
this._defineOverlayConfig().popperConfig.modifiers) ||
{}),
...((this.config.popperConfig && this.config.popperConfig.modifiers) || {}),
},
},
});
}
/**
* @overridable method `_defineOverlay`
* @desc returns an object with default configuration options for your overlay component.
* This is generally speaking easier to override than _defineOverlay method entirely.
* @returns {OverlayController}
*/
// eslint-disable-next-line
_defineOverlayConfig() {
return {
placementMode: 'local',
};
}
updated(changedProperties) {
super.updated(changedProperties);
if (
changedProperties.has('opened') &&
this._overlayCtrl &&
!this.__blockSyncToOverlayCtrl
) {
this.__syncToOverlayController();
}
}
/**
* @overridable
* @desc use this method to setup your open and close event listeners
* For example, set a click event listener on _overlayInvokerNode to set opened to true
*/
// eslint-disable-next-line class-methods-use-this
_setupOpenCloseListeners() {
this.__closeEventInContentNodeHandler = ev => {
ev.stopPropagation();
this._overlayCtrl.hide();
};
if (this._overlayContentNode) {
this._overlayContentNode.addEventListener(
'close-overlay',
this.__closeEventInContentNodeHandler,
);
}
}
/**
* @overridable
* @desc use this method to tear down your event listeners
*/
// eslint-disable-next-line class-methods-use-this
_teardownOpenCloseListeners() {
if (this._overlayContentNode) {
this._overlayContentNode.removeEventListener(
'close-overlay',
this.__closeEventInContentNodeHandler,
);
}
}
connectedCallback() {
super.connectedCallback();
// we do a setup after every connectedCallback as firstUpdated will only be called once
this.__needsSetup = true;
this.updateComplete.then(() => {
if (this.__needsSetup) {
this._setupOverlayCtrl();
}
this.__needsSetup = false;
});
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
if (this._overlayCtrl) {
this._teardownOverlayCtrl();
}
}
get _overlayInvokerNode() {
return Array.from(this.children).find(child => child.slot === 'invoker');
}
get _overlayBackdropNode() {
return Array.from(this.children).find(child => child.slot === 'backdrop');
}
get _overlayContentNode() {
if (!this._cachedOverlayContentNode) {
this._cachedOverlayContentNode = Array.from(this.children).find(
child => child.slot === 'content',
);
}
return this._cachedOverlayContentNode;
}
get _overlayContentWrapperNode() {
return this.shadowRoot.querySelector('#overlay-content-node-wrapper');
}
_setupOverlayCtrl() {
this._overlayCtrl = this._defineOverlay({
contentNode: this._overlayContentNode,
contentWrapperNode: this._overlayContentWrapperNode,
invokerNode: this._overlayInvokerNode,
backdropNode: this._overlayBackdropNode,
});
this.__syncToOverlayController(); this.__syncToOverlayController();
this.__setupSyncFromOverlayController();
this._setupOpenCloseListeners();
} }
}
_teardownOverlayCtrl() { /**
this._teardownOpenCloseListeners(); * @overridable
this.__teardownSyncFromOverlayController(); * @desc use this method to setup your open and close event listeners
this._overlayCtrl.teardown(); * For example, set a click event listener on _overlayInvokerNode to set opened to true
*/
// eslint-disable-next-line class-methods-use-this
_setupOpenCloseListeners() {
/**
* @param {{ stopPropagation: () => void; }} ev
*/
this.__closeEventInContentNodeHandler = ev => {
ev.stopPropagation();
/** @type {OverlayController} */ (this._overlayCtrl).hide();
};
if (this._overlayContentNode) {
this._overlayContentNode.addEventListener(
'close-overlay',
this.__closeEventInContentNodeHandler,
);
} }
}
/**
* @overridable
* @desc use this method to tear down your event listeners
*/
// eslint-disable-next-line class-methods-use-this
_teardownOpenCloseListeners() {
if (this._overlayContentNode) {
this._overlayContentNode.removeEventListener(
'close-overlay',
this.__closeEventInContentNodeHandler,
);
}
}
connectedCallback() {
super.connectedCallback();
// we do a setup after every connectedCallback as firstUpdated will only be called once
this.__needsSetup = true;
this.updateComplete.then(() => {
if (this.__needsSetup) {
this._setupOverlayCtrl();
}
this.__needsSetup = false;
});
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
if (this._overlayCtrl) {
this._teardownOverlayCtrl();
}
}
get _overlayInvokerNode() {
return Array.from(this.children).find(child => child.slot === 'invoker');
}
get _overlayBackdropNode() {
return Array.from(this.children).find(child => child.slot === 'backdrop');
}
get _overlayContentNode() {
if (!this._cachedOverlayContentNode) {
this._cachedOverlayContentNode = Array.from(this.children).find(
child => child.slot === 'content',
);
}
return this._cachedOverlayContentNode;
}
get _overlayContentWrapperNode() {
return this.shadowRoot.querySelector('#overlay-content-node-wrapper');
}
_setupOverlayCtrl() {
/** @type {OverlayController} */
this._overlayCtrl = this._defineOverlay({
contentNode: this._overlayContentNode,
contentWrapperNode: this._overlayContentWrapperNode,
invokerNode: this._overlayInvokerNode,
backdropNode: this._overlayBackdropNode,
});
this.__syncToOverlayController();
this.__setupSyncFromOverlayController();
this._setupOpenCloseListeners();
}
_teardownOverlayCtrl() {
this._teardownOpenCloseListeners();
this.__teardownSyncFromOverlayController();
/** @type {OverlayController} */
(this._overlayCtrl).teardown();
}
/**
* When the opened state is changed by an Application Developer,cthe OverlayController is
* requested to show/hide. It might happen that this request is not honoured
* (intercepted in before-hide for instance), so that we need to sync the controller state
* to this webcomponent again, preventing eternal loops.
* @param {boolean} newOpened
*/
async _setOpenedWithoutPropertyEffects(newOpened) {
this.__blockSyncToOverlayCtrl = true;
this.opened = newOpened;
await this.updateComplete;
this.__blockSyncToOverlayCtrl = false;
}
__setupSyncFromOverlayController() {
this.__onOverlayCtrlShow = () => {
this.opened = true;
};
this.__onOverlayCtrlHide = () => {
this.opened = false;
};
/** /**
* When the opened state is changed by an Application Developer,cthe OverlayController is * @param {{ preventDefault: () => void; }} beforeShowEvent
* requested to show/hide. It might happen that this request is not honoured
* (intercepted in before-hide for instance), so that we need to sync the controller state
* to this webcomponent again, preventing eternal loops.
*/ */
async _setOpenedWithoutPropertyEffects(newOpened) { this.__onBeforeShow = beforeShowEvent => {
this.__blockSyncToOverlayCtrl = true; const event = new CustomEvent('before-opened', { cancelable: true });
this.opened = newOpened; this.dispatchEvent(event);
await this.updateComplete; if (event.defaultPrevented) {
this.__blockSyncToOverlayCtrl = false; // Check whether our current `.opened` state is not out of sync with overlayCtrl
} this._setOpenedWithoutPropertyEffects(
/** @type {OverlayController} */ (this._overlayCtrl).isShown,
__setupSyncFromOverlayController() { );
this.__onOverlayCtrlShow = () => { beforeShowEvent.preventDefault();
this.opened = true;
};
this.__onOverlayCtrlHide = () => {
this.opened = false;
};
this.__onBeforeShow = beforeShowEvent => {
const event = new CustomEvent('before-opened', { cancelable: true });
this.dispatchEvent(event);
if (event.defaultPrevented) {
// Check whether our current `.opened` state is not out of sync with overlayCtrl
this._setOpenedWithoutPropertyEffects(this._overlayCtrl.isShown);
beforeShowEvent.preventDefault();
}
};
this.__onBeforeHide = beforeHideEvent => {
const event = new CustomEvent('before-closed', { cancelable: true });
this.dispatchEvent(event);
if (event.defaultPrevented) {
// Check whether our current `.opened` state is not out of sync with overlayCtrl
this._setOpenedWithoutPropertyEffects(this._overlayCtrl.isShown);
beforeHideEvent.preventDefault();
}
};
this._overlayCtrl.addEventListener('show', this.__onOverlayCtrlShow);
this._overlayCtrl.addEventListener('hide', this.__onOverlayCtrlHide);
this._overlayCtrl.addEventListener('before-show', this.__onBeforeShow);
this._overlayCtrl.addEventListener('before-hide', this.__onBeforeHide);
}
__teardownSyncFromOverlayController() {
this._overlayCtrl.removeEventListener('show', this.__onOverlayCtrlShow);
this._overlayCtrl.removeEventListener('hide', this.__onOverlayCtrlHide);
this._overlayCtrl.removeEventListener('before-show', this.__onBeforeShow);
this._overlayCtrl.removeEventListener('before-hide', this.__onBeforeHide);
}
__syncToOverlayController() {
if (this.opened) {
this._overlayCtrl.show();
} else {
this._overlayCtrl.hide();
} }
};
/**
* @param {{ preventDefault: () => void; }} beforeHideEvent
*/
this.__onBeforeHide = beforeHideEvent => {
const event = new CustomEvent('before-closed', { cancelable: true });
this.dispatchEvent(event);
if (event.defaultPrevented) {
// Check whether our current `.opened` state is not out of sync with overlayCtrl
this._setOpenedWithoutPropertyEffects(
/** @type {OverlayController} */
(this._overlayCtrl).isShown,
);
beforeHideEvent.preventDefault();
}
};
/** @type {OverlayController} */
(this._overlayCtrl).addEventListener('show', this.__onOverlayCtrlShow);
/** @type {OverlayController} */
(this._overlayCtrl).addEventListener('hide', this.__onOverlayCtrlHide);
/** @type {OverlayController} */
(this._overlayCtrl).addEventListener('before-show', this.__onBeforeShow);
/** @type {OverlayController} */
(this._overlayCtrl).addEventListener('before-hide', this.__onBeforeHide);
}
__teardownSyncFromOverlayController() {
/** @type {OverlayController} */ (this._overlayCtrl).removeEventListener(
'show',
/** @type {EventListener} */ (this.__onOverlayCtrlShow),
);
/** @type {OverlayController} */ (this._overlayCtrl).removeEventListener(
'hide',
/** @type {EventListener} */ (this.__onOverlayCtrlHide),
);
/** @type {OverlayController} */ (this._overlayCtrl).removeEventListener(
'before-show',
/** @type {EventListener} */ (this.__onBeforeShow),
);
/** @type {OverlayController} */ (this._overlayCtrl).removeEventListener(
'before-hide',
/** @type {EventListener} */ (this.__onBeforeHide),
);
}
__syncToOverlayController() {
if (this.opened) {
/** @type {OverlayController} */ (this._overlayCtrl).show();
} else {
/** @type {OverlayController} */ (this._overlayCtrl).hide();
} }
}, }
); };
export const OverlayMixin = dedupeMixin(OverlayMixinImplementation);

View file

@ -1,24 +1,12 @@
import { unsetSiblingsInert, setSiblingsInert } from './utils/inert-siblings.js'; import { unsetSiblingsInert, setSiblingsInert } from './utils/inert-siblings.js';
import { globalOverlaysStyle } from './globalOverlaysStyle.js'; import { globalOverlaysStyle } from './globalOverlaysStyle.js';
const isIOS = navigator.userAgent.match(/iPhone|iPad|iPod/i);
/** /**
* @typedef {object} OverlayController * @typedef {import('./OverlayController.js').OverlayController} OverlayController
* @param {(object) => TemplateResult} contentTemplate the template function
* which is called on update
* @param {(boolean, object) => void} sync updates shown state and data all together
* @param {(object) => void} update updates the overlay (with data if provided as a first argument)
* @param {Function} show shows the overlay
* @param {Function} hide hides the overlay
* @param {boolean} hasBackdrop displays a gray backdrop while the overlay is opened
* @param {boolean} isBlocking hides all other overlays once shown
* @param {boolean} preventsScroll prevents scrolling the background
* while this overlay is opened
* @param {boolean} trapsKeyboardFocus keeps focus within the overlay,
* and prevents interaction with the overlay background
*/ */
const isIOS = navigator.userAgent.match(/iPhone|iPad|iPod/i);
/** /**
* `OverlaysManager` which manages overlays which are rendered into the body * `OverlaysManager` which manages overlays which are rendered into the body
*/ */
@ -42,12 +30,13 @@ export class OverlaysManager {
* 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
*/ */
// eslint-disable-next-line class-methods-use-this
get globalRootNode() { get globalRootNode() {
if (!this.constructor.__globalRootNode) { if (!OverlaysManager.__globalRootNode) {
this.constructor.__globalRootNode = this.constructor.__createGlobalRootNode(); OverlaysManager.__globalRootNode = OverlaysManager.__createGlobalRootNode();
this.constructor.__globalStyleNode = this.constructor.__createGlobalStyleNode(); OverlaysManager.__globalStyleNode = OverlaysManager.__createGlobalStyleNode();
} }
return this.constructor.__globalRootNode; return OverlaysManager.__globalRootNode;
} }
/** /**
@ -67,9 +56,12 @@ export class OverlaysManager {
} }
constructor() { constructor() {
/** @type {OverlayController[]} */
this.__list = []; this.__list = [];
/** @type {OverlayController[]} */
this.__shownList = []; this.__shownList = [];
this.__siblingsInert = false; this.__siblingsInert = false;
/** @type {WeakMap<OverlayController, OverlayController[]>} */
this.__blockingMap = new WeakMap(); this.__blockingMap = new WeakMap();
} }
@ -86,6 +78,9 @@ export class OverlaysManager {
return ctrlToAdd; return ctrlToAdd;
} }
/**
* @param {OverlayController} ctrlToRemove
*/
remove(ctrlToRemove) { remove(ctrlToRemove) {
if (!this.list.find(ctrl => ctrlToRemove === ctrl)) { if (!this.list.find(ctrl => ctrlToRemove === ctrl)) {
throw new Error('could not find controller to remove'); throw new Error('could not find controller to remove');
@ -93,6 +88,9 @@ export class OverlaysManager {
this.__list = this.list.filter(ctrl => ctrl !== ctrlToRemove); this.__list = this.list.filter(ctrl => ctrl !== ctrlToRemove);
} }
/**
* @param {OverlayController} ctrlToShow
*/
show(ctrlToShow) { show(ctrlToShow) {
if (this.list.find(ctrl => ctrlToShow === ctrl)) { if (this.list.find(ctrl => ctrlToShow === ctrl)) {
this.hide(ctrlToShow); this.hide(ctrlToShow);
@ -108,6 +106,9 @@ export class OverlaysManager {
}); });
} }
/**
* @param {any} ctrlToHide
*/
hide(ctrlToHide) { hide(ctrlToHide) {
if (!this.list.find(ctrl => ctrlToHide === ctrl)) { if (!this.list.find(ctrl => ctrlToHide === ctrl)) {
throw new Error('could not find controller to hide'); throw new Error('could not find controller to hide');
@ -124,13 +125,17 @@ export class OverlaysManager {
this.__shownList = []; this.__shownList = [];
this.__siblingsInert = false; this.__siblingsInert = false;
const rootNode = this.constructor.__globalRootNode; const rootNode = OverlaysManager.__globalRootNode;
if (rootNode) { if (rootNode) {
rootNode.parentElement.removeChild(rootNode); if (rootNode.parentElement) {
this.constructor.__globalRootNode = undefined; rootNode.parentElement.removeChild(rootNode);
}
OverlaysManager.__globalRootNode = undefined;
document.head.removeChild(this.constructor.__globalStyleNode); document.head.removeChild(
this.constructor.__globalStyleNode = undefined; /** @type {HTMLStyleElement} */ (OverlaysManager.__globalStyleNode),
);
OverlaysManager.__globalStyleNode = undefined;
} }
} }
@ -150,13 +155,14 @@ export class OverlaysManager {
informTrapsKeyboardFocusGotEnabled() { informTrapsKeyboardFocusGotEnabled() {
if (this.siblingsInert === false) { if (this.siblingsInert === false) {
if (this.constructor.__globalRootNode) { if (OverlaysManager.__globalRootNode) {
setSiblingsInert(this.globalRootNode); setSiblingsInert(this.globalRootNode);
} }
this.__siblingsInert = true; this.__siblingsInert = true;
} }
} }
// @ts-ignore
informTrapsKeyboardFocusGotDisabled({ disabledCtrl, findNewTrap = true } = {}) { informTrapsKeyboardFocusGotDisabled({ disabledCtrl, findNewTrap = true } = {}) {
const next = this.shownList.find( const next = this.shownList.find(
ctrl => ctrl !== disabledCtrl && ctrl.trapsKeyboardFocus === true, ctrl => ctrl !== disabledCtrl && ctrl.trapsKeyboardFocus === true,
@ -166,7 +172,7 @@ export class OverlaysManager {
next.enableTrapsKeyboardFocus(); next.enableTrapsKeyboardFocus();
} }
} else if (this.siblingsInert === true) { } else if (this.siblingsInert === true) {
if (this.constructor.__globalRootNode) { if (OverlaysManager.__globalRootNode) {
unsetSiblingsInert(this.globalRootNode); unsetSiblingsInert(this.globalRootNode);
} }
this.__siblingsInert = false; this.__siblingsInert = false;
@ -195,7 +201,10 @@ export class OverlaysManager {
} }
} }
/** Blocking */ /**
* Blocking
* @param {OverlayController} blockingCtrl
*/
requestToShowOnly(blockingCtrl) { requestToShowOnly(blockingCtrl) {
const controllersToHide = this.shownList.filter(ctrl => ctrl !== blockingCtrl); const controllersToHide = this.shownList.filter(ctrl => ctrl !== blockingCtrl);
@ -203,10 +212,19 @@ export class OverlaysManager {
this.__blockingMap.set(blockingCtrl, controllersToHide); this.__blockingMap.set(blockingCtrl, controllersToHide);
} }
/**
* @param {OverlayController} blockingCtrl
*/
retractRequestToShowOnly(blockingCtrl) { retractRequestToShowOnly(blockingCtrl) {
if (this.__blockingMap.has(blockingCtrl)) { if (this.__blockingMap.has(blockingCtrl)) {
const controllersWhichGotHidden = this.__blockingMap.get(blockingCtrl); const controllersWhichGotHidden = /** @type {OverlayController[]} */ (this.__blockingMap.get(
blockingCtrl,
));
controllersWhichGotHidden.map(ctrl => ctrl.show()); controllersWhichGotHidden.map(ctrl => ctrl.show());
} }
} }
} }
/** @type {HTMLElement | undefined} */
OverlaysManager.__globalRootNode = undefined;
/** @type {HTMLStyleElement | undefined} */
OverlaysManager.__globalStyleNode = undefined;

View file

@ -1,11 +1,16 @@
export const withBottomSheetConfig = () => ({ /**
hasBackdrop: true, * @typedef {import('../../types/OverlayConfig').OverlayConfig} OverlayConfig
preventsScroll: true, */
trapsKeyboardFocus: true,
hidesOnEsc: true, export const withBottomSheetConfig = () =>
placementMode: 'global', /** @type {OverlayConfig} */ ({
viewportConfig: { hasBackdrop: true,
placement: 'bottom', preventsScroll: true,
}, trapsKeyboardFocus: true,
handlesAccessibility: true, hidesOnEsc: true,
}); placementMode: 'global',
viewportConfig: {
placement: 'bottom',
},
handlesAccessibility: true,
});

View file

@ -1,15 +1,19 @@
export const withDropdownConfig = () => ({ /**
placementMode: 'local', * @typedef {import('../../types/OverlayConfig').OverlayConfig} OverlayConfig
*/
inheritsReferenceWidth: 'min', export const withDropdownConfig = () =>
hidesOnOutsideClick: true, /** @type {OverlayConfig} */ ({
popperConfig: { placementMode: 'local',
placement: 'bottom-start', inheritsReferenceWidth: 'min',
modifiers: { hidesOnOutsideClick: true,
offset: { popperConfig: {
enabled: false, placement: 'bottom-start',
modifiers: {
offset: {
enabled: false,
},
}, },
}, },
}, handlesAccessibility: true,
handlesAccessibility: true, });
});

View file

@ -1,12 +1,16 @@
export const withModalDialogConfig = () => ({ /**
placementMode: 'global', * @typedef {import('../../types/OverlayConfig').OverlayConfig} OverlayConfig
viewportConfig: { */
placement: 'center',
},
hasBackdrop: true, export const withModalDialogConfig = () =>
preventsScroll: true, /** @type {OverlayConfig} */ ({
trapsKeyboardFocus: true, placementMode: 'global',
hidesOnEsc: true, viewportConfig: {
handlesAccessibility: true, placement: 'center',
}); },
hasBackdrop: true,
preventsScroll: true,
trapsKeyboardFocus: true,
hidesOnEsc: true,
handlesAccessibility: true,
});

View file

@ -1,10 +1,14 @@
import { singletonManager } from 'singleton-manager'; import { singletonManager } from 'singleton-manager';
// eslint-disable-next-line import/no-cycle
import { OverlaysManager } from './OverlaysManager.js'; import { OverlaysManager } from './OverlaysManager.js';
// eslint-disable-next-line import/no-mutable-exports // eslint-disable-next-line import/no-mutable-exports
export let overlays = export let overlays =
singletonManager.get('@lion/overlays::overlays::0.15.x') || new OverlaysManager(); singletonManager.get('@lion/overlays::overlays::0.15.x') || new OverlaysManager();
/**
* @param {OverlaysManager} newOverlays
*/
export function setOverlays(newOverlays) { export function setOverlays(newOverlays) {
overlays = newOverlays; overlays = newOverlays;
} }

View file

@ -45,7 +45,7 @@ export function rotateFocus(rootElement, e) {
const [first, last] = boundaryEls; const [first, last] = boundaryEls;
// Get the currently focused element // Get the currently focused element
const activeElement = getDeepActiveElement(); const activeElement = /** @type {HTMLElement} */ (getDeepActiveElement());
/** /**
* If currently focused on the root element or an element contained within the root element: * If currently focused on the root element or an element contained within the root element:
@ -74,7 +74,8 @@ export function containFocus(rootElement) {
const focusableElements = getFocusableElements(rootElement); const focusableElements = getFocusableElements(rootElement);
// Initial focus goes to first element with autofocus, or the root element // Initial focus goes to first element with autofocus, or the root element
const initialFocus = focusableElements.find(e => e.hasAttribute('autofocus')) || rootElement; const initialFocus = focusableElements.find(e => e.hasAttribute('autofocus')) || rootElement;
let /** @type {HTMLElement} */ tabDetectionElement; /** @type {HTMLElement} */
let tabDetectionElement;
// If root element will receive focus, it should have a tabindex of -1. // If root element will receive focus, it should have a tabindex of -1.
// This makes it focusable through js, but it won't appear in the tab order // This makes it focusable through js, but it won't appear in the tab order
@ -103,7 +104,9 @@ export function containFocus(rootElement) {
} }
function isForwardTabInWindow() { function isForwardTabInWindow() {
const compareMask = tabDetectionElement.compareDocumentPosition(document.activeElement); const compareMask = tabDetectionElement.compareDocumentPosition(
/** @type {Element} */ (document.activeElement),
);
return compareMask === Node.DOCUMENT_POSITION_PRECEDING; return compareMask === Node.DOCUMENT_POSITION_PRECEDING;
} }

View file

@ -36,12 +36,11 @@ function getTabindex(element) {
} }
/** /**
* @param {HTMLElement} element * @param {HTMLElement|HTMLSlotElement} element
*/ */
function getChildNodes(element) { function getChildNodes(element) {
if (element.localName === 'slot') { if (element.localName === 'slot') {
/** @type {HTMLSlotElement} */ const slot = /** @type {HTMLSlotElement} */ (element);
const slot = element;
return slot.assignedNodes({ flatten: true }); return slot.assignedNodes({ flatten: true });
} }
@ -51,48 +50,46 @@ function getChildNodes(element) {
} }
/** /**
* @param {Node} node * @param {Element} element
* @returns {boolean} * @returns {boolean}
*/ */
function isVisibleElement(node) { function isVisibleElement(element) {
if (node.nodeType !== Node.ELEMENT_NODE) { if (element.nodeType !== Node.ELEMENT_NODE) {
return false; return false;
} }
// A slot is not visible, but it's children might so we need // A slot is not visible, but it's children might so we need
// to treat is as such. // to treat is as such.
if (node.localName === 'slot') { if (element.localName === 'slot') {
return true; return true;
} }
return isVisible(/** @type {HTMLElement} */ (node)); return isVisible(/** @type {HTMLElement} */ (element));
} }
/** /**
* Recursive function that traverses the children of the target node and finds * Recursive function that traverses the children of the target node and finds
* elements that can receive focus. Mutates the nodes property for performance. * elements that can receive focus. Mutates the nodes property for performance.
* *
* @param {Node} node * @param {Element} element
* @param {HTMLElement[]} nodes * @param {HTMLElement[]} nodes
* @returns {boolean} whether the returned node list should be sorted. This happens when * @returns {boolean} whether the returned node list should be sorted. This happens when
* there is an element with tabindex > 0 * there is an element with tabindex > 0
*/ */
function collectFocusableElements(node, nodes) { function collectFocusableElements(element, nodes) {
// If not an element or not visible, no need to explore children. // If not an element or not visible, no need to explore children.
if (!isVisibleElement(node)) { if (!isVisibleElement(element)) {
return false; return false;
} }
/** @type {HTMLElement} */ const el = /** @type {HTMLElement} */ (element);
const element = node; const tabIndex = getTabindex(el);
const tabIndex = getTabindex(element);
let needsSort = tabIndex > 0; let needsSort = tabIndex > 0;
if (tabIndex >= 0) { if (tabIndex >= 0) {
nodes.push(element); nodes.push(el);
} }
const childNodes = getChildNodes(element); const childNodes = /** @type {Element[]} */ (getChildNodes(el));
for (let i = 0; i < childNodes.length; i += 1) { for (let i = 0; i < childNodes.length; i += 1) {
needsSort = collectFocusableElements(childNodes[i], nodes) || needsSort; needsSort = collectFocusableElements(childNodes[i], nodes) || needsSort;
} }
@ -100,13 +97,13 @@ function collectFocusableElements(node, nodes) {
} }
/** /**
* @param {Node} node * @param {Element} element
* @returns {HTMLElement[]} * @returns {HTMLElement[]}
*/ */
export function getFocusableElements(node) { export function getFocusableElements(element) {
/** @type {HTMLElement[]} */ /** @type {HTMLElement[]} */
const nodes = []; const nodes = [];
const needsSort = collectFocusableElements(node, nodes); const needsSort = collectFocusableElements(element, nodes);
return needsSort ? sortByTabIndex(nodes) : nodes; return needsSort ? sortByTabIndex(nodes) : nodes;
} }

View file

@ -8,7 +8,7 @@
* @param {HTMLElement} element * @param {HTMLElement} element
*/ */
export function setSiblingsInert(element) { export function setSiblingsInert(element) {
const parentChildren = element.parentElement.children; const parentChildren = /** @type {HTMLCollection} */ (element.parentElement?.children);
for (let i = 0; i < parentChildren.length; i += 1) { for (let i = 0; i < parentChildren.length; i += 1) {
const sibling = parentChildren[i]; const sibling = parentChildren[i];
@ -24,13 +24,13 @@ export function setSiblingsInert(element) {
* @param {HTMLElement} element * @param {HTMLElement} element
*/ */
export function unsetSiblingsInert(element) { export function unsetSiblingsInert(element) {
const parentChildren = element.parentElement.children; const parentChildren = /** @type {HTMLCollection} */ (element.parentElement?.children);
for (let i = 0; i < parentChildren.length; i += 1) { for (let i = 0; i < parentChildren.length; i += 1) {
const sibling = parentChildren[i]; const sibling = parentChildren[i];
if (sibling !== element) { if (sibling !== element) {
sibling.removeAttribute('inert', ''); sibling.removeAttribute('inert');
sibling.removeAttribute('aria-hidden', 'true'); sibling.removeAttribute('aria-hidden');
} }
} }
} }

View file

@ -1,7 +1,7 @@
import { getFocusableElements } from './get-focusable-elements.js'; import { getFocusableElements } from './get-focusable-elements.js';
export function simulateTab(node = document.body) { export function simulateTab(node = document.body) {
const current = document.activeElement; const current = /** @type {HTMLElement} */ (document.activeElement);
const all = getFocusableElements(node); const all = getFocusableElements(node);
const currentIndex = all.indexOf(current); const currentIndex = all.indexOf(current);

View file

@ -1,51 +0,0 @@
/**
* @typedef {object} OverlayConfig
* @property {HTMLElement} [elementToFocusAfterHide=document.body] the element that should be
* called `.focus()` on after dialog closes
* @property {boolean} [hasBackdrop=false] whether it should have a backdrop (currently
* exclusive to globalOverlayController)
* @property {boolean} [isBlocking=false] hides other overlays when mutiple are opened
* (currently exclusive to globalOverlayController)
* @property {boolean} [preventsScroll=false] prevents scrolling body content when overlay
* opened (currently exclusive to globalOverlayController)
* @property {boolean} [trapsKeyboardFocus=false] rotates tab, implicitly set when 'isModal'
* @property {boolean} [hidesOnEsc=false] hides the overlay when pressing [ esc ]
* @property {boolean} [hidesOnOutsideClick=false] hides the overlay when clicking next to it,
* exluding invoker. (currently exclusive to localOverlayController)
* https://github.com/ing-bank/lion/pull/61
* @property {'max'|'full'|'min'|'none'} [inheritsReferenceWidth='none'] will align contentNode
* with referenceNode (invokerNode by default) for local overlays. Usually needed for dropdowns.
* 'max' will prevent contentNode from exceeding width
* of referenceNode, 'min' guarantees that contentNode will be at least as wide as referenceNode.
* 'full' will make sure that the invoker width always is the same.
* @property {HTMLElement} invokerNode the interactive element (usually a button) invoking the
* dialog or tooltip
* @property {HTMLElement} [referenceNode] the element that is used to position the overlay content
* relative to. Usually, this is the same element as invokerNode. Should only be provided whne
* @property {HTMLElement} contentNode the most important element: the overlay itself.
* @property {HTMLElement} [contentWrapperNode] the wrapper element of contentNode, used to supply
* inline positioning styles. When a Popper arrow is needed, it acts as parent of the arrow node.
* Will be automatically created for global and non projected contentNodes.
* Required when used in shadow dom mode or when Popper arrow is supplied. Essential for allowing
* webcomponents to style their projected contentNodes.
* @property {HTMLElement} [backdropNode] the element that is placed behin the contentNode. When
* not provided and `hasBackdrop` is true, a backdropNode will be automatically created
* @property {'global'|'local'} placementMode determines the connection point in DOM (body vs next
* to invoker).
* @property {boolean} [isTooltip=false] has a totally different interaction- and accessibility
* pattern from all other overlays. Will behave as role="tooltip" element instead of a role="dialog"
* element.
* @property {'label'|'description'} [invokerRelation='description']
* @property {boolean} [handlesAccessibility]
* For non `isTooltip`:
* - sets aria-expanded="true/false" and aria-haspopup="true" on invokerNode
* - sets aria-controls on invokerNode
* - returns focus to invokerNode on hide
* - sets focus to overlay content(?)
*
* For `isTooltip`:
* - sets role="tooltip" and aria-labelledby/aria-describedby on the content
* @property {object} popperConfig popper configuration. Will be used when placementMode is 'local'
* @property {object} viewportConfig viewport configuration. Will be used when placementMode is
* 'global'
*/

View file

@ -1,6 +1,17 @@
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 } from '../src/overlays.js'; import { overlays } from '../src/overlays.js';
// eslint-disable-next-line no-unused-vars
import { OverlayController } from '../src/OverlayController.js';
/**
* @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig
* @typedef {import('../types/OverlayMixinTypes').DefineOverlayConfig} DefineOverlayConfig
* @typedef {import('../types/OverlayMixinTypes').OverlayHost} OverlayHost
* @typedef {import('../types/OverlayMixinTypes').OverlayMixin} OverlayMixin
* @typedef {import('@lion/core').LitElement} LitElement
* @typedef {LitElement & OverlayHost & {_overlayCtrl:OverlayController}} OverlayEl
*/
function getGlobalOverlayNodes() { function getGlobalOverlayNodes() {
return Array.from(overlays.globalRootNode.children).filter( return Array.from(overlays.globalRootNode.children).filter(
@ -8,26 +19,29 @@ function getGlobalOverlayNodes() {
); );
} }
/**
* @param {{tagString:string, tag: object, suffix?:string}} config
*/
export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) { export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
describe(`OverlayMixin${suffix}`, () => { describe(`OverlayMixin${suffix}`, () => {
it('should not be opened by default', async () => { it('should not be opened by default', async () => {
const el = await fixture(html` const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag}> <${tag}>
<div slot="content">content of the overlay</div> <div slot="content">content of the overlay</div>
<button slot="invoker">invoker button</button> <button slot="invoker">invoker button</button>
</${tag}> </${tag}>
`); `));
expect(el.opened).to.be.false; expect(el.opened).to.be.false;
expect(el._overlayCtrl.isShown).to.be.false; expect(el._overlayCtrl.isShown).to.be.false;
}); });
it('syncs opened to overlayController', async () => { it('syncs opened to overlayController', async () => {
const el = await fixture(html` const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag}> <${tag}>
<div slot="content">content of the overlay</div> <div slot="content">content of the overlay</div>
<button slot="invoker">invoker button</button> <button slot="invoker">invoker button</button>
</${tag}> </${tag}>
`); `));
el.opened = true; el.opened = true;
await el.updateComplete; await el.updateComplete;
await el._overlayCtrl._showComplete; await el._overlayCtrl._showComplete;
@ -42,12 +56,12 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
}); });
it('syncs OverlayController to opened', async () => { it('syncs OverlayController to opened', async () => {
const el = await fixture(html` const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag}> <${tag}>
<div slot="content">content of the overlay</div> <div slot="content">content of the overlay</div>
<button slot="invoker">invoker button</button> <button slot="invoker">invoker button</button>
</${tag}> </${tag}>
`); `));
expect(el.opened).to.be.false; expect(el.opened).to.be.false;
await el._overlayCtrl.show(); await el._overlayCtrl.show();
expect(el.opened).to.be.true; expect(el.opened).to.be.true;
@ -59,7 +73,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
it('does not change the body size when opened', async () => { it('does not change the body size when opened', async () => {
const parentNode = document.createElement('div'); const parentNode = document.createElement('div');
parentNode.setAttribute('style', 'height: 10000px; width: 10000px;'); parentNode.setAttribute('style', 'height: 10000px; width: 10000px;');
const elWithBigParent = await fixture( const elWithBigParent = /** @type {OverlayEl} */ (await fixture(
html` html`
<${tag}> <${tag}>
<div slot="content">content of the overlay</div> <div slot="content">content of the overlay</div>
@ -67,24 +81,35 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
</${tag}> </${tag}>
`, `,
{ parentNode }, { parentNode },
); ));
const { offsetWidth, offsetHeight } = elWithBigParent.offsetParent; const {
offsetWidth,
offsetHeight,
} = /** @type {HTMLElement} */ (elWithBigParent.offsetParent);
await elWithBigParent._overlayCtrl.show(); await elWithBigParent._overlayCtrl.show();
expect(elWithBigParent.opened).to.be.true; expect(elWithBigParent.opened).to.be.true;
expect(elWithBigParent.offsetParent.offsetWidth).to.equal(offsetWidth); expect(/** @type {HTMLElement} */ (elWithBigParent?.offsetParent).offsetWidth).to.equal(
expect(elWithBigParent.offsetParent.offsetHeight).to.equal(offsetHeight); offsetWidth,
);
expect(/** @type {HTMLElement} */ (elWithBigParent?.offsetParent).offsetHeight).to.equal(
offsetHeight,
);
await elWithBigParent._overlayCtrl.hide(); await elWithBigParent._overlayCtrl.hide();
expect(elWithBigParent.offsetParent.offsetWidth).to.equal(offsetWidth); expect(/** @type {HTMLElement} */ (elWithBigParent?.offsetParent).offsetWidth).to.equal(
expect(elWithBigParent.offsetParent.offsetHeight).to.equal(offsetHeight); offsetWidth,
);
expect(/** @type {HTMLElement} */ (elWithBigParent?.offsetParent).offsetHeight).to.equal(
offsetHeight,
);
}); });
it('should respond to initially and dynamically setting the config', async () => { it('should respond to initially and dynamically setting the config', async () => {
const itEl = await fixture(html` const itEl = /** @type {OverlayEl} */ (await fixture(html`
<${tag} .config=${{ trapsKeyboardFocus: false, viewportConfig: { placement: 'top' } }}> <${tag} .config=${{ trapsKeyboardFocus: false, viewportConfig: { placement: 'top' } }}>
<div slot="content">content of the overlay</div> <div slot="content">content of the overlay</div>
<button slot="invoker">invoker button</button> <button slot="invoker">invoker button</button>
</${tag}> </${tag}>
`); `));
itEl.opened = true; itEl.opened = true;
await itEl.updateComplete; await itEl.updateComplete;
expect(itEl._overlayCtrl.trapsKeyboardFocus).to.be.false; expect(itEl._overlayCtrl.trapsKeyboardFocus).to.be.false;
@ -95,12 +120,12 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
it('fires "opened-changed" event on hide', async () => { it('fires "opened-changed" event on hide', async () => {
const spy = sinon.spy(); const spy = sinon.spy();
const el = await fixture(html` const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag} @opened-changed="${spy}"> <${tag} @opened-changed="${spy}">
<div slot="content">content of the overlay</div> <div slot="content">content of the overlay</div>
<button slot="invoker">invoker button</button> <button slot="invoker">invoker button</button>
</${tag}> </${tag}>
`); `));
expect(spy).not.to.have.been.called; expect(spy).not.to.have.been.called;
await el._overlayCtrl.show(); await el._overlayCtrl.show();
await el.updateComplete; await el.updateComplete;
@ -114,12 +139,12 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
it('fires "before-closed" event on hide', async () => { it('fires "before-closed" event on hide', async () => {
const beforeSpy = sinon.spy(); const beforeSpy = sinon.spy();
const el = await fixture(html` const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag} @before-closed="${beforeSpy}" .opened="${true}"> <${tag} @before-closed="${beforeSpy}" .opened="${true}">
<div slot="content">content of the overlay</div> <div slot="content">content of the overlay</div>
<button slot="invoker">invoker button</button> <button slot="invoker">invoker button</button>
</${tag}> </${tag}>
`); `));
// Wait until it's done opening (handling features is async) // Wait until it's done opening (handling features is async)
await nextFrame(); await nextFrame();
expect(beforeSpy).not.to.have.been.called; expect(beforeSpy).not.to.have.been.called;
@ -130,12 +155,12 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
it('fires before-opened" event on show', async () => { it('fires before-opened" event on show', async () => {
const beforeSpy = sinon.spy(); const beforeSpy = sinon.spy();
const el = await fixture(html` const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag} @before-opened="${beforeSpy}"> <${tag} @before-opened="${beforeSpy}">
<div slot="content">content of the overlay</div> <div slot="content">content of the overlay</div>
<button slot="invoker">invoker button</button> <button slot="invoker">invoker button</button>
</${tag}> </${tag}>
`); `));
expect(beforeSpy).not.to.have.been.called; expect(beforeSpy).not.to.have.been.called;
await el._overlayCtrl.show(); await el._overlayCtrl.show();
expect(beforeSpy).to.have.been.called; expect(beforeSpy).to.have.been.called;
@ -143,16 +168,16 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
}); });
it('allows to call `preventDefault()` on "before-opened"/"before-closed" events', async () => { it('allows to call `preventDefault()` on "before-opened"/"before-closed" events', async () => {
function preventer(ev) { function preventer(/** @type Event */ ev) {
ev.preventDefault(); ev.preventDefault();
} }
const el = await fixture(html` const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag} @before-opened="${preventer}" @before-closed="${preventer}"> <${tag} @before-opened="${preventer}" @before-closed="${preventer}">
<div slot="content">content of the overlay</div> <div slot="content">content of the overlay</div>
<button slot="invoker">invoker button</button> <button slot="invoker">invoker button</button>
</${tag}> </${tag}>
`); `));
el.querySelector('[slot="invoker"]').click(); /** @type {HTMLElement} */ (el.querySelector('[slot="invoker"]')).click();
await nextFrame(); await nextFrame();
expect(el.opened).to.be.false; expect(el.opened).to.be.false;
@ -164,12 +189,14 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
}); });
it('hides content on "close-overlay" event within the content ', async () => { it('hides content on "close-overlay" event within the content ', async () => {
function sendCloseEvent(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 = await fixture(html` <button @click=${sendCloseEvent}>close</button> `); const closeBtn = /** @type {OverlayEl} */ (await fixture(
html` <button @click=${sendCloseEvent}>close</button> `,
));
const el = await fixture(html` const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag} opened> <${tag} opened>
<div slot="content"> <div slot="content">
content of the overlay content of the overlay
@ -177,7 +204,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
</div> </div>
<button slot="invoker">invoker button</button> <button slot="invoker">invoker button</button>
</${tag}> </${tag}>
`); `));
closeBtn.click(); closeBtn.click();
expect(el.opened).to.be.false; expect(el.opened).to.be.false;
}); });
@ -194,7 +221,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
}); });
it('supports nested overlays', async () => { it('supports nested overlays', async () => {
const el = await fixture(html` const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag} id="main-dialog"> <${tag} id="main-dialog">
<div slot="content" id="mainContent"> <div slot="content" id="mainContent">
open nested overlay: open nested overlay:
@ -207,32 +234,34 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
</div> </div>
<button slot="invoker" id="mainInvoker">invoker button</button> <button slot="invoker" id="mainInvoker">invoker button</button>
</${tag}> </${tag}>
`); `));
if (el._overlayCtrl.placementMode === 'global') { if (el._overlayCtrl.placementMode === 'global') {
expect(getGlobalOverlayNodes().length).to.equal(2); expect(getGlobalOverlayNodes().length).to.equal(2);
} }
el.opened = true; el.opened = true;
await aTimeout(); await aTimeout(0);
expect(el._overlayCtrl.contentNode).to.be.displayed; expect(el._overlayCtrl.contentNode).to.be.displayed;
const nestedOverlayEl = el._overlayCtrl.contentNode.querySelector(tagString); const nestedOverlayEl = /** @type {OverlayEl} */ (el._overlayCtrl.contentNode.querySelector(
tagString,
));
nestedOverlayEl.opened = true; nestedOverlayEl.opened = true;
await aTimeout(); await aTimeout(0);
expect(nestedOverlayEl._overlayCtrl.contentNode).to.be.displayed; expect(nestedOverlayEl._overlayCtrl.contentNode).to.be.displayed;
}); });
it('[global] allows for moving of the element', async () => { it('[global] allows for moving of the element', async () => {
const el = await fixture(html` const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag}> <${tag}>
<div slot="content" id="nestedContent">content of the nested overlay</div> <div slot="content" id="nestedContent">content of the nested overlay</div>
<button slot="invoker">invoker nested</button> <button slot="invoker">invoker nested</button>
</${tag}> </${tag}>
`); `));
if (el._overlayCtrl.placementMode === 'global') { if (el._overlayCtrl.placementMode === 'global') {
expect(getGlobalOverlayNodes().length).to.equal(1); expect(getGlobalOverlayNodes().length).to.equal(1);
const moveTarget = 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(getGlobalOverlayNodes().length).to.equal(1);
@ -240,14 +269,14 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
}); });
it('reconstructs the overlay when disconnected and reconnected to DOM (support for nested overlay nodes)', async () => { it('reconstructs the overlay when disconnected and reconnected to DOM (support for nested overlay nodes)', async () => {
const nestedEl = await fixture(html` const nestedEl = /** @type {OverlayEl} */ (await fixture(html`
<${tag} id="nest"> <${tag} id="nest">
<div slot="content" id="nestedContent">content of the nested overlay</div> <div slot="content" id="nestedContent">content of the nested overlay</div>
<button slot="invoker">invoker nested</button> <button slot="invoker">invoker nested</button>
</${tag}> </${tag}>
`); `));
const el = await fixture(html` const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag} id="main"> <${tag} id="main">
<div slot="content" id="mainContent"> <div slot="content" id="mainContent">
open nested overlay: open nested overlay:
@ -255,7 +284,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
</div> </div>
<button slot="invoker">invoker button</button> <button slot="invoker">invoker button</button>
</${tag}> </${tag}>
`); `));
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
@ -268,7 +297,10 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
); );
expect(lastContentNodeInContainer.firstElementChild.slot).to.equal('content'); expect(lastContentNodeInContainer.firstElementChild.slot).to.equal('content');
} else { } else {
const contentNode = el._overlayContentNode.querySelector('#nestedContent'); // @ts-ignore allow protected props in tests
const contentNode = /** @type {HTMLElement} */ (el._overlayContentNode.querySelector(
'#nestedContent',
));
expect(contentNode).to.not.be.null; expect(contentNode).to.not.be.null;
expect(contentNode.innerText).to.equal('content of the nested overlay'); expect(contentNode.innerText).to.equal('content of the nested overlay');
} }

View file

@ -16,18 +16,25 @@ import { overlays } from '../src/overlays.js';
import { keyCodes } from '../src/utils/key-codes.js'; import { keyCodes } from '../src/utils/key-codes.js';
import { simulateTab } from '../src/utils/simulate-tab.js'; import { simulateTab } from '../src/utils/simulate-tab.js';
const withGlobalTestConfig = () => ({ /**
placementMode: 'global', * @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig
contentNode: fixtureSync(html`<div>my content</div>`), * @typedef {import('../types/OverlayConfig').ViewportPlacement} ViewportPlacement
}); */
const withLocalTestConfig = () => ({ const withGlobalTestConfig = () =>
placementMode: 'local', /** @type {OverlayConfig} */ ({
contentNode: fixtureSync(html`<div>my content</div>`), placementMode: 'global',
invokerNode: fixtureSync(html` contentNode: /** @type {HTMLElement} */ (fixtureSync(html`<div>my content</div>`)),
<div role="button" style="width: 100px; height: 20px;">Invoker</div> });
`),
}); const withLocalTestConfig = () =>
/** @type {OverlayConfig} */ ({
placementMode: 'local',
contentNode: /** @type {HTMLElement} */ (fixtureSync(html`<div>my content</div>`)),
invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;">Invoker</div>
`)),
});
afterEach(() => { afterEach(() => {
overlays.teardown(); overlays.teardown();
@ -52,10 +59,15 @@ describe('OverlayController', () => {
}); });
describe('Z-index on local overlays', () => { describe('Z-index on local overlays', () => {
/** @type {HTMLElement} */
let contentNode; let contentNode;
/**
* @param {string} zIndexVal
* @param {{ mode?: string }} options
*/
async function createZNode(zIndexVal, { mode } = {}) { async function createZNode(zIndexVal, { mode } = {}) {
if (mode === 'global') { if (mode === 'global') {
contentNode = await fixture(html` contentNode = /** @type {HTMLElement} */ (await fixture(html`
<div class="z-index--${zIndexVal}"> <div class="z-index--${zIndexVal}">
<style> <style>
.z-index--${zIndexVal} { .z-index--${zIndexVal} {
@ -64,10 +76,12 @@ describe('OverlayController', () => {
</style> </style>
I should be on top I should be on top
</div> </div>
`); `));
} }
if (mode === 'inline') { if (mode === 'inline') {
contentNode = await fixture(html` <div>I should be on top</div> `); contentNode = /** @type {HTMLElement} */ (await fixture(
html` <div>I should be on top</div> `,
));
contentNode.style.zIndex = zIndexVal; contentNode.style.zIndex = zIndexVal;
} }
return contentNode; return contentNode;
@ -131,19 +145,19 @@ describe('OverlayController', () => {
it.skip('creates local target next to sibling for placement mode "local"', async () => { it.skip('creates local target next to sibling for placement mode "local"', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
invokerNode: await fixture(html`<button>Invoker</button>`), invokerNode: /** @type {HTMLElement} */ (await fixture(html`<button>Invoker</button>`)),
}); });
expect(ctrl._renderTarget).to.be.undefined; expect(ctrl._renderTarget).to.be.undefined;
expect(ctrl.content).to.equal(ctrl.invokerNode.nextElementSibling); expect(ctrl.content).to.equal(ctrl.invokerNode?.nextElementSibling);
}); });
it('keeps local target for placement mode "local" when already connected', async () => { it('keeps local target for placement mode "local" when already connected', async () => {
const parentNode = await fixture(html` const parentNode = /** @type {HTMLElement} */ (await fixture(html`
<div id="parent"> <div id="parent">
<div id="content">Content</div> <div id="content">Content</div>
</div> </div>
`); `));
const contentNode = parentNode.querySelector('#content'); const contentNode = /** @type {HTMLElement} */ (parentNode.querySelector('#content'));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode, contentNode,
@ -193,7 +207,8 @@ describe('OverlayController', () => {
const shadowHost = document.createElement('div'); const shadowHost = document.createElement('div');
shadowHost.id = 'shadowHost'; shadowHost.id = 'shadowHost';
shadowHost.attachShadow({ mode: 'open' }); shadowHost.attachShadow({ mode: 'open' });
shadowHost.shadowRoot.innerHTML = ` /** @type {ShadowRoot} */
(shadowHost.shadowRoot).innerHTML = `
<div id="contentWrapperNode"> <div id="contentWrapperNode">
<slot name="contentNode"></slot> <slot name="contentNode"></slot>
<my-arrow></my-arrow> <my-arrow></my-arrow>
@ -203,7 +218,7 @@ describe('OverlayController', () => {
contentNode.slot = 'contentNode'; contentNode.slot = 'contentNode';
shadowHost.appendChild(contentNode); shadowHost.appendChild(contentNode);
const wrapper = await fixture('<div id="wrapper"></div>'); const wrapper = /** @type {HTMLElement} */ (await fixture('<div id="wrapper"></div>'));
// Ensure the contentNode is connected to DOM // Ensure the contentNode is connected to DOM
wrapper.appendChild(shadowHost); wrapper.appendChild(shadowHost);
@ -230,7 +245,7 @@ describe('OverlayController', () => {
it('accepts an .contentNode<Node> to directly set content', async () => { it('accepts an .contentNode<Node> to directly set content', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withGlobalTestConfig(), ...withGlobalTestConfig(),
contentNode: await fixture('<p>direct node</p>'), contentNode: /** @type {HTMLElement} */ (await fixture('<p>direct node</p>')),
}); });
expect(ctrl.contentNode).to.have.trimmed.text('direct node'); expect(ctrl.contentNode).to.have.trimmed.text('direct node');
}); });
@ -238,7 +253,7 @@ describe('OverlayController', () => {
it('accepts an .invokerNode<Node> to directly set invoker', async () => { it('accepts an .invokerNode<Node> to directly set invoker', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withGlobalTestConfig(), ...withGlobalTestConfig(),
invokerNode: await fixture('<button>invoke</button>'), invokerNode: /** @type {HTMLElement} */ (await fixture('<button>invoke</button>')),
}); });
expect(ctrl.invokerNode).to.have.trimmed.text('invoke'); expect(ctrl.invokerNode).to.have.trimmed.text('invoke');
}); });
@ -247,7 +262,7 @@ describe('OverlayController', () => {
it('recognizes projected contentNode', async () => { it('recognizes projected contentNode', async () => {
const shadowHost = document.createElement('div'); const shadowHost = document.createElement('div');
shadowHost.attachShadow({ mode: 'open' }); shadowHost.attachShadow({ mode: 'open' });
shadowHost.shadowRoot.innerHTML = ` /** @type {ShadowRoot} */ (shadowHost.shadowRoot).innerHTML = `
<div id="contentWrapperNode"> <div id="contentWrapperNode">
<slot name="contentNode"></slot> <slot name="contentNode"></slot>
<my-arrow></my-arrow> <my-arrow></my-arrow>
@ -263,7 +278,9 @@ describe('OverlayController', () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode, contentNode,
contentWrapperNode: shadowHost.shadowRoot.getElementById('contentWrapperNode'), contentWrapperNode: /** @type {HTMLElement} */ (
/** @type {ShadowRoot} */ (shadowHost.shadowRoot).getElementById('contentWrapperNode')
),
}); });
expect(ctrl.__isContentNodeProjected).to.be.true; expect(ctrl.__isContentNodeProjected).to.be.true;
@ -272,14 +289,14 @@ describe('OverlayController', () => {
describe('When contentWrapperNode needs to be provided for correct arrow positioning', () => { describe('When contentWrapperNode needs to be provided for correct arrow positioning', () => {
it('uses contentWrapperNode as provided for local positioning', async () => { it('uses contentWrapperNode as provided for local positioning', async () => {
const el = await fixture(html` const el = /** @type {HTMLElement} */ (await fixture(html`
<div id="contentWrapperNode"> <div id="contentWrapperNode">
<div id="contentNode"></div> <div id="contentNode"></div>
<my-arrow></my-arrow> <my-arrow></my-arrow>
</div> </div>
`); `));
const contentNode = el.querySelector('#contentNode'); const contentNode = /** @type {HTMLElement} */ (el.querySelector('#contentNode'));
const contentWrapperNode = el; const contentWrapperNode = el;
const ctrl = new OverlayController({ const ctrl = new OverlayController({
@ -288,7 +305,7 @@ describe('OverlayController', () => {
contentWrapperNode, contentWrapperNode,
}); });
expect(ctrl._contentWrapperNode).to.equal(contentWrapperNode); expect(ctrl.contentWrapperNode).to.equal(contentWrapperNode);
}); });
}); });
}); });
@ -316,9 +333,9 @@ describe('OverlayController', () => {
}); });
it('keeps focus within the overlay e.g. you can not tab out by accident', async () => { it('keeps focus within the overlay e.g. you can not tab out by accident', async () => {
const contentNode = await fixture(html` const contentNode = /** @type {HTMLElement} */ (await fixture(html`
<div><input id="input1" /><input id="input2" /></div> <div><input id="input1" /><input id="input2" /></div>
`); `));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withGlobalTestConfig(), ...withGlobalTestConfig(),
trapsKeyboardFocus: true, trapsKeyboardFocus: true,
@ -326,13 +343,16 @@ describe('OverlayController', () => {
}); });
await ctrl.show(); await ctrl.show();
const elOutside = await fixture(html`<button>click me</button>`); const elOutside = /** @type {HTMLElement} */ (await fixture(
html`<button>click me</button>`,
));
const input1 = ctrl.contentNode.querySelectorAll('input')[0]; const input1 = ctrl.contentNode.querySelectorAll('input')[0];
const input2 = ctrl.contentNode.querySelectorAll('input')[1]; const input2 = ctrl.contentNode.querySelectorAll('input')[1];
input2.focus(); input2.focus();
// this mimics a tab within the contain-focus system used // this mimics a tab within the contain-focus system used
const event = new CustomEvent('keydown', { detail: 0, bubbles: true }); const event = new CustomEvent('keydown', { detail: 0, bubbles: true });
// @ts-ignore override private key
event.keyCode = keyCodes.tab; event.keyCode = keyCodes.tab;
window.dispatchEvent(event); window.dispatchEvent(event);
@ -341,7 +361,7 @@ describe('OverlayController', () => {
}); });
it('allows to move the focus outside of the overlay if trapsKeyboardFocus is disabled', async () => { it('allows to move the focus outside of the overlay if trapsKeyboardFocus is disabled', async () => {
const contentNode = await fixture(html`<div><input /></div>`); const contentNode = /** @type {HTMLElement} */ (await fixture(html`<div><input /></div>`));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withGlobalTestConfig(), ...withGlobalTestConfig(),
@ -349,11 +369,11 @@ describe('OverlayController', () => {
trapsKeyboardFocus: true, trapsKeyboardFocus: true,
}); });
// add element to dom to allow focus // add element to dom to allow focus
await fixture(html`${ctrl.content}`); /** @type {HTMLElement} */ (await fixture(html`${ctrl.content}`));
await ctrl.show(); await ctrl.show();
const elOutside = await fixture(html`<input />`); const elOutside = /** @type {HTMLElement} */ (await fixture(html`<input />`));
const input = ctrl.contentNode.querySelector('input'); const input = /** @type {HTMLInputElement} */ (ctrl.contentNode.querySelector('input'));
input.focus(); input.focus();
simulateTab(); simulateTab();
@ -391,7 +411,7 @@ describe('OverlayController', () => {
}); });
await ctrl.show(); await ctrl.show();
ctrl.contentNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); ctrl.contentNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
await aTimeout(); await aTimeout(0);
expect(ctrl.isShown).to.be.false; expect(ctrl.isShown).to.be.false;
}); });
@ -414,7 +434,7 @@ describe('OverlayController', () => {
}); });
await ctrl.show(); await ctrl.show();
document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
await aTimeout(); await aTimeout(0);
expect(ctrl.isShown).to.be.false; expect(ctrl.isShown).to.be.false;
}); });
@ -431,7 +451,7 @@ describe('OverlayController', () => {
describe('hidesOnOutsideClick', () => { describe('hidesOnOutsideClick', () => {
it('hides on outside click', async () => { it('hides on outside click', async () => {
const contentNode = await fixture('<div>Content</div>'); const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>'));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withGlobalTestConfig(), ...withGlobalTestConfig(),
hidesOnOutsideClick: true, hidesOnOutsideClick: true,
@ -440,13 +460,13 @@ describe('OverlayController', () => {
await ctrl.show(); await ctrl.show();
document.body.click(); document.body.click();
await aTimeout(); await aTimeout(0);
expect(ctrl.isShown).to.be.false; expect(ctrl.isShown).to.be.false;
}); });
it('doesn\'t hide on "inside" click', async () => { it('doesn\'t hide on "inside" click', async () => {
const invokerNode = await fixture('<button>Invoker</button>'); const invokerNode = /** @type {HTMLElement} */ (await fixture('<button>Invoker</button>'));
const contentNode = await fixture('<div>Content</div>'); const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>'));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withGlobalTestConfig(), ...withGlobalTestConfig(),
hidesOnOutsideClick: true, hidesOnOutsideClick: true,
@ -456,13 +476,13 @@ describe('OverlayController', () => {
await ctrl.show(); await ctrl.show();
// Don't hide on invoker click // Don't hide on invoker click
ctrl.invokerNode.click(); ctrl.invokerNode?.click();
await aTimeout(); await aTimeout(0);
expect(ctrl.isShown).to.be.true; expect(ctrl.isShown).to.be.true;
// Don't hide on inside (content) click // Don't hide on inside (content) click
ctrl.contentNode.click(); ctrl.contentNode.click();
await aTimeout(); await aTimeout(0);
expect(ctrl.isShown).to.be.true; expect(ctrl.isShown).to.be.true;
@ -474,8 +494,8 @@ describe('OverlayController', () => {
}); });
it('doesn\'t hide on "inside sub shadow dom" click', async () => { it('doesn\'t hide on "inside sub shadow dom" click', async () => {
const invokerNode = await fixture('<button>Invoker</button>'); const invokerNode = /** @type {HTMLElement} */ (await fixture('<button>Invoker</button>'));
const contentNode = await fixture('<div>Content</div>'); const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>'));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withGlobalTestConfig(), ...withGlobalTestConfig(),
hidesOnOutsideClick: true, hidesOnOutsideClick: true,
@ -493,25 +513,28 @@ describe('OverlayController', () => {
} }
connectedCallback() { connectedCallback() {
this.shadowRoot.innerHTML = '<div><button>click me</button></div>'; /** @type {ShadowRoot} */
(this.shadowRoot).innerHTML = '<div><button>click me</button></div>';
} }
}, },
); );
const tag = unsafeStatic(tagString); const tag = unsafeStatic(tagString);
ctrl.updateConfig({ ctrl.updateConfig({
contentNode: await fixture(html` contentNode: /** @type {HTMLElement} */ (await fixture(html`
<div> <div>
<div>Content</div> <div>Content</div>
<${tag}></${tag}> <${tag}></${tag}>
</div> </div>
`), `)),
}); });
await ctrl.show(); await ctrl.show();
// Don't hide on inside shadowDom click // Don't hide on inside shadowDom click
ctrl.contentNode.querySelector(tagString).shadowRoot.querySelector('button').click(); /** @type {ShadowRoot} */
// @ts-expect-error
(ctrl.contentNode.querySelector(tagString).shadowRoot).querySelector('button').click();
await aTimeout(); await aTimeout(0);
expect(ctrl.isShown).to.be.true; expect(ctrl.isShown).to.be.true;
// Important to check if it can be still shown after, because we do some hacks inside // Important to check if it can be still shown after, because we do some hacks inside
@ -522,15 +545,21 @@ describe('OverlayController', () => {
}); });
it('works with 3rd party code using "event.stopPropagation()" on bubble phase', async () => { it('works with 3rd party code using "event.stopPropagation()" on bubble phase', async () => {
const invokerNode = await fixture('<div role="button">Invoker</div>'); const invokerNode = /** @type {HTMLElement} */ (await fixture(
const contentNode = await fixture('<div>Content</div>'); '<div role="button">Invoker</div>',
));
const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>'));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
hidesOnOutsideClick: true, hidesOnOutsideClick: true,
contentNode, contentNode,
invokerNode, invokerNode,
}); });
const dom = await fixture(` const dom = await fixture(
/**
* @param {{ stopPropagation: () => any; }} e
*/
`
<div> <div>
<div id="popup">${invokerNode}${contentNode}</div> <div id="popup">${invokerNode}${contentNode}</div>
<div <div
@ -539,17 +568,19 @@ describe('OverlayController', () => {
/* propagates */ /* propagates */
}}" }}"
></div> ></div>
<third-party-noise @click="${e => e.stopPropagation()}"> <third-party-noise @click="${(/** @type {Event} */ e) => e.stopPropagation()}">
This element prevents our handlers from reaching the document click handler. This element prevents our handlers from reaching the document click handler.
</third-party-noise> </third-party-noise>
</div> </div>
`); `,
);
await ctrl.show(); await ctrl.show();
expect(ctrl.isShown).to.equal(true); expect(ctrl.isShown).to.equal(true);
dom.querySelector('third-party-noise').click(); /** @type {HTMLElement} */
await aTimeout(); (dom.querySelector('third-party-noise')).click();
await aTimeout(0);
expect(ctrl.isShown).to.equal(false); expect(ctrl.isShown).to.equal(false);
// Important to check if it can be still shown after, because we do some hacks inside // Important to check if it can be still shown after, because we do some hacks inside
@ -558,15 +589,17 @@ describe('OverlayController', () => {
}); });
it('works with 3rd party code using "event.stopPropagation()" on capture phase', async () => { it('works with 3rd party code using "event.stopPropagation()" on capture phase', async () => {
const invokerNode = await fixture(html`<div role="button">Invoker</div>`); const invokerNode = /** @type {HTMLElement} */ (await fixture(
const contentNode = await fixture('<div>Content</div>'); html`<div role="button">Invoker</div>`,
));
const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>'));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
hidesOnOutsideClick: true, hidesOnOutsideClick: true,
contentNode, contentNode,
invokerNode, invokerNode,
}); });
const dom = await fixture(` const dom = /** @type {HTMLElement} */ (await fixture(`
<div> <div>
<div id="popup">${invokerNode}${ctrl.content}</div> <div id="popup">${invokerNode}${ctrl.content}</div>
<div <div
@ -579,11 +612,12 @@ describe('OverlayController', () => {
This element prevents our handlers from reaching the document click handler. This element prevents our handlers from reaching the document click handler.
</third-party-noise> </third-party-noise>
</div> </div>
`); `));
dom.querySelector('third-party-noise').addEventListener( /** @type {HTMLElement} */
(dom.querySelector('third-party-noise')).addEventListener(
'click', 'click',
event => { (/** @type {Event} */ event) => {
event.stopPropagation(); event.stopPropagation();
}, },
true, true,
@ -592,8 +626,9 @@ describe('OverlayController', () => {
await ctrl.show(); await ctrl.show();
expect(ctrl.isShown).to.equal(true); expect(ctrl.isShown).to.equal(true);
dom.querySelector('third-party-noise').click(); /** @type {HTMLElement} */
await aTimeout(); (dom.querySelector('third-party-noise')).click();
await aTimeout(0);
expect(ctrl.isShown).to.equal(false); expect(ctrl.isShown).to.equal(false);
// Important to check if it can be still shown after, because we do some hacks inside // Important to check if it can be still shown after, because we do some hacks inside
@ -602,13 +637,13 @@ describe('OverlayController', () => {
}); });
it('doesn\'t hide on "inside label" click', async () => { it('doesn\'t hide on "inside label" click', async () => {
const contentNode = await fixture(` const contentNode = /** @type {HTMLElement} */ (await fixture(`
<div> <div>
<label for="test">test</label> <label for="test">test</label>
<input id="test"> <input id="test">
Content Content
</div>`); </div>`));
const labelNode = contentNode.querySelector('label[for=test]'); const labelNode = /** @type {HTMLElement} */ (contentNode.querySelector('label[for=test]'));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withGlobalTestConfig(), ...withGlobalTestConfig(),
hidesOnOutsideClick: true, hidesOnOutsideClick: true,
@ -618,7 +653,7 @@ describe('OverlayController', () => {
// Don't hide on label click // Don't hide on label click
labelNode.click(); labelNode.click();
await aTimeout(); await aTimeout(0);
expect(ctrl.isShown).to.be.true; expect(ctrl.isShown).to.be.true;
}); });
@ -626,7 +661,7 @@ describe('OverlayController', () => {
describe('elementToFocusAfterHide', () => { describe('elementToFocusAfterHide', () => {
it('focuses body when hiding by default', async () => { it('focuses body when hiding by default', async () => {
const contentNode = await fixture('<div><input /></div>'); const contentNode = /** @type {HTMLElement} */ (await fixture('<div><input /></div>'));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withGlobalTestConfig(), ...withGlobalTestConfig(),
viewportConfig: { viewportConfig: {
@ -636,7 +671,7 @@ describe('OverlayController', () => {
}); });
await ctrl.show(); await ctrl.show();
const input = contentNode.querySelector('input'); const input = /** @type {HTMLInputElement} */ (contentNode.querySelector('input'));
input.focus(); input.focus();
expect(document.activeElement).to.equal(input); expect(document.activeElement).to.equal(input);
@ -646,8 +681,10 @@ describe('OverlayController', () => {
}); });
it('supports elementToFocusAfterHide option to focus it when hiding', async () => { it('supports elementToFocusAfterHide option to focus it when hiding', async () => {
const input = await fixture('<input />'); const input = /** @type {HTMLElement} */ (await fixture('<input />'));
const contentNode = await fixture('<div><textarea></textarea></div>'); const contentNode = /** @type {HTMLElement} */ (await fixture(
'<div><textarea></textarea></div>',
));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withGlobalTestConfig(), ...withGlobalTestConfig(),
elementToFocusAfterHide: input, elementToFocusAfterHide: input,
@ -655,7 +692,7 @@ describe('OverlayController', () => {
}); });
await ctrl.show(); await ctrl.show();
const textarea = contentNode.querySelector('textarea'); const textarea = /** @type {HTMLTextAreaElement} */ (contentNode.querySelector('textarea'));
textarea.focus(); textarea.focus();
expect(document.activeElement).to.equal(textarea); expect(document.activeElement).to.equal(textarea);
@ -664,8 +701,10 @@ describe('OverlayController', () => {
}); });
it('allows to set elementToFocusAfterHide on show', async () => { it('allows to set elementToFocusAfterHide on show', async () => {
const input = await fixture('<input />'); const input = /** @type {HTMLElement} */ (await fixture('<input />'));
const contentNode = await fixture('<div><textarea></textarea></div>'); const contentNode = /** @type {HTMLElement} */ (await fixture(
'<div><textarea></textarea></div>',
));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withGlobalTestConfig(), ...withGlobalTestConfig(),
viewportConfig: { viewportConfig: {
@ -675,7 +714,7 @@ describe('OverlayController', () => {
}); });
await ctrl.show(input); await ctrl.show(input);
const textarea = contentNode.querySelector('textarea'); const textarea = /** @type {HTMLTextAreaElement} */ (contentNode.querySelector('textarea'));
textarea.focus(); textarea.focus();
expect(document.activeElement).to.equal(textarea); expect(document.activeElement).to.equal(textarea);
@ -1067,7 +1106,7 @@ describe('OverlayController', () => {
it('reinitializes content', async () => { it('reinitializes content', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode: await fixture(html`<div>content1</div>`), contentNode: /** @type {HTMLElement} */ (await fixture(html`<div>content1</div>`)),
}); });
await ctrl.show(); // Popper adds inline styles await ctrl.show(); // Popper adds inline styles
expect(ctrl.content.style.transform).not.to.be.undefined; expect(ctrl.content.style.transform).not.to.be.undefined;
@ -1075,18 +1114,18 @@ describe('OverlayController', () => {
ctrl.updateConfig({ ctrl.updateConfig({
placementMode: 'local', placementMode: 'local',
contentNode: await fixture(html`<div>content2</div>`), contentNode: /** @type {HTMLElement} */ (await fixture(html`<div>content2</div>`)),
}); });
expect(ctrl.contentNode.textContent).to.include('content2'); expect(ctrl.contentNode.textContent).to.include('content2');
}); });
it('respects the initial config provided to new OverlayController(initialConfig)', async () => { it('respects the initial config provided to new OverlayController(initialConfig)', async () => {
const contentNode = fixtureSync(html`<div>my content</div>`); const contentNode = /** @type {HTMLElement} */ (fixtureSync(html`<div>my content</div>`));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
// This is the shared config // This is the shared config
placementMode: 'global', placementMode: 'global',
handlesAccesibility: true, handlesAccessibility: true,
contentNode, contentNode,
}); });
ctrl.updateConfig({ ctrl.updateConfig({
@ -1095,32 +1134,30 @@ describe('OverlayController', () => {
hidesOnEsc: true, hidesOnEsc: true,
}); });
expect(ctrl.placementMode).to.equal('local'); expect(ctrl.placementMode).to.equal('local');
expect(ctrl.handlesAccesibility).to.equal(true); expect(ctrl.handlesAccessibility).to.equal(true);
expect(ctrl.contentNode).to.equal(contentNode); expect(ctrl.contentNode).to.equal(contentNode);
}); });
// Currently not working, enable again when we fix updateConfig // Currently not working, enable again when we fix updateConfig
it.skip('allows for updating viewport config placement only, while keeping the content shown', async () => { it.skip('allows for updating viewport config placement only, while keeping the content shown', async () => {
const contentNode = fixtureSync(html`<div>my content</div>`); const contentNode = /** @type {HTMLElement} */ (fixtureSync(html`<div>my content</div>`));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
// This is the shared config // This is the shared config
placementMode: 'global', placementMode: 'global',
handlesAccesibility: true, handlesAccessibility: true,
contentNode, contentNode,
}); });
ctrl.show(); ctrl.show();
expect( expect(
ctrl._contentWrapperNode.classList.contains('global-overlays__overlay-container--center'), ctrl.contentWrapperNode.classList.contains('global-overlays__overlay-container--center'),
); );
expect(ctrl.isShown).to.be.true; expect(ctrl.isShown).to.be.true;
ctrl.updateConfig({ viewportConfig: { placement: 'top-right' } }); ctrl.updateConfig({ viewportConfig: { placement: 'top-right' } });
expect( expect(
ctrl._contentWrapperNode.classList.contains( ctrl.contentWrapperNode.classList.contains('global-overlays__overlay-container--top-right'),
'global-overlays__overlay-container--top-right',
),
); );
expect(ctrl.isShown).to.be.true; expect(ctrl.isShown).to.be.true;
}); });
@ -1128,17 +1165,19 @@ describe('OverlayController', () => {
describe('Accessibility', () => { describe('Accessibility', () => {
it('synchronizes [aria-expanded] on invoker', async () => { it('synchronizes [aria-expanded] on invoker', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>'); const invokerNode = /** @type {HTMLElement} */ (await fixture(
'<div role="button">invoker</div>',
));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
handlesAccessibility: true, handlesAccessibility: true,
invokerNode, invokerNode,
}); });
expect(ctrl.invokerNode.getAttribute('aria-expanded')).to.equal('false'); expect(ctrl.invokerNode?.getAttribute('aria-expanded')).to.equal('false');
await ctrl.show(); await ctrl.show();
expect(ctrl.invokerNode.getAttribute('aria-expanded')).to.equal('true'); expect(ctrl.invokerNode?.getAttribute('aria-expanded')).to.equal('true');
await ctrl.hide(); await ctrl.hide();
expect(ctrl.invokerNode.getAttribute('aria-expanded')).to.equal('false'); expect(ctrl.invokerNode?.getAttribute('aria-expanded')).to.equal('false');
}); });
it('creates unique id for content', async () => { it('creates unique id for content', async () => {
@ -1150,7 +1189,9 @@ describe('OverlayController', () => {
}); });
it('preserves content id when present', async () => { it('preserves content id when present', async () => {
const contentNode = await fixture('<div id="preserved">content</div>'); const contentNode = /** @type {HTMLElement} */ (await fixture(
'<div id="preserved">content</div>',
));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
handlesAccessibility: true, handlesAccessibility: true,
@ -1160,7 +1201,9 @@ describe('OverlayController', () => {
}); });
it('adds [role=dialog] on content', async () => { it('adds [role=dialog] on content', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>'); const invokerNode = /** @type {HTMLElement} */ (await fixture(
'<div role="button">invoker</div>',
));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
handlesAccessibility: true, handlesAccessibility: true,
@ -1170,8 +1213,12 @@ describe('OverlayController', () => {
}); });
it('preserves [role] on content when present', async () => { it('preserves [role] on content when present', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>'); const invokerNode = /** @type {HTMLElement} */ (await fixture(
const contentNode = await fixture('<div role="menu">invoker</div>'); '<div role="button">invoker</div>',
));
const contentNode = /** @type {HTMLElement} */ (await fixture(
'<div role="menu">invoker</div>',
));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
handlesAccessibility: true, handlesAccessibility: true,
@ -1187,7 +1234,7 @@ describe('OverlayController', () => {
new OverlayController({ new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
handlesAccessibility: true, handlesAccessibility: true,
invokerNode: null, invokerNode: undefined,
}); });
properlyInstantiated = true; properlyInstantiated = true;
} catch (e) { } catch (e) {
@ -1282,18 +1329,22 @@ describe('OverlayController', () => {
describe('Tooltip', () => { describe('Tooltip', () => {
it('adds [aria-describedby] on invoker', async () => { it('adds [aria-describedby] on invoker', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>'); const invokerNode = /** @type {HTMLElement} */ (await fixture(
'<div role="button">invoker</div>',
));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
handlesAccessibility: true, handlesAccessibility: true,
isTooltip: true, isTooltip: true,
invokerNode, invokerNode,
}); });
expect(ctrl.invokerNode.getAttribute('aria-describedby')).to.equal(ctrl._contentId); expect(ctrl.invokerNode?.getAttribute('aria-describedby')).to.equal(ctrl._contentId);
}); });
it('adds [aria-labelledby] on invoker when invokerRelation is label', async () => { it('adds [aria-labelledby] on invoker when invokerRelation is label', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>'); const invokerNode = /** @type {HTMLElement} */ (await fixture(
'<div role="button">invoker</div>',
));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
handlesAccessibility: true, handlesAccessibility: true,
@ -1301,12 +1352,14 @@ describe('OverlayController', () => {
invokerRelation: 'label', invokerRelation: 'label',
invokerNode, invokerNode,
}); });
expect(ctrl.invokerNode.getAttribute('aria-describedby')).to.equal(null); expect(ctrl.invokerNode?.getAttribute('aria-describedby')).to.equal(null);
expect(ctrl.invokerNode.getAttribute('aria-labelledby')).to.equal(ctrl._contentId); expect(ctrl.invokerNode?.getAttribute('aria-labelledby')).to.equal(ctrl._contentId);
}); });
it('adds [role=tooltip] on content', async () => { it('adds [role=tooltip] on content', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>'); const invokerNode = /** @type {HTMLElement} */ (await fixture(
'<div role="button">invoker</div>',
));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
handlesAccessibility: true, handlesAccessibility: true,
@ -1318,7 +1371,9 @@ describe('OverlayController', () => {
describe('Teardown', () => { describe('Teardown', () => {
it('restores [role] on dialog content', async () => { it('restores [role] on dialog content', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>'); const invokerNode = /** @type {HTMLElement} */ (await fixture(
'<div role="button">invoker</div>',
));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
handlesAccessibility: true, handlesAccessibility: true,
@ -1330,8 +1385,12 @@ describe('OverlayController', () => {
}); });
it('restores [role] on tooltip content', async () => { it('restores [role] on tooltip content', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>'); const invokerNode = /** @type {HTMLElement} */ (await fixture(
const contentNode = await fixture('<div role="presentation">content</div>'); '<div role="button">invoker</div>',
));
const contentNode = /** @type {HTMLElement} */ (await fixture(
'<div role="presentation">content</div>',
));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
handlesAccessibility: true, handlesAccessibility: true,
@ -1345,8 +1404,12 @@ describe('OverlayController', () => {
}); });
it('restores [aria-describedby] on content', async () => { it('restores [aria-describedby] on content', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>'); const invokerNode = /** @type {HTMLElement} */ (await fixture(
const contentNode = await fixture('<div role="presentation">content</div>'); '<div role="button">invoker</div>',
));
const contentNode = /** @type {HTMLElement} */ (await fixture(
'<div role="presentation">content</div>',
));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
handlesAccessibility: true, handlesAccessibility: true,
@ -1360,8 +1423,12 @@ describe('OverlayController', () => {
}); });
it('restores [aria-labelledby] on content', async () => { it('restores [aria-labelledby] on content', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>'); const invokerNode = /** @type {HTMLElement} */ (await fixture(
const contentNode = await fixture('<div role="presentation">content</div>'); '<div role="button">invoker</div>',
));
const contentNode = /** @type {HTMLElement} */ (await fixture(
'<div role="presentation">content</div>',
));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
handlesAccessibility: true, handlesAccessibility: true,
@ -1393,6 +1460,7 @@ describe('OverlayController', () => {
it('throws if invalid .placementMode gets passed on', async () => { it('throws if invalid .placementMode gets passed on', async () => {
expect(() => { expect(() => {
new OverlayController({ new OverlayController({
// @ts-ignore
placementMode: 'invalid', placementMode: 'invalid',
}); });
}).to.throw( }).to.throw(
@ -1411,7 +1479,7 @@ describe('OverlayController', () => {
it('throws if contentNodeWrapper is not provided for projected contentNode', async () => { it('throws if contentNodeWrapper is not provided for projected contentNode', async () => {
const shadowHost = document.createElement('div'); const shadowHost = document.createElement('div');
shadowHost.attachShadow({ mode: 'open' }); shadowHost.attachShadow({ mode: 'open' });
shadowHost.shadowRoot.innerHTML = ` /** @type {ShadowRoot} */ (shadowHost.shadowRoot).innerHTML = `
<div id="contentWrapperNode"> <div id="contentWrapperNode">
<slot name="contentNode"></slot> <slot name="contentNode"></slot>
<my-arrow></my-arrow> <my-arrow></my-arrow>

View file

@ -2,12 +2,18 @@ import { expect, fixture, html } from '@open-wc/testing';
import { OverlayController } from '../src/OverlayController.js'; import { OverlayController } from '../src/OverlayController.js';
import { OverlaysManager } from '../src/OverlaysManager.js'; import { OverlaysManager } from '../src/OverlaysManager.js';
/**
* @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig
*/
describe('OverlaysManager', () => { describe('OverlaysManager', () => {
/** @type {OverlayConfig} */
let defaultOptions; let defaultOptions;
/** @type {OverlaysManager} */
let mngr; let mngr;
beforeEach(async () => { beforeEach(async () => {
const contentNode = await fixture(html`<p>my content</p>`); const contentNode = /** @type {HTMLElement} */ (await fixture(html`<p>my content</p>`));
defaultOptions = { defaultOptions = {
placementMode: 'global', placementMode: 'global',
@ -36,8 +42,8 @@ describe('OverlaysManager', () => {
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(mngr.constructor.__globalRootNode).to.be.undefined; expect(OverlaysManager.__globalRootNode).to.be.undefined;
expect(mngr.constructor.__globalStyleNode).to.be.undefined; expect(OverlaysManager.__globalStyleNode).to.be.undefined;
}); });
it('can add/remove controllers', () => { it('can add/remove controllers', () => {

View file

@ -3,10 +3,16 @@ import { fixtureSync } from '@open-wc/testing-helpers';
import { OverlayController } from '../src/OverlayController.js'; import { OverlayController } from '../src/OverlayController.js';
import { overlays } from '../src/overlays.js'; import { overlays } from '../src/overlays.js';
const withDefaultGlobalConfig = () => ({ /**
placementMode: 'global', * @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig
contentNode: fixtureSync(html`<p>my content</p>`), * @typedef {import('../types/OverlayConfig').ViewportPlacement} ViewportPlacement
}); */
const withDefaultGlobalConfig = () =>
/** @type {OverlayConfig} */ ({
placementMode: 'global',
contentNode: fixtureSync(html`<p>my content</p>`),
});
describe('Global Positioning', () => { describe('Global Positioning', () => {
afterEach(() => { afterEach(() => {
@ -50,7 +56,7 @@ describe('Global Positioning', () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withDefaultGlobalConfig(), ...withDefaultGlobalConfig(),
viewportConfig: { viewportConfig: {
placement: viewportPlacement, placement: /** @type {ViewportPlacement} */ (viewportPlacement),
}, },
}); });
await ctrl.show(); await ctrl.show();

View file

@ -1,15 +1,22 @@
import { expect, fixture, fixtureSync, html } from '@open-wc/testing'; import { expect, fixture, fixtureSync, html } from '@open-wc/testing';
// @ts-ignore
import Popper from 'popper.js/dist/esm/popper.min.js'; import Popper from 'popper.js/dist/esm/popper.min.js';
import { OverlayController } from '../src/OverlayController.js'; import { OverlayController } from '../src/OverlayController.js';
import { normalizeTransformStyle } from './utils-tests/local-positioning-helpers.js'; import { normalizeTransformStyle } from './utils-tests/local-positioning-helpers.js';
const withLocalTestConfig = () => ({ /**
placementMode: 'local', * @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig
contentNode: fixtureSync(html` <div>my content</div> `), * @typedef {import('../types/OverlayConfig').ViewportPlacement} ViewportPlacement
invokerNode: fixtureSync(html` */
<div role="button" style="width: 100px; height: 20px;">Invoker</div>
`), const withLocalTestConfig = () =>
}); /** @type {OverlayConfig} */ ({
placementMode: 'local',
contentNode: /** @type {HTMLElement} */ (fixtureSync(html` <div>my content</div> `)),
invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;">Invoker</div>
`)),
});
describe('Local Positioning', () => { describe('Local Positioning', () => {
// Please use absolute positions in the tests below to prevent the HTML generated by // Please use absolute positions in the tests below to prevent the HTML generated by
@ -20,23 +27,23 @@ describe('Local Positioning', () => {
...withLocalTestConfig(), ...withLocalTestConfig(),
}); });
await ctrl.show(); await ctrl.show();
expect(ctrl._popper).to.be.an.instanceof(Popper); expect(/** @type {Popper} */ (ctrl._popper)).to.be.an.instanceof(Popper);
expect(ctrl._popper.modifiers).to.exist; expect(/** @type {Popper} */ (ctrl._popper).modifiers).to.exist;
await ctrl.hide(); await ctrl.hide();
expect(ctrl._popper).to.be.an.instanceof(Popper); expect(/** @type {Popper} */ (ctrl._popper)).to.be.an.instanceof(Popper);
expect(ctrl._popper.modifiers).to.exist; expect(/** @type {Popper} */ (ctrl._popper).modifiers).to.exist;
}); });
it('positions correctly', async () => { it('positions correctly', async () => {
// smoke test for integration of popper // smoke test for integration of popper
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode: fixtureSync(html` contentNode: /** @type {HTMLElement} */ (fixtureSync(html`
<div style="width: 80px; height: 30px; background: green;"></div> <div style="width: 80px; height: 30px; background: green;"></div>
`), `)),
invokerNode: fixtureSync(html` invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
<div role="button" style="width: 20px; height: 10px; background: orange;"></div> <div role="button" style="width: 20px; height: 10px; background: orange;"></div>
`), `)),
}); });
await fixture(html` await fixture(html`
<div style="position: fixed; left: 100px; top: 100px;"> <div style="position: fixed; left: 100px; top: 100px;">
@ -54,10 +61,12 @@ describe('Local Positioning', () => {
it('uses top as the default placement', async () => { it('uses top as the default placement', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `), contentNode: /** @type {HTMLElement} */ (fixtureSync(
invokerNode: fixtureSync(html` html` <div style="width: 80px; height: 20px;"></div> `,
)),
invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div> <div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`), `)),
}); });
await fixture(html` await fixture(html`
<div style="position: fixed; left: 100px; top: 100px;"> <div style="position: fixed; left: 100px; top: 100px;">
@ -71,10 +80,12 @@ describe('Local Positioning', () => {
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 () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `), contentNode: /** @type {HTMLElement} */ (fixtureSync(
invokerNode: fixtureSync(html` html` <div style="width: 80px; height: 20px;"></div> `,
)),
invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div> <div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`), `)),
popperConfig: { popperConfig: {
placement: 'left-start', placement: 'left-start',
}, },
@ -92,12 +103,14 @@ describe('Local Positioning', () => {
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 () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;">invoker</div> `), contentNode: /** @type {HTMLElement} */ (fixtureSync(
invokerNode: fixtureSync(html` html` <div style="width: 80px; height: 20px;">invoker</div> `,
)),
invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}> <div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}>
content content
</div> </div>
`), `)),
popperConfig: { popperConfig: {
placement: 'top-start', placement: 'top-start',
}, },
@ -113,10 +126,12 @@ describe('Local Positioning', () => {
it('allows the user to override default Popper modifiers', async () => { it('allows the user to override default Popper modifiers', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `), contentNode: /** @type {HTMLElement} */ (fixtureSync(
invokerNode: fixtureSync(html` html` <div style="width: 80px; height: 20px;"></div> `,
)),
invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div> <div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`), `)),
popperConfig: { popperConfig: {
modifiers: { modifiers: {
keepTogether: { keepTogether: {
@ -136,8 +151,12 @@ describe('Local Positioning', () => {
`); `);
await ctrl.show(); await ctrl.show();
const keepTogether = ctrl._popper.modifiers.find(item => item.name === 'keepTogether'); const keepTogether = /** @type {Popper} */ (ctrl._popper).modifiers.find(
const offset = ctrl._popper.modifiers.find(item => item.name === 'offset'); (/** @type {{ name: string }} */ item) => item.name === 'keepTogether',
);
const offset = /** @type {Popper} */ (ctrl._popper).modifiers.find(
(/** @type {{ name: string }} */ item) => item.name === 'offset',
);
expect(keepTogether.enabled).to.be.false; expect(keepTogether.enabled).to.be.false;
expect(offset.enabled).to.be.true; expect(offset.enabled).to.be.true;
expect(offset.offset).to.equal('0, 16px'); expect(offset.offset).to.equal('0, 16px');
@ -146,10 +165,12 @@ 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: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `), contentNode: /** @type {HTMLElement} */ (fixtureSync(
invokerNode: fixtureSync(html` html` <div style="width: 80px; height: 20px;"></div> `,
)),
invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div> <div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`), `)),
popperConfig: { popperConfig: {
placement: 'top', placement: 'top',
}, },
@ -177,10 +198,12 @@ describe('Local Positioning', () => {
it.skip('updates placement properly even during hidden state', async () => { it.skip('updates placement properly even during hidden state', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `), contentNode: /** @type {HTMLElement} */ (fixtureSync(
invokerNode: fixtureSync(html` html` <div style="width: 80px; height: 20px;"></div> `,
)),
invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div> <div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}></div>
`), `)),
popperConfig: { popperConfig: {
placement: 'top', placement: 'top',
modifiers: { modifiers: {
@ -215,7 +238,9 @@ describe('Local Positioning', () => {
}, },
}); });
await ctrl.show(); await ctrl.show();
expect(ctrl._popper.options.modifiers.offset.offset).to.equal('0, 20px'); expect(/** @type {Popper} */ (ctrl._popper).options.modifiers.offset.offset).to.equal(
'0, 20px',
);
expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( expect(normalizeTransformStyle(ctrl.content.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',
@ -226,12 +251,14 @@ describe('Local Positioning', () => {
it.skip('updates positioning correctly during shown state when config gets updated', async () => { it.skip('updates positioning correctly during shown state when config gets updated', async () => {
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode: fixtureSync(html` <div style="width: 80px; height: 20px;"></div> `), contentNode: /** @type {HTMLElement} */ (fixtureSync(
invokerNode: fixtureSync(html` html` <div style="width: 80px; height: 20px;"></div> `,
)),
invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}> <div role="button" style="width: 100px; height: 20px;" @click=${() => ctrl.show()}>
Invoker Invoker
</div> </div>
`), `)),
popperConfig: { popperConfig: {
placement: 'top', placement: 'top',
modifiers: { modifiers: {
@ -271,9 +298,9 @@ describe('Local Positioning', () => {
}); });
it('can set the contentNode minWidth as the invokerNode width', async () => { it('can set the contentNode minWidth as the invokerNode width', async () => {
const invokerNode = await fixture(html` const invokerNode = /** @type {HTMLElement} */ (await fixture(html`
<div role="button" style="width: 60px;">invoker</div> <div role="button" style="width: 60px;">invoker</div>
`); `));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
inheritsReferenceWidth: 'min', inheritsReferenceWidth: 'min',
@ -284,9 +311,9 @@ describe('Local Positioning', () => {
}); });
it('can set the contentNode maxWidth as the invokerNode width', async () => { it('can set the contentNode maxWidth as the invokerNode width', async () => {
const invokerNode = await fixture(html` const invokerNode = /** @type {HTMLElement} */ (await fixture(html`
<div role="button" style="width: 60px;">invoker</div> <div role="button" style="width: 60px;">invoker</div>
`); `));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
inheritsReferenceWidth: 'max', inheritsReferenceWidth: 'max',
@ -297,9 +324,9 @@ describe('Local Positioning', () => {
}); });
it('can set the contentNode width as the invokerNode width', async () => { it('can set the contentNode width as the invokerNode width', async () => {
const invokerNode = await fixture(html` const invokerNode = /** @type {HTMLElement} */ (await fixture(html`
<div role="button" style="width: 60px;">invoker</div> <div role="button" style="width: 60px;">invoker</div>
`); `));
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
inheritsReferenceWidth: 'full', inheritsReferenceWidth: 'full',

View file

@ -13,9 +13,9 @@ describe('getDeepActiveElement()', () => {
</div> </div>
`); `);
const el1 = element.querySelector('#el-1'); const el1 = /** @type {HTMLElement} */ (element.querySelector('#el-1'));
const el2 = element.querySelector('#el-2'); const el2 = /** @type {HTMLElement} */ (element.querySelector('#el-2'));
const el3 = element.querySelector('#el-3'); const el3 = /** @type {HTMLElement} */ (element.querySelector('#el-3'));
el1.focus(); el1.focus();
expect(getDeepActiveElement()).to.eql(el1); expect(getDeepActiveElement()).to.eql(el1);
@ -59,13 +59,16 @@ describe('getDeepActiveElement()', () => {
</div> </div>
`); `);
const elA = element.querySelector(elTag).shadowRoot; const elTagEl = /** @type {HTMLElement} */ (element.querySelector(elTag));
const elB = elA.querySelector(elNestedTag).shadowRoot; const elA = /** @type {ShadowRoot} */ (elTagEl.shadowRoot);
const elA1 = elA.querySelector('#el-a-1'); const elNestedTagEl = /** @type {HTMLElement} */ (elA.querySelector(elNestedTag));
const elA2 = elA.querySelector('#el-a-2'); const elB = /** @type {ShadowRoot} */ (elNestedTagEl.shadowRoot);
const elB1 = elB.querySelector('#el-b-1');
const elB2 = elB.querySelector('#el-b-1'); const elA1 = /** @type {HTMLElement} */ (elA.querySelector('#el-a-1'));
const el1 = element.querySelector('#el-1'); const elA2 = /** @type {HTMLElement} */ (elA.querySelector('#el-a-2'));
const elB1 = /** @type {HTMLElement} */ (elB.querySelector('#el-b-1'));
const elB2 = /** @type {HTMLElement} */ (elB.querySelector('#el-b-1'));
const el1 = /** @type {HTMLElement} */ (element.querySelector('#el-1'));
elA1.focus(); elA1.focus();
expect(getDeepActiveElement()).to.eql(elA1); expect(getDeepActiveElement()).to.eql(elA1);

View file

@ -1,6 +1,6 @@
import { expect, fixture, html } from '@open-wc/testing'; import { expect, fixture, html } from '@open-wc/testing';
// @ts-expect-error
import { renderLitAsNode } from '@lion/helpers'; import { renderLitAsNode } from '@lion/helpers';
import { getDeepActiveElement } from '../../src/utils/get-deep-active-element.js'; import { getDeepActiveElement } from '../../src/utils/get-deep-active-element.js';
import { getFocusableElements } from '../../src/utils/get-focusable-elements.js'; import { getFocusableElements } from '../../src/utils/get-focusable-elements.js';
import { keyCodes } from '../../src/utils/key-codes.js'; import { keyCodes } from '../../src/utils/key-codes.js';
@ -8,10 +8,14 @@ import { containFocus } from '../../src/utils/contain-focus.js';
function simulateTabWithinContainFocus() { function simulateTabWithinContainFocus() {
const event = new CustomEvent('keydown', { detail: 0, bubbles: true }); const event = new CustomEvent('keydown', { detail: 0, bubbles: true });
// @ts-ignore override keyCode
event.keyCode = keyCodes.tab; event.keyCode = keyCodes.tab;
window.dispatchEvent(event); window.dispatchEvent(event);
} }
/**
* @param {HTMLElement} elToRecieveFocus
*/
function simulateTabInWindow(elToRecieveFocus) { function simulateTabInWindow(elToRecieveFocus) {
window.dispatchEvent(new Event('blur')); window.dispatchEvent(new Event('blur'));
elToRecieveFocus.focus(); elToRecieveFocus.focus();
@ -77,7 +81,7 @@ function createShadowDomNode() {
describe('containFocus()', () => { describe('containFocus()', () => {
it('starts focus at the root element when there is no element with [autofocus]', async () => { it('starts focus at the root element when there is no element with [autofocus]', async () => {
await fixture(lightDomTemplate); await fixture(lightDomTemplate);
const root = document.getElementById('rootElement'); const root = /** @type {HTMLElement} */ (document.getElementById('rootElement'));
const { disconnect } = containFocus(root); const { disconnect } = containFocus(root);
expect(getDeepActiveElement()).to.equal(root); expect(getDeepActiveElement()).to.equal(root);
@ -89,7 +93,7 @@ describe('containFocus()', () => {
it('starts focus at the element with [autofocus] attribute', async () => { it('starts focus at the element with [autofocus] attribute', async () => {
await fixture(lightDomAutofocusTemplate); await fixture(lightDomAutofocusTemplate);
const el = document.querySelector('input[autofocus]'); const el = /** @type {HTMLElement} */ (document.querySelector('input[autofocus]'));
const { disconnect } = containFocus(el); const { disconnect } = containFocus(el);
expect(getDeepActiveElement()).to.equal(el); expect(getDeepActiveElement()).to.equal(el);
@ -99,11 +103,11 @@ describe('containFocus()', () => {
it('on tab, focuses first focusable element if focus was on element outside root element', async () => { it('on tab, focuses first focusable element if focus was on element outside root element', async () => {
await fixture(lightDomTemplate); await fixture(lightDomTemplate);
const root = document.getElementById('rootElement'); const root = /** @type {HTMLElement} */ (document.getElementById('rootElement'));
const focusableElements = getFocusableElements(root); const focusableElements = getFocusableElements(root);
const { disconnect } = containFocus(root); const { disconnect } = containFocus(root);
document.getElementById('outside-1').focus(); /** @type {HTMLElement} */ (document.getElementById('outside-1')).focus();
simulateTabWithinContainFocus(); simulateTabWithinContainFocus();
expect(getDeepActiveElement()).to.equal(focusableElements[0]); expect(getDeepActiveElement()).to.equal(focusableElements[0]);
@ -113,7 +117,7 @@ describe('containFocus()', () => {
it('on tab, focuses first focusable element if focus was on the last focusable element', async () => { it('on tab, focuses first focusable element if focus was on the last focusable element', async () => {
await fixture(lightDomTemplate); await fixture(lightDomTemplate);
const root = document.getElementById('rootElement'); const root = /** @type {HTMLElement} */ (document.getElementById('rootElement'));
const focusableElements = getFocusableElements(root); const focusableElements = getFocusableElements(root);
const { disconnect } = containFocus(root); const { disconnect } = containFocus(root);
@ -127,7 +131,7 @@ describe('containFocus()', () => {
it('on tab, does not interfere if focus remains within the root element', async () => { it('on tab, does not interfere if focus remains within the root element', async () => {
await fixture(lightDomTemplate); await fixture(lightDomTemplate);
const root = document.getElementById('rootElement'); const root = /** @type {HTMLElement} */ (document.getElementById('rootElement'));
const focusableElements = getFocusableElements(root); const focusableElements = getFocusableElements(root);
const { disconnect } = containFocus(root); const { disconnect } = containFocus(root);
@ -147,16 +151,16 @@ describe('containFocus()', () => {
describe('Tabbing into window', () => { describe('Tabbing into window', () => {
it('restores focus within root element', async () => { it('restores focus within root element', async () => {
await fixture(lightDomTemplate); await fixture(lightDomTemplate);
const root = document.getElementById('rootElement'); const root = /** @type {HTMLElement} */ (document.getElementById('rootElement'));
const focusableElements = getFocusableElements(root); const focusableElements = getFocusableElements(root);
const { disconnect } = containFocus(root); const { disconnect } = containFocus(root);
// Simulate tab in window // Simulate tab in window
simulateTabInWindow(document.getElementById('outside-1')); simulateTabInWindow(/** @type {HTMLElement} */ (document.getElementById('outside-1')));
expect(getDeepActiveElement()).to.equal(focusableElements[0]); expect(getDeepActiveElement()).to.equal(focusableElements[0]);
// Simulate shift+tab in window // Simulate shift+tab in window
simulateTabInWindow(document.getElementById('outside-2')); simulateTabInWindow(/** @type {HTMLElement} */ (document.getElementById('outside-2')));
expect(getDeepActiveElement()).to.equal(focusableElements[focusableElements.length - 1]); expect(getDeepActiveElement()).to.equal(focusableElements[focusableElements.length - 1]);
disconnect(); disconnect();
@ -164,16 +168,16 @@ describe('containFocus()', () => {
it('restores focus within root element with shadow dom', async () => { it('restores focus within root element with shadow dom', async () => {
const el = await fixture(html`${createShadowDomNode()}`); const el = await fixture(html`${createShadowDomNode()}`);
const root = el.querySelector('#rootElementShadow'); const root = /** @type {HTMLElement} */ (el.querySelector('#rootElementShadow'));
const focusableElements = getFocusableElements(root); const focusableElements = getFocusableElements(root);
const { disconnect } = containFocus(root); const { disconnect } = containFocus(root);
// Simulate tab in window // Simulate tab in window
simulateTabInWindow(document.getElementById('outside-1')); simulateTabInWindow(/** @type {HTMLElement} */ (document.getElementById('outside-1')));
expect(getDeepActiveElement()).to.equal(focusableElements[0]); expect(getDeepActiveElement()).to.equal(focusableElements[0]);
// Simulate shift+tab in window // Simulate shift+tab in window
simulateTabInWindow(document.getElementById('outside-2')); simulateTabInWindow(/** @type {HTMLElement} */ (document.getElementById('outside-2')));
expect(getDeepActiveElement()).to.equal(focusableElements[focusableElements.length - 1]); expect(getDeepActiveElement()).to.equal(focusableElements[focusableElements.length - 1]);
disconnect(); disconnect();
@ -181,7 +185,7 @@ describe('containFocus()', () => {
it('keeps focus if already in rootElement', async () => { it('keeps focus if already in rootElement', async () => {
const el = await fixture(html`${createShadowDomNode()}`); const el = await fixture(html`${createShadowDomNode()}`);
const root = el.querySelector('#rootElementShadow'); const root = /** @type {HTMLElement} */ (el.querySelector('#rootElementShadow'));
const focusableElements = getFocusableElements(root); const focusableElements = getFocusableElements(root);
const { disconnect } = containFocus(root); const { disconnect } = containFocus(root);

View file

@ -6,7 +6,7 @@
*/ */
export function normalizeTransformStyle(cssValue) { export function normalizeTransformStyle(cssValue) {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const [_, transformType, positionPart] = cssValue.match(/(.*)\((.*?)\)/); const [, transformType, positionPart] = cssValue.match(/(.*)\((.*?)\)/) || [];
const normalizedNumbers = positionPart const normalizedNumbers = positionPart
.split(',') .split(',')
.map(p => Math.round(Number(p.replace('px', '')))); .map(p => Math.round(Number(p.replace('px', ''))));

View file

@ -4,118 +4,126 @@ import { isVisible } from '../../src/utils/is-visible.js';
describe('isVisible()', () => { describe('isVisible()', () => {
it('returns true for static block elements', async () => { it('returns true for static block elements', async () => {
const element = await fixture(`<div style="width:10px; height:10px;"></div>`); const element = /** @type {HTMLElement} */ (await fixture(
`<div style="width:10px; height:10px;"></div>`,
));
expect(isVisible(element)).to.equal(true); expect(isVisible(element)).to.equal(true);
}); });
it('returns false for hidden static block elements', async () => { it('returns false for hidden static block elements', async () => {
const element = await fixture(`<div style="width:10px; height:10px;" hidden></div>`); const element = /** @type {HTMLElement} */ (await fixture(
`<div style="width:10px; height:10px;" hidden></div>`,
));
expect(isVisible(element)).to.equal(false); expect(isVisible(element)).to.equal(false);
}); });
it('returns true for relative block elements', async () => { it('returns true for relative block elements', async () => {
const element = await fixture( const element = /** @type {HTMLElement} */ (await fixture(
`<div style="width:10px; height:10px; position:relative; top:10px; left:10px;"></div>`, `<div style="width:10px; height:10px; position:relative; top:10px; left:10px;"></div>`,
); ));
expect(isVisible(element)).to.equal(true); expect(isVisible(element)).to.equal(true);
}); });
it('returns false for hidden relative block elements', async () => { it('returns false for hidden relative block elements', async () => {
const element = await fixture( const element = /** @type {HTMLElement} */ (await fixture(
`<div style="width:10px; height:10px; position:relative; top:10px; left:10px;" hidden></div>`, `<div style="width:10px; height:10px; position:relative; top:10px; left:10px;" hidden></div>`,
); ));
expect(isVisible(element)).to.equal(false); expect(isVisible(element)).to.equal(false);
}); });
it('returns true for absolute block elements', async () => { it('returns true for absolute block elements', async () => {
const element = await fixture(` const element = /** @type {HTMLElement} */ (await fixture(`
<div style="width:10px; height:10px; position:absolute; top:10px; left:10px;"></div> <div style="width:10px; height:10px; position:absolute; top:10px; left:10px;"></div>
`); `));
expect(isVisible(element)).to.equal(true); expect(isVisible(element)).to.equal(true);
}); });
it('returns false for hidden absolute block elements', async () => { it('returns false for hidden absolute block elements', async () => {
const element = await fixture(` const element = /** @type {HTMLElement} */ (await fixture(`
<div style="width:10px; height:10px; position:absolute; top:10px; left:10px;" hidden></div> <div style="width:10px; height:10px; position:absolute; top:10px; left:10px;" hidden></div>
`); `));
expect(isVisible(element)).to.equal(false); expect(isVisible(element)).to.equal(false);
}); });
it('returns true for relative block elements', async () => { it('returns true for relative block elements', async () => {
const element = await fixture(` const element = /** @type {HTMLElement} */ (await fixture(`
<div style="width:10px; height:10px; position:fixed;top:10px; left:10px;"></div> <div style="width:10px; height:10px; position:fixed;top:10px; left:10px;"></div>
`); `));
expect(isVisible(element)).to.equal(true); expect(isVisible(element)).to.equal(true);
}); });
it('returns true for relative block elements', async () => { it('returns true for relative block elements', async () => {
const element = await fixture(` const element = /** @type {HTMLElement} */ (await fixture(`
<div style="width:10px; height:10px; position:fixed;top:10px; left:10px;" hidden></div> <div style="width:10px; height:10px; position:fixed;top:10px; left:10px;" hidden></div>
`); `));
expect(isVisible(element)).to.equal(false); expect(isVisible(element)).to.equal(false);
}); });
it('returns true for inline elements', async () => { it('returns true for inline elements', async () => {
const element = await fixture(`<span>Inline content</span>`); const element = /** @type {HTMLElement} */ (await fixture(`<span>Inline content</span>`));
expect(isVisible(element)).to.equal(true); expect(isVisible(element)).to.equal(true);
}); });
it('returns true for inline elements without content', async () => { it('returns true for inline elements without content', async () => {
const element = await fixture(`<span></span>`); const element = /** @type {HTMLElement} */ (await fixture(`<span></span>`));
expect(isVisible(element)).to.equal(true); expect(isVisible(element)).to.equal(true);
}); });
it('returns true for static block elements with 0 dimensions', async () => { it('returns true for static block elements with 0 dimensions', async () => {
const element = await fixture(`<div style="width:0; height:0;"></div>`); const element = /** @type {HTMLElement} */ (await fixture(
`<div style="width:0; height:0;"></div>`,
));
expect(isVisible(element)).to.equal(true); expect(isVisible(element)).to.equal(true);
}); });
it('returns false for hidden inline elements', async () => { it('returns false for hidden inline elements', async () => {
const element = await fixture(`<span hidden>Inline content</span>`); const element = /** @type {HTMLElement} */ (await fixture(
`<span hidden>Inline content</span>`,
));
expect(isVisible(element)).to.equal(false); expect(isVisible(element)).to.equal(false);
}); });
it('returns false invisible elements', async () => { it('returns false invisible elements', async () => {
const element = await fixture( const element = /** @type {HTMLElement} */ (await fixture(
`<div style="width:10px; height:10px; visibility: hidden;"></div>`, `<div style="width:10px; height:10px; visibility: hidden;"></div>`,
); ));
expect(isVisible(element)).to.equal(false); expect(isVisible(element)).to.equal(false);
}); });
it('returns false when hidden by parent', async () => { it('returns false when hidden by parent', async () => {
const element = await fixture(` const element = /** @type {HTMLElement} */ (await fixture(`
<div hidden> <div hidden>
<div id="target" style="width:10px; height:10px;"></div> <div id="target" style="width:10px; height:10px;"></div>
<div></div> <div></div>
</div> </div>
`); `));
const target = element.querySelector('#target'); const target = /** @type {HTMLElement} */ (element.querySelector('#target'));
expect(isVisible(target)).to.equal(false); expect(isVisible(target)).to.equal(false);
}); });
it('returns false when invisible by parent', async () => { it('returns false when invisible by parent', async () => {
const element = await fixture(` const element = /** @type {HTMLElement} */ (await fixture(`
<div style="visibility: hidden;"> <div style="visibility: hidden;">
<div id="target" style="width:10px; height:10px;"></div> <div id="target" style="width:10px; height:10px;"></div>
<div></div> <div></div>
</div> </div>
`); `));
const target = element.querySelector('#target'); const target = /** @type {HTMLElement} */ (element.querySelector('#target'));
expect(isVisible(target)).to.equal(false); expect(isVisible(target)).to.equal(false);
}); });
}); });

View file

@ -0,0 +1,69 @@
import { PopperOptions } from 'popper.js';
export interface OverlayConfig {
/** Determines the connection point in DOM (body vs next to invoker). */
placementMode?: 'global' | 'local' | undefined;
/** The interactive element (usually a button) invoking the dialog or tooltip */
invokerNode?: HTMLElement;
/** The element that is used to position the overlay content relative to. Usually, this is the same element as invokerNode. Should only be provided when invokerNode should not be positioned against */
referenceNode?: HTMLElement | undefined;
/** The most important element: the overlay itself */
contentNode?: HTMLElement;
/** The wrapper element of contentNode, used to supply inline positioning styles. When a Popper arrow is needed, it acts as parent of the arrow node. Will be automatically created for global and non projected contentNodes. Required when used in shadow dom mode or when Popper arrow is supplied. Essential for allowing webcomponents to style their projected contentNodes */
contentWrapperNode?: HTMLElement;
/** The element that is placed behin the contentNode. When not provided and `hasBackdrop` is true, a backdropNode will be automatically created */
backdropNode?: HTMLElement;
/** The element that should be called `.focus()` on after dialog closes */
elementToFocusAfterHide?: HTMLElement;
/** Whether it should have a backdrop (currently exclusive to globalOverlayController) */
hasBackdrop?: boolean;
/** Hides other overlays when mutiple are opened (currently exclusive to globalOverlayController) */
isBlocking?: boolean;
/** Prevents scrolling body content when overlay opened (currently exclusive to globalOverlayController) */
preventsScroll?: boolean;
/** Rotates tab, implicitly set when 'isModal' */
trapsKeyboardFocus?: boolean;
/** Hides the overlay when pressing [ esc ] */
hidesOnEsc?: boolean;
/** Hides the overlay when clicking next to it, exluding invoker */
hidesOnOutsideClick?: boolean;
/** Hides the overlay when pressing esc, even when contentNode has no focus */
hidesOnOutsideEsc?: boolean;
/** Will align contentNode with referenceNode (invokerNode by default) for local overlays. Usually needed for dropdowns. 'max' will prevent contentNode from exceeding width of referenceNode, 'min' guarantees that contentNode will be at least as wide as referenceNode. 'full' will make sure that the invoker width always is the same. */
inheritsReferenceWidth?: 'max' | 'full' | 'min' | 'none';
/**
* For non `isTooltip`:
* - sets aria-expanded="true/false" and aria-haspopup="true" on invokerNode
* - sets aria-controls on invokerNode
* - returns focus to invokerNode on hide
* - sets focus to overlay content(?)
*
* For `isTooltip`:
* - sets role="tooltip" and aria-labelledby/aria-describedby on the content
*/
handlesAccessibility?: boolean;
/** Has a totally different interaction- and accessibility pattern from all other overlays. Will behave as role="tooltip" element instead of a role="dialog" element */
isTooltip?: boolean;
/** By default, the tooltip content is a 'description' for the invoker (uses aria-describedby) Setting this property to 'label' makes the content function as a label (via aria-labelledby) */
invokerRelation?: 'label' | 'description';
/** Popper configuration. Will be used when placementMode is 'local' */
popperConfig?: PopperOptions;
/** Viewport configuration. Will be used when placementMode is 'global' */
viewportConfig?: ViewportConfig;
}
export type ViewportPlacement =
| 'center'
| 'top-left'
| 'top'
| 'top-right'
| 'right'
| 'bottom-right'
| 'bottom'
| 'bottom-left'
| 'left'
| 'center';
export interface ViewportConfig {
placement: ViewportPlacement;
}

View file

@ -0,0 +1,67 @@
import { Constructor } from '@open-wc/dedupe-mixin';
import { LitElement } from '@lion/core';
import { OverlayConfig } from './OverlayConfig.js';
import { OverlayController } from '../src/OverlayController.js';
export interface DefineOverlayConfig {
/** The interactive element (usually a button) invoking the dialog or tooltip */
invokerNode: HTMLElement;
/** The element that is used to position the overlay content relative to. Usually, this is the same element as invokerNode. Should only be provided when invokerNode should not be positioned against */
referenceNode?: HTMLElement;
/** The most important element: the overlay itself */
contentNode: HTMLElement;
/** The wrapper element of contentNode, used to supply inline positioning styles. When a Popper arrow is needed, it acts as parent of the arrow node. Will be automatically created for global and non projected contentNodes. Required when used in shadow dom mode or when Popper arrow is supplied. Essential for allowing webcomponents to style their projected contentNodes */
contentWrapperNode?: HTMLElement;
/** The element that is placed behin the contentNode. When not provided and `hasBackdrop` is true, a backdropNode will be automatically created */
backdropNode?: HTMLElement;
}
export declare class OverlayHost {
public opened: Boolean;
public get config(): OverlayConfig;
public set config(value: OverlayConfig);
protected _overlayCtrl: OverlayController;
protected get _overlayInvokerNode(): HTMLElement;
protected get _overlayBackdropNode(): HTMLElement;
protected get _overlayContentNode(): HTMLElement;
protected get _overlayContentWrapperNode(): HTMLElement;
/**
* returns an instance of a (dynamic) overlay controller
* In case overriding _defineOverlayConfig is not enough
*/
protected _defineOverlay(config: DefineOverlayConfig): OverlayController;
protected _defineOverlayConfig(): OverlayConfig;
protected _setupOpenCloseListeners(): void;
protected _teardownOpenCloseListeners(): void;
protected _setupOverlayCtrl(): void;
protected _teardownOverlayCtrl(): void;
/**
* When the opened state is changed by an Application Developer,cthe OverlayController is
* requested to show/hide. It might happen that this request is not honoured
* (intercepted in before-hide for instance), so that we need to sync the controller state
* to this webcomponent again, preventing eternal loops.
*/
protected _setOpenedWithoutPropertyEffects(newOpened: Boolean): Promise<undefined>;
private __setupSyncFromOverlayController(): void;
private __teardownSyncFromOverlayController(): void;
private __syncToOverlayController(): void;
}
export declare function OverlayImplementation<T extends Constructor<LitElement>>(
superclass: T,
): T & Constructor<OverlayHost> & OverlayHost;
export type OverlayMixin = typeof OverlayImplementation;

View file

@ -1,6 +1,10 @@
import { css, html, LitElement } from '@lion/core'; import { css, html, LitElement } from '@lion/core';
import { OverlayMixin } from '@lion/overlays'; import { OverlayMixin } from '@lion/overlays';
/**
* @typedef {import('@lion/overlays/types/OverlayConfig').OverlayConfig} OverlayConfig
*/
/** /**
* @customElement lion-tooltip * @customElement lion-tooltip
*/ */
@ -82,7 +86,7 @@ export class LionTooltip extends OverlayMixin(LitElement) {
/** /**
* Decides whether the tooltip invoker text should be considered a description * Decides whether the tooltip invoker text should be considered a description
* (sets aria-describedby) or a label (sets aria-labelledby). * (sets aria-describedby) or a label (sets aria-labelledby).
* @type {'label'\'description'} * @type {'label'|'description'}
*/ */
this.invokerRelation = 'description'; this.invokerRelation = 'description';
this._mouseActive = false; this._mouseActive = false;
@ -112,9 +116,9 @@ export class LionTooltip extends OverlayMixin(LitElement) {
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
_defineOverlayConfig() { _defineOverlayConfig() {
return { return /** @type {OverlayConfig} */ ({
placementMode: 'local', placementMode: 'local',
elementToFocusAfterHide: null, elementToFocusAfterHide: undefined,
hidesOnEsc: true, hidesOnEsc: true,
hidesOnOutsideEsc: true, hidesOnOutsideEsc: true,
popperConfig: { popperConfig: {
@ -137,7 +141,7 @@ export class LionTooltip extends OverlayMixin(LitElement) {
handlesAccessibility: true, handlesAccessibility: true,
isTooltip: true, isTooltip: true,
invokerRelation: this.invokerRelation, invokerRelation: this.invokerRelation,
}; });
} }
__setupRepositionCompletePromise() { __setupRepositionCompletePromise() {
@ -147,15 +151,21 @@ export class LionTooltip extends OverlayMixin(LitElement) {
} }
get _arrowNode() { get _arrowNode() {
return this.shadowRoot.querySelector('[x-arrow]'); return /** @type {ShadowRoot} */ (this.shadowRoot).querySelector('[x-arrow]');
} }
/**
* @param {import("popper.js").default.Data} data
*/
__syncFromPopperState(data) { __syncFromPopperState(data) {
if (!data) { if (!data) {
return; return;
} }
if (this._arrowNode && data.placement !== this._arrowNode.placement) { if (
this.__repositionCompleteResolver(data.placement); this._arrowNode &&
data.placement !== /** @type {Element & {placement:string}} */ (this._arrowNode).placement
) {
/** @type {function} */ (this.__repositionCompleteResolver)(data.placement);
this.__setupRepositionCompletePromise(); this.__setupRepositionCompletePromise();
} }
} }

View file

@ -2,6 +2,11 @@ import { runOverlayMixinSuite } from '@lion/overlays/test-suites/OverlayMixin.su
import { aTimeout, expect, fixture, html, unsafeStatic } from '@open-wc/testing'; import { aTimeout, expect, fixture, html, unsafeStatic } from '@open-wc/testing';
import '../lion-tooltip.js'; import '../lion-tooltip.js';
/**
* @typedef {import('../src/LionTooltip.js').LionTooltip} LionTooltip
* @typedef {import('@lion/overlays/types/OverlayConfig').OverlayConfig} OverlayConfig
*/
describe('lion-tooltip', () => { describe('lion-tooltip', () => {
describe('Integration tests', () => { describe('Integration tests', () => {
const tagString = 'lion-tooltip'; const tagString = 'lion-tooltip';
@ -16,85 +21,99 @@ describe('lion-tooltip', () => {
describe('Basic', () => { describe('Basic', () => {
it('shows content on mouseenter and hide on mouseleave', async () => { it('shows content on mouseenter and hide on mouseleave', async () => {
const el = await fixture(html` const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip> <lion-tooltip>
<div slot="content">Hey there</div> <div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button> <button slot="invoker">Tooltip button</button>
</lion-tooltip> </lion-tooltip>
`); `));
const eventMouseEnter = new Event('mouseenter'); const eventMouseEnter = new Event('mouseenter');
el.dispatchEvent(eventMouseEnter); el.dispatchEvent(eventMouseEnter);
await el.updateComplete; await el.updateComplete;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.isShown).to.equal(true); expect(el._overlayCtrl.isShown).to.equal(true);
const eventMouseLeave = new Event('mouseleave'); const eventMouseLeave = new Event('mouseleave');
el.dispatchEvent(eventMouseLeave); el.dispatchEvent(eventMouseLeave);
await el.updateComplete; await el.updateComplete;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.isShown).to.equal(false); expect(el._overlayCtrl.isShown).to.equal(false);
}); });
it('shows content on mouseenter and remain shown on focusout', async () => { it('shows content on mouseenter and remain shown on focusout', async () => {
const el = await fixture(html` const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip> <lion-tooltip>
<div slot="content">Hey there</div> <div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button> <button slot="invoker">Tooltip button</button>
</lion-tooltip> </lion-tooltip>
`); `));
const eventMouseEnter = new Event('mouseenter'); const eventMouseEnter = new Event('mouseenter');
el.dispatchEvent(eventMouseEnter); el.dispatchEvent(eventMouseEnter);
await el.updateComplete; await el.updateComplete;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.isShown).to.equal(true); expect(el._overlayCtrl.isShown).to.equal(true);
const eventFocusOut = new Event('focusout'); const eventFocusOut = new Event('focusout');
el.dispatchEvent(eventFocusOut); el.dispatchEvent(eventFocusOut);
await el.updateComplete; await el.updateComplete;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.isShown).to.equal(true); expect(el._overlayCtrl.isShown).to.equal(true);
}); });
it('shows content on focusin and hide on focusout', async () => { it('shows content on focusin and hide on focusout', async () => {
const el = await fixture(html` const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip> <lion-tooltip>
<div slot="content">Hey there</div> <div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button> <button slot="invoker">Tooltip button</button>
</lion-tooltip> </lion-tooltip>
`); `));
const invoker = Array.from(el.children).find(child => child.slot === 'invoker'); const invoker = /** @type {HTMLElement} */ (Array.from(el.children).find(
child => child.slot === 'invoker',
));
const eventFocusIn = new Event('focusin'); const eventFocusIn = new Event('focusin');
invoker.dispatchEvent(eventFocusIn); invoker.dispatchEvent(eventFocusIn);
await el.updateComplete; await el.updateComplete;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.isShown).to.equal(true); expect(el._overlayCtrl.isShown).to.equal(true);
const eventFocusOut = new Event('focusout'); const eventFocusOut = new Event('focusout');
invoker.dispatchEvent(eventFocusOut); invoker.dispatchEvent(eventFocusOut);
await el.updateComplete; await el.updateComplete;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.isShown).to.equal(false); expect(el._overlayCtrl.isShown).to.equal(false);
}); });
it('shows content on focusin and remain shown on mouseleave', async () => { it('shows content on focusin and remain shown on mouseleave', async () => {
const el = await fixture(html` const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip> <lion-tooltip>
<div slot="content">Hey there</div> <div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button> <button slot="invoker">Tooltip button</button>
</lion-tooltip> </lion-tooltip>
`); `));
const invoker = Array.from(el.children).find(child => child.slot === 'invoker'); const invoker = /** @type {HTMLElement} */ (Array.from(el.children).find(
child => child.slot === 'invoker',
));
const eventFocusIn = new Event('focusin'); const eventFocusIn = new Event('focusin');
invoker.dispatchEvent(eventFocusIn); invoker.dispatchEvent(eventFocusIn);
await el.updateComplete; await el.updateComplete;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.isShown).to.equal(true); expect(el._overlayCtrl.isShown).to.equal(true);
const eventMouseLeave = new Event('mouseleave'); const eventMouseLeave = new Event('mouseleave');
invoker.dispatchEvent(eventMouseLeave); invoker.dispatchEvent(eventMouseLeave);
await el.updateComplete; await el.updateComplete;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.isShown).to.equal(true); expect(el._overlayCtrl.isShown).to.equal(true);
}); });
it('contains html when specified in tooltip content body', async () => { it('contains html when specified in tooltip content body', async () => {
const el = await fixture(html` const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip> <lion-tooltip>
<div slot="content"> <div slot="content">
This is Tooltip using <strong id="click_overlay">overlay</strong> This is Tooltip using <strong id="click_overlay">overlay</strong>
</div> </div>
<button slot="invoker">Tooltip button</button> <button slot="invoker">Tooltip button</button>
</lion-tooltip> </lion-tooltip>
`); `));
const invoker = Array.from(el.children).find(child => child.slot === 'invoker'); const invoker = /** @type {HTMLElement} */ (Array.from(el.children).find(
child => child.slot === 'invoker',
));
const event = new Event('mouseenter'); const event = new Event('mouseenter');
invoker.dispatchEvent(event); invoker.dispatchEvent(event);
await el.updateComplete; await el.updateComplete;
@ -104,32 +123,32 @@ describe('lion-tooltip', () => {
describe('Arrow', () => { describe('Arrow', () => {
it('shows when "has-arrow" is configured', async () => { it('shows when "has-arrow" is configured', async () => {
const el = await fixture(html` const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip has-arrow> <lion-tooltip has-arrow>
<div slot="content"> <div slot="content">
This is Tooltip using <strong id="click_overlay">overlay</strong> This is Tooltip using <strong id="click_overlay">overlay</strong>
</div> </div>
<button slot="invoker">Tooltip button</button> <button slot="invoker">Tooltip button</button>
</lion-tooltip> </lion-tooltip>
`); `));
expect(el._arrowNode).to.be.displayed; expect(el._arrowNode).to.be.displayed;
}); });
it('makes sure positioning of the arrow is correct', async () => { it('makes sure positioning of the arrow is correct', async () => {
const el = await fixture(html` const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip <lion-tooltip
has-arrow has-arrow
.config="${{ .config="${/** @type {OverlayConfig} */ ({
popperConfig: { popperConfig: {
placement: 'right', placement: 'right',
}, },
}}" })}"
style="position: relative; top: 10px;" style="position: relative; top: 10px;"
> >
<div slot="content" style="height: 30px; background-color: red;">Hey there</div> <div slot="content" style="height: 30px; background-color: red;">Hey there</div>
<button slot="invoker" style="height: 30px;">Tooltip button</button> <button slot="invoker" style="height: 30px;">Tooltip button</button>
</lion-tooltip> </lion-tooltip>
`); `));
el.opened = true; el.opened = true;
@ -140,7 +159,9 @@ describe('lion-tooltip', () => {
'30px (content height) - 8px = 22px, divided by 2 = 11px offset --> arrow is in the middle', '30px (content height) - 8px = 22px, divided by 2 = 11px offset --> arrow is in the middle',
); */ ); */
expect(getComputedStyle(el._arrowNode).getPropertyValue('left')).to.equal( expect(
getComputedStyle(/** @type {HTMLElement} */ (el._arrowNode)).getPropertyValue('left'),
).to.equal(
'-10px', '-10px',
` `
arrow height is 8px so this offset should be taken into account to align the arrow properly, arrow height is 8px so this offset should be taken into account to align the arrow properly,
@ -152,15 +173,17 @@ describe('lion-tooltip', () => {
describe('Positioning', () => { describe('Positioning', () => {
it('updates popper positioning correctly, without overriding other modifiers', async () => { it('updates popper positioning correctly, without overriding other modifiers', async () => {
const el = await fixture(html` const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip style="position: absolute; top: 100px" opened> <lion-tooltip style="position: absolute; top: 100px" opened>
<div slot="content">Hey there</div> <div slot="content">Hey there</div>
<div slot="invoker">Tooltip button</div> <div slot="invoker">Tooltip button</div>
</lion-tooltip> </lion-tooltip>
`); `));
await aTimeout(); await aTimeout(0);
// @ts-expect-error allow protected props in tests
const initialPopperModifiers = el._overlayCtrl.config.popperConfig.modifiers; const initialPopperModifiers = el._overlayCtrl.config.popperConfig.modifiers;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.config.popperConfig.placement).to.equal('top'); expect(el._overlayCtrl.config.popperConfig.placement).to.equal('top');
// TODO: this fails in CI, we need to investigate why in CI // TODO: this fails in CI, we need to investigate why in CI
// the value of the transform is: translate3d(16px, -26px, 0px)' // the value of the transform is: translate3d(16px, -26px, 0px)'
@ -176,9 +199,11 @@ describe('lion-tooltip', () => {
el.opened = false; el.opened = false;
el.opened = true; el.opened = true;
await aTimeout(); await aTimeout(0);
// @ts-expect-error allow protected props in tests
const updatedPopperModifiers = el._overlayCtrl.config.popperConfig.modifiers; const updatedPopperModifiers = el._overlayCtrl.config.popperConfig.modifiers;
expect(updatedPopperModifiers).to.deep.equal(initialPopperModifiers); expect(updatedPopperModifiers).to.deep.equal(initialPopperModifiers);
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.config.popperConfig.placement).to.equal('bottom'); expect(el._overlayCtrl.config.popperConfig.placement).to.equal('bottom');
// TODO: this fails in CI, we need to investigate why in CI // TODO: this fails in CI, we need to investigate why in CI
// the value of the transform is: translate3d(16px, 26px, 0px)' // the value of the transform is: translate3d(16px, 26px, 0px)'
@ -190,63 +215,63 @@ describe('lion-tooltip', () => {
describe('Accessibility', () => { describe('Accessibility', () => {
it('should have a tooltip role set on the tooltip', async () => { it('should have a tooltip role set on the tooltip', async () => {
const el = await fixture(html` const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip> <lion-tooltip>
<div slot="content">Hey there</div> <div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button> <button slot="invoker">Tooltip button</button>
</lion-tooltip> </lion-tooltip>
`); `));
// FIXME: This should be refactored to Array.from(this.children).find(child => child.slot === 'content'). // FIXME: This should be refactored to Array.from(this.children).find(child => child.slot === 'content').
// When this issue is fixed https://github.com/ing-bank/lion/issues/382 // When this issue is fixed https://github.com/ing-bank/lion/issues/382
const content = el.querySelector('[slot=content]'); const content = /** @type {HTMLElement} */ (el.querySelector('[slot=content]'));
expect(content.getAttribute('role')).to.be.equal('tooltip'); expect(content.getAttribute('role')).to.be.equal('tooltip');
}); });
it('should have aria-describedby role set on the invoker', async () => { it('should have aria-describedby role set on the invoker', async () => {
const el = await fixture(html` const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip> <lion-tooltip>
<div slot="content">Hey there</div> <div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button> <button slot="invoker">Tooltip button</button>
</lion-tooltip> </lion-tooltip>
`); `));
const content = el.querySelector('[slot=content]'); const content = /** @type {HTMLElement} */ (el.querySelector('[slot=content]'));
const invoker = el.querySelector('[slot=invoker]'); const invoker = /** @type {HTMLElement} */ (el.querySelector('[slot=invoker]'));
expect(invoker.getAttribute('aria-describedby')).to.be.equal(content.id); expect(invoker.getAttribute('aria-describedby')).to.be.equal(content.id);
expect(invoker.getAttribute('aria-labelledby')).to.be.equal(null); expect(invoker.getAttribute('aria-labelledby')).to.be.equal(null);
}); });
it('should have aria-labelledby role set on the invoker when [ invoker-relation="label"]', async () => { it('should have aria-labelledby role set on the invoker when [ invoker-relation="label"]', async () => {
const el = await fixture(html` const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip invoker-relation="label"> <lion-tooltip invoker-relation="label">
<div slot="content">Hey there</div> <div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button> <button slot="invoker">Tooltip button</button>
</lion-tooltip> </lion-tooltip>
`); `));
const content = el.querySelector('[slot=content]'); const content = /** @type {HTMLElement} */ (el.querySelector('[slot=content]'));
const invoker = el.querySelector('[slot=invoker]'); const invoker = /** @type {HTMLElement} */ (el.querySelector('[slot=invoker]'));
expect(invoker.getAttribute('aria-describedby')).to.be.equal(null); expect(invoker.getAttribute('aria-describedby')).to.be.equal(null);
expect(invoker.getAttribute('aria-labelledby')).to.be.equal(content.id); expect(invoker.getAttribute('aria-labelledby')).to.be.equal(content.id);
}); });
it('should be accessible when closed', async () => { it('should be accessible when closed', async () => {
const el = await fixture(html` const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip> <lion-tooltip>
<div slot="content">Hey there</div> <div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button> <button slot="invoker">Tooltip button</button>
</lion-tooltip> </lion-tooltip>
`); `));
await expect(el).to.be.accessible; await expect(el).to.be.accessible;
}); });
it('should be accessible when opened', async () => { it('should be accessible when opened', async () => {
const el = await fixture(html` const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip> <lion-tooltip>
<div slot="content">Hey there</div> <div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button> <button slot="invoker">Tooltip button</button>
</lion-tooltip> </lion-tooltip>
`); `));
const invoker = el.querySelector('[slot="invoker"]'); const invoker = /** @type {HTMLElement} */ (el.querySelector('[slot="invoker"]'));
const eventFocusIn = new Event('focusin'); const eventFocusIn = new Event('focusin');
invoker.dispatchEvent(eventFocusIn); invoker.dispatchEvent(eventFocusIn);
await el.updateComplete; await el.updateComplete;

View file

@ -19,7 +19,10 @@
"packages/tabs/**/*.js", "packages/tabs/**/*.js",
"packages/singleton-manager/**/*.js", "packages/singleton-manager/**/*.js",
"packages/localize/**/*.js", "packages/localize/**/*.js",
"packages/form-core/**/*.js" "packages/form-core/**/*.js",
"packages/overlays/**/*.js",
"packages/tooltip/**/*.js",
"packages/button/src/**/*.js"
], ],
"exclude": [ "exclude": [
"node_modules", "node_modules",