@substrate-system/web-component
v0.0.49
Published
Minimal parent web component
Readme
web component
A minimal parent class for web components.
This extends the native HTMLElement, and adds
some methods to help with events.
- Install
- tl;dr
- Hide Undefined Elements
- Examples
- Reflected Attributes
- Modules
- methods
- Misc
- Develop
- Test
- See also
Install
npm i -S @substrate-system/web-componenttl;dr
- use
.emitto emit a namepsaced event - use
.on(name, handler)to listen for namespaced events - use
.dispatchto emit a non-namespaced event - use
.event(name)to get the namespaced event type - extend the factory function to create a web component
- Listen for all event with the
'*'event name. - Hide your component's content until it is ready
Hide Undefined Elements
[!TIP] Use the CSS
:definedpseudo-class to hide elements until they have been defined in JS, to prevent a FOUCE.
my-element:not(:defined) {
visibility: hidden;
}[!CAUTION] JS must exist on the device for the custom elements to be defined. A better option might be to set a single class when everything is defined.
FOUCE
My favorite way to deal with FOUCE is to add a class to the body tag, then remove it in JS, and hide things with CSS, as seen here.
HTML
Write the static HTML like this:
<html class="reduce-fouce">
...
</html>JS
<script type="module">
await Promise.race([
// Load all custom elements
Promise.allSettled([
customElements.whenDefined('my-button'),
customElements.whenDefined('my-card'),
customElements.whenDefined('my-rating')
// ...
]),
// Resolve after two seconds
new Promise(resolve => setTimeout(resolve, 2000))
]);
// Remove the class, showing the page content
document.documentElement.classList.remove('reduce-fouce');
</script>CSS
html.reduce-fouce {
opacity: 0;
}noscript
Include a noscript tag for when Javascript does not exist:
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="/style.css">
<noscript>
<style>
html.reduce-fouce {
opacity: 1!important;
}
</style>
</noscript>
</head>Examples
Create a component
Use the factory function to create a new web component.
import { WebComponent } from '@substrate-system/web-component'
class AnotherElement extends WebComponent.create('another-element') {
connectedCallback () {
this.innerHTML = `<div>
hello again
</div>`
}
}
// call custom customElements.define with the right tag name
AnotherElement.define()The new component will have a property NAME on the class that is equal to
the name you passed in.
The component name should be kebab case.
Add the component to the DOM
document.body.innerHTML += '<another-element></another-element>'Listen for events
Use a helper method, WebComponent.event(name:string), to get a
namespaced event name.
// find the instance
const el = document.querySelector('my-element')
// listen for namespaced events
el?.addEventListener(MyElement.event('hello'), ev => {
console.log(ev.detail) // => 'some data'
})
// shorthand for namespaced listeners
el?.on('hello', ev => {
console.log(ev.type) // => 'my-element:hello'
})
// listen for non-namespaced events
el?.addEventListener('hello', ev => {
console.log(ev.detail) // => 'some data again'
})Wildcard Event Listeners
The component supports two types of wildcard event listeners.
Namespaced wildcard: Component.event('*')
Listen to all events emitted through the component's .emit() method
(events in the component's namespace):
const el = document.querySelector('my-element')
const listener = (ev) => {
console.log('Namespaced event:', ev.type)
}
// Add listener for all 'my-element:*' events
el.addEventListener(MyElement.event('*'), listener)
// These will trigger the listener
el.emit('click') // Fires with type 'my-element:click'
el.emit('change') // Fires with type 'my-element:change'
// This will NOT trigger the listener (not namespaced)
el.dispatch('hello')
// Remove the wildcard listener
el.removeEventListener(MyElement.event('*'), listener)Global wildcard: '*'
Listen to all events dispatched through the element:
const el = document.querySelector('my-element')
const listener = (ev) => {
console.log('Any event:', ev.type)
}
// Add listener for ALL events
el.addEventListener('*', listener)
// ALL of these trigger the listener
el.emit('custom') // my-element:custom
el.dispatch('hello') // hello
el.dispatchEvent(new Event('click')) // click
// Remove the global wildcard listener
el.removeEventListener('*', listener)Emit a namespaced event from the instance
// find the instance
const el = document.querySelector('my-element')
// dispatch an event
el?.emit('hello', { detail: 'some data' }) // => `my-element:hello`Listen for a namespaced event
Use the static method .event to get a namespaced event name.
class ExampleComponent extends WebComponent {
tag = 'example-component'
// ...
}
const ev = ExampleComponent.event('click')
// => 'example-component:click'Emit a plain string (not namespaced) event
The dispatch method wont namespace the event name. It just emits the
literal string.
const el = document.querySelector('my-element')
// dispatch an event as plain string, not namespaced
el?.dispatch('hello', { detail: 'some data again' }) // => `hello`Listen for all namespaced events from a component
Use the pattern Component.event('*') to listen to all events emitted by a
specific component with its namespace.
const el = document.querySelector('my-element')
// Listen to all namespaced events from this component
el?.addEventListener(MyElement.event('*'), ev => {
console.log('Caught namespaced event:', ev.type)
// Will catch 'my-element:click', 'my-element:change', etc.
})
// This will trigger the wildcard listener
el?.emit('click', { detail: 'clicked' })
el?.emit('change', { detail: 'changed' })Listen for all events (global wildcard)
Use the literal string '*' to listen to all events dispatched through
the element, including both namespaced and non-namespaced events, as well as
native DOM events.
const el = document.querySelector('my-element')
// Listen to ALL events on this element
el?.addEventListener('*', ev => {
console.log('Caught any event:', ev.type)
// Will catch everything: 'my-element:click', 'hello', 'click', etc.
})
// All of these trigger the global wildcard listener
el?.emit('custom') // Triggers with type 'my-element:custom'
el?.dispatch('hello') // Triggers with type 'hello'
el?.dispatchEvent(new Event('click')) // Triggers with type 'click'Reflected Attributes
[!NOTE] Frameworks like Preact and React set non-string props on custom elements via property assignment (
el.disabled = true) rather thansetAttribute('disabled', ''). Native elements handle this automatically because the browser has built-in IDL reflection. Web components need to opt in.
Declare which attributes should be reflected as properties using two static arrays:
class SubstrateButton extends WebComponent {
static TAG = 'substrate-button'
// Boolean attributes: property is true when attribute is present
static reflectedBooleanAttributes = ['disabled', 'readonly']
// String attributes: property mirrors the attribute value (null if absent)
static reflectedStringAttributes = ['type', 'name', 'value']
// Optional: TypeScript types for reflected properties
declare disabled:boolean
declare readonly:boolean
declare type:string|null
declare name:string|null
declare value:string|null
render () { /* ... */ }
}
SubstrateButton.define()The base class generates getters and setters for each declared attribute.
observedAttributes is auto-derived — no need to declare it separately.
Attribute reactivity
When a reflected attribute changes (via property assignment OR
setAttribute), the browser fires attributeChangedCallback, which routes
to your handleChange_${name} method as usual:
class SubstrateButton extends WebComponent {
static reflectedBooleanAttributes = ['disabled']
declare disabled:boolean
handleChange_disabled (_oldValue:string|null, newValue:string|null) {
this.qs('button')?.toggleAttribute('disabled', newValue !== null)
}
render () {
this.innerHTML = `<button>click me</button>`
}
}Observing non-reflected attributes
To observe additional attributes that don't need property reflection, extend
observedAttributes using super:
class MyElement extends WebComponent {
static reflectedBooleanAttributes = ['disabled']
static get observedAttributes () {
return [...super.observedAttributes, 'role']
}
handleChange_role (_old:string|null, next:string|null) {
if (next) this.qs('button')?.setAttribute('role', next)
}
}TypeScript types
Reflected properties are installed at runtime and are invisible to TypeScript.
Use declare to add types without emitting runtime code:
class MyButton extends WebComponent {
static reflectedBooleanAttributes = ['disabled']
static reflectedStringAttributes = ['type']
declare disabled:boolean // boolean: true = present, false = absent
declare type:string|null // string|null: null when attribute is absent
}Migration from manual observedAttributes
If you previously declared static observedAttributes and wrote manual
getters/setters, migrate like this:
Before:
static observedAttributes = ['disabled']
get disabled ():boolean { return this.hasAttribute('disabled') }
set disabled (v:boolean) { this.toggleAttribute('disabled', v) }After:
static reflectedBooleanAttributes = ['disabled']
declare disabled:boolean // TypeScript type only, no runtime codeIf your setter has custom side-effect logic beyond toggleAttribute, keep the
hand-written setter. The base class detects it and leaves it untouched.
Modules
ESM
This exposes ESM and common JS via package.json exports field.
const { WebComponent } = import '@substrate-system/web-component'Common JS
const { WebCompponent } = require('@substrate-system/web-component')methods
emit(name:string, opts:{ bubbles?, cancelable?, detail? }):boolean
This will emit a CustomEvent, namespaced according to a convention.
The return value is
the same as the native .dispatchEvent method,
returns
trueif either event'scancelableattribute value is false or itspreventDefault()method was not invoked, andfalseotherwise.
Because the event is namespaced, we can use event bubbling while minimizing event name collisions.
The naming convention is to take the NAME property of the class, and append a
string :event-name.
So emit('test') dispatches an event like my-element:test.
class MyElement {
NAME = 'my-element' // <-- for event namespace
// ...
}
// ... then use the element in markup ...
const el = document.querySelector('my-element')
// 'my-element:test' event
el.addEventListener(MyElement.event('test'), ev => {
console.log(ev.detail) // => 'some data'
})
// ... in the future ...
el.emit('test', 'some data') // dispatch `my-element:test` eventSee also, Custom events in Web Components
dispatch (type, opts)
Create and emit an event, no namespacing. The return value is the same as the
native .dispatchEvent method,
returns
trueif either event'scancelableattribute value is false or itspreventDefault()method was not invoked, andfalseotherwise.
That is, it returns true if it was not preventDetaulted.
dispatch (type:string, opts:Partial<{
bubbles,
cancelable,
detail
}>):booleandispatch example
const el = document.querySelector('my-element')
el.dispatch('change') // => 'change' eventon (name:string, handler:(ev:Event)=>any, options?:boolean|AddEventListenerOptions)
Listen for namespaced events with shorthand syntax.
Internally this maps name to Component.event(name) and calls
addEventListener.
const el = document.querySelector('my-element')
el?.on('ready', ev => {
console.log(ev.type) // => 'my-element:ready'
})
// pass native addEventListener options
el?.on('ready', ev => {
console.log('fires once')
}, { once: true })
// namespaced wildcard
el?.on('*', ev => {
console.log(ev.type) // => any 'my-element:*' event from .emit()
})on('*', handler) only listens to namespaced events for that component.
It does not listen to non-namespaced events such as el.dispatch('click').
event (name:string):string
Return the namespaced event name.
event example
MyElement.event('change') // => 'my-element:change'You can also use '*' as the event name to create a wildcard listener pattern:
MyElement.event('*') // => 'my-element:*'This is used with addEventListener to listen to all namespaced events from
a component.
qs
A convenient shortcut to element.querySelector.
qs (selector:string):HTMLElement|nullqsa
Shortcut to document.querySelectorAll
qsa (selector:string):ReturnType<typeof document.querySelectorAll>element.qs & element.qsa
A shortcut to element.querySelector & element.querySelectorAll.
example
const myElement = document.querySelector('my-element')
debug('the namespaced event...', MyElement.event('aaa'))
// query inside the element
const buttons = myElement?.qsa('button')Misc
Some Notes
Attributes (strings, numbers, booleans) tend to reflect, properties don’t.
That means,
- Attributes typically reflect to properties — when you set
count="5"in HTML, the element's.countproperty mirrors that value (often converting the string to a number). - Properties typically DON'T reflect back to attributes — when you set element.count = 10 in JavaScript, it usually doesn't update the HTML attribute to count="10"
See The killer feature of Web Components.
/util
Various functions.
qs
A convenient shortcut to document.querySelector.
import { qs } from 'substrate-system/web-component/qs'qsa
A shortcut to document.querySelectorAll.
import { qsa } from 'substrate-system/web-component/qsa'isRegistered(name:string)
Check if an element name has been used already.
function isRegistered (elName:string):booleanimport { isRegistered } from '@substrate-system/web-component/util'example
import { isRegistered } from '@substrate-system/web-component/util'
if (!isRegistered('example-component')) {
customElements.define('example-component', ExampleComponent)
}define(name:string, element:CustomElementConstructor)
Add a component to the custom element registry.
This uses isRegistered, so it will not throw if the name has been
taken already.
import { define } from '@substrate-system/web-component/util'function define (name:string, element:CustomElementConstructor) {/attributes
Utilities for working with HTML attributes.
import { toAttributes } from '@substrate-system/web-component/attributes'Attrs
A type alias for attribute objects.
type Attrs = Record<string,
undefined|null|string|number|boolean|(string|number)[]>toAttributes(attrs:Attrs):string
Transform an object into an HTML attributes string. Keys with falsy values
(undefined, null, false, 0, '') are omitted. Boolean true
produces a bare attribute name. Arrays are joined with spaces.
function toAttributes (attrs:Attrs):stringexample
import { toAttributes } from '@substrate-system/web-component/attributes'
toAttributes({ class: 'foo bar', disabled: true })
// => 'class="foo bar" disabled'
toAttributes({ class: ['foo', 'bar'], hidden: false })
// => 'class="foo bar"'
toAttributes({ id: 'my-el', tabindex: 1 })
// => 'id="my-el" tabindex="1"'Develop
Start a localhost server:
npm startTest
npm test