diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1877ad..b1d7ca8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,42 +8,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
-- You can now bind attributes with `??foo="${bar}"` syntax. This is functionally
- equivalent to the `nullish` updater and will replace that functionality later.
-- A new `unsafe` updater was added to replace `unsafeHTML` and `unsafeSVG`. You
- use it like `unsafe(value, 'html')` and `unsafe(value, 'svg')`.
+- You can now bind attributes with `??foo="${bar}"` syntax in the default
+ template engine. This is functionally equivalent to the `nullish` updater from
+ the default template engine and will replace that functionality later (#204).
### Changed
- Template errors now include approximate line numbers from the offending
- template. They also print the registered custom element tag name (#201).
+ template in the default template engine. 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.
-- Interpolation of `textarea` is stricter. This used to be handled with some
- leniency — ``. Now, you have to fit the
- interpolation exactly — ``.
+ `undefined` in the default template engine. This makes it behave identically
+ to `nullish` in the default template engine. However, both updaters are
+ deprecated — the `??attr` binding should be used instead when using the
+ default template engine (#204).
+- Interpolation of `textarea` is more strict in the default template engine.
+ This used to be handled with some leniency for newlines in templates —
+ ``. Now, you have to interpolate exactly —
+ `` (#219).
+- You may now bind values of type `DocumentFragment` within the template engine.
+ In particular, this was added to enable advanced flows without needing to
+ bloat the default template engine interface (#207, #216).
### Deprecated
- The `ifDefined` and `nullish` updaters are deprecated, update templates to use
- syntax like `??foo="${bar}"`.
-- The `repeat` updater is deprecated, use `map` instead.
-- The `unsafeHTML` and `unsafeSVG` updaters are deprecated, use `unsafe`.
+ syntax like `??foo="${bar}"` (#204).
+- The `repeat` updater is deprecated, use `map` instead (#204).
+- The `unsafeHTML` and `unsafeSVG` updaters are deprecated, bind a
+ `DocumentFragment` value instead (#207, #216).
- The `plaintext` tag is no longer handled. This is a deprecated html tag which
- required special handling… but it’s unlikely that anyone is using that.
+ required special handling… but it’s unlikely that anyone is using that (#220).
+- The `live` updater is deprecated. Use a delegated event listener for the
+ `change` event if you need tight control over DOM state in forms (#208).
### 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 `unsafe` back to `null`.
-- The `map` updater throws immediately when given non-array input. Previously,
- it only threw _just before_ it was bound as content.
-- Dummy content cursor is no longer appended to end of template. This was an
- innocuous off-by-one error when creating instrumented html from the tagged
- template strings.
+- Transitions from different content values should all now work for the default
+ template engine. 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 `unsafe` back to `null` (#223).
+- The `map` updater throws immediately when given non-array input for the
+ default template engine. Previously, it only threw when it was bound (#222).
+- The `map` updater throws if the return value from the provided `identify`
+ callback returns a duplicate value (#218).
+- Dummy content cursor is no longer appended to end of template for the default
+ template engine. This was an innocuous off-by-one error when creating
+ instrumented html from the tagged template strings (#221).
## [1.1.1] - 2024-11-09
diff --git a/doc/RECIPES.md b/doc/RECIPES.md
new file mode 100644
index 0000000..739651a
--- /dev/null
+++ b/doc/RECIPES.md
@@ -0,0 +1,103 @@
+# Recipes
+
+Part of the [philosophy](../README.md#project-philosophy) for `x-element` is to
+implement only a minimal set of functionality. Rather than build a bespoke
+feature to cover each-and-every use case — we simply document how to achieve
+some desired outcomes via “recipes” for less common situations.
+
+## How do I instantiate trusted markup?
+
+In certain, _rare_ occasions, it’s acceptable to instantiate a pre-defined
+markup string as DOM using `innerHTML`. Rather than supply some sort of special
+function (e.g., `carefulWhatYouAreDoingIsUnsafe`), we trust that authors will
+understand the hazards of `innerHTML` and will use with care. The basic pattern
+here is to instantiate your markup with a `` and then pass its inner
+`.content` (a `DocumentFragment`) into the template engine.
+
+```js
+class MyElement extends XElement {
+ static get properties() {
+ return {
+ // …
+ markup: {
+ type: String,
+ input: [/* … */],
+ compute: (/* … */) => {/* sanitize / purify / careful out there! */},
+ },
+ fragment: {
+ type: DocumentFragment,
+ input: ['markup'],
+ compute: (markup) => {
+ if (markup) {
+ const template = document.createElement('template');
+ template.innerHTML = markup;
+ return template.content;
+ }
+ },
+ },
+ };
+ }
+ static template(html) {
+ return ({ fragment }) => {
+ return html`
+
+
The following is injected…
+ ${fragment}
+
+ `;
+ };
+ }
+}
+```
+
+## How do I force application state to flow the way I want in forms?
+
+A common pain point when building forms is managing the _flow of data_. Does the
+model act as the source of truth? Or, does the DOM? Well, that’s up to you! If
+you _are_ trying to control forms strictly from some application state, you will
+need to make sure that (1) your change events propagate the right information,
+(2) your state is guaranteed to flow back to your view, and (3) your DOM state
+is correct by the time a potential form submission occurs (e.g., a submit event
+can follow _directly_ behind a change event in certain situations). It’s not
+possible to predict how authors wish to manage such cases — so it’s not possible
+to encode this at a library level. Here’s one way you might go about managing
+this though!
+
+```js
+class MyElement extends XElement {
+ static get properties() {
+ return {
+ // …
+ foo: {
+ type: String, // You probably want this to be a string for proper comparisons.
+ },
+ };
+ }
+ static get listeners() {
+ return {
+ change: (host, event) => this.onChange(host, event);
+ };
+ }
+ static template(html, { connected }) {
+ return ({ foo }) => {
+ return html`
+
+ `;
+ };
+ }
+ static onChange(host, event) {
+ if (event.target.id === 'foo') {
+ // The user has updated the input value. Wait for the next animation
+ // frame and re-bind our value. Note that even in this case, if a submit
+ // follows directly behind a change event — the DOM would still contain
+ // possibly-stale state.
+ requestAnimationFrame(() => {
+ const foo = host.shadowRoot.getElementById('foo');
+ foo.value = host.foo;
+ });
+ }
+ }
+}
+```
diff --git a/doc/TEMPLATES.md b/doc/TEMPLATES.md
index 67fab91..60d97f7 100644
--- a/doc/TEMPLATES.md
+++ b/doc/TEMPLATES.md
@@ -21,49 +21,19 @@ static template(html, { map }) {
}
```
-The following binding types are supported:
-
-| Type | Example |
-| :------------------ | :----------------------------------------- |
-| attribute | `` |
-| attribute (boolean) | `` |
-| attribute (defined) | `` |
-| property | `` |
-| content | `${foo}` |
-
-Emulates:
-
-```javascript
-const el = document.createElement('div');
-el.attachShadow({ mode: 'open' });
-el.innerHTML = '';
-const target = el.shadowRoot.getElementById('target');
-
-// attribute value bindings set the attribute value
-target.setAttribute('foo', bar);
-
-// 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
-target.foo = bar;
-
-// content bindings create text nodes for basic content
-const text = document.createTextNode('');
-text.textContent = foo;
-target.append(text);
-
-// content bindings append a child for singular, nested content
-target.append(foo);
-
-// content binding maps and appends children for arrays of nested content
-target.append(...foo);
-```
+The following bindings are supported:
+
+| Binding | Template | Emulates |
+| :------------------ | :--------------------------- | :------------------------------------------------------------ |
+| -- | -- | `const el = document.createElement('div');` |
+| attribute | `` | `el.setAttribute('foo', bar);` |
+| attribute (boolean) | `` | `el.setAttribute('foo', ''); // if “bar” is truthy` |
+| -- | -- | `el.removeAttribute('foo'); // if “bar” is falsy` |
+| attribute (defined) | `` | `el.setAttribute('foo', bar); // if “bar” is non-nullish` |
+| -- | -- | `el.removeAttribute('foo'); // if “bar” is nullish` |
+| property | `` | `el.foo = bar;` |
+| content | `
${foo}
` | `el.append(document.createTextNode(foo)) // if “bar” is text` |
+| -- | -- | (see [content binding](#content-binding) for composition) |
**Important note on serialization during data binding:**
@@ -81,8 +51,6 @@ The following template languages are supported:
The following value updaters are supported:
* `map` (can be used with content bindings)
-* `unsafe` (can be used with content bindings)
-* `live` (can be used with property bindings)
**A note on non-primitive data:**
@@ -216,23 +184,6 @@ html``;
// el.foo = bar;
```
-#### The `live` property binding
-
-You can wrap the property 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 instead
-check if `value === el.value` whenever a `render` is kicked off.
-
### Content binding
The content binding does different things based on the value type passed in.
@@ -324,35 +275,6 @@ html`
${bar}
`;
//
♥1…♣A
```
-#### The `unsafe` content binding
-
-The `unsafe` content binding allows you to parse / instantiate text from a
-trusted source. This should _only_ be used to inject trusted content — never
-user content.
-
-```js
-const bar = '';
-html`
${unsafe(bar, 'html')}
`;
-//
-// console.prompt('can you hear me now?');
-
-const bar = '';
-html`
-
-`;
-//
-//
-//
-```
-
## Customizing your base class
Following is a working example using [lit-html](https://lit.dev):
diff --git a/test/test-template-engine.js b/test/test-template-engine.js
index 2d89ff4..6becbfc 100644
--- a/test/test-template-engine.js
+++ b/test/test-template-engine.js
@@ -2,13 +2,30 @@ import XElement from '../x-element.js';
import { assert, describe, it } from './x-test.js';
// Long-term interface.
-const { render, html, svg, map, unsafe } = XElement.templateEngine;
-
-// Tentative interface. We may or may not keep these.
-const { live } = XElement.templateEngine;
+const { render, html, svg, map } = XElement.templateEngine;
// Deprecated interface. We will eventually delete these.
-const { ifDefined, nullish, repeat, unsafeHTML, unsafeSVG } = XElement.templateEngine;
+const { ifDefined, nullish, repeat, live, unsafeHTML, unsafeSVG } = XElement.templateEngine;
+
+// Overwrite console warn for testing so we don’t get spammed with our own
+// deprecation warnings.
+const seen = new Set();
+const warn = console.warn; // eslint-disable-line no-console
+const localMessages = [
+ 'Deprecated "ifDefined" from default templating engine interface.',
+ 'Deprecated "nullish" from default templating engine interface.',
+ 'Deprecated "live" from default templating engine interface.',
+ 'Deprecated "unsafeHTML" from default templating engine interface.',
+ 'Deprecated "unsafeSVG" from default templating engine interface.',
+ 'Deprecated "repeat" from default templating engine interface.',
+];
+console.warn = (...args) => { // eslint-disable-line no-console
+ if (!localMessages.includes(args[0]?.message)) {
+ warn(...args);
+ } else {
+ seen.add(args[0].message);
+ }
+};
describe('html rendering', () => {
it('renders basic string', () => {
@@ -505,6 +522,24 @@ describe('html rendering', () => {
assert(container.textContent === '[object HTMLInputElement]');
container.remove();
});
+
+ it('renders DocumentFragment nodes with simple append action', () => {
+ const getTemplate = ({ fragment }) => {
+ return html`${fragment}`;
+ };
+ const container = document.createElement('div');
+ document.body.append(container);
+ const template = document.createElement('template');
+ template.innerHTML = '';
+ render(container, getTemplate({ fragment: template.content.cloneNode(true) }));
+ assert(container.childElementCount === 1);
+ assert(container.children[0].localName === 'input');
+ template.innerHTML = '';
+ render(container, getTemplate({ fragment: template.content.cloneNode(true) }));
+ assert(container.childElementCount === 1);
+ assert(container.children[0].localName === 'textarea');
+ container.remove();
+ });
});
describe('html updaters', () => {
@@ -562,19 +597,6 @@ describe('html updaters', () => {
container.remove();
});
- it('unsafe html', () => {
- const getTemplate = ({ content }) => {
- return html`