From e42071d8dc75d9616ea422ef8701ebafe604b96f Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Mon, 7 Sep 2020 12:20:13 +0200 Subject: [PATCH] fix: types for overlays, tooltip, button --- .changeset/nervous-timers-attend.md | 7 + packages/button/src/LionButton.js | 36 +- packages/button/test/lion-button.test.js | 93 ++-- packages/overlays/docs/demo-overlay-system.js | 19 +- packages/overlays/docs/directives/ref.js | 11 +- packages/overlays/src/OverlayController.js | 500 +++++++++++++---- packages/overlays/src/OverlayMixin.js | 519 ++++++++++-------- packages/overlays/src/OverlaysManager.js | 74 ++- .../configurations/withBottomSheetConfig.js | 27 +- .../src/configurations/withDropdownConfig.js | 28 +- .../configurations/withModalDialogConfig.js | 26 +- packages/overlays/src/overlays.js | 4 + packages/overlays/src/utils/contain-focus.js | 9 +- .../src/utils/get-focusable-elements.js | 37 +- packages/overlays/src/utils/inert-siblings.js | 8 +- packages/overlays/src/utils/simulate-tab.js | 2 +- packages/overlays/src/utils/typedef.js | 51 -- .../test-suites/OverlayMixin.suite.js | 118 ++-- .../overlays/test/OverlayController.test.js | 302 ++++++---- .../overlays/test/OverlaysManager.test.js | 12 +- .../overlays/test/global-positioning.test.js | 16 +- .../overlays/test/local-positioning.test.js | 117 ++-- .../test/utils-tests/active-element.test.js | 23 +- .../test/utils-tests/contain-focus.test.js | 32 +- .../utils-tests/local-positioning-helpers.js | 2 +- .../test/utils-tests/visibility.test.js | 60 +- packages/overlays/types/OverlayConfig.d.ts | 69 +++ .../overlays/types/OverlayMixinTypes.d.ts | 67 +++ packages/tooltip/src/LionTooltip.js | 24 +- packages/tooltip/test/lion-tooltip.test.js | 105 ++-- tsconfig.json | 5 +- 31 files changed, 1556 insertions(+), 847 deletions(-) create mode 100644 .changeset/nervous-timers-attend.md delete mode 100644 packages/overlays/src/utils/typedef.js create mode 100644 packages/overlays/types/OverlayConfig.d.ts create mode 100644 packages/overlays/types/OverlayMixinTypes.d.ts diff --git a/.changeset/nervous-timers-attend.md b/.changeset/nervous-timers-attend.md new file mode 100644 index 000000000..58df2c0bd --- /dev/null +++ b/.changeset/nervous-timers-attend.md @@ -0,0 +1,7 @@ +--- +'@lion/button': patch +'@lion/overlays': patch +'@lion/tooltip': patch +--- + +Types for overlays, tooltip and button diff --git a/packages/button/src/LionButton.js b/packages/button/src/LionButton.js index 03fdc1f31..87188dbdc 100644 --- a/packages/button/src/LionButton.js +++ b/packages/button/src/LionButton.js @@ -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 '); - const lionButtonEl = await fixture('foo'); + const nativeButtonEl = /** @type {LionButton} */ (await fixture('')); + const lionButtonEl = /** @type {LionButton} */ (await fixture( + 'foo', + )); 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('foo'); + const el = /** @type {LionButton} */ (await fixture('foo')); const event = await prepareClickEvent(el); expect(event.target).to.equal(el); }); diff --git a/packages/overlays/docs/demo-overlay-system.js b/packages/overlays/docs/demo-overlay-system.js index 04751922d..4bcbfa85f 100644 --- a/packages/overlays/docs/demo-overlay-system.js +++ b/packages/overlays/docs/demo-overlay-system.js @@ -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); diff --git a/packages/overlays/docs/directives/ref.js b/packages/overlays/docs/directives/ref.js index 1c39eb01a..14b13cfbb 100644 --- a/packages/overlays/docs/directives/ref.js +++ b/packages/overlays/docs/directives/ref.js @@ -1,6 +1,11 @@ import { directive } from '@lion/core'; -const cache = new WeakMap(); +/** + * @typedef {import('lit-html').PropertyPart} PropertyPart + */ + +/** @type {WeakSet} */ +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 diff --git a/packages/overlays/src/OverlayController.js b/packages/overlays/src/OverlayController.js index b1ad9dc58..05861310b 100644 --- a/packages/overlays/src/OverlayController.js +++ b/packages/overlays/src/OverlayController.js @@ -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} + */ async function preloadPopper() { - return import('popper.js/dist/esm/popper.min.js'); + // @ts-ignore + return /** @type {Promise} */ (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; diff --git a/packages/overlays/src/OverlayMixin.js b/packages/overlays/src/OverlayMixin.js index a007dba48..495e49d88 100644 --- a/packages/overlays/src/OverlayMixin.js +++ b/packages/overlays/src/OverlayMixin.js @@ -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); diff --git a/packages/overlays/src/OverlaysManager.js b/packages/overlays/src/OverlaysManager.js index 8d0203eb6..5144255e5 100644 --- a/packages/overlays/src/OverlaysManager.js +++ b/packages/overlays/src/OverlaysManager.js @@ -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} */ 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; diff --git a/packages/overlays/src/configurations/withBottomSheetConfig.js b/packages/overlays/src/configurations/withBottomSheetConfig.js index bf8c6b70e..27e2973d4 100644 --- a/packages/overlays/src/configurations/withBottomSheetConfig.js +++ b/packages/overlays/src/configurations/withBottomSheetConfig.js @@ -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, + }); diff --git a/packages/overlays/src/configurations/withDropdownConfig.js b/packages/overlays/src/configurations/withDropdownConfig.js index cfbbd0ce8..a2981a381 100644 --- a/packages/overlays/src/configurations/withDropdownConfig.js +++ b/packages/overlays/src/configurations/withDropdownConfig.js @@ -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, + }); diff --git a/packages/overlays/src/configurations/withModalDialogConfig.js b/packages/overlays/src/configurations/withModalDialogConfig.js index 4dbbc742e..a5865b2fd 100644 --- a/packages/overlays/src/configurations/withModalDialogConfig.js +++ b/packages/overlays/src/configurations/withModalDialogConfig.js @@ -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, + }); diff --git a/packages/overlays/src/overlays.js b/packages/overlays/src/overlays.js index c844e77d2..1d5d0e5fa 100644 --- a/packages/overlays/src/overlays.js +++ b/packages/overlays/src/overlays.js @@ -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; } diff --git a/packages/overlays/src/utils/contain-focus.js b/packages/overlays/src/utils/contain-focus.js index 0a06f7a70..fe218a64e 100644 --- a/packages/overlays/src/utils/contain-focus.js +++ b/packages/overlays/src/utils/contain-focus.js @@ -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; } diff --git a/packages/overlays/src/utils/get-focusable-elements.js b/packages/overlays/src/utils/get-focusable-elements.js index b4d0fd11b..403a25e95 100644 --- a/packages/overlays/src/utils/get-focusable-elements.js +++ b/packages/overlays/src/utils/get-focusable-elements.js @@ -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; } diff --git a/packages/overlays/src/utils/inert-siblings.js b/packages/overlays/src/utils/inert-siblings.js index 19ab9c5c2..7dea0a56a 100644 --- a/packages/overlays/src/utils/inert-siblings.js +++ b/packages/overlays/src/utils/inert-siblings.js @@ -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'); } } } diff --git a/packages/overlays/src/utils/simulate-tab.js b/packages/overlays/src/utils/simulate-tab.js index 1df2106d4..5ee24098f 100644 --- a/packages/overlays/src/utils/simulate-tab.js +++ b/packages/overlays/src/utils/simulate-tab.js @@ -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); diff --git a/packages/overlays/src/utils/typedef.js b/packages/overlays/src/utils/typedef.js deleted file mode 100644 index 0861e79de..000000000 --- a/packages/overlays/src/utils/typedef.js +++ /dev/null @@ -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' - */ diff --git a/packages/overlays/test-suites/OverlayMixin.suite.js b/packages/overlays/test-suites/OverlayMixin.suite.js index b9f1b0c96..c156f0af9 100644 --- a/packages/overlays/test-suites/OverlayMixin.suite.js +++ b/packages/overlays/test-suites/OverlayMixin.suite.js @@ -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}>
content of the overlay
- `); + `)); 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}>
content of the overlay
- `); + `)); 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}>
content of the overlay
- `); + `)); 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}>
content of the overlay
@@ -67,24 +81,35 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) { `, { 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' } }}>
content of the overlay
- `); + `)); 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}">
content of the overlay
- `); + `)); 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}">
content of the overlay
- `); + `)); // 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}">
content of the overlay
- `); + `)); 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}">
content of the overlay
- `); - 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` `); + const closeBtn = /** @type {OverlayEl} */ (await fixture( + html` `, + )); - const el = await fixture(html` + const el = /** @type {OverlayEl} */ (await fixture(html` <${tag} opened>
content of the overlay @@ -177,7 +204,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
- `); + `)); 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">
open nested overlay: @@ -207,32 +234,34 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
- `); + `)); 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}>
content of the nested overlay
- `); + `)); if (el._overlayCtrl.placementMode === 'global') { expect(getGlobalOverlayNodes().length).to.equal(1); - const moveTarget = await fixture('
'); + const moveTarget = /** @type {OverlayEl} */ (await fixture('
')); 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">
content of the nested overlay
- `); + `)); - const el = await fixture(html` + const el = /** @type {OverlayEl} */ (await fixture(html` <${tag} id="main">
open nested overlay: @@ -255,7 +284,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
- `); + `)); 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'); } diff --git a/packages/overlays/test/OverlayController.test.js b/packages/overlays/test/OverlayController.test.js index 4411537ba..47a4fdc8a 100644 --- a/packages/overlays/test/OverlayController.test.js +++ b/packages/overlays/test/OverlayController.test.js @@ -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`
my content
`), -}); +/** + * @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig + * @typedef {import('../types/OverlayConfig').ViewportPlacement} ViewportPlacement + */ -const withLocalTestConfig = () => ({ - placementMode: 'local', - contentNode: fixtureSync(html`
my content
`), - invokerNode: fixtureSync(html` -
Invoker
- `), -}); +const withGlobalTestConfig = () => + /** @type {OverlayConfig} */ ({ + placementMode: 'global', + contentNode: /** @type {HTMLElement} */ (fixtureSync(html`
my content
`)), + }); + +const withLocalTestConfig = () => + /** @type {OverlayConfig} */ ({ + placementMode: 'local', + contentNode: /** @type {HTMLElement} */ (fixtureSync(html`
my content
`)), + invokerNode: /** @type {HTMLElement} */ (fixtureSync(html` +
Invoker
+ `)), + }); 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`
I should be on top
- `); + `)); } if (mode === 'inline') { - contentNode = await fixture(html`
I should be on top
`); + contentNode = /** @type {HTMLElement} */ (await fixture( + html`
I should be on top
`, + )); 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``), + invokerNode: /** @type {HTMLElement} */ (await fixture(html``)), }); 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`
Content
- `); - 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 = `
@@ -203,7 +218,7 @@ describe('OverlayController', () => { contentNode.slot = 'contentNode'; shadowHost.appendChild(contentNode); - const wrapper = await fixture('
'); + const wrapper = /** @type {HTMLElement} */ (await fixture('
')); // Ensure the contentNode is connected to DOM wrapper.appendChild(shadowHost); @@ -230,7 +245,7 @@ describe('OverlayController', () => { it('accepts an .contentNode to directly set content', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), - contentNode: await fixture('

direct node

'), + contentNode: /** @type {HTMLElement} */ (await fixture('

direct node

')), }); expect(ctrl.contentNode).to.have.trimmed.text('direct node'); }); @@ -238,7 +253,7 @@ describe('OverlayController', () => { it('accepts an .invokerNode to directly set invoker', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), - invokerNode: await fixture(''), + invokerNode: /** @type {HTMLElement} */ (await fixture('')), }); 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 = `
@@ -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`
- `); + `)); - 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`
- `); + `)); const ctrl = new OverlayController({ ...withGlobalTestConfig(), trapsKeyboardFocus: true, @@ -326,13 +343,16 @@ describe('OverlayController', () => { }); await ctrl.show(); - const elOutside = await fixture(html``); + const elOutside = /** @type {HTMLElement} */ (await fixture( + html``, + )); const input1 = ctrl.contentNode.querySelectorAll('input')[0]; const input2 = ctrl.contentNode.querySelectorAll('input')[1]; input2.focus(); // this mimics a tab within the contain-focus system used const event = new CustomEvent('keydown', { detail: 0, bubbles: true }); + // @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`
`); + const contentNode = /** @type {HTMLElement} */ (await fixture(html`
`)); 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``); - const input = ctrl.contentNode.querySelector('input'); + const elOutside = /** @type {HTMLElement} */ (await fixture(html``)); + 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('
Content
'); + const contentNode = /** @type {HTMLElement} */ (await fixture('
Content
')); 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(''); - const contentNode = await fixture('
Content
'); + const invokerNode = /** @type {HTMLElement} */ (await fixture('')); + const contentNode = /** @type {HTMLElement} */ (await fixture('
Content
')); 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(''); - const contentNode = await fixture('
Content
'); + const invokerNode = /** @type {HTMLElement} */ (await fixture('')); + const contentNode = /** @type {HTMLElement} */ (await fixture('
Content
')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnOutsideClick: true, @@ -493,25 +513,28 @@ describe('OverlayController', () => { } connectedCallback() { - this.shadowRoot.innerHTML = '
'; + /** @type {ShadowRoot} */ + (this.shadowRoot).innerHTML = '
'; } }, ); const tag = unsafeStatic(tagString); ctrl.updateConfig({ - contentNode: await fixture(html` -
-
Content
- <${tag}> -
- `), + contentNode: /** @type {HTMLElement} */ (await fixture(html` +
+
Content
+ <${tag}> +
+ `)), }); 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('
Invoker
'); - const contentNode = await fixture('
Content
'); + const invokerNode = /** @type {HTMLElement} */ (await fixture( + '
Invoker
', + )); + const contentNode = /** @type {HTMLElement} */ (await fixture('
Content
')); const ctrl = new OverlayController({ ...withLocalTestConfig(), hidesOnOutsideClick: true, contentNode, invokerNode, }); - const dom = await fixture(` + const dom = await fixture( + /** + * @param {{ stopPropagation: () => any; }} e + */ + `
{ /* propagates */ }}" >
- + This element prevents our handlers from reaching the document click handler.
- `); + `, + ); 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`
Invoker
`); - const contentNode = await fixture('
Content
'); + const invokerNode = /** @type {HTMLElement} */ (await fixture( + html`
Invoker
`, + )); + const contentNode = /** @type {HTMLElement} */ (await fixture('
Content
')); const ctrl = new OverlayController({ ...withLocalTestConfig(), hidesOnOutsideClick: true, contentNode, invokerNode, }); - const dom = await fixture(` + const dom = /** @type {HTMLElement} */ (await fixture(`
{ This element prevents our handlers from reaching the document click handler.
- `); + `)); - 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(`
Content -
`); - const labelNode = contentNode.querySelector('label[for=test]'); +
`)); + 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('
'); + const contentNode = /** @type {HTMLElement} */ (await fixture('
')); 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(''); - const contentNode = await fixture('
'); + const input = /** @type {HTMLElement} */ (await fixture('')); + const contentNode = /** @type {HTMLElement} */ (await fixture( + '
', + )); 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(''); - const contentNode = await fixture('
'); + const input = /** @type {HTMLElement} */ (await fixture('')); + const contentNode = /** @type {HTMLElement} */ (await fixture( + '
', + )); 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`
content1
`), + contentNode: /** @type {HTMLElement} */ (await fixture(html`
content1
`)), }); 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`
content2
`), + contentNode: /** @type {HTMLElement} */ (await fixture(html`
content2
`)), }); expect(ctrl.contentNode.textContent).to.include('content2'); }); it('respects the initial config provided to new OverlayController(initialConfig)', async () => { - const contentNode = fixtureSync(html`
my content
`); + const contentNode = /** @type {HTMLElement} */ (fixtureSync(html`
my content
`)); 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`
my content
`); + const contentNode = /** @type {HTMLElement} */ (fixtureSync(html`
my content
`)); 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('
invoker
'); + const invokerNode = /** @type {HTMLElement} */ (await fixture( + '
invoker
', + )); 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('
content
'); + const contentNode = /** @type {HTMLElement} */ (await fixture( + '
content
', + )); const ctrl = new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, @@ -1160,7 +1201,9 @@ describe('OverlayController', () => { }); it('adds [role=dialog] on content', async () => { - const invokerNode = await fixture('
invoker
'); + const invokerNode = /** @type {HTMLElement} */ (await fixture( + '
invoker
', + )); 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('
invoker
'); - const contentNode = await fixture('
invoker
'); + const invokerNode = /** @type {HTMLElement} */ (await fixture( + '
invoker
', + )); + const contentNode = /** @type {HTMLElement} */ (await fixture( + '
invoker
', + )); 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('
invoker
'); + const invokerNode = /** @type {HTMLElement} */ (await fixture( + '
invoker
', + )); 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('
invoker
'); + const invokerNode = /** @type {HTMLElement} */ (await fixture( + '
invoker
', + )); 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('
invoker
'); + const invokerNode = /** @type {HTMLElement} */ (await fixture( + '
invoker
', + )); 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('
invoker
'); + const invokerNode = /** @type {HTMLElement} */ (await fixture( + '
invoker
', + )); const ctrl = new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, @@ -1330,8 +1385,12 @@ describe('OverlayController', () => { }); it('restores [role] on tooltip content', async () => { - const invokerNode = await fixture('
invoker
'); - const contentNode = await fixture('
content
'); + const invokerNode = /** @type {HTMLElement} */ (await fixture( + '
invoker
', + )); + const contentNode = /** @type {HTMLElement} */ (await fixture( + '
content
', + )); const ctrl = new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, @@ -1345,8 +1404,12 @@ describe('OverlayController', () => { }); it('restores [aria-describedby] on content', async () => { - const invokerNode = await fixture('
invoker
'); - const contentNode = await fixture('
content
'); + const invokerNode = /** @type {HTMLElement} */ (await fixture( + '
invoker
', + )); + const contentNode = /** @type {HTMLElement} */ (await fixture( + '
content
', + )); const ctrl = new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, @@ -1360,8 +1423,12 @@ describe('OverlayController', () => { }); it('restores [aria-labelledby] on content', async () => { - const invokerNode = await fixture('
invoker
'); - const contentNode = await fixture('
content
'); + const invokerNode = /** @type {HTMLElement} */ (await fixture( + '
invoker
', + )); + const contentNode = /** @type {HTMLElement} */ (await fixture( + '
content
', + )); 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 = `
diff --git a/packages/overlays/test/OverlaysManager.test.js b/packages/overlays/test/OverlaysManager.test.js index bc85f35d6..6a50d34e2 100644 --- a/packages/overlays/test/OverlaysManager.test.js +++ b/packages/overlays/test/OverlaysManager.test.js @@ -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`

my content

`); + const contentNode = /** @type {HTMLElement} */ (await fixture(html`

my content

`)); 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', () => { diff --git a/packages/overlays/test/global-positioning.test.js b/packages/overlays/test/global-positioning.test.js index 149c5c3b8..507bfcf93 100644 --- a/packages/overlays/test/global-positioning.test.js +++ b/packages/overlays/test/global-positioning.test.js @@ -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`

my content

`), -}); +/** + * @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig + * @typedef {import('../types/OverlayConfig').ViewportPlacement} ViewportPlacement + */ + +const withDefaultGlobalConfig = () => + /** @type {OverlayConfig} */ ({ + placementMode: 'global', + contentNode: fixtureSync(html`

my content

`), + }); 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(); diff --git a/packages/overlays/test/local-positioning.test.js b/packages/overlays/test/local-positioning.test.js index 91aa93179..ba538f34c 100644 --- a/packages/overlays/test/local-positioning.test.js +++ b/packages/overlays/test/local-positioning.test.js @@ -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`
my content
`), - invokerNode: fixtureSync(html` -
Invoker
- `), -}); +/** + * @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig + * @typedef {import('../types/OverlayConfig').ViewportPlacement} ViewportPlacement + */ + +const withLocalTestConfig = () => + /** @type {OverlayConfig} */ ({ + placementMode: 'local', + contentNode: /** @type {HTMLElement} */ (fixtureSync(html`
my content
`)), + invokerNode: /** @type {HTMLElement} */ (fixtureSync(html` +
Invoker
+ `)), + }); 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`
- `), - invokerNode: fixtureSync(html` + `)), + invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
- `), + `)), }); await fixture(html`
@@ -54,10 +61,12 @@ describe('Local Positioning', () => { it('uses top as the default placement', async () => { const ctrl = new OverlayController({ ...withLocalTestConfig(), - contentNode: fixtureSync(html`
`), - invokerNode: fixtureSync(html` + contentNode: /** @type {HTMLElement} */ (fixtureSync( + html`
`, + )), + invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
ctrl.show()}>
- `), + `)), }); await fixture(html`
@@ -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`
`), - invokerNode: fixtureSync(html` + contentNode: /** @type {HTMLElement} */ (fixtureSync( + html`
`, + )), + invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
ctrl.show()}>
- `), + `)), 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`
invoker
`), - invokerNode: fixtureSync(html` + contentNode: /** @type {HTMLElement} */ (fixtureSync( + html`
invoker
`, + )), + invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
ctrl.show()}> content
- `), + `)), 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`
`), - invokerNode: fixtureSync(html` + contentNode: /** @type {HTMLElement} */ (fixtureSync( + html`
`, + )), + invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
ctrl.show()}>
- `), + `)), 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`
`), - invokerNode: fixtureSync(html` + contentNode: /** @type {HTMLElement} */ (fixtureSync( + html`
`, + )), + invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
ctrl.show()}>
- `), + `)), 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`
`), - invokerNode: fixtureSync(html` + contentNode: /** @type {HTMLElement} */ (fixtureSync( + html`
`, + )), + invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
ctrl.show()}>
- `), + `)), 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`
`), - invokerNode: fixtureSync(html` + contentNode: /** @type {HTMLElement} */ (fixtureSync( + html`
`, + )), + invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`
ctrl.show()}> Invoker
- `), + `)), 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`
invoker
- `); + `)); 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`
invoker
- `); + `)); 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`
invoker
- `); + `)); const ctrl = new OverlayController({ ...withLocalTestConfig(), inheritsReferenceWidth: 'full', diff --git a/packages/overlays/test/utils-tests/active-element.test.js b/packages/overlays/test/utils-tests/active-element.test.js index 65868397c..6f341a16d 100644 --- a/packages/overlays/test/utils-tests/active-element.test.js +++ b/packages/overlays/test/utils-tests/active-element.test.js @@ -13,9 +13,9 @@ describe('getDeepActiveElement()', () => {
`); - 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()', () => {
`); - 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); diff --git a/packages/overlays/test/utils-tests/contain-focus.test.js b/packages/overlays/test/utils-tests/contain-focus.test.js index d3b99c96d..7cab868ad 100644 --- a/packages/overlays/test/utils-tests/contain-focus.test.js +++ b/packages/overlays/test/utils-tests/contain-focus.test.js @@ -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); diff --git a/packages/overlays/test/utils-tests/local-positioning-helpers.js b/packages/overlays/test/utils-tests/local-positioning-helpers.js index 87e07a1d2..3af107428 100644 --- a/packages/overlays/test/utils-tests/local-positioning-helpers.js +++ b/packages/overlays/test/utils-tests/local-positioning-helpers.js @@ -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', '')))); diff --git a/packages/overlays/test/utils-tests/visibility.test.js b/packages/overlays/test/utils-tests/visibility.test.js index a30efed18..4154ac1f7 100644 --- a/packages/overlays/test/utils-tests/visibility.test.js +++ b/packages/overlays/test/utils-tests/visibility.test.js @@ -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(`
`); + const element = /** @type {HTMLElement} */ (await fixture( + `
`, + )); expect(isVisible(element)).to.equal(true); }); it('returns false for hidden static block elements', async () => { - const element = await fixture(``); + const element = /** @type {HTMLElement} */ (await fixture( + ``, + )); expect(isVisible(element)).to.equal(false); }); it('returns true for relative block elements', async () => { - const element = await fixture( + const element = /** @type {HTMLElement} */ (await fixture( `
`, - ); + )); 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( ``, - ); + )); expect(isVisible(element)).to.equal(false); }); it('returns true for absolute block elements', async () => { - const element = await fixture(` + const element = /** @type {HTMLElement} */ (await fixture(`
- `); + `)); 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(` - `); + `)); expect(isVisible(element)).to.equal(false); }); it('returns true for relative block elements', async () => { - const element = await fixture(` + const element = /** @type {HTMLElement} */ (await fixture(`
- `); + `)); expect(isVisible(element)).to.equal(true); }); it('returns true for relative block elements', async () => { - const element = await fixture(` + const element = /** @type {HTMLElement} */ (await fixture(` - `); + `)); expect(isVisible(element)).to.equal(false); }); it('returns true for inline elements', async () => { - const element = await fixture(`Inline content`); + const element = /** @type {HTMLElement} */ (await fixture(`Inline content`)); expect(isVisible(element)).to.equal(true); }); it('returns true for inline elements without content', async () => { - const element = await fixture(``); + const element = /** @type {HTMLElement} */ (await fixture(``)); expect(isVisible(element)).to.equal(true); }); it('returns true for static block elements with 0 dimensions', async () => { - const element = await fixture(`
`); + const element = /** @type {HTMLElement} */ (await fixture( + `
`, + )); expect(isVisible(element)).to.equal(true); }); it('returns false for hidden inline elements', async () => { - const element = await fixture(``); + const element = /** @type {HTMLElement} */ (await fixture( + ``, + )); expect(isVisible(element)).to.equal(false); }); it('returns false invisible elements', async () => { - const element = await fixture( + const element = /** @type {HTMLElement} */ (await fixture( `
`, - ); + )); expect(isVisible(element)).to.equal(false); }); it('returns false when hidden by parent', async () => { - const element = await fixture(` + const element = /** @type {HTMLElement} */ (await fixture(` - `); + `)); - 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(`
- `); + `)); - const target = element.querySelector('#target'); + const target = /** @type {HTMLElement} */ (element.querySelector('#target')); expect(isVisible(target)).to.equal(false); }); }); diff --git a/packages/overlays/types/OverlayConfig.d.ts b/packages/overlays/types/OverlayConfig.d.ts new file mode 100644 index 000000000..191bfb20f --- /dev/null +++ b/packages/overlays/types/OverlayConfig.d.ts @@ -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; +} diff --git a/packages/overlays/types/OverlayMixinTypes.d.ts b/packages/overlays/types/OverlayMixinTypes.d.ts new file mode 100644 index 000000000..023d1d1a4 --- /dev/null +++ b/packages/overlays/types/OverlayMixinTypes.d.ts @@ -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; + + private __setupSyncFromOverlayController(): void; + private __teardownSyncFromOverlayController(): void; + private __syncToOverlayController(): void; +} + +export declare function OverlayImplementation>( + superclass: T, +): T & Constructor & OverlayHost; + +export type OverlayMixin = typeof OverlayImplementation; diff --git a/packages/tooltip/src/LionTooltip.js b/packages/tooltip/src/LionTooltip.js index d4ce8f6dd..5f5ff55ff 100644 --- a/packages/tooltip/src/LionTooltip.js +++ b/packages/tooltip/src/LionTooltip.js @@ -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(); } } diff --git a/packages/tooltip/test/lion-tooltip.test.js b/packages/tooltip/test/lion-tooltip.test.js index 111f92fe9..6effd50ce 100644 --- a/packages/tooltip/test/lion-tooltip.test.js +++ b/packages/tooltip/test/lion-tooltip.test.js @@ -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`
Hey there
- `); + `)); 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`
Hey there
- `); + `)); 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`
Hey there
- `); - 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`
Hey there
- `); - 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`
This is Tooltip using overlay
- `); - 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`
This is Tooltip using overlay
- `); + `)); 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`
Hey there
- `); + `)); 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`
Hey there
Tooltip button
- `); + `)); - 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`
Hey there
- `); + `)); // 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`
Hey there
- `); - 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`
Hey there
- `); - 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`
Hey there
- `); + `)); 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`
Hey there
- `); - 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; diff --git a/tsconfig.json b/tsconfig.json index 93fe4a470..d366667b5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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",