diff --git a/.changeset/beige-planets-buy.md b/.changeset/beige-planets-buy.md
new file mode 100644
index 0000000000..9a0202666f
--- /dev/null
+++ b/.changeset/beige-planets-buy.md
@@ -0,0 +1,5 @@
+---
+'@lion/ui': patch
+---
+
+improve deep-contains function so it works correctly with slots
diff --git a/packages/ui/components/overlays/src/utils/deep-contains.js b/packages/ui/components/overlays/src/utils/deep-contains.js
index b87e5a35ef..b571324164 100644
--- a/packages/ui/components/overlays/src/utils/deep-contains.js
+++ b/packages/ui/components/overlays/src/utils/deep-contains.js
@@ -1,20 +1,149 @@
+/**
+ * A number, or a string containing a number.
+ * @typedef {{element: HTMLElement; deepContains: boolean} | null} CacheItem
+ */
+
/**
* Whether first element contains the second element, also goes through shadow roots
* @param {HTMLElement|ShadowRoot} el
* @param {HTMLElement|ShadowRoot} targetEl
+ * @param {{[key: string]: CacheItem[]}} cache
* @returns {boolean}
*/
-export function deepContains(el, targetEl) {
+export function deepContains(el, targetEl, cache = {}) {
+ /**
+ * @description A `Typescript` `type guard` for `HTMLElement`
+ * @param {Element|ShadowRoot} htmlElement
+ * @returns {htmlElement is HTMLElement}
+ */
+ function isHTMLElement(htmlElement) {
+ return 'getAttribute' in htmlElement;
+ }
+
+ /**
+ * @description Returns a cached item for the given element or null otherwise
+ * @param {HTMLElement|ShadowRoot} element
+ * @returns {CacheItem|null}
+ */
+ function getCachedItem(element) {
+ if (!isHTMLElement(element)) {
+ return null;
+ }
+ const slotName = element.getAttribute('slot');
+ /** @type {CacheItem|null} */
+ let result = null;
+ if (slotName) {
+ const cachedItemsWithSameName = cache[slotName];
+ if (cachedItemsWithSameName) {
+ result = cachedItemsWithSameName.filter(item => item?.element === element)[0] || null;
+ }
+ }
+ return result;
+ }
+
+ const cachedItem = getCachedItem(el);
+ if (cachedItem) {
+ return cachedItem.deepContains;
+ }
+
+ /**
+ * @description Cache an html element and its `deepContains` status
+ * @param {boolean} contains The `deepContains` status for the element
+ * @returns {void}
+ */
+ function cacheItem(contains) {
+ if (!isHTMLElement(el)) {
+ return;
+ }
+ const slotName = el.getAttribute('slot');
+ if (slotName) {
+ // eslint-disable-next-line no-param-reassign
+ cache[slotName] = cache[slotName] || [];
+ cache[slotName].push({ element: el, deepContains: contains });
+ }
+ }
+
let containsTarget = el.contains(targetEl);
if (containsTarget) {
+ cacheItem(true);
return true;
}
+ /**
+ * A `Typescript` `type guard` for `HTMLSlotElement`
+ * @param {HTMLElement|HTMLSlotElement} htmlElement
+ * @returns {htmlElement is HTMLSlotElement}
+ */
+ function isSlot(htmlElement) {
+ return htmlElement.tagName === 'SLOT';
+ }
+
+ /**
+ * Returns a slot projection or it returns `null` if `htmlElement` is not an `HTMLSlotElement`
+ * @example
+ * Let's say this is a custom element declared as follows:
+ * ```
+ *
+ * shadowRoot
+ *
+ *
+ * my content
+ *
+ * ```
+ * Then for `slot#dialog-content-slot` which is defined in the ShadowDom the function returns `div#my-slot-content` which is defined in the LightDom
+ * @param {HTMLElement|HTMLSlotElement} htmlElement
+ * @returns {Element[]}
+ * */
+ function getSlotProjections(htmlElement) {
+ return isSlot(htmlElement) ? /** @type {Element[]} */ (htmlElement.assignedElements()) : [];
+ }
+
+ /**
+ * @description A `Typescript` `type guard` for `ShadowRoot`
+ * @param {Element|ShadowRoot} htmlElement
+ * @returns {htmlElement is ShadowRoot}
+ */
+ function isShadowRoot(htmlElement) {
+ return htmlElement.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
+ }
+
+ /**
+ * Check whether any element contains target
+ * @param {(Element|ShadowRoot|null)[]} elements
+ * */
+ function checkElements(elements) {
+ let contains = false;
+ for (let i = 0; i < elements.length; i += 1) {
+ const element = elements[i];
+ if (
+ element &&
+ (isHTMLElement(element) || isShadowRoot(element)) &&
+ deepContains(element, targetEl, cache)
+ ) {
+ contains = true;
+ break;
+ }
+ }
+ return contains;
+ }
+
/** @param {HTMLElement|ShadowRoot} elem */
function checkChildren(elem) {
for (let i = 0; i < elem.children.length; i += 1) {
const child = /** @type {HTMLElement} */ (elem.children[i]);
- if (child.shadowRoot && deepContains(child.shadowRoot, targetEl)) {
+ const cachedChild = getCachedItem(child);
+ if (cachedChild) {
+ containsTarget = cachedChild.deepContains || containsTarget;
+ break;
+ }
+ const slotProjections = getSlotProjections(child);
+ const childSubElements = [child.shadowRoot, ...slotProjections];
+ if (checkElements(childSubElements)) {
containsTarget = true;
break;
}
@@ -26,11 +155,13 @@ export function deepContains(el, targetEl) {
// If element is not shadowRoot itself
if (el instanceof HTMLElement && el.shadowRoot) {
- containsTarget = deepContains(el.shadowRoot, targetEl);
+ containsTarget = deepContains(el.shadowRoot, targetEl, cache);
if (containsTarget) {
+ cacheItem(true);
return true;
}
}
checkChildren(el);
+ cacheItem(containsTarget);
return containsTarget;
}
diff --git a/packages/ui/components/overlays/test/utils-tests/deep-contains.test.js b/packages/ui/components/overlays/test/utils-tests/deep-contains.test.js
index d6e6307234..3a66951839 100644
--- a/packages/ui/components/overlays/test/utils-tests/deep-contains.test.js
+++ b/packages/ui/components/overlays/test/utils-tests/deep-contains.test.js
@@ -124,4 +124,102 @@ describe('deepContains()', () => {
expect(deepContains(element, elementFirstChildShadowChildShadow)).to.be.true;
expect(deepContains(element, elementFirstChildShadowChildShadowLastChild)).to.be.true;
});
+
+ it('returns true if the element, which is located in ShadowsRoot, contains a target element, located in the LightDom', async () => {
+ const mainElement = /** @type {HTMLElement} */ (await fixture('
'));
+ mainElement.innerHTML = `
+
+
+
+
+
+
+ `;
+ const shadowRoot = mainElement.attachShadow({ mode: 'open' });
+ shadowRoot.innerHTML = `
+
+ `;
+ const inputElement = /** @type {HTMLElement} */ (
+ mainElement.querySelector('#light-el-input-2')
+ );
+ const dialogWrapperElement = /** @type {HTMLElement} */ (
+ shadowRoot.querySelector('#dialog-wrapper')
+ );
+ expect(deepContains(dialogWrapperElement, inputElement)).to.be.true;
+ });
+
+ it(`returns true if the element, which is located in ShadowRoot, contains a target element, located in the ShadowRoot element of the LightDom element `, async () => {
+ /**
+ * The DOM for the `main` element looks as follows:
+ *
+ *
+ * #shadow-root
+ *
// dialogWrapperElement
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * #shadow-root
+ *
+ * #shadow-root
+ * //inputElement
+ *
+ *
+ *
+ *
+ */
+ const mainElement = /** @type {HTMLElement} */ (await fixture('
'));
+ mainElement.innerHTML = `
+
+
+ `;
+ const contentWrapper = /** @type {HTMLElement} */ (
+ mainElement.querySelector('#content-wrapper')
+ );
+ const contentWrapperShadowRoot = contentWrapper.attachShadow({ mode: 'open' });
+ contentWrapperShadowRoot.innerHTML = `
+
+ `;
+ const contentWrapperSub = /** @type {HTMLElement} */ (
+ contentWrapperShadowRoot.querySelector('#conent-wrapper-sub')
+ );
+ const contentWrapperSubShadowRoot = contentWrapperSub.attachShadow({ mode: 'open' });
+ contentWrapperSubShadowRoot.innerHTML = `
+
+ `;
+ const inputElement = /** @type {HTMLElement} */ (
+ contentWrapperSubShadowRoot.querySelector('#content-input')
+ );
+ const mainElementShadowRoot = mainElement.attachShadow({ mode: 'open' });
+ mainElementShadowRoot.innerHTML = `
+
+ `;
+ const dialogWrapperElement = /** @type {HTMLElement} */ (
+ mainElementShadowRoot.querySelector('#dialog-wrapper')
+ );
+ expect(deepContains(dialogWrapperElement, inputElement)).to.be.true;
+ });
});