diff --git a/CHANGELOG.md b/CHANGELOG.md
index 855f04e..7780ae1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,10 +6,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- You can now bind attributes with `??foo="${bar}"` syntax. This is functionally
+ equivalent to the `nullish` updater and will replace that functionality later.
+
### Changed
- Template errors now include approximate line numbers from the offending
template. They also print the registered custom element tag name (#201).
+- The `ifDefined` updater now deletes the attribute on `null` in addition to
+ `undefined`. This makes it behave identically to `nullish`. However, both
+ updaters are deprecated and the `??attr` binding should be used instead.
+
+### Deprecated
+
+- The `ifDefined` and `nullish` updaters are deprecated, update templates to use
+ syntax like `??foo="${bar}"`.
+- The `repeat` updater is deprecated, use `map` instead.
+
+### Fixed
+
+- Transitions from different content values should all now work. For example,
+ you previously could not change from a text value to an array. Additionally,
+ state is properly cleared when going from one value type to another — e.g.,
+ when going from `unsafeHTML` back to `null`.
## [1.1.1] - 2024-11-09
@@ -27,13 +48,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New support for static `styles` getter for `adoptedStyleSheets` ergonomics
(#52).
-### Fixed
-
-- The `map` function now works with properties / attributes bound across
- template contexts (#179).
-- The `x-element.d.ts` file now reflects the actual interface. Previously, it
- has some issues (e.g., improper module export).
-
### Changed
- The `x-element.js` file is now “typed” via JSDoc. The validity of the JSDoc
@@ -41,6 +55,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
are exported into a generated `x-element.d.ts` file. Previously, that file was
hand-curated.
+### Fixed
+
+- The `map` function now works with properties / attributes bound across
+ template contexts (#179).
+- The `x-element.d.ts` file now reflects the actual interface. Previously, it
+ has some issues (e.g., improper module export).
+
## [1.0.0] - 2024-02-29
### Added
diff --git a/doc/TEMPLATES.md b/doc/TEMPLATES.md
index c792e05..4294ef1 100644
--- a/doc/TEMPLATES.md
+++ b/doc/TEMPLATES.md
@@ -20,57 +20,330 @@ static template(html, { map }) {
The following binding types are supported:
-| Type | Example |
-| :------------------ | :--------------------- |
-| attribute | `
` |
Emulates:
```javascript
const el = document.createElement('my-custom-element');
+el.attachShadow({ mode: 'open' });
+el.innerHTML = ``;
+const target = el.shadowRoot.getElementById('target');
-// attribute value bindings add or modify the attribute value
-el.setAttribute('foo', bar);
+// attribute value bindings set the attribute value
+target.setAttribute('foo', bar);
-// attribute boolean bindings add or remove the attribute
-el.setAttribute('foo', '');
+// attribute boolean bindings set the attribute to an empty string or remove
+target.setAttribute('foo', ''); // when bar is truthy
+target.removeAttribute('foo'); // when bar is falsy
+
+// attribute defined bindings set the attribute if the value is non-nullish
+target.setAttribute('foo', bar); // when bar is non-nullish
+target.removeAttribute('foo'); // when bar is nullish
// property bindings assign the value to the property of the node
-el.foo = bar;
+target.foo = bar;
+
+// content bindings create text nodes for text content
+target.append(document.createTextNode(foo));
-// text bindings assign the value to the content of the node
-el.textContent = foo;
+// content bindings append a child for singular, nested content
+target.append(foo);
-// content bindings create and append text to the node
-el.appendChild(document.createTextNode(foo))
+// content binding maps and appends children for arrays of nested content
+target.append(...foo);
```
-### Important note on serialization during data binding:
+**Important note on serialization during data binding:**
+
Browsers must serialize values assigned to a DOM node's attributes using `toString()`. Properties with scalar types `String`, `Number`, and `Boolean` are bound to attributes using this native serialization. To help you avoid `[object Object]` surprises, properties defined using `x-element` allow you to specify their anticipated type. Attempting to bind non-scalar types to _attributes_ will result in an `x-element` error message.
-The following directives are supported:
+The following value updaters are supported:
-* ifDefined
-* live
-* map
-* nullish
-* repeat
-* unsafeHTML
-* unsafeSVG
+* live (can be used with property bindings)
+* map (can be used with content bindings)
+* unsafeHTML (can be used with content bindings)
+* unsafeSVG (can be used with content bindings)
The following template languages are supported:
* html
* svg
-### A note on non-primitive data:
+**A note on non-primitive data:**
Because DOM manipulation is *slow* — template engines do their best to avoid it (if a value hasn’t changed, DOM manipulation is skipped). To know if a non-primitive property has changed, a change-by-reference check is performed (versus a change-by-value check). Therefore, treat non-primitives as if they’re immutable. I.e., do this: `element.property = { ...element.property, foo: 'bar' }`, and don’t do this: `element.property.foo = 'bar'`.
+## Value binding (in detail)
+
+There are only a handful of _types_ of bindings. And for each of those types of
+bindings, there are only a handful of _values_ that are appropriate for them.
+This section will cover everything by type, and then by value.
+
+### Attribute binding
+
+The basic attribute binding simply calls `setAttribute` verbatim.
+
+```js
+const bar = 'something';
+html``;
+//
+
+const bar = 99;
+html``;
+//
+
+const bar = undefined;
+html``;
+//
+
+const bar = null;
+html``;
+//
+
+const bar = true;
+html``;
+//
+
+const bar = false;
+html``;
+//
+
+const bar = {};
+html``;
+//
+```
+
+### Boolean attribute binding
+
+The boolean attribute binding will either set the attribute to the empty string
+or remove the attribute.
+
+```js
+const bar = 'something';
+html``;
+//
+
+const bar = 99;
+html``;
+//
+
+const bar = undefined;
+html``;
+//
+
+const bar = null;
+html``;
+//
+
+const bar = true;
+html``;
+//
+
+const bar = false;
+html``;
+//
+
+const bar = {};
+html``;
+//
+```
+
+### Defined attribute binding
+
+The defined attribute binding will set the attribute via `setAttribute` verbatim
+_if_ the value is non-nullish. Otherwise, it will remove the attribute.
+
+```js
+const bar = 'something';
+html``;
+//
+
+const bar = 99;
+html``;
+//
+
+const bar = undefined;
+html``;
+//
+
+const bar = null;
+html``;
+//
+
+const bar = true;
+html``;
+//
+
+const bar = false;
+html``;
+//
+
+const bar = {};
+html``;
+//
+```
+
+### Property binding
+
+#### Basic property binding
+
+The basic property binding just binds the typed value to the target element.
+
+```js
+const bar = 'something';
+html``;
+//
+// el.foo = bar;
+```
+
+#### The `live` property binding
+
+You can wrap the value being bound in the `live` updater to ensure that each
+`render` call will sync the template‘s value into the DOM. This is primarily
+used to control form inputs.
+
+```js
+const bar = 'something';
+html``;
+//
+// el.value = bar;
+```
+
+The key difference to note is that the basic property binding will not attempt
+to perform an update if `value === lastValue`. The `live` binding will always
+set `el.value = value` whenever a `render` is kicked off.
+
+### Content binding
+
+The content binding does different things based on the value passed in.
+
+#### Basic content binding
+
+The most basic content binding sets the value as text content.
+
+```js
+const bar = 'something';
+html`
${bar}
`;
+//
something
+
+const bar = 99;
+html`
${bar}
`;
+//
99
+
+const bar = undefined;
+html`
${bar}
`;
+//
+
+const bar = null;
+html`
${bar}
`;
+//
+
+const bar = true;
+html`
${bar}
`;
+//
true
+
+const bar = false;
+html`
${bar}
`;
+//
false
+
+const bar = {};
+html`
${bar}
`;
+//
[object Object]
+```
+
+#### Composed content binding
+
+When the content being bound is itself a template, you get composition.
+
+```js
+const bar = html`something`;
+html`
${bar}
`;
+//
something
+```
+
+#### Array content binding
+
+When the content being bound is an array of templates, you get a mapping.
+
+```js
+const bar = [
+ html`one`,
+ html`two`,
+];
+html`
${bar}
`;
+//
onetwo
+
+// … but, you typically don’t have a static array, you map it idiomatically.
+const terms = ['one', 'two'];
+const bar = terms.map(term => html`${item}`);
+html`
${bar}
`;
+//
onetwo
+```
+
+#### The `map` content binding
+
+The `map` content binding adds some special behavior on top of the basic array
+content binding. In particular, it _keeps track_ of each child node based on
+a `identify` function declared by the caller. This enables the template engine
+to _move_ child nodes under certain circumstances (versus having to constantly
+destroy and recreate). And that shuffling behavior enables authors to animate
+DOM nodes across such transitions.
+
+```js
+// Note that you can shuffle the deck without destroying / creating DOM.
+const deck = [
+ { id: 'hearts-one', text: '\u26651' },
+ // …
+ { id: 'clubs-ace', text: '\u2663A' },
+];
+const bar = map(deck, card => card.id, card => html`${item.text}`);
+html`
${bar}
`;
+//
♥1…♣A
+```
+
+#### The `unsafeHTML` content binding
+
+The `unsafeHTML` content binding allows you to leverage html from a trusted
+source. This should _only_ be used to inject trusted content — never user
+content.
+
+```js
+const bar = '';
+html`
${unsafeHTML(bar)}
`;
+//
+// console.prompt('can you hear me now?');
+```
+
+#### The `unsafeSVG` content binding
+
+The `unsafeSVG` binding should be used within some svg context. It will take
+trusted svg strings and instantiate them (similar to `unsafeHTML`);
+
+```js
+const bar = '';
+html`
+
+`;
+//
+//
+//
+```
+
## Customizing your base class
Following is a working example using [lit-html](https://lit.dev):
diff --git a/test/test-initialization-errors.js b/test/test-initialization-errors.js
index 0d20954..194402c 100644
--- a/test/test-initialization-errors.js
+++ b/test/test-initialization-errors.js
@@ -89,13 +89,13 @@ it('errors are thrown in connectedCallback when template result fails to render'
class TestElement extends XElement {
static get properties() {
return {
- strings: {},
+ strings: { default: () => [] },
};
}
static template(html, { map }) {
return ({ strings }) => {
- // In this case, "map" will fail if "strings" is not an array.
- return html`${map(strings, () => {}, string => html`${string}`)}`;
+ // In this case, "map" will fail when bound to an attribute.
+ return html``;
};
}
}
@@ -120,13 +120,13 @@ it('errors are thrown in connectedCallback when template result fails to render
class TestElement extends XElement {
static get properties() {
return {
- strings: {},
+ strings: { default: () => [] },
};
}
static template(html, { map }) {
return ({ strings }) => {
- // In this case, "map" will fail if "strings" is not an array.
- return html`${map(strings, () => {}, string => html`${string}`)}`;
+ // In this case, "map" will fail when bound to an attribute.
+ return html``;
};
}
}
diff --git a/test/test-template-engine.js b/test/test-template-engine.js
index e3f39b1..b33f27e 100644
--- a/test/test-template-engine.js
+++ b/test/test-template-engine.js
@@ -2,10 +2,13 @@ import XElement from '../x-element.js';
import { assert, describe, it } from './x-test.js';
// Long-term interface.
-const { render, html, svg, map, nullish } = XElement.templateEngine;
+const { render, html, svg, map } = XElement.templateEngine;
-// Migration-related interface. We may or may not keep these.
-const { live, unsafeHTML, unsafeSVG, ifDefined, repeat } = XElement.templateEngine;
+// Tentative interface. We may or may not keep these.
+const { live, unsafeHTML, unsafeSVG } = XElement.templateEngine;
+
+// Deprecated interface. We will eventually delete these.
+const { ifDefined, nullish, repeat } = XElement.templateEngine;
describe('html rendering', () => {
it('renders basic string', () => {
@@ -131,6 +134,31 @@ describe('html rendering', () => {
container.remove();
});
+ it('renders defined attributes', () => {
+ const getTemplate = ({ attr }) => {
+ return html``;
+ };
+ const container = document.createElement('div');
+ document.body.append(container);
+ render(container, getTemplate({ attr: 'foo' }));
+ assert(container.querySelector('#target').getAttribute('attr') === 'foo');
+ render(container, getTemplate({ attr: '' }));
+ assert(container.querySelector('#target').getAttribute('attr') === '');
+ render(container, getTemplate({ attr: 'bar' }));
+ assert(container.querySelector('#target').getAttribute('attr') === 'bar');
+ render(container, getTemplate({ attr: undefined }));
+ assert(container.querySelector('#target').getAttribute('attr') === null);
+ render(container, getTemplate({ attr: 'baz' }));
+ assert(container.querySelector('#target').getAttribute('attr') === 'baz');
+ render(container, getTemplate({ attr: null }));
+ assert(container.querySelector('#target').getAttribute('attr') === null);
+ render(container, getTemplate({ attr: false }));
+ assert(container.querySelector('#target').getAttribute('attr') === 'false');
+ render(container, getTemplate({ attr: true }));
+ assert(container.querySelector('#target').getAttribute('attr') === 'true');
+ container.remove();
+ });
+
it('renders properties', () => {
const getTemplate = ({ prop }) => {
return html`\
@@ -291,8 +319,8 @@ describe('html rendering', () => {
id="svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
- width="${ifDefined(width)}"
- height="${ifDefined(height)}">
+ ??width="${width}"
+ ??height="${height}">
`;
};
@@ -319,8 +347,8 @@ describe('html rendering', () => {
class="<><>>>"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
- width="${ifDefined(width)}"
- height="${ifDefined(height)}">
+ ??width="${width}"
+ ??height="${height}">
`;
};
@@ -340,7 +368,7 @@ describe('html rendering', () => {
it('self-closing tags work', () => {
const getTemplate = ({ type }) => {
- return html``;
+ return html``;
};
const container = document.createElement('div');
document.body.append(container);
@@ -377,10 +405,8 @@ describe('html updaters', () => {
assert(container.querySelector('#target').getAttribute('maybe') === null);
render(container, getTemplate({ maybe: false }));
assert(container.querySelector('#target').getAttribute('maybe') === 'false');
-
- // This is correct, but perhaps unexpected.
render(container, getTemplate({ maybe: null }));
- assert(container.querySelector('#target').getAttribute('maybe') === 'null');
+ assert(container.querySelector('#target').getAttribute('maybe') === null);
container.remove();
});
@@ -729,6 +755,107 @@ describe('html updaters', () => {
assert(container.querySelector('#target').children[0] !== foo);
container.remove();
});
+
+ describe('changing content updaters', () => {
+ // The template engine needs to clear content between cursors if the updater
+ // changes — it‘d be far to complex to try and allow one updater try and
+ // take over from a different one.
+ const resolve = (type, value) => {
+ switch(type) {
+ case 'map': return map(value, item => item.id, item => html``);
+ case 'unsafeHTML': return unsafeHTML(value);
+ default: return value; // E.g., an array, some text, null, undefined, etc.
+ }
+ };
+ const getTemplate = ({ type, value }) => html`
${resolve(type, value)}
`;
+ const run = (...transitions) => {
+ const container = document.createElement('div');
+ document.body.append(container);
+ for (const transition of transitions) {
+ transition(container);
+ }
+ container.remove();
+ };
+ const toUndefinedContent = container => {
+ render(container, getTemplate({ type: undefined, value: undefined }));
+ assert(!!container.querySelector('#target'));
+ assert(container.querySelector('#target').childElementCount === 0);
+ };
+ const toNullContent = container => {
+ render(container, getTemplate({ type: undefined, value: null }));
+ assert(!!container.querySelector('#target'));
+ assert(container.querySelector('#target').childElementCount === 0);
+ };
+ const toTextContent = container => {
+ render(container, getTemplate({ type: undefined, value: 'hi there' }));
+ assert(!!container.querySelector('#target'));
+ assert(container.querySelector('#target').childElementCount === 0);
+ assert(container.querySelector('#target').textContent === 'hi there');
+ };
+ const toArrayContent = container => {
+ const getArrayTemplate = ({ id }) => html``;
+ render(container, getTemplate({
+ type: undefined,
+ value: [{ id: 'moo' }, { id: 'mar' }, { id: 'maz' }].map(item => getArrayTemplate(item)),
+ }));
+ assert(!!container.querySelector('#target'));
+ assert(!!container.querySelector('#moo'));
+ assert(!!container.querySelector('#mar'));
+ assert(!!container.querySelector('#maz'));
+ assert(container.querySelector('#target').childElementCount === 3);
+ assert(container.querySelector('#target').textContent === '', container.querySelector('#target').textContent);
+ };
+ const toUnsafeHTML = container => {
+ render(container, getTemplate({ type: 'unsafeHTML', value: '' }));
+ assert(!!container.querySelector('#target'));
+ assert(!!container.querySelector('#unsafe-html'));
+ assert(container.querySelector('#target').textContent === '');
+ };
+ const toMap = container => {
+ render(container, getTemplate({ type: 'map', value: [{ id: 'foo' }, { id: 'bar' }] }));
+ assert(!!container.querySelector('#target'));
+ assert(!!container.querySelector('#foo'));
+ assert(!!container.querySelector('#bar'));
+ assert(container.querySelector('#target').childElementCount === 2);
+ assert(container.querySelector('#target').textContent === '');
+ };
+
+ it('can change from undefined content to null content', () => run(toUndefinedContent, toNullContent));
+ it('can change from undefined content to text content', () => run(toUndefinedContent, toTextContent));
+ it('can change from undefined content to array content', () => run(toUndefinedContent, toArrayContent));
+ it('can change from undefined content to map', () => run(toUndefinedContent, toMap));
+ it('can change from undefined content to unsafe html', () => run(toUndefinedContent, toUnsafeHTML));
+
+ it('can change from null content to undefined content', () => run(toNullContent, toUndefinedContent));
+ it('can change from null content to text content', () => run(toNullContent, toTextContent));
+ it('can change from null content to array content', () => run(toNullContent, toArrayContent));
+ it('can change from null content to map', () => run(toNullContent, toMap));
+ it('can change from null content to unsafe html', () => run(toNullContent, toUnsafeHTML));
+
+ it('can change from text content to undefined content', () => run(toTextContent, toUndefinedContent));
+ it('can change from text content to null content', () => run(toTextContent, toNullContent));
+ it('can change from text content to array content', () => run(toTextContent, toArrayContent));
+ it('can change from text content to map', () => run(toTextContent, toMap));
+ it('can change from text content to unsafe html', () => run(toTextContent, toUnsafeHTML));
+
+ it('can change from array content to undefined content', () => run(toArrayContent, toUndefinedContent));
+ it('can change from array content to null content', () => run(toArrayContent, toNullContent));
+ it('can change from array content to text content', () => run(toArrayContent, toTextContent));
+ it('can change from array content to map', () => run(toArrayContent, toMap));
+ it('can change from array content to unsafe html', () => run(toArrayContent, toUnsafeHTML));
+
+ it('can change from map to undefined content', () => run(toMap, toUndefinedContent));
+ it('can change from map to null content', () => run(toMap, toNullContent));
+ it('can change from map to text content', () => run(toMap, toTextContent));
+ it('can change from map to array content', () => run(toMap, toArrayContent));
+ it('can change from map to unsafe html', () => run(toMap, toUnsafeHTML));
+
+ it('can change from unsafeHtml to undefined content', () => run(toUnsafeHTML, toUndefinedContent));
+ it('can change from unsafeHtml to null content', () => run(toUnsafeHTML, toNullContent));
+ it('can change from unsafeHtml to text content', () => run(toUnsafeHTML, toTextContent));
+ it('can change from unsafeHtml to array content', () => run(toUnsafeHTML, toArrayContent));
+ it('can change from unsafeHtml to map', () => run(toUnsafeHTML, toMap));
+ });
});
describe('svg rendering', () => {
@@ -1106,6 +1233,24 @@ describe('rendering errors', () => {
container.remove();
});
+ it('throws if used on a "defined"', () => {
+ const expected = 'The live update must be used on a property, not on a defined attribute.';
+ const getTemplate = ({ maybe }) => {
+ return html``;
+ };
+ const container = document.createElement('div');
+ document.body.append(container);
+ let actual;
+ try {
+ render(container, getTemplate({ maybe: 'yes' }));
+ } catch (error) {
+ actual = error.message;
+ }
+ assert(!!actual, 'No error was thrown.');
+ assert(actual === expected, actual);
+ container.remove();
+ });
+
it('throws if used with "content"', () => {
const expected = 'The live update must be used on a property, not on content.';
const getTemplate = ({ maybe }) => {
@@ -1180,6 +1325,24 @@ describe('rendering errors', () => {
container.remove();
});
+ it('throws if used on a "defined"', () => {
+ const expected = 'The unsafeHTML update must be used on content, not on a defined attribute.';
+ const getTemplate = ({ maybe }) => {
+ return html``;
+ };
+ const container = document.createElement('div');
+ document.body.append(container);
+ let actual;
+ try {
+ render(container, getTemplate({ maybe: 'yes' }));
+ } catch (error) {
+ actual = error.message;
+ }
+ assert(!!actual, 'No error was thrown.');
+ assert(actual === expected, actual);
+ container.remove();
+ });
+
it('throws if used with a "property"', () => {
const expected = 'The unsafeHTML update must be used on content, not on a property.';
const getTemplate = ({ maybe }) => {
@@ -1274,6 +1437,24 @@ describe('rendering errors', () => {
container.remove();
});
+ it('throws if used on a "defined"', () => {
+ const expected = 'The unsafeSVG update must be used on content, not on a defined attribute.';
+ const getTemplate = ({ maybe }) => {
+ return html``;
+ };
+ const container = document.createElement('div');
+ document.body.append(container);
+ let actual;
+ try {
+ render(container, getTemplate({ maybe: 'yes' }));
+ } catch (error) {
+ actual = error.message;
+ }
+ assert(!!actual, 'No error was thrown.');
+ assert(actual === expected, actual);
+ container.remove();
+ });
+
it('throws if used with a "property"', () => {
const expected = 'The unsafeSVG update must be used on content, not on a property.';
const getTemplate = ({ maybe }) => {
@@ -1341,7 +1522,7 @@ describe('rendering errors', () => {
document.body.append(container);
let actual;
try {
- render(container, getTemplate({ maybe: 'yes' }));
+ render(container, getTemplate({ maybe: ['yes'] }));
} catch (error) {
actual = error.message;
}
@@ -1359,7 +1540,7 @@ describe('rendering errors', () => {
document.body.append(container);
let actual;
try {
- render(container, getTemplate({ maybe: 'yes' }));
+ render(container, getTemplate({ maybe: ['yes'] }));
} catch (error) {
actual = error.message;
}
@@ -1377,7 +1558,7 @@ describe('rendering errors', () => {
document.body.append(container);
let actual;
try {
- render(container, getTemplate({ maybe: 'yes' }));
+ render(container, getTemplate({ maybe: ['yes'] }));
} catch (error) {
actual = error.message;
}
@@ -1395,7 +1576,25 @@ describe('rendering errors', () => {
document.body.append(container);
let actual;
try {
- render(container, getTemplate({ maybe: 'yes' }));
+ render(container, getTemplate({ maybe: ['yes'] }));
+ } catch (error) {
+ actual = error.message;
+ }
+ assert(!!actual, 'No error was thrown.');
+ assert(actual === expected, actual);
+ container.remove();
+ });
+
+ it('throws if used on a "defined"', () => {
+ const expected = 'The map update must be used on content, not on a defined attribute.';
+ const getTemplate = ({ maybe }) => {
+ return html`