Skip to content

Commit

Permalink
Simplify x-element.
Browse files Browse the repository at this point in the history
This closes #57, closes #52, closes #46, closes #42, closes #36,
closes #31, closes #30, closes #28, closes #26, and closes #25.
  • Loading branch information
theengineear committed May 30, 2020
1 parent 21a07a5 commit 51c1dbf
Show file tree
Hide file tree
Showing 49 changed files with 2,659 additions and 2,465 deletions.
10 changes: 9 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
// We should periodically check in on the "recommended" rules we're extending
// and remove redundancies:
// https://github.com/eslint/eslint/blob/master/conf/eslint-recommended.js

/* eslint-env node */
module.exports = {
root: true,
extends: 'eslint:recommended',
rules: {
eqeqeq: 'error',
'comma-dangle': ['warn', 'always-multiline'],
'comma-dangle': ['warn', {
arrays: 'always-multiline',
objects: 'always-multiline',
imports: 'always-multiline',
exports: 'always-multiline',
functions: 'only-multiline',
}],
'no-console': 'warn',
'no-prototype-builtins': 'warn',
'no-shadow': 'warn',
Expand Down
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

# XElement

A base class for creating Custom Elements. Adapted from https://github.com/kenchris/lit-element

```
yarn install && yarn start
```
Expand Down
228 changes: 138 additions & 90 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,124 +2,172 @@

This describes the expected behavior of `x-element`.

## Mixin hierarchy
## Properties

Property definitions are essential to `x-element`. Not only do they cause
invalidation and subsequent re-rendering — they also allow for effects that take
place after property value changes (computation, reflection, and observation).

Properties are declared via a static properties getter and should be a plain
object mapping property keys (expected to be camelCase) to property definitions.
Each property key will track a related kebab-cased attribute. For example, the
`"myProp"` key has an associated `"my-prop"` attribute.

Use care not to shadow properties from `x-element` or any constructor in its
inheritance chain. E.g., you cannot use `"render"` as a property name since it
is already specified in the `x-element` interface.

Property definitions have the following options:

- `type` [Function]: used for deserialization and serialization.
- `attribute` [String]: if defined, this will be used to for sync / reflect.
- `resolver` [Function]: function to call with associated dependencies.
- `dependencies`: [StringArray]: dependencies to be passed to resolver callback.
- `reflect` [Boolean]: reflects property to attribute after property change.
- `observer` [Function]: function to call when property changes.
- `value` [Function|Any]: initial, default value for non-computed property.
- `readOnly` [Boolean]: prevent writing from normal setter?
- `internal` [Boolean]: prevent reading / writing from normal getter / setter?

Resolver callbacks are provided the values of the declared dependencies.
Observer callbacks are provided `host`, the current `value` and the `oldValue`
in that order.

### Example

```javascript
class MyElement extends XElement {
static get properties() {
return {
mood: {
type: String,
value: 'happy',
},
isHappy: {
type: Boolean,
resolver: this.resolveIsHappy,
dependencies: ['mood'],
reflect: true,
observer: this.observeIsHappy,
},
};
}

static resolveIsHappy(mood) {
if (mood === 'happy') {
return true;
}
}

static observeIsHappy(value) {
if (value !== 'happy') {
prompt('Everything ok?');
}
}

static template(html) {
return ({ mood }) => {
return html`
<style>
:host([is-happy]) {
background-color: tomato;
}
</style>
<span>I'm ${mood}.</span>
`;
};
}
}
```

## Listeners

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.
`x-element` 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.

### `element-mixin`
### Example

```javascript
class MyElement extends XElement {
static get listeners() {
return { click: this.onClick };
}

static onClick(host, event) {
/* TODO */
}
}
```

Provides base functionality for creating custom elements with shadow roots and
hooks to re-render the element.
## Manually adding listeners

### `properties-mixin`
If you need more fine-grain control over when listeners are added or removed,
you can use the `addListener` and `removeListener` functions.

Allows you to declare the `properties` block. This leverages the `element-mixin`
to observe and `invalidate` on property changes to cause a re-render. The
`properties` block allows you to declare the following via this mixin:
## Internal properties

- `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?
As an element author, you can use `.internalProperties` to get "internal"
properties and set "internal" or "readOnly" properties.

### `property-effects-mixin`
It's common to want to define and use properties as an element author without
broadcasting them as public to integrators. While you cannot make a property
truly private, appropriate context and errors are thrown to make sure that
integrators know they're doing something unsafe yet authors do not suffer from
weird ergonomics.

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:
## Render Root

- `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
By default, `x-element` 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 — examples are listed below.

### `listeners-mixin`
### No Shadow Root

Provides a declarative `listeners` block which adds bound listeners on connect
and removes them on disconnect.
```javascript
class MyElement extends XElement {
static createRenderRoot(host) {
return host;
}
}
```

### `lit-html-mixin`
### Focus Delegation

This mixin simply makes an opinion to use `lit-html` as the templating engine.
```javascript
class MyElement extends XElement {
static createRenderRoot(host) {
return host.attachShadowRoot({ mode: 'open', delegatesFocus: true });
}
}
```

## Lifecycle

### Analysis

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.
Analysis takes place once per class. This allows all future instances to share
common setup work.

### Construction

Note: work to truly cache analysis work per-class is ongoing. Right now, this
happens per instance.
Initialization takes place once per instance in the `constructor` callback.

### Initialization

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:

- handle post-definition upgrade scenario
- initialize render root
- initialize property values
- compute properties
- render
- enable property effects
- reflect properties
- observe properties
Initialization takes place once per instance on first connection.

### Update

When properties update on an initialized element, the following should occur:

- reflect property if needed
- observe property if needed
- compute dependent properties if needed, causes subsequent property changes

## Properties

The properties block allows you to define the following:

- `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
- await a queued microtask (prevents unnecessary, synchronous work)
- compute properties (this is implied and happens lazily)
- reflect properties
- render result
- observe properties

## References

- [WHATWG Custom Elements Spec](https://html.spec.whatwg.org/multipage/custom-elements.html)


## Computed properties and graphs

Consider the following properties:

```
{
a: { type: Boolean },
b: { type: Boolean, computed: 'computeB(a)' },
c: { type: Boolean, computed: 'computeC(a, b)' }
}
```

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:

```
a
↙ ↘
b → c
```

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.

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.
45 changes: 26 additions & 19 deletions demo/demo-element-attributes.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
import XElementProperties from '../x-element-properties.js';
import XElement from '../x-element.js';

class DemoAttributesElement extends XElementProperties {
static template() {
return ({ hyphenatedValue }) => `
<style>
:host {
display: block;
width: 200px;
}
const styleSheet = XElement.createStyleSheet(`
:host {
display: block;
width: 200px;
}
:host([hyphenated-value]) {
background-color: magenta;
}
:host([hyphenated-value]) {
background-color: magenta;
}
:host([boolean-value]) {
font-weight: bold;
}
</style>
<div id="demo">${hyphenatedValue}</div>
`;
}
:host([boolean-value]) {
font-weight: bold;
}
`);

class DemoAttributesElement extends XElement {
static get properties() {
return {
hyphenatedValue: {
Expand All @@ -31,6 +26,18 @@ class DemoAttributesElement extends XElementProperties {
},
};
}

static createRenderRoot(host) {
const shadowRoot = super.createRenderRoot(host);
shadowRoot.adoptedStyleSheets = [styleSheet];
return shadowRoot;
}

static template(html) {
return ({ hyphenatedValue }) => {
return html`<div id="demo">${hyphenatedValue}</div>`;
};
}
}

customElements.define('demo-element-attributes', DemoAttributesElement);
Loading

0 comments on commit 51c1dbf

Please sign in to comment.