fix: add slot support to deepContains function
This commit is contained in:
parent
436d5eebbe
commit
58796deb0e
3 changed files with 237 additions and 3 deletions
5
.changeset/beige-planets-buy.md
Normal file
5
.changeset/beige-planets-buy.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@lion/ui': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
improve deep-contains function so it works correctly with slots
|
||||||
|
|
@ -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
|
* Whether first element contains the second element, also goes through shadow roots
|
||||||
* @param {HTMLElement|ShadowRoot} el
|
* @param {HTMLElement|ShadowRoot} el
|
||||||
* @param {HTMLElement|ShadowRoot} targetEl
|
* @param {HTMLElement|ShadowRoot} targetEl
|
||||||
|
* @param {{[key: string]: CacheItem[]}} cache
|
||||||
* @returns {boolean}
|
* @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);
|
let containsTarget = el.contains(targetEl);
|
||||||
if (containsTarget) {
|
if (containsTarget) {
|
||||||
|
cacheItem(true);
|
||||||
return 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:
|
||||||
|
* ```
|
||||||
|
* <custom-element>
|
||||||
|
* shadowRoot
|
||||||
|
* <div id="dialog-wrapper">
|
||||||
|
* <div id="dialog-header">Header</div>
|
||||||
|
* <div id="dialog-content">
|
||||||
|
* <slot id="dialog-content-slot" name="content"></slot>
|
||||||
|
* </div>
|
||||||
|
* </div>
|
||||||
|
* <!-- Light DOM -->
|
||||||
|
* <div id="my-slot-content" slot="content">my content</div>
|
||||||
|
* </custom-element>
|
||||||
|
* ```
|
||||||
|
* 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 */
|
/** @param {HTMLElement|ShadowRoot} elem */
|
||||||
function checkChildren(elem) {
|
function checkChildren(elem) {
|
||||||
for (let i = 0; i < elem.children.length; i += 1) {
|
for (let i = 0; i < elem.children.length; i += 1) {
|
||||||
const child = /** @type {HTMLElement} */ (elem.children[i]);
|
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;
|
containsTarget = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -26,11 +155,13 @@ export function deepContains(el, targetEl) {
|
||||||
|
|
||||||
// If element is not shadowRoot itself
|
// If element is not shadowRoot itself
|
||||||
if (el instanceof HTMLElement && el.shadowRoot) {
|
if (el instanceof HTMLElement && el.shadowRoot) {
|
||||||
containsTarget = deepContains(el.shadowRoot, targetEl);
|
containsTarget = deepContains(el.shadowRoot, targetEl, cache);
|
||||||
if (containsTarget) {
|
if (containsTarget) {
|
||||||
|
cacheItem(true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
checkChildren(el);
|
checkChildren(el);
|
||||||
|
cacheItem(containsTarget);
|
||||||
return containsTarget;
|
return containsTarget;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,4 +124,102 @@ describe('deepContains()', () => {
|
||||||
expect(deepContains(element, elementFirstChildShadowChildShadow)).to.be.true;
|
expect(deepContains(element, elementFirstChildShadowChildShadow)).to.be.true;
|
||||||
expect(deepContains(element, elementFirstChildShadowChildShadowLastChild)).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('<div id="main"></div>'));
|
||||||
|
mainElement.innerHTML = `
|
||||||
|
<div slot="content" id="light-el-content">
|
||||||
|
<input type="text" id="light-el-input-1"></input>
|
||||||
|
</div>
|
||||||
|
<div slot="content" id="light-el-content">
|
||||||
|
<input type="text" id="light-el-input-2"></input>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const shadowRoot = mainElement.attachShadow({ mode: 'open' });
|
||||||
|
shadowRoot.innerHTML = `
|
||||||
|
<div id="dialog-wrapper">
|
||||||
|
<div id="dialog-header">
|
||||||
|
Header
|
||||||
|
</div>
|
||||||
|
<div id="dialog-content">
|
||||||
|
<slot name="content" id="shadow-el-content"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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:
|
||||||
|
*
|
||||||
|
* <div id="main">
|
||||||
|
* #shadow-root
|
||||||
|
* <div id="dialog-wrapper"> // dialogWrapperElement
|
||||||
|
* <div id="dialog-header">
|
||||||
|
* Header
|
||||||
|
* </div>
|
||||||
|
* <div id="dialog-content">
|
||||||
|
* <slot name="content" id="shadow-el-content"></slot>
|
||||||
|
* </div>
|
||||||
|
* </div>
|
||||||
|
* <div slot="content" id="light-el-content">
|
||||||
|
* <div id="conent-wrapper">
|
||||||
|
* #shadow-root
|
||||||
|
* <div id="conent-wrapper-sub">
|
||||||
|
* #shadow-root
|
||||||
|
* <input type="type" id="content-input"></input> //inputElement
|
||||||
|
* </div>
|
||||||
|
* </div>
|
||||||
|
* </div>
|
||||||
|
* </div>
|
||||||
|
*/
|
||||||
|
const mainElement = /** @type {HTMLElement} */ (await fixture('<div id="main"></div>'));
|
||||||
|
mainElement.innerHTML = `
|
||||||
|
<div slot="content" id="light-el-content">
|
||||||
|
<div id="content-wrapper"></div>
|
||||||
|
</div>
|
||||||
|
<div slot="content" id="light-el-content">
|
||||||
|
<div id="content-wrapper"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const contentWrapper = /** @type {HTMLElement} */ (
|
||||||
|
mainElement.querySelector('#content-wrapper')
|
||||||
|
);
|
||||||
|
const contentWrapperShadowRoot = contentWrapper.attachShadow({ mode: 'open' });
|
||||||
|
contentWrapperShadowRoot.innerHTML = `
|
||||||
|
<div id="conent-wrapper-sub"></div>
|
||||||
|
`;
|
||||||
|
const contentWrapperSub = /** @type {HTMLElement} */ (
|
||||||
|
contentWrapperShadowRoot.querySelector('#conent-wrapper-sub')
|
||||||
|
);
|
||||||
|
const contentWrapperSubShadowRoot = contentWrapperSub.attachShadow({ mode: 'open' });
|
||||||
|
contentWrapperSubShadowRoot.innerHTML = `
|
||||||
|
<input type="type" id="content-input"></input>
|
||||||
|
`;
|
||||||
|
const inputElement = /** @type {HTMLElement} */ (
|
||||||
|
contentWrapperSubShadowRoot.querySelector('#content-input')
|
||||||
|
);
|
||||||
|
const mainElementShadowRoot = mainElement.attachShadow({ mode: 'open' });
|
||||||
|
mainElementShadowRoot.innerHTML = `
|
||||||
|
<div id="dialog-wrapper">
|
||||||
|
<div id="dialog-header">
|
||||||
|
Header
|
||||||
|
</div>
|
||||||
|
<div id="dialog-content">
|
||||||
|
<slot name="content" id="shadow-el-content"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const dialogWrapperElement = /** @type {HTMLElement} */ (
|
||||||
|
mainElementShadowRoot.querySelector('#dialog-wrapper')
|
||||||
|
);
|
||||||
|
expect(deepContains(dialogWrapperElement, inputElement)).to.be.true;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue