-
Notifications
You must be signed in to change notification settings - Fork 98
feat: add partMap directive for dynamic part names #12226
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+204
−0
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| /** | ||
| * @license | ||
| * Copyright (c) 2026 - 2026 Vaadin Ltd. | ||
| * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ | ||
| */ | ||
| import type { DirectiveResult } from 'lit/directive.js'; | ||
|
|
||
| /** | ||
| * A key-value set of part names to truthy values. | ||
| */ | ||
| export interface PartNameInfo { | ||
| readonly [name: string]: string | boolean | number | null | undefined; | ||
| } | ||
|
|
||
| /** | ||
| * A directive that applies dynamic shadow DOM part names. | ||
| * | ||
| * This must be used in the `part` attribute and must be the only binding in it. | ||
| * Each property name in `partNameInfo` is added to the element's `part` list | ||
| * if the property value is truthy, and removed if the value is falsy. | ||
| */ | ||
| export declare function partMap(partNameInfo: PartNameInfo): DirectiveResult; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| /** | ||
| * @license | ||
| * Copyright (c) 2026 - 2026 Vaadin Ltd. | ||
| * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ | ||
| */ | ||
| import { noChange } from 'lit'; | ||
| import { Directive, directive, PartType } from 'lit/directive.js'; | ||
|
|
||
| class PartMapDirective extends Directive { | ||
| // Part names applied by the directive on the previous render, | ||
| // used to remove names that no longer apply. | ||
| #previousParts; | ||
|
|
||
| // Part names declared statically in the attribute, never removed. | ||
| #staticParts; | ||
|
|
||
| constructor(partInfo) { | ||
| super(partInfo); | ||
| if (partInfo.type !== PartType.ATTRIBUTE || partInfo.name !== 'part' || partInfo.strings?.length > 2) { | ||
| throw new Error('`partMap()` can only be used in the `part` attribute and must be the only binding in it.'); | ||
| } | ||
| } | ||
|
|
||
| render(partNameInfo) { | ||
| // Add spaces to ensure separation from static parts | ||
| return ` ${Object.keys(partNameInfo) | ||
| .filter((key) => partNameInfo[key]) | ||
| .join(' ')} `; | ||
| } | ||
|
|
||
| update(part, [partNameInfo]) { | ||
| // Remember dynamic parts on the first render | ||
| if (this.#previousParts === undefined) { | ||
| this.#previousParts = new Set(); | ||
| if (part.strings !== undefined) { | ||
| this.#staticParts = new Set( | ||
| part.strings | ||
| .join(' ') | ||
| .split(/\s/u) | ||
| .filter((s) => s !== ''), | ||
| ); | ||
| } | ||
| Object.keys(partNameInfo).forEach((name) => { | ||
| if (partNameInfo[name] && !this.#staticParts?.has(name)) { | ||
| this.#previousParts.add(name); | ||
| } | ||
| }); | ||
| return this.render(partNameInfo); | ||
| } | ||
|
|
||
| const partList = part.element.part; | ||
|
|
||
| // Remove old parts that no longer apply | ||
| this.#previousParts.forEach((name) => { | ||
| if (!(name in partNameInfo)) { | ||
| partList.remove(name); | ||
| this.#previousParts.delete(name); | ||
| } | ||
| }); | ||
|
|
||
| // Add or remove parts based on their partMap value | ||
| Object.keys(partNameInfo).forEach((name) => { | ||
| const value = !!partNameInfo[name]; | ||
| if (value !== this.#previousParts.has(name) && !this.#staticParts?.has(name)) { | ||
| if (value) { | ||
| partList.add(name); | ||
| this.#previousParts.add(name); | ||
| } else { | ||
| partList.remove(name); | ||
| this.#previousParts.delete(name); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| return noChange; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * A directive that applies dynamic shadow DOM part names. | ||
| * | ||
| * This must be used in the `part` attribute and must be the only binding in it. | ||
| * Each property name in `partNameInfo` is added to the element's `part` list | ||
| * if the property value is truthy, and removed if the value is falsy. | ||
| */ | ||
| export const partMap = directive(PartMapDirective); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| import { expect } from '@vaadin/chai-plugins'; | ||
| import { fixtureSync } from '@vaadin/testing-helpers'; | ||
| import { html, render } from 'lit'; | ||
| import { partMap } from '../src/directives/part-map.js'; | ||
|
|
||
| describe('partMap', () => { | ||
| let container; | ||
|
|
||
| beforeEach(() => { | ||
| container = fixtureSync('<div></div>'); | ||
| }); | ||
|
|
||
| describe('dynamic parts', () => { | ||
| let element; | ||
|
|
||
| function renderPart(partNameInfo) { | ||
| render(html`<div part=${partMap(partNameInfo)}></div>`, container); | ||
| return container.firstElementChild; | ||
| } | ||
|
|
||
| it('should add part names with truthy values', () => { | ||
| element = renderPart({ foo: true, bar: 1, baz: 'yes' }); | ||
| expect([...element.part]).to.have.members(['foo', 'bar', 'baz']); | ||
| }); | ||
|
|
||
| it('should not add part names with falsy values', () => { | ||
| element = renderPart({ foo: false, bar: 0, baz: '', qux: null, quux: undefined }); | ||
| expect([...element.part]).to.be.empty; | ||
| }); | ||
|
|
||
| it('should remove part names whose values become falsy', () => { | ||
| element = renderPart({ foo: true, bar: true }); | ||
| renderPart({ foo: false, bar: true }); | ||
| expect([...element.part]).to.have.members(['bar']); | ||
| }); | ||
|
|
||
| it('should remove part names omitted on re-render', () => { | ||
| element = renderPart({ foo: true, bar: true }); | ||
| renderPart({ bar: true }); | ||
| expect([...element.part]).to.have.members(['bar']); | ||
| }); | ||
|
|
||
| it('should add part names whose values become truthy', () => { | ||
| element = renderPart({ foo: false }); | ||
| renderPart({ foo: true }); | ||
| expect([...element.part]).to.have.members(['foo']); | ||
| }); | ||
|
|
||
| it('should keep part names added outside the directive', () => { | ||
| element = renderPart({ foo: true }); | ||
| element.part.add('external'); | ||
| renderPart({ foo: false }); | ||
| expect([...element.part]).to.have.members(['external']); | ||
| }); | ||
| }); | ||
|
|
||
| describe('static parts', () => { | ||
| let element; | ||
|
|
||
| function renderPart(partNameInfo) { | ||
| render(html`<div part="static ${partMap(partNameInfo)}"></div>`, container); | ||
| return container.firstElementChild; | ||
| } | ||
|
|
||
| it('should keep static part names on the first render', () => { | ||
| element = renderPart({ foo: true }); | ||
| expect([...element.part]).to.have.members(['static', 'foo']); | ||
| }); | ||
|
|
||
| it('should keep static part names on re-render', () => { | ||
| element = renderPart({ foo: true }); | ||
| renderPart({ foo: false }); | ||
| expect([...element.part]).to.have.members(['static']); | ||
| }); | ||
|
|
||
| it('should keep static part names with falsy values in the map', () => { | ||
| element = renderPart({ static: true }); | ||
| renderPart({ static: false }); | ||
| expect([...element.part]).to.have.members(['static']); | ||
| }); | ||
| }); | ||
|
|
||
| describe('errors', () => { | ||
| it('should throw when used in an attribute other than part', () => { | ||
| expect(() => { | ||
| render(html`<div class=${partMap({ foo: true })}></div>`, container); | ||
| }).to.throw(/can only be used in the `part` attribute/u); | ||
| }); | ||
|
|
||
| it('should throw when combined with another binding in the attribute', () => { | ||
| expect(() => { | ||
| render(html`<div part="${partMap({ foo: true })} ${'bar'}"></div>`, container); | ||
| }).to.throw(/must be the only binding/u); | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.