From ada4465520cfa869d501da31d1c1ab841ee39dbb Mon Sep 17 00:00:00 2001 From: bardleb Date: Tue, 16 May 2023 10:51:54 -0400 Subject: [PATCH 1/7] feat: Separating slot controller out into a package --- packages/controllers/slot-manager/README.md | 86 ++++ packages/controllers/slot-manager/index.ts | 1 + .../controllers/slot-manager/package.json | 42 ++ .../slot-manager/src/slot-manager.ts | 401 ++++++++++++++++++ .../slot-manager/tsconfig.build.json | 10 + 5 files changed, 540 insertions(+) create mode 100644 packages/controllers/slot-manager/README.md create mode 100644 packages/controllers/slot-manager/index.ts create mode 100644 packages/controllers/slot-manager/package.json create mode 100644 packages/controllers/slot-manager/src/slot-manager.ts create mode 100644 packages/controllers/slot-manager/tsconfig.build.json diff --git a/packages/controllers/slot-manager/README.md b/packages/controllers/slot-manager/README.md new file mode 100644 index 000000000..bfd5e6528 --- /dev/null +++ b/packages/controllers/slot-manager/README.md @@ -0,0 +1,86 @@ +# SlotsController + +The `SlotsController` is a reactive controller that allows cloning slots into the shadow DOM of a component. It provides methods for managing slots, adding annotations, and dispatching events from cloned slots in the shadow DOM to the equivalent slots in the light DOM. + +## Installation + +To use the `SlotsController`, you need to install the required dependencies: + +```bash +npm install lit +``` + +## Usage + +Import the `SlotsController` and instantiate it in your component: + +```javascript +import { ReactiveControllerHost } from 'lit'; +import { SlotsController } from './SlotsController'; + +class MyComponent extends HTMLElement { + constructor() { + super(); + const slotsController = new SlotsController(this); + // ... + } +} +``` + +## API + +### SlotsController(host: ReactiveControllerHost & HTMLElement) + +Creates a new instance of the `SlotsController` for the given host element. + +- `host`: The host element that will be controlled by the `SlotsController`. + +#### Methods + +##### getSlottedNodes(slotName?: string | null): Array + +Returns an array of slotted nodes for the specified slot name. + +- `slotName`: The name of the slot. If not provided, returns the default slot nodes. + +##### exist(slotName?: string | null): boolean + +Checks if a slot exists. + +- `slotName`: The name of the slot. If not provided, checks for the default slot. + +##### addAnnotations(slotName: string, lightDomSlot: ChildNode): HTMLElement + +Adds annotations to a slot, such as cloned-slot attributes and comments in the light DOM. + +- `slotName`: The name of the slot. +- `lightDomSlot`: The light DOM slot to annotate. + +##### dispatchEventsToLightDom(eventsToDispatch: string[], clonedSlot: HTMLElement): void + +Dispatches events from cloned slots in the shadow DOM to the equivalent slots in the light DOM. + +- `eventsToDispatch`: An array of event types to dispatch. +- `clonedSlot`: The cloned slot in the shadow DOM. + +##### renderInShadow(slotName?: string, wrapperElement?: string | null, eventsToDispatch?: string[], addAnnotations?: boolean): Array | null + +Renders the specified slot in the shadow DOM. + +- `slotName`: The name of the slot. If not provided, renders the default slot. +- `wrapperElement`: Optional wrapper element for each slot. +- `eventsToDispatch`: An array of event types to dispatch from the cloned slots. +- `addAnnotations`: Whether to add annotations to the slot. Defaults to `true`. + +##### conditionalNGSlot(slotName: string, renderInShadow?: boolean, extraClasses?: string | null, extraAttributes?: { name: string, value: string }[]): TemplateResult | null + +Conditionally renders a slot with a wrapper and additional classes. + +- `slotName`: The name of the slot. +- `renderInShadow`: Whether to render the slot in the shadow DOM. Defaults to `true`. +- `extraClasses`: Additional classes to add to the wrapper element. Defaults to `null`. +- `extraAttributes`: Additional attributes to add to the wrapper element. Defaults to an empty array. + +## License + +This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details. \ No newline at end of file diff --git a/packages/controllers/slot-manager/index.ts b/packages/controllers/slot-manager/index.ts new file mode 100644 index 000000000..e71c4a90b --- /dev/null +++ b/packages/controllers/slot-manager/index.ts @@ -0,0 +1 @@ +export { SlotManager } from './src/slot-manager'; diff --git a/packages/controllers/slot-manager/package.json b/packages/controllers/slot-manager/package.json new file mode 100644 index 000000000..989735e99 --- /dev/null +++ b/packages/controllers/slot-manager/package.json @@ -0,0 +1,42 @@ +{ + "name": "@phase2/outline-controller-slot-manager", + "version": "0.0.1", + "description": "Controller to help with slots (renderInShadow, exist, etc.)", + "keywords": [ + "outline components", + "outline design", + "slots", + "shadow DOM" + ], + "main": "index.ts", + "types": "index.ts", + "typings": "index.d.ts", + "files": [ + "/dist/", + "/src/", + "!/dist/tsconfig.build.tsbuildinfo" + ], + "author": "Phase2 Technology", + "repository": { + "type": "git", + "url": "https://github.com/phase2/outline.git", + "directory": "packages/controllers/slot-manager" + }, + "license": "BSD-3-Clause", + "scripts": { + "build": "node ../../../scripts/build.js", + "package": "yarn publish" + }, + "dependencies": { + "lit": "^2.3.1" + }, + "devDependencies": { + "tslib": "^2.1.0" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": "./index.ts" + } + } diff --git a/packages/controllers/slot-manager/src/slot-manager.ts b/packages/controllers/slot-manager/src/slot-manager.ts new file mode 100644 index 000000000..6c56abc85 --- /dev/null +++ b/packages/controllers/slot-manager/src/slot-manager.ts @@ -0,0 +1,401 @@ +import { ReactiveControllerHost } from 'lit'; +import { classMap } from 'lit/directives/class-map.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { html, unsafeStatic } from 'lit/static-html.js'; + +/** + * The SlotManager ReactiveController. + * + * This controller allows cloning slots into the shadow DOM, + * by calling a function inside render() of the component. + * Any changes in the light DOM trigger requestUpdate() and thus re-cloning + * of the slots into the shadow DOM. + * The controller dispatches any events that were specified when they are triggered + * in the cloned slots in shadow DOM to the equivalent light DOM slot. + * + * @param host The host element + */ + +export class SlotManager { + host: ReactiveControllerHost & Element; + /** + * Watches for changes to components childlist and clones nodes to shadow dom. + */ + _mutationObserver = new MutationObserver(this._handleMutation.bind(this)); + + constructor(host: ReactiveControllerHost & Element) { + // Store a reference to the host + this.host = host; + // Register for lifecycle updates + host.addController(this); + } + + hostConnected(): void {} + + /** + * MutationObserver callback. + */ + _handleMutation() { + // disconnect observer before making updates (to avoid infinite loop when adding comment) + this._mutationObserver.disconnect(); + this.host.requestUpdate(); + } + + /** + * Get slotted nodes by slot name. + * @param {string | null} slotName - The slot name to search for. + * @returns {Array} An array of slotted nodes. + */ + getSlottedNodes(slotName: string | null = null) { + const defaultSlot = slotName === '' || slotName === null; + let slottedNodes = []; + + if (defaultSlot) { + slottedNodes = Array.from(this.host.childNodes).filter( + node => this.isDefaultSlotText(node) || this.isDefaultSlotElement(node) + ); + } else { + slottedNodes = Array.from( + this.host.querySelectorAll(`[slot=${slotName}]`) + ); + } + + if (slottedNodes.length) { + return slottedNodes; + } else { + return false; + } + } + + /** + * Check if a slot exists. + * @param {string | null} slotName - The slot name to check for. + * @returns {boolean} True if the slot exists, false otherwise. + */ + exist(slotName: string | null = null) { + return Boolean(this.getSlottedNodes(slotName)); + } + + /** + * Check if a node is a default slot text. + * @param {Node} node - The node to check. + * @returns {boolean} True if the node is a default slot text, false otherwise. + */ + isDefaultSlotText(node: Node) { + return node.nodeType === node.TEXT_NODE && node.textContent!.trim() !== ''; + } + + /** + * Check if a node is a default slot element. + * @param {Node} node - The node to check. + * @returns {boolean} True if the node is a default slot element, false otherwise. + */ + isDefaultSlotElement(node: Node) { + return ( + node.nodeType === node.ELEMENT_NODE && + (node as HTMLElement).getAttribute('slot') === null + ); + } + + /** + * Add annotations to a slot. + * @param {string} slotName - The slot name. + * @param {ChildNode} lightDomSlot - The light DOM slot. + * @returns {HTMLElement} The annotated slot. + */ + addAnnotations(slotName: string, lightDomSlot: ChildNode) { + // Create cloned-node element + const clonedSlot = lightDomSlot.cloneNode(true) as HTMLElement; + + // Add a comment above the slot in light DOM, to indicate it was cloned to shadow DOM + const annotationComment = + `SlotManager cloned this ` + + (slotName === '' ? 'default-slot' : `named-slot '${slotName}'`) + + ` into the shadow DOM`; + + // Add the comment only once, avoid duplicate comments when requestUpdate() runs + // Check if a light DOM comment already exist + const commentExist = Array.from(this.host.childNodes).some( + node => node.nodeValue === annotationComment + ); + if (!commentExist) { + lightDomSlot.before(document.createComment(annotationComment)); + } + + if (slotName !== '') { + clonedSlot.setAttribute('cloned-slot-type', 'named-slot'); + clonedSlot.setAttribute('cloned-slot-name', slotName); + clonedSlot.removeAttribute('slot'); + return clonedSlot; + } + + if (this.isDefaultSlotElement(lightDomSlot)) { + clonedSlot.setAttribute('cloned-slot-type', 'default-slot--element'); + clonedSlot.setAttribute('cloned-slot-name', 'default'); + clonedSlot.removeAttribute('slot'); + return clonedSlot; + } else { + // Insert the text-only default slot into a node element + const slotWrapper = document.createElement('cloned-slot'); + clonedSlot.parentNode?.insertBefore(slotWrapper, clonedSlot); + slotWrapper.appendChild(clonedSlot); + slotWrapper.setAttribute('cloned-slot-type', 'default-slot--text'); + slotWrapper.setAttribute('cloned-slot-name', 'default'); + return slotWrapper; + } + } + + /** + * Dispatch events from cloned slots in shadow DOM to the equivalent light DOM slot. + * @param {string[]} eventsToDispatch - The events to dispatch. + * @param {HTMLElement} clonedSlot - The cloned slot. + * + * As there is no way (aside from devtools) to determine what events are occurring in the DOM, + * what we can do is simulate an event that originated in the shadow DOM. + * Therefore, clicking on an element in a slotted shadow DOM would simulate a click event + * on a parallel element in a slotted light DOM. + * + * When an event triggers - + * Step 1 - + * In the Shadow DOM, identify the path that leads to the element that triggered the event. + * + * Step 2 - + * In the Light DOM, find the equivalent path to the one found in step 1. + * + * Step 3 - + * Dispatch the event to the light DOM, following the same path that was found in step 2. + * + * Step 4 - + * Wait for the component to refresh (by using timeout 0), then focus is reset to the component that hosts the element. + * + * Step 5 - + * Focus the browser on the original element in Shadow DOM that triggered the event found in step 1 + */ + dispatchEventsToLightDom( + eventsToDispatch: string[], + clonedSlot: HTMLElement + ) { + // Dispatch events from shadow DOM to original node in light DOM + eventsToDispatch.forEach(eventType => { + clonedSlot.addEventListener(eventType, event => { + if (event.target) { + const elementPathInShadowDom = this.getElementPathInShadowDom(event); + const elementPathInLightDom = this.getElementPathInLightDom( + elementPathInShadowDom + ); + + // Dispatch same event to element in Light DOM + if (elementPathInLightDom) { + elementPathInLightDom.dispatchEvent(new Event(eventType)); + } + + // dispatchEvent focuses on the main component, + // use setTimeout 0 to allow for display update to happen, + // then restore the last focused element. + setTimeout(() => { + const originElementFocus = this.getElementPathInLightDom( + elementPathInShadowDom, + true + ) as HTMLElement; + if (originElementFocus) { + originElementFocus.focus(); + } + }, 0); + } + }); + }); + } + + /** + * Render a slot in the shadow DOM. + * @param {string} [slotName=''] - The slot name. + * @param {string[]} [eventsToDispatch=[]] - The events to dispatch. + * @param {boolean} [addAnnotations=true] - Whether to add annotations to the slot. + * @returns {Array | null} An array of cloned slots or null if no slots found. + */ + renderInShadow( + slotName = '', + eventsToDispatch = [] as string[], + addAnnotations = true + ) { + // Cloning node allow us to re-use slots, as well a keep a copy in the light DOM. + const slots = this.getSlottedNodes(slotName); + + if (slots) { + const allClonedSlots = slots.map(slot => { + const lightDomSlot = slot; + let clonedSlot: HTMLElement; + + if (addAnnotations) { + // Add additional annotations - cloned-slot attributes and a comment in light DOM + clonedSlot = this.addAnnotations(slotName, lightDomSlot); + } else { + // Clone the slot into the shadow DOM as is with no annotations + clonedSlot = lightDomSlot.cloneNode(true) as HTMLElement; + } + + this.dispatchEventsToLightDom(eventsToDispatch, clonedSlot); + + return clonedSlot; + }); + + // Add mutation observer to watch for changes in the light DOM + this._mutationObserver.observe(this.host, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + }); + return allClonedSlots; + } + return null; + } + + /** + * Get an array of CSS selectors that can be used to select the target of the event. + * @param {Event} event - The event whose target we are trying to find. + * @returns {Array} An array of CSS selectors that can be used to select the target of the event. + */ + getElementPathInShadowDom(event: Event) { + // Get the path of the event + const path = event.composedPath() as HTMLElement[]; + + // The selectors we will return + const selectors = []; + + // Loop through the path until we find a shadow root + let shadowFound = false; + + for (let i = 0; !shadowFound && i < path.length; i++) { + const el = path[i]; + // If we find a shadow root, we are done + if (el.nodeName === '#document-fragment') { + shadowFound = true; + } else { + // Get a CSS selector for this element + const selector = this.getSelectorForSingleElement(el); + // If we found a selector, add it to our array + if (selector) { + selectors.push(selector); + } + } + } + + // Return the selectors in the right order (we processed them in reverse) + const reversedSelector = selectors.reverse(); + return reversedSelector; + } + + /** + * Get the class selector for a single element. + * @param {HTMLElement} currentElement - The current element. + * @returns {Object | null} The selector object or null if the element has no parent. + */ + getSelectorForSingleElement(currentElement: HTMLElement) { + // If the element has no parent element, it is the root element + if (!currentElement.parentElement) { + return null; + } + + // Create a selector for the current element + const currentSelectorClassName = Array.from(currentElement.classList).join( + '.' + ); + const currentSelector = `${currentElement.localName}${ + currentSelectorClassName !== '' ? '.' + currentSelectorClassName : '' + }`; + + // Get all siblings of the current element + const siblings = Array.from( + currentElement.parentElement.querySelectorAll(currentSelector) + ); + + // Get the current element's index + const currentIndex = siblings.indexOf(currentElement); + + // Create the final selector object + const selector = { + name: currentSelector, + index: currentIndex, + }; + return selector; + } + + /** + * Gets the targeted element from the event path. + * @param {Array} elementPathInShadowDom - The path of the event. + * @param {boolean} [isShadow=false] - Whether to search in the shadow DOM. + * @returns {Element | null} The targeted element or null if not found. + */ + getElementPathInLightDom( + elementPathInShadowDom: { + name: string; + index: number; + }[], + isShadow = false + ) { + // start at the host element + let El = isShadow + ? (this.host.shadowRoot as Element | null) + : (this.host as Element); + + if (!El) { + return null; + } + + // loop through the event path + for (let i = 0; i < elementPathInShadowDom.length; i++) { + // get the element with the name in the event path + El = El.querySelectorAll(elementPathInShadowDom[i].name)[ + elementPathInShadowDom[i].index + ]; + } + // return the targeted element + return El; + } + + printExtraAttributes(extraAttributes: { name: string; value: string }[]) { + return unsafeStatic( + extraAttributes + .map(attribute => `${attribute.name}=${attribute.value}`) + .join(' ') + ); + } + + /** + * Conditionally render a slot with a wrapper and additional classes. + * @param {string} slotName - The slot name. + * @param {boolean} [renderInShadow=true] - Whether to render the slot in the shadow DOM. + * @param {string | null} [classes=null] - Additional classes to add to the wrapper. + * @param {string | null} [attributes=null] - Additional attributes to add to the wrapper. + * @returns {TemplateResult | null} The rendered slot or null if the slot does not exist. + */ + conditionalSlot( + slotName: string, + renderInShadow = true, + extraClasses: string | null = null, + extraAttributes: { name: string; value: string }[] = [] + ) { + const defaultSlot = slotName === '' || slotName === null; + const wrapperClasses = { + 'default-slot': defaultSlot, + [`${slotName}`]: !defaultSlot, + [`${extraClasses}`]: extraClasses ?? false, + }; + + if (this.exist(slotName)) { + return html`
+ ${ + renderInShadow + ? html`${this.renderInShadow(slotName)}` + : html` ` + } +
`; + } else { + return null; + } + } +} diff --git a/packages/controllers/slot-manager/tsconfig.build.json b/packages/controllers/slot-manager/tsconfig.build.json new file mode 100644 index 000000000..fac75484a --- /dev/null +++ b/packages/controllers/slot-manager/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist" + }, + "include": ["index.ts", "src/**/*", "tests/**/*"], + "references": [{ "path": "../../outline-core/tsconfig.build.json" }] + } + \ No newline at end of file From 371ed8bbcceae7114e2e5f3d9d22faa20117df16 Mon Sep 17 00:00:00 2001 From: bardleb Date: Tue, 16 May 2023 10:52:55 -0400 Subject: [PATCH 2/7] fix: updated readme --- packages/controllers/slot-manager/README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/controllers/slot-manager/README.md b/packages/controllers/slot-manager/README.md index bfd5e6528..11746db42 100644 --- a/packages/controllers/slot-manager/README.md +++ b/packages/controllers/slot-manager/README.md @@ -15,14 +15,17 @@ npm install lit Import the `SlotsController` and instantiate it in your component: ```javascript -import { ReactiveControllerHost } from 'lit'; import { SlotsController } from './SlotsController'; class MyComponent extends HTMLElement { constructor() { super(); const slotsController = new SlotsController(this); - // ... + // ... @todo Add more usage examples + // default slot example + // slot exist function + // renderInShadow example + // conditionalSlot example } } ``` @@ -72,7 +75,7 @@ Renders the specified slot in the shadow DOM. - `eventsToDispatch`: An array of event types to dispatch from the cloned slots. - `addAnnotations`: Whether to add annotations to the slot. Defaults to `true`. -##### conditionalNGSlot(slotName: string, renderInShadow?: boolean, extraClasses?: string | null, extraAttributes?: { name: string, value: string }[]): TemplateResult | null +##### conditionalSlot(slotName: string, renderInShadow?: boolean, extraClasses?: string | null, extraAttributes?: { name: string, value: string }[]): TemplateResult | null Conditionally renders a slot with a wrapper and additional classes. From 2c0f57d79ab27db20536e237f244b1b9fe116e46 Mon Sep 17 00:00:00 2001 From: Ofer Shaal Date: Sun, 21 May 2023 23:55:16 -0400 Subject: [PATCH 3/7] fix: update README --- packages/controllers/slot-manager/README.md | 26 +++++++++------------ 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/controllers/slot-manager/README.md b/packages/controllers/slot-manager/README.md index 11746db42..b0fbace97 100644 --- a/packages/controllers/slot-manager/README.md +++ b/packages/controllers/slot-manager/README.md @@ -1,26 +1,26 @@ -# SlotsController +# Slot Manager -The `SlotsController` is a reactive controller that allows cloning slots into the shadow DOM of a component. It provides methods for managing slots, adding annotations, and dispatching events from cloned slots in the shadow DOM to the equivalent slots in the light DOM. +The `SlotManager` is a reactive controller that allows cloning slots into the shadow DOM of a component. It provides methods for managing slots, adding annotations, and dispatching events from cloned slots in the shadow DOM to the equivalent slots in the light DOM. ## Installation -To use the `SlotsController`, you need to install the required dependencies: +To use the `SlotManager`, you need to install the required dependencies: ```bash -npm install lit +yarn add -D @phase2/slot-manager ``` ## Usage -Import the `SlotsController` and instantiate it in your component: +Import the `SlotManager` and instantiate it in your component: ```javascript -import { SlotsController } from './SlotsController'; +import { SlotManager } from './slot-manager'; -class MyComponent extends HTMLElement { +class MyComponent extends LitElement { constructor() { super(); - const slotsController = new SlotsController(this); + const SlotManager = new SlotManager(this); // ... @todo Add more usage examples // default slot example // slot exist function @@ -32,11 +32,11 @@ class MyComponent extends HTMLElement { ## API -### SlotsController(host: ReactiveControllerHost & HTMLElement) +### SlotManager(host: ReactiveControllerHost & HTMLElement) -Creates a new instance of the `SlotsController` for the given host element. +Creates a new instance of the `SlotManager` for the given host element. -- `host`: The host element that will be controlled by the `SlotsController`. +- `host`: The host element that will be controlled by the `SlotManager`. #### Methods @@ -83,7 +83,3 @@ Conditionally renders a slot with a wrapper and additional classes. - `renderInShadow`: Whether to render the slot in the shadow DOM. Defaults to `true`. - `extraClasses`: Additional classes to add to the wrapper element. Defaults to `null`. - `extraAttributes`: Additional attributes to add to the wrapper element. Defaults to an empty array. - -## License - -This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details. \ No newline at end of file From 61f0f2450c6a42afd2988b7fd2a8a6c470fa47e3 Mon Sep 17 00:00:00 2001 From: Jake Strawn Date: Mon, 22 May 2023 14:07:46 -0400 Subject: [PATCH 4/7] fix(prettier): Fixing improper indentations. --- .../controllers/slot-manager/package.json | 80 +++++++++---------- .../slot-manager/tsconfig.build.json | 17 ++-- 2 files changed, 48 insertions(+), 49 deletions(-) diff --git a/packages/controllers/slot-manager/package.json b/packages/controllers/slot-manager/package.json index 989735e99..abb504efc 100644 --- a/packages/controllers/slot-manager/package.json +++ b/packages/controllers/slot-manager/package.json @@ -1,42 +1,42 @@ { - "name": "@phase2/outline-controller-slot-manager", - "version": "0.0.1", - "description": "Controller to help with slots (renderInShadow, exist, etc.)", - "keywords": [ - "outline components", - "outline design", - "slots", - "shadow DOM" - ], - "main": "index.ts", - "types": "index.ts", - "typings": "index.d.ts", - "files": [ - "/dist/", - "/src/", - "!/dist/tsconfig.build.tsbuildinfo" - ], - "author": "Phase2 Technology", - "repository": { - "type": "git", - "url": "https://github.com/phase2/outline.git", - "directory": "packages/controllers/slot-manager" - }, - "license": "BSD-3-Clause", - "scripts": { - "build": "node ../../../scripts/build.js", - "package": "yarn publish" - }, - "dependencies": { - "lit": "^2.3.1" - }, - "devDependencies": { - "tslib": "^2.1.0" - }, - "publishConfig": { - "access": "public" - }, - "exports": { - ".": "./index.ts" - } + "name": "@phase2/outline-controller-slot-manager", + "version": "0.0.1", + "description": "Controller to help with slots (renderInShadow, exist, etc.)", + "keywords": [ + "outline components", + "outline design", + "slots", + "shadow DOM" + ], + "main": "index.ts", + "types": "index.ts", + "typings": "index.d.ts", + "files": [ + "/dist/", + "/src/", + "!/dist/tsconfig.build.tsbuildinfo" + ], + "author": "Phase2 Technology", + "repository": { + "type": "git", + "url": "https://github.com/phase2/outline.git", + "directory": "packages/controllers/slot-manager" + }, + "license": "BSD-3-Clause", + "scripts": { + "build": "node ../../../scripts/build.js", + "package": "yarn publish" + }, + "dependencies": { + "lit": "^2.3.1" + }, + "devDependencies": { + "tslib": "^2.1.0" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": "./index.ts" } +} diff --git a/packages/controllers/slot-manager/tsconfig.build.json b/packages/controllers/slot-manager/tsconfig.build.json index fac75484a..5eac9d313 100644 --- a/packages/controllers/slot-manager/tsconfig.build.json +++ b/packages/controllers/slot-manager/tsconfig.build.json @@ -1,10 +1,9 @@ { - "extends": "../../../tsconfig.json", - "compilerOptions": { - "rootDir": ".", - "outDir": "./dist" - }, - "include": ["index.ts", "src/**/*", "tests/**/*"], - "references": [{ "path": "../../outline-core/tsconfig.build.json" }] - } - \ No newline at end of file + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist" + }, + "include": ["index.ts", "src/**/*", "tests/**/*"], + "references": [{ "path": "../../outline-core/tsconfig.build.json" }] +} From a208ab9255cfb61774cea65e9ccf31eb8de21e70 Mon Sep 17 00:00:00 2001 From: Jake Strawn Date: Tue, 6 Feb 2024 13:54:04 -0500 Subject: [PATCH 5/7] feat(slot-manager): Simplified logic dramatically. --- .../slot-manager/src/slot-manager.ts | 359 ++---------------- 1 file changed, 40 insertions(+), 319 deletions(-) diff --git a/packages/controllers/slot-manager/src/slot-manager.ts b/packages/controllers/slot-manager/src/slot-manager.ts index 6c56abc85..29ff79520 100644 --- a/packages/controllers/slot-manager/src/slot-manager.ts +++ b/packages/controllers/slot-manager/src/slot-manager.ts @@ -1,52 +1,45 @@ import { ReactiveControllerHost } from 'lit'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { html, unsafeStatic } from 'lit/static-html.js'; +import { html } from 'lit/static-html.js'; /** - * The SlotManager ReactiveController. - * + * @typedef SlotName + * @type {string | null} + * @description A type for slot name. + */ +type SlotName = string | null; + +/** + * @class SlotManager + * @description The SlotManager ReactiveController. * This controller allows cloning slots into the shadow DOM, * by calling a function inside render() of the component. * Any changes in the light DOM trigger requestUpdate() and thus re-cloning * of the slots into the shadow DOM. * The controller dispatches any events that were specified when they are triggered * in the cloned slots in shadow DOM to the equivalent light DOM slot. - * - * @param host The host element + * @param {ReactiveControllerHost & Element} host - The host element. */ - export class SlotManager { host: ReactiveControllerHost & Element; + /** - * Watches for changes to components childlist and clones nodes to shadow dom. + * @constructor + * @description Store a reference to the host. + * @param {ReactiveControllerHost & Element} host - The host element. */ - _mutationObserver = new MutationObserver(this._handleMutation.bind(this)); - constructor(host: ReactiveControllerHost & Element) { - // Store a reference to the host this.host = host; - // Register for lifecycle updates - host.addController(this); - } - - hostConnected(): void {} - - /** - * MutationObserver callback. - */ - _handleMutation() { - // disconnect observer before making updates (to avoid infinite loop when adding comment) - this._mutationObserver.disconnect(); - this.host.requestUpdate(); } /** - * Get slotted nodes by slot name. - * @param {string | null} slotName - The slot name to search for. - * @returns {Array} An array of slotted nodes. + * @method getSlottedNodes + * @description Get slotted nodes by slot name. + * @param {SlotName} slotName - The name of the slot to search for. If null or empty string, the method will return the default slot nodes. + * @returns {Node[] | false} An array of slotted nodes if any are found, or `false` if no slotted nodes are found. */ - getSlottedNodes(slotName: string | null = null) { + getSlottedNodes(slotName: SlotName = null) { const defaultSlot = slotName === '' || slotName === null; let slottedNodes = []; @@ -68,16 +61,18 @@ export class SlotManager { } /** - * Check if a slot exists. - * @param {string | null} slotName - The slot name to check for. + * @method doesSlotExist + * @description Check if a slot exists. + * @param {SlotName} slotName - The slot name to check for. * @returns {boolean} True if the slot exists, false otherwise. */ - exist(slotName: string | null = null) { + doesSlotExist(slotName: SlotName = null) { return Boolean(this.getSlottedNodes(slotName)); } /** - * Check if a node is a default slot text. + * @method isDefaultSlotText + * @description Check if a node is a default slot text. * @param {Node} node - The node to check. * @returns {boolean} True if the node is a default slot text, false otherwise. */ @@ -86,7 +81,8 @@ export class SlotManager { } /** - * Check if a node is a default slot element. + * @method isDefaultSlotElement + * @description Check if a node is a default slot element. * @param {Node} node - The node to check. * @returns {boolean} True if the node is a default slot element, false otherwise. */ @@ -98,301 +94,26 @@ export class SlotManager { } /** - * Add annotations to a slot. - * @param {string} slotName - The slot name. - * @param {ChildNode} lightDomSlot - The light DOM slot. - * @returns {HTMLElement} The annotated slot. - */ - addAnnotations(slotName: string, lightDomSlot: ChildNode) { - // Create cloned-node element - const clonedSlot = lightDomSlot.cloneNode(true) as HTMLElement; - - // Add a comment above the slot in light DOM, to indicate it was cloned to shadow DOM - const annotationComment = - `SlotManager cloned this ` + - (slotName === '' ? 'default-slot' : `named-slot '${slotName}'`) + - ` into the shadow DOM`; - - // Add the comment only once, avoid duplicate comments when requestUpdate() runs - // Check if a light DOM comment already exist - const commentExist = Array.from(this.host.childNodes).some( - node => node.nodeValue === annotationComment - ); - if (!commentExist) { - lightDomSlot.before(document.createComment(annotationComment)); - } - - if (slotName !== '') { - clonedSlot.setAttribute('cloned-slot-type', 'named-slot'); - clonedSlot.setAttribute('cloned-slot-name', slotName); - clonedSlot.removeAttribute('slot'); - return clonedSlot; - } - - if (this.isDefaultSlotElement(lightDomSlot)) { - clonedSlot.setAttribute('cloned-slot-type', 'default-slot--element'); - clonedSlot.setAttribute('cloned-slot-name', 'default'); - clonedSlot.removeAttribute('slot'); - return clonedSlot; - } else { - // Insert the text-only default slot into a node element - const slotWrapper = document.createElement('cloned-slot'); - clonedSlot.parentNode?.insertBefore(slotWrapper, clonedSlot); - slotWrapper.appendChild(clonedSlot); - slotWrapper.setAttribute('cloned-slot-type', 'default-slot--text'); - slotWrapper.setAttribute('cloned-slot-name', 'default'); - return slotWrapper; - } - } - - /** - * Dispatch events from cloned slots in shadow DOM to the equivalent light DOM slot. - * @param {string[]} eventsToDispatch - The events to dispatch. - * @param {HTMLElement} clonedSlot - The cloned slot. - * - * As there is no way (aside from devtools) to determine what events are occurring in the DOM, - * what we can do is simulate an event that originated in the shadow DOM. - * Therefore, clicking on an element in a slotted shadow DOM would simulate a click event - * on a parallel element in a slotted light DOM. - * - * When an event triggers - - * Step 1 - - * In the Shadow DOM, identify the path that leads to the element that triggered the event. - * - * Step 2 - - * In the Light DOM, find the equivalent path to the one found in step 1. - * - * Step 3 - - * Dispatch the event to the light DOM, following the same path that was found in step 2. - * - * Step 4 - - * Wait for the component to refresh (by using timeout 0), then focus is reset to the component that hosts the element. - * - * Step 5 - - * Focus the browser on the original element in Shadow DOM that triggered the event found in step 1 - */ - dispatchEventsToLightDom( - eventsToDispatch: string[], - clonedSlot: HTMLElement - ) { - // Dispatch events from shadow DOM to original node in light DOM - eventsToDispatch.forEach(eventType => { - clonedSlot.addEventListener(eventType, event => { - if (event.target) { - const elementPathInShadowDom = this.getElementPathInShadowDom(event); - const elementPathInLightDom = this.getElementPathInLightDom( - elementPathInShadowDom - ); - - // Dispatch same event to element in Light DOM - if (elementPathInLightDom) { - elementPathInLightDom.dispatchEvent(new Event(eventType)); - } - - // dispatchEvent focuses on the main component, - // use setTimeout 0 to allow for display update to happen, - // then restore the last focused element. - setTimeout(() => { - const originElementFocus = this.getElementPathInLightDom( - elementPathInShadowDom, - true - ) as HTMLElement; - if (originElementFocus) { - originElementFocus.focus(); - } - }, 0); - } - }); - }); - } - - /** - * Render a slot in the shadow DOM. - * @param {string} [slotName=''] - The slot name. - * @param {string[]} [eventsToDispatch=[]] - The events to dispatch. - * @param {boolean} [addAnnotations=true] - Whether to add annotations to the slot. - * @returns {Array | null} An array of cloned slots or null if no slots found. - */ - renderInShadow( - slotName = '', - eventsToDispatch = [] as string[], - addAnnotations = true - ) { - // Cloning node allow us to re-use slots, as well a keep a copy in the light DOM. - const slots = this.getSlottedNodes(slotName); - - if (slots) { - const allClonedSlots = slots.map(slot => { - const lightDomSlot = slot; - let clonedSlot: HTMLElement; - - if (addAnnotations) { - // Add additional annotations - cloned-slot attributes and a comment in light DOM - clonedSlot = this.addAnnotations(slotName, lightDomSlot); - } else { - // Clone the slot into the shadow DOM as is with no annotations - clonedSlot = lightDomSlot.cloneNode(true) as HTMLElement; - } - - this.dispatchEventsToLightDom(eventsToDispatch, clonedSlot); - - return clonedSlot; - }); - - // Add mutation observer to watch for changes in the light DOM - this._mutationObserver.observe(this.host, { - subtree: true, - childList: true, - attributes: true, - characterData: true, - }); - return allClonedSlots; - } - return null; - } - - /** - * Get an array of CSS selectors that can be used to select the target of the event. - * @param {Event} event - The event whose target we are trying to find. - * @returns {Array} An array of CSS selectors that can be used to select the target of the event. - */ - getElementPathInShadowDom(event: Event) { - // Get the path of the event - const path = event.composedPath() as HTMLElement[]; - - // The selectors we will return - const selectors = []; - - // Loop through the path until we find a shadow root - let shadowFound = false; - - for (let i = 0; !shadowFound && i < path.length; i++) { - const el = path[i]; - // If we find a shadow root, we are done - if (el.nodeName === '#document-fragment') { - shadowFound = true; - } else { - // Get a CSS selector for this element - const selector = this.getSelectorForSingleElement(el); - // If we found a selector, add it to our array - if (selector) { - selectors.push(selector); - } - } - } - - // Return the selectors in the right order (we processed them in reverse) - const reversedSelector = selectors.reverse(); - return reversedSelector; - } - - /** - * Get the class selector for a single element. - * @param {HTMLElement} currentElement - The current element. - * @returns {Object | null} The selector object or null if the element has no parent. - */ - getSelectorForSingleElement(currentElement: HTMLElement) { - // If the element has no parent element, it is the root element - if (!currentElement.parentElement) { - return null; - } - - // Create a selector for the current element - const currentSelectorClassName = Array.from(currentElement.classList).join( - '.' - ); - const currentSelector = `${currentElement.localName}${ - currentSelectorClassName !== '' ? '.' + currentSelectorClassName : '' - }`; - - // Get all siblings of the current element - const siblings = Array.from( - currentElement.parentElement.querySelectorAll(currentSelector) - ); - - // Get the current element's index - const currentIndex = siblings.indexOf(currentElement); - - // Create the final selector object - const selector = { - name: currentSelector, - index: currentIndex, - }; - return selector; - } - - /** - * Gets the targeted element from the event path. - * @param {Array} elementPathInShadowDom - The path of the event. - * @param {boolean} [isShadow=false] - Whether to search in the shadow DOM. - * @returns {Element | null} The targeted element or null if not found. - */ - getElementPathInLightDom( - elementPathInShadowDom: { - name: string; - index: number; - }[], - isShadow = false - ) { - // start at the host element - let El = isShadow - ? (this.host.shadowRoot as Element | null) - : (this.host as Element); - - if (!El) { - return null; - } - - // loop through the event path - for (let i = 0; i < elementPathInShadowDom.length; i++) { - // get the element with the name in the event path - El = El.querySelectorAll(elementPathInShadowDom[i].name)[ - elementPathInShadowDom[i].index - ]; - } - // return the targeted element - return El; - } - - printExtraAttributes(extraAttributes: { name: string; value: string }[]) { - return unsafeStatic( - extraAttributes - .map(attribute => `${attribute.name}=${attribute.value}`) - .join(' ') - ); - } - - /** - * Conditionally render a slot with a wrapper and additional classes. - * @param {string} slotName - The slot name. - * @param {boolean} [renderInShadow=true] - Whether to render the slot in the shadow DOM. - * @param {string | null} [classes=null] - Additional classes to add to the wrapper. - * @param {string | null} [attributes=null] - Additional attributes to add to the wrapper. + * @method conditionalSlot + * @description Conditionally render a slot with a wrapper and additional classes. + * @param {SlotName} slotName - The slot name. + * @param {string | null} [extraClasses=null] - Additional classes to add to the wrapper. * @returns {TemplateResult | null} The rendered slot or null if the slot does not exist. */ conditionalSlot( - slotName: string, - renderInShadow = true, - extraClasses: string | null = null, - extraAttributes: { name: string; value: string }[] = [] + slotName: SlotName = null, + extraClasses: string | null = null ) { const defaultSlot = slotName === '' || slotName === null; const wrapperClasses = { - 'default-slot': defaultSlot, - [`${slotName}`]: !defaultSlot, - [`${extraClasses}`]: extraClasses ?? false, + 'slot-default': defaultSlot, + [`slot-${slotName}`]: !defaultSlot, + [`${extraClasses}`]: extraClasses !== null, }; - if (this.exist(slotName)) { - return html`
- ${ - renderInShadow - ? html`${this.renderInShadow(slotName)}` - : html` ` - } + if (this.doesSlotExist(slotName)) { + return html`
+
`; } else { return null; From 6fc96ff1eae4079c5051d18f36604d8d338f8db5 Mon Sep 17 00:00:00 2001 From: Jake Strawn Date: Tue, 6 Feb 2024 15:28:01 -0500 Subject: [PATCH 6/7] feat(slot-manager): Fixed version number. --- packages/controllers/slot-manager/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/controllers/slot-manager/package.json b/packages/controllers/slot-manager/package.json index abb504efc..55dde2029 100644 --- a/packages/controllers/slot-manager/package.json +++ b/packages/controllers/slot-manager/package.json @@ -1,7 +1,7 @@ { "name": "@phase2/outline-controller-slot-manager", - "version": "0.0.1", - "description": "Controller to help with slots (renderInShadow, exist, etc.)", + "version": "0.0.0", + "description": "Slot management utilities for web components.", "keywords": [ "outline components", "outline design", From cd949161769997ea7ec8500edd053ed46509e986 Mon Sep 17 00:00:00 2001 From: Jake Strawn Date: Tue, 6 Feb 2024 15:51:49 -0500 Subject: [PATCH 7/7] feat(slot-manager): Tweaked docs. --- packages/controllers/slot-manager/README.md | 82 +++++-------------- .../slot-manager/src/slot-manager.ts | 9 +- 2 files changed, 24 insertions(+), 67 deletions(-) diff --git a/packages/controllers/slot-manager/README.md b/packages/controllers/slot-manager/README.md index b0fbace97..bcdfdf98c 100644 --- a/packages/controllers/slot-manager/README.md +++ b/packages/controllers/slot-manager/README.md @@ -1,85 +1,47 @@ # Slot Manager -The `SlotManager` is a reactive controller that allows cloning slots into the shadow DOM of a component. It provides methods for managing slots, adding annotations, and dispatching events from cloned slots in the shadow DOM to the equivalent slots in the light DOM. +The `SlotManager` is a utility class for managing slots in Lit web components. It provides several methods to interact with and manipulate slots. ## Installation To use the `SlotManager`, you need to install the required dependencies: ```bash -yarn add -D @phase2/slot-manager +yarn add -D @phase2/outline-controller-slot-manager ``` ## Usage -Import the `SlotManager` and instantiate it in your component: +### Importing `SlotManager` -```javascript -import { SlotManager } from './slot-manager'; +```typescript +import { SlotManager } from '@phase2/outline-controller-slot-manager'; +``` + +### Instantiating `SlotManager` + +```typescript +import { LitElement, html } from 'lit'; +import { SlotManager } from '@phase2/outline-controller-slot-manager'; + +class MyElement extends LitElement { + slotManager: SlotManager; -class MyComponent extends LitElement { constructor() { super(); - const SlotManager = new SlotManager(this); - // ... @todo Add more usage examples - // default slot example - // slot exist function - // renderInShadow example - // conditionalSlot example + this.slotManager = new SlotManager(this); } } ``` -## API - -### SlotManager(host: ReactiveControllerHost & HTMLElement) - -Creates a new instance of the `SlotManager` for the given host element. - -- `host`: The host element that will be controlled by the `SlotManager`. - -#### Methods - -##### getSlottedNodes(slotName?: string | null): Array - -Returns an array of slotted nodes for the specified slot name. - -- `slotName`: The name of the slot. If not provided, returns the default slot nodes. - -##### exist(slotName?: string | null): boolean - -Checks if a slot exists. - -- `slotName`: The name of the slot. If not provided, checks for the default slot. - -##### addAnnotations(slotName: string, lightDomSlot: ChildNode): HTMLElement - -Adds annotations to a slot, such as cloned-slot attributes and comments in the light DOM. - -- `slotName`: The name of the slot. -- `lightDomSlot`: The light DOM slot to annotate. - -##### dispatchEventsToLightDom(eventsToDispatch: string[], clonedSlot: HTMLElement): void - -Dispatches events from cloned slots in the shadow DOM to the equivalent slots in the light DOM. - -- `eventsToDispatch`: An array of event types to dispatch. -- `clonedSlot`: The cloned slot in the shadow DOM. - -##### renderInShadow(slotName?: string, wrapperElement?: string | null, eventsToDispatch?: string[], addAnnotations?: boolean): Array | null +## Details -Renders the specified slot in the shadow DOM. +- `getSlottedNodes(slotName: SlotName = null)`: This method retrieves all nodes slotted under the given slot name. If the slot name is null or an empty string, it returns the default slot nodes. If no slotted nodes are found, it returns `false`. -- `slotName`: The name of the slot. If not provided, renders the default slot. -- `wrapperElement`: Optional wrapper element for each slot. -- `eventsToDispatch`: An array of event types to dispatch from the cloned slots. -- `addAnnotations`: Whether to add annotations to the slot. Defaults to `true`. +- `doesSlotExist(slotName: SlotName = null)`: This method checks if a slot with the given name exists in the host element. It returns `true` if the slot exists, `false` otherwise. -##### conditionalSlot(slotName: string, renderInShadow?: boolean, extraClasses?: string | null, extraAttributes?: { name: string, value: string }[]): TemplateResult | null +- `isDefaultSlotText(node: Node)`: This method checks if a given node is a default slot text. It returns `true` if the node is a default slot text, `false` otherwise. -Conditionally renders a slot with a wrapper and additional classes. +- `isDefaultSlotElement(node: Node)`: This method checks if a given node is a default slot element. It returns `true` if the node is a default slot element, `false` otherwise. -- `slotName`: The name of the slot. -- `renderInShadow`: Whether to render the slot in the shadow DOM. Defaults to `true`. -- `extraClasses`: Additional classes to add to the wrapper element. Defaults to `null`. -- `extraAttributes`: Additional attributes to add to the wrapper element. Defaults to an empty array. +- `conditionalSlot(slotName: SlotName = null, extraClasses: string | null = null)`: This method conditionally renders a slot with a wrapper and additional classes. It returns the rendered slot or `null` if the slot does not exist. diff --git a/packages/controllers/slot-manager/src/slot-manager.ts b/packages/controllers/slot-manager/src/slot-manager.ts index 29ff79520..0bc8d9628 100644 --- a/packages/controllers/slot-manager/src/slot-manager.ts +++ b/packages/controllers/slot-manager/src/slot-manager.ts @@ -12,13 +12,8 @@ type SlotName = string | null; /** * @class SlotManager - * @description The SlotManager ReactiveController. - * This controller allows cloning slots into the shadow DOM, - * by calling a function inside render() of the component. - * Any changes in the light DOM trigger requestUpdate() and thus re-cloning - * of the slots into the shadow DOM. - * The controller dispatches any events that were specified when they are triggered - * in the cloned slots in shadow DOM to the equivalent light DOM slot. + * @description Slot management utilities for Lit web components. + * * @param {ReactiveControllerHost & Element} host - The host element. */ export class SlotManager {