Web-Components
tosijs provides the abstract Component class to make defining custom-elements
easier.
Componentleverages the elements proxy and css to make defining elements very efficient.Componentmakes it easy to create custom-elements with no shadowDOM but with slotting behavior so your elements are lighter weight and easier to style.- It solves friction points like allowing element tagNames to be changed on-the-fly to avoid registry clashes.
- It allows you to deploy
Componentclasses as zero dependency blueprints functions.
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 explicitdisplayvalue (e.g.,block,inline-block,flex) in your:hoststyles.
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. ToolBar → tool-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
slot attributeclass 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 breakstosijs'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 callsuper.render()if you overriderender()in a form-associated component.It is necessary to call
super.connectedCallback,super.disconnectedCallback,super.render()(for form-associated), andsuper()in theconstructor()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:
- All-in-one: Attributes, defaults, and types defined in one place
- Declarative: No constructor needed
- Type inference: Default values determine parsing (boolean attributes just work)
Attribute Types
- string (default
''): Attribute value used as-is - number (default
0): Attribute value parsed withparseFloat() - boolean (default
false): Presence meanstrue, absence meansfalse
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:
- Validation methods (
checkValidity(),reportValidity(),setValidity()) - Automatic validation against HTML attributes (
required,minlength,maxlength,pattern) - Form lifecycle callbacks (
formResetCallback,formDisabledCallback,formStateRestoreCallback) - Custom states via
this.internals.states - Complete examples
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:
- Declare it with a default: Simply assign a non-undefined default (e.g.,
value = '') - Initialization: If a
valueattribute is present, it initializes the property (as a string) - Setting value: You can set it to any type directly (e.g., objects, arrays)
- Change events: When
valuechanges, achangeevent is automatically dispatched - Auto-render: When
valuechanges,render()is automatically called - 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. ToolBar → tool-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 toelementCreator()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.
- live-example uses multiple named slots to implement powers the interactive examples used for this site.
- side-nav implements the sidebar navigation used on this site.
- data-table implements virtualized tables with resizable, reorderable, sortable columns that can handle more data than you're probably willing to load.
- form and field allow you to
quickly create forms that leverage all the built-in functionality of
<input>elements (including powerful validation) even for custom-fields. - markdown-viewer uses
markedto render markdown. - babylon-3d lets you easily embed 3d scenes in your application using babylonjs