diff --git a/.changeset/famous-feet-kneel.md b/.changeset/famous-feet-kneel.md new file mode 100644 index 0000000000..e944282ace --- /dev/null +++ b/.changeset/famous-feet-kneel.md @@ -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. 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" 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..876f561ea7 --- /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 { + /** + * Name of the slot. Leave empty for the default slot. + */ + slot?: string; + /** + * CSS selector used to filter the elements returned. + */ + 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). + * + * 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, + * `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!: Array; + * @queryAssignedElements() + * unnamedSlotEls?: Array; + * + * render() { + * return html` + * + * + * `; + * } + * } + * ``` + * @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?.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..ef8bed4f2b --- /dev/null +++ b/packages/reactive-element/src/test/decorators/queryAssignedElements_test.ts @@ -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` + + + `; + } + } + + const defaultSymbol = Symbol('default'); + @customElement('assigned-elements-el-2') + class E extends RenderingElement { + @queryAssignedElements() [defaultSymbol]!: Element[]; + + @queryAssignedElements({slot: 'header'}) headerAssigned!: Element[]; + + override render() { + return html` + + + `; + } + } + + // 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` +
A
+
+
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; + } + } + + 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); + }); + + 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, []); + }); +}); 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 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..4300c450bc --- /dev/null +++ b/packages/ts-transformers/src/internal/decorators/query-assigned-elements.ts @@ -0,0 +1,305 @@ +/** + * @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({slot: '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) || ts.isShorthandPropertyAssignment(p)) + ) + ) { + throw new Error( + `queryAssignedElements object literal argument can only include ` + + `property assignment. For example: '{ slot: "example" }' is ` + + `supported, whilst '{ ...otherOpts }' is unsupported.` + ); + } + const {slot, selector} = this._retrieveSlotAndSelector(arg0); + litClassContext.litFileContext.replaceAndMoveComments( + property, + this._createQueryAssignedElementsGetter({ + name, + slot, + selector, + 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?: ts.Expression; + selector?: ts.Expression; + } { + if (!opts) { + return {}; + } + 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; + } + if (ts.isPropertyAssignment(propAssignment)) { + return propAssignment.initializer; + } + if (ts.isShorthandPropertyAssignment(propAssignment)) { + return propAssignment.name; + } + return; + }; + return { + slot: findExpressionFor('slot'), + selector: findExpressionFor('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 + * { slot: '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) && + !['slot', 'selector'].includes(p.name.text) + ); + if (assignedElementsProperties.length === 0) { + return; + } + return this._factory.updateObjectLiteralExpression( + opts, + assignedElementsProperties + ); + } + + private _createQueryAssignedElementsGetter({ + name, + slot, + selector, + assignedElsOptions, + }: { + name: string; + slot?: ts.Expression; + selector?: ts.Expression; + assignedElsOptions?: ts.ObjectLiteralExpression; + }) { + const factory = this._factory; + + const slotSelector = slot + ? this.createNamedSlotSelector(slot) + : this.createDefaultSlotSelector(); + + 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, + [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, + [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 + ); + } + + /** + * 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}]` 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) { + 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(']', ']'))] + ); + } + + /** + * @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 254fb50437..215ec9f210 100644 --- a/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts +++ b/packages/ts-transformers/src/tests/idiomatic-decorators-test.ts @@ -563,6 +563,296 @@ 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({ slot: '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({ slot: \`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({slot: '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({slot: '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 (with slot and selector identifiers)', () => { + const input = ` + import {LitElement} from 'lit'; + import {queryAssignedElements} from 'lit/decorators.js'; + + const slot = 'list'; + const selector = '.item'; + + class MyElement extends LitElement { + // listItems comment + @queryAssignedElements({slot: slot, selector: selector}) + listItems: HTMLElement[]; + } + `; + + 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 (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, selector, flatten}) + listItems: HTMLElement[]; + } + `; + + 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 (arbitrary inline expressions)', () => { + const input = ` + import {LitElement} from 'lit'; + import {queryAssignedElements} from 'lit/decorators.js'; + + 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 + 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), + /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({slot: 'list', ...{}}) + listItems: HTMLElement[]; + } + `; + assert.throws( + () => checkTransform(input, '', options), + /argument can only include property assignment/ + ); + }); + test('@queryAssignedNodes (default slot)', () => { const input = ` import {LitElement} from 'lit';