diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d94e3d6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] diff --git a/README.md b/README.md index 809ff43..36fafdb 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ # XElement -A base class for creating Custom Elements. Adapted from https://github.com/kenchris/lit-element - ``` yarn install && yarn start ``` diff --git a/SPEC.md b/SPEC.md index 2ee7456..f985c2a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,125 +1,358 @@ -# The `x-element` spec +[![Build Status](https://travis-ci.com/Netflix/x-element.svg?branch=master)](https://travis-ci.com/Netflix/x-element) -This describes the expected behavior of `x-element`. +# XElement -## Mixin hierarchy +A base class for custom elements. -The `x-element` code is organized in a set of progressively-enhancing mixins -which are intended to be used in order. You can only omit mixins from the tail, -not from the middle/head. The intent is to force the correct base hooks so that -custom extension is supported. +Define and register your element: -### `element-mixin` +```javascript +class HelloWorld extends XElement { + template(html) { + return () => html`Hello World!`; + } +} -Provides base functionality for creating custom elements with shadow roots and -hooks to re-render the element. +customElements.define('hello-world', HelloWorld); +``` -### `properties-mixin` +And use it in your markup: + +```html + + +
+ +Math.hypot(${base}, ${height}) = ${hypotenuse}
+ `;
+ }
+}
+```
-### `property-effects-mixin`
+## Reflected properties
+
+In general, attribute changes are synced to properties. However, you can also
+ensure that property changes reflect to attributes by setting `reflect: true`.
+
+## Read-only and internal properties
+
+Sometimes you want a property to be part of the public interface (either for
+attribute or property introspection), but you want to manage the value of that
+property internally. You can set `readOnly: true` to achieve this. Such a
+property can only be written to using `host.internal`.
+
+Other times, you don't want a property to be part of the public interface
+(common for resolved properties). You can set `internal: true` to achieve this.
+Such a property can only be read from or written to using `host.internal`.
+
+### Example
+
+```javascript
+class MyElement extends XElement {
+ static get properties() {
+ return {
+ date: {
+ type: String,
+ readOnly: true,
+ value: () => new Date().toISOString(),
+ reflect: true,
+ },
+ interval: {
+ internal: true,
+ },
+ };
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ clearInterval(this.internal.interval);
+ this.internal.interval = setInterval(() => this.tick(), 100);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ clearInterval(this.internal.interval);
+ }
+
+ tick() {
+ this.internal.date = new Date().toISOString();
+ }
+
+ static template(html) {
+ return ({ date }) => html`${date}`;
+ }
+}
+```
-Enhances the interface to properties block to handle _property effects_. I.e.,
-effects that can take place whenever a property updates. This adds the following
-configuration to the properties block:
+## Resolved properties
-- `reflect` [Boolean]: reflect property to attribute?
-- `observer` [String]: DSL used to resolve an observer callback
-- `computed` [String]: DSL used to resolve computed callback and dependencies
+Create a resolved property by providing a list of dependencies and a resolver.
+XElement will manage the lifecycle of property resolution when dependencies are
+updated.
-### `listeners-mixin`
+Resolvers will be called with the current values of the declared dependencies —
+in the order given. Because resolvers are not called unless input arguments
+change, resolvers should be pure functions.
-Provides a declarative `listeners` block which adds bound listeners on connect
-and removes them on disconnect.
+If the given function is defined on the constructor, the context (`this`) is
+guaranteed to be the constructor when called later.
-### `lit-html-mixin`
+## Observed properties
-This mixin simply makes an opinion to use `lit-html` as the templating engine.
+Create an observed property by providing an observer function. Whenever the
+property changes, the given observer will be called. The observer will be called
+with `host, value, oldValue`
-## Lifecycle
+If the given function is defined on the constructor, the context (`this`) is
+guaranteed to be the constructor when called later.
-### Analysis
+## Listeners
-Analysis should take place once per class on first construction. This allows all
-future instances to share common setup work. The result of the analysis phase is
-made available again during initialization.
+XElement supports declarative, delegated event handlers via a `listeners`
+block. Listeners are added during the `connectedCallback` and are removed during
+the `disconnectedCallback` in the element's lifecycle. By default, listeners are
+added to the render root.
-Note: work to truly cache analysis work per-class is ongoing. Right now, this
-happens per instance.
+If the given function is defined on the constructor, the context (`this`) is
+guaranteed to be the constructor when called later. The provided arguments will
+be `host, event`.
-### Initialization
+### Example
-Initialization should take place once per instance on first connection. This
-allows each class to leverage cached information in the analysis phase and
-leverage initialization work through disconnection and reconnection to the DOM.
-Initialization should work in the following order:
+```javascript
+class MyElement extends XElement {
+ static get properties() {
+ return {
+ clicks: {
+ type: Number,
+ readOnly: true,
+ value: 0,
+ },
+ };
+ }
-- handle post-definition upgrade scenario
-- initialize render root
-- initialize property values
-- compute properties
-- render
-- enable property effects
-- reflect properties
-- observe properties
+ static get listeners() {
+ return { click: this.onClick };
+ }
-### Update
+ static onClick(host, event) {
+ host.internal.clicks++;
+ }
-When properties update on an initialized element, the following should occur:
+ static template(html) {
+ return ({ clicks }) => {
+ return html`Clicks: ${clicks}`;
+ }
+ }
+}
+```
-- reflect property if needed
-- observe property if needed
-- compute dependent properties if needed, causes subsequent property changes
+## Manually adding listeners
+
+If you need more fine-grain control over when listeners are added or removed,
+you can use the `listen` and `unlisten` functions.
+
+If the given function is defined on the constructor, the context (`this`) is
+guaranteed to be the constructor when called later. The provided arguments will
+be `host, event`.
+
+If the given function is defined on the instance, the context (`this`) is
+guaranteed to be the instance when called later. In this case, the only argument
+provided will be the `event`.
+
+### Example
+
+```javascript
+class MyElement extends XElement {
+ static get properties() {
+ return {
+ clicks: {
+ type: Number,
+ readOnly: true,
+ value: 0,
+ },
+ };
+ }
+
+ static onClick(host, event) {
+ host.internal.clicks++;
+ }
+
+ onClick(event) {
+ this.internal.clicks++;
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.listen(this.shadowRoot, 'click', this.onClick);
+ this.listen(this.shadowRoot, 'click', this.constructor.onClick);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.unlisten(this.shadowRoot, 'click', this.onClick);
+ this.unlisten(this.shadowRoot, 'click', this.constructor.onClick);
+ }
+
+ static template(html) {
+ return ({ clicks }) => {
+ return html`Clicks: ${clicks}`;
+ }
+ }
+}
+```
-## Properties
+## Render Root
-The properties block allows you to define the following:
+By default, XElement will create an open shadow root. However, you can change
+this behavior by overriding the `createRenderRoot` method. There are a few
+reasons why you might want to do this as shown below.
-- `type` [Function]: type associated with the property
-- `value` [Funciton|Any Literal]: _initial_ value for the property or getter
-- `readOnly` [Boolean]: prevent property updates via normal setter?
-- `reflect` [Boolean]: reflect property to attribute?
-- `observer` [String]: DSL used to resolve an observer callback
-- `computed` [String]: DSL used to resolve computed callback and dependencies
+### No Shadow Root
-## References
+```javascript
+class MyElement extends XElement {
+ static createRenderRoot(host) {
+ return host;
+ }
+}
+```
-- [WHATWG Custom Elements Spec](https://html.spec.whatwg.org/multipage/custom-elements.html)
+### Focus Delegation
+```javascript
+class MyElement extends XElement {
+ static createRenderRoot(host) {
+ return host.attachShadowRoot({ mode: 'open', delegatesFocus: true });
+ }
+}
+```
-## Computed properties and graphs
+## Lifecycle
-Consider the following properties:
+### Constructor Analysis
-```
-{
- a: { type: Boolean },
- b: { type: Boolean, computed: 'computeB(a)' },
- c: { type: Boolean, computed: 'computeC(a, b)' }
-}
-```
+Analysis takes place once per class. This allows all future instances to share
+common setup work. Halting errors are thrown here to assist in development.
-This properties block declares that `c` depends on `a` and `b` and that `b`
-depends on `a`. However, the _order_ in which we resolve `b` and `c` when `a`
-changes is important. In general, computed properties form a Directed, Acyclic
-Graph (DAG). The DAG looks like this:
+### Instance Construction
-```
- a
- ↙ ↘
-b → c
+Each instance undergoes one-time setup work in the `constructor` callback.
+
+### Instance Initialization
+
+Each instance is initialized once upon first connection.
+
+### Update
+
+When properties update on an initialized element, the following should occur:
+
+- await a queued microtask (prevents unnecessary, synchronous work)
+- compute properties (this is implied and happens lazily)
+- reflect properties
+- render result
+- observe properties
+
+## Recipes
+
+### Observing multiple properties
+
+In certain cases, you may want to observe multiple properties at once. One way
+to achieve this is to resolve the properties into a new object and observe that
+object.
+
+```javascript
+class MyElement extends XElement {
+ static get properties() {
+ return {
+ tag: {
+ type: String,
+ value: 'div',
+ },
+ text: {
+ type: String,
+ },
+ element: {
+ type: HTMLElement,
+ internal: true,
+ dependencies: ['tag'],
+ resolver: tag => document.createElement(tag),
+ observer: (host, element) => {
+ const container = host.shadowRoot.getElementById('container');
+ container.innerHTML = '';
+ container.append(element);
+ }
+ },
+ update: {
+ type: Object,
+ internal: true,
+ dependencies: ['element', 'text'],
+ resolver: (element, text) => ({ element, text }),
+ observer: (host, { element, text }) => {
+ element.textContent = text;
+ },
+ },
+ };
+ }
+
+ static template(html) {
+ return () => {
+ return html``;
+ }
+ }
+}
```
-DAGs can be solved using a topological sorting algorithm and this computation
-can be done at analysis-time to prevent repeating expensive work at runtime.
+## References
-Note that DAGs can have multiple solutions. For completeness, the solution for
-this DAG is `[a, b, c]`. This means that if `a` changes, you need to then update
-`b` and then update `c`--in that order.
+- [WHATWG Custom Elements Spec](https://html.spec.whatwg.org/multipage/custom-elements.html)
diff --git a/demo/demo-element-attributes.js b/demo/demo-element-attributes.js
index 506a52d..ce7d256 100644
--- a/demo/demo-element-attributes.js
+++ b/demo/demo-element-attributes.js
@@ -1,26 +1,6 @@
-import XElementProperties from '../x-element-properties.js';
-
-class DemoAttributesElement extends XElementProperties {
- static template() {
- return ({ hyphenatedValue }) => `
-
- ${hyphenatedValue}
- `;
- }
+import XElement from '../x-element.js';
+class DemoAttributesElement extends XElement {
static get properties() {
return {
hyphenatedValue: {
@@ -31,6 +11,28 @@ class DemoAttributesElement extends XElementProperties {
},
};
}
+
+ static template(html) {
+ return ({ hyphenatedValue }) => {
+ return html`
+
+ ${hyphenatedValue}
+ `;
+ };
+ }
}
customElements.define('demo-element-attributes', DemoAttributesElement);
diff --git a/demo/demo-element-properties.js b/demo/demo-element-properties.js
index 7931b38..436642b 100644
--- a/demo/demo-element-properties.js
+++ b/demo/demo-element-properties.js
@@ -1,26 +1,6 @@
-import XElementProperties from '../x-element-properties.js';
-
-class DemoPropertiesElement extends XElementProperties {
- static template() {
- return ({ reflected }) => `
-
- ${reflected}
- `;
- }
+import XElement from '../x-element.js';
+class DemoPropertiesElement extends XElement {
static get properties() {
return {
reflected: {
@@ -34,6 +14,28 @@ class DemoPropertiesElement extends XElementProperties {
},
};
}
+
+ static template(html) {
+ return ({ reflected }) => {
+ return html`
+
+ ${reflected}
+ `;
+ };
+ }
}
customElements.define('demo-element-properties', DemoPropertiesElement);
diff --git a/demo/demo-element.js b/demo/demo-element.js
index 8c37f3c..2a1667b 100644
--- a/demo/demo-element.js
+++ b/demo/demo-element.js
@@ -1,57 +1,40 @@
-import XElementBasic from '../x-element-basic.js';
-
-class DemoElement extends XElementBasic {
- static template() {
- return ({ reflected }) => `
-
- ${reflected}
- `;
- }
-
- static get observedAttributes() {
- return ['reflected', 'booleanValue'];
- }
-
- set reflected(value) {
- if (value) {
- this.setAttribute('reflected', value);
- } else {
- this.removeAttribute('reflected');
- }
- }
-
- get reflected() {
- return this.getAttribute('reflected');
- }
-
- set booleanValue(value) {
- if (value) {
- this.setAttribute('boolean-value', '');
- } else {
- this.removeAttribute('boolean-value');
- }
- }
-
- get booleanValue() {
- return this.getAttribute('boolean-value');
+import XElement from '../x-element.js';
+
+class DemoElement extends XElement {
+ static get properties() {
+ return {
+ reflected: {
+ type: String,
+ reflect: true,
+ },
+ booleanValue: {
+ type: Boolean,
+ reflect: true,
+ value: true,
+ },
+ };
}
- connectedCallback() {
- super.connectedCallback();
- this.booleanValue = true;
+ static template(html) {
+ return ({ reflected }) => {
+ return html`
+
+ ${reflected}
+ `;
+ };
}
}
diff --git a/demo/index.html b/demo/index.html
index 27f50a2..db5cb02 100644
--- a/demo/index.html
+++ b/demo/index.html
@@ -5,11 +5,13 @@
+
+