From 02ac04b212f9a439bbf9c887c9079357a0b438e2 Mon Sep 17 00:00:00 2001 From: Andrew Jakubowicz Date: Tue, 30 Nov 2021 18:52:32 -0800 Subject: [PATCH 1/9] Add queryAssignedElements decorator behaving as a declarative slot.assignedElements Add references to the new internal decorator across the monorepo packages. --- packages/lit-element/package.json | 4 + packages/lit-element/src/decorators.ts | 1 + .../src/decorators/query-assigned-elements.ts | 7 + packages/lit/package.json | 3 + packages/lit/src/decorators.ts | 1 + .../src/decorators/query-assigned-elements.ts | 7 + packages/reactive-element/package.json | 4 + packages/reactive-element/src/decorators.ts | 1 + .../src/decorators/query-assigned-elements.ts | 80 +++++++ .../decorators/queryAssignedElements_test.ts | 195 ++++++++++++++++++ 10 files changed, 303 insertions(+) create mode 100644 packages/lit-element/src/decorators/query-assigned-elements.ts create mode 100644 packages/lit/src/decorators/query-assigned-elements.ts create mode 100644 packages/reactive-element/src/decorators/query-assigned-elements.ts create mode 100644 packages/reactive-element/src/test/decorators/queryAssignedElements_test.ts diff --git a/packages/lit-element/package.json b/packages/lit-element/package.json index ce92b6b3db..f93a4f4090 100644 --- a/packages/lit-element/package.json +++ b/packages/lit-element/package.json @@ -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" diff --git a/packages/lit-element/src/decorators.ts b/packages/lit-element/src/decorators.ts index 54230a57e6..d69900c684 100644 --- a/packages/lit-element/src/decorators.ts +++ b/packages/lit-element/src/decorators.ts @@ -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'; diff --git a/packages/lit-element/src/decorators/query-assigned-elements.ts b/packages/lit-element/src/decorators/query-assigned-elements.ts new file mode 100644 index 0000000000..28bafe899b --- /dev/null +++ b/packages/lit-element/src/decorators/query-assigned-elements.ts @@ -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'; diff --git a/packages/lit/package.json b/packages/lit/package.json index 6735d232e5..8b63ad0339 100644 --- a/packages/lit/package.json +++ b/packages/lit/package.json @@ -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" }, diff --git a/packages/lit/src/decorators.ts b/packages/lit/src/decorators.ts index 4d3e68ac6e..e81d7aee5c 100644 --- a/packages/lit/src/decorators.ts +++ b/packages/lit/src/decorators.ts @@ -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'; diff --git a/packages/lit/src/decorators/query-assigned-elements.ts b/packages/lit/src/decorators/query-assigned-elements.ts new file mode 100644 index 0000000000..28bafe899b --- /dev/null +++ b/packages/lit/src/decorators/query-assigned-elements.ts @@ -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'; diff --git a/packages/reactive-element/package.json b/packages/reactive-element/package.json index c814431a65..98a214b91d 100644 --- a/packages/reactive-element/package.json +++ b/packages/reactive-element/package.json @@ -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" diff --git a/packages/reactive-element/src/decorators.ts b/packages/reactive-element/src/decorators.ts index 12a5e66c49..d243d5c071 100644 --- a/packages/reactive-element/src/decorators.ts +++ b/packages/reactive-element/src/decorators.ts @@ -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'; diff --git a/packages/reactive-element/src/decorators/query-assigned-elements.ts b/packages/reactive-element/src/decorators/query-assigned-elements.ts new file mode 100644 index 0000000000..53708e8217 --- /dev/null +++ b/packages/reactive-element/src/decorators/query-assigned-elements.ts @@ -0,0 +1,80 @@ +/** + * @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'; +import {decorateProperty} from './base.js'; + +export interface QueryAssignedElementsOptions extends AssignedNodesOptions { + /** + * A string name of the slot. Leave empty for the default slot. + */ + slotName?: string; + /** + * A string which filters the results to elements that match the given css + * 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, + * `slotName` and `selector`. + * @param options.slotName 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({ slotName: 'list' }) + * listItems; + * @queryAssignedElements() + * unnamedSlotEls; + * + * render() { + * return html` + * + * + * `; + * } + * } + * ``` + * @category Decorator + */ +export function queryAssignedElements(options?: QueryAssignedElementsOptions) { + const {slotName, selector} = options ?? {}; + return decorateProperty({ + descriptor: (_name: PropertyKey) => ({ + get(this: ReactiveElement) { + const slotSelector = `slot${ + slotName ? `[name=${slotName}]` : ':not([name])' + }`; + const slot = this.renderRoot?.querySelector(slotSelector); + const elements = (slot as HTMLSlotElement).assignedElements(options); + if (selector) { + return elements.filter((node) => node.matches(selector)); + } + return elements; + }, + enumerable: true, + configurable: true, + }), + }); +} diff --git a/packages/reactive-element/src/test/decorators/queryAssignedElements_test.ts b/packages/reactive-element/src/test/decorators/queryAssignedElements_test.ts new file mode 100644 index 0000000000..6cafc7e6f1 --- /dev/null +++ b/packages/reactive-element/src/test/decorators/queryAssignedElements_test.ts @@ -0,0 +1,195 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {queryAssignedElements} from '../../decorators/query-assigned-elements.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; + + class D extends RenderingElement { + @queryAssignedElements() defaultAssigned!: Element[]; + + @queryAssignedElements({slotName: 'footer', flatten: true}) + footerAssigned!: Element[]; + + @queryAssignedElements({slotName: 'footer', flatten: false}) + footerNotFlattenedSlot!: Element[]; + + @queryAssignedElements({ + slotName: 'footer', + flatten: true, + selector: '.item', + }) + footerAssignedFiltered!: Element[]; + + override render() { + return html` + + + `; + } + } + customElements.define('assigned-elements-el', D); + + class E extends RenderingElement { + @queryAssignedElements() defaultAssigned!: Element[]; + + @queryAssignedElements({slotName: 'header'}) headerAssigned!: Element[]; + + override render() { + return html` + + + `; + } + } + customElements.define('assigned-elements-el-2', E); + + const defaultSymbol = Symbol('default'); + const headerSymbol = Symbol('header'); + class S extends RenderingElement { + @queryAssignedElements() [defaultSymbol]!: Element[]; + + @queryAssignedElements({slotName: 'header'}) [headerSymbol]!: Element[]; + + override render() { + return html` + + + `; + } + } + customElements.define('assigned-elements-el-symbol', S); + + // Note, there are 2 elements here so that the `flatten` option of + // the decorator can be tested. + class C extends RenderingElement { + div!: HTMLDivElement; + div2!: HTMLDivElement; + div3!: HTMLDivElement; + assignedEls!: D; + assignedEls2!: E; + assignedEls3!: S; + @queryAssignedElements() missingSlotAssignedElements!: Element[]; + + override render() { + return html` +
A
+
+
B
+
B
+ `; + } + + 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; + this.assignedEls3 = this.renderRoot.querySelector( + 'assigned-elements-el-symbol' + ) as S; + } + } + customElements.define(generateElementName(), C); + + setup(async () => { + container = document.createElement('div'); + container.id = 'test-container'; + document.body.appendChild(container); + el = new C(); + container.appendChild(el); + await el.updateComplete; + await el.assignedEls.updateComplete; + }); + + teardown(() => { + if (container !== undefined) { + container.parentElement!.removeChild(container); + (container as any) = undefined; + } + }); + + 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.appendChild(text1); + el.assignedEls.appendChild(child); + const text2 = document.createTextNode(''); + el.assignedEls.appendChild(text2); + flush(); + assert.deepEqual(el.assignedEls.defaultAssigned, [el.div, child]); + el.assignedEls.removeChild(child); + flush(); + assert.deepEqual(el.assignedEls.defaultAssigned, [el.div]); + }); + + test('returns assignedElements for unnamed slot that is not first slot', () => { + assert.deepEqual(el.assignedEls2.defaultAssigned, [el.div2]); + }); + + test('returns assignedElements for unnamed slot via symbol property', () => { + assert.deepEqual(el.assignedEls3[defaultSymbol], [el.div3]); + }); + + test('returns flattened assignedElements for slot', () => { + assert.deepEqual(el.assignedEls.footerAssigned, []); + const child1 = document.createElement('div'); + const child2 = document.createElement('div'); + el.appendChild(child1); + el.appendChild(child2); + flush(); + assert.deepEqual(el.assignedEls.footerAssigned, [child1, child2]); + + assert.equal(el.assignedEls.footerNotFlattenedSlot.length, 1); + assert.equal(el.assignedEls.footerNotFlattenedSlot?.[0]?.tagName, 'SLOT'); + + el.removeChild(child2); + flush(); + assert.deepEqual(el.assignedEls.footerAssigned, [child1]); + }); + + test('always returns an array, even if the slot is not rendered', () => { + assert.isArray(el.missingSlotAssignedElements); + }); + + 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.appendChild(child1); + el.appendChild(child2); + flush(); + assert.deepEqual(el.assignedEls.footerAssignedFiltered, [child2]); + el.removeChild(child2); + flush(); + assert.deepEqual(el.assignedEls.footerAssignedFiltered, []); + }); +}); From 3534ccdd4cac861b433c3b1bafd49f1fa06ad492 Mon Sep 17 00:00:00 2001 From: Andrew Jakubowicz Date: Thu, 2 Dec 2021 13:44:13 -0800 Subject: [PATCH 2/9] Add QueryAssignedElementsVisitor to ts-transformers for new queryAssignedElements. --- .../src/idiomatic-decorators.ts | 2 + .../decorators/query-assigned-elements.ts | 256 ++++++++++++++++++ .../src/tests/idiomatic-decorators-test.ts | 227 ++++++++++++++++ 3 files changed, 485 insertions(+) create mode 100644 packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts diff --git a/packages/ts-transformers/src/idiomatic-decorators.ts b/packages/ts-transformers/src/idiomatic-decorators.ts index 3f07eff4d0..9fd741c365 100644 --- a/packages/ts-transformers/src/idiomatic-decorators.ts +++ b/packages/ts-transformers/src/idiomatic-decorators.ts @@ -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'; @@ -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), diff --git a/packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts b/packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts new file mode 100644 index 0000000000..9691756e72 --- /dev/null +++ b/packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts @@ -0,0 +1,256 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import ts from 'typescript'; + +import type {LitClassContext} from '../lit-class-context.js'; +import type {MemberDecoratorVisitor} from '../visitor.js'; + +/** + * Transform: + * + * @queryAssignedElements({slotName: 'list', selector: '.item'}) + * listItems + * + * Into: + * + * get listItems() { + * return this.renderRoot + * ?.querySelector('slot[name=list]') + * ?.assignedElements() + * ?.filter((node) => node.matches('.item') + * } + */ +export class QueryAssignedElementsVisitor implements MemberDecoratorVisitor { + readonly kind = 'memberDecorator'; + readonly decoratorName = 'queryAssignedElements'; + + private readonly _factory: ts.NodeFactory; + + constructor({factory}: ts.TransformationContext) { + this._factory = factory; + } + + visit( + litClassContext: LitClassContext, + property: ts.ClassElement, + decorator: ts.Decorator + ) { + if (!ts.isPropertyDeclaration(property)) { + return; + } + if (!ts.isCallExpression(decorator.expression)) { + return; + } + if (!ts.isIdentifier(property.name)) { + return; + } + const name = property.name.text; + const [arg0] = decorator.expression.arguments; + if (arg0 && !ts.isObjectLiteralExpression(arg0)) { + throw new Error( + `queryAssignedElements argument is expected to be an inlined ` + + `object literal. Instead received: '${arg0.getText()}'` + ); + } + if (arg0 && arg0.properties.some((p) => !ts.isPropertyAssignment(p))) { + throw new Error( + `queryAssignedElements object literal argument can only include ` + + `property assignment. For example: '{ slotName: "example" }' is ` + + `supported, whilst '{ ...otherOpts }' is unsupported.` + ); + } + const {slotName, selector} = this._retrieveSlotAndSelector(arg0); + litClassContext.litFileContext.replaceAndMoveComments( + property, + this._createQueryAssignedElementsGetter( + name, + slotName, + selector, + this._filterAssignedElementsOptions(arg0) + ) + ); + } + + private _retrieveSlotAndSelector(opts?: ts.ObjectLiteralExpression): { + slotName: string; + selector: string; + } { + if (!opts) { + return {slotName: '', selector: ''}; + } + const findStringLiteralFor = (key: string): string => { + const propAssignment = opts.properties.find( + (p) => p.name && ts.isIdentifier(p.name) && p.name.text === key + ); + if (!propAssignment) { + return ''; + } + if ( + propAssignment && + ts.isPropertyAssignment(propAssignment) && + ts.isStringLiteral(propAssignment.initializer) + ) { + return propAssignment.initializer.text; + } + throw new Error( + `queryAssignedElements object literal property '${key}' must be a ` + + `string literal.` + ); + }; + return { + slotName: findStringLiteralFor('slotName'), + selector: findStringLiteralFor('selector'), + }; + } + + /** + * queryAssignedElements options contains a superset of the options that + * `HTMLSlotElement.assignedElements` accepts. This method takes the original + * optional options passed into `queryAssignedElements` and filters out any + * decorator specific property assignments. + * + * Given: + * + * ```ts + * { slotName: 'example', flatten: false } + * ``` + * + * returns: + * + * ```ts + * { flatten: false } + * ``` + * + * Returns `undefined` instead of an empty object literal if no property + * assignments are left after filtering, such that we don't generate code + * like `HTMLSlotElement.assignedElements({})`. + */ + private _filterAssignedElementsOptions( + opts?: ts.ObjectLiteralExpression + ): ts.ObjectLiteralExpression | undefined { + if (!opts) { + return; + } + const assignedElementsProperties = opts.properties.filter( + (p) => + p.name && + ts.isIdentifier(p.name) && + !['slotName', 'selector'].includes(p.name.text) + ); + if (assignedElementsProperties.length === 0) { + return; + } + return this._factory.updateObjectLiteralExpression( + opts, + assignedElementsProperties + ); + } + + private _createQueryAssignedElementsGetter( + name: string, + slotName: string, + selector: string, + assignedElsOptions?: ts.ObjectLiteralExpression + ) { + const factory = this._factory; + + const slotSelector = `slot${ + slotName ? `[name=${slotName}]` : ':not([name])' + }`; + + const assignedElementsOptions = assignedElsOptions + ? [assignedElsOptions] + : []; + + // this.renderRoot?.querySelector()?.assignedElements() + const assignedElements = factory.createCallChain( + factory.createPropertyAccessChain( + factory.createCallChain( + factory.createPropertyAccessChain( + factory.createPropertyAccessExpression( + factory.createThis(), + factory.createIdentifier('renderRoot') + ), + factory.createToken(ts.SyntaxKind.QuestionDotToken), + factory.createIdentifier('querySelector') + ), + undefined, + undefined, + [factory.createStringLiteral(slotSelector)] + ), + factory.createToken(ts.SyntaxKind.QuestionDotToken), + factory.createIdentifier('assignedElements') + ), + undefined, + undefined, + assignedElementsOptions + ); + + const returnExpression = !selector + ? assignedElements + : // ?.filter((node) => node.matches()) + factory.createCallChain( + factory.createPropertyAccessChain( + assignedElements, + factory.createToken(ts.SyntaxKind.QuestionDotToken), + factory.createIdentifier('filter') + ), + undefined, + undefined, + [ + factory.createArrowFunction( + undefined, + undefined, + [ + factory.createParameterDeclaration( + undefined, + undefined, + undefined, + factory.createIdentifier('node'), + undefined, + undefined, + undefined + ), + ], + undefined, + factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + factory.createCallExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier('node'), + factory.createIdentifier('matches') + ), + undefined, + [factory.createStringLiteral(selector)] + ) + ), + ] + ); + + // { return } + const getterBody = factory.createBlock( + [ + factory.createReturnStatement( + factory.createBinaryExpression( + returnExpression, + factory.createToken(ts.SyntaxKind.QuestionQuestionToken), + factory.createArrayLiteralExpression([], false) + ) + ), + ], + true + ); + + return factory.createGetAccessorDeclaration( + undefined, + undefined, + factory.createIdentifier(name), + [], + undefined, + getterBody + ); + } +} diff --git a/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts b/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts index 254fb50437..c4bdfcfdf6 100644 --- a/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts +++ b/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts @@ -563,6 +563,233 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { checkTransform(input, expected, options); }); + test('@queryAssignedElements (default slot)', () => { + const input = ` + import {LitElement} from 'lit'; + import {queryAssignedElements} from 'lit/decorators.js'; + + class MyElement extends LitElement { + unrelated1() {} + + // listItems comment + @queryAssignedElements() + listItems: NodeListOf; + + unrelated2() {} + } + `; + + const expected = ` + import {LitElement} from 'lit'; + + class MyElement extends LitElement { + unrelated1() {} + + // listItems comment + get listItems() { + return this.renderRoot + ?.querySelector('slot:not([name])') + ?.assignedElements() ?? []; + } + + unrelated2() {} + } + `; + checkTransform(input, expected, options); + }); + + test('@queryAssignedElements (with slot name)', () => { + const input = ` + import {LitElement} from 'lit'; + import {queryAssignedElements} from 'lit/decorators.js'; + + class MyElement extends LitElement { + // listItems comment + @queryAssignedElements({ slotName: 'list' }) + listItems: HTMLElement[]; + } + `; + + const expected = ` + import {LitElement} from 'lit'; + + class MyElement extends LitElement { + // listItems comment + get listItems() { + return this.renderRoot + ?.querySelector('slot[name=list]') + ?.assignedElements() ?? []; + } + } + `; + checkTransform(input, expected, options); + }); + + test('@queryAssignedElements (with flatten)', () => { + const input = ` + import {LitElement} from 'lit'; + import {queryAssignedElements} from 'lit/decorators.js'; + + class MyElement extends LitElement { + // listItems comment + @queryAssignedElements({ slotName: 'list', flatten: true }) + listItems: NodeListOf; + } + `; + + const expected = ` + import {LitElement} from 'lit'; + + class MyElement extends LitElement { + // listItems comment + get listItems() { + return this.renderRoot + ?.querySelector('slot[name=list]') + ?.assignedElements({flatten: true}) ?? []; + } + } + `; + checkTransform(input, expected, options); + }); + + test('@queryAssignedElements (with selector)', () => { + const input = ` + import {LitElement} from 'lit'; + import {queryAssignedElements} from 'lit/decorators.js'; + + class MyElement extends LitElement { + // listItems comment + @queryAssignedElements({slotName: 'list', flatten: false, selector: '.item'}) + listItems: NodeListOf; + } + `; + + const expected = ` + import {LitElement} from 'lit'; + + class MyElement extends LitElement { + // listItems comment + get listItems() { + return this.renderRoot + ?.querySelector('slot[name=list]') + ?.assignedElements({ flatten: false }) + ?.filter((node) => node.matches('.item') + ) ?? []; + } + } + `; + checkTransform(input, expected, options); + }); + + test('@queryAssignedElements (with assignedElements identifier)', () => { + // It doesn't matter if the HTMLSlotElement.assignedElements options are + // using an identifer as we don't need to extract them. + const input = ` + import {LitElement} from 'lit'; + import {queryAssignedElements} from 'lit/decorators.js'; + + const isFlatten: boolean = false; + + class MyElement extends LitElement { + // listItems comment + @queryAssignedElements({slotName: 'list', flatten: isFlatten, selector: '.item'}) + listItems: HTMLElement[]; + } + `; + + const expected = ` + import {LitElement} from 'lit'; + + const isFlatten = false; + + class MyElement extends LitElement { + // listItems comment + get listItems() { + return this.renderRoot + ?.querySelector('slot[name=list]') + ?.assignedElements({ flatten: isFlatten }) + ?.filter((node) => node.matches('.item') + ) ?? []; + } + } + `; + checkTransform(input, expected, options); + }); + + test('@queryAssignedElements (fails if not object literal)', () => { + const input = ` + import {LitElement} from 'lit'; + import {queryAssignedElements} from 'lit/decorators.js'; + + const someIdentifier = {slotName: 'list'}; + + class MyElement extends LitElement { + // listItems comment + @queryAssignedElements(someIdentifier) + listItems: HTMLElement[]; + } + `; + assert.throws( + () => checkTransform(input, '', options), + /expected to be an inlined object literal/ + ); + }); + + test('@queryAssignedElements (fails if not property assignment - spread)', () => { + const input = ` + import {LitElement} from 'lit'; + import {queryAssignedElements} from 'lit/decorators.js'; + + class MyElement extends LitElement { + // listItems comment + @queryAssignedElements({slotName: 'list', ...{}}) + listItems: HTMLElement[]; + } + `; + assert.throws( + () => checkTransform(input, '', options), + /argument can only include property assignment/ + ); + }); + + test('@queryAssignedElements (fails if not property assignment - shorthand)', () => { + const input = ` + import {LitElement} from 'lit'; + import {queryAssignedElements} from 'lit/decorators.js'; + + const slotName = "shorthandSyntaxInvalid"; + + class MyElement extends LitElement { + // listItems comment + @queryAssignedElements({slotName}) + listItems: HTMLElement[]; + } + `; + assert.throws( + () => checkTransform(input, '', options), + /argument can only include property assignment/ + ); + }); + + test('@queryAssignedElements (fail if slotName or selector not literal)', () => { + const input = ` + import {LitElement} from 'lit'; + import {queryAssignedElements} from 'lit/decorators.js'; + + const slotName = 'list'; + + class MyElement extends LitElement { + // listItems comment + @queryAssignedElements({slotName: slotName}) + listItems: HTMLElement[]; + } + `; + assert.throws( + () => checkTransform(input, '', options), + /property 'slotName' must be a string literal/ + ); + }); + test('@queryAssignedNodes (default slot)', () => { const input = ` import {LitElement} from 'lit'; From 5437e70be3faccf5551ea8574313032a11ff769d Mon Sep 17 00:00:00 2001 From: Andrew Jakubowicz Date: Thu, 2 Dec 2021 14:39:04 -0800 Subject: [PATCH 3/9] Code review feedback. `slot` instead of `slotName`. Fix up copy pasted tests such that they are more modern, remove unused pieces. Update the transformer tests. --- .../src/decorators/query-assigned-elements.ts | 18 +++--- .../decorators/queryAssignedElements_test.ts | 56 ++++++++----------- .../decorators/query-assigned-elements.ts | 24 ++++---- .../src/tests/idiomatic-decorators-test.ts | 24 ++++---- 4 files changed, 53 insertions(+), 69 deletions(-) diff --git a/packages/reactive-element/src/decorators/query-assigned-elements.ts b/packages/reactive-element/src/decorators/query-assigned-elements.ts index 53708e8217..dd45aef19d 100644 --- a/packages/reactive-element/src/decorators/query-assigned-elements.ts +++ b/packages/reactive-element/src/decorators/query-assigned-elements.ts @@ -18,7 +18,7 @@ export interface QueryAssignedElementsOptions extends AssignedNodesOptions { /** * A string name of the slot. Leave empty for the default slot. */ - slotName?: string; + slot?: string; /** * A string which filters the results to elements that match the given css * selector. @@ -35,15 +35,15 @@ export interface QueryAssignedElementsOptions extends AssignedNodesOptions { * @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, - * `slotName` and `selector`. - * @param options.slotName Name of the slot. Undefined or empty string for the + * `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({ slotName: 'list' }) + * @queryAssignedElements({ slot: 'list' }) * listItems; * @queryAssignedElements() * unnamedSlotEls; @@ -59,15 +59,13 @@ export interface QueryAssignedElementsOptions extends AssignedNodesOptions { * @category Decorator */ export function queryAssignedElements(options?: QueryAssignedElementsOptions) { - const {slotName, selector} = options ?? {}; + const {slot, selector} = options ?? {}; return decorateProperty({ descriptor: (_name: PropertyKey) => ({ get(this: ReactiveElement) { - const slotSelector = `slot${ - slotName ? `[name=${slotName}]` : ':not([name])' - }`; - const slot = this.renderRoot?.querySelector(slotSelector); - const elements = (slot as HTMLSlotElement).assignedElements(options); + const slotSelector = `slot${slot ? `[name=${slot}]` : ':not([name])'}`; + const slotEl = this.renderRoot?.querySelector(slotSelector); + const elements = (slotEl as HTMLSlotElement).assignedElements(options); if (selector) { return elements.filter((node) => node.matches(selector)); } diff --git a/packages/reactive-element/src/test/decorators/queryAssignedElements_test.ts b/packages/reactive-element/src/test/decorators/queryAssignedElements_test.ts index 6cafc7e6f1..de7aaeabc2 100644 --- a/packages/reactive-element/src/test/decorators/queryAssignedElements_test.ts +++ b/packages/reactive-element/src/test/decorators/queryAssignedElements_test.ts @@ -5,6 +5,7 @@ */ import {queryAssignedElements} from '../../decorators/query-assigned-elements.js'; +import {customElement} from '../../decorators/custom-element.js'; import { canTestReactiveElement, generateElementName, @@ -20,17 +21,18 @@ const flush = let container: HTMLElement; let el: C; + @customElement('assigned-elements-el') class D extends RenderingElement { @queryAssignedElements() defaultAssigned!: Element[]; - @queryAssignedElements({slotName: 'footer', flatten: true}) + @queryAssignedElements({slot: 'footer', flatten: true}) footerAssigned!: Element[]; - @queryAssignedElements({slotName: 'footer', flatten: false}) + @queryAssignedElements({slot: 'footer', flatten: false}) footerNotFlattenedSlot!: Element[]; @queryAssignedElements({ - slotName: 'footer', + slot: 'footer', flatten: true, selector: '.item', }) @@ -43,12 +45,12 @@ const flush = `; } } - customElements.define('assigned-elements-el', D); + @customElement('assigned-elements-el-2') class E extends RenderingElement { @queryAssignedElements() defaultAssigned!: Element[]; - @queryAssignedElements({slotName: 'header'}) headerAssigned!: Element[]; + @queryAssignedElements({slot: 'header'}) headerAssigned!: Element[]; override render() { return html` @@ -57,26 +59,21 @@ const flush = `; } } - customElements.define('assigned-elements-el-2', E); const defaultSymbol = Symbol('default'); - const headerSymbol = Symbol('header'); + + @customElement('assigned-elements-el-symbol') class S extends RenderingElement { @queryAssignedElements() [defaultSymbol]!: Element[]; - @queryAssignedElements({slotName: 'header'}) [headerSymbol]!: Element[]; - override render() { - return html` - - - `; + return html` `; } } - customElements.define('assigned-elements-el-symbol', S); // 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; @@ -114,23 +111,17 @@ const flush = ) as S; } } - customElements.define(generateElementName(), C); setup(async () => { container = document.createElement('div'); - container.id = 'test-container'; - document.body.appendChild(container); + document.body.append(container); el = new C(); - container.appendChild(el); - await el.updateComplete; - await el.assignedEls.updateComplete; + container.append(el); + await new Promise((r) => setTimeout(r, 0)); }); teardown(() => { - if (container !== undefined) { - container.parentElement!.removeChild(container); - (container as any) = undefined; - } + container?.remove(); }); test('returns assignedElements for slot', () => { @@ -139,13 +130,12 @@ const flush = assert.deepEqual(el.assignedEls.defaultAssigned, [el.div]); const child = document.createElement('div'); const text1 = document.createTextNode(''); - el.assignedEls.appendChild(text1); - el.assignedEls.appendChild(child); + el.assignedEls.append(text1, child); const text2 = document.createTextNode(''); - el.assignedEls.appendChild(text2); + el.assignedEls.append(text2); flush(); assert.deepEqual(el.assignedEls.defaultAssigned, [el.div, child]); - el.assignedEls.removeChild(child); + child.remove(); flush(); assert.deepEqual(el.assignedEls.defaultAssigned, [el.div]); }); @@ -162,15 +152,14 @@ const flush = assert.deepEqual(el.assignedEls.footerAssigned, []); const child1 = document.createElement('div'); const child2 = document.createElement('div'); - el.appendChild(child1); - el.appendChild(child2); + 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'); - el.removeChild(child2); + child2.remove(); flush(); assert.deepEqual(el.assignedEls.footerAssigned, [child1]); }); @@ -184,11 +173,10 @@ const flush = const child1 = document.createElement('div'); const child2 = document.createElement('div'); child2.classList.add('item'); - el.appendChild(child1); - el.appendChild(child2); + el.append(child1, child2); flush(); assert.deepEqual(el.assignedEls.footerAssignedFiltered, [child2]); - el.removeChild(child2); + child2.remove(); flush(); assert.deepEqual(el.assignedEls.footerAssignedFiltered, []); }); diff --git a/packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts b/packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts index 9691756e72..8ed236dd1e 100644 --- a/packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts +++ b/packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts @@ -12,7 +12,7 @@ import type {MemberDecoratorVisitor} from '../visitor.js'; /** * Transform: * - * @queryAssignedElements({slotName: 'list', selector: '.item'}) + * @queryAssignedElements({slot: 'list', selector: '.item'}) * listItems * * Into: @@ -59,16 +59,16 @@ export class QueryAssignedElementsVisitor implements MemberDecoratorVisitor { if (arg0 && arg0.properties.some((p) => !ts.isPropertyAssignment(p))) { throw new Error( `queryAssignedElements object literal argument can only include ` + - `property assignment. For example: '{ slotName: "example" }' is ` + + `property assignment. For example: '{ slot: "example" }' is ` + `supported, whilst '{ ...otherOpts }' is unsupported.` ); } - const {slotName, selector} = this._retrieveSlotAndSelector(arg0); + const {slot, selector} = this._retrieveSlotAndSelector(arg0); litClassContext.litFileContext.replaceAndMoveComments( property, this._createQueryAssignedElementsGetter( name, - slotName, + slot, selector, this._filterAssignedElementsOptions(arg0) ) @@ -76,11 +76,11 @@ export class QueryAssignedElementsVisitor implements MemberDecoratorVisitor { } private _retrieveSlotAndSelector(opts?: ts.ObjectLiteralExpression): { - slotName: string; + slot: string; selector: string; } { if (!opts) { - return {slotName: '', selector: ''}; + return {slot: '', selector: ''}; } const findStringLiteralFor = (key: string): string => { const propAssignment = opts.properties.find( @@ -102,7 +102,7 @@ export class QueryAssignedElementsVisitor implements MemberDecoratorVisitor { ); }; return { - slotName: findStringLiteralFor('slotName'), + slot: findStringLiteralFor('slot'), selector: findStringLiteralFor('selector'), }; } @@ -116,7 +116,7 @@ export class QueryAssignedElementsVisitor implements MemberDecoratorVisitor { * Given: * * ```ts - * { slotName: 'example', flatten: false } + * { slot: 'example', flatten: false } * ``` * * returns: @@ -139,7 +139,7 @@ export class QueryAssignedElementsVisitor implements MemberDecoratorVisitor { (p) => p.name && ts.isIdentifier(p.name) && - !['slotName', 'selector'].includes(p.name.text) + !['slot', 'selector'].includes(p.name.text) ); if (assignedElementsProperties.length === 0) { return; @@ -152,15 +152,13 @@ export class QueryAssignedElementsVisitor implements MemberDecoratorVisitor { private _createQueryAssignedElementsGetter( name: string, - slotName: string, + slot: string, selector: string, assignedElsOptions?: ts.ObjectLiteralExpression ) { const factory = this._factory; - const slotSelector = `slot${ - slotName ? `[name=${slotName}]` : ':not([name])' - }`; + const slotSelector = `slot${slot ? `[name=${slot}]` : ':not([name])'}`; const assignedElementsOptions = assignedElsOptions ? [assignedElsOptions] diff --git a/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts b/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts index c4bdfcfdf6..c597b992d5 100644 --- a/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts +++ b/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts @@ -605,7 +605,7 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { class MyElement extends LitElement { // listItems comment - @queryAssignedElements({ slotName: 'list' }) + @queryAssignedElements({ slot: 'list' }) listItems: HTMLElement[]; } `; @@ -632,7 +632,7 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { class MyElement extends LitElement { // listItems comment - @queryAssignedElements({ slotName: 'list', flatten: true }) + @queryAssignedElements({ slot: 'list', flatten: true }) listItems: NodeListOf; } `; @@ -659,7 +659,7 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { class MyElement extends LitElement { // listItems comment - @queryAssignedElements({slotName: 'list', flatten: false, selector: '.item'}) + @queryAssignedElements({slot: 'list', flatten: false, selector: '.item'}) listItems: NodeListOf; } `; @@ -692,7 +692,7 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { class MyElement extends LitElement { // listItems comment - @queryAssignedElements({slotName: 'list', flatten: isFlatten, selector: '.item'}) + @queryAssignedElements({slot: 'list', flatten: isFlatten, selector: '.item'}) listItems: HTMLElement[]; } `; @@ -721,7 +721,7 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { import {LitElement} from 'lit'; import {queryAssignedElements} from 'lit/decorators.js'; - const someIdentifier = {slotName: 'list'}; + const someIdentifier = {slot: 'list'}; class MyElement extends LitElement { // listItems comment @@ -742,7 +742,7 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { class MyElement extends LitElement { // listItems comment - @queryAssignedElements({slotName: 'list', ...{}}) + @queryAssignedElements({slot: 'list', ...{}}) listItems: HTMLElement[]; } `; @@ -757,11 +757,11 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { import {LitElement} from 'lit'; import {queryAssignedElements} from 'lit/decorators.js'; - const slotName = "shorthandSyntaxInvalid"; + const slot = "shorthandSyntaxInvalid"; class MyElement extends LitElement { // listItems comment - @queryAssignedElements({slotName}) + @queryAssignedElements({slot}) listItems: HTMLElement[]; } `; @@ -771,22 +771,22 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { ); }); - test('@queryAssignedElements (fail if slotName or selector not literal)', () => { + test('@queryAssignedElements (fail if slot or selector not literal)', () => { const input = ` import {LitElement} from 'lit'; import {queryAssignedElements} from 'lit/decorators.js'; - const slotName = 'list'; + const slot = 'list'; class MyElement extends LitElement { // listItems comment - @queryAssignedElements({slotName: slotName}) + @queryAssignedElements({slot: slot}) listItems: HTMLElement[]; } `; assert.throws( () => checkTransform(input, '', options), - /property 'slotName' must be a string literal/ + /property 'slot' must be a string literal/ ); }); From e114771655d42478d20f2b7eff638ab4953459d6 Mon Sep 17 00:00:00 2001 From: Andrew Jakubowicz Date: Thu, 2 Dec 2021 14:58:14 -0800 Subject: [PATCH 4/9] Add changeset --- .changeset/famous-feet-kneel.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/famous-feet-kneel.md diff --git a/.changeset/famous-feet-kneel.md b/.changeset/famous-feet-kneel.md new file mode 100644 index 0000000000..5a8a21d2cb --- /dev/null +++ b/.changeset/famous-feet-kneel.md @@ -0,0 +1,9 @@ +--- +'lit': minor +'lit-element': minor +'lit-html': 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 by a CSS selector. From f5f535391c2182b9f7cfbb23c92cd434fd964d21 Mon Sep 17 00:00:00 2001 From: Andrew Jakubowicz Date: Thu, 2 Dec 2021 16:24:14 -0800 Subject: [PATCH 5/9] More code review feedback. --- .changeset/famous-feet-kneel.md | 3 +-- .../decorators/queryAssignedElements_test.ts | 27 +++---------------- packages/ts-transformers/README.md | 21 ++++++++------- 3 files changed, 15 insertions(+), 36 deletions(-) diff --git a/.changeset/famous-feet-kneel.md b/.changeset/famous-feet-kneel.md index 5a8a21d2cb..e944282ace 100644 --- a/.changeset/famous-feet-kneel.md +++ b/.changeset/famous-feet-kneel.md @@ -1,9 +1,8 @@ --- 'lit': minor 'lit-element': minor -'lit-html': 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 by a CSS selector. +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. diff --git a/packages/reactive-element/src/test/decorators/queryAssignedElements_test.ts b/packages/reactive-element/src/test/decorators/queryAssignedElements_test.ts index de7aaeabc2..ef8bed4f2b 100644 --- a/packages/reactive-element/src/test/decorators/queryAssignedElements_test.ts +++ b/packages/reactive-element/src/test/decorators/queryAssignedElements_test.ts @@ -46,9 +46,10 @@ const flush = } } + const defaultSymbol = Symbol('default'); @customElement('assigned-elements-el-2') class E extends RenderingElement { - @queryAssignedElements() defaultAssigned!: Element[]; + @queryAssignedElements() [defaultSymbol]!: Element[]; @queryAssignedElements({slot: 'header'}) headerAssigned!: Element[]; @@ -60,17 +61,6 @@ const flush = } } - const defaultSymbol = Symbol('default'); - - @customElement('assigned-elements-el-symbol') - class S extends RenderingElement { - @queryAssignedElements() [defaultSymbol]!: Element[]; - - override render() { - return html` `; - } - } - // Note, there are 2 elements here so that the `flatten` option of // the decorator can be tested. @customElement(generateElementName()) @@ -80,7 +70,6 @@ const flush = div3!: HTMLDivElement; assignedEls!: D; assignedEls2!: E; - assignedEls3!: S; @queryAssignedElements() missingSlotAssignedElements!: Element[]; override render() { @@ -90,9 +79,6 @@ const flush =
B
-
B
`; } @@ -106,9 +92,6 @@ const flush = this.assignedEls2 = this.renderRoot.querySelector( 'assigned-elements-el-2' ) as E; - this.assignedEls3 = this.renderRoot.querySelector( - 'assigned-elements-el-symbol' - ) as S; } } @@ -141,11 +124,7 @@ const flush = }); test('returns assignedElements for unnamed slot that is not first slot', () => { - assert.deepEqual(el.assignedEls2.defaultAssigned, [el.div2]); - }); - - test('returns assignedElements for unnamed slot via symbol property', () => { - assert.deepEqual(el.assignedEls3[defaultSymbol], [el.div3]); + assert.deepEqual(el.assignedEls2[defaultSymbol], [el.div2]); }); test('returns flattened assignedElements for slot', () => { diff --git a/packages/ts-transformers/README.md b/packages/ts-transformers/README.md index b13f00be1e..a0139ae0f6 100644 --- a/packages/ts-transformers/README.md +++ b/packages/ts-transformers/README.md @@ -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 From 7a8f736bef4f6974f6cd648887b158f8ac7d365c Mon Sep 17 00:00:00 2001 From: Andrew Jakubowicz Date: Thu, 2 Dec 2021 16:54:20 -0800 Subject: [PATCH 6/9] Unblock ssr test breakage caused by query-assigned-elements.js not being found. --- packages/labs/ssr/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/labs/ssr/package.json b/packages/labs/ssr/package.json index 9ecc70b627..55ffedafd0 100644 --- a/packages/labs/ssr/package.json +++ b/packages/labs/ssr/package.json @@ -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" From 8413ed3ac73dff14d27674bd5f2d608a8fc7f073 Mon Sep 17 00:00:00 2001 From: Andrew Jakubowicz Date: Mon, 6 Dec 2021 12:14:04 -0800 Subject: [PATCH 7/9] Address ts-transformers feedback and allow shorthand properties. In this change made the transformer far more flexible. It no longer forces string literal arguments, and instead utilizes template strings to handle arbitrary expressions or identifiers. --- .../decorators/query-assigned-elements.ts | 95 +++++++++----- .../src/tests/idiomatic-decorators-test.ts | 119 +++++++++++++----- 2 files changed, 156 insertions(+), 58 deletions(-) diff --git a/packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts b/packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts index 8ed236dd1e..d3a861bb30 100644 --- a/packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts +++ b/packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts @@ -56,7 +56,13 @@ export class QueryAssignedElementsVisitor implements MemberDecoratorVisitor { `object literal. Instead received: '${arg0.getText()}'` ); } - if (arg0 && arg0.properties.some((p) => !ts.isPropertyAssignment(p))) { + if ( + arg0 && + arg0.properties.some( + (p) => + !(ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) + ) + ) { throw new Error( `queryAssignedElements object literal argument can only include ` + `property assignment. For example: '{ slot: "example" }' is ` + @@ -66,44 +72,44 @@ export class QueryAssignedElementsVisitor implements MemberDecoratorVisitor { const {slot, selector} = this._retrieveSlotAndSelector(arg0); litClassContext.litFileContext.replaceAndMoveComments( property, - this._createQueryAssignedElementsGetter( + this._createQueryAssignedElementsGetter({ name, slot, selector, - this._filterAssignedElementsOptions(arg0) - ) + assignedElsOptions: this._filterAssignedElementsOptions(arg0), + }) ); } + /** + * @param opts object literal node passed into the queryAssignedElements decorator + * @returns expression nodes for the slot and selector. + */ private _retrieveSlotAndSelector(opts?: ts.ObjectLiteralExpression): { - slot: string; - selector: string; + slot?: ts.Expression; + selector?: ts.Expression; } { if (!opts) { - return {slot: '', selector: ''}; + return {}; } - const findStringLiteralFor = (key: string): string => { + const findExpressionFor = (key: string): ts.Expression | undefined => { const propAssignment = opts.properties.find( (p) => p.name && ts.isIdentifier(p.name) && p.name.text === key ); if (!propAssignment) { - return ''; + return; } - if ( - propAssignment && - ts.isPropertyAssignment(propAssignment) && - ts.isStringLiteral(propAssignment.initializer) - ) { - return propAssignment.initializer.text; + if (ts.isPropertyAssignment(propAssignment)) { + return propAssignment.initializer; } - throw new Error( - `queryAssignedElements object literal property '${key}' must be a ` + - `string literal.` - ); + if (ts.isShorthandPropertyAssignment(propAssignment)) { + return propAssignment.name; + } + return; }; return { - slot: findStringLiteralFor('slot'), - selector: findStringLiteralFor('selector'), + slot: findExpressionFor('slot'), + selector: findExpressionFor('selector'), }; } @@ -150,15 +156,22 @@ export class QueryAssignedElementsVisitor implements MemberDecoratorVisitor { ); } - private _createQueryAssignedElementsGetter( - name: string, - slot: string, - selector: string, - assignedElsOptions?: ts.ObjectLiteralExpression - ) { + private _createQueryAssignedElementsGetter({ + name, + slot, + selector, + assignedElsOptions, + }: { + name: string; + slot?: ts.Expression; + selector?: ts.Expression; + assignedElsOptions?: ts.ObjectLiteralExpression; + }) { const factory = this._factory; - const slotSelector = `slot${slot ? `[name=${slot}]` : ':not([name])'}`; + const slotSelector = slot + ? this.createNamedSlotSelector(slot) + : this.createDefaultSlotSelector(); const assignedElementsOptions = assignedElsOptions ? [assignedElsOptions] @@ -178,7 +191,7 @@ export class QueryAssignedElementsVisitor implements MemberDecoratorVisitor { ), undefined, undefined, - [factory.createStringLiteral(slotSelector)] + [slotSelector] ), factory.createToken(ts.SyntaxKind.QuestionDotToken), factory.createIdentifier('assignedElements') @@ -222,7 +235,7 @@ export class QueryAssignedElementsVisitor implements MemberDecoratorVisitor { factory.createIdentifier('matches') ), undefined, - [factory.createStringLiteral(selector)] + [selector] ) ), ] @@ -251,4 +264,26 @@ export class QueryAssignedElementsVisitor implements MemberDecoratorVisitor { getterBody ); } + + /** + * @param slot Expression that evaluates to the slot name. + * @returns Template string node representing `slot[name=${slot}]` + */ + private createNamedSlotSelector(slot: ts.Expression): ts.TemplateExpression { + const factory = this._factory; + return factory.createTemplateExpression( + factory.createTemplateHead('slot[name=', 'slot[name='), + [factory.createTemplateSpan(slot, factory.createTemplateTail(']', ']'))] + ); + } + + /** + * @returns Template string node representing `slot:not([name])` + */ + private createDefaultSlotSelector() { + return this._factory.createNoSubstitutionTemplateLiteral( + 'slot:not([name])', + 'slot:not([name])' + ); + } } diff --git a/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts b/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts index c597b992d5..c8b1886791 100644 --- a/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts +++ b/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts @@ -588,7 +588,7 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { // listItems comment get listItems() { return this.renderRoot - ?.querySelector('slot:not([name])') + ?.querySelector(\`slot:not([name])\`) ?.assignedElements() ?? []; } @@ -617,7 +617,7 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { // listItems comment get listItems() { return this.renderRoot - ?.querySelector('slot[name=list]') + ?.querySelector(\`slot[name=\${"list"}]\`) ?.assignedElements() ?? []; } } @@ -644,7 +644,7 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { // listItems comment get listItems() { return this.renderRoot - ?.querySelector('slot[name=list]') + ?.querySelector(\`slot[name=\${"list"}]\`) ?.assignedElements({flatten: true}) ?? []; } } @@ -671,7 +671,7 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { // listItems comment get listItems() { return this.renderRoot - ?.querySelector('slot[name=list]') + ?.querySelector(\`slot[name=\${"list"}]\`) ?.assignedElements({ flatten: false }) ?.filter((node) => node.matches('.item') ) ?? []; @@ -706,9 +706,9 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { // listItems comment get listItems() { return this.renderRoot - ?.querySelector('slot[name=list]') + ?.querySelector(\`slot[name=\${"list"}]\`) ?.assignedElements({ flatten: isFlatten }) - ?.filter((node) => node.matches('.item') + ?.filter((node) => node.matches(".item") ) ?? []; } } @@ -716,77 +716,140 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { checkTransform(input, expected, options); }); - test('@queryAssignedElements (fails if not object literal)', () => { + test('@queryAssignedElements (with slot and selector identifiers)', () => { const input = ` import {LitElement} from 'lit'; import {queryAssignedElements} from 'lit/decorators.js'; - const someIdentifier = {slot: 'list'}; + const slot = 'list'; + const selector = '.item'; class MyElement extends LitElement { // listItems comment - @queryAssignedElements(someIdentifier) + @queryAssignedElements({slot: slot, selector: selector}) listItems: HTMLElement[]; } `; - assert.throws( - () => checkTransform(input, '', options), - /expected to be an inlined object literal/ - ); + + const expected = ` + import {LitElement} from 'lit'; + + const slot = 'list'; + const selector = '.item'; + + class MyElement extends LitElement { + // listItems comment + get listItems() { + return this.renderRoot + ?.querySelector(\`slot[name=\${slot}]\`) + ?.assignedElements() + ?.filter((node) => node.matches(selector) + ) ?? []; + } + } + `; + checkTransform(input, expected, options); }); - test('@queryAssignedElements (fails if not property assignment - spread)', () => { + test('@queryAssignedElements (shorthand properties)', () => { const input = ` import {LitElement} from 'lit'; import {queryAssignedElements} from 'lit/decorators.js'; + const slot = 'list'; + const selector = '.item'; + const flatten: boolean = false; + class MyElement extends LitElement { // listItems comment - @queryAssignedElements({slot: 'list', ...{}}) + @queryAssignedElements({slot, selector, flatten}) listItems: HTMLElement[]; } `; - assert.throws( - () => checkTransform(input, '', options), - /argument can only include property assignment/ - ); + + const expected = ` + import {LitElement} from 'lit'; + + const slot = 'list'; + const selector = '.item'; + const flatten = false; + + class MyElement extends LitElement { + // listItems comment + get listItems() { + return this.renderRoot + ?.querySelector(\`slot[name=\${slot}]\`) + ?.assignedElements({ flatten }) + ?.filter((node) => node.matches(selector) + ) ?? []; + } + } + `; + checkTransform(input, expected, options); }); - test('@queryAssignedElements (fails if not property assignment - shorthand)', () => { + test('@queryAssignedElements (arbitrary inline expressions)', () => { const input = ` import {LitElement} from 'lit'; import {queryAssignedElements} from 'lit/decorators.js'; - const slot = "shorthandSyntaxInvalid"; + class MyElement extends LitElement { + // listItems comment + @queryAssignedElements({slot: "li" + "st", selector: "." + "item", flatten: true || false}) + listItems: HTMLElement[]; + } + `; + + const expected = ` + import {LitElement} from 'lit'; class MyElement extends LitElement { // listItems comment - @queryAssignedElements({slot}) + get listItems() { + return this.renderRoot + ?.querySelector(\`slot[name=\${"li" + "st"}]\`) + ?.assignedElements({ flatten: true || false }) + ?.filter((node) => node.matches("." + "item") + ) ?? []; + } + } + `; + checkTransform(input, expected, options); + }); + + test('@queryAssignedElements (fails if not object literal)', () => { + const input = ` + import {LitElement} from 'lit'; + import {queryAssignedElements} from 'lit/decorators.js'; + + const someIdentifier = {slot: 'list'}; + + class MyElement extends LitElement { + // listItems comment + @queryAssignedElements(someIdentifier) listItems: HTMLElement[]; } `; assert.throws( () => checkTransform(input, '', options), - /argument can only include property assignment/ + /expected to be an inlined object literal/ ); }); - test('@queryAssignedElements (fail if slot or selector not literal)', () => { + test('@queryAssignedElements (fails if not property assignment - spread)', () => { const input = ` import {LitElement} from 'lit'; import {queryAssignedElements} from 'lit/decorators.js'; - const slot = 'list'; - class MyElement extends LitElement { // listItems comment - @queryAssignedElements({slot: slot}) + @queryAssignedElements({slot: 'list', ...{}}) listItems: HTMLElement[]; } `; assert.throws( () => checkTransform(input, '', options), - /property 'slot' must be a string literal/ + /argument can only include property assignment/ ); }); From c3d0506de4e04657e845141d3255e044c0cb9ceb Mon Sep 17 00:00:00 2001 From: Andrew Jakubowicz Date: Mon, 6 Dec 2021 13:22:25 -0800 Subject: [PATCH 8/9] Transformer code review feedback: inline string literals & update tests. --- .../decorators/query-assigned-elements.ts | 20 +++++++++++++++++-- .../src/tests/idiomatic-decorators-test.ts | 10 +++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts b/packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts index d3a861bb30..4300c450bc 100644 --- a/packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts +++ b/packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts @@ -266,11 +266,27 @@ export class QueryAssignedElementsVisitor implements MemberDecoratorVisitor { } /** + * Returns a template string which resolves the passed in slot name + * expression. + * + * Special handling is included for string literals and no substitution + * template literals. In this case we inline the slot name into the selector + * to match what is more likely to have been authored. + * * @param slot Expression that evaluates to the slot name. - * @returns Template string node representing `slot[name=${slot}]` + * @returns Template string node representing `slot[name=${slot}]` except when + * `slot` is a string literal. Then the literal is inlined. I.e. for a slot + * expression of `"list"`, return `slot[name=list]`. */ - private createNamedSlotSelector(slot: ts.Expression): ts.TemplateExpression { + private createNamedSlotSelector(slot: ts.Expression) { const factory = this._factory; + if (ts.isStringLiteral(slot) || ts.isNoSubstitutionTemplateLiteral(slot)) { + const inlinedSlotSelector = `slot[name=${slot.text}]`; + return this._factory.createNoSubstitutionTemplateLiteral( + inlinedSlotSelector, + inlinedSlotSelector + ); + } return factory.createTemplateExpression( factory.createTemplateHead('slot[name=', 'slot[name='), [factory.createTemplateSpan(slot, factory.createTemplateTail(']', ']'))] diff --git a/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts b/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts index c8b1886791..215ec9f210 100644 --- a/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts +++ b/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts @@ -617,7 +617,7 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { // listItems comment get listItems() { return this.renderRoot - ?.querySelector(\`slot[name=\${"list"}]\`) + ?.querySelector(\`slot[name=list]\`) ?.assignedElements() ?? []; } } @@ -632,7 +632,7 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { class MyElement extends LitElement { // listItems comment - @queryAssignedElements({ slot: 'list', flatten: true }) + @queryAssignedElements({ slot: \`list\`, flatten: true }) listItems: NodeListOf; } `; @@ -644,7 +644,7 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { // listItems comment get listItems() { return this.renderRoot - ?.querySelector(\`slot[name=\${"list"}]\`) + ?.querySelector(\`slot[name=list]\`) ?.assignedElements({flatten: true}) ?? []; } } @@ -671,7 +671,7 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { // listItems comment get listItems() { return this.renderRoot - ?.querySelector(\`slot[name=\${"list"}]\`) + ?.querySelector(\`slot[name=list]\`) ?.assignedElements({ flatten: false }) ?.filter((node) => node.matches('.item') ) ?? []; @@ -706,7 +706,7 @@ const tests = (test: uvu.Test, options: ts.CompilerOptions) => { // listItems comment get listItems() { return this.renderRoot - ?.querySelector(\`slot[name=\${"list"}]\`) + ?.querySelector(\`slot[name=list]\`) ?.assignedElements({ flatten: isFlatten }) ?.filter((node) => node.matches(".item") ) ?? []; From b3dae4b94f30179cc84164264133cd97cb716039 Mon Sep 17 00:00:00 2001 From: Andrew Jakubowicz Date: Mon, 6 Dec 2021 17:16:56 -0800 Subject: [PATCH 9/9] Add feedback from Justin. Some documentation updates and a missed optional chaining. --- .../src/decorators/query-assigned-elements.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/reactive-element/src/decorators/query-assigned-elements.ts b/packages/reactive-element/src/decorators/query-assigned-elements.ts index dd45aef19d..876f561ea7 100644 --- a/packages/reactive-element/src/decorators/query-assigned-elements.ts +++ b/packages/reactive-element/src/decorators/query-assigned-elements.ts @@ -16,12 +16,11 @@ import {decorateProperty} from './base.js'; export interface QueryAssignedElementsOptions extends AssignedNodesOptions { /** - * A string name of the slot. Leave empty for the default slot. + * 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 - * selector. + * CSS selector used to filter the elements returned. */ selector?: string; } @@ -32,6 +31,8 @@ export interface QueryAssignedElementsOptions extends AssignedNodesOptions { * way to use * [`slot.assignedElements`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLSlotElement/assignedElements). * + * Note, the type of this property should be annotated as `Array`. + * * @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, @@ -39,14 +40,14 @@ export interface QueryAssignedElementsOptions extends AssignedNodesOptions { * @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. + * given CSS selector. * * ```ts * class MyElement { * @queryAssignedElements({ slot: 'list' }) - * listItems; + * listItems!: Array; * @queryAssignedElements() - * unnamedSlotEls; + * unnamedSlotEls?: Array; * * render() { * return html` @@ -64,8 +65,9 @@ export function queryAssignedElements(options?: QueryAssignedElementsOptions) { 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); + const slotEl = + this.renderRoot?.querySelector(slotSelector); + const elements = slotEl?.assignedElements(options) ?? []; if (selector) { return elements.filter((node) => node.matches(selector)); }