Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[reactive-element] Add queryAssignedElements decorator #2327

Merged
merged 9 commits into from
Dec 7, 2021
8 changes: 8 additions & 0 deletions .changeset/famous-feet-kneel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'lit': minor
'lit-element': minor
'@lit/reactive-element': minor
'@lit/ts-transformers': minor
---

Add `queryAssignedElements` decorator for a declarative API that calls `HTMLSlotElement.assignedElements()` on a specified slot. `selector` option allows filtering returned elements with a CSS selector.
justinfagnani marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions packages/labs/ssr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"lit": "^2.0.0",
"lit-element": "^3.0.0",
"lit-html": "^2.0.0",
"@lit/reactive-element": "1.0.2",
"node-fetch": "^2.6.0",
"parse5": "^6.0.1",
"resolve": "^1.10.1"
Expand Down
4 changes: 4 additions & 0 deletions packages/lit-element/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
"development": "./development/decorators/query-all.js",
"default": "./decorators/query-all.js"
},
"./decorators/query-assigned-elements.js": {
"development": "./development/decorators/query-assigned-elements.js",
"default": "./decorators/query-assigned-elements.js"
},
"./decorators/query-assigned-nodes.js": {
"development": "./development/decorators/query-assigned-nodes.js",
"default": "./decorators/query-assigned-nodes.js"
Expand Down
1 change: 1 addition & 0 deletions packages/lit-element/src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ export * from '@lit/reactive-element/decorators/event-options.js';
export * from '@lit/reactive-element/decorators/query.js';
export * from '@lit/reactive-element/decorators/query-all.js';
export * from '@lit/reactive-element/decorators/query-async.js';
export * from '@lit/reactive-element/decorators/query-assigned-elements.js';
export * from '@lit/reactive-element/decorators/query-assigned-nodes.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/

export * from '@lit/reactive-element/decorators/query-assigned-elements.js';
3 changes: 3 additions & 0 deletions packages/lit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
"./decorators/query-all.js": {
"default": "./decorators/query-all.js"
},
"./decorators/query-assigned-elements.js": {
"default": "./decorators/query-assigned-elements.js"
},
"./decorators/query-assigned-nodes.js": {
"default": "./decorators/query-assigned-nodes.js"
},
Expand Down
1 change: 1 addition & 0 deletions packages/lit/src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export * from '@lit/reactive-element/decorators/event-options.js';
export * from '@lit/reactive-element/decorators/query.js';
export * from '@lit/reactive-element/decorators/query-all.js';
export * from '@lit/reactive-element/decorators/query-async.js';
export * from '@lit/reactive-element/decorators/query-assigned-elements.js';
export * from '@lit/reactive-element/decorators/query-assigned-nodes.js';
7 changes: 7 additions & 0 deletions packages/lit/src/decorators/query-assigned-elements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/

export * from '@lit/reactive-element/decorators/query-assigned-elements.js';
4 changes: 4 additions & 0 deletions packages/reactive-element/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@
"development": "./development/decorators/query-all.js",
"default": "./decorators/query-all.js"
},
"./decorators/query-assigned-elements.js": {
"development": "./development/decorators/query-assigned-elements.js",
"default": "./decorators/query-assigned-elements.js"
},
"./decorators/query-assigned-nodes.js": {
"development": "./development/decorators/query-assigned-nodes.js",
"default": "./decorators/query-assigned-nodes.js"
Expand Down
1 change: 1 addition & 0 deletions packages/reactive-element/src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ export * from './decorators/event-options.js';
export * from './decorators/query.js';
export * from './decorators/query-all.js';
export * from './decorators/query-async.js';
export * from './decorators/query-assigned-elements.js';
export * from './decorators/query-assigned-nodes.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/

/*
* IMPORTANT: For compatibility with tsickle and the Closure JS compiler, all
* property decorators (but not class decorators) in this file that have
* an @ExportDecoratedItems annotation must be defined as a regular function,
* not an arrow function.
*/

import {ReactiveElement} from '../reactive-element.js';
AndrewJakubowicz marked this conversation as resolved.
Show resolved Hide resolved
import {decorateProperty} from './base.js';

export interface QueryAssignedElementsOptions extends AssignedNodesOptions {
/**
* A string name of the slot. Leave empty for the default slot.
*/
slot?: string;
/**
* A string which filters the results to elements that match the given css
AndrewJakubowicz marked this conversation as resolved.
Show resolved Hide resolved
* selector.
*/
selector?: string;
}

/**
* A property decorator that converts a class property into a getter that
* returns the `assignedElements` of the given `slot`. Provides a declarative
* way to use
* [`slot.assignedElements`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLSlotElement/assignedElements).
*
* @param options Object that sets options for nodes to be returned. See
* [MDN parameters section](https://developer.mozilla.org/en-US/docs/Web/API/HTMLSlotElement/assignedElements#parameters)
* for available options. Also accepts two more optional properties,
* `slot` and `selector`.
* @param options.slot Name of the slot. Undefined or empty string for the
* default slot.
* @param options.selector Element results are filtered such that they match the
* given css selector.
*
* ```ts
* class MyElement {
* @queryAssignedElements({ slot: 'list' })
* listItems;
AndrewJakubowicz marked this conversation as resolved.
Show resolved Hide resolved
* @queryAssignedElements()
* unnamedSlotEls;
*
* render() {
* return html`
* <slot name="list"></slot>
* <slot></slot>
* `;
* }
* }
* ```
* @category Decorator
*/
export function queryAssignedElements(options?: QueryAssignedElementsOptions) {
const {slot, selector} = options ?? {};
return decorateProperty({
descriptor: (_name: PropertyKey) => ({
get(this: ReactiveElement) {
const slotSelector = `slot${slot ? `[name=${slot}]` : ':not([name])'}`;
const slotEl = this.renderRoot?.querySelector(slotSelector);
const elements = (slotEl as HTMLSlotElement).assignedElements(options);
justinfagnani marked this conversation as resolved.
Show resolved Hide resolved
if (selector) {
return elements.filter((node) => node.matches(selector));
}
return elements;
},
enumerable: true,
configurable: true,
}),
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/

import {queryAssignedElements} from '../../decorators/query-assigned-elements.js';
import {customElement} from '../../decorators/custom-element.js';
import {
canTestReactiveElement,
generateElementName,
RenderingElement,
html,
} from '../test-helpers.js';
import {assert} from '@esm-bundle/chai';

const flush =
window.ShadyDOM && window.ShadyDOM.flush ? window.ShadyDOM.flush : () => {};

(canTestReactiveElement ? suite : suite.skip)('@queryAssignedElements', () => {
let container: HTMLElement;
let el: C;

@customElement('assigned-elements-el')
class D extends RenderingElement {
@queryAssignedElements() defaultAssigned!: Element[];

@queryAssignedElements({slot: 'footer', flatten: true})
footerAssigned!: Element[];

@queryAssignedElements({slot: 'footer', flatten: false})
footerNotFlattenedSlot!: Element[];

@queryAssignedElements({
slot: 'footer',
flatten: true,
selector: '.item',
})
footerAssignedFiltered!: Element[];

override render() {
return html`
<slot></slot>
<slot name="footer"></slot>
`;
}
}

const defaultSymbol = Symbol('default');
@customElement('assigned-elements-el-2')
class E extends RenderingElement {
@queryAssignedElements() [defaultSymbol]!: Element[];
AndrewJakubowicz marked this conversation as resolved.
Show resolved Hide resolved

@queryAssignedElements({slot: 'header'}) headerAssigned!: Element[];

override render() {
return html`
<slot name="header"></slot>
<slot></slot>
`;
}
}

// Note, there are 2 elements here so that the `flatten` option of
// the decorator can be tested.
@customElement(generateElementName())
class C extends RenderingElement {
div!: HTMLDivElement;
div2!: HTMLDivElement;
div3!: HTMLDivElement;
assignedEls!: D;
assignedEls2!: E;
@queryAssignedElements() missingSlotAssignedElements!: Element[];

override render() {
return html`
<assigned-elements-el
><div id="div1">A</div>
<slot slot="footer"></slot
></assigned-elements-el>
<assigned-elements-el-2><div id="div2">B</div></assigned-elements-el-2>
`;
}

override firstUpdated() {
this.div = this.renderRoot.querySelector('#div1') as HTMLDivElement;
this.div2 = this.renderRoot.querySelector('#div2') as HTMLDivElement;
this.div3 = this.renderRoot.querySelector('#div3') as HTMLDivElement;
this.assignedEls = this.renderRoot.querySelector(
'assigned-elements-el'
) as D;
this.assignedEls2 = this.renderRoot.querySelector(
'assigned-elements-el-2'
) as E;
}
}

setup(async () => {
container = document.createElement('div');
document.body.append(container);
el = new C();
container.append(el);
await new Promise((r) => setTimeout(r, 0));
});

teardown(() => {
container?.remove();
});

test('returns assignedElements for slot', () => {
// Note, `defaultAssigned` does not `flatten` so we test that the property
// reflects current state and state when nodes are added or removed.
assert.deepEqual(el.assignedEls.defaultAssigned, [el.div]);
const child = document.createElement('div');
const text1 = document.createTextNode('');
el.assignedEls.append(text1, child);
const text2 = document.createTextNode('');
el.assignedEls.append(text2);
flush();
assert.deepEqual(el.assignedEls.defaultAssigned, [el.div, child]);
child.remove();
flush();
assert.deepEqual(el.assignedEls.defaultAssigned, [el.div]);
});

test('returns assignedElements for unnamed slot that is not first slot', () => {
assert.deepEqual(el.assignedEls2[defaultSymbol], [el.div2]);
});

test('returns flattened assignedElements for slot', () => {
assert.deepEqual(el.assignedEls.footerAssigned, []);
const child1 = document.createElement('div');
const child2 = document.createElement('div');
el.append(child1, child2);
flush();
assert.deepEqual(el.assignedEls.footerAssigned, [child1, child2]);

assert.equal(el.assignedEls.footerNotFlattenedSlot.length, 1);
assert.equal(el.assignedEls.footerNotFlattenedSlot?.[0]?.tagName, 'SLOT');

child2.remove();
flush();
assert.deepEqual(el.assignedEls.footerAssigned, [child1]);
});

test('always returns an array, even if the slot is not rendered', () => {
assert.isArray(el.missingSlotAssignedElements);
AndrewJakubowicz marked this conversation as resolved.
Show resolved Hide resolved
});

test('returns assignedElements for slot filtered by selector', () => {
assert.deepEqual(el.assignedEls.footerAssignedFiltered, []);
const child1 = document.createElement('div');
const child2 = document.createElement('div');
child2.classList.add('item');
el.append(child1, child2);
flush();
assert.deepEqual(el.assignedEls.footerAssignedFiltered, [child2]);
child2.remove();
flush();
assert.deepEqual(el.assignedEls.footerAssignedFiltered, []);
});
});
21 changes: 11 additions & 10 deletions packages/ts-transformers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,17 @@ customElements.define('simple-greeting', SimpleGreeting);

#### Supported decorators

| Decorator | Transformer behavior |
| --------------------- | ------------------------------------------------------------------------------------- |
| `@customElement` | Adds a `customElements.define` call |
| `@property` | Adds an entry to `static properties`, and moves initializers to the `constructor` |
| `@state` | Same as `@property` with `{state: true}` |
| `@query` | Defines a getter that calls `querySelector` |
| `@querySelectorAll` | Defines a getter that calls `querySelectorAll` |
| `@queryAsync` | Defines an `async` getter that awaits `updateComplete` and then calls `querySelector` |
| `@queryAssignedNodes` | Defines a getter that calls `querySelector('slot[name=foo]').assignedNodes` |
| `@localized` | Adds an `updateWhenLocaleChanges` call to the constructor |
| Decorator | Transformer behavior |
| ------------------------ | ------------------------------------------------------------------------------------- |
| `@customElement` | Adds a `customElements.define` call |
| `@property` | Adds an entry to `static properties`, and moves initializers to the `constructor` |
| `@state` | Same as `@property` with `{state: true}` |
| `@query` | Defines a getter that calls `querySelector` |
| `@querySelectorAll` | Defines a getter that calls `querySelectorAll` |
| `@queryAsync` | Defines an `async` getter that awaits `updateComplete` and then calls `querySelector` |
| `@queryAssignedElements` | Defines a getter that calls `querySelector('slot[name=foo]').assignedElements` |
| `@queryAssignedNodes` | Defines a getter that calls `querySelector('slot[name=foo]').assignedNodes` |
| `@localized` | Adds an `updateWhenLocaleChanges` call to the constructor |

### preserveBlankLinesTransformer

Expand Down
2 changes: 2 additions & 0 deletions packages/ts-transformers/src/idiomatic-decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {StateVisitor} from './internal/decorators/state.js';
import {QueryVisitor} from './internal/decorators/query.js';
import {QueryAllVisitor} from './internal/decorators/query-all.js';
import {QueryAsyncVisitor} from './internal/decorators/query-async.js';
import {QueryAssignedElementsVisitor} from './internal/decorators/query-assigned-elements.js';
import {QueryAssignedNodesVisitor} from './internal/decorators/query-assigned-nodes.js';
import {EventOptionsVisitor} from './internal/decorators/event-options.js';
import {LocalizedVisitor} from './internal/decorators/localized.js';
Expand Down Expand Up @@ -65,6 +66,7 @@ export function idiomaticDecoratorsTransformer(
new QueryVisitor(context),
new QueryAllVisitor(context),
new QueryAsyncVisitor(context),
new QueryAssignedElementsVisitor(context),
new QueryAssignedNodesVisitor(context),
new EventOptionsVisitor(context, program),
new LocalizedVisitor(context),
Expand Down