diff --git a/.changeset/smooth-boats-argue.md b/.changeset/smooth-boats-argue.md
index 6a93afc4c..1b3bcadf9 100644
--- a/.changeset/smooth-boats-argue.md
+++ b/.changeset/smooth-boats-argue.md
@@ -2,4 +2,4 @@
'@lion/ui': patch
---
-overlayController teardown cleanup (removes logs in test files)
+[overlays]: overlayController teardown cleanup (removes logs in test files)
diff --git a/.changeset/tricky-suns-change.md b/.changeset/tricky-suns-change.md
new file mode 100644
index 000000000..4775be4ad
--- /dev/null
+++ b/.changeset/tricky-suns-change.md
@@ -0,0 +1,5 @@
+---
+'@lion/ui': patch
+---
+
+[overlays]: allow Subclassers to teardown at the right moment
diff --git a/.changeset/tricky-suns-changerz.md b/.changeset/tricky-suns-changerz.md
new file mode 100644
index 000000000..0cb9ea690
--- /dev/null
+++ b/.changeset/tricky-suns-changerz.md
@@ -0,0 +1,5 @@
+---
+'@lion/ui': minor
+---
+
+[overlays]: rework setup and teardown logic of OverlayMixin (allow to reconnect offline dom nodes; allow moving dom nodes in performant way)
diff --git a/packages/ui/components/combobox/test/lion-combobox.test.js b/packages/ui/components/combobox/test/lion-combobox.test.js
index 410d5e929..34682ae55 100644
--- a/packages/ui/components/combobox/test/lion-combobox.test.js
+++ b/packages/ui/components/combobox/test/lion-combobox.test.js
@@ -229,6 +229,8 @@ describe('lion-combobox', () => {
`)
);
+ await el.updateComplete;
+
const { _inputNode } = getComboboxMembers(el);
_inputNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
diff --git a/packages/ui/components/dialog/test-helpers/test-router.js b/packages/ui/components/dialog/test-helpers/test-router.js
new file mode 100644
index 000000000..9b102b48c
--- /dev/null
+++ b/packages/ui/components/dialog/test-helpers/test-router.js
@@ -0,0 +1,43 @@
+import { html, LitElement } from 'lit';
+
+class TestRouter extends LitElement {
+ static properties = {
+ routingMap: {
+ type: Object,
+ attribute: false,
+ },
+ path: { type: String },
+ };
+
+ constructor() {
+ super();
+
+ /** @type {{ [path: string]: HTMLElement }} */
+ this.routingMap = {};
+ /** @type {string} */
+ this.path = '';
+ }
+
+ render() {
+ return html`
+
+
+ ${Object.keys(this.routingMap).map(
+ path =>
+ html``,
+ )}
+
+
${this.routingMap[this.path]}
+
+ `;
+ }
+}
+
+customElements.define('test-router', TestRouter);
diff --git a/packages/ui/components/dialog/test/lion-dialog.test.js b/packages/ui/components/dialog/test/lion-dialog.test.js
index 3838b6703..35074be09 100644
--- a/packages/ui/components/dialog/test/lion-dialog.test.js
+++ b/packages/ui/components/dialog/test/lion-dialog.test.js
@@ -2,6 +2,7 @@
import { expect, fixture as _fixture, html, unsafeStatic, aTimeout } from '@open-wc/testing';
import { runOverlayMixinSuite } from '../../overlays/test-suites/OverlayMixin.suite.js';
import { isActiveElement } from '../../core/test-helpers/isActiveElement.js';
+import '../test-helpers/test-router.js';
import '@lion/ui/define/lion-dialog.js';
/**
@@ -187,4 +188,57 @@ describe('lion-dialog', () => {
expect(invokerButton.getAttribute('aria-expanded')).to.equal(null);
});
});
+
+ describe('Edge cases', () => {
+ it('does not lose click event handler when was detached and reattached', async () => {
+ const el = await fixture(html`
+ Hey there
+
+
+ `,
+ b: html` B
`,
+ }}"
+ path="a"
+ >
+ `);
+
+ const getDialog = () =>
+ /** @type {LionDialog} */ (el.shadowRoot?.querySelector('lion-dialog'));
+ const getInvoker = () =>
+ /** @type {HTMLElement} */ (getDialog().querySelector('[slot="invoker"]'));
+
+ getInvoker().click();
+ const dialog = getDialog();
+ // @ts-expect-error [allow-protected-in-tests]
+ await dialog._overlayCtrl._showComplete;
+ expect(dialog.opened).to.be.true;
+
+ dialog.close();
+ // @ts-expect-error [allow-protected-in-tests]
+ await dialog._overlayCtrl._hideComplete;
+ expect(dialog.opened).to.be.false;
+
+ const buttonA = /** @type {HTMLElement} */ (el.shadowRoot?.querySelector('#path-a'));
+ const buttonB = /** @type {HTMLElement} */ (el.shadowRoot?.querySelector('#path-b'));
+
+ buttonB.click();
+ await el.updateComplete;
+ buttonA.click();
+ await el.updateComplete;
+ await el.updateComplete;
+
+ getInvoker().click();
+
+ const dialogAfterRouteChange = getDialog();
+ // @ts-expect-error [allow-protected-in-tests]
+ expect(dialogAfterRouteChange._overlayCtrl).not.to.be.undefined;
+ // @ts-expect-error [allow-protected-in-tests]
+ await dialogAfterRouteChange._overlayCtrl._showComplete;
+ expect(dialogAfterRouteChange.opened).to.be.true;
+ });
+ });
});
diff --git a/packages/ui/components/overlays/src/OverlayController.js b/packages/ui/components/overlays/src/OverlayController.js
index 9ff3e8295..17f15a20a 100644
--- a/packages/ui/components/overlays/src/OverlayController.js
+++ b/packages/ui/components/overlays/src/OverlayController.js
@@ -1003,16 +1003,16 @@ export class OverlayController extends EventTarget {
* @param {{ phase: OverlayPhase }} config
*/
_handleVisibilityTriggers({ phase }) {
- if (typeof this.visibilityTriggerFunction !== 'function') return;
-
- if (phase === 'init') {
- this.__visibilityTriggerHandler = this.visibilityTriggerFunction({
- phase,
- controller: this,
- });
- }
- if (this.__visibilityTriggerHandler[phase]) {
- this.__visibilityTriggerHandler[phase]();
+ if (typeof this.visibilityTriggerFunction === 'function') {
+ if (phase === 'init') {
+ this.__visibilityTriggerHandler = this.visibilityTriggerFunction({
+ phase,
+ controller: this,
+ });
+ }
+ if (this.__visibilityTriggerHandler[phase]) {
+ this.__visibilityTriggerHandler[phase]();
+ }
}
}
@@ -1192,7 +1192,6 @@ export class OverlayController extends EventTarget {
event.composedPath().includes(this.contentNode) ||
deepContains(this.contentNode, /** @type {HTMLElement|ShadowRoot} */ (event.target));
if (hasPressedInside) return;
-
this.hide();
};
diff --git a/packages/ui/components/overlays/src/OverlayMixin.js b/packages/ui/components/overlays/src/OverlayMixin.js
index 39466f83b..6b2bce481 100644
--- a/packages/ui/components/overlays/src/OverlayMixin.js
+++ b/packages/ui/components/overlays/src/OverlayMixin.js
@@ -24,6 +24,8 @@ export const OverlayMixinImplementation = superclass =>
};
}
+ #hasSetup = false;
+
constructor() {
super();
/**
@@ -173,22 +175,23 @@ export const OverlayMixinImplementation = superclass =>
}
}
- /**
- * @param {import('lit-element').PropertyValues } changedProperties
- */
- firstUpdated(changedProperties) {
- super.firstUpdated(changedProperties);
+ connectedCallback() {
+ super.connectedCallback();
- this._setupOverlayCtrl();
+ this.updateComplete.then(() => {
+ if (this.#hasSetup) return;
+ this._setupOverlayCtrl();
+ this.#hasSetup = true;
+ });
}
async disconnectedCallback() {
super.disconnectedCallback();
- if (!this._overlayCtrl) return;
- if (await this.#isMovingInDom()) return;
-
- this._teardownOverlayCtrl();
+ if (await this._isPermanentlyDisconnected()) {
+ this._teardownOverlayCtrl();
+ this.#hasSetup = false;
+ }
}
/**
@@ -238,6 +241,8 @@ export const OverlayMixinImplementation = superclass =>
/** @protected */
_setupOverlayCtrl() {
+ if (this._overlayCtrl) return;
+
/** @type {OverlayController} */
this._overlayCtrl = this._defineOverlay({
contentNode: this._overlayContentNode,
@@ -253,11 +258,12 @@ export const OverlayMixinImplementation = superclass =>
/** @protected */
_teardownOverlayCtrl() {
+ if (!this._overlayCtrl) return;
+
this._teardownOpenCloseListeners();
this.__teardownSyncFromOverlayController();
- /** @type {OverlayController} */
- (this._overlayCtrl).teardown();
+ /** @type {OverlayController} */ (this._overlayCtrl).teardown();
}
/**
@@ -396,9 +402,9 @@ export const OverlayMixinImplementation = superclass =>
* Before we decide to teardown, let's wait to see if we were not just moving nodes around.
* @return {Promise}
*/
- async #isMovingInDom() {
+ async _isPermanentlyDisconnected() {
await this.updateComplete;
- return this.isConnected;
+ return !this.isConnected;
}
};
export const OverlayMixin = dedupeMixin(OverlayMixinImplementation);
diff --git a/packages/ui/components/overlays/test-suites/OverlayMixin.suite.js b/packages/ui/components/overlays/test-suites/OverlayMixin.suite.js
index 912078952..00a71a252 100644
--- a/packages/ui/components/overlays/test-suites/OverlayMixin.suite.js
+++ b/packages/ui/components/overlays/test-suites/OverlayMixin.suite.js
@@ -1,18 +1,25 @@
-import { expect, fixture, html, nextFrame, aTimeout } from '@open-wc/testing';
-
-import sinon from 'sinon';
+import {
+ unsafeStatic,
+ nextFrame,
+ aTimeout,
+ defineCE,
+ fixture,
+ expect,
+ html,
+} from '@open-wc/testing';
import { overlays as overlaysManager, OverlayController } from '@lion/ui/overlays.js';
-
-import '@lion/ui/define/lion-dialog.js';
import { browserDetection } from '@lion/ui/core.js';
+import { cache } from 'lit/directives/cache.js';
+import '@lion/ui/define/lion-dialog.js';
+import { LitElement } from 'lit';
+import sinon from 'sinon';
/**
- * @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig
* @typedef {import('../types/OverlayMixinTypes.js').DefineOverlayConfig} DefineOverlayConfig
- * @typedef {import('../types/OverlayMixinTypes.js').OverlayHost} OverlayHost
- * @typedef {import('../types/OverlayMixinTypes.js').OverlayMixin} OverlayMixin
- * @typedef {import('lit').LitElement} LitElement
* @typedef {LitElement & OverlayHost & {_overlayCtrl:OverlayController}} OverlayEl
+ * @typedef {import('../types/OverlayMixinTypes.js').OverlayMixin} OverlayMixin
+ * @typedef {import('../types/OverlayMixinTypes.js').OverlayHost} OverlayHost
+ * @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig
*/
function getGlobalOverlayCtrls() {
@@ -331,6 +338,173 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
}).to.not.throw;
stub.restore();
});
+
+ describe('Teardown', () => {
+ it('does not teardown when moving dom nodes', async () => {
+ const el = /** @type {OverlayEl} */ (
+ await fixture(html`
+ <${tag}>
+ content
+
+ ${tag}>
+ `)
+ );
+ // @ts-expect-error [allow-protected] in tests
+ const teardownSpy = sinon.spy(el, '_teardownOverlayCtrl');
+ // @ts-expect-error [allow-protected] in tests
+ const spyDisconnected = sinon.spy(el, '_isPermanentlyDisconnected');
+
+ // move around in dom
+ const newParent = document.createElement('div');
+ document.body.appendChild(newParent);
+ newParent.appendChild(el);
+ expect(spyDisconnected.callCount).to.equal(1);
+
+ const otherParent = document.createElement('div');
+ document.body.appendChild(otherParent);
+ otherParent.appendChild(el);
+ await aTimeout(0);
+
+ expect(spyDisconnected.callCount).to.equal(2);
+ expect(teardownSpy.callCount).to.equal(0);
+
+ // simulate a permanent disconnect
+ otherParent.removeChild(el);
+ await aTimeout(0);
+
+ expect(teardownSpy.callCount).to.equal(1);
+ });
+
+ it('allows to disconnect and reconnect later', async () => {
+ const el = /** @type {OverlayEl} */ (
+ await fixture(html`
+ <${tag}>
+ content
+
+ ${tag}>
+ `)
+ );
+ // @ts-expect-error [allow-protected] in tests
+ const teardownSpy = sinon.spy(el, '_teardownOverlayCtrl');
+ // @ts-expect-error [allow-protected] in tests
+ const spyDisconnected = sinon.spy(el, '_isPermanentlyDisconnected');
+ // @ts-expect-error [allow-protected] in tests
+ const setupSpy = sinon.spy(el, '_setupOverlayCtrl');
+
+ // 'permanently' disconnect (although we will reconnect later)
+ const offlineParent = document.createElement('div');
+ offlineParent.appendChild(el);
+ await aTimeout(0);
+
+ expect(spyDisconnected.callCount).to.equal(1);
+ expect(teardownSpy.callCount).to.equal(1);
+
+ // reconnect
+ document.body.appendChild(offlineParent);
+ await aTimeout(0);
+
+ expect(setupSpy.callCount).to.equal(1);
+ });
+
+ it('works with cache directive when reconnected', async () => {
+ class CachingContext extends LitElement {
+ static properties = {
+ switched: { type: Boolean },
+ };
+
+ constructor() {
+ super();
+
+ this.switched = false;
+ }
+
+ get overlayEl() {
+ return this.shadowRoot?.querySelector('#myOverlay');
+ }
+
+ render() {
+ return html`${cache(
+ this.switched
+ ? html`something else`
+ : html`<${tag} id="myOverlay">
+ content b
+
+ ${tag}>`,
+ )}`;
+ }
+ }
+
+ const cachingTagName = defineCE(CachingContext);
+ const cachingTag = unsafeStatic(cachingTagName);
+
+ const el = /** @type {CachingContext} */ (
+ await fixture(html`
+ <${cachingTag}>${cachingTag}>
+ `)
+ );
+
+ // @ts-expect-error [allow-protected] in tests
+ const teardownSpy = sinon.spy(el.overlayEl, '_teardownOverlayCtrl');
+ // @ts-expect-error [allow-protected] in tests
+ const spyDisconnected = sinon.spy(el.overlayEl, '_isPermanentlyDisconnected');
+ // @ts-expect-error [allow-protected] in tests
+ const setupSpy = sinon.spy(el.overlayEl, '_setupOverlayCtrl');
+
+ el.switched = true;
+ // render the new content
+ await el.updateComplete;
+ // wait for the teardown to complete
+ await el.updateComplete;
+
+ expect(spyDisconnected.callCount).to.equal(1);
+ expect(teardownSpy.callCount).to.equal(1);
+ expect(setupSpy.callCount).to.equal(0);
+
+ el.switched = false;
+ await el.updateComplete;
+
+ expect(setupSpy.callCount).to.equal(1);
+ });
+
+ it('correctly removes event listeners when disconnected from dom', async () => {
+ const el = /** @type {OverlayEl} */ (
+ await fixture(html`
+ <${tag}>
+ content
+
+ ${tag}>
+ `)
+ );
+
+ const eventRemoveSpy = sinon.spy(el._overlayCtrl, 'removeEventListener');
+ el.parentNode?.removeChild(el);
+ await el.updateComplete;
+
+ expect(eventRemoveSpy.callCount).to.equal(0);
+
+ await el.updateComplete;
+ const hasOneInstanceFor = (
+ /** @type {string} */ eventName,
+ /** @type {any[][]} */ eventsToSearch,
+ /** @type {string} */ methodToRemove,
+ ) =>
+ Boolean(
+ eventsToSearch.filter(
+ ([eventToSearch, methodToSearch]) =>
+ eventToSearch === eventName && methodToSearch === methodToRemove,
+ ).length,
+ );
+
+ // @ts-expect-error [allow-private] in tests
+ expect(hasOneInstanceFor('show', eventRemoveSpy.args, el.__onOverlayCtrlShow)).to.be.true;
+ // @ts-expect-error [allow-private] in tests
+ expect(hasOneInstanceFor('hide', eventRemoveSpy.args, el.__onOverlayCtrlHide)).to.be.true;
+ // @ts-expect-error [allow-private] in tests
+ expect(hasOneInstanceFor('before-show', eventRemoveSpy.args, el.__onBeforeShow)).to.be.true;
+ // @ts-expect-error [allow-private] in tests
+ expect(hasOneInstanceFor('before-hide', eventRemoveSpy.args, el.__onBeforeHide)).to.be.true;
+ });
+ });
});
describe(`OverlayMixin${suffix} nested`, () => {
@@ -388,6 +562,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
const moveTarget = /** @type {OverlayEl} */ (await fixture(''));
moveTarget.appendChild(el);
await el.updateComplete;
+
expect(getGlobalOverlayCtrls().length).to.equal(1);
}
});
diff --git a/packages/ui/components/select-rich/src/LionSelectRich.js b/packages/ui/components/select-rich/src/LionSelectRich.js
index a8dc0df8b..06d798d5c 100644
--- a/packages/ui/components/select-rich/src/LionSelectRich.js
+++ b/packages/ui/components/select-rich/src/LionSelectRich.js
@@ -423,6 +423,7 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
*/
_teardownOverlayCtrl() {
super._teardownOverlayCtrl();
+
this._overlayCtrl.removeEventListener('show', this.__overlayOnShow);
this._overlayCtrl.removeEventListener('before-show', this.__overlayBeforeShow);
this._overlayCtrl.removeEventListener('hide', this.__overlayOnHide);
@@ -434,11 +435,12 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
* @protected
*/
async _alignInvokerWidth() {
+ await this.updateComplete;
+
if (!this._overlayCtrl?.content) {
return;
}
- await this.updateComplete;
const initContentDisplay = this._overlayCtrl.content.style.display;
const initContentMinWidth = this._overlayCtrl.contentWrapperNode.style.minWidth;
const initContentWidth = this._overlayCtrl.contentWrapperNode.style.width;
diff --git a/packages/ui/components/select-rich/test/lion-select-rich.test.js b/packages/ui/components/select-rich/test/lion-select-rich.test.js
index e131bc2fb..7bc4cc232 100644
--- a/packages/ui/components/select-rich/test/lion-select-rich.test.js
+++ b/packages/ui/components/select-rich/test/lion-select-rich.test.js
@@ -7,6 +7,7 @@ import '@lion/ui/define/lion-select-rich.js';
import '@lion/ui/define/lion-listbox.js';
import '@lion/ui/define/lion-option.js';
import { LitElement } from 'lit';
+import sinon from 'sinon';
import {
fixture as _fixture,
unsafeStatic,
@@ -711,6 +712,49 @@ describe('lion-select-rich', () => {
});
});
+ describe('Teardown', () => {
+ it('correctly removes event listeners when disconnected from dom', async () => {
+ const el = await fixture(html`
+
+ Item 1
+ Item 2
+
+ `);
+
+ const { _overlayCtrl } = getSelectRichMembers(el);
+ const eventRemoveSpy = sinon.spy(_overlayCtrl, 'removeEventListener');
+
+ el.parentNode?.removeChild(el);
+
+ expect(eventRemoveSpy.callCount).to.equal(0);
+
+ await el.updateComplete;
+ expect(eventRemoveSpy.callCount).to.equal(0);
+
+ await el.updateComplete;
+
+ const hasOneInstanceFor = (
+ /** @type {string} */ eventName,
+ /** @type {any[][]} */ eventsToSearch,
+ /** @type {string} */ methodToRemove,
+ ) =>
+ Boolean(
+ eventsToSearch.filter(
+ ([eventToSearch, methodToSearch]) =>
+ eventToSearch === eventName && methodToSearch === methodToRemove,
+ ).length,
+ );
+
+ // @ts-expect-error [allow-private] in tests
+ expect(hasOneInstanceFor('show', eventRemoveSpy.args, el.__overlayOnShow)).to.be.true;
+ // @ts-expect-error [allow-private] in tests
+ expect(hasOneInstanceFor('before-show', eventRemoveSpy.args, el.__overlayBeforeShow)).to.be
+ .true;
+ // @ts-expect-error [allow-private] in tests
+ expect(hasOneInstanceFor('hide', eventRemoveSpy.args, el.__overlayOnHide)).to.be.true;
+ });
+ });
+
describe('Subclassers', () => {
it('allows to override the type of overlay', async () => {
const mySelectTagString = defineCE(