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 Jun 10, 2020
1 parent 77a31c5 commit ad66821
Show file tree
Hide file tree
Showing 24 changed files with 1,662 additions and 762 deletions.
277 changes: 213 additions & 64 deletions SPEC.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[![Build Status](https://travis-ci.com/Netflix/x-element.svg?branch=master)](https://travis-ci.com/Netflix/x-element)

# `x-element`
# XElement

A base class for custom elements.

Expand All @@ -23,7 +23,7 @@ And use it in your markup:
<html>
<head>
<meta charset="UTF-8">
<title>Hello World.</title>
<title>Hello World</title>
<script type="module" src="./hello-world.js"></script>
</head>
<body>
Expand All @@ -34,99 +34,189 @@ And use it in your markup:

## 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).
Property definitions are essential to XElement. Properties and their
corresponding attributes are watched. Changes enqueue a render and can also
trigger effects (resolution, 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.
Be mindful of shadowing when naming properties. For example, using the `title`
property will cause a native tooltip to appear on hover since it's part of the
standard `HTMLElement` 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.
- `type` [Function]: associate properties with types.
- `attribute` [String]: override default attribute for properties.
- `dependencies`: [StringArray]: declare dependencies of resolved properties.
- `resolver` [Function]: resolve properties when other properties change.
- `reflect` [Boolean]: reflect properties to attributes.
- `observer` [Function]: watch changes to properties.
- `value` [Function|Any]: provide initial, default values for properties.
- `readOnly` [Boolean]: prevent setting properties on the host.
- `internal` [Boolean]: prevent getting / setting properties on the host.

### Example

```javascript
class MyElement extends XElement {
static get properties() {
return {
mood: {
type: String,
value: 'happy',
a: {
type: Number,
value: 3,
},
b: {
type: Number,
value: 4,
},
isHappy: {
c: {
type: Number,
dependencies: ['a', 'b'],
resolver: (a, b) => Math.sqrt(a**2 + b**2),
},
valid: {
type: Boolean,
resolver: this.resolveIsHappy,
dependencies: ['mood'],
dependencies: ['c'],
resolver: c => !!c,
reflect: true,
observer: this.observeIsHappy,
},
};
}

static resolveIsHappy(mood) {
if (mood === 'happy') {
return true;
}
static template(html) {
return ({ a, b, c }) => html`
<style>
:host {
display: block;
}
:host(:not([valid])) {
display: none;
}
</style>
<code>Math.sqrt(${a}**2 + ${b}**2) = ${c}<code>
`;
}
}
```

static observeIsHappy(host, value, oldValue) {
if (value !== 'happy') {
prompt('Everything ok?');
}
## 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 ({ mood }) => {
return html`
<style>
:host([is-happy]) {
background-color: tomato;
}
</style>
<span>I'm ${mood}.</span>
`;
};
return ({ date }) => html`<span>${date}<span>`;
}
}
```

## Resolved properties

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.

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.

If the given function is defined on the constructor, the context (`this`) is
guaranteed to be the constructor when called later.

## Observed properties

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`

If the given function is defined on the constructor, the context (`this`) is
guaranteed to be the constructor when called later.

## Listeners

`x-element` supports declarative, delegated event handlers via a `listeners`
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.

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

### Example

```javascript
class MyElement extends XElement {
static get properties() {
return {
clicks: {
type: Number,
readOnly: true,
value: 0,
},
};
}

static get listeners() {
return { click: this.onClick };
}

static onClick(host, event) {
/* TODO */
host.internal.clicks++;
}

static template(html) {
return ({ clicks }) => {
return html`<span>Clicks: ${clicks}</span>`;
}
}
}
```
Expand All @@ -136,22 +226,61 @@ class MyElement extends XElement {
If you need more fine-grain control over when listeners are added or removed,
you can use the `addListener` and `removeListener` functions.

## Internal properties
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.addListener(this.shadowRoot, 'click', this.onClick);
this.addListener(this.shadowRoot, 'click', this.constructor.onClick);
}

As an element author, you can use `.internalProperties` to get "internal"
properties and set "internal" or "readOnly" properties.
disconnectedCallback() {
super.disconnectedCallback();
this.removeListener(this.shadowRoot, 'click', this.onClick);
this.removeListener(this.shadowRoot, 'click', this.constructor.onClick);
}

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.
static template(html) {
return ({ clicks }) => {
return html`<span>Clicks: ${clicks}</span>`;
}
}
}
```

## Render Root

By default, `x-element` will create an open shadow root. However, you can change
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 — examples are listed below.
reasons why you might want to do this as shown below.

### No Shadow Root

Expand All @@ -173,20 +302,40 @@ class MyElement extends XElement {
}
```

### Adopted stylesheets

```javascript
const styleSheet = XElement.createStyleSheet(`
:host {
display: block;
background-color: tomato;
width: 50px;
height: 50px;
}
`);

class MyElement extends XElement {
static createRenderRoot(host) {
const shadowRoot = super.createRenderRoot(host);
shadowRoot.adoptedStyleSheets = [styleSheet];
}
}
```

## Lifecycle

### Analysis
### Constructor Analysis

Analysis takes place once per class. This allows all future instances to share
common setup work.
common setup work. Halting errors are thrown here to assist in development.

### Construction
### Instance Construction

Initialization takes place once per instance in the `constructor` callback.
Each instance undergoes one-time setup work in the `constructor` callback.

### Initialization
### Instance Initialization

Initialization takes place once per instance on first connection.
Each instance is initialized once upon first connection.

### Update

Expand Down
9 changes: 9 additions & 0 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,20 @@
<script type="module" src="demo-element.js"></script>
<script type="module" src="demo-element-properties.js"></script>
<script type="module" src="demo-element-attributes.js"></script>
<script type="module" src="mouse-ghost.js"></script>
<script type="module" src="right-triangle.js"></script>
</head>
<body>
<demo-element></demo-element>
<demo-element-properties></demo-element-properties>
<demo-element-attributes hyphenated-value="ok" boolean-value></demo-element-attributes>
<right-triangle></right-triangle>
<mouse-ghost></mouse-ghost>
<mouse-ghost></mouse-ghost>
<mouse-ghost></mouse-ghost>
<mouse-ghost></mouse-ghost>
<mouse-ghost></mouse-ghost>
<mouse-ghost></mouse-ghost>
<script src="index.js"></script>
</body>
</html>
Loading

0 comments on commit ad66821

Please sign in to comment.