Development: Lit Helpers
A library with helpers functions for working with lit.
Installation
npm i --save @open-wc/lit-helpers
Privately Settable Read-Only Properties
ReadOnlyPropertiesMixin
provides a way for components based on LitElement
(or it's parent class UpdatingElement
) to define properties by adding readOnly: true
to their property declaration. Those properties are read-only from the outside, but can be updated internally with the setReadOnlyProperties
method.
import { ReadOnlyPropertiesMixin } from '@open-wc/lit-helpers';
import { LitElement } from 'lit';
class SettableElement extends ReadOnlyPropertiesMixin(LitElement) {
static get properties() {
return {
timestamp: { type: Number, readOnly: true },
};
}
constructor() {
super();
this.timestamp = Date.now();
}
updateTime() {
this.setReadOnlyProperties({ timestamp: Date.now() });
}
render() {
return html`
<button @click="${this.updateTime}">Update Time</button>
<time>${this.timestamp}</time>
`;
}
}
The mixin also supports the @property
decorator.
import { ReadOnlyPropertiesMixin } from '@open-wc/lit-helpers';
import { LitElement, property } from 'lit';
class SettableElement extends ReadOnlyPropertiesMixin(LitElement) {
@property({ type: Number, readOnly: true }) timestamp = Date.now();
updateTime() {
this.setReadOnlyProperties({ timestamp: Date.now() });
}
render() {
return html`
<button @click="${this.updateTime}">Update Time</button>
<time>${this.timestamp}</time>
`;
}
}
Known Limitations
Order of Application Matters
Currently, this mixin only works properly when applied to LitElement (or UpdatingElement) directly. In other words, if you have a component which inherits like the example below, then ReadOnlyPropertiesMixin
must be applied to LitElement
only.
// Bad
class Lowest extends LitElement {
@property({ readOnly: true }) lowestProperty = undefined;
}
class Highest extends ReadOnlyPropertiesMixin(Lowest) {
// will not work as expected
@property({ readOnly: true }) highestProperty = undefined;
}
// Good
class Lowest extends ReadOnlyPropertiesMixin(LitElement) {
@property({ readOnly: true }) lowestProperty = undefined;
}
class Highest extends Lowest {
@property({ readOnly: true }) highestProperty = undefined;
}
Properties Must be Initialized
Read only properties must be initialized with some value, even if the value is undefined
. This is because the mixin allows one free setting, to support class field initialization.
// Bad
class Uninitialized extends ReadOnlyPropertiesMixin(LitElement) {
@property({ readOnly: true }) uninitialized;
}
// Good
class Initialized extends ReadOnlyPropertiesMixin(LitElement) {
@property({ readOnly: true }) initialized = undefined;
}
Spread Directives
In some situations you will find the need to apply a large or semi-unknown amount of attributes, boolean attributes, events, and properties to an element. By default lit-html
would expect you to bind them each individually:
const elementTemplate = obj => html`
<custom-element
my-attribute=${obj.attribute}
?my-boolean-attribute=${obj.booleanAttribute}
@my-event=${obj.event}
.myProperty=${obj.property}
></custom-element>
`;
This collection of directives allows you to bind object containing values of various types (spread
), event types (spreadEvent
), or property types (spreadProps
) to elements in your template literals. There are many conciderations to take into account when leveraging these helpers, but if you keep them in mind there is much benefit to be found in them.
spread
The spread
directive provides a way to apply all of this data to your element in a single binding:
import { spread } from '@open-wc/lit-helpers';
const elementTemplate = (
obj = {
'my-attribute': 'foo',
'?my-boolean-attribute': true,
'@my-event': () => console.log('my-event fired'),
'.myProperty': { foo: 'bar' },
},
) => html` <custom-element ${spread(obj)}></custom-element> `;
The spread
directive will allow you to access any of the binding types available via lit-html
by branding the property name of the corresponding value with the same sigil that you would use in your lit-html
template. Attributes require no sigil, boolean attributes require a ?
, events a @
, and properties the .
sigil.
spreadEvents
If you expect to only have events to supply to your element from the provided object, you can use the spreadEvents
directive, which does not require you to brand the event names you are spreading onto your element:
import { spreadEvents } from '@open-wc/lit-helpers';
const elementTemplate = (
obj = {
'my-event': () => {
console.log('my-event');
},
'my-other-event': () => {
console.log('my-other-event');
},
'my-additional-event': () => {
console.log('my-additional-event');
},
},
) => html` <custom-element ${spreadEvents(obj)}></custom-element> `;
This can also be leveraged in order to bind multiple callbacks to the same event by spreading more that one object onto your element:
import { spreadEvents } from '@open-wc/lit-helpers';
const elementTemplate = (
obj1 = {
click: () => {
console.log('first click handler');
},
},
obj2 = {
click: () => {
console.log('second click handler');
},
},
) => html` <custom-element ${spreadEvents(obj1)} ${spreadEvents(obj2)}></custom-element> `;
spreadProps
If you expect to only have properties to supply to your element from the provided object, you can use the spreadProps
directive, which does not require you to brand the properties you are spreading onto your element:
import { spreadProps } from '@open-wc/lit-helpers';
const elementTemplate = (
obj = {
string: 'string',
number: 10,
array: ['This', 'is', 'an', 'array.'],
object: {
foo: 'bar',
baz: true,
bar: false,
},
},
) => html` <custom-element ${spreadProps(obj)}></custom-element> `;
Considerations
SSR
These helpers are being delivered as lit-html
directives but due to the imperative nature of their internals they are currently not structured for availability in an server side rendered context. Keep this in mind when structuring components that consumer these directives and be sure to inform down stream consumers that you are leveraging these tool in your documentation so that they can be aware of these short comings as well.
Bind order
By default, lit-html
does not allow multiple bindings of the same type to the same name: e.g. <custom-element .value=${value1} .value=${value2}>
will immediately cause visual issues in the delivery of your template. When leveraging the various spread directives, you will suddenly be able to do so as your bindings are being moved out of the standard template heuristics. This may bring about subtle and hard to debug issues within your template. Take the following code sample:
<custom-element
${spreadProps({
prop1: 'prop1',
prop2: 'prop2',
prop3: 'prop3',
})}
.prop1=${'prop4'}
></custom-element>
What is the value of document.querySelector('custom-element').prop1
? If you said "prop1" then you've falled into one of these issues. The .prop1=${'prop4'}
comes later in the template, so it wins and applies the value as "prop4". This might be somewhat easier to visualize in the above example, but imagine the following:
const template = (obj, value) => html`
<custom-element ${spreadProps(obj)} .prop1=${value}></custom-element>
`;
const obj = {
prop1: 'prop1',
prop2: 'prop2',
prop3: 'prop3',
};
render(template(obj, 'prop4'), document.body);
// Here document.querySelector('custom-element').prop1 === 'prop4'
// ...
delete obj.prop1;
render(template(obj, 'prop4'), document.body);
You've removed the prop1
entry from the object you are spreading onto your element, what's the value of document.querySelector('custom-element').prop1
now? If you expected the spread
directive to clean up the value of prop1
because it was removed from the object before the second render you may be surprised to see that the value is still "prop4". Taking the reverse binding into account, you'll get a different outcome:
const template = (obj, value) => html`
<custom-element .prop1=${value} ${spreadProps(obj)}></custom-element>
`;
const obj = {
prop1: 'prop1',
prop2: 'prop2',
prop3: 'prop3',
};
render(template(obj, 'prop4'), document.body);
// Here document.querySelector('custom-element').prop1 === 'prop1'
// ...
delete obj.prop1;
render(template(obj, 'prop4'), document.body);
With the spread
in the second binding position, we get the value from our object applied to the element. Great! But, in our second render, what would you expect the value of document.querySelector('custom-element').prop1
to be? "prop4" is the winner, because when the spread
directive looks to remove the property, if it finds that the value of that property is not what it previously applied, it will not set it to undefined
. This means that the earlier bound value will remain applied. However, this also means that any changes to the property not managed by the template
will persist as well, including changes to the element's internal state and manual changes from outside of the template.
Update order
Similar to the edge cases that can come from bind order realities, what render pass properties are updated in your template literal can cause tricky to debug edge cases as well. This comes from the way that dirty checking with the lit-html
render pass prefers the "last in" value.
Taking, again, our sample from above:
const template = (obj, value) => html`
<custom-element ${spreadProps(obj)} .prop1=${value}></custom-element>
`;
Imagine that in subsequent renders the identity of the variable passes as value
is not changed, but the identity of the variable passed as obj
is, and within obj
there is a property prop1
. In this case the value for prop1
in obj
would superceed that of value
as the binding to .prop1
will not return as dirty, meaning the value in obj
is the "last in" to the content and will be applied in that second render pass.
The undiscovered country
There are likely other confusing situations that complex consumption of the spread directives may push you into, so hedge you bets and keep your bindings as simple as you can while leveraging the powers these directives afford. If all of your content is managed via a single spread helper, you will be less likley to run into undiscovered binding or update order issues.
const template = everything => html` <custom-element ${spread(everything)}></custom-element> `;
If this approach, particularly the task of creating a sigil signed property for every value in the everything
argument, is difficult, you can achieve much the same by breaking keeping the various spread content separate:
const template = (attrs, props, events) => html`
<custom-element ${spread(attr)} ${spreadProps(props)} ${spreadEvents(event)}></custom-element>
`;