Web Components (Component)
tosijs provides the abstract Component class to make defining
custom elements easier. Everything
here is the platform's own machinery (shadow DOM,
slots,
form-associated elements) with the boilerplate removed.
import { withAttributes } from 'tosijs'
class NameTag extends withAttributes({ caption: 'Hello, my name is' }) {
static preferredTagName = 'name-tag'
static lightStyleSpec = {
':host': {
display: 'inline-flex',
flexDirection: 'column',
margin: '4px',
border: '2px solid #c00',
borderRadius: '8px',
overflow: 'hidden',
textAlign: 'center',
},
':host [part="caption"]': {
background: '#c00',
color: 'white',
padding: '4px 12px',
fontSize: '80%',
},
':host tosi-slot': {
padding: '8px 12px',
fontSize: '150%',
},
}
// what goes inside every <name-tag>: a caption, then whatever it is given
content = ({ span, slot }) => [span({ part: 'caption' }), slot()]
// runs when the element is first shown and whenever an attribute changes
render() {
super.render()
this.parts.caption.textContent = this.caption
}
}
const nameTag = NameTag.elementCreator()
preview.append(
nameTag('Ada'),
nameTag({ caption: 'Bonjour, je m’appelle' }, 'Grace')
)
test('a light-DOM component renders its attribute and its children', async () => {
const [ada, grace] = preview.querySelectorAll('name-tag')
// render() runs on an animation frame, which a background page can delay
const until = async (check) => {
for (let i = 0; i < 40 && !check(); i++) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
}
const captionOf = (el) => el.querySelector('[part="caption"]')?.textContent
await until(() => captionOf(ada) === 'Hello, my name is')
expect(ada.shadowRoot).toBe(null)
expect(ada.querySelector('[part="caption"]').textContent).toBe('Hello, my name is')
expect(ada.querySelector('tosi-slot').textContent).toBe('Ada')
grace.caption = 'Hi, I am'
await until(() => captionOf(grace) === 'Hi, I am')
expect(grace.querySelector('[part="caption"]').textContent).toBe('Hi, I am')
expect(grace.getAttribute('caption')).toBe('Hi, I am')
grace.caption = 'Bonjour, je m’appelle'
})
That is a complete custom element: <name-tag caption="…"> works in HTML, and
nameTag() creates one from JavaScript.
- The example above has no shadow DOM, and that is the default. Its content
is ordinary DOM that you style with ordinary CSS (
lightStyleSpec), and<slot>still works, so components compose. What a shadow DOM adds is CSS isolation; a component only gets one if you give it ashadowStyleSpec. Componentleverages the elements proxy and css to make defining elements very efficient.- Attributes are declared once, with their defaults, and become typed properties
(
this.captionabove). - 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 lightStyleSpec = {
':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
The properties of a component instance, most important first. Each has a section below, in the same order.
| property | what it is |
|---|---|
content |
The template for the inside of the element: elements, or a function that is handed element creators and returns them. Defaults to a single <slot>. |
value |
Optional. Give a component a value and it behaves like an <input>: setting it re-renders the component and emits change. |
parts |
Finds elements in your content by name: this.parts.caption is the element with part="caption". |
hydrated, whenHydrated |
Whether the content has been built yet, and a promise that resolves once it has. |
internals |
The ElementInternals of a form-associated component. |
styleNode |
The <style> element placed in the shadow root, for a component that has one. |
content
content: ((elements: ElementsProxy) => ContentType) | ContentType | null = 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')
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 { withAttributes } from 'tosijs'
class LabeledInput extends withAttributes({ 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 data bindings do not operate inside a shadowDOM — binding dispatch
cannot "see" elements there, and a component that tries now gets a console warning
instead of silent failure. The semantically correct model is the one for any
stateful component, shadowDOM or not: it is bound like an <input> or
<textarea> — its value is the binding surface. With a shadowDOM it is
simply the only option. Bind the component itself (e.g. bindings.value) from outside; setting
value automatically queues render() and emits change, so implement render()
to reflect value into the shadow DOM and let change events carry edits back out.
How the component represents its value internally is the implementer's business —
which also means shadow components don't compose bindings internally: wiring
nested widgets inside a shadow tree is manual (set their value in render(),
listen to their events). A shadowDOM component is materially a different thing than
a lightDOM component. For non-value internal state, observe() + parts, with
unobserve() on disconnect. Event sugar is the exception: on() handlers work
inside open shadow roots — composed events cross the boundary and the dispatcher
resolves the true origin via composedPath().)
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).
Each item is classified exactly as elements.div() classifies its positional
arguments — the same code, so the two cannot drift:
| item | result |
|---|---|
| a node, string or number | a child, in place |
| a tosijs proxy | a live child (a bound <span>) |
a Date, bigint, boolean, or anything with its own toString |
text, in place |
a plain object or a Map |
props applied to the host |
null / undefined |
nothing, deliberately |
| an array | a warning — spread it |
A single non-array content is classified the same way, so content = proxy
works rather than throwing.
In shadow DOM a bound child renders nothing. Data bindings are inert inside a shadow root by design, so a proxy in the
contentof a shadow-DOM component produces an empty span and a warning. Bind the component'svaluefrom outside and implementrender()instead.
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 const 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>was removed in 1.8.0.
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 excludestosijs's data bindings (inside the shadow DOM you manage state updates yourself withobserve()+parts;on()event handlers do work there viacomposedPath()).
value
If your component has a value, it should behave like an <input>.
That is the model for every stateful component, with or without a shadow DOM:
the app binds its value, the component reflects value into its own content in
render(), and a user's edit sets value, which emits change. See
A stateful component is 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.
parts
parts: {[key:string]: Element | undefined}
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 finds a content element by, in order: part="foo" (the preferred
form — it's also what ::part() styling targets), and finally foo as a css
selector — so this.parts['.foo'] finds a content element with class="foo"
while this.parts.h1 finds an <h1>.
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.
A component's [part] elements are captured from its content when it hydrates, so
this.parts.foo always resolves to your own part — never a matching [part]
inside a nested component or slotted content.
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.)
hydrated, whenHydrated
A component's content is built when the element is first connected to a document,
not when it is constructed. this.hydrated is true once that has happened, and
await this.whenHydrated waits for it (it resolves immediately if it already has).
Use them before reading parts on an element that may not have been inserted yet.
internals
Present when the class sets static formAssociated = true: the element's
ElementInternals,
used for form values, validation, ARIA and custom states. See
static formAssociated below.
styleNode
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.
Component static properties
What you declare on the class. Each has a section below, in the same order.
| static property | what it does |
|---|---|
preferredTagName |
The tag name, e.g. 'name-tag'. |
withAttributes(), initAttributes |
Declare the attributes that are kept in sync with typed properties. |
lightStyleSpec |
Styles for a component with no shadow DOM, added to the document once. |
shadowStyleSpec |
Styles placed inside a shadow root. Setting it is what gives the component a shadow DOM. |
formAssociated |
Set to true to take part in forms. |
extends |
For customized built-in elements. |
contract 🚧 |
The component's description of itself: constraints, parts and a test fixture. In flux. |
Component.elements |
The elements proxy, for convenience. |
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.
withAttributes() and static initAttributes
static initAttributes installs instance properties at hydration, and
TypeScript cannot derive an instance type from a static declared in the same
class. Declare the map as a value instead and inference does the work:
import { withAttributes } from 'tosijs'
export class Stepper extends withAttributes({
count: 0,
mode: 'add',
})<StepperParts> {
static preferredTagName = 'my-stepper'
render() {
this.count + 1 // number
this.mode.trim() // string
this.nope() // a type error, as it should be
}
}
This is a move of the static initAttributes = {…} you already had, not an
extra declaration — the same object, somewhere inference can see it. It is the
recommended form for new components.
Until 1.10.0 Component carried [key: string]: any so those properties would
compile, which had the side effect of accepting every typo in every
component anyone wrote (tosijs#36). Removing it is why this exists.
static initAttributesstill works and is not deprecated.withAttributes()sets it, and it remains the only way to add attributes to an existing component class —withAttributesalways extendsComponent. Build a base with one, extend and add with the other; they compose at runtime, and the subclass's own declaration wins per key:class Base extends withAttributes({ label: 'base' }) { … } class Sub extends Base { static initAttributes = { extra: 7 } // declare ONLY what you add } export interface Sub extends ComponentAttrs<typeof Sub.initAttributes> {}Declare only the new keys — do not spread the base's map in. tosijs merges the whole prototype chain, so spreading is redundant, and it hides the case the merge exists for.
The interface line is required on the subclass, and it is the one place it cannot be avoided:
withAttributestypes the instance for the class it creates, and it cannot reach back into a class it did not create, sothis.extrahas no type without it.this.labelis inherited and typed already.Computed attributes are excluded from the declared type, deliberately.
Component.computed()means your class implements the property itself, normally asget/set, so declaring it here as well is what breaks. Its setter may callthis.queueRender().If you cannot restructure the class,
ComponentAttrs<T>says the same thing in one line:export interface Widget extends ComponentAttrs<typeof Widget.initAttributes> {}
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.
This is the stable, terse way to declare attributes, and it is what most components use. To add constraints (
enum,const, …) to an attribute, seestatic contractabove — the two compose, and you do not have to move a declaration to constrain it.
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.
Computed attributes
Component.computed(shape) declares an attribute your class implements itself with
an ordinary get/set. tosijs wraps your setter so a change always re-renders — you
never call queueRender() — and the name joins observedAttributes, so markup and
setAttribute reach your setter too.
The argument is a shape, not a default: '' for string-valued, false for
presence-valued. There is no number shape, because markup has no numbers — take the
string and parse it. A getter with no setter is a read-only derived attribute. A
native DOM property name (title, id, hidden, …) throws rather than shadowing
the platform's accessor.
import { Component } from 'tosijs'
// THE REAL-BROWSER TIER MATTERS FOR THIS ONE. The mechanism is
// markup → attributeChangedCallback → your setter, which rides custom-element
// UPGRADE TIMING — what happy-dom models most loosely, and where this project
// has been bitten before. Every unit test for computed attributes runs under
// happy-dom; this fence is what pins the feature in Chromium and Firefox.
test('a computed attribute is set from markup, in a real browser', async () => {
class NameTag extends Component {
static preferredTagName = 'doc-name-tag'
static initAttributes = { fullName: Component.computed('') }
first = '?'
last = '?'
get fullName() {
return `${this.first} ${this.last}`
}
set fullName(v) {
const [f, ...rest] = String(v).split(' ')
this.first = f
this.last = rest.join(' ')
}
content = null
}
NameTag.elementCreator()
// parsed from MARKUP, then upgraded — the ordering that matters
preview.innerHTML = '<doc-name-tag full-name="Grace Hopper"></doc-name-tag>'
const el = preview.querySelector('doc-name-tag')
await new Promise((resolve) => setTimeout(resolve, 60))
expect(el.fullName).toBe('Grace Hopper')
expect(el.first).toBe('Grace')
// a post-upgrade setAttribute reaches the setter too
el.setAttribute('full-name', 'Ada Lovelace')
await new Promise((resolve) => setTimeout(resolve, 60))
expect(el.fullName).toBe('Ada Lovelace')
// and a property write must NOT fire `change` — that is the value-commit
// signal, and an attribute is not a value
let changes = 0
el.addEventListener('change', () => changes++)
el.fullName = 'Alan Turing'
await new Promise((resolve) => setTimeout(resolve, 60))
expect(el.fullName).toBe('Alan Turing')
expect(changes).toBe(0)
})
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 }
}
static lightStyleSpec?: TosiStyleSheet
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 shadowStyleSpec?: TosiStyleSheet
Styles injected into the component's shadow DOM as a <style> element.
Setting this property causes the component to use shadow DOM.
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
static extends?: string
For customized built-in elements. Passed as { extends } to customElements.define().
static contract: ComponentMap 🚧 IN FLUX
🚧 THE CONTRACT API IS IN FLUX — expect it to change without a deprecation cycle. Not "experimental" in the shrug sense: the idea is settled and the feature works. What is unsettled is its shape, and we would rather get that right than freeze it early and carry a mistake. Changes will land in patch and minor releases, and the CHANGELOG will say so. Nothing else in
Componentis in flux —initAttributes,content,parts, form association and the rest are stable.Specifically open: how
contract.attributesandinitAttributesdivide the work; whether an integrator's overlay may embellish a component's own declaration rather than replace it wholesale; and the precedence between the two (tosijs#29, #30). It settles when those close.If you need stability today, declare attributes with
initAttributes— it is stable, it is terser, and since 1.8.1 it is described to agents identically.
A component's self-declaration: what it is, what its attributes and value are
allowed to be, which parts it exposes, and a test fixture — in one structure.
It feeds the docs, the agent surface, and exerciseComponent(), so a
declaration that lies breaks visibly.
class Stepper extends Component {
static preferredTagName = 'my-stepper'
static initAttributes = { count: 0, mode: 'add' }
static contract = {
description: 'increments a counter',
attributes: { mode: { enum: ['add', 'subtract'] } },
}
}
initAttributes DECLARES; contract.attributes ENRICHES. They compose —
declaring both is the intended shape, not an error. initAttributes gives a
name, a default and an inferred type; the contract adds constraints the
built-in checker enforces (enum, const) plus anything a registered schema
engine understands. A contract entry may omit default when initAttributes
already supplies one, so constraining one attribute costs one line rather than
a rewrite. The contract wins per key.
Both forms are described identically to an agent. (Before 1.8.1 they were not:
attributes were read from the contract alone, so a component using
initAttributes — nearly all of them — appeared in the map with no attribute
description at all. tosijs#29.)
Component.elements
const {label, span, input} = Component.elements
This is simply provided as a convenient way to get to elements
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 members must not use it.) Name by
intent: handle<Event> for a handler function the component invokes (e.g.
handleResize, handleClick), or add<Event>Listener for a method that
registers listeners for a synthetic event the component dispatches (e.g.
addClickListener).
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
Called (on an animation frame) when the component is first shown and whenever
an attribute or its value changes, or you call queueRender(). Use it to bring
the DOM that content built up to date in place: set a textContent, toggle
hidden, update a class. Do not rebuild the content here unless the component's
structure has genuinely changed: replacing elements throws away focus, caret
position and scroll state, and is slower. See
Building Apps.
Be sure to call super.render() if you implement render in the subclass.
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 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.
Naming the creator's type: use ElementCreator<YourComponent>.
import type { ElementCreator } from 'tosijs'
let toolBar: ElementCreator<ToolBar>
toolBar = ToolBar.elementCreator()
toolBar().someMethodOfYours() // typed
The natural-looking alternative silently loses the type:
// DON'T — resolves to unknown
let toolBar: ReturnType<typeof ToolBar.elementCreator>
toolBar().someMethodOfYours() // TS18046: 'toolBar()' is of type 'unknown'
elementCreator is declared static elementCreator<C = Component>(this: new () => C), so the component type is inferred from this. ReturnType<> does not
bind this, so C falls back and the result is unknown — with no error at
the declaration, only later at every use. This accounted for 156 of the
type errors in tosijs's own test suite before it was noticed.
Deprecated: Passing
{ tag, styleSpec, extends }as options toelementCreator()still works but emits deprecation warnings. Use the static properties instead.
The contractviolation event
When a component declares contract.value and a binding writes a value
that violates it, the write is applied and reported rather than thrown — state
is authoritative on that path, and throwing inside the binding-dispatch loop
would strand every element bound after this one. Alongside the one-time
console.error, the component dispatches a bubbling contractviolation event
so an app can react programmatically:
el.addEventListener('contractviolation', (event) => {
const { reason, value, schema } = event.detail
telemetry.record('contract', { tag: el.tagName, reason })
})
It fires once per element per distinct reason, per bad-state episode — not once per binding pass, and not only once ever.
Not per pass, because for an object- or array-valued contract the upstream
value !== newValue guard never matches (the proxy returns a fresh object on
every access), so an unthrottled dispatch fired on every pass for the life of
the page.
The latch clears the moment the value stops violating — a valid value, an
empty field, or null. So re-entering a bad state fires again, which is what
makes this usable for a validation banner that hides on correction and has to
come back if the user re-breaks the field:
el.addEventListener('contractviolation', ({ detail }) => showBanner(detail.reason))
// and clear the banner on your own valid-input path
A listener therefore counts episodes, not binding-dispatch frequency — which is the number you actually wanted.
A direct write (el.value = bad) still throws instead — no event, because
the caller is right there to catch it.
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