Web-Components

tosijs provides the abstract Component class to make defining custom-elements easier.

Component

To define a custom-element you can subclass Component, simply add the properties and methods you want, with some help from Component itself, and then simply export your new class's elementCreator() which is a function that defines your new component's element and produces instances of it as needed.

import {Component} from 'tosijs'

class ToolBar extends Component {
  static preferredTagName = 'tool-bar'
  static shadowStyleSpec = {
    ':host': {
      display: 'flex',
      gap: '10px',
    },
  }
}

export const toolBar = ToolBar.elementCreator()

Note: Custom elements default to display: inline, which often causes them to appear dimensionless. Unless you want this (e.g., for content-holder elements), set an explicit display value (e.g., block, inline-block, flex) in your :host styles.

This component is just a structural element. By default a Component subclass will comprise itself and a <slot>. You can change this by giving your subclass its own content template.

static preferredTagName sets the desired tag name for the custom element. If omitted, it is derived from the class name (e.g. ToolBartool-bar), but this does not survive minification. elementCreator() returns an ElementCreator function that creates instances of the element.

See elements for more information on ElementCreator functions.

Component properties

content: Element | Element[] | () => Element | () => Element[] | null

Here's a simple example of a custom-element that simply produces a <label> wrapped around <span> and an <input>. Its value is synced to that of its <input> so the user doesn't need to care about how it works internally.

import { Component, elements } from 'tosijs'

class LabeledInput extends Component {
  static initAttributes = { caption: 'untitled' }
  value = ''

  content = ({label, span, input}) => label(span(), input())

  connectedCallback() {
    super.connectedCallback()
    const {input} = this.parts
    input.addEventListener('input', () => {
      this.value = input.value
    })
  }

  render() {
    super.render()
    const {span, input} = this.parts
    span.textContent = this.caption
    if (input.value !== this.value) {
      input.value = this.value
    }
  }
}

const labeledInput = LabeledInput.elementCreator()

preview.append(
  labeledInput({caption: 'A text field', value: 'some text'})
)

content is, in essence, a template for the internals of the element. By default it's a single <slot> element. If you explicitly want an element with no content you can set your subclass's content to null or omit any <slot> from its template.

By setting content to be a function that returns elements instead of a collection of elements you can take customize elements based on the component's properties. In particular, you can use onXxxx syntax sugar to bind events.

(Note that you cannot bind to xin paths reliably if your component uses a shadowDOM because xin cannot "see" elements there. As a general rule, you need to take care of anything in the shadowDOM yourself.)

ElementProps in content arrays

When content returns an array, any plain objects (ElementProps) in the array are applied to the host element itself, just as they would be applied to the element being created by div(), span(), etc. This provides a clean way to set up styles, event handlers, classes, and bindings on the component from within content:

class MyButton extends Component {
  static preferredTagName = 'my-button'

  content = ({span}) => [
    { onClick: () => console.log('clicked!'), style: { cursor: 'pointer' } },
    span({part: 'label'}, 'Click me'),
  ]
}

Multiple ElementProps objects are merged (later values override earlier ones). Only plain objects are treated as props — DOM nodes, strings, numbers, and proxied values pass through as children.

If you'd like to see a more complex example along the same lines, look at form and field.

names and the slot attribute
class MenuBar extends Component {
  static shadowStyleSpec = {
    ':host, :host > slot': {
      display: 'flex',
    },
    ':host > slot:nth-child(1)': {
      flex: '1 1 auto'
    },
  }

  content = ({slot}) => [slot(), slot({name: 'gadgets'})]
}

export menuBar = MenuBar.elementCreator()

One of the neat things about custom-elements is that you can give them multiple <slot>s with different name attributes and then have children target a specific slot using the slot attribute.

This app's layout (the nav sidebar that disappears if the app is in a narrow space, etc.) is built using just such a custom-element.

<tosi-slot>

If you put <slot> elements inside a Component subclass that doesn't have a shadowDOM, they will automatically be replaced with <tosi-slot> elements that have the expected behavior (i.e. sucking in children in based on their <slot> attribute).

<tosi-slot> doesn't support :slotted but since there's no shadowDOM, just style such elements normally, or use tosi-slot as a CSS-selector.

