diff --git a/tools/reactive-controllers/README.md b/tools/reactive-controllers/README.md index 1029ac7707..f2892afa56 100644 --- a/tools/reactive-controllers/README.md +++ b/tools/reactive-controllers/README.md @@ -1,8 +1,12 @@ ## Description -[Reactive controllers](https://lit.dev/docs/composition/controllers/) are a tool for code reuse and composition within [Lit](https://lit.dev), a core dependency of Spectrum Web Components. Reactive controllers can be shared across components to reduce both code complexity and size, and to deliver a consistent user experience. These reactive controllers are used by the Spectrum Web Components library and are published to NPM for you to leverage in your projects as well. +[Reactive controllers](https://lit.dev/docs/composition/controllers/) are a tool for code reuse and composition within [Lit](https://lit.dev), a core dependency of Spectrum Web Components. Reactive controllers can be reused across components to reduce both code complexity and size, and to deliver a consistent user experience. These reactive controllers are used by the Spectrum Web Components library and are published to NPM for you to leverage in your projects as well. ### Reactive controllers +- [ElementResolutionController](../element-resolution) +- ColorController +- FocusGroupController +- LanguageReslutionController - [MatchMediaController](../match-media) - [RovingTabindexController](../roving-tab-index) diff --git a/tools/reactive-controllers/element-resolution.md b/tools/reactive-controllers/element-resolution.md new file mode 100644 index 0000000000..8dd55ab3a5 --- /dev/null +++ b/tools/reactive-controllers/element-resolution.md @@ -0,0 +1,82 @@ +## Description + +An `ElementResolutionController` keeps an active reference to another element in the same DOM tree. Supply the controller with a selector to query and it will manage observing the DOM tree to ensure that the reference it holds is always the first matched element or `null`. + +### Usage + +[![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/reactive-controllers) +[![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/reactive-controllers?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/reactive-controllers) + +``` +yarn add @spectrum-web-components/reactive-controllers +``` + +Import the `ElementResolutionController` and/or `elementResolverUpdatedSymbol` via: + +``` +import { ElementResolutionController, elementResolverUpdatedSymbol } from '@spectrum-web-components/reactive-controllers/ElementResolution.js'; +``` + +## Example + +An `ElementResolutionController` can be applied to a host element like the following. + +```js +import { html, LitElement } from 'lit'; +import { ElementResolutionController } from '@spectrum-web-components/reactive-controllers/ElementResolution.js'; + +class RootEl extends LitElement { + resolvedElement = new ElementResolutionController(this); + + costructor() { + super(); + this.resovledElement.selector = '.other-element'; + } +} + +customElements.define('root-el', RootEl); +``` + +In this example, the selector `'.other-element'` is supplied to the resolver, which mean in the following example, `this.resolvedElement.element` will maintain a reference to the sibling `
` element: + +```html-no-demo + +
+``` + +The resolved reference will always be the first element matching the selector applied, so in the following example the element with content "First!" will be the reference: + +```html-no-demo + +
First!
+
Last.
+``` + +A [`MutationObserver`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) is leveraged to track mutations to the DOM tree in which the host element resides in order to update the element reference on any changes to the content therein that could change the resolved element. + +## Updates + +Changes to the resolved element reference are reported to the host element via a call to the `requestUpdate()` method. This will be provided the `elementResolverUpdatedSymbol` as the changed key. If your element leverages this value against the changes map, it can react directly to changes in the resolved element: + +```ts +import { html, LitElement } from 'lit'; +import { + ElementResolutionController, + elementResolverUpdatedSymbol, +} from '@spectrum-web-components/reactive-controllers/ElementResolution.js'; + +class RootEl extends LitElement { + resolvedElement = new ElementResolutionController(this); + + costructor() { + super(); + this.resovledElement.selector = '.other-element'; + } + + protected override willUpdate(changes: PropertyValues): void { + if (changes.has(elementResolverUpdatedSymbol)) { + // work to be done only when the element reference has been updated + } + } +} +``` diff --git a/tools/reactive-controllers/package.json b/tools/reactive-controllers/package.json index eb9eefdd01..1b9bd616e3 100644 --- a/tools/reactive-controllers/package.json +++ b/tools/reactive-controllers/package.json @@ -29,6 +29,10 @@ "development": "./src/Color.dev.js", "default": "./src/Color.js" }, + "./src/ElementResolution.js": { + "development": "./src/ElementResolution.dev.js", + "default": "./src/ElementResolution.js" + }, "./src/FocusGroup.js": { "development": "./src/FocusGroup.dev.js", "default": "./src/FocusGroup.js" diff --git a/tools/reactive-controllers/src/ElementResolution.ts b/tools/reactive-controllers/src/ElementResolution.ts new file mode 100644 index 0000000000..2820864208 --- /dev/null +++ b/tools/reactive-controllers/src/ElementResolution.ts @@ -0,0 +1,123 @@ +/* +Copyright 2022 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import type { ReactiveController, ReactiveElement } from 'lit'; +export const elementResolverUpdatedSymbol = Symbol('element resolver updated'); + +export class ElementResolutionController implements ReactiveController { + get element(): HTMLElement | null { + return this._element; + } + + set element(element: HTMLElement | null) { + if (element === this.element) return; + const previous = this.element; + this._element = element; + // requestUpdate leveraging the exported Symbol() so that the + // changes can be easily tracked in the host element. + this.host.requestUpdate(elementResolverUpdatedSymbol, previous); + } + + private _element: HTMLElement | null = null; + + private host!: ReactiveElement; + + private observer!: MutationObserver; + + get selector(): string { + return this._selector; + } + + set selector(selector: string) { + if (selector === this.selector) return; + this.releaseElement(); + this._selector = selector; + this.resolveElement(); + } + + private _selector = ''; + + constructor( + host: ReactiveElement, + { selector }: { selector: string } = { selector: '' } + ) { + this.host = host; + this.selector = selector; + this.observer = new MutationObserver(this.mutationCallback); + // Add the controller after the MutationObserver has been created in preparation + // for the `hostConnected`/`hostDisconnected` callbacks to be run. + this.host.addController(this); + } + + protected mutationCallback: MutationCallback = (mutationList) => { + let needsResolution = false; + mutationList.forEach((mutation) => { + if (needsResolution) return; + if (mutation.type === 'childList') { + const currentElementRemoved = + this.element && + [...mutation.removedNodes].includes(this.element); + const matchingElementAdded = + !!this.selector && + ([...mutation.addedNodes] as HTMLElement[]).some((el) => + el?.matches?.(this.selector) + ); + needsResolution = + needsResolution || + currentElementRemoved || + matchingElementAdded; + } + if (mutation.type === 'attributes') { + const attributeChangedOnCurrentElement = + mutation.target === this.element; + const attributeChangedOnMatchingElement = + !!this.selector && + (mutation.target as HTMLElement).matches(this.selector); + needsResolution = + needsResolution || + attributeChangedOnCurrentElement || + attributeChangedOnMatchingElement; + } + }); + if (needsResolution) { + this.resolveElement(); + } + }; + + public hostConnected(): void { + this.resolveElement(); + this.observer.observe(this.host.getRootNode(), { + subtree: true, + childList: true, + attributes: true, + }); + } + + public hostDisconnected(): void { + this.releaseElement(); + this.observer.disconnect(); + } + + private resolveElement(): void { + if (!this.selector) { + this.releaseElement(); + return; + } + + const parent = this.host.getRootNode() as HTMLElement; + this.element = parent.querySelector(this.selector) as HTMLElement; + } + + private releaseElement(): void { + this.element = null; + } +} diff --git a/tools/reactive-controllers/test/element-resolution.test.ts b/tools/reactive-controllers/test/element-resolution.test.ts new file mode 100644 index 0000000000..bb56c5dd72 --- /dev/null +++ b/tools/reactive-controllers/test/element-resolution.test.ts @@ -0,0 +1,50 @@ +/* +Copyright 2022 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { html, LitElement } from 'lit'; +import { elementUpdated, expect, fixture } from '@open-wc/testing'; +import { ElementResolutionController } from '@spectrum-web-components/reactive-controllers/src/ElementResolution.js'; + +describe('Match Media', () => { + it('responds to media changes', async () => { + class TestEl extends LitElement {} + if (!customElements.get('test-element-resolution-el')) { + customElements.define('test-element-resolution-el', TestEl); + } + const test = await fixture( + html` +
+ +
+
+
+ ` + ); + const el = test.querySelector('test-element-resolution-el') as TestEl; + const target1 = test.querySelector('#one') as HTMLDivElement; + const target2 = test.querySelector('#two') as HTMLDivElement; + const controller = new ElementResolutionController(el as LitElement); + expect(controller.element).to.be.null; + controller.selector = '.target'; + await elementUpdated(el); + expect(controller.element === target1).to.be.true; + test.insertAdjacentElement('afterbegin', target2); + await elementUpdated(el); + expect(controller.element === target2).to.be.true; + target2.setAttribute('class', 'not-target'); + await elementUpdated(el); + expect(controller.element === target1).to.be.true; + target2.setAttribute('class', 'target'); + await elementUpdated(el); + expect(controller.element === target2).to.be.true; + }); +});