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 + *
+ *
Header
+ *
+ * + *
+ *
+ * + *
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 = ` +
+
+ Header +
+
+ +
+
+ `; + 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 + *
+ * Header + *
+ *
+ * + *
+ *
+ *
+ *
+ * #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 = ` +
+
+ Header +
+
+ +
+
+ `; + const dialogWrapperElement = /** @type {HTMLElement} */ ( + mainElementShadowRoot.querySelector('#dialog-wrapper') + ); + expect(deepContains(dialogWrapperElement, inputElement)).to.be.true; + }); });