Skip to content

Commit

Permalink
fix: add support for Element Resolution
Browse files Browse the repository at this point in the history
  • Loading branch information
Westbrook committed Nov 21, 2022
1 parent e176666 commit d6a65d0
Show file tree
Hide file tree
Showing 5 changed files with 264 additions and 1 deletion.
6 changes: 5 additions & 1 deletion tools/reactive-controllers/README.md
Original file line number Diff line number Diff line change
@@ -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)
82 changes: 82 additions & 0 deletions tools/reactive-controllers/element-resolution.md
Original file line number Diff line number Diff line change
@@ -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 `<div>` element:

```html-no-demo
<root-el></root-el>
<div class="other-element"></div>
```

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
<root-el></root-el>
<div class="other-element">First!</div>
<div class="other-element">Last.</div>
```

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
}
}
}
```
4 changes: 4 additions & 0 deletions tools/reactive-controllers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
123 changes: 123 additions & 0 deletions tools/reactive-controllers/src/ElementResolution.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
50 changes: 50 additions & 0 deletions tools/reactive-controllers/test/element-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -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`
<div>
<test-element-resolution-el></test-element-resolution-el>
<div class="target" id="one"></div>
<div class="target" id="two"></div>
</div>
`
);
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;
});
});

0 comments on commit d6a65d0

Please sign in to comment.