Note that you cannot give a <slot> element attributes (other than name) so if you want to give a <tosi-slot> attributes (such as class or style), create it explicitly (e.g. using elements.tosiSlot()) rather than using <slot> elements and letting them be switched out (because they'll lose any attributes you give them).

The legacy name <xin-slot> still works but emits a deprecation warning.

Here's a very simple example:

import { Component, elements } from 'tosijs'

class FauxSlotExample extends Component {
  content = ({h4, h5, tosiSlot}) => [
    h4('This is a web-component with no shadow DOM and working slots!'),
    h5('top slot'),
    tosiSlot({name: 'top'}),
    h5('middle slot'),
    tosiSlot(),
    h5('bottom slot'),
    tosiSlot({name: 'bottom'}),
  ]
}

FauxSlotExample.preferredTagName = 'faux-slot-example'
FauxSlotExample.lightStyleSpec = {
  ':host': {
    display: 'flex',
    flexDirection: 'column'
  },
  ':host h4, :host h5': {
    margin: 0,
  },
  ':host tosi-slot': {
    border: '2px solid grey'
  }
}
const fauxSlotExample = FauxSlotExample.elementCreator()

const { div } = elements

preview.append(
  fauxSlotExample(
    div({slot: 'bottom'}, 'I should be on the bottom'),
    div({slot: 'top'}, 'I should be on the top'),
    div('I should be in the middle')
  )
)
Background

<slot> elements do not work as expected in shadowDOM-less components. This is hugely annoying since it prevents components from composing nicely unless they have a shadowDOM, and while the shadowDOM is great for small widgets, it's terrible for composite views and breaks tosijs's bindings (inside the shadow DOM you need to do data- and event- binding manually).

styleNode: HTMLStyleElement

styleNode is the <style> element that will be inserted into the element's shadowRoot.

If a Component subclass has no styleNode, no shadowRoot will be created. This reduces the memory and performance cost of the element.

This is to avoid the performance/memory costs associated with the shadowDOM for custom-elements with no styling.

Notes

Styling custom-elements can be tricky, and it's worth learning about how the :host and :slotted() selectors work.

It's also very useful to understand how CSS-Variables interact with the shadowDOM. In particular, CSS-variables are passed into the shadowDOM when other CSS rules are not. You can use css rules to modify css-variables which will then penetrate the shadowDOM.

refs: {[key:string]: Element | undefined}

render() {
  super.render() // see note
  const {span, input} = this.parts
  span.textContent = this.caption
  if (input.value !== this.value) {
    input.value = this.value
  }
}

Note: For form-associated components, super.render() syncs the form value automatically when the value changes. Always call super.render() if you override render() in a form-associated component.

It is necessary to call super.connectedCallback, super.disconnectedCallback, super.render() (for form-associated), and super() in the constructor() should you override them.

this.parts returns a proxy that provides elements conveniently and efficiently. It is intended to facilitate access to static elements (it memoizes its values the first time they are computed).

this.parts.foo will return a content element with data-ref="foo". If no such element is found it tries it as a css selector, so this.parts['.foo'] would find a content element with class="foo" while this.parts.h1 will find an <h1>.

this.parts will also remove a data-ref attribute once it has been used to find the element. This means that if you use all your refs in render or connectedCallback then no trace will remain in the DOM for a mounted element.

parts only resolves after hydration — the content it looks through is instantiated on connectedCallback, not at construction. Reading parts on an uninserted element (e.g. one just back from elementCreator()) has nothing to find. If a public getter needs a ref before the element is guaranteed inserted, gate it on this.hydrated or await this.whenHydrated first. (Prior to this you could not ask whether an element was hydrated without probing parts, and that probe permanently bound the proxy to the light DOM.)

Component properties

content: ((elements: ElementsProxy) => ContentType) | null | ContentType = slot()

A component's content property can either be static content (it defaults to being a <slot> element) or an arrow function that creates the basic content of the element on hydration. Static content will be deep-cloned.

By using an arrow function the content created can refer to the custom-element's properties and attributes (and this occurs post-initialization). This also means you can bind event-handlers in the component (which should also be arrow functions unless they don't need to refer to the element)

Because a content function is passed the elements proxy, you can easily destructure any element creators you need:

content = ({div}) => div('hello world')

ContentType can be an HTMLElement or an array of elements.

Note that if a component does not use the shadowDOM, its <slot> elements will be replaced with <tosi-slot> elements. This allows composition to work as expected without requiring the shadow DOM.

Component static properties

static initAttributes: Record<string, any>

Declares attributes that should be watched and synced with properties. The keys are property names (camelCase), and the values are the defaults which also determine the type.

import { Component } from 'tosijs'

class MyWidget extends Component {
  static initAttributes = {
    caption: '',      // string attribute
    count: 0,         // number attribute (auto-parsed)
    disabled: false,  // boolean attribute (presence/absence)
  }

  render() {
    // this.caption, this.count, this.disabled are automatically available
    // and synced with HTML attributes
  }
}

This replaces both the old initAttributes() method call AND the instance property declarations. A single static object now defines which properties are attributes, their default values, and their types:

Attribute Types

For non-attribute properties (e.g. objects), just declare them as regular instance properties on your class.

Migration from initAttributes()

Old (deprecated):

class MyComponent extends Component {
  caption = ''
  count = 0

  constructor() {
    super()
    this.initAttributes('caption', 'count')
  }
}

New:

class MyComponent extends Component {
  static initAttributes = { caption: '', count: 0 }
}

Component methods

queueRender(triggerChangeEvent = false): void

Uses requestAnimationFrame to queue a call to the component's render method. If called with true it will also trigger a change event.

private initValue(): void

Don't call this! Sets up expected behavior for an HTMLElement with a value (i.e. triggering a change events and render when the value changes).

private hydrate(): void

Don't call this Appends content to the element (or its shadowRoot if it has a styleNode)

connectedCallback(): void

