Skip to content

dy/define-element

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

62 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

define-element (proposal)

<define-element> - custom element to declare custom elements. (Similar to <defs> in SVG). Compilation of existing proposals / prototypes.

<define-element>
  <x-time>
    <template>{{ time.toLocaleTimeString() }}</template>

    <script scoped>
      let id
      this.onconnected = () => id = setInterval(() => this.field.time = new Date(), 1000)
      this.ondisconnected = () => clearInterval(id)
    </script>

    <style scoped>
      :host { font-family: monospace; }
    </style>
  </x-time>
</define-element>

<x-time></x-time>

Contents

Element Definition

Element is defined by-example (similar to <defs> in SVG) and may contain <template>, <style> and <script> sections.

<define-element>
  <my-element prop:type="default">
    <template>
      {{ content }}
    </template>
    <style></style>
    <script></script>
  </my-element>

  <another-element>...</another-element>
</define-element>

<my-element></my-element>

Instances of <element-name> automatically receive defined attributes and content.

If <template> section isn't defined, the instance content preserved as is.

Why?

Template-instantiation proposal naturally accomodates for template fields/parts, making it work outside of <template> tag would encounter certain issues: parsing table, SVG attributes, liquid syntax conflict etc.

Single <define-element> can define multiple custom elements.

Property types

Props with optional types are defined declaratively as custom element attributes:

<define-element>
  <x-x count:number="0" flag:boolean text:string time:date value="default">
    <template>{{ count }}</template>
    <script scoped>
      console.log(this.props.count) // 0
      this.props.count++
      console.log(this.props.count) // 1
    </script>
  </x-x>
</define-element>

Available types are any primitives (attributes are case-agnostic):

  • :string for String
  • :boolean for Boolean
  • :number for Number
  • :date for Date
  • :array for Array
  • :object for Object
  • no type for automatic detection

Props values are available under element.props. Changing any of element.props.* is reflected in attributes.

See Element Properties proposal, attr-types, element-props.

Template

<template> supports template parts with expressions:

<define-element>
  <my-element>
    <template>
      <h1>{{ user.name }}</h1>Email: <a href="mailto:{{ user.email }}">{{ user.email }}</a>
    </template>
    <script scoped>
      this.field.user = { name: 'George Harisson', email: 'george@harisson.om' }
    </script>
  </my-element>
</define-element>

Template part values are available as element.field object. Changing any of the field.* automatically rerenders the template.

A field can potentially support reactive types as well: Promise/Thenable, Observable/Subject, AsyncIterable etc. In that case update happens by changing the reactive state:

<template>{{ count }}</template>
<script scoped>
  this.field.count = asyncIterator
</script>

See template-parts, template-expressions – polyfills for Template-Parts proposal.

Expressions

Syntax is JS subset:

Part Expression Accessible as
Value {{ foo }} field.foo
Property {{ foo.bar?.baz }}, {{ foo["bar"] }} field.foo.bar
Function call {{ foo(bar) }} field.foo, field.bar
Method call {{ foo.bar() }} field.foo.bar
Boolean operators {{ !foo && bar || baz }} field.foo, field.bar, field.baz
Ternary {{ foo ? bar : baz }} field.foo, field.bar, field.baz
Primitives {{ "foo" }}, {{ true }}, {{ 0.1 }}
Comparison {{ foo == 1 }}, {{ bar > foo }} field.foo, field.bar
Math {{ a * 2 + b / 3 }} field.a, field.b
Loop {{ item, idx in list }} field.list
Spread {{ ...foo }} field.foo

Loops

Organized via foreach directive:

<define-element>
  <ul is="my-list">
    <template>
      <template directive="foreach" expression="item, index in items"><li id="item-{{ index }}">{{ item.text }}</li></template>
    </template>
    <script scoped>
      this.field.items = [1,2,3]
    </script>
  </ul>
</define-element>

<ul is="my-list"></ul>

Conditions

Organized via if directive or ternary operator.

For text variants ternary operator is shorter:

<span>Status: {{ status === 0 ? 'Active' : 'Inactive' }}</span>

To optionally display an element, use if-else if-else directives:

<template directive="if" expression="status === 0">Inactive</template>
<template directive="else if" expression="status === 1">Active</template>
<template directive="else">Finished</template>

Shadowmode

Can be defined via shadowrootmode property:

<my-element>
  <template shadowrootmode="closed"><template>
</my-element>
<my-element>
  <template shadowrootmode="open"><template>
</my-element>

See declarative-shadow-dom.

Slots

Slots allow injecting content into instances aside from attributes.

<define-element>
  <my-element>
    <template>
      <h1><slot name="title"></slot></h1>
      <p><slot name="content">{{ children }}</slot></p>
    </template>
  </my-element>
</define-element>

<my-element>
  <span slot="title">Hello World</span>
  <span slot="content">Our adventure has begun</span>
</my-element>

Script

There are two possible ways to attach scripts to the defined element.

First is via scoped script attribute. That enables script to run with this defined as element instance, instead of window. Also, it automatically exposes internal element references as parts.

Script runs in connectedCallback with children and properties parsed and present on the element.

<define-element>
  <main-header text:string>
    <template>
      <h1 part="header">{{ content }}</h1>
    </template>
    <script scoped>
      this // my-element
      this.part.header // h1
      this.field.content = this.prop.text
    </script>
  </main-header>
</define-element>

See scoped proposal discussions: 1, 2 and <script scoped> polyfill implementation.

Second method is via custom element constructor, as proposed in declarative custom elements. It provides more granular control over constructor, callbacks and attributes. At the same time, it would require manual control over children, props and reactivity.

<define-element>
  <my-element>
    <template></template>
    <script type="module">
      export default class MyCustomElement extends HTMLElement {
        constructor() {
          super()
        }
        connectedCallback() {}
        disconnectedCallback() {}
      }
    </script>
  </my-element>
</define-element>

Style

Styles can be defined either globally or with scoped attribute, limiting CSS to only component instances.

<define-element name="percentage-bar" percentage:number="0">
  <template shadowrootmode="closed">
    <div id="progressbar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{percentage}}">
      <div part="bar" style="width: {{percentage}}%"></div>
      <div part="label"><slot></slot></div>
    </div>
  </template>
  <style scoped>
    :host { display: inline-block; }
    #progressbar { position: relative; display: block; width: 100%; height: 100%; }
    #bar { background-color: #36f; height: 100%; }
    #label { position: absolute; top: 0px; left: 0px; width: 100%; height: 100%; text-align: center; }
  </style>
</define-element>

See <style scoped>.

Lifecycle events

There are connected, disconnected and attributechanged events generated to simplify instance lifecycle management. They're available as onconnected, ondisconnected and onattributechanged event handlers as well.

<define-element>
  <x-element>
    <script scoped>
      // by default the script is run once when instance is `connected`, to have children and attributes available

      this.onconnected = () => console.log('connected')
      this.ondisconnected = () => console.log('disconnected')
      this.onattributechanged = (e) => console.log('attributechanged', e.attributeChanged, e.newValue, e.oldValue)
    </script>
  </x-element>
</define-element>

See disconnected, attributechanged.

Examples

Hello World

<define-element>
  <welcome-user>
    <template>Hello, {{ name || '...' }}</template>
    <script scoped>
      this.field.name = await fetch('/user').json()
    </script>
  </welcome-user>
</define-element>

<welcome-user/>

Timer

<define-element>
  <x-timer start:number="0">
    <template>
      <time part="timer">{{ count }}</time>
    </template>
    <script scoped>
      this.field.count = this.prop.start
      let id
      this.onconnected = () => id = setInterval(() => this.field.count++, 1000)
      this.ondisconnected = () => clearInterval(id)
    </script>
  </x-timer>
</define-element>

<x-timer start="0"/>

Clock

<define-element>
  <x-clock start:date>
    <template>
      <time datetime="{{ time }}">{{ time.toLocaleTimeString() }}</time>
    </template>
    <script scoped>
      this.field.time = this.prop.start || new Date();
      let id
      this.onconnected = () => id = setInterval(() => this.field.time = new Date(), 1000)
      this.ondisconnected = () => clearInterval(id)
    </script>
    <style scoped>
      :host {}
    </style>
  </x-clock>
</define-element>
...
<x-clock start="17:28"/>

Counter

<define-element>
  <x-counter count:number="0">
    <template>
      <output>{{ count }}</output>
      <button part="inc">+</button>
      <button part="dec">‐</button>
    </template>
    <script scoped>
      this.part.inc.onclick = e => this.props.count++
      this.part.dec.onclick = e => this.props.count--
    </script>
  </x-counter>
</define-element>

Todo list

<define-element>
  <todo-list>
    <template>
      <input part="text" placeholder="Add Item..." required>
      <button type="submit">Add</button>
      <ul class="todo-list">
        <template directive="foreach" expression="items in todos"><li class="todo-item">{{ item.text }}</li></template>
      </ul>
    </template>
    <script scoped>
      // initialize from child nodes
      this.field.todos = this.children.map(child => {text: child.textContent})
      this.part.text.onsubmit = e => {
        e.preventDefault()
        if (form.checkValidity()) {
          this.field.todos.push({ text: this.part.text.value })
          form.reset()
        }
      }
    </script>
  </todo-list>
</define-element>

<todo-list>
  <li>A</li>
  <li>B</li>
</todo-list>

Form validator

<define-element>
  <form is="validator-form">
    <template shadowrootmode="closed">
      <label for=email>Please enter an email address:</label>
      <input id="email">
      <template expression="!valid" directive="if">The address is invalid</span></template>
    </template>

    <script scoped type="module">
      const isValidEmail = s => /.+@.+\..+/i.test(s);
      export default class ValidatorForm extends HTMLFormElement {
        constructor () {
          this.email.onchange= e => this.field.valid = isValidEmail(e.target.value)
        }
      }
    </script>
  </form>
</define-element>

<form is="validator-form"></form>

Refs

License

ISC

πŸ•‰

About

Custom element definitions for HTML

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published