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,
} from '@lion/core';
const isKeyboardClickEvent = e => e.keyCode === 32 /* space */ || e.keyCode === 13; /* enter */
const isSpaceKeyboardClickEvent = e => e.keyCode === 32 || e.key === 32; /* space */
const isKeyboardClickEvent = (/** @type {KeyboardEvent} */ e) =>
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)) {
static get properties() {
@ -131,8 +134,11 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
];
}
/** @type {HTMLButtonElement} */
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() {
@ -143,12 +149,11 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
return {
...super.slots,
_button: () => {
if (!this.constructor._button) {
this.constructor._button = document.createElement('button');
this.constructor._button.setAttribute('tabindex', '-1');
this.constructor._button.setAttribute('aria-hidden', 'true');
}
return this.constructor._button.cloneNode();
/** @type {HTMLButtonElement} */
const buttonEl = document.createElement('button');
buttonEl.setAttribute('tabindex', '-1');
buttonEl.setAttribute('aria-hidden', 'true');
return buttonEl;
},
};
}
@ -176,6 +181,9 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
this.__teardownEvents();
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
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
* 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.
* @param {Event} e
*/
__clickDelegationHandler(e) {
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);
}
/**
* @param {KeyboardEvent} e
*/
__keydownHandler(e) {
if (this.active || !isKeyboardClickEvent(e)) {
if (isSpaceKeyboardClickEvent(e)) {
@ -248,6 +260,9 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
}
this.active = true;
/**
* @param {KeyboardEvent} keyupEvent
*/
const keyupHandler = keyupEvent => {
if (isKeyboardClickEvent(keyupEvent)) {
this.active = false;
@ -257,6 +272,9 @@ export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement))
document.addEventListener('keyup', keyupHandler, true);
}
/**
* @param {KeyboardEvent} e
*/
__keyupHandler(e) {
if (isKeyboardClickEvent(e)) {
// 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 '../lion-button.js';
/**
* @typedef {import('@lion/button/src/LionButton').LionButton} LionButton
*/
/**
* @param {HTMLElement} el
*/
function getClickArea(el) {
if (el.shadowRoot) {
return el.shadowRoot.querySelector('.click-area');
@ -12,13 +19,13 @@ function getClickArea(el) {
describe('lion-button', () => {
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('tabindex')).to.equal('0');
});
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.getAttribute('type')).to.be.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 () => {
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.getAttribute('type')).to.be.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 () => {
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(window.getComputedStyle(el._nativeButtonNode).clip).to.equal('rect(0px, 0px, 0px, 0px)');
});
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;
});
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('aria-disabled')).to.equal('true');
@ -64,7 +73,7 @@ describe('lion-button', () => {
describe('active', () => {
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'));
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 () => {
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'));
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 () => {
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 }));
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 () => {
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 }));
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 () => {
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 }));
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 () => {
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 }));
expect(el.active).to.be.true;
@ -150,7 +159,7 @@ describe('lion-button', () => {
describe('a11y', () => {
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');
el.role = 'foo';
await el.updateComplete;
@ -158,17 +167,21 @@ describe('lion-button', () => {
});
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');
});
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');
});
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');
el.disabled = false;
await el.updateComplete;
@ -179,12 +192,16 @@ describe('lion-button', () => {
});
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');
});
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');
el.disabled = false;
await el.updateComplete;
@ -193,7 +210,7 @@ describe('lion-button', () => {
it('has an aria-labelledby and wrapper element in IE11', async () => {
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;
const wrapperId = el.getAttribute('aria-labelledby');
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 () => {
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');
});
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();
});
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'] });
});
});
@ -223,7 +242,7 @@ describe('lion-button', () => {
describe('form integration', () => {
describe('with submit event', () => {
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`
<form @submit="${formSubmitSpy}">
<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 () => {
const formSubmitSpy = sinon.spy(e => e.preventDefault());
const formSubmitSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const form = await fixture(html`
<form @submit="${formSubmitSpy}">
<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 () => {
const formSubmitSpy = sinon.spy(e => e.preventDefault());
const formSubmitSpy = /** @type {EventListener} */ (sinon.spy(e => e.preventDefault()));
const form = await fixture(html`
<form @submit="${formSubmitSpy}">
<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)
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`
<form @submit="${formSubmitSpy}">
<input name="foo" />
@ -315,7 +334,7 @@ describe('lion-button', () => {
describe('with click event', () => {
it('behaves like native `button` when clicked', async () => {
const formButtonClickedSpy = sinon.spy();
const formButtonClickedSpy = /** @type {EventListener} */ (sinon.spy());
const form = await fixture(html`
<form @submit=${ev => ev.preventDefault()}>
<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 () => {
const formButtonClickedSpy = sinon.spy();
const formButtonClickedSpy = /** @type {EventListener} */ (sinon.spy());
const form = await fixture(html`
<form @submit=${ev => ev.preventDefault()}>
<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 () => {
const formButtonClickedSpy = sinon.spy();
const formButtonClickedSpy = /** @type {EventListener} */ (sinon.spy());
const form = await fixture(html`
<form @submit=${ev => ev.preventDefault()}>
<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)
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`
<form @submit=${ev => ev.preventDefault()}>
<input name="foo" />
@ -386,8 +405,10 @@ describe('lion-button', () => {
describe('click event', () => {
it('is fired once', async () => {
const clickSpy = sinon.spy();
const el = await fixture(html`<lion-button @click="${clickSpy}">foo</lion-button>`);
const clickSpy = /** @type {EventListener} */ (sinon.spy());
const el = /** @type {LionButton} */ (await fixture(
html`<lion-button @click="${clickSpy}">foo</lion-button>`,
));
getClickArea(el).click();
@ -414,8 +435,10 @@ describe('lion-button', () => {
let lionButtonEvent;
before(async () => {
const nativeButtonEl = await fixture('<button>foo</button>');
const lionButtonEl = await fixture('<lion-button>foo</lion-button>');
const nativeButtonEl = /** @type {LionButton} */ (await fixture('<button>foo</button>'));
const lionButtonEl = /** @type {LionButton} */ (await fixture(
'<lion-button>foo</lion-button>',
));
nativeButtonEvent = await prepareClickEvent(nativeButtonEl);
lionButtonEvent = await prepareClickEvent(lionButtonEl);
});
@ -436,7 +459,7 @@ describe('lion-button', () => {
});
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);
expect(event.target).to.equal(el);
});

View file

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

View file

@ -1,6 +1,11 @@
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.
@ -21,11 +26,11 @@ const cache = new WeakMap();
*
* @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)) {
return;
}
cache.set(part.committer.element);
cache.add(part.committer.element);
const attrName = part.committer.name;
const key = attrName.replace(/^#/, '');
// eslint-disable-next-line no-param-reassign

View file

@ -1,14 +1,29 @@
import '@lion/core/src/differentKeyEventNamesShimIE.js';
import { EventTargetShim } from '@lion/core';
// eslint-disable-next-line import/no-cycle
import { overlays } from './overlays.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() {
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_CLASS = 'global-overlays__overlay';
// @ts-expect-error CSS not yet typed
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
* always configure as if it were a local overlay.
*/
export class OverlayController {
export class OverlayController extends EventTargetShim {
/**
* @constructor
* @param {OverlayConfig} config initial config. Will be remembered as shared config
* when `.updateConfig()` is called.
*/
constructor(config = {}, manager = overlays) {
this.__fakeExtendsEventTarget();
super();
this.manager = manager;
this.__sharedConfig = config;
/** @type {OverlayConfig} */
this._defaultConfig = {
placementMode: null,
placementMode: undefined,
contentNode: config.contentNode,
contentWrapperNode: config.contentWrapperNode,
invokerNode: config.invokerNode,
backdropNode: config.backdropNode,
referenceNode: null,
referenceNode: undefined,
elementToFocusAfterHide: config.invokerNode,
inheritsReferenceWidth: 'none',
hasBackdrop: false,
@ -100,7 +114,7 @@ export class OverlayController {
hidesOnOutsideClick: false,
isTooltip: false,
invokerRelation: 'description',
handlesUserInteraction: false,
// handlesUserInteraction: false,
handlesAccessibility: false,
popperConfig: {
placement: 'top',
@ -146,18 +160,209 @@ export class OverlayController {
this.updateConfig(config);
this.__hasActiveTrapsKeyboardFocus = false;
this.__hasActiveBackdrop = true;
this.__escKeyHandler = this.__escKeyHandler.bind(this);
}
/**
* The invokerNode
* @type {HTMLElement | undefined}
*/
get invoker() {
return this.invokerNode;
}
/**
* The contentWrapperNode
* @type {HTMLElement}
*/
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)
* we need to know where we should reappend contentWrapperNode (or contentNode in case it's
* projected).
@ -170,35 +375,42 @@ export class OverlayController {
}
/** config [l2] or [l4] */
if (this.__isContentNodeProjected) {
return this.__originalContentParent.getRootNode().host;
// @ts-expect-error
return this.__originalContentParent?.getRootNode().host;
}
/** config [l1] or [l3] */
return this.__originalContentParent;
return /** @type {HTMLElement} */ (this.__originalContentParent);
}
/**
* @desc The element our local overlay will be positioned relative to.
* @type {HTMLElement}
* @type {HTMLElement | undefined}
*/
get _referenceNode() {
return this.referenceNode || this.invokerNode;
}
/**
* @param {string} value
*/
set elevation(value) {
if (this._contentWrapperNode) {
this._contentWrapperNode.style.zIndex = value;
if (this.contentWrapperNode) {
this.contentWrapperNode.style.zIndex = value;
}
if (this.backdropNode) {
this.backdropNode.style.zIndex = value;
}
}
/**
* @type {number}
*/
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.
* Note that this method is the only allowed way to update a configuration of an
* OverlayController instance.
@ -208,6 +420,7 @@ export class OverlayController {
// Teardown all previous configs
this._handleFeatures({ phase: 'teardown' });
/** @type {OverlayConfig} */
this.__prevConfig = this.config || {};
this.config = {
@ -229,10 +442,15 @@ export class OverlayController {
};
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.__elementToFocusAfterHide = undefined;
}
/**
* @param {OverlayConfig} newConfig
*/
// eslint-disable-next-line class-methods-use-this
__validateConfiguration(newConfig) {
if (!newConfig.placementMode) {
@ -268,14 +486,18 @@ export class OverlayController {
// }
}
/**
* @param {{ cfgToAdd: OverlayConfig }} options
*/
_init({ cfgToAdd }) {
this.__initContentWrapperNode({ cfgToAdd });
this.__initConnectionTarget();
if (this.placementMode === 'local') {
// Lazily load Popper if not done yet
if (!this.constructor.popperModule) {
this.constructor.popperModule = preloadPopper();
if (!OverlayController.popperModule) {
// @ts-expect-error
OverlayController.popperModule = preloadPopper();
}
}
this._handleFeatures({ phase: 'init' });
@ -283,9 +505,10 @@ export class OverlayController {
__initConnectionTarget() {
// Now, add our node to the right place in dom (renderTarget)
if (this._contentWrapperNode !== this.__prevConfig._contentWrapperNode) {
if (this.config.placementMode === 'global' || !this.__isContentNodeProjected) {
this._contentWrapperNode.appendChild(this.contentNode);
if (this.contentWrapperNode !== this.__prevConfig?.contentWrapperNode) {
if (this.config?.placementMode === 'global' || !this.__isContentNodeProjected) {
/** @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
this._renderTarget.appendChild(this.contentNode);
} else {
const isInsideRenderTarget = this._renderTarget === this._contentWrapperNode.parentNode;
const nodeContainsTarget = this._contentWrapperNode.contains(this._renderTarget);
const isInsideRenderTarget = this._renderTarget === this.contentWrapperNode.parentNode;
const nodeContainsTarget = this.contentWrapperNode.contains(this._renderTarget);
if (!isInsideRenderTarget && !nodeContainsTarget) {
// contentWrapperNode becomes the direct (non projected) parent of contentNode
this._renderTarget.appendChild(this._contentWrapperNode);
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...
* @param {{ cfgToAdd: OverlayConfig }} options
*/
__initContentWrapperNode({ cfgToAdd }) {
if (this.config.contentWrapperNode && this.placementMode === 'local') {
if (this.config?.contentWrapperNode && this.placementMode === 'local') {
/** config [l2],[l3],[l4] */
this._contentWrapperNode = this.config.contentWrapperNode;
this.__contentWrapperNode = this.config.contentWrapperNode;
} else {
/** config [l1],[g1] */
this._contentWrapperNode = document.createElement('div');
this.__contentWrapperNode = document.createElement('div');
}
this._contentWrapperNode.style.cssText = null;
this._contentWrapperNode.style.display = 'none';
this.contentWrapperNode.style.cssText = '';
this.contentWrapperNode.style.display = 'none';
if (getComputedStyle(this.contentNode).position === 'absolute') {
// Having a _contWrapperNode and a contentNode with 'position:absolute' results in
@ -328,19 +552,21 @@ export class OverlayController {
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.
/** config [l2], [l4] */
this.__originalContentParent = this._contentWrapperNode.parentNode;
this.__originalContentParent = /** @type {HTMLElement} */ (this.contentWrapperNode
.parentNode);
} else if (cfgToAdd.contentNode && cfgToAdd.contentNode.isConnected) {
// We need to keep track of the original local context.
/** config [l1], [l3], [g1] */
this.__originalContentParent = 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 }) {
if (this.placementMode !== 'local') {
@ -350,11 +576,14 @@ export class OverlayController {
if (phase === 'setup') {
const zIndexNumber = Number(getComputedStyle(this.contentNode).zIndex);
if (zIndexNumber < 1 || Number.isNaN(zIndexNumber)) {
this._contentWrapperNode.style.zIndex = 1;
this.contentWrapperNode.style.zIndex = '1';
}
}
}
/**
* @param {{ phase: OverlayPhase }} config
*/
__setupTeardownAccessibility({ phase }) {
if (phase === 'init') {
this.__storeOriginalAttrs(this.contentNode, ['role', 'id']);
@ -380,7 +609,7 @@ export class OverlayController {
this.contentNode.setAttribute('role', 'tooltip');
} else {
if (this.invokerNode) {
this.invokerNode.setAttribute('aria-expanded', this.isShown);
this.invokerNode.setAttribute('aria-expanded', `${this.isShown}`);
}
if (!this.contentNode.getAttribute('role')) {
this.contentNode.setAttribute('role', 'dialog');
@ -391,6 +620,10 @@ export class OverlayController {
}
}
/**
* @param {HTMLElement} node
* @param {string[]} attrs
*/
__storeOriginalAttrs(node, attrs) {
const attrMap = {};
attrs.forEach(attrName => {
@ -413,7 +646,7 @@ export class OverlayController {
}
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) {
this._showResolve();
/** @type {function} */ (this._showResolve)();
return;
}
const event = new CustomEvent('before-show', { cancelable: true });
this.dispatchEvent(event);
if (!event.defaultPrevented) {
this._contentWrapperNode.style.display = '';
this.contentWrapperNode.style.display = '';
this._keepBodySize({ phase: 'before-show' });
await this._handleFeatures({ phase: 'show' });
this._keepBodySize({ phase: 'show' });
await this._handlePosition({ phase: 'show' });
this.elementToFocusAfterHide = elementToFocusAfterHide;
this.__elementToFocusAfterHide = elementToFocusAfterHide;
this.dispatchEvent(new Event('show'));
}
this._showResolve();
/** @type {function} */ (this._showResolve)();
}
/**
* @param {{ phase: OverlayPhase }} config
*/
async _handlePosition({ phase }) {
if (this.placementMode === 'global') {
const addOrRemove = phase === 'show' ? 'add' : 'remove';
const placementClass = `${GLOBAL_OVERLAYS_CONTAINER_CLASS}--${this.viewportConfig.placement}`;
this._contentWrapperNode.classList[addOrRemove](GLOBAL_OVERLAYS_CONTAINER_CLASS);
this._contentWrapperNode.classList[addOrRemove](placementClass);
this.contentWrapperNode.classList[addOrRemove](GLOBAL_OVERLAYS_CONTAINER_CLASS);
this.contentWrapperNode.classList[addOrRemove](placementClass);
this.contentNode.classList[addOrRemove](GLOBAL_OVERLAYS_CLASS);
} else if (this.placementMode === 'local' && phase === 'show') {
/**
@ -465,10 +701,13 @@ export class OverlayController {
* This is however necessary for initial placement.
*/
await this.__createPopperInstance();
this._popper.update();
/** @type {Popper} */ (this._popper).update();
}
}
/**
* @param {{ phase: OverlayPhase }} config
*/
_keepBodySize({ phase }) {
switch (phase) {
case 'before-show':
@ -479,7 +718,9 @@ export class OverlayController {
break;
case 'show': {
if (supportsCSSTypedObject) {
// @ts-expect-error types attributeStyleMap not available yet
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;
} else if (window.getComputedStyle) {
const bodyStyle = window.getComputedStyle(document.body);
@ -488,12 +729,16 @@ export class OverlayController {
this.__bodyMarginBottom = parseInt(bodyStyle.getPropertyValue('margin-bottom'), 10);
}
}
const scrollbarWidth = document.body.clientWidth - this.__bodyClientWidth;
const scrollbarHeight = document.body.clientHeight - this.__bodyClientHeight;
const scrollbarWidth =
document.body.clientWidth - /** @type {number} */ (this.__bodyClientWidth);
const scrollbarHeight =
document.body.clientHeight - /** @type {number} */ (this.__bodyClientHeight);
const newMarginRight = this.__bodyMarginRight + scrollbarWidth;
const newMarginBottom = this.__bodyMarginBottom + scrollbarHeight;
if (supportsCSSTypedObject) {
// @ts-expect-error types attributeStyleMap + CSS.px not available yet
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));
} else {
document.body.style.marginRight = `${newMarginRight}px`;
@ -503,7 +748,9 @@ export class OverlayController {
}
case 'hide':
if (supportsCSSTypedObject) {
// @ts-expect-error types attributeStyleMap + CSS.px not available yet
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));
} else {
document.body.style.marginRight = `${this.__bodyMarginRight}px`;
@ -528,7 +775,7 @@ export class OverlayController {
}
if (!this.isShown) {
this._hideResolve();
/** @type {function} */ (this._hideResolve)();
return;
}
@ -536,26 +783,27 @@ export class OverlayController {
this.dispatchEvent(event);
if (!event.defaultPrevented) {
// await this.transitionHide({ backdropNode: this.backdropNode, contentNode: this.contentNode });
this._contentWrapperNode.style.display = 'none';
this.contentWrapperNode.style.display = 'none';
this._handleFeatures({ phase: 'hide' });
this._keepBodySize({ phase: 'hide' });
this.dispatchEvent(new Event('hide'));
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
async transitionHide({ backdropNode, contentNode }) {}
async transitionHide(config) {}
_restoreFocus() {
// We only are allowed to move focus if we (still) 'own' it.
// Otherwise we assume the 'outside world' has, purposefully, taken over
// if (this._contentWrapperNode.activeElement) {
if (this.elementToFocusAfterHide) {
this.elementToFocusAfterHide.focus();
}
// }
}
async toggle() {
@ -563,10 +811,8 @@ export class OverlayController {
}
/**
* @desc All features are handled here. Every feature is set up on show
* and torn
* @param {object} config
* @param {'init'|'show'|'hide'|'teardown'} config.phase
* All features are handled here.
* @param {{ phase: OverlayPhase }} config
*/
_handleFeatures({ phase }) {
this._handleZIndex({ phase });
@ -600,6 +846,9 @@ export class OverlayController {
}
}
/**
* @param {{ phase: OverlayPhase }} config
*/
_handlePreventsScroll({ phase }) {
switch (phase) {
case 'show':
@ -612,6 +861,9 @@ export class OverlayController {
}
}
/**
* @param {{ phase: OverlayPhase }} config
*/
_handleBlocking({ phase }) {
switch (phase) {
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
* animation is played.
* @param {{ animation?: boolean, phase: OverlayPhase }} config
*/
_handleBackdrop({ animation = true, phase }) {
if (this.placementMode === 'local') {
switch (phase) {
case 'init':
if (!this.backdropNode) {
this.backdropNode = document.createElement('div');
this.backdropNode.classList.add('local-overlays__backdrop');
this.__backdropNode = document.createElement('div');
/** @type {HTMLElement} */
(this.backdropNode).classList.add('local-overlays__backdrop');
}
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;
case 'show':
this.__hasActiveBackdrop = true;
@ -658,61 +913,65 @@ export class OverlayController {
return;
}
this.backdropNode.parentNode.removeChild(this.backdropNode);
this.__backdropNode = undefined;
break;
/* no default */
}
return;
}
const { backdropNode } = this;
switch (phase) {
case 'init':
this.backdropNode = document.createElement('div');
this.__backdropNode = document.createElement('div');
this.backdropNode.classList.add('global-overlays__backdrop');
this._contentWrapperNode.parentElement.insertBefore(
/** @type {HTMLElement} */
(this.contentWrapperNode.parentElement).insertBefore(
this.backdropNode,
this._contentWrapperNode,
this.contentWrapperNode,
);
break;
case 'show':
backdropNode.classList.add('global-overlays__backdrop--visible');
this.backdropNode.classList.add('global-overlays__backdrop--visible');
if (animation === true) {
backdropNode.classList.add('global-overlays__backdrop--fade-in');
this.backdropNode.classList.add('global-overlays__backdrop--fade-in');
}
this.__hasActiveBackdrop = true;
break;
case 'hide':
if (!backdropNode) {
if (!this.backdropNode) {
return;
}
backdropNode.classList.remove('global-overlays__backdrop--fade-in');
this.backdropNode.classList.remove('global-overlays__backdrop--fade-in');
if (animation) {
/** @type {(ev:AnimationEvent) => void} */
let afterFadeOut;
backdropNode.classList.add('global-overlays__backdrop--fade-out');
this.backdropNode.classList.add('global-overlays__backdrop--fade-out');
this.__backDropAnimation = new Promise(resolve => {
afterFadeOut = () => {
backdropNode.classList.remove('global-overlays__backdrop--fade-out');
backdropNode.classList.remove('global-overlays__backdrop--visible');
backdropNode.removeEventListener('animationend', afterFadeOut);
this.backdropNode.classList.remove('global-overlays__backdrop--fade-out');
this.backdropNode.classList.remove('global-overlays__backdrop--visible');
this.backdropNode.removeEventListener('animationend', afterFadeOut);
resolve();
};
});
backdropNode.addEventListener('animationend', afterFadeOut);
// @ts-expect-error
this.backdropNode.addEventListener('animationend', afterFadeOut);
} else {
backdropNode.classList.remove('global-overlays__backdrop--visible');
this.backdropNode.classList.remove('global-overlays__backdrop--visible');
}
this.__hasActiveBackdrop = false;
break;
case 'teardown':
if (!backdropNode || !backdropNode.parentNode) {
if (!this.backdropNode || !this.backdropNode.parentNode) {
return;
}
if (animation && this.__backDropAnimation) {
this.__backDropAnimation.then(() => {
backdropNode.parentNode.removeChild(backdropNode);
/** @type {HTMLElement} */
(this.backdropNode.parentNode).removeChild(this.backdropNode);
});
} else {
backdropNode.parentNode.removeChild(backdropNode);
this.backdropNode.parentNode.removeChild(this.backdropNode);
}
break;
/* no default */
@ -723,6 +982,9 @@ export class OverlayController {
return this.__hasActiveTrapsKeyboardFocus;
}
/**
* @param {{ phase: OverlayPhase }} config
*/
_handleTrapsKeyboardFocus({ phase }) {
if (phase === 'show') {
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 }) {
if (phase === 'show') {
this.__escKeyHandler = ev => ev.key === 'Escape' && this.hide();
this.contentNode.addEventListener('keyup', this.__escKeyHandler);
if (this.invokerNode) {
this.invokerNode.addEventListener('keyup', this.__escKeyHandler);
@ -774,9 +1042,13 @@ export class OverlayController {
}
}
/**
* @param {{ phase: OverlayPhase }} config
*/
_handleHidesOnOutsideEsc({ phase }) {
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);
} else if (phase === 'hide') {
document.removeEventListener('keyup', this.__escKeyHandler);
@ -791,19 +1063,22 @@ export class OverlayController {
const referenceWidth = `${this._referenceNode.clientWidth}px`;
switch (this.inheritsReferenceWidth) {
case 'max':
this._contentWrapperNode.style.maxWidth = referenceWidth;
this.contentWrapperNode.style.maxWidth = referenceWidth;
break;
case 'full':
this._contentWrapperNode.style.width = referenceWidth;
this.contentWrapperNode.style.width = referenceWidth;
break;
case 'min':
this._contentWrapperNode.style.minWidth = referenceWidth;
this._contentWrapperNode.style.width = 'auto';
this.contentWrapperNode.style.minWidth = referenceWidth;
this.contentWrapperNode.style.width = 'auto';
break;
/* no default */
}
}
/**
* @param {{ phase: OverlayPhase }} config
*/
_handleHidesOnOutsideClick({ phase }) {
const addOrRemoveListener = phase === 'show' ? 'addEventListener' : 'removeEventListener';
@ -811,6 +1086,7 @@ export class OverlayController {
let wasClickInside = false;
let wasIndirectSynchronousClick = false;
// Handle on capture phase and remember till the next task that there was an inside click
/** @type {EventListenerOrEventListenerObject} */
this.__preventCloseOutsideClick = () => {
if (wasClickInside) {
// 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
/** @type {EventListenerOrEventListenerObject} */
this.__onCaptureHtmlClick = () => {
setTimeout(() => {
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) {
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 }) {
if (phase === 'init' || phase === 'teardown') {
this.__setupTeardownAccessibility({ phase });
}
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' });
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
@ -867,28 +1162,25 @@ export class OverlayController {
_teardownContentWrapperNode() {
if (
this.placementMode === 'global' &&
this._contentWrapperNode &&
this._contentWrapperNode.parentNode
this.contentWrapperNode &&
this.contentWrapperNode.parentNode
) {
this._contentWrapperNode.parentNode.removeChild(this._contentWrapperNode);
this.contentWrapperNode.parentNode.removeChild(this.contentWrapperNode);
}
}
async __createPopperInstance() {
if (this._popper) {
this._popper.destroy();
this._popper = null;
this._popper = undefined;
}
const { default: Popper } = await this.constructor.popperModule;
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);
// @ts-expect-error
const { default: Popper } = await OverlayController.popperModule;
/** @type {Popper} */
this._popper = new Popper(this._referenceNode, this.contentWrapperNode, {
...this.config?.popperConfig,
});
}
}
/** @type {PopperModule | undefined} */
OverlayController.popperModule = undefined;

View file

@ -2,255 +2,298 @@ import { dedupeMixin } from '@lion/core';
import { OverlayController } from './OverlayController.js';
/**
* @type {Function()}
* @polymerMixinOverlayMixin
* @mixinFunction
* @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig
* @typedef {import('../types/OverlayMixinTypes').DefineOverlayConfig} DefineOverlayConfig
* @typedef {import('../types/OverlayMixinTypes').OverlayHost} OverlayHost
* @typedef {import('../types/OverlayMixinTypes').OverlayMixin} OverlayMixin
*/
export const OverlayMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-shadow
class OverlayMixin extends superclass {
static get properties() {
return {
opened: {
type: Boolean,
reflect: true,
/**
* @type {OverlayMixin}
*/
export const OverlayMixinImplementation = superclass =>
class OverlayMixin extends superclass {
static get properties() {
return {
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();
this.opened = false;
this.__needsSetup = true;
this.config = {};
}
/**
* @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 {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 (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,
});
if (changedProperties.has('opened') && this._overlayCtrl && !this.__blockSyncToOverlayCtrl) {
this.__syncToOverlayController();
this.__setupSyncFromOverlayController();
this._setupOpenCloseListeners();
}
}
_teardownOverlayCtrl() {
this._teardownOpenCloseListeners();
this.__teardownSyncFromOverlayController();
this._overlayCtrl.teardown();
/**
* @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() {
/**
* @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
* 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 {{ preventDefault: () => void; }} beforeShowEvent
*/
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;
};
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();
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(
/** @type {OverlayController} */ (this._overlayCtrl).isShown,
);
beforeShowEvent.preventDefault();
}
};
/**
* @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 { globalOverlaysStyle } from './globalOverlaysStyle.js';
const isIOS = navigator.userAgent.match(/iPhone|iPad|iPod/i);
/**
* @typedef {object} 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
* @typedef {import('./OverlayController.js').OverlayController} OverlayController
*/
const isIOS = navigator.userAgent.match(/iPhone|iPad|iPod/i);
/**
* `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
* You can use .add or .remove to modify it
*/
// eslint-disable-next-line class-methods-use-this
get globalRootNode() {
if (!this.constructor.__globalRootNode) {
this.constructor.__globalRootNode = this.constructor.__createGlobalRootNode();
this.constructor.__globalStyleNode = this.constructor.__createGlobalStyleNode();
if (!OverlaysManager.__globalRootNode) {
OverlaysManager.__globalRootNode = OverlaysManager.__createGlobalRootNode();
OverlaysManager.__globalStyleNode = OverlaysManager.__createGlobalStyleNode();
}
return this.constructor.__globalRootNode;
return OverlaysManager.__globalRootNode;
}
/**
@ -67,9 +56,12 @@ export class OverlaysManager {
}
constructor() {
/** @type {OverlayController[]} */
this.__list = [];
/** @type {OverlayController[]} */
this.__shownList = [];
this.__siblingsInert = false;
/** @type {WeakMap<OverlayController, OverlayController[]>} */
this.__blockingMap = new WeakMap();
}
@ -86,6 +78,9 @@ export class OverlaysManager {
return ctrlToAdd;
}
/**
* @param {OverlayController} ctrlToRemove
*/
remove(ctrlToRemove) {
if (!this.list.find(ctrl => ctrlToRemove === ctrl)) {
throw new Error('could not find controller to remove');
@ -93,6 +88,9 @@ export class OverlaysManager {
this.__list = this.list.filter(ctrl => ctrl !== ctrlToRemove);
}
/**
* @param {OverlayController} ctrlToShow
*/
show(ctrlToShow) {
if (this.list.find(ctrl => ctrlToShow === ctrl)) {
this.hide(ctrlToShow);
@ -108,6 +106,9 @@ export class OverlaysManager {
});
}
/**
* @param {any} ctrlToHide
*/
hide(ctrlToHide) {
if (!this.list.find(ctrl => ctrlToHide === ctrl)) {
throw new Error('could not find controller to hide');
@ -124,13 +125,17 @@ export class OverlaysManager {
this.__shownList = [];
this.__siblingsInert = false;
const rootNode = this.constructor.__globalRootNode;
const rootNode = OverlaysManager.__globalRootNode;
if (rootNode) {
rootNode.parentElement.removeChild(rootNode);
this.constructor.__globalRootNode = undefined;
if (rootNode.parentElement) {
rootNode.parentElement.removeChild(rootNode);
}
OverlaysManager.__globalRootNode = undefined;
document.head.removeChild(this.constructor.__globalStyleNode);
this.constructor.__globalStyleNode = undefined;
document.head.removeChild(
/** @type {HTMLStyleElement} */ (OverlaysManager.__globalStyleNode),
);
OverlaysManager.__globalStyleNode = undefined;
}
}
@ -150,13 +155,14 @@ export class OverlaysManager {
informTrapsKeyboardFocusGotEnabled() {
if (this.siblingsInert === false) {
if (this.constructor.__globalRootNode) {
if (OverlaysManager.__globalRootNode) {
setSiblingsInert(this.globalRootNode);
}
this.__siblingsInert = true;
}
}
// @ts-ignore
informTrapsKeyboardFocusGotDisabled({ disabledCtrl, findNewTrap = true } = {}) {
const next = this.shownList.find(
ctrl => ctrl !== disabledCtrl && ctrl.trapsKeyboardFocus === true,
@ -166,7 +172,7 @@ export class OverlaysManager {
next.enableTrapsKeyboardFocus();
}
} else if (this.siblingsInert === true) {
if (this.constructor.__globalRootNode) {
if (OverlaysManager.__globalRootNode) {
unsetSiblingsInert(this.globalRootNode);
}
this.__siblingsInert = false;
@ -195,7 +201,10 @@ export class OverlaysManager {
}
}
/** Blocking */
/**
* Blocking
* @param {OverlayController} blockingCtrl
*/
requestToShowOnly(blockingCtrl) {
const controllersToHide = this.shownList.filter(ctrl => ctrl !== blockingCtrl);
@ -203,10 +212,19 @@ export class OverlaysManager {
this.__blockingMap.set(blockingCtrl, controllersToHide);
}
/**
* @param {OverlayController} blockingCtrl
*/
retractRequestToShowOnly(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());
}
}
}
/** @type {HTMLElement | undefined} */
OverlaysManager.__globalRootNode = undefined;
/** @type {HTMLStyleElement | undefined} */
OverlaysManager.__globalStyleNode = undefined;

View file

@ -1,11 +1,16 @@
export const withBottomSheetConfig = () => ({
hasBackdrop: true,
preventsScroll: true,
trapsKeyboardFocus: true,
hidesOnEsc: true,
placementMode: 'global',
viewportConfig: {
placement: 'bottom',
},
handlesAccessibility: true,
});
/**
* @typedef {import('../../types/OverlayConfig').OverlayConfig} OverlayConfig
*/
export const withBottomSheetConfig = () =>
/** @type {OverlayConfig} */ ({
hasBackdrop: true,
preventsScroll: true,
trapsKeyboardFocus: 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',
hidesOnOutsideClick: true,
popperConfig: {
placement: 'bottom-start',
modifiers: {
offset: {
enabled: false,
export const withDropdownConfig = () =>
/** @type {OverlayConfig} */ ({
placementMode: 'local',
inheritsReferenceWidth: 'min',
hidesOnOutsideClick: true,
popperConfig: {
placement: 'bottom-start',
modifiers: {
offset: {
enabled: false,
},
},
},
},
handlesAccessibility: true,
});
handlesAccessibility: true,
});

View file

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

View file

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

View file

@ -45,7 +45,7 @@ export function rotateFocus(rootElement, e) {
const [first, last] = boundaryEls;
// 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:
@ -74,7 +74,8 @@ export function containFocus(rootElement) {
const focusableElements = getFocusableElements(rootElement);
// Initial focus goes to first element with autofocus, or the root element
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.
// 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() {
const compareMask = tabDetectionElement.compareDocumentPosition(document.activeElement);
const compareMask = tabDetectionElement.compareDocumentPosition(
/** @type {Element} */ (document.activeElement),
);
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) {
if (element.localName === 'slot') {
/** @type {HTMLSlotElement} */
const slot = element;
const slot = /** @type {HTMLSlotElement} */ (element);
return slot.assignedNodes({ flatten: true });
}
@ -51,48 +50,46 @@ function getChildNodes(element) {
}
/**
* @param {Node} node
* @param {Element} element
* @returns {boolean}
*/
function isVisibleElement(node) {
if (node.nodeType !== Node.ELEMENT_NODE) {
function isVisibleElement(element) {
if (element.nodeType !== Node.ELEMENT_NODE) {
return false;
}
// A slot is not visible, but it's children might so we need
// to treat is as such.
if (node.localName === 'slot') {
if (element.localName === 'slot') {
return true;
}
return isVisible(/** @type {HTMLElement} */ (node));
return isVisible(/** @type {HTMLElement} */ (element));
}
/**
* Recursive function that traverses the children of the target node and finds
* elements that can receive focus. Mutates the nodes property for performance.
*
* @param {Node} node
* @param {Element} element
* @param {HTMLElement[]} nodes
* @returns {boolean} whether the returned node list should be sorted. This happens when
* 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 (!isVisibleElement(node)) {
if (!isVisibleElement(element)) {
return false;
}
/** @type {HTMLElement} */
const element = node;
const tabIndex = getTabindex(element);
const el = /** @type {HTMLElement} */ (element);
const tabIndex = getTabindex(el);
let needsSort = 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) {
needsSort = collectFocusableElements(childNodes[i], nodes) || needsSort;
}
@ -100,13 +97,13 @@ function collectFocusableElements(node, nodes) {
}
/**
* @param {Node} node
* @param {Element} element
* @returns {HTMLElement[]}
*/
export function getFocusableElements(node) {
export function getFocusableElements(element) {
/** @type {HTMLElement[]} */
const nodes = [];
const needsSort = collectFocusableElements(node, nodes);
const needsSort = collectFocusableElements(element, nodes);
return needsSort ? sortByTabIndex(nodes) : nodes;
}

View file

@ -8,7 +8,7 @@
* @param {HTMLElement} 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) {
const sibling = parentChildren[i];
@ -24,13 +24,13 @@ export function setSiblingsInert(element) {
* @param {HTMLElement} 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) {
const sibling = parentChildren[i];
if (sibling !== element) {
sibling.removeAttribute('inert', '');
sibling.removeAttribute('aria-hidden', 'true');
sibling.removeAttribute('inert');
sibling.removeAttribute('aria-hidden');
}
}
}

View file

@ -1,7 +1,7 @@
import { getFocusableElements } from './get-focusable-elements.js';
export function simulateTab(node = document.body) {
const current = document.activeElement;
const current = /** @type {HTMLElement} */ (document.activeElement);
const all = getFocusableElements(node);
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 sinon from 'sinon';
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() {
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 = '' }) {
describe(`OverlayMixin${suffix}`, () => {
it('should not be opened by default', async () => {
const el = await fixture(html`
const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag}>
<div slot="content">content of the overlay</div>
<button slot="invoker">invoker button</button>
</${tag}>
`);
`));
expect(el.opened).to.be.false;
expect(el._overlayCtrl.isShown).to.be.false;
});
it('syncs opened to overlayController', async () => {
const el = await fixture(html`
const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag}>
<div slot="content">content of the overlay</div>
<button slot="invoker">invoker button</button>
</${tag}>
`);
`));
el.opened = true;
await el.updateComplete;
await el._overlayCtrl._showComplete;
@ -42,12 +56,12 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
});
it('syncs OverlayController to opened', async () => {
const el = await fixture(html`
const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag}>
<div slot="content">content of the overlay</div>
<button slot="invoker">invoker button</button>
</${tag}>
`);
`));
expect(el.opened).to.be.false;
await el._overlayCtrl.show();
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 () => {
const parentNode = document.createElement('div');
parentNode.setAttribute('style', 'height: 10000px; width: 10000px;');
const elWithBigParent = await fixture(
const elWithBigParent = /** @type {OverlayEl} */ (await fixture(
html`
<${tag}>
<div slot="content">content of the overlay</div>
@ -67,24 +81,35 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
</${tag}>
`,
{ parentNode },
);
const { offsetWidth, offsetHeight } = elWithBigParent.offsetParent;
));
const {
offsetWidth,
offsetHeight,
} = /** @type {HTMLElement} */ (elWithBigParent.offsetParent);
await elWithBigParent._overlayCtrl.show();
expect(elWithBigParent.opened).to.be.true;
expect(elWithBigParent.offsetParent.offsetWidth).to.equal(offsetWidth);
expect(elWithBigParent.offsetParent.offsetHeight).to.equal(offsetHeight);
expect(/** @type {HTMLElement} */ (elWithBigParent?.offsetParent).offsetWidth).to.equal(
offsetWidth,
);
expect(/** @type {HTMLElement} */ (elWithBigParent?.offsetParent).offsetHeight).to.equal(
offsetHeight,
);
await elWithBigParent._overlayCtrl.hide();
expect(elWithBigParent.offsetParent.offsetWidth).to.equal(offsetWidth);
expect(elWithBigParent.offsetParent.offsetHeight).to.equal(offsetHeight);
expect(/** @type {HTMLElement} */ (elWithBigParent?.offsetParent).offsetWidth).to.equal(
offsetWidth,
);
expect(/** @type {HTMLElement} */ (elWithBigParent?.offsetParent).offsetHeight).to.equal(
offsetHeight,
);
});
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' } }}>
<div slot="content">content of the overlay</div>
<button slot="invoker">invoker button</button>
</${tag}>
`);
`));
itEl.opened = true;
await itEl.updateComplete;
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 () => {
const spy = sinon.spy();
const el = await fixture(html`
const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag} @opened-changed="${spy}">
<div slot="content">content of the overlay</div>
<button slot="invoker">invoker button</button>
</${tag}>
`);
`));
expect(spy).not.to.have.been.called;
await el._overlayCtrl.show();
await el.updateComplete;
@ -114,12 +139,12 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
it('fires "before-closed" event on hide', async () => {
const beforeSpy = sinon.spy();
const el = await fixture(html`
const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag} @before-closed="${beforeSpy}" .opened="${true}">
<div slot="content">content of the overlay</div>
<button slot="invoker">invoker button</button>
</${tag}>
`);
`));
// Wait until it's done opening (handling features is async)
await nextFrame();
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 () => {
const beforeSpy = sinon.spy();
const el = await fixture(html`
const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag} @before-opened="${beforeSpy}">
<div slot="content">content of the overlay</div>
<button slot="invoker">invoker button</button>
</${tag}>
`);
`));
expect(beforeSpy).not.to.have.been.called;
await el._overlayCtrl.show();
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 () => {
function preventer(ev) {
function preventer(/** @type Event */ ev) {
ev.preventDefault();
}
const el = await fixture(html`
const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag} @before-opened="${preventer}" @before-closed="${preventer}">
<div slot="content">content of the overlay</div>
<button slot="invoker">invoker button</button>
</${tag}>
`);
el.querySelector('[slot="invoker"]').click();
`));
/** @type {HTMLElement} */ (el.querySelector('[slot="invoker"]')).click();
await nextFrame();
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 () => {
function sendCloseEvent(e) {
e.target.dispatchEvent(new Event('close-overlay', { bubbles: true }));
function sendCloseEvent(/** @type {Event} */ e) {
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>
<div slot="content">
content of the overlay
@ -177,7 +204,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
</div>
<button slot="invoker">invoker button</button>
</${tag}>
`);
`));
closeBtn.click();
expect(el.opened).to.be.false;
});
@ -194,7 +221,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
});
it('supports nested overlays', async () => {
const el = await fixture(html`
const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag} id="main-dialog">
<div slot="content" id="mainContent">
open nested overlay:
@ -207,32 +234,34 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
</div>
<button slot="invoker" id="mainInvoker">invoker button</button>
</${tag}>
`);
`));
if (el._overlayCtrl.placementMode === 'global') {
expect(getGlobalOverlayNodes().length).to.equal(2);
}
el.opened = true;
await aTimeout();
await aTimeout(0);
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;
await aTimeout();
await aTimeout(0);
expect(nestedOverlayEl._overlayCtrl.contentNode).to.be.displayed;
});
it('[global] allows for moving of the element', async () => {
const el = await fixture(html`
const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag}>
<div slot="content" id="nestedContent">content of the nested overlay</div>
<button slot="invoker">invoker nested</button>
</${tag}>
`);
`));
if (el._overlayCtrl.placementMode === 'global') {
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);
await el.updateComplete;
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 () => {
const nestedEl = await fixture(html`
const nestedEl = /** @type {OverlayEl} */ (await fixture(html`
<${tag} id="nest">
<div slot="content" id="nestedContent">content of the nested overlay</div>
<button slot="invoker">invoker nested</button>
</${tag}>
`);
`));
const el = await fixture(html`
const el = /** @type {OverlayEl} */ (await fixture(html`
<${tag} id="main">
<div slot="content" id="mainContent">
open nested overlay:
@ -255,7 +284,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
</div>
<button slot="invoker">invoker button</button>
</${tag}>
`);
`));
if (el._overlayCtrl.placementMode === 'global') {
// 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');
} 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.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 { simulateTab } from '../src/utils/simulate-tab.js';
const withGlobalTestConfig = () => ({
placementMode: 'global',
contentNode: fixtureSync(html`<div>my content</div>`),
});
/**
* @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig
* @typedef {import('../types/OverlayConfig').ViewportPlacement} ViewportPlacement
*/
const withLocalTestConfig = () => ({
placementMode: 'local',
contentNode: fixtureSync(html`<div>my content</div>`),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;">Invoker</div>
`),
});
const withGlobalTestConfig = () =>
/** @type {OverlayConfig} */ ({
placementMode: 'global',
contentNode: /** @type {HTMLElement} */ (fixtureSync(html`<div>my content</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(() => {
overlays.teardown();
@ -52,10 +59,15 @@ describe('OverlayController', () => {
});
describe('Z-index on local overlays', () => {
/** @type {HTMLElement} */
let contentNode;
/**
* @param {string} zIndexVal
* @param {{ mode?: string }} options
*/
async function createZNode(zIndexVal, { mode } = {}) {
if (mode === 'global') {
contentNode = await fixture(html`
contentNode = /** @type {HTMLElement} */ (await fixture(html`
<div class="z-index--${zIndexVal}">
<style>
.z-index--${zIndexVal} {
@ -64,10 +76,12 @@ describe('OverlayController', () => {
</style>
I should be on top
</div>
`);
`));
}
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;
}
return contentNode;
@ -131,19 +145,19 @@ describe('OverlayController', () => {
it.skip('creates local target next to sibling for placement mode "local"', async () => {
const ctrl = new OverlayController({
...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.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 () => {
const parentNode = await fixture(html`
const parentNode = /** @type {HTMLElement} */ (await fixture(html`
<div id="parent">
<div id="content">Content</div>
</div>
`);
const contentNode = parentNode.querySelector('#content');
`));
const contentNode = /** @type {HTMLElement} */ (parentNode.querySelector('#content'));
const ctrl = new OverlayController({
...withLocalTestConfig(),
contentNode,
@ -193,7 +207,8 @@ describe('OverlayController', () => {
const shadowHost = document.createElement('div');
shadowHost.id = 'shadowHost';
shadowHost.attachShadow({ mode: 'open' });
shadowHost.shadowRoot.innerHTML = `
/** @type {ShadowRoot} */
(shadowHost.shadowRoot).innerHTML = `
<div id="contentWrapperNode">
<slot name="contentNode"></slot>
<my-arrow></my-arrow>
@ -203,7 +218,7 @@ describe('OverlayController', () => {
contentNode.slot = '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
wrapper.appendChild(shadowHost);
@ -230,7 +245,7 @@ describe('OverlayController', () => {
it('accepts an .contentNode<Node> to directly set content', async () => {
const ctrl = new OverlayController({
...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');
});
@ -238,7 +253,7 @@ describe('OverlayController', () => {
it('accepts an .invokerNode<Node> to directly set invoker', async () => {
const ctrl = new OverlayController({
...withGlobalTestConfig(),
invokerNode: await fixture('<button>invoke</button>'),
invokerNode: /** @type {HTMLElement} */ (await fixture('<button>invoke</button>')),
});
expect(ctrl.invokerNode).to.have.trimmed.text('invoke');
});
@ -247,7 +262,7 @@ describe('OverlayController', () => {
it('recognizes projected contentNode', async () => {
const shadowHost = document.createElement('div');
shadowHost.attachShadow({ mode: 'open' });
shadowHost.shadowRoot.innerHTML = `
/** @type {ShadowRoot} */ (shadowHost.shadowRoot).innerHTML = `
<div id="contentWrapperNode">
<slot name="contentNode"></slot>
<my-arrow></my-arrow>
@ -263,7 +278,9 @@ describe('OverlayController', () => {
const ctrl = new OverlayController({
...withLocalTestConfig(),
contentNode,
contentWrapperNode: shadowHost.shadowRoot.getElementById('contentWrapperNode'),
contentWrapperNode: /** @type {HTMLElement} */ (
/** @type {ShadowRoot} */ (shadowHost.shadowRoot).getElementById('contentWrapperNode')
),
});
expect(ctrl.__isContentNodeProjected).to.be.true;
@ -272,14 +289,14 @@ describe('OverlayController', () => {
describe('When contentWrapperNode needs to be provided for correct arrow positioning', () => {
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="contentNode"></div>
<my-arrow></my-arrow>
</div>
`);
`));
const contentNode = el.querySelector('#contentNode');
const contentNode = /** @type {HTMLElement} */ (el.querySelector('#contentNode'));
const contentWrapperNode = el;
const ctrl = new OverlayController({
@ -288,7 +305,7 @@ describe('OverlayController', () => {
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 () => {
const contentNode = await fixture(html`
const contentNode = /** @type {HTMLElement} */ (await fixture(html`
<div><input id="input1" /><input id="input2" /></div>
`);
`));
const ctrl = new OverlayController({
...withGlobalTestConfig(),
trapsKeyboardFocus: true,
@ -326,13 +343,16 @@ describe('OverlayController', () => {
});
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 input2 = ctrl.contentNode.querySelectorAll('input')[1];
input2.focus();
// this mimics a tab within the contain-focus system used
const event = new CustomEvent('keydown', { detail: 0, bubbles: true });
// @ts-ignore override private key
event.keyCode = keyCodes.tab;
window.dispatchEvent(event);
@ -341,7 +361,7 @@ describe('OverlayController', () => {
});
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({
...withGlobalTestConfig(),
@ -349,11 +369,11 @@ describe('OverlayController', () => {
trapsKeyboardFocus: true,
});
// add element to dom to allow focus
await fixture(html`${ctrl.content}`);
/** @type {HTMLElement} */ (await fixture(html`${ctrl.content}`));
await ctrl.show();
const elOutside = await fixture(html`<input />`);
const input = ctrl.contentNode.querySelector('input');
const elOutside = /** @type {HTMLElement} */ (await fixture(html`<input />`));
const input = /** @type {HTMLInputElement} */ (ctrl.contentNode.querySelector('input'));
input.focus();
simulateTab();
@ -391,7 +411,7 @@ describe('OverlayController', () => {
});
await ctrl.show();
ctrl.contentNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
await aTimeout();
await aTimeout(0);
expect(ctrl.isShown).to.be.false;
});
@ -414,7 +434,7 @@ describe('OverlayController', () => {
});
await ctrl.show();
document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
await aTimeout();
await aTimeout(0);
expect(ctrl.isShown).to.be.false;
});
@ -431,7 +451,7 @@ describe('OverlayController', () => {
describe('hidesOnOutsideClick', () => {
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({
...withGlobalTestConfig(),
hidesOnOutsideClick: true,
@ -440,13 +460,13 @@ describe('OverlayController', () => {
await ctrl.show();
document.body.click();
await aTimeout();
await aTimeout(0);
expect(ctrl.isShown).to.be.false;
});
it('doesn\'t hide on "inside" click', async () => {
const invokerNode = await fixture('<button>Invoker</button>');
const contentNode = await fixture('<div>Content</div>');
const invokerNode = /** @type {HTMLElement} */ (await fixture('<button>Invoker</button>'));
const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>'));
const ctrl = new OverlayController({
...withGlobalTestConfig(),
hidesOnOutsideClick: true,
@ -456,13 +476,13 @@ describe('OverlayController', () => {
await ctrl.show();
// Don't hide on invoker click
ctrl.invokerNode.click();
await aTimeout();
ctrl.invokerNode?.click();
await aTimeout(0);
expect(ctrl.isShown).to.be.true;
// Don't hide on inside (content) click
ctrl.contentNode.click();
await aTimeout();
await aTimeout(0);
expect(ctrl.isShown).to.be.true;
@ -474,8 +494,8 @@ describe('OverlayController', () => {
});
it('doesn\'t hide on "inside sub shadow dom" click', async () => {
const invokerNode = await fixture('<button>Invoker</button>');
const contentNode = await fixture('<div>Content</div>');
const invokerNode = /** @type {HTMLElement} */ (await fixture('<button>Invoker</button>'));
const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>'));
const ctrl = new OverlayController({
...withGlobalTestConfig(),
hidesOnOutsideClick: true,
@ -493,25 +513,28 @@ describe('OverlayController', () => {
}
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);
ctrl.updateConfig({
contentNode: await fixture(html`
<div>
<div>Content</div>
<${tag}></${tag}>
</div>
`),
contentNode: /** @type {HTMLElement} */ (await fixture(html`
<div>
<div>Content</div>
<${tag}></${tag}>
</div>
`)),
});
await ctrl.show();
// 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;
// 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 () => {
const invokerNode = await fixture('<div role="button">Invoker</div>');
const contentNode = await fixture('<div>Content</div>');
const invokerNode = /** @type {HTMLElement} */ (await fixture(
'<div role="button">Invoker</div>',
));
const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>'));
const ctrl = new OverlayController({
...withLocalTestConfig(),
hidesOnOutsideClick: true,
contentNode,
invokerNode,
});
const dom = await fixture(`
const dom = await fixture(
/**
* @param {{ stopPropagation: () => any; }} e
*/
`
<div>
<div id="popup">${invokerNode}${contentNode}</div>
<div
@ -539,17 +568,19 @@ describe('OverlayController', () => {
/* propagates */
}}"
></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.
</third-party-noise>
</div>
`);
`,
);
await ctrl.show();
expect(ctrl.isShown).to.equal(true);
dom.querySelector('third-party-noise').click();
await aTimeout();
/** @type {HTMLElement} */
(dom.querySelector('third-party-noise')).click();
await aTimeout(0);
expect(ctrl.isShown).to.equal(false);
// 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 () => {
const invokerNode = await fixture(html`<div role="button">Invoker</div>`);
const contentNode = await fixture('<div>Content</div>');
const invokerNode = /** @type {HTMLElement} */ (await fixture(
html`<div role="button">Invoker</div>`,
));
const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>'));
const ctrl = new OverlayController({
...withLocalTestConfig(),
hidesOnOutsideClick: true,
contentNode,
invokerNode,
});
const dom = await fixture(`
const dom = /** @type {HTMLElement} */ (await fixture(`
<div>
<div id="popup">${invokerNode}${ctrl.content}</div>
<div
@ -579,11 +612,12 @@ describe('OverlayController', () => {
This element prevents our handlers from reaching the document click handler.
</third-party-noise>
</div>
`);
`));
dom.querySelector('third-party-noise').addEventListener(
/** @type {HTMLElement} */
(dom.querySelector('third-party-noise')).addEventListener(
'click',
event => {
(/** @type {Event} */ event) => {
event.stopPropagation();
},
true,
@ -592,8 +626,9 @@ describe('OverlayController', () => {
await ctrl.show();
expect(ctrl.isShown).to.equal(true);
dom.querySelector('third-party-noise').click();
await aTimeout();
/** @type {HTMLElement} */
(dom.querySelector('third-party-noise')).click();
await aTimeout(0);
expect(ctrl.isShown).to.equal(false);
// 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 () => {
const contentNode = await fixture(`
const contentNode = /** @type {HTMLElement} */ (await fixture(`
<div>
<label for="test">test</label>
<input id="test">
Content
</div>`);
const labelNode = contentNode.querySelector('label[for=test]');
</div>`));
const labelNode = /** @type {HTMLElement} */ (contentNode.querySelector('label[for=test]'));
const ctrl = new OverlayController({
...withGlobalTestConfig(),
hidesOnOutsideClick: true,
@ -618,7 +653,7 @@ describe('OverlayController', () => {
// Don't hide on label click
labelNode.click();
await aTimeout();
await aTimeout(0);
expect(ctrl.isShown).to.be.true;
});
@ -626,7 +661,7 @@ describe('OverlayController', () => {
describe('elementToFocusAfterHide', () => {
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({
...withGlobalTestConfig(),
viewportConfig: {
@ -636,7 +671,7 @@ describe('OverlayController', () => {
});
await ctrl.show();
const input = contentNode.querySelector('input');
const input = /** @type {HTMLInputElement} */ (contentNode.querySelector('input'));
input.focus();
expect(document.activeElement).to.equal(input);
@ -646,8 +681,10 @@ describe('OverlayController', () => {
});
it('supports elementToFocusAfterHide option to focus it when hiding', async () => {
const input = await fixture('<input />');
const contentNode = await fixture('<div><textarea></textarea></div>');
const input = /** @type {HTMLElement} */ (await fixture('<input />'));
const contentNode = /** @type {HTMLElement} */ (await fixture(
'<div><textarea></textarea></div>',
));
const ctrl = new OverlayController({
...withGlobalTestConfig(),
elementToFocusAfterHide: input,
@ -655,7 +692,7 @@ describe('OverlayController', () => {
});
await ctrl.show();
const textarea = contentNode.querySelector('textarea');
const textarea = /** @type {HTMLTextAreaElement} */ (contentNode.querySelector('textarea'));
textarea.focus();
expect(document.activeElement).to.equal(textarea);
@ -664,8 +701,10 @@ describe('OverlayController', () => {
});
it('allows to set elementToFocusAfterHide on show', async () => {
const input = await fixture('<input />');
const contentNode = await fixture('<div><textarea></textarea></div>');
const input = /** @type {HTMLElement} */ (await fixture('<input />'));
const contentNode = /** @type {HTMLElement} */ (await fixture(
'<div><textarea></textarea></div>',
));
const ctrl = new OverlayController({
...withGlobalTestConfig(),
viewportConfig: {
@ -675,7 +714,7 @@ describe('OverlayController', () => {
});
await ctrl.show(input);
const textarea = contentNode.querySelector('textarea');
const textarea = /** @type {HTMLTextAreaElement} */ (contentNode.querySelector('textarea'));
textarea.focus();
expect(document.activeElement).to.equal(textarea);
@ -1067,7 +1106,7 @@ describe('OverlayController', () => {
it('reinitializes content', async () => {
const ctrl = new OverlayController({
...withLocalTestConfig(),
contentNode: await fixture(html`<div>content1</div>`),
contentNode: /** @type {HTMLElement} */ (await fixture(html`<div>content1</div>`)),
});
await ctrl.show(); // Popper adds inline styles
expect(ctrl.content.style.transform).not.to.be.undefined;
@ -1075,18 +1114,18 @@ describe('OverlayController', () => {
ctrl.updateConfig({
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');
});
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({
// This is the shared config
placementMode: 'global',
handlesAccesibility: true,
handlesAccessibility: true,
contentNode,
});
ctrl.updateConfig({
@ -1095,32 +1134,30 @@ describe('OverlayController', () => {
hidesOnEsc: true,
});
expect(ctrl.placementMode).to.equal('local');
expect(ctrl.handlesAccesibility).to.equal(true);
expect(ctrl.handlesAccessibility).to.equal(true);
expect(ctrl.contentNode).to.equal(contentNode);
});
// Currently not working, enable again when we fix updateConfig
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({
// This is the shared config
placementMode: 'global',
handlesAccesibility: true,
handlesAccessibility: true,
contentNode,
});
ctrl.show();
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;
ctrl.updateConfig({ viewportConfig: { placement: 'top-right' } });
expect(
ctrl._contentWrapperNode.classList.contains(
'global-overlays__overlay-container--top-right',
),
ctrl.contentWrapperNode.classList.contains('global-overlays__overlay-container--top-right'),
);
expect(ctrl.isShown).to.be.true;
});
@ -1128,17 +1165,19 @@ describe('OverlayController', () => {
describe('Accessibility', () => {
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({
...withLocalTestConfig(),
handlesAccessibility: true,
invokerNode,
});
expect(ctrl.invokerNode.getAttribute('aria-expanded')).to.equal('false');
expect(ctrl.invokerNode?.getAttribute('aria-expanded')).to.equal('false');
await ctrl.show();
expect(ctrl.invokerNode.getAttribute('aria-expanded')).to.equal('true');
expect(ctrl.invokerNode?.getAttribute('aria-expanded')).to.equal('true');
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 () => {
@ -1150,7 +1189,9 @@ describe('OverlayController', () => {
});
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({
...withLocalTestConfig(),
handlesAccessibility: true,
@ -1160,7 +1201,9 @@ describe('OverlayController', () => {
});
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({
...withLocalTestConfig(),
handlesAccessibility: true,
@ -1170,8 +1213,12 @@ describe('OverlayController', () => {
});
it('preserves [role] on content when present', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>');
const contentNode = await fixture('<div role="menu">invoker</div>');
const invokerNode = /** @type {HTMLElement} */ (await fixture(
'<div role="button">invoker</div>',
));
const contentNode = /** @type {HTMLElement} */ (await fixture(
'<div role="menu">invoker</div>',
));
const ctrl = new OverlayController({
...withLocalTestConfig(),
handlesAccessibility: true,
@ -1187,7 +1234,7 @@ describe('OverlayController', () => {
new OverlayController({
...withLocalTestConfig(),
handlesAccessibility: true,
invokerNode: null,
invokerNode: undefined,
});
properlyInstantiated = true;
} catch (e) {
@ -1282,18 +1329,22 @@ describe('OverlayController', () => {
describe('Tooltip', () => {
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({
...withLocalTestConfig(),
handlesAccessibility: true,
isTooltip: true,
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 () => {
const invokerNode = await fixture('<div role="button">invoker</div>');
const invokerNode = /** @type {HTMLElement} */ (await fixture(
'<div role="button">invoker</div>',
));
const ctrl = new OverlayController({
...withLocalTestConfig(),
handlesAccessibility: true,
@ -1301,12 +1352,14 @@ describe('OverlayController', () => {
invokerRelation: 'label',
invokerNode,
});
expect(ctrl.invokerNode.getAttribute('aria-describedby')).to.equal(null);
expect(ctrl.invokerNode.getAttribute('aria-labelledby')).to.equal(ctrl._contentId);
expect(ctrl.invokerNode?.getAttribute('aria-describedby')).to.equal(null);
expect(ctrl.invokerNode?.getAttribute('aria-labelledby')).to.equal(ctrl._contentId);
});
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({
...withLocalTestConfig(),
handlesAccessibility: true,
@ -1318,7 +1371,9 @@ describe('OverlayController', () => {
describe('Teardown', () => {
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({
...withLocalTestConfig(),
handlesAccessibility: true,
@ -1330,8 +1385,12 @@ describe('OverlayController', () => {
});
it('restores [role] on tooltip content', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>');
const contentNode = await fixture('<div role="presentation">content</div>');
const invokerNode = /** @type {HTMLElement} */ (await fixture(
'<div role="button">invoker</div>',
));
const contentNode = /** @type {HTMLElement} */ (await fixture(
'<div role="presentation">content</div>',
));
const ctrl = new OverlayController({
...withLocalTestConfig(),
handlesAccessibility: true,
@ -1345,8 +1404,12 @@ describe('OverlayController', () => {
});
it('restores [aria-describedby] on content', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>');
const contentNode = await fixture('<div role="presentation">content</div>');
const invokerNode = /** @type {HTMLElement} */ (await fixture(
'<div role="button">invoker</div>',
));
const contentNode = /** @type {HTMLElement} */ (await fixture(
'<div role="presentation">content</div>',
));
const ctrl = new OverlayController({
...withLocalTestConfig(),
handlesAccessibility: true,
@ -1360,8 +1423,12 @@ describe('OverlayController', () => {
});
it('restores [aria-labelledby] on content', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>');
const contentNode = await fixture('<div role="presentation">content</div>');
const invokerNode = /** @type {HTMLElement} */ (await fixture(
'<div role="button">invoker</div>',
));
const contentNode = /** @type {HTMLElement} */ (await fixture(
'<div role="presentation">content</div>',
));
const ctrl = new OverlayController({
...withLocalTestConfig(),
handlesAccessibility: true,
@ -1393,6 +1460,7 @@ describe('OverlayController', () => {
it('throws if invalid .placementMode gets passed on', async () => {
expect(() => {
new OverlayController({
// @ts-ignore
placementMode: 'invalid',
});
}).to.throw(
@ -1411,7 +1479,7 @@ describe('OverlayController', () => {
it('throws if contentNodeWrapper is not provided for projected contentNode', async () => {
const shadowHost = document.createElement('div');
shadowHost.attachShadow({ mode: 'open' });
shadowHost.shadowRoot.innerHTML = `
/** @type {ShadowRoot} */ (shadowHost.shadowRoot).innerHTML = `
<div id="contentWrapperNode">
<slot name="contentNode"></slot>
<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 { OverlaysManager } from '../src/OverlaysManager.js';
/**
* @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig
*/
describe('OverlaysManager', () => {
/** @type {OverlayConfig} */
let defaultOptions;
/** @type {OverlaysManager} */
let mngr;
beforeEach(async () => {
const contentNode = await fixture(html`<p>my content</p>`);
const contentNode = /** @type {HTMLElement} */ (await fixture(html`<p>my content</p>`));
defaultOptions = {
placementMode: 'global',
@ -36,8 +42,8 @@ describe('OverlaysManager', () => {
expect(document.head.querySelector('[data-global-overlays=""]')).be.null;
// safety check via private access (do not use this)
expect(mngr.constructor.__globalRootNode).to.be.undefined;
expect(mngr.constructor.__globalStyleNode).to.be.undefined;
expect(OverlaysManager.__globalRootNode).to.be.undefined;
expect(OverlaysManager.__globalStyleNode).to.be.undefined;
});
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 { overlays } from '../src/overlays.js';
const withDefaultGlobalConfig = () => ({
placementMode: 'global',
contentNode: fixtureSync(html`<p>my content</p>`),
});
/**
* @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig
* @typedef {import('../types/OverlayConfig').ViewportPlacement} ViewportPlacement
*/
const withDefaultGlobalConfig = () =>
/** @type {OverlayConfig} */ ({
placementMode: 'global',
contentNode: fixtureSync(html`<p>my content</p>`),
});
describe('Global Positioning', () => {
afterEach(() => {
@ -50,7 +56,7 @@ describe('Global Positioning', () => {
const ctrl = new OverlayController({
...withDefaultGlobalConfig(),
viewportConfig: {
placement: viewportPlacement,
placement: /** @type {ViewportPlacement} */ (viewportPlacement),
},
});
await ctrl.show();

View file

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

View file

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

View file

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

View file

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

View file

@ -4,118 +4,126 @@ import { isVisible } from '../../src/utils/is-visible.js';
describe('isVisible()', () => {
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);
});
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);
});
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>`,
);
));
expect(isVisible(element)).to.equal(true);
});
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>`,
);
));
expect(isVisible(element)).to.equal(false);
});
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>
`);
`));
expect(isVisible(element)).to.equal(true);
});
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>
`);
`));
expect(isVisible(element)).to.equal(false);
});
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>
`);
`));
expect(isVisible(element)).to.equal(true);
});
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>
`);
`));
expect(isVisible(element)).to.equal(false);
});
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);
});
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);
});
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);
});
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);
});
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>`,
);
));
expect(isVisible(element)).to.equal(false);
});
it('returns false when hidden by parent', async () => {
const element = await fixture(`
const element = /** @type {HTMLElement} */ (await fixture(`
<div hidden>
<div id="target" style="width:10px; height:10px;"></div>
<div></div>
</div>
`);
`));
const target = element.querySelector('#target');
const target = /** @type {HTMLElement} */ (element.querySelector('#target'));
expect(isVisible(target)).to.equal(false);
});
it('returns false when invisible by parent', async () => {
const element = await fixture(`
const element = /** @type {HTMLElement} */ (await fixture(`
<div style="visibility: hidden;">
<div id="target" style="width:10px; height:10px;"></div>
<div></div>
</div>
`);
`));
const target = element.querySelector('#target');
const target = /** @type {HTMLElement} */ (element.querySelector('#target'));
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 { OverlayMixin } from '@lion/overlays';
/**
* @typedef {import('@lion/overlays/types/OverlayConfig').OverlayConfig} OverlayConfig
*/
/**
* @customElement lion-tooltip
*/
@ -82,7 +86,7 @@ export class LionTooltip extends OverlayMixin(LitElement) {
/**
* Decides whether the tooltip invoker text should be considered a description
* (sets aria-describedby) or a label (sets aria-labelledby).
* @type {'label'\'description'}
* @type {'label'|'description'}
*/
this.invokerRelation = 'description';
this._mouseActive = false;
@ -112,9 +116,9 @@ export class LionTooltip extends OverlayMixin(LitElement) {
// eslint-disable-next-line class-methods-use-this
_defineOverlayConfig() {
return {
return /** @type {OverlayConfig} */ ({
placementMode: 'local',
elementToFocusAfterHide: null,
elementToFocusAfterHide: undefined,
hidesOnEsc: true,
hidesOnOutsideEsc: true,
popperConfig: {
@ -137,7 +141,7 @@ export class LionTooltip extends OverlayMixin(LitElement) {
handlesAccessibility: true,
isTooltip: true,
invokerRelation: this.invokerRelation,
};
});
}
__setupRepositionCompletePromise() {
@ -147,15 +151,21 @@ export class LionTooltip extends OverlayMixin(LitElement) {
}
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) {
if (!data) {
return;
}
if (this._arrowNode && data.placement !== this._arrowNode.placement) {
this.__repositionCompleteResolver(data.placement);
if (
this._arrowNode &&
data.placement !== /** @type {Element & {placement:string}} */ (this._arrowNode).placement
) {
/** @type {function} */ (this.__repositionCompleteResolver)(data.placement);
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 '../lion-tooltip.js';
/**
* @typedef {import('../src/LionTooltip.js').LionTooltip} LionTooltip
* @typedef {import('@lion/overlays/types/OverlayConfig').OverlayConfig} OverlayConfig
*/
describe('lion-tooltip', () => {
describe('Integration tests', () => {
const tagString = 'lion-tooltip';
@ -16,85 +21,99 @@ describe('lion-tooltip', () => {
describe('Basic', () => {
it('shows content on mouseenter and hide on mouseleave', async () => {
const el = await fixture(html`
const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip>
<div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button>
</lion-tooltip>
`);
`));
const eventMouseEnter = new Event('mouseenter');
el.dispatchEvent(eventMouseEnter);
await el.updateComplete;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.isShown).to.equal(true);
const eventMouseLeave = new Event('mouseleave');
el.dispatchEvent(eventMouseLeave);
await el.updateComplete;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.isShown).to.equal(false);
});
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>
<div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button>
</lion-tooltip>
`);
`));
const eventMouseEnter = new Event('mouseenter');
el.dispatchEvent(eventMouseEnter);
await el.updateComplete;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.isShown).to.equal(true);
const eventFocusOut = new Event('focusout');
el.dispatchEvent(eventFocusOut);
await el.updateComplete;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.isShown).to.equal(true);
});
it('shows content on focusin and hide on focusout', async () => {
const el = await fixture(html`
const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip>
<div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button>
</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');
invoker.dispatchEvent(eventFocusIn);
await el.updateComplete;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.isShown).to.equal(true);
const eventFocusOut = new Event('focusout');
invoker.dispatchEvent(eventFocusOut);
await el.updateComplete;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.isShown).to.equal(false);
});
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>
<div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button>
</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');
invoker.dispatchEvent(eventFocusIn);
await el.updateComplete;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.isShown).to.equal(true);
const eventMouseLeave = new Event('mouseleave');
invoker.dispatchEvent(eventMouseLeave);
await el.updateComplete;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.isShown).to.equal(true);
});
it('contains html when specified in tooltip content body', async () => {
const el = await fixture(html`
const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip>
<div slot="content">
This is Tooltip using <strong id="click_overlay">overlay</strong>
</div>
<button slot="invoker">Tooltip button</button>
</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');
invoker.dispatchEvent(event);
await el.updateComplete;
@ -104,32 +123,32 @@ describe('lion-tooltip', () => {
describe('Arrow', () => {
it('shows when "has-arrow" is configured', async () => {
const el = await fixture(html`
const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip has-arrow>
<div slot="content">
This is Tooltip using <strong id="click_overlay">overlay</strong>
</div>
<button slot="invoker">Tooltip button</button>
</lion-tooltip>
`);
`));
expect(el._arrowNode).to.be.displayed;
});
it('makes sure positioning of the arrow is correct', async () => {
const el = await fixture(html`
const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip
has-arrow
.config="${{
.config="${/** @type {OverlayConfig} */ ({
popperConfig: {
placement: 'right',
},
}}"
})}"
style="position: relative; top: 10px;"
>
<div slot="content" style="height: 30px; background-color: red;">Hey there</div>
<button slot="invoker" style="height: 30px;">Tooltip button</button>
</lion-tooltip>
`);
`));
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',
); */
expect(getComputedStyle(el._arrowNode).getPropertyValue('left')).to.equal(
expect(
getComputedStyle(/** @type {HTMLElement} */ (el._arrowNode)).getPropertyValue('left'),
).to.equal(
'-10px',
`
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', () => {
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>
<div slot="content">Hey there</div>
<div slot="invoker">Tooltip button</div>
</lion-tooltip>
`);
`));
await aTimeout();
await aTimeout(0);
// @ts-expect-error allow protected props in tests
const initialPopperModifiers = el._overlayCtrl.config.popperConfig.modifiers;
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.config.popperConfig.placement).to.equal('top');
// TODO: this fails in CI, we need to investigate why in CI
// the value of the transform is: translate3d(16px, -26px, 0px)'
@ -176,9 +199,11 @@ describe('lion-tooltip', () => {
el.opened = false;
el.opened = true;
await aTimeout();
await aTimeout(0);
// @ts-expect-error allow protected props in tests
const updatedPopperModifiers = el._overlayCtrl.config.popperConfig.modifiers;
expect(updatedPopperModifiers).to.deep.equal(initialPopperModifiers);
// @ts-expect-error allow protected props in tests
expect(el._overlayCtrl.config.popperConfig.placement).to.equal('bottom');
// TODO: this fails in CI, we need to investigate why in CI
// the value of the transform is: translate3d(16px, 26px, 0px)'
@ -190,63 +215,63 @@ describe('lion-tooltip', () => {
describe('Accessibility', () => {
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>
<div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button>
</lion-tooltip>
`);
`));
// 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
const content = el.querySelector('[slot=content]');
const content = /** @type {HTMLElement} */ (el.querySelector('[slot=content]'));
expect(content.getAttribute('role')).to.be.equal('tooltip');
});
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>
<div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button>
</lion-tooltip>
`);
const content = el.querySelector('[slot=content]');
const invoker = el.querySelector('[slot=invoker]');
`));
const content = /** @type {HTMLElement} */ (el.querySelector('[slot=content]'));
const invoker = /** @type {HTMLElement} */ (el.querySelector('[slot=invoker]'));
expect(invoker.getAttribute('aria-describedby')).to.be.equal(content.id);
expect(invoker.getAttribute('aria-labelledby')).to.be.equal(null);
});
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">
<div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button>
</lion-tooltip>
`);
const content = el.querySelector('[slot=content]');
const invoker = el.querySelector('[slot=invoker]');
`));
const content = /** @type {HTMLElement} */ (el.querySelector('[slot=content]'));
const invoker = /** @type {HTMLElement} */ (el.querySelector('[slot=invoker]'));
expect(invoker.getAttribute('aria-describedby')).to.be.equal(null);
expect(invoker.getAttribute('aria-labelledby')).to.be.equal(content.id);
});
it('should be accessible when closed', async () => {
const el = await fixture(html`
const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip>
<div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button>
</lion-tooltip>
`);
`));
await expect(el).to.be.accessible;
});
it('should be accessible when opened', async () => {
const el = await fixture(html`
const el = /** @type {LionTooltip} */ (await fixture(html`
<lion-tooltip>
<div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button>
</lion-tooltip>
`);
const invoker = el.querySelector('[slot="invoker"]');
`));
const invoker = /** @type {HTMLElement} */ (el.querySelector('[slot="invoker"]'));
const eventFocusIn = new Event('focusin');
invoker.dispatchEvent(eventFocusIn);
await el.updateComplete;

View file

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