Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/component-base/src/directives/part-map.d.ts
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.
Comment thread
vursen marked this conversation as resolved.
* 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;
86 changes: 86 additions & 0 deletions packages/component-base/src/directives/part-map.js
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);
96 changes: 96 additions & 0 deletions packages/component-base/test/part-map.test.js
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);
});
});
});
Loading