If the class has a handleResize handler then a ResizeObserver will trigger resize events on the element when its size changes and handleResize will be set up to respond to them. (The legacy name onResize still works but is deprecated — the on<Event> prefix is reserved for event-handler sugar in the elements factory, so component callbacks use the handle<Event> convention instead.)

Also, if the subclass has defined value, calls initValue().

connectedCallback is a great place to attach event-handlers to elements in your component.

Be sure to call super.connectedCallback() if you implement connectedCallback in the subclass.

disconnectedCallback(): void

Be sure to call super.disconnectedCallback() if you implement disconnectedCallback in the subclass.

render(): void

Be sure to call super.render() if you implement render in the subclass.

Component static properties

Component.elements

const {label, span, input} = Component.elements

This is simply provided as a convenient way to get to elements

static formAssociated: boolean

Set static formAssociated = true in your subclass to enable form participation via ElementInternals. When true, the component will have this.internals available for form integration, validation, ARIA properties, and custom states.

Form-associated components are automatically made focusable (tabindex="0") unless you explicitly set a different tabindex. This is required for form validation to work correctly (the browser needs to focus invalid elements).

See web-component-validation for the complete validation API documentation, including:

value property

If your component has a value, it should behave like an <input>.

The value property is special in Component. It is NOT an attribute - it's a property that can be initialized from an attribute. Here's what you need to know:

  1. Declare it with a default: Simply assign a non-undefined default (e.g., value = '')
  2. Initialization: If a value attribute is present, it initializes the property (as a string)
  3. Setting value: You can set it to any type directly (e.g., objects, arrays)
  4. Change events: When value changes, a change event is automatically dispatched
  5. Auto-render: When value changes, render() is automatically called
  6. Computed values: If your value is computed, call queueRender(true) to trigger change + render

Do NOT put value in static initAttributes - it will be rejected with a warning. The Component class handles value specially to provide form-like behavior automatically.

adoptedCallback

The adoptedCallback lifecycle method is called when a component is moved to a different document, such as into or out of an iframe. Subclasses can implement this directly.

import { Component, elements } from 'tosijs'

class AdoptableWidget extends Component {
  docCount = 0

  content = ({span}) => span({part: 'info'})

  adoptedCallback() {
    this.docCount++
    this.queueRender()
  }

  render() {
    this.parts.info.textContent = `Adopted ${this.docCount} time(s). Document: ${this.ownerDocument.title || 'untitled'}`
  }
}

AdoptableWidget.preferredTagName = 'adoptable-widget'
const adoptableWidget = AdoptableWidget.elementCreator()
const {iframe, button, div, span} = elements

const widget = adoptableWidget()
const widgetSlot = span({class: 'widget-slot'}, widget)
const frame = iframe()
const moveBtn = button('Move to iframe')
const backBtn = button('Move back')

moveBtn.addEventListener('click', () => {
  frame.contentDocument.body.append(frame.contentDocument.adoptNode(widget))
})
backBtn.addEventListener('click', () => {
  widgetSlot.append(document.adoptNode(widget))
})

preview.append(widgetSlot, div(moveBtn, backBtn), frame)
.preview .widget-slot {
  display: block;
  min-height: 40px;
  border: 2px dashed #888;
  margin-bottom: 10px;
}
.preview adoptable-widget {
  display: block;
  padding: 10px;
  background: #666;
  color: white;
}
.preview > div { display: flex; gap: 8px; margin-bottom: 10px; }
.preview iframe {
  width: 100%;
  height: 60px;
  border: 2px dashed #888;
  background: #fff;
}

Component static properties

static preferredTagName?: string

Sets the desired tag name for the custom element. If omitted, it is derived from the class name (e.g. ToolBartool-bar), but this does not survive minification. If the tag is already in use, a unique anonymous tag is generated.

static shadowStyleSpec?: XinStyleSheet

Styles injected into the component's shadow DOM as a <style> element. Setting this property causes the component to use shadow DOM.

static lightStyleSpec?: XinStyleSheet

Global styles appended to document.head when the first instance is inserted in the DOM. :host selectors are automatically rewritten to the tag name, e.g.:

class ToolBar extends Component {
  static preferredTagName = 'tool-bar'
  static lightStyleSpec = {
    ':host': {
      display: 'flex',
      padding: 'var(--toolbar-padding, 0 8px)',
      gap: '4px'
    }
  }
}

produces tool-bar { display: flex; ... } in a global <style> element.

static extends?: string

For customized built-in elements. Passed as { extends } to customElements.define().

Component static methods

Component.elementCreator(): ElementCreator

export const toolBar = ToolBar.elementCreator()

Returns a function that creates the custom-element. Registration uses preferredTagName, lightStyleSpec, shadowStyleSpec, and extends from the class's static properties.

elementCreator is memoized and only generated once.

Deprecated: Passing { tag, styleSpec, extends } as options to elementCreator() still works but emits deprecation warnings. Use the static properties instead.

Examples

tosijs-ui is a component library built using this Component class that provides the essential additions to standard HTML elements needed to build many user-interfaces.