📢 What's new in 3.1.0? Check out the Changelog!
Build your own frontend framework with Ornament, a stable, mid-level, pareto-optimal, treeshakable and tiny TypeScript-positive toolkit for web component infrastructure! Escape from heavyweight frameworks, constant rewrites and the all-encompassing frontend FOMO with a declarative, simple, and type-safe API for almost-vanilla web components:
import { define, attr, string, number, connected, reactive, } from "@sirpepe/ornament"; // Register the element with the specified tag name @define("my-greeter") class MyGreeter extends HTMLElement { // No built-in rendering functionality. Shadow DOM or light DOM? Template // strings, JSX, or something else entirely? You decide! #shadow = this.attachShadow({ mode: "open" }); // Define content attributes alongside corresponding getter/setter pairs // for a JS api and attribute change handling and type checking. If you use // TypeScript, the type checks will work at compile time *and* at run time @attr(string()) accessor name = "Anonymous"; @attr(number({ min: 0 })) accessor age = 0; // Mark the method as reactive to have it run every time one of the attributes // change, and also run it when the component first connects to the DOM. @reactive() @connected() greet() { this.#shadow.innerHTML = `Hello! My name is ${this.name}, my age is ${this.age}`; } }
123456789101112131415161718192021222324252627282930
import {
define,
attr,
string,
number,
connected,
reactive,
} from "@sirpepe/ornament";
// Register the element with the specified tag name
@define("my-greeter")
class MyGreeter extends HTMLElement {
// No built-in rendering functionality. Shadow DOM or light DOM? Template
// strings, JSX, or something else entirely? You decide!
#shadow = this.attachShadow({ mode: "open" });
// Define content attributes alongside corresponding getter/setter pairs
// for a JS api and attribute change handling and type checking. If you use
// TypeScript, the type checks will work at compile time *and* at run time
@attr(string()) accessor name = "Anonymous";
@attr(number({ min: 0 })) accessor age = 0;
// Mark the method as reactive to have it run every time one of the attributes
// change, and also run it when the component first connects to the DOM.
@reactive()
@connected()
greet() {
this.#shadow.innerHTML = `Hello! My name is ${this.name}, my age is ${this.age}`;
}
}Ornament makes quasi-vanilla web components fun and easy when compared to the equivalent boilerplate monstrosity that one would have to write by hand otherwise:
😱 Unveil the horror 😱
class MyGreeter extends HTMLElement { #shadow = this.attachShadow({ mode: "open" }); // Internal "name" and "age" states, initialized from the element's content // attributes, with default values in case the content attributes are not set. // The value for "age" has to be figured out with some imperative code in the // constructor to keep NaN off our backs. #name = this.getAttribute("name") || "Anonymous"; #age; constructor() { super(); // mandatory boilerplate let age = Number(this.getAttribute("age")); if (Number.isNaN(age)) { // Remember to keep NaN in check age = 0; } this.#age = 0; } // Remember to run the reactive method when connecting to the DOM connectedCallback() { this.greet(); } // Method to run each time `#name` or `#age` changes greet() { this.#shadow.innerHTML = `Hello! My name is ${this.#name}, my age is ${this.#age}`; } // DOM getter for the property, required to make JS operations like // `console.log(el.name)` work get name() { return this.#name; } // DOM setter for the property with type checking and/or conversion *and* // attribute updates, required to make JS operations like `el.name = "Alice"` // work. set name(value) { value = String(value); // Remember to convert/check the type! this.#name = value; this.setAttribute("name", value); // Remember to sync the content attribute! this.greet(); // Remember to run the method! } // DOM getter for the property, required to make JS operations like // `console.log(el.age)` work get age() { return this.#age; } // DOM setter for the property with type checking and/or conversion *and* // attribute updates, required to make JS operations like `el.age = 42` work. set age(value) { value = Number(value); // Remember to convert/check the type! if (Number.isNaN(value) || value < 0) { // Remember to keep NaN in check value = 0; } this.#age = value; this.setAttribute("age", value); // Remember to sync the content attribute! this.greet(); // Remember to run the method! } // Attribute change handling, required to make JS operations like // `el.setAttribute("name", "Bob")` update the internal element state. attributeChangedCallback(name, oldValue, newValue) { // Because `#name` is a string, and attribute values are always strings as // well we don't need to convert the types at this stage, but we still need // to manually make sure that we fall back to "Anonymous" if the new value // is null (if the attribute got removed) or if the value is (essentially) // an empty string if (name === "name") { if (newValue === null || newValue.trim() === "") { newValue = "Anonymous"; } this.#name = newValue; this.greet(); // Remember to run the method! } // But for "#age" we do again need to convert types, check for NaN, enforce // the min value of 0... if (name === "age") { const value = Number(value); // Remember to convert/check the type! if (Number.isNaN(value) || value < 0) { // Remember to keep NaN in check value = 0; } this.#age = value; this.greet(); // Remember to run the method! } } // Required for attribute change monitoring to work static get observedAttributes() { return ["name", "age"]; // remember to always keep this up to date } } // Finally remember to register the element window.customElements.define("my-greeter", MyGreeter);
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
class MyGreeter extends HTMLElement {
#shadow = this.attachShadow({ mode: "open" });
// Internal "name" and "age" states, initialized from the element's content
// attributes, with default values in case the content attributes are not set.
// The value for "age" has to be figured out with some imperative code in the
// constructor to keep NaN off our backs.
#name = this.getAttribute("name") || "Anonymous";
#age;
constructor() {
super(); // mandatory boilerplate
let age = Number(this.getAttribute("age"));
if (Number.isNaN(age)) {
// Remember to keep NaN in check
age = 0;
}
this.#age = 0;
}
// Remember to run the reactive method when connecting to the DOM
connectedCallback() {
this.greet();
}
// Method to run each time `#name` or `#age` changes
greet() {
this.#shadow.innerHTML = `Hello! My name is ${this.#name}, my age is ${this.#age}`;
}
// DOM getter for the property, required to make JS operations like
// `console.log(el.name)` work
get name() {
return this.#name;
}
// DOM setter for the property with type checking and/or conversion *and*
// attribute updates, required to make JS operations like `el.name = "Alice"`
// work.
set name(value) {
value = String(value); // Remember to convert/check the type!
this.#name = value;
this.setAttribute("name", value); // Remember to sync the content attribute!
this.greet(); // Remember to run the method!
}
// DOM getter for the property, required to make JS operations like
// `console.log(el.age)` work
get age() {
return this.#age;
}
// DOM setter for the property with type checking and/or conversion *and*
// attribute updates, required to make JS operations like `el.age = 42` work.
set age(value) {
value = Number(value); // Remember to convert/check the type!
if (Number.isNaN(value) || value < 0) {
// Remember to keep NaN in check
value = 0;
}
this.#age = value;
this.setAttribute("age", value); // Remember to sync the content attribute!
this.greet(); // Remember to run the method!
}
// Attribute change handling, required to make JS operations like
// `el.setAttribute("name", "Bob")` update the internal element state.
attributeChangedCallback(name, oldValue, newValue) {
// Because `#name` is a string, and attribute values are always strings as
// well we don't need to convert the types at this stage, but we still need
// to manually make sure that we fall back to "Anonymous" if the new value
// is null (if the attribute got removed) or if the value is (essentially)
// an empty string
if (name === "name") {
if (newValue === null || newValue.trim() === "") {
newValue = "Anonymous";
}
this.#name = newValue;
this.greet(); // Remember to run the method!
}
// But for "#age" we do again need to convert types, check for NaN, enforce
// the min value of 0...
if (name === "age") {
const value = Number(value); // Remember to convert/check the type!
if (Number.isNaN(value) || value < 0) {
// Remember to keep NaN in check
value = 0;
}
this.#age = value;
this.greet(); // Remember to run the method!
}
}
// Required for attribute change monitoring to work
static get observedAttributes() {
return ["name", "age"]; // remember to always keep this up to date
}
}
// Finally remember to register the element
window.customElements.define("my-greeter", MyGreeter);Ornament makes only the most tedious bits of building vanilla web components (attribute handling and lifecycle reactions) easy by adding some primitives that really should be part of the standard, but aren't. Ornament is not a framework, but something that you want to build your own framework on top of. Combine Ornament's baseline web component features with something like uhtml or Preact for rending, add your favorite state management library (or don't), write some glue code and enjoy your very own frontend web framework.
Tutorial: click counter with Preact
Step through this tutorial to see how a component built with Preact comes together, but note that you don't have to use Preact with Ornament! You can use tagged template literals, vanilla DOM manipulation, state could live in Signals … it's up to you!
12345678910111213141516171819202122232425
Regularwebcomponentaccessorstateimport{define,attr,prop,numberimport{Fragment,h,render}from"preact";//RegistertheclassaacustomHTMLtag@define("click-counter")classClickCounterextendsHTMLElement{//Publiccontentattributefortheinitialvalue@attr(number({min:0}))accessorvalue=0;//Internalattributeforthecurrentclickcount@prop(number({min:0}))accessor#count=this.value;//Component'srenderlogic,boiltwithpreact,@connected()//runmethodwhenthecomponentconnectsreactive,@reactive(connected}from"@sirpepe/ornament";#render(){render(<><buttononClick={()=>this.#count++}>+1</button>Total:<b>{this.#count}</b></>,this,);}}{keys:["#count"]}this.#countchanges)//runmethodwhenattributeschange
➡️➡️
// Regular web component class
class ClickCounter extends HTMLElement {
}// Regular web component class
class ClickCounter extends HTMLElement {
// Public accessor for the initial value
accessor value = 0;
// Internal state for the current click count
accessor #count = this.value;
}import { define } from "@sirpepe/ornament";
// Register the class a a custom HTML tag
@define("click-counter")
class ClickCounter extends HTMLElement {
// Public accessor for the initial value
accessor value = 0;
// Internal state for the current click count
accessor #count = this.value;
}import { define, attr, prop, number } from "@sirpepe/ornament";
// Register the class a a custom HTML tag
@define("click-counter")
class ClickCounter extends HTMLElement {
// Public content attribute for the initial value
@attr(number({ min: 0 })) accessor value = 0;
// Internal attribute for the current click count
@prop(number({ min: 0 })) accessor #count = this.value;
}import { define, attr, prop, number } from "@sirpepe/ornament";
import { Fragment, h, render } from "preact";
// Register the class a a custom HTML tag
@define("click-counter")
class ClickCounter extends HTMLElement {
// Public content attribute for the initial value
@attr(number({ min: 0 })) accessor value = 0;
// Internal attribute for the current click count
@prop(number({ min: 0 })) accessor #count = this.value;
}import { define, attr, prop, number } from "@sirpepe/ornament";
import { Fragment, h, render } from "preact";
// Register the class a a custom HTML tag
@define("click-counter")
class ClickCounter extends HTMLElement {
// Public content attribute for the initial value
@attr(number({ min: 0 })) accessor value = 0;
// Internal attribute for the current click count
@prop(number({ min: 0 })) accessor #count = this.value;
// Component's render logic, boilt with preact
#render() {}
}import { define, attr, prop, number } from "@sirpepe/ornament";
import { Fragment, h, render } from "preact";
// Register the class a a custom HTML tag
@define("click-counter")
class ClickCounter extends HTMLElement {
// Public content attribute for the initial value
@attr(number({ min: 0 })) accessor value = 0;
// Internal attribute for the current click count
@prop(number({ min: 0 })) accessor #count = this.value;
// Component's render logic, boilt with preact
#render() {
render(<></>, this);
}
}import { define, attr, prop, number } from "@sirpepe/ornament";
import { Fragment, h, render } from "preact";
// Register the class a a custom HTML tag
@define("click-counter")
class ClickCounter extends HTMLElement {
// Public content attribute for the initial value
@attr(number({ min: 0 })) accessor value = 0;
// Internal attribute for the current click count
@prop(number({ min: 0 })) accessor #count = this.value;
// Component's render logic, boilt with preact
#render() {
render(
<>
<button onClick={() => this.#count++}>+1</button>
</>,
this,
);
}
}import { define, attr, prop, number } from "@sirpepe/ornament";
import { Fragment, h, render } from "preact";
// Register the class a a custom HTML tag
@define("click-counter")
class ClickCounter extends HTMLElement {
// Public content attribute for the initial value
@attr(number({ min: 0 })) accessor value = 0;
// Internal attribute for the current click count
@prop(number({ min: 0 })) accessor #count = this.value;
// Component's render logic, boilt with preact
#render() {
render(
<>
<button onClick={() => this.#count++}>+1</button>
Total: <b>{this.#count}</b>
</>,
this,
);
}
}import { define, attr, prop, number, connected } from "@sirpepe/ornament";
import { Fragment, h, render } from "preact";
// Register the class a a custom HTML tag
@define("click-counter")
class ClickCounter extends HTMLElement {
// Public content attribute for the initial value
@attr(number({ min: 0 })) accessor value = 0;
// Internal attribute for the current click count
@prop(number({ min: 0 })) accessor #count = this.value;
// Component's render logic, boilt with preact
@connected() // run method when the component connects
#render() {
render(
<>
<button onClick={() => this.#count++}>+1</button>
Total: <b>{this.#count}</b>
</>,
this,
);
}
}
import { define, attr, prop, number, reactive, connected } from "@sirpepe/ornament";
import { Fragment, h, render } from "preact";
// Register the class a a custom HTML tag
@define("click-counter")
class ClickCounter extends HTMLElement {
// Public content attribute for the initial value
@attr(number({ min: 0 })) accessor value = 0;
// Internal attribute for the current click count
@prop(number({ min: 0 })) accessor #count = this.value;
// Component's render logic, boilt with preact
@connected() // run method when the component connects
@reactive() // run method when attributes change
#render() {
render(
<>
<button onClick={() => this.#count++}>+1</button>
Total: <b>{this.#count}</b>
</>,
this,
);
}
}import { define, attr, prop, number, reactive, connected } from "@sirpepe/ornament";
import { Fragment, h, render } from "preact";
// Register the class a a custom HTML tag
@define("click-counter")
class ClickCounter extends HTMLElement {
// Public content attribute for the initial value
@attr(number({ min: 0 })) accessor value = 0;
// Internal attribute for the current click count
@prop(number({ min: 0 })) accessor #count = this.value;
// Component's render logic, boilt with preact
@connected() // run method when the component connects
@reactive({ keys: ["#count"] }) // run method when this.#count changes
#render() {
render(
<>
<button onClick={() => this.#count++}>+1</button>
Total: <b>{this.#count}</b>
</>,
this,
);
}
}- Start with a regular, vanilla web component class.
- Model the component's state using class accessors. Alternatively, you could store state in signals or on objects that implement
EventTarget. - Import the decorator
@define()to register your custom element class. - Use decorators
@attr()and@prop()to define content and IDL attributes respectively. Thenumber()-transformer plugs type-checking into the attributes. - Import Preact's functionality. Note that miuch of this can be abstracted away if you choose to write your own base class or micro-framework.
- Write a method to handle rendering. Can be public, can be private, it's up to you.
- Time to write some JSX. Don't like JSX? Then just use something else for rendering!
- Listen for events. You could alternatively leverage event delegation in the component class, if that's more up your alley.
- Show the current click count. Note that this could just as well be rendered into Shadow DOM if you need more encapsulation.
- Use the decorator
@connected()to run the render method when the component connects to the DOM. - Use the decorator
@reactive()to run the render method when any attributes change. But since we only really care about#count… - … we make sure to only render-when something relevant to the method changes.
@reactive()has multiple filtering options.
Guide
Installation
Install @sirpepe/ornament with your favorite package manager. To get the decorator syntax working in 2025, you will probably need some tooling support, such as:
- @babel/plugin-proposal-decorators
(with the option
versionset to"2023-11") - esbuild (with the option
targetset toesnext) - TypeScript 5.0+
(with the option
experimentalDecoratorsturned off).
Apart from that, Ornament is just a bunch of functions. No further setup required.
General philosophy
The native APIs for web components are verbose and imperative, but lend themselves to quite a bit of streamlining with the upcoming syntax for ECMAScript Decorators. The native APIs are also missing a few important primitives. Ornament's goal is to provide the missing primitives and to streamline the developer experience. Ornament is not a framework but instead aims to be:
- as stable as possible by remaining dependency-free, keeping its own code to an absolute minimum, and relying on iron-clad web standards where possible
- fast and lean by being nothing more than just a bag of relatively small and simple functions
- supportive of gradual adoption and removal by being able to co-exist with vanilla web component code
- malleable by being easy to extend, easy to customize, and easy to get rid of
- universal by adhering to (the spirit of) web standards, thereby staying compatible with vanilla web component code as well as all sorts of web frameworks
- equipped with useful type definitions (and work within the constraints of TypeScript)
Ornament is infrastructure for web components and not a framework itself. It makes dealing with the native APIs bearable and leaves building something actually sophisticated up to you. Ornament does not come with any of the following:
- State management (even though it is simple to connect components to signals or event targets)
- Rendering (but it works well with uhtml, Preact and similar libraries)
- Built-in solutions for client-side routing, data fetching, or really anything beyond the components themselves
- Any preconceived notions about what should be going on server-side
- Specialized syntax or tooling for every (or any specific) use case
You can (and probably have to) therefore pick or write your own solutions for
the above features. Check out the examples folder for inspiration! The
examples can be built using npm run build-examples.
Exit strategy
Every good library should come with an exit strategy as well as install instructions. Here is how you can get rid of Ornament if you want to migrate away:
- Components built with Ornament will generally turn out to be very close to vanilla web components, so they will most probably just keep working when used with other frameworks/libraries. You can theoretically just keep your components and replace them only when the need for change arises.
- If you want to replace Ornament with hand-written logic for web components,
you can replace all attribute and update handling piecemeal. Ornament's
decorators co-exist with native
attributeChangedCallback()and friends just fine. Ornament extends what you can do with custom elements, it does not abstract anything away. - Much of your migration will depend on how you build on top of Ornament. You should keep reusable components and app-specific state containers separate, just as you would do in e.g. React. This will make maintenance and eventual migration much easier, but this is really outside of Ornament's area of responsibility.
In general, migrating away should not be too problematic. The components that you will build with Ornament will naturally tend to be self-contained and universal, and will therefore more or less always keep chugging along.
Decorators
API overview
| Decorator | Class element | static |
#private |
Symbols | Summary |
|---|---|---|---|---|---|
@define() |
Class | - | - | - | Register a custom element class with a tag name and set it up for use with Ornament's other decorators |
@enhance() |
Class | - | - | - | Set up a custom element class for use with Ornament's other decorators, but do not register it with a tag name |
@prop() |
Accessor | ✕ | ✓ | ✓ | Define an accessor to work as a property with a given data type |
@attr() |
Accessor | ✕ | ✓[^1] | ✓[^1] | Define an accessor to work as a content attribute and associated property with a given data type |
@state() |
Accessor | ✕ | ✓ | ✓[^2] | Track the accessor's value in the element's CustomStateSet |
@reactive() |
Method, Field[^3] | ✕ | ✓ | ✓ | Run a method or class field function when accessors decorated with @prop() or @attr() change value (with optional conditions) |
@init() |
Method, Field[^3] | ✕ | ✓ | ✓ | Run a method or class field function after the class constructor finishes |
@connected() |
Method, Field[^3] | ✕ | ✓ | ✓ | Run a method or class field function when the element connects to the DOM |
@disconnected() |
Method, Field[^3] | ✕ | ✓ | ✓ | Run a method or class field function when the element disconnects from the DOM |
@connectedMove() |
Method, Field[^3] | ✕ | ✓ | ✓ | Run a method or class field function when the element is moved with moveBefore() |
@adopted() |
Method, Field[^3] | ✕ | ✓ | ✓ | Run a method or class field function when the element is adopted by a new document |
@formAssociated() |
Method, Field[^3] | ✕ | ✓ | ✓ | Run a method or class field function when the element is associated with a form element |
@formReset() |
Method, Field[^3] | ✕ | ✓ | ✓ | Run a method or class field function when the element's form owner resets |
@formDisabled() |
Method, Field[^3] | ✕ | ✓ | ✓ | Run a method or class field function when the element's ancestor fieldset is disabled |
@formStateRestore() |
Method, Field[^3] | ✕ | ✓ | ✓ | Run a method or class field function when the element's formStateRestoreCallback fires |
@subscribe() |
Accessor, Method, Field[^3] | ✕ | ✓ | ✓ | Update a reactive accessor or run a method or class field function to react to changes to a signal or to events on an EventTarget |
@observe() |
Method, Field[^3] | ✕ | ✓ | ✓ | Run a method or class field function as a callback for an IntersectionObserver, MutationObserver, or ResizeObserver |
@debounce() |
Method, Field[^3] | ✓ | ✓ | ✓ | Debounce a method or class field function, (including static) |
[^1]:
Can be #private or a symbol if a non-private non-symbol getter/setter
pair for the attribute name exists and a content attribute name has been
set using the as option.
[^2]: Can be a symbol if a string value has been provided for the state field
[^3]: Class field values must be of type function
@define(tagName: string, options: ElementDefinitionOptions = {}, registry: CustomElementRegistry = window.customElements)
Class decorator to register a class as a custom element, basically an
alternative syntax for customElements.define();
import { define } from "@sirpepe/ornament"; @define("my-test") class MyTest extends HTMLElement {} console.log(document.createElement("my-test")); // instance of MyTest
123456
import { define } from "@sirpepe/ornament";
@define("my-test")
class MyTest extends HTMLElement {}
console.log(document.createElement("my-test")); // instance of MyTest@define() also sets up attribute observation for use with the @attr()
decorator, prepares the hooks for lifecycle decorators like @connected() and
ensures that property upgrades for previously undefined elements happen in a
predictable fashion. Most of Ornament's features will only work if the component class is decorated with either @define() or @enhance()!
What are safe upgrades?
HTML tags can be used even if the browser does not (yet) know about them, and this also works with web components - the browser can upgrade custom elements event after the parser has processed them as unknown elements. But this can lead to unexpected behavior when properties are set on elements that have not yet been properly defined, shadowing relevant accessors on the prototype:
const x = document.createElement("hello-world"); // "x" = unknown element = object with "HTMLElement.prototype" as prototype x.data = 42; // "x" now has an _own_ property data=42 // Implements an accessor for hello-world. The getters and // setters end up as properties on the prototype class HelloWorld extends HTMLElement { accessor data = 23; } window.customElements.define("hello-world", HelloWorld); // It is now clear that "x" should have had "HelloWorld.prototype" as its // prototype all along window.customElements.upgrade(x); // "x" now gets "HelloWorld.prototype" as its prototype (with the accessor) console.log(x.data); // logs 42, bypassing the getter - "x" itself has an own property "data", the // accessor on the prototype is shadowed
12345678910111213141516171819202122
const x = document.createElement("hello-world");
// "x" = unknown element = object with "HTMLElement.prototype" as prototype
x.data = 42;
// "x" now has an _own_ property data=42
// Implements an accessor for hello-world. The getters and
// setters end up as properties on the prototype
class HelloWorld extends HTMLElement {
accessor data = 23;
}
window.customElements.define("hello-world", HelloWorld);
// It is now clear that "x" should have had "HelloWorld.prototype" as its
// prototype all along
window.customElements.upgrade(x);
// "x" now gets "HelloWorld.prototype" as its prototype (with the accessor)
console.log(x.data);
// logs 42, bypassing the getter - "x" itself has an own property "data", the
// accessor on the prototype is shadowedOrnament ensures safe upgrades, always making sure that no prototype accessors for attributes are ever shadowed by properties defined before an element was properly upgraded.
Notes for TypeScript
You should add your custom element's interface to HTMLElementTagNameMap to
make it work with native DOM APIs:
@define("my-test") export class MyTest extends HTMLElement { foo = 1; } declare global { interface HTMLElementTagNameMap { "my-test": MyTest; } } let test = document.createElement("my-test"); console.log(test.foo); // only type checks with the above interface declaration
12345678910111213
@define("my-test")
export class MyTest extends HTMLElement {
foo = 1;
}
declare global {
interface HTMLElementTagNameMap {
"my-test": MyTest;
}
}
let test = document.createElement("my-test");
console.log(test.foo); // only type checks with the above interface declarationIf you want to run your component code in a non-browser environment like JSDOM,
you can pass the JSDOM's CustomElementRegistry as the third argument to
@define().
@enhance()
Class decorator to set up attribute observation and lifecycle hooks without registering the class as a custom element.
import { enhance } from "@sirpepe/ornament"; @enhance() class MyTest extends HTMLElement {} // MyTest can only be instantiated when it has been registered as a custom // element. Because we use @enhance() instead of @define() in this example, we // have to take care of this manually. window.customElements.define("my-test", MyTest); console.log(document.createElement("my-test")); // instance of MyTest
1234567891011
import { enhance } from "@sirpepe/ornament";
@enhance()
class MyTest extends HTMLElement {}
// MyTest can only be instantiated when it has been registered as a custom
// element. Because we use @enhance() instead of @define() in this example, we
// have to take care of this manually.
window.customElements.define("my-test", MyTest);
console.log(document.createElement("my-test")); // instance of MyTestThis decorator is only really useful if you need to handle element registration
in some other way than what @define() provides. It is safe to apply
@enhance() more than once on a class, or on both (or either) a base class and
subclass:
import { enhance } from "@sirpepe/ornament"; // Not useful, but also not a problem @enhance() @enhance() @enhance() class MyTest0 extends HTMLElement {} // Works @enhance() class Base1 extends HTMLElement {} class MyTest1 extends Base1 {} // Works class Base2 extends HTMLElement {} @enhance() class MyTest2 extends Base2 {} // Works @enhance() class Base3 extends HTMLElement {} @enhance() class MyTest3 extends Base3 {}
1234567891011121314151617181920212223
import { enhance } from "@sirpepe/ornament";
// Not useful, but also not a problem
@enhance()
@enhance()
@enhance()
class MyTest0 extends HTMLElement {}
// Works
@enhance()
class Base1 extends HTMLElement {}
class MyTest1 extends Base1 {}
// Works
class Base2 extends HTMLElement {}
@enhance()
class MyTest2 extends Base2 {}
// Works
@enhance()
class Base3 extends HTMLElement {}
@enhance()
class MyTest3 extends Base3 {}Remember that most of Ornament's features will only work if the component class is decorated with either @define() or @enhance().
@prop(transformer: Transformer<any, any>)
Accessor decorator to define a property on the custom element class without an associated content attribute. Such a property is more or less a regular accessor with two additional features:
- it uses transformers for type checking and validation
- changes cause class members decorated with
@reactive()to run
Example:
import { define, prop, number } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { // Applies the number transformer to ensure that foo is always a number @prop(number()) accessor foo = 23; // Automatically runs when "foo" (or any accessor decorated with @prop() or // @attr()) changes @reactive() log() { console.log(`Foo changed to ${this.foo}`); } } let testEl = document.createElement("my-test"); console.log(testEl.foo); // logs 23 testEl.foo = 42; // logs "Foo changed to 42" console.log(testEl.foo); // logs 42 testEl.foo = "asdf"; // throw exception (thanks to the number transformer)
1234567891011121314151617181920
import { define, prop, number } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
// Applies the number transformer to ensure that foo is always a number
@prop(number()) accessor foo = 23;
// Automatically runs when "foo" (or any accessor decorated with @prop() or
// @attr()) changes
@reactive()
log() {
console.log(`Foo changed to ${this.foo}`);
}
}
let testEl = document.createElement("my-test");
console.log(testEl.foo); // logs 23
testEl.foo = 42; // logs "Foo changed to 42"
console.log(testEl.foo); // logs 42
testEl.foo = "asdf"; // throw exception (thanks to the number transformer)Accessors defined with @prop() work as a JavaScript-only API. Values can
only be accessed through the accessor's getter, invalid values are rejected by
the setter with exceptions. @prop() can be used on private accessors or
symbols without problem.
Note that you can still define your own accessors, getters, setters etc. as you
would usually do. They will still work as expected, but they will not cause
@reactive() methods to run.
@attr(transformer: Transformer<any, any>, options: AttrOptions = {})
Accessor decorator to define a property with a matching content attribute on
the custom element class. This results in something very similar to
accessors decorated with @prop(), but with the following additional features:
- Its value can be initialized from a content attribute, if the attribute is present
- Changes to the content attribute's value (eg. via
setAttribute()) update the value of the property to match (depending on the options and the transformer)
What's the deal with content attributes?
Getting attribute handling on Web Components right is hard, because many
different APIs and states need to interact in just the right way and the related
code tends to end up scattered across various class members. Attributes on HTML
elements have two faces: the content attribute and the DOM property.
Content attributes are always strings and are defined either via HTML or via DOM
methods like setAttribute(). DOM properties can be accessed via object
properties such as someElement.foo and may be of any type. Both faces of
attributes need to be implemented and properly synced up for an element to be
truly compatible with any software out there - a JS frontend framework may work
primarily with properties, while HTML authors or server-side rendering
software will work with content attributes.
Keeping content and properties in sync can entail any or all of the following tasks:
- Updating the content attribute when the property gets changed (eg. update the HTML attribute
idwhen runningelement.id = "foo"in JS) - Updating the property when the content attribute gets changed (eg.
element.idshould return"bar"afterelement.setAttribute("id", "bar")) - Converting types while updating content and/or properties (an attribute may be a
numberas a property, but content attributes are by definition always strings) - Rejecting invalid types on the property setter (as opposed to converting types from content to properties which, like all of HTML, never throws an error)
- Connecting properties and content attributes with different names (like how the content attribute
classmaps to the propertyclassName) - Fine-tuning the synchronization behavior depending on circumstances (see the interaction between the
valuecontent and properties on<input>) - Remembering to execute side effects (like updating Shadow DOM) when any properties and/or content attribute changes
This is all very annoying to write by hand, but because the above behavior is more or less the same for all attributes, it is possible to to simplify the syntax quite a bit:
import { attr, number } from "@sirpepe/ornament"; @define("my-test") class MyTest extends HTMLElement { @attr(number({ min: -100, max: 100 })) accessor value = 0; @reactive() log() { console.log(this.value); } }
1234567891011
import { attr, number } from "@sirpepe/ornament";
@define("my-test")
class MyTest extends HTMLElement {
@attr(number({ min: -100, max: 100 })) accessor value = 0;
@reactive()
log() {
console.log(this.value);
}
}The line starting with with @attr gets you a content and a matching DOM
property named value, which...
- Always reflects a number between
-100and100 - Initializes from the content attribute and falls back to the initializer value
0if the attribute is missing or can't be interpreted as a number - Automatically updates the content attribute with the stringified value of the property when the property is updated
- Automatically updates the property when the content attribute is updated (it parses the attribute value into a number and clamps it to the specified range)
- Implements getters and setters for the properties, with the getter always returning a number and the setter rejecting invalid values (non-numbers or numbers outside the specified range of
[-100, 100]) - Causes the method marked
@reactive()to run on update
You can use @prop() for standalone properties (that is, DOM properties
without an associated content attributes), swap out the number() transformer
for something else, or combine any of the above with hand-written logic.
import { define, attr, number } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { // Applies the number transformer to ensure that content attribute values get // parsed into numbers and that new non-number values passed to the property // setter get rejected @attr(number()) accessor foo = 23; // 23 = fallback value // Automatically runs when "foo", or any accessor decorated with @prop() or // @attr(), changes (plus once on element initialization) @reactive() log() { console.log(`Foo changed to ${this.foo}`); } } document.body.innerHTML = `<my-test foo="42"></my-test>`; let testEl = document.querySelector("my-test"); console.log(testEl.foo); // logs 42 (initialized from the attribute) testEl.foo = 1337; // logs "Foo changed to 1337" console.log(testEl.foo); // logs 1337 console.log(testEl.getAttribute("foo")); // logs "1337" testEl.foo = "asdf"; // throw exception (thanks to the number transformer) testEl.setAttribute("foo", "asdf"); // works, content attributes can be any string console.log(testEl.foo); // logs 23 (fallback value)
12345678910111213141516171819202122232425
import { define, attr, number } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
// Applies the number transformer to ensure that content attribute values get
// parsed into numbers and that new non-number values passed to the property
// setter get rejected
@attr(number()) accessor foo = 23; // 23 = fallback value
// Automatically runs when "foo", or any accessor decorated with @prop() or
// @attr(), changes (plus once on element initialization)
@reactive() log() {
console.log(`Foo changed to ${this.foo}`);
}
}
document.body.innerHTML = `<my-test foo="42"></my-test>`;
let testEl = document.querySelector("my-test");
console.log(testEl.foo); // logs 42 (initialized from the attribute)
testEl.foo = 1337; // logs "Foo changed to 1337"
console.log(testEl.foo); // logs 1337
console.log(testEl.getAttribute("foo")); // logs "1337"
testEl.foo = "asdf"; // throw exception (thanks to the number transformer)
testEl.setAttribute("foo", "asdf"); // works, content attributes can be any string
console.log(testEl.foo); // logs 23 (fallback value)Accessors defined with @attr() work like all other supported attributes on
built-in elements. Content attribute values (which are always strings) get
parsed by the transformer, which also deals with invalid values in a graceful
way (i.e. without throwing exceptions). Values can also be accessed through the
DOM property's accessor, where invalid values are rejected with exceptions by
the setter.
@attr() can only be used on private accessors or symbols only if the following
holds true:
- The option
asmust be set - A non-private, non-symbol getter/setter pair for the attribute name defined in the option
asmust exist on the custom element class
Content attributes always have public DOM properties, and ornament enforces
this. A private/symbol attribute accessor with a manually-provided public facade
may be useful if you want to attach some additional logic to the public API
(= hand-written getters and setters) while still having the convenience of of
using @attr on an accessor.
Note that you can still define your own attribute handling with
attributeChangedCallback() and static get observedAttributes() as you would
usually do. This will keep working work as expected, but changes to such
attributes will not cause @reactive() methods to run.
Options for @attr()
as(string, optional): Sets an attribute name different from the accessor's name, similar to how theclasscontent attribute works for theclassNameproperty on built-in elements. Ifasis not set, the content attribute's name will be equal to the accessor's name.asis required when the decorator is applied to a symbol or private property.reflective(boolean, optional): Iffalse, prevents the content attribute from updating when the property is updated, similar to howvalueworks oninputelements. Defaults to true.
@state(options: StateOptions = {})
Accessor decorator that tracks the accessor's value in the element's
CustomStateSet.
By default, the state's name is the decorated member's name and Boolean is
used to decide whether a state should be added or removed from the set.
import { define, state } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @state() accessor foo = 0; // Default: tracks the state "foo" by transforming the value with Boolean() @state({ name: "isOdd", toBoolean: (value) => value % 2 !== 0 }) accessor bar = 0; // Custom: tracks "bar" as "isOff" by transforming the value with toBoolean() } let testEl = document.createElement("my-test"); // Custom state "foo" is not set, since Boolean(0) === false console.log(testEl.matches(":state(foo)")); // > false // Custom state "isOdd" is not set, since (value % 2 !== 0) === false console.log(testEl.matches(":state(isOdd)")); // > false testEl.foo = 1; testEl.bar = 1; // Custom state "foo" is set, since Boolean(1) === true console.log(testEl.matches(":state(foo)")); // > true // Custom state "isOdd" is set, since (value % 2 !== 0) === true console.log(testEl.matches(":state(isOdd)")); // > true
1234567891011121314151617181920212223242526272829
import { define, state } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@state()
accessor foo = 0;
// Default: tracks the state "foo" by transforming the value with Boolean()
@state({ name: "isOdd", toBoolean: (value) => value % 2 !== 0 })
accessor bar = 0;
// Custom: tracks "bar" as "isOff" by transforming the value with toBoolean()
}
let testEl = document.createElement("my-test");
// Custom state "foo" is not set, since Boolean(0) === false
console.log(testEl.matches(":state(foo)")); // > false
// Custom state "isOdd" is not set, since (value % 2 !== 0) === false
console.log(testEl.matches(":state(isOdd)")); // > false
testEl.foo = 1;
testEl.bar = 1;
// Custom state "foo" is set, since Boolean(1) === true
console.log(testEl.matches(":state(foo)")); // > true
// Custom state "isOdd" is set, since (value % 2 !== 0) === true
console.log(testEl.matches(":state(isOdd)")); // > trueThe decorator works with private accessors. If no name option is provided, the
custom state name includes the # sign. Use on symbol accessors requires the
name option.
To properly combine with @prop() and @attr(), @state() should be applied
to the accessor last to benefit from the type checking and/or conversion
provided from the other decorators:
import { define, state, prop, number } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @prop(number({ min: 0 })) // <- @prop() comes first @state({ toBoolean: (x) => x % 2 === 0 }) // <- @state() comes last accessor foo = 0; } const testEl = new Test(); // state "foo" is true testEl.foo = 1; // state "foo" is false try { testEl.foo = -2; // <- this fails } catch { // state "foo" on el still false; @prop intercepted the set operation }
1234567891011121314151617181920
import { define, state, prop, number } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@prop(number({ min: 0 })) // <- @prop() comes first
@state({ toBoolean: (x) => x % 2 === 0 }) // <- @state() comes last
accessor foo = 0;
}
const testEl = new Test();
// state "foo" is true
testEl.foo = 1;
// state "foo" is false
try {
testEl.foo = -2; // <- this fails
} catch {
// state "foo" on el still false; @prop intercepted the set operation
}Options for @state()
name(string, optional): name of the state in the CustomStateSet. Ifnameis not set, the state's name will be equal to the accessor's name.nameis required when the decorator is applied to a symbol.toBoolean(((value, instance) => boolean), optional): Function to transform the accessor's value into a boolean, which in turn decides whether a state should be added or removed from the set. Defaults to theBooleanfunction. Called withthisset to the component instance.
@reactive(options: ReactiveOptions = {})
Method and class field decorator that runs class members when accessors
decorated with @prop() or @attr() change their values:
import { define, reactive, prop, number } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @prop(number()) accessor foo = 0; @prop(number()) accessor bar = 0; @reactive() log() { console.log(`foo is now ${this.foo}, bar is now ${this.bar}`); } } let testEl = document.createElement("my-test"); testEl.foo = 1; testEl.bar = 2; // first logs "foo is now 1, bar is now 0" // then logs "foo is now 1, bar is now 2"
12345678910111213141516171819
import { define, reactive, prop, number } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@prop(number()) accessor foo = 0;
@prop(number()) accessor bar = 0;
@reactive()
log() {
console.log(`foo is now ${this.foo}, bar is now ${this.bar}`);
}
}
let testEl = document.createElement("my-test");
testEl.foo = 1;
testEl.bar = 2;
// first logs "foo is now 1, bar is now 0"
// then logs "foo is now 1, bar is now 2"Decorated members are called with no arguments. They react to changes to the
instances' internal state and should therefore be able to access all relevant
data through this. In many cases you may want to apply @reactive() to
methods decorated with @debounce() to prevent excessive
calls.
The predicate and/or keys options can be used to control whether the
decorated method or function reacts to an update. For the decorated member to
run, the following needs to be true:
options.keysmust either have been omitted or must contain the property or content attribute name that changedoptions.excludeKeysmust either have been omitted or must not contain the property or content attribute name that changedoptions.predicatemust either have been omitted or must return true when called immediately before the function is scheduled to run
Options for @reactive()
keys(Array<string | symbol>, optional): List of attributes (defined by@prop()or@attr()) to monitor. Can include private names and symbols. Defaults to monitoring all content attributes and properties defined by@prop()or@attr().excludeKeys(Array<string | symbol>, optional): List of attributes and properties (defined by@prop()or@attr()) not to monitor. Can include private names and symbols. Defaults to an empty array.predicate(Function(this: T, prop: string | symbol, newValue: any, instance: T) => boolean): If provided, controls whether or not the decorated method is called for a given change. Note that this function is not part of the class declaration itself and can therefore not access private fields oninstance, but the predicate function gets passed the affected property's name and new value.
@init()
Method and class field decorator that runs class members when the class constructor finishes. This has the same effect as adding method calls to the end of the constructor's body.
import { define, init } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { constructor() { super(); console.log(23); } @init() log() { console.log(42); } } let testEl = document.createElement("my-test"); // first logs 23, then logs 42
1234567891011121314151617
import { define, init } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
constructor() {
super();
console.log(23);
}
@init()
log() {
console.log(42);
}
}
let testEl = document.createElement("my-test");
// first logs 23, then logs 42This decorator is particularly useful if you need to run @reactive() methods
once on component initialization. Decorated members are run with no arguments
and always right after the constructor finishes. This even extends to methods
and class field functions decorated with @debounce().
@connected()
Method and class field decorator that runs class members when the component
connects to the DOM and the component's connectedCallback() fires:
import { define, connected } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @connected() log() { console.log("Connected!"); } } let testEl = document.createElement("my-test"); document.body.append(testEl); // testEl.log logs "Connected!"
12345678910111213
import { define, connected } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@connected()
log() {
console.log("Connected!");
}
}
let testEl = document.createElement("my-test");
document.body.append(testEl);
// testEl.log logs "Connected!"Decorated members are run with no arguments. You can also still use the regular
connectedCallback().
If @connectedMove() is used anywhere in the class definition, @connected()
will not run its target when the element is moved with
moveBefore().
This mirrors the behavior of connectedCallback() when used in conjunction with
connectedMoveCallback(). If you use connectedCallback(), its behavior
depends on the presence of connectedMoveCallback() - Ornament and vanilla
lifecycle callbacks operate in separate spheres.
@disconnected()
Method and class field decorator that runs decorated class members when the
component disconnects from the DOM and the component's disconnectedCallback()
fires:
import { define, disconnected } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @disconnected() log() { console.log("Disconnected!"); } } let testEl = document.createElement("my-test"); document.body.append(testEl); testEl.remove(); // testEl.log logs "Disconnected!"
1234567891011121314
import { define, disconnected } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@disconnected()
log() {
console.log("Disconnected!");
}
}
let testEl = document.createElement("my-test");
document.body.append(testEl);
testEl.remove();
// testEl.log logs "Disconnected!"Decorated members are run with no arguments. You can also still use the regular
disconnectedCallback().
If @connectedMove() is used anywhere in the class definition,
@disconnected() will not run its target when the element is moved with
moveBefore().
This mirrors the behavior of disconnectedCallback() when used in conjunction
with connectedMoveCallback(). If you use disconnectedCallback(), its
behavior depends on the presence of connectedMoveCallback() - Ornament and
vanilla lifecycle callbacks operate in separate spheres.
@connectedMove()
Method and class field decorator that runs decorated class members when the
component is moved around the DOM with moveBefore()
and its connectedMoveCallback() fires:
import { define, connectedMove } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @connectedMove() log() { console.log("Moved!"); } } let testEl = document.createElement("my-test"); document.body.append(document.createElement("div"), testEl); document.body.moveBefore(testEl, document.body.firstChild); // testEl.log logs "Moved!"
1234567891011121314
import { define, connectedMove } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@connectedMove()
log() {
console.log("Moved!");
}
}
let testEl = document.createElement("my-test");
document.body.append(document.createElement("div"), testEl);
document.body.moveBefore(testEl, document.body.firstChild);
// testEl.log logs "Moved!"Decorated members are run with no arguments. You can also still use the regular
disconnectedCallback().
If @connectedMove() is used anywhere in the class definition, @connected()
and @disconnected() will not run their targets on use of moveBefore().
This mirrors the behavior of connectedCallback() and disconnectedCallback()
when used in conjunction with connectedMoveCallback().
The execution of vanilla connectedCallback() and disconnectedCallback() is
not influenced by any of the decorators - Ornament and vanilla lifecycle
callbacks operate in separate spheres.
@adopted()
Method and class field decorator that runs decorated class members when the
component is moved to a new document and the component's adoptedCallback()
fires:
import { define, adopted } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @adopted() log() { console.log("Adopted!"); } } let testEl = document.createElement("my-test"); const newDocument = new Document(); newDocument.adoptNode(testEl); // testEl.log logs "Adopted!"
12345678910111213
import { define, adopted } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@adopted() log() {
console.log("Adopted!");
}
}
let testEl = document.createElement("my-test");
const newDocument = new Document();
newDocument.adoptNode(testEl);
// testEl.log logs "Adopted!"Decorated members are run with no arguments. You can also still use the regular
adoptedCallback().
@formAssociated()
Method and class field decorator that runs decorated class members when a
form-associated component's form owner changes and its
formAssociatedCallback() fires:
import { define, formAssociated } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { static formAssociated = true; @formAssociated() log(newOwner) { console.log(newOwner); // null or HTMLFormElement } } let testEl = document.createElement("my-test"); let form = document.createElement("form"); form.append(testEl); // testEl.log logs "form"
1234567891011121314
import { define, formAssociated } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
static formAssociated = true;
@formAssociated() log(newOwner) {
console.log(newOwner); // null or HTMLFormElement
}
}
let testEl = document.createElement("my-test");
let form = document.createElement("form");
form.append(testEl);
// testEl.log logs "form"Decorated members are passed the new form owner (if any) as an argument. You can
also still use the regular formAssociatedCallback().
@formReset()
Method and class field decorator that runs decorated class members when a
form-associated component's form owner resets and its formResetCallback()
fires:
import { define, formReset } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { static formAssociated = true; @formReset() log() { console.log("Reset!"); } } let testEl = document.createElement("my-test"); let form = document.createElement("form"); form.append(testEl); form.reset(); // ... some time passes... // testEl.log logs "Reset!"
12345678910111213141516
import { define, formReset } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
static formAssociated = true;
@formReset() log() {
console.log("Reset!");
}
}
let testEl = document.createElement("my-test");
let form = document.createElement("form");
form.append(testEl);
form.reset();
// ... some time passes...
// testEl.log logs "Reset!"Decorated members are run with no arguments. You can also still use the regular
formResetCallback().
Note that form reset events are observably asynchronous, unlike all other
lifecycle events. This is due to the form reset algorithm (and therefore the
vanilla formResetCallback()) itself being async.
@formDisabled()
Method and class field decorator that runs decorated class members when a
form-associated component's fieldset gets disabled and its
formDisabledCallback() fires:
import { define, formDisabled } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { static formAssociated = true; @formDisabled() log(state) { console.log("Disabled via fieldset:", state); // true or false } } let testEl = document.createElement("my-test"); let fieldset = document.createElement("fieldset"); let form = document.createElement("form"); form.append(fieldset); fieldset.append(testEl); fieldset.disabled = true; // testEl.log logs "Disabled via fieldset: true"
1234567891011121314151617
import { define, formDisabled } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
static formAssociated = true;
@formDisabled() log(state) {
console.log("Disabled via fieldset:", state); // true or false
}
}
let testEl = document.createElement("my-test");
let fieldset = document.createElement("fieldset");
let form = document.createElement("form");
form.append(fieldset);
fieldset.append(testEl);
fieldset.disabled = true;
// testEl.log logs "Disabled via fieldset: true"Decorated members are passed the new form disabled state as an argument. You
can also still use the regular formDisabledCallback().
@formStateRestore()
Method and class field decorator that causes runs decorated class methods
when a form-associated component's formStateRestoreCallback() fires. This does
not work in Chrome-based browsers as of November 2023.
@subscribe(...args)
Accessor, method or class field decorator that subscribes to either
Event Targets or
signals, depending on the arguments. If
the decorated class member is a method or a function, it runs when the
EventTarget dispatches a new event or when the signal receives a new value. If
the decorated member is an accessor, it gets updated with the last event object
(for event targets) or signal values (for signals). You can decorate the
accessor with @prop() to cause methods decorated with @reactive() to run
when its value changes.
Subscribe to EventTargets: @subscribe(targetOrTargetFactory: EventTarget | ((instance: T) => EventTarget) | Promise<EventTarget>, eventNames: string, options: EventSubscribeOptions = {})
Subscribe the decorated class member to one or more events an EventTarget.
EventTarget is
an interface that objects such as HTMLElement, Window, Document and many more
objects implement. You can also create a vanilla event target or extend the
EventTarget class:
import { define, subscribe } from "@sirpepe/ornament"; const myTarget = new EventTarget(); @define("my-test") class Test extends HTMLElement { @subscribe(myTarget, "foo") log(evt) { // evt = Event({ name: "foo", target: myTarget }) // this = Test instance console.log(`'${evt.type}' event fired!`); } } let testEl = document.createElement("my-test"); myTarget.dispatchEvent(new Event("foo")); // testEl.log logs "'foo' event fired!"
12345678910111213141516171819
import { define, subscribe } from "@sirpepe/ornament";
const myTarget = new EventTarget();
@define("my-test")
class Test extends HTMLElement {
@subscribe(myTarget, "foo")
log(evt) {
// evt = Event({ name: "foo", target: myTarget })
// this = Test instance
console.log(`'${evt.type}' event fired!`);
}
}
let testEl = document.createElement("my-test");
myTarget.dispatchEvent(new Event("foo"));
// testEl.log logs "'foo' event fired!"To subscribe to multiple events, pass a single string with the event names separated by whitespace:
import { define, subscribe } from "@sirpepe/ornament"; const myTarget = new EventTarget(); @define("my-test") class Test extends HTMLElement { @subscribe(myTarget, "foo bar") #a() {} // subscribed to both "foo" and "bar" }
12345678
import { define, subscribe } from "@sirpepe/ornament";
const myTarget = new EventTarget();
@define("my-test")
class Test extends HTMLElement {
@subscribe(myTarget, "foo bar") #a() {} // subscribed to both "foo" and "bar"
}You can also provide a target-producing factory or promise in place of the target itself:
import { define, subscribe } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { // "window" is a perfectly valid event target @subscribe(window, "update") #a() {} // same effect as below @subscribe(() => window, "update") #b() {} // same effect as above @subscribe(Promise.resolve(window), "update") #c() {} // same effect as above }
123456789
import { define, subscribe } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
// "window" is a perfectly valid event target
@subscribe(window, "update") #a() {} // same effect as below
@subscribe(() => window, "update") #b() {} // same effect as above
@subscribe(Promise.resolve(window), "update") #c() {} // same effect as above
}The target-producing factory function can be used to access targets that depend on the element instance, such as the element's shadow root. The factory function gets called each time an element initializes, with its first argument set to the instance.
Notes for TypeScript
An event target can actually be delivered by an arbitrarily long chain of
nested functions and promises. This is annoying to handle on the type level,
you'll just have to any your way around that or provide this capability in
a type-safe wrapper.
Making the @subscribe() decorator type-safe for use with events is a gnarly
prospect. Given an event target and an event name, the decorator can't know
what type of event the method must expect. Therefore the following is possible
by default:
import { define, subscribe } from "@sirpepe/ornament"; let target = document.createElement("div"); @define("my-test") class Test extends HTMLElement { @subscribe(target, "click") #handleClicks(evt: MouseEvent) {} // This type checks, as it should @subscribe(target, "click") #handleAnimations(evt: AnimationEvent) {} // This type checks too! }
123456789101112
import { define, subscribe } from "@sirpepe/ornament";
let target = document.createElement("div");
@define("my-test")
class Test extends HTMLElement {
@subscribe(target, "click")
#handleClicks(evt: MouseEvent) {} // This type checks, as it should
@subscribe(target, "click")
#handleAnimations(evt: AnimationEvent) {} // This type checks too!
}A mapping between event names and corresponding event types (such as "click"
→ MouseEvent) exists for specific cases. For example HTMLElementEventMap
contains the mappings for events emitted by HTML elements. But because
@subscribe() can work with any event target, the existence or relevance of
such a mapping can't be assumed. The only way around this is to create an
abstraction for specific use cases where such a mapping is available. This can
be based on @subscribe() itself:
// Create a variant of @subscribe() specific to DOM events const listen = < T extends HTMLElement, K extends keyof HTMLElementEventMap, >( source: HTMLElement, ...eventNames: K[] ) => subscribe<T, HTMLElement, HTMLElementEventMap[K]>( source, eventNames.join(" "), ); const eventSource = document.createElement("div"); class Test extends HTMLElement { // Works: "click" is a MouseEvent @listen(eventSource, "click") handleClick(evt: MouseEvent) {} // Works: all event types listed by name are covered in the union @listen(eventSource, "transitionstart", "animationstart") handleAnimationStart(evt: AnimationEvent | TransitionEvent) {} // Type error: "focus" is not a mouse event @listen(eventSource, "focus") handleFocus(evt: MouseEvent) {} // Type error: type "TransitionEvent" is not covered @listen(eventSource, "transitionend", "animationend") handleAnimationEnd(evt: AnimationEvent) {} // Type error: "asdf" is not a DOM event @listen(eventSource, "asdf") handleAsdf(evt: Event) {}
12345678910111213141516171819202122232425262728293031323334
// Create a variant of @subscribe() specific to DOM events
const listen = <
T extends HTMLElement,
K extends keyof HTMLElementEventMap,
>(
source: HTMLElement,
...eventNames: K[]
) =>
subscribe<T, HTMLElement, HTMLElementEventMap[K]>(
source,
eventNames.join(" "),
);
const eventSource = document.createElement("div");
class Test extends HTMLElement {
// Works: "click" is a MouseEvent
@listen(eventSource, "click")
handleClick(evt: MouseEvent) {}
// Works: all event types listed by name are covered in the union
@listen(eventSource, "transitionstart", "animationstart")
handleAnimationStart(evt: AnimationEvent | TransitionEvent) {}
// Type error: "focus" is not a mouse event
@listen(eventSource, "focus")
handleFocus(evt: MouseEvent) {}
// Type error: type "TransitionEvent" is not covered
@listen(eventSource, "transitionend", "animationend")
handleAnimationEnd(evt: AnimationEvent) {}
// Type error: "asdf" is not a DOM event
@listen(eventSource, "asdf")
handleAsdf(evt: Event) {}Options for @subscribe() for EventTarget
targetOrTargetFactory(EventTarget | Promise<EventTarget> | ((instance: T) => EventTarget) | Promise<EventTarget>): The event target (or event-target-returning function/promise) to subscribe toeventNames(string): The event(s) to listen to. To subscribe to multiple events, pass a single string with the event names separated by whitespaceoptions(object, optional): Event handling options, consisting of...- predicate (function
(this: T, event: Event, instance: T) => boolean, optional): If provided, controls whether or not the decorated method is called for a given event. Gets passed the element instance and the event object, and must return a boolean. Note that this method always handles the raw event object, before and eventualtransform()is applied. - transform (function
<U>(this: T, event: Event, instance: T) => U, optional): If provided, transforms the event object into something else. The decorated class element must be compatible with the type returned fromtransform(). - activateOn (Array<string>, optional): Ornament event on which to activate the subscription (that is, when to actually start listening on the EventTarget). Defaults to
["init", "connected"]. - deactivateOn (Array<string>, optional): Ornament event on which to deactivate the subscription (when to call
removeEventListener()on the EventTarget). Defaults to["disconnected"]. - capture (boolean, optional): option for
addEventListener() - once (boolean, optional): option for
addEventListener() - passive (boolean, optional): option for
addEventListener() - signal (AbortSignal, optional): option for
addEventListener()
- predicate (function
Subscribe to Signals: @subscribe(signal: SignalLike<any>, options: SignalSubscribeOptions = {})
Subscribe to a signal:
import { define, subscribe } from "@sirpepe/ornament"; import { signal } from "@preact/signals-core"; const counter = signal(0); @define("my-test") class Test extends HTMLElement { @subscribe(counter) test() { console.log(counter.value); } } const instance = new Test(); counter.value = 1; counter.value = 2; counter.value = 3; // logs 0, 1, 2, 3
12345678910111213141516
import { define, subscribe } from "@sirpepe/ornament";
import { signal } from "@preact/signals-core";
const counter = signal(0);
@define("my-test")
class Test extends HTMLElement {
@subscribe(counter)
test() {
console.log(counter.value);
}
}
const instance = new Test();
counter.value = 1;
counter.value = 2;
counter.value = 3;
// logs 0, 1, 2, 3Any signal-like object that implements the following API should work:
type Signal<T> = { // Takes an update callback and returns an unsubscribe function subscribe(callback: () => void): () => void; // Represents the current value value: T; };
123456
type Signal<T> = {
// Takes an update callback and returns an unsubscribe function
subscribe(callback: () => void): () => void;
// Represents the current value
value: T;
};Because signals permanently represent reactive values, subscribing itself causes the method to be called with the then-current signal value. This is in contrast to subscribing to Event Targets, which (usually) do not represent values, but just happen to throw events around.
Options for @subscribe() for signals
signal(Signal): The signal to subscribe tooptions(object, optional): Update handling options, consisting of...- predicate (function
(this: T, value, instance: T) => boolean, optional): If provided, controls whether or not the decorated method is called for a given signal update. Gets passed the element instance and the signal's value, and must return a boolean. Note that this method always handles the raw signal value, before and eventualtransform()is applied. - transform (function
<U>(this: T, value, instance: T) => U, optional): If provided, transforms the signal value into something else. The decorated class element must be compatible with the type returned fromtransform(). - activateOn (Array<string>, optional): Ornament event on which to activate the subscription (that is, when to actually subscribe to the Signal). Defaults to
["init", "connected"]. - deactivateOn (Array<string>, optional): Ornament event on which to unsubscribe from the signal. Defaults to
["disconnected"].
- predicate (function
@debounce(options: DebounceOptions = {})
Method and class field decorator for debouncing method/function invocation:
import { define, debounce } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { // Debounce a class method @debounce() test1(x) { console.log(x); } // Debounce a class field function @debounce() test2 = (x) => console.log(x); } const el = new Test(); el.test1(1); el.test1(2); el.test1(3); // only logs "3" el.test2("a"); el.test2("b"); el.test2("c"); // only logs "c"
123456789101112131415161718192021222324
import { define, debounce } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
// Debounce a class method
@debounce()
test1(x) {
console.log(x);
}
// Debounce a class field function
@debounce() test2 = (x) => console.log(x);
}
const el = new Test();
el.test1(1);
el.test1(2);
el.test1(3);
// only logs "3"
el.test2("a");
el.test2("b");
el.test2("c");
// only logs "c"@debounce() works with class methods, static methods, and class field
functions.
Notes for TypeScript
Debouncing a method or class field function makes it impossible for the method
or function to return anything but undefined. TypeScript does currently not
allow decorators to modify its target's type, so @debounce() can't do that. If
you apply @debounce() to a method (x: number) => number, TypeScript will
keep using this signature, even though the decorated method will no longer be
able to return anything but undefined.
Options for @debounce()
fn(function, optional): The debounce function to use. Defaults todebounce.raf(). The following debounce functions are available:debounce.raf(): usesrequestAnimationFrame()debounce.timeout(ms: number): usessetTimeout()debounce.asap(): runs the function after the next microtask
@observe(ctor: ObserverConstructor, options: ObserverOptions = {})
Method and class field decorator that sets up a MutationObserver, ResizeObserver, or IntersectionObserver with the element instance as the target and the decorated method as the callback. This enables the component to observe itself:
import { define, observe } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { // Pass the observer constructor and relevant options @observe(MutationObserver, { childList: true }) reactToChanges(mutations, observer) { // "mutations" = array of MutationRecord objects // "observer" = the observer instance console.log(mutations); } } const el = new Test(); el.innerText = "Test"; // cause mutation // Test.reactToChanges() gets called asynchronously by the observer
1234567891011121314151617
import { define, observe } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
// Pass the observer constructor and relevant options
@observe(MutationObserver, { childList: true })
reactToChanges(mutations, observer) {
// "mutations" = array of MutationRecord objects
// "observer" = the observer instance
console.log(mutations);
}
}
const el = new Test();
el.innerText = "Test"; // cause mutation
// Test.reactToChanges() gets called asynchronously by the observer@observe() always observes the element that the decorated method belongs to and its reactions are always observably (heh) asynchronous. The decorator does little more than create an observer object with the options provided and the decorated method as the callback function. In theory this should work with every kind of DOM-related observer, but has only been tested with MutationObserver, ResizeObserver and IntersectionObserver so far.
Options for @observe()
Ctor(function): The observer constructor function (probably one ofMutationObserver,ResizeObserver, andIntersectionObserver)options(object, optional): A mixin type consisting of- All options for the relevant observer type (see MDN for options for MutationObserver, ResizeObserver, IntersectionObserver)
predicate(function(instance: T, records, observer) => boolean): If provided, controls whether or not an observer callback invocation calls the decorated method. Gets passed the observer's callback arguments (an array of records and the observer object) as well as the element instance and must return a boolean.- activateOn (Array<string>, optional): Ornament event on which to start observing the element. Defaults to
["init", "connected"]. - deactivateOn (Array<string>, optional): Ornament event on which to stop observing the element. Defaults to
["disconnected"].
Transformers
Transformers define how the accessor decorators @attr() and @prop()
implement attribute handling and type transformations. This includes converting
content attributes from and to DOM properties, type checks on setters, and
running side effects.
Transformers overview
| Transformer | Type | Options |
|---|---|---|
string() |
string |
|
href() |
string (URL) |
location |
bool() |
boolean |
|
number() |
number |
min, max, allowNaN, nullable |
int() |
bigint |
min, max, nullable |
json() |
Any (JSON serializable for use with @attr()) |
reviver, replacer |
list() |
Array | separator, transform |
literal() |
Any | values, transform |
any() |
any |
|
event() |
function | null |
A transformer is just a bag of functions with the following type signature:
export type Transformer< extends HTMLElement, Value, IntermediateValue = Value, > = { // Validates and/or transforms a value before it is used to initialize the // accessor. Can also be used to run a side effect when the accessor // initializes. Defaults to the identity function. init: ( this: T, value: Value, context: ClassAccessorDecoratorContext<T, Value>, isContentAttribute: boolean, ) => Value; // Turns content attribute values into DOM property values. Must never throw // exceptions, and instead always just deal with its input. Must not cause any // observable side effects. May return NO_VALUE in case the content attribute // can't be parsed, in which case the @attr() decorator must not change the // property value parse: (this: T, value: string | null) => Value | typeof NO_VALUE; // Decides if setter inputs, which may be of absolutely any type, should be // accepted or rejected. Should throw for invalid values, just like setters on // built-in elements may. Must not cause any observable side effects. validate: ( this: T, value: unknown, isContentAttribute: boolean, ) => asserts value is IntermediateValue; // Transforms values that were accepted by validate() into the proper type by // eg. clamping numbers, normalizing strings etc. transform: (this: T, value: IntermediateValue) => Value; // Turns DOM property values into content attribute values (strings), thereby // controlling the attribute representation of an accessor together with // updateContentAttr(). Must never throw, defaults to the String() function stringify: (this: T, value: Value) => string; // Determines whether a new attribute value is equal to the old value. If this // method returns true, reactive callbacks will not be triggered. Defaults to // simple strict equality (===). eql: (this: T, a: Value, b: Value) => boolean; // Optionally run a side effect immediately before the accessor's setter is // invoked. Required by the event transformer. beforeSet: ( this: T, value: Value, context: ClassAccessorDecoratorContext<T, Value>, attributeRemoved: boolean, ) => void; // Optionally transform the getter's response. Required by the href // transformer. transformGet: (this: T, value: Value) => Value; // Decides if, based on a new value, an attribute gets updated to match the // new value (true/false) or removed (null). Only gets called when the // transformer's eql() method returns false. Defaults to a function that // always returns true. updateContentAttr: ( oldValue: Value | null, newValue: Value | null, ) => boolean | null; };
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
export type Transformer<
extends HTMLElement,
Value,
IntermediateValue = Value,
> = {
// Validates and/or transforms a value before it is used to initialize the
// accessor. Can also be used to run a side effect when the accessor
// initializes. Defaults to the identity function.
init: (
this: T,
value: Value,
context: ClassAccessorDecoratorContext<T, Value>,
isContentAttribute: boolean,
) => Value;
// Turns content attribute values into DOM property values. Must never throw
// exceptions, and instead always just deal with its input. Must not cause any
// observable side effects. May return NO_VALUE in case the content attribute
// can't be parsed, in which case the @attr() decorator must not change the
// property value
parse: (this: T, value: string | null) => Value | typeof NO_VALUE;
// Decides if setter inputs, which may be of absolutely any type, should be
// accepted or rejected. Should throw for invalid values, just like setters on
// built-in elements may. Must not cause any observable side effects.
validate: (
this: T,
value: unknown,
isContentAttribute: boolean,
) => asserts value is IntermediateValue;
// Transforms values that were accepted by validate() into the proper type by
// eg. clamping numbers, normalizing strings etc.
transform: (this: T, value: IntermediateValue) => Value;
// Turns DOM property values into content attribute values (strings), thereby
// controlling the attribute representation of an accessor together with
// updateContentAttr(). Must never throw, defaults to the String() function
stringify: (this: T, value: Value) => string;
// Determines whether a new attribute value is equal to the old value. If this
// method returns true, reactive callbacks will not be triggered. Defaults to
// simple strict equality (===).
eql: (this: T, a: Value, b: Value) => boolean;
// Optionally run a side effect immediately before the accessor's setter is
// invoked. Required by the event transformer.
beforeSet: (
this: T,
value: Value,
context: ClassAccessorDecoratorContext<T, Value>,
attributeRemoved: boolean,
) => void;
// Optionally transform the getter's response. Required by the href
// transformer.
transformGet: (this: T, value: Value) => Value;
// Decides if, based on a new value, an attribute gets updated to match the
// new value (true/false) or removed (null). Only gets called when the
// transformer's eql() method returns false. Defaults to a function that
// always returns true.
updateContentAttr: (
oldValue: Value | null,
newValue: Value | null,
) => boolean | null;
};Because transformers need to potentially do a lot of type juggling and bookkeeping, they are somewhat tricky to get right, but they are also always only a few self-contained lines of code. If you want to extend Ornament, you should simply clone one of the built-in transformers and modify it to your liking.
Notes for all transformers
For use with both @prop() and @attr()
In principle all transformers can be used with both @prop() and @attr().
Very few transformers are limited to use with either decorator, such as
event() (which makes very little sense outside of content attributes).
The accessor's initial value serves as fallback value in case no other data is
available (eg. when a content attribute gets removed). Transformers validate
their initial value and most transformers contain reasonable default values
("" for string(), 0 for number() etc.).
For use with @attr()
A content attribute's property value can be unset to the accessor's initial value by removing a previously set content attribute:
As an example:
import { define, attr, string } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @attr(string()) accessor foo = "default value"; @attr(string()) accessor bar = "default value"; @attr(string()) accessor baz; } document.body.innerHTML += `<my-test foo="other value"></my-test>`;
12345678910
import { define, attr, string } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@attr(string()) accessor foo = "default value";
@attr(string()) accessor bar = "default value";
@attr(string()) accessor baz;
}
document.body.innerHTML += `<my-test foo="other value"></my-test>`;The attributes foo, bar and baz behave as follows:
- The element initializes with a content attribute
fooalready set in HTML. The propertyfoowill therefore (because it uses the string type via thestring()transformer) contain"other value". Should the content attributefooget removed, the DOM property will contain"default value". - The content attribute
baris not set in HTML, which will result in the propertybarcontaining the accessor's default value"default value". - The content attribute
bazis also not set in HTML and the accessor has no initial value, so thestring()transformer's built-in fallback value""gets used.
Transformer string()
Implements a string attribute or property. Modeled after built-in string
attributes such as id and lang, it will always represent a string and
stringify any and all non-string values.
import { define, attr, string } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @attr(string()) accessor foo = "default value"; }
123456
import { define, attr, string } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@attr(string()) accessor foo = "default value";
}Behavior overview for transformer string()
| Operation | DOM property value | Content attribute (when used with @attr()) |
|---|---|---|
Set property to x |
String(x) |
DOM property value |
| Set content attribute | Content attribute value | As set (equal to property value) |
| Remove content attribute | Initial value or "" |
Removed |
Transformer href({ location = window.location }: { location?: Location } = {})
Implements a string attribute or property that works exactly like the href
attribute on <a> in that it automatically turns relative URLs into absolute
URLs.
import { define, attr, href } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @attr(href()) accessor foo = ""; } let testEl = new Test(); // Assuming that the page is served from localhost: console.log(testEl.foo); // > "" testEl.foo = "asdf"; console.log(testEl.foo); // > "http://localhost/asdf" testEl.foo = "https://example.com/foo/bar/"; console.log(testEl.foo); // > "https://example.com/foo/bar/"
123456789101112131415
import { define, attr, href } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@attr(href()) accessor foo = "";
}
let testEl = new Test();
// Assuming that the page is served from localhost:
console.log(testEl.foo); // > ""
testEl.foo = "asdf";
console.log(testEl.foo); // > "http://localhost/asdf"
testEl.foo = "https://example.com/foo/bar/";
console.log(testEl.foo); // > "https://example.com/foo/bar/"If you want to run your component code in a non-browser environment like JSDOM,
you can pass the JSDOM's window.location as the option location.
Behavior overview for transformer href()
| Operation | DOM property value | Content attribute (when used with @attr()) |
|---|---|---|
| Set property to absolute URL (string) | Absolute URL | DOM property value |
Set property to any other value x |
Relative URL to String(x) |
DOM property value |
| Set content attribute to absolute URL (string) | Absolute URL | As set |
Set content attribute to any other string x |
Relative URL to x |
As set |
| Remove content attribute | Initial value or "" |
Removed |
Transformer number(options: NumberOptions = {})
Implements a number attribute with optional range constraints.
import { define, attr, number } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { // With default options (see below) @attr(number()) accessor foo = 0; // With all options set @attr(number({ min: 0, max: 10 })) accessor bar = 0; }
12345678910
import { define, attr, number } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
// With default options (see below)
@attr(number()) accessor foo = 0;
// With all options set
@attr(number({ min: 0, max: 10 })) accessor bar = 0;
}Non-numbers get converted to numbers. The transformer allows null and
undefined (with the latter converting to null) if the option nullable is
set to true. If converting a non-number to a number results in NaN and the
option allowNaN is not set to true, the property setter and the accessor's
initializer throw exceptions.
Options for transformer number()
min(number, optional): Smallest possible value. Defaults to-Infinity. Content attribute values less thanminget clamped, property values get validated and (if too small) rejected with an exception. Can be omitted or set tonullorundefinedto signify no minimum value.max(number, optional): Largest possible value. Defaults toInfinity. Content attribute values greater thanmaxget clamped, property values get validated and (if too large) rejected with an exception. Can be omitted or set tonullorundefinedto signify no maximum value.allowNaN(boolean, optional): Whether or notNaNis allowed. Defaults tofalse.nullable(boolean, optional): Whether or notnullandundefined(with the latter converting tonull) are allowed. Defaults tofalse.
Behavior overview for transformer number()
| Operation | DOM property value | Content attribute (when used with @attr()) |
|---|---|---|
Set property to value x |
minmax(opts.min, opts.max, toNumber(x, allowNaN)) |
String(property value) |
| Set property to out-of-range value | RangeError | String(property value) |
Set property to null or undefined |
null is nullable is true, otherwise 0 |
Removed if nullable is true, otherwise String(property value) |
Set content attribute to value x |
minmax(opts.min, opts.max, toNumber(x, allowNaN)) |
As set |
| Set content attribute to non-numeric value | No change, or NaN if option allowNaN is true |
As set |
| Set content attribute to out-of-range value | No change | As set |
| Remove content attribute | null is nullable is true, otherwise initial value or 0 |
Removed |
Transformer int(options: IntOptions = {})
Implements a bigint attribute. Content attribute values are expressed as plain
numeric strings without the trailing n used in JavaScript's BigInt literal
syntax.
import { define, attr, int } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { // With default options (see below) @attr(int()) accessor foo = 0n; // With all options set @attr(int({ min: 0n, max: 10n, nullable: false })) accessor bar = 0n; }
12345678910
import { define, attr, int } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
// With default options (see below)
@attr(int()) accessor foo = 0n;
// With all options set
@attr(int({ min: 0n, max: 10n, nullable: false })) accessor bar = 0n;
}The transformer allows null and undefined (with the latter converting to
null) if the option nullable is set to true. In all other cases, the DOM
property setter throws an exception when its input cannot be converted to
BigInt.
Options for transformer int()
min(bigint, optional): Smallest possible value. Defaults to the minimum possible bigint value. Content attribute values less thanminget clamped, property values get validated and (if too small) rejected with an exception. Can be omitted or set tonullorundefinedto signify no minimum value.max(bigint, optional): Largest possible value. Defaults to the maximum possible bigint value. Content attribute values greater thanmaxget clamped, property values get validated and (if too large) rejected with an exception. Can be omitted or set tonullorundefinedto signify no maximum value.nullable(boolean, optional): Whether or notnullandundefined(with the latter converting tonull) are allowed. Defaults tofalse.
Behavior overview for transformer int()
| Operation | DOM property value | Content attribute (when used with @attr()) |
|---|---|---|
Set property to value x |
minmax(ops.min, opts.max, BigInt(x)) |
String(property value) |
| Set property to out-of-range value | RangeError | String(property value) |
Set property to null or undefined |
null is nullable is true, otherwise 0n |
Removed if nullable is true, otherwise String(property value) |
| Set property to non-int value | BigInt(x) |
String(property value) |
Set content attribute to value x |
minmax(opts.min, opts.max, BigInt(x)) |
As set |
| Set non-int content attribute | Clamp to Int if float, otherwise no change | As set |
| Remove content attribute | null is nullable is true, otherwise initial value or 0 |
Removed |
Transformer bool()
Implements a boolean attribute. Modeled after built-in boolean attributes such
as disabled. Changes to the DOM property values toggle the content
attribute and do not just change the content attribute's value.
import { define, attr, bool } from "@sirpepe/ornament"; class DemoElement extends HTMLElement { @attr(bool()) accessor foo = false; }
12345
import { define, attr, bool } from "@sirpepe/ornament";
class DemoElement extends HTMLElement {
@attr(bool()) accessor foo = false;
}In this case, the property foo always represents a boolean. Any
non-boolean value gets coerced to booleans. If the content attribute foo gets
set to any value (including the empty string), foo returns true - only a
missing content attribute counts as false. Conversely, the content attribute
will be set to the empty string when the property is true and the
attribute will be removed when the property is false.
If you want your content attribute to represent "false" as a string value,
you can use the literal() transformer with the strings "true" and "false".
Behavior overview for transformer bool()
| Operation | DOM property value | Content attribute (when used with @attr()) |
|---|---|---|
Set property to value x |
Boolean(x) |
Removed when DOM property is false, otherwise set to empty string |
Set content attribute to x |
true |
As set |
| Remove content attribute | false |
Removed |
Transformer list(options: ListOptions = {})
Implements an attribute with an array of values, defined by another transformer.
The list() transformer passes individual items to the transformer passed in
the options and deals with content attributes by splitting and/joining
stringified array contents:
import { define, attr, list, number } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @attr(list({ transform: number(), separator: "," })) accessor numbers = [0]; }
123456
import { define, attr, list, number } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@attr(list({ transform: number(), separator: "," })) accessor numbers = [0];
}This parses the content attribute numbers as a comma-separated list of
strings, which are in turn parsed into numbers by the number() transformer
passed to the list() transformers options. If the content attribute gets set
to something other than a comma-separated list of numeric strings,
the attribute's value resets back to the initial value [0]. Any attempt at
setting the properties to values other than arrays of will result in an exception
outright. Depending on the transformer the array's content may be subject to
further validation and/or transformations.
Note that when parsing a content attribute string, values are trimmed and empty strings are filtered out before they are passed on to the inner transformer:
import { define, attr, list, number } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @attr(list({ transform: number() })) accessor foo = [0]; } const el = new Test(); el.setAttribute("foo", " 1, , ,,2 ,3 "); console.log(el.foo); // > [1, 2, 3]
123456789
import { define, attr, list, number } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@attr(list({ transform: number() })) accessor foo = [0];
}
const el = new Test();
el.setAttribute("foo", " 1, , ,,2 ,3 ");
console.log(el.foo); // > [1, 2, 3]Options for list(options?)
separator(string, optional): Separator string. Defaults to","transform(Transformer): Transformer to use, eg.string()for a list of strings,number()for numbers etc.
Behavior overview for transformer list()
| Operation | DOM property value | Content attribute (when used with @attr()) |
|---|---|---|
| Set property | Exception is not an array, otherwise array with content guarded by options.transformer.validate |
DOM property values joined with options.separator |
| Set content attribute | Attribute value is split on the separator, then trimmed, then non-empty strings are passed into options.transformer.parse |
As set |
| Remove content attribute | Initial value or empty array | Removed |
Transformer literal(options: LiteralOptions = {})
Implements an attribute with a finite number of valid values. Should really be
called "enum", but that's a reserved word in JavaScript. It works by declaring
the valid list of values and a matching transformer. If, for example, the list
of valid values consists of strings, then the string() transformer is the
right transformer to use:
import { define, attr, literal, string } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @attr(literal({ values: ["A", "B"], transform: string() })) accessor foo = "A"; }
1234567
import { define, attr, literal, string } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@attr(literal({ values: ["A", "B"], transform: string() })) accessor foo =
"A";
}In this case, the content attribute can be set to any value (as is usual in
HTML), but if the content attribute gets set to a value other than A or B,
the property value will remain unchanged. Any attempt at setting the
property to values other than A or B will result in an exception.
The default value is either the value the accessor was initialized with or, if
the accessor has no initial value, the first element in values.
Notes for TypeScript
To use literal() with literal union types, make sure that the values option
is not subject to type widening, eg. via as const:
@define("test-element") class TestElement extends HTMLElement { // Works: values is ["a", "b"] @prop(literal({ values: ["a", "b"] as const, transform: string() })) accessor bah: "a" | "b" = "a"; // Errors: values is string[] @prop(literal({ values: ["a", "b"], transform: string() })) accessor bbb: "a" | "b" = "a"; }
1234567891011
@define("test-element")
class TestElement extends HTMLElement {
// Works: values is ["a", "b"]
@prop(literal({ values: ["a", "b"] as const, transform: string() }))
accessor bah: "a" | "b" = "a";
// Errors: values is string[]
@prop(literal({ values: ["a", "b"], transform: string() }))
accessor bbb: "a" | "b" = "a";
}The ordering of values is not important.
Options for literal(options?)
values(array): List of valid values. Must contain at least one element.transform(Transformer): Transformer to use, eg.string()for a list of strings,number()for numbers etc.
Behavior overview for transformer literal()
| Operation | DOM property value | Content attribute (when used with @attr()) |
|---|---|---|
Set property value to x |
Exception if not in options.values, otherwise defined by options.transformer.validate |
Defined by options.transformer.stringify |
Set content attribute to x |
Parsed by options.transformer.parse. If the result is in options.values, result, otherwise no change |
As set |
| Remove attribute | Initial value or first element in options.values |
Removed |
Transformer json(options: JSONOptions = {})
Implements an attribute that can take any value. When used with @attr(), the
value must be serializable with JSON in order to be reflected as a content
attribute. When used with @prop(), no restrictions apply.
import { define, attr, prop, json } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { // Must be valid JSON when used with @attr() @attr(json()) accessor foo = { user: "", email: "" }; // When used with prop, any value can be used @prop(json()) accessor foo = { value: 42n }; }
123456789
import { define, attr, prop, json } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
// Must be valid JSON when used with @attr()
@attr(json()) accessor foo = { user: "", email: "" };
// When used with prop, any value can be used
@prop(json()) accessor foo = { value: 42n };
}Content attributes, defined with @attr(), are parsed with JSON.parse(). In
this case, any invalid JSON is represented with the data used to initialize the
accessor. Using the property setter with inputs than can't be serialized
with JSON.stringify() throws errors. This transformer is really just a wrapper
around JSON.parse() and JSON.stringify() without any object validation.
Equality is checked with ===.
Notes for TypeScript
Even though the transformer will accept literally any JSON-serializable value at runtime, TypeScript may infer a more restrictive type from the accessor's initial value. Decorators can't currently change the type of class members they are applied to, so you may need to provide a type annotation.Options for json(options?)
reviver(function, optional): Thereviverargument to use withJSON.parse(), if any. Only of use when used with@attr()replacer(function, optional): Thereplacerargument to use withJSON.stringify(), if any. Only of use when used with@attr()
Behavior overview for transformer json() (when used with @attr())
| Operation | DOM property value | Content attribute |
|---|---|---|
Set property value to x |
JSON.parse(JSON.stringify(x)) |
JSON.stringify(propertyValue, null, options.reviver) |
Set content attribute to x |
No change if invalid JSON, otherwise JSON.parse(x, options.receiver) |
As set |
| Remove content attribute | Initial value or undefined |
Removed |
Behavior overview for transformer json() (when used with @prop())
| Operation | DOM property value | Content attribute |
|---|---|---|
Set property value to x |
x |
- |
Transformer any()
Implements a transformer that does no type checking at all and falls back to
the global String function for serializing to content attributes. Use this if
you really don't care about types.
import { define, prop, any } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @prop(any()) accessor whatever: any = 42; }
123456
import { define, prop, any } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@prop(any()) accessor whatever: any = 42;
}Transformers returned from calling any() make for great prototypes for your
own custom transformer. Just note that transformers are bags of functions and
not classes, so you will need to use Object.setPrototypeOf() and friends to
"extend" transformers.
Notes for TypeScript
Even though the transformer will accept literally any value at runtime, TS may infer a more restrictive type from the accessor's initial values. Decorators can't currently change the type of class members they are applied to, so you may need to provide an `any` type annotation.Transformer event()
Implements old-school inline event handler attributes in the style of
onclick="console.log(42)". To work properly, this should only be used in
conjunction with @attr() (with reflectivity enabled) and on a non-private,
non-static accessor that has a name starting with on:
import { define, attr, eventHandler } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @attr(event()) accessor onfoo: ((evt: Event) => void) | null = null; }
123456
import { define, attr, eventHandler } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@attr(event()) accessor onfoo: ((evt: Event) => void) | null = null;
}This can then be used in HTML:
<my-test onfoo="console.log('Foo event:', event)"></my-test> <script> document.querySelector("my-test").dispatchEvent(new Event("foo")); // Logs "'Foo event:', Event{type: "foo"}" </script>
12345
<my-test onfoo="console.log('Foo event:', event)"></my-test>
<script>
document.querySelector("my-test").dispatchEvent(new Event("foo"));
// Logs "'Foo event:', Event{type: "foo"}"
</script>Or in JavaScript:
const testEl = document.createElement("my-test"); testEl.onfoo = (event) => console.log("Foo event:", event); testEl.dispatchEvent(new Event("foo")); // Logs "'Foo event:', Event{type: "foo"}"
1234
const testEl = document.createElement("my-test");
testEl.onfoo = (event) => console.log("Foo event:", event);
testEl.dispatchEvent(new Event("foo"));
// Logs "'Foo event:', Event{type: "foo"}"Regular "proper" addEventListener() is obviously also always available.
It should be noted that for built-in events that bubble, inline event handlers can be added to any element in order to facilitate event delegation. These event handlers are considered global event handlers, and all custom inline event handlers are obviously not global - they can only be used on the components that explicitly implement them.
Behavior overview for transformer event()
The behavior of event() matches the behavior of built-in event handlers like
onclick.
HTML elements do usually not expose any metadata, even though knowing the names and data types for content attributes would be quite useful sometimes. Ornament exposes a few metadata helper functions that help in scenarios where meta-programming components (eg. SSR) is required.
getTagName(instanceOrCtor)
Given an instance or custom element constructor, this function returns
the element's tag name. It returns null if the object in question is not
a custom element defined via @define():
import { define, getTagName } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement {} console.log(getTagName(Test)); // > "my-test" console.log(getTagName(new Test())); // > "my-test"
12345678910
import { define, getTagName } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {}
console.log(getTagName(Test));
// > "my-test"
console.log(getTagName(new Test()));
// > "my-test"This serves roughly the same function as the standard CustomElementRegistry.getName() method but does not require access to the specific CustomElementRegistry that the element is registered with.
listAttributes(instanceOrCtor)
Lists the content attribute names that were defined via @attr() on the
custom element (or constructor) in question:
import { define, attr, string, listAttributes } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @attr(string()) accessor foo = ""; @attr(string(), { as: "asdf" }) accessor bar = ""; } console.log(listAttributes(Test)); // > ["foo", "asdf"] console.log(listAttributes(new Test())); // > ["foo", "asdf"]
12345678910111213
import { define, attr, string, listAttributes } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@attr(string()) accessor foo = "";
@attr(string(), { as: "asdf" }) accessor bar = "";
}
console.log(listAttributes(Test));
// > ["foo", "asdf"]
console.log(listAttributes(new Test()));
// > ["foo", "asdf"]This is roughly analogous to the observedAttributes static property on custom
element classes, but only lists content attributes defined with ornament's
@attr() - manually defined attributes and DOM properties defined with
@prop() are excluded.
getAttribute(instanceOrCtor, contentAttributeName)
Returns the DOM property name and transformer used to define a content attribute:
import { define, attr, number, getAttribute } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { // Requires non-negative values @attr(number({ min: 0 }), { as: "asdf" }) accessor bar = 0; } const { prop, transformer } = getAttribute(Test, "asdf"); console.log(prop); // > "bar" - the backend accessor for the content attribute "asdf" transformer.parse("-1"); // > 0; input clamped to valid value transformer.validate(-1, true); // Throws an error; the transformer only accepts nonnegative numbers
123456789101112131415161718
import { define, attr, number, getAttribute } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
// Requires non-negative values
@attr(number({ min: 0 }), { as: "asdf" }) accessor bar = 0;
}
const { prop, transformer } = getAttribute(Test, "asdf");
console.log(prop);
// > "bar" - the backend accessor for the content attribute "asdf"
transformer.parse("-1");
// > 0; input clamped to valid value
transformer.validate(-1, true);
// Throws an error; the transformer only accepts nonnegative numbersThis is particularly useful if you need access to the parsing and stringification logic for content attributes for eg. SSR.
Event Bus
Ornament runs intra-component communication over an internal event bus. You will almost certainly never need to access it directly, but there is is an API just in case.
| Event | Cause | Event type | Payload (args property on the event object) |
|---|---|---|---|
init |
Constructor ran to completion | OrnamentEvent<"init"> |
[] |
connected |
connectedCallback() fired |
OrnamentEvent<"connected"> |
[] |
disconnected |
disconnectedCallback() fired |
OrnamentEvent<"disconnected"> |
[] |
connectedMove |
connectedMoveCallback() fired |
OrnamentEvent<"connectedMove"> |
[] |
adopted |
adoptedCallback() fired |
OrnamentEvent<"adopted"> |
[] |
prop |
DOM property change (@prop or @attr) |
OrnamentEvent<"prop"> |
[Name: string | symbol, NewValue: any] |
attr |
Content attribute change (@attr) |
OrnamentEvent<"attr"> |
[Name: string, OldValue: string | null, NewValue: string | null] |
formAssociated |
formAssociatedCallback() fired |
OrnamentEvent<"formAssociated"> |
`[Owner: HTMLFormElement | null] |
formReset |
formResetCallback() fired |
OrnamentEvent<"formReset"> |
[] |
formDisabled |
formDisabledCallback() fired |
OrnamentEvent<"formDisabled"> |
[Disabled: boolean] |
formStateRestore |
formStateRestoreCallback() fired |
OrnamentEvent<"formStateRestore"> |
[Reason: "autocomplete" | "restore"] |
Notes for TypeScript
You can declare additions to the global interface OrnamentEventMap to extend
this list with your own events.
trigger(instance, name, ...payload)
Dispatches an event on the event bus for the component instance. The arguments
payload must be all the for the args property on the event object on the
event object (eg. a single boolean for for formDisabled).
import { trigger } from "@sirpepe/ornament"; // Dispatches an "connected" event. This will run all methods on "someElement" // that were decorated with @connect(). trigger(someElement, "connected"); // note no args // Dispatches an "prop" event. This will run all methods on "someElement" // that were decorated with @reactive(), provided the "foo" key is not excluded // in the setup of the @reactive decorator trigger(someElement, "prop", "foo", 42); // note args for prop name and value
12345678910
import { trigger } from "@sirpepe/ornament";
// Dispatches an "connected" event. This will run all methods on "someElement"
// that were decorated with @connect().
trigger(someElement, "connected"); // note no args
// Dispatches an "prop" event. This will run all methods on "someElement"
// that were decorated with @reactive(), provided the "foo" key is not excluded
// in the setup of the @reactive decorator
trigger(someElement, "prop", "foo", 42); // note args for prop name and valuelisten(instance, name, callback, options?)
Listens to events on the event bus for the component instance. The event bus
is an instance of EventTarget, which means that you can pass any and all
event listener options
as the last argument.
import { listen } from "@sirpepe/ornament"; // Listen for "prop" event on the event bus for "someElement" listen(someElement, "prop", (evt) => { const [name, value] = event.args; window.alert(`Attribute ${name} was changed to ${value}!`); });
1234567
import { listen } from "@sirpepe/ornament";
// Listen for "prop" event on the event bus for "someElement"
listen(someElement, "prop", (evt) => {
const [name, value] = event.args;
window.alert(`Attribute ${name} was changed to ${value}!`);
});class OrnamentEvent<K extends keyof OrnamentEventMap>
Event type used on the internal event bus. Only really useful if you want to create your own events while using TypeScript.
Other utilities
getInternals(instance: HTMLElement): ElementInternals
Get the ElementInternals
for a component instance. While getInternals() be called as often as required,
component's attachInternals() methods are still single-use:
import { define, getInternals } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement {} const testEl = new Test(); const internals1 = getInternals(testEl); const internals2 = testEl.attachInternals(); console.log(internals1 === internals2); // > true getInternals(testEl); // works a second time getInternals(testEl); // works a third time testEl.attachInternals()) to.throw(); // > Exception on second use
12345678910111213
import { define, getInternals } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {}
const testEl = new Test();
const internals1 = getInternals(testEl);
const internals2 = testEl.attachInternals();
console.log(internals1 === internals2); // > true
getInternals(testEl); // works a second time
getInternals(testEl); // works a third time
testEl.attachInternals()) to.throw(); // > Exception on second useSymbols
NO_VALUE
Transformers can return a special symbol to indicate that they were unable to
parse an input. This symbol is exported by Ornament as NO_VALUE and is also
available behind the key "ORNAMENT_NO_VALUE" in the global symbol registry.
METADATA
Ornament, being a collection of decorators, stores its metadata in
Decorator Metadata. To
avoid collisions with other libraries, the actual metadata is hidden behind a
symbol that is exported by Ornament as ORNAMENT_METADATA_KEY or available
behind the key "ORNAMENT_METADATA_KEY" in the global symbol registry. The
contents of the metadata record should not be considered part of Ornament's
stable API and could change at any moment. Use the metadata API instead.
Troubleshooting
TypeError: Cannot read private member from an object whose class did not declare it
This usually happens when methods decorated with @init() run at inopportune
times. Consider the following example:
import { define, init } from "@sirpepe/ornament"; function otherDecorator(target) { return class OtherMixin extends target { #secret = 42; get foo() { return this.#secret; // <- Fails because @init() runs too early } }; } @otherDecorator @define("foo-bar") // If this was before @otherDecorator it would work class Test extends HTMLElement { @init() // Runs after the constructor of Test has run, does not wait for the mixin class method() { console.log(this.foo); } } new Test();
123456789101112131415161718192021
import { define, init } from "@sirpepe/ornament";
function otherDecorator(target) {
return class OtherMixin extends target {
#secret = 42;
get foo() {
return this.#secret; // <- Fails because @init() runs too early
}
};
}
@otherDecorator
@define("foo-bar") // If this was before @otherDecorator it would work
class Test extends HTMLElement {
@init() // Runs after the constructor of Test has run, does not wait for the mixin class
method() {
console.log(this.foo);
}
}
new Test();In this scenario, @define() sets up to trigger @init() once the constructor
of Test has finished. Inside Test, the method method() accesses the getter
foo which is provided by the decorator @otherDecorator. The getter in turn
tries to accesses the private field #secret, but fails with an exception.
This happens because @define() installs logic that triggers the init event on
the constructor for class Test, but the resulting class gets extended in turn
by OtherMixin.
OtherMixinConstructor( DefineMixinConstructor( TestConstructor( HTMLElementConstructor() ) // <---- init event happens here, after TestConstructor has run ) // <---- finishes only after the init event has happened )
123456789
OtherMixinConstructor(
DefineMixinConstructor(
TestConstructor(
HTMLElementConstructor()
)
// <---- init event happens here, after TestConstructor has run
)
// <---- finishes only after the init event has happened
)This results in the event running before the private field #secret is fully
initialized. The simplest way to remedy this situation is to apply
@otherDecorator first. You might also want to consider using @connected()
instead of @init().
This is not a bug in Ornament, but rather a simple effect of how mixin classes
are subclasses of their targets. Because @init() is equivalent to calling the
decorated method in the constructor, the effect can be reproduced without involving Ornament at all:
function decorator(target) { return class DecoratorMixin extends target { #secret = 42; get feature() { return this.#secret; } }; } @decorator class A { constructor() { console.log(this.feature); } } new A();
1234567891011121314151617
function decorator(target) {
return class DecoratorMixin extends target {
#secret = 42;
get feature() {
return this.#secret;
}
};
}
@decorator
class A {
constructor() {
console.log(this.feature);
}
}
new A();Cookbook
Debounced reactive
@reactive() causes its decorated method to get called for once for every
attribute and property change. This is sometimes useful, but sometimes you will
want to batch method calls for increased efficiency. This is easy if you combine
@reactive() with @debounce():
import { define, prop, reactive, int } from "@sirpepe/ornament"; @define("my-test") export class TestElement extends HTMLElement { @prop(int()) accessor value = 0; @reactive() @debounce() #log() { console.log("Value is now", this.value); } } let el = new TestElement(); el.value = 1; el.value = 2; el.value = 2; // Only logs "Value is now 3"
12345678910111213141516171819
import { define, prop, reactive, int } from "@sirpepe/ornament";
@define("my-test")
export class TestElement extends HTMLElement {
@prop(int()) accessor value = 0;
@reactive()
@debounce()
#log() {
console.log("Value is now", this.value);
}
}
let el = new TestElement();
el.value = 1;
el.value = 2;
el.value = 2;
// Only logs "Value is now 3"Rendering shadow DOM with uhtml
Ornament does not directly concern itself with rendering Shadow DOM, but you can combine Ornament with suitable libraries such as uhtml:
import { render, html } from "uhtml"; import { define, prop, reactive, int } from "@sirpepe/ornament"; @define("counter-element") export class CounterElement extends HTMLElement { @prop(int()) accessor value = 0; @reactive() @debounce() #render() { render( this.shadowRoot ?? this.attachShadow({ mode: "open" }), html` Current value: ${this.value} <button .click={() => ++this.value}>Add 1</button> ` ); } }
12345678910111213141516171819
import { render, html } from "uhtml";
import { define, prop, reactive, int } from "@sirpepe/ornament";
@define("counter-element")
export class CounterElement extends HTMLElement {
@prop(int()) accessor value = 0;
@reactive()
@debounce()
#render() {
render(
this.shadowRoot ?? this.attachShadow({ mode: "open" }),
html`
Current value: ${this.value}
<button .click={() => ++this.value}>Add 1</button>
`
);
}
}This component uses an event handler to update the decorated accessor value,
which in turn causes the @reactive() method #render() to update the UI
accordingly - debounced with @debounce() for batched updates.
Rendering shadow DOM with Preact
You can also use Preact to render shadow DOM:
import { define, attr, number, reactive, connected } from "@sirpepe/ornament"; import { Fragment, h, render } from "preact"; @define("click-counter") class ClickCounter extends HTMLElement { #shadow = this.attachShadow({ mode: "closed" }); @attr(number({ min: 0 }), { reflective: false }) accessor up = 0; @attr(number({ min: 0 }), { reflective: false }) accessor down = 0; @connected() @reactive() render() { render( <> <button onClick={() => this.up++}>+1</button> Total: <b>{this.up + this.down}</b> <button onClick={() => this.down--}>-1</button> </>, this.#shadow, ); } }
1234567891011121314151617181920212223
import { define, attr, number, reactive, connected } from "@sirpepe/ornament";
import { Fragment, h, render } from "preact";
@define("click-counter")
class ClickCounter extends HTMLElement {
#shadow = this.attachShadow({ mode: "closed" });
@attr(number({ min: 0 }), { reflective: false }) accessor up = 0;
@attr(number({ min: 0 }), { reflective: false }) accessor down = 0;
@connected()
@reactive()
render() {
render(
<>
<button onClick={() => this.up++}>+1</button>
Total: <b>{this.up + this.down}</b>
<button onClick={() => this.down--}>-1</button>
</>,
this.#shadow,
);
}
}In the case of Web Components and Ornament, it makes some sense to use class members for local state instead of hooks.
Read-only property
You can create a writable private accessor with @prop() and manually expose a
public getter. This keeps reactive functions working, but only allows readonly
access from outside the component:
import { define, attr, reactive, string } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { // Writable, but private @prop(string()) accessor #foo = "Starting value"; // Provides public readonly access to #foo get foo() { return this.#foo; } change() { this.#foo++; } // Reacts to changes to #foo, which can only be caused by calling the method // `change()` @reactive() log() { console.log(this.#foo); } }
1234567891011121314151617181920212223
import { define, attr, reactive, string } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
// Writable, but private
@prop(string()) accessor #foo = "Starting value";
// Provides public readonly access to #foo
get foo() {
return this.#foo;
}
change() {
this.#foo++;
}
// Reacts to changes to #foo, which can only be caused by calling the method
// `change()`
@reactive()
log() {
console.log(this.#foo);
}
}Custom logic in DOM properties
The point of the accessor keyword is to generate a getter, setter, and private
property in a way that makes it easy to apply a decorator to everything at once.
But because the getters and setters are auto-generated, there is no
non-decorator way to attach custom logic to accessor members. To work around
this for DOM properties defined via @attr() or @prop(), you can build
and decorate a private or symbol accessor that you then expose with a custom
facade:
@define("my-test") class Test extends HTMLElement { // Implements a content attribute "foo" with getters and setters at #secret @attr(string(), { as: "foo" }) accessor #secret = "A"; // To provide public a public DOM API, we just write a getter/setter pair with // names matching the content attribute get foo() { console.log("Custom getter logic!"); return this.#secret; // accesses the getter decorated with @attr() } set foo(value) { console.log("Custom seter logic!"); this.#secret = value; // accesses the setter decorated with @attr() } }
1234567891011121314151617
@define("my-test")
class Test extends HTMLElement {
// Implements a content attribute "foo" with getters and setters at #secret
@attr(string(), { as: "foo" }) accessor #secret = "A";
// To provide public a public DOM API, we just write a getter/setter pair with
// names matching the content attribute
get foo() {
console.log("Custom getter logic!");
return this.#secret; // accesses the getter decorated with @attr()
}
set foo(value) {
console.log("Custom seter logic!");
this.#secret = value; // accesses the setter decorated with @attr()
}
}Notes for @attr():
- The option
asis mandatory when you use@attr()on a private or symbol accessor - Ornament throws exceptions if the class does not implement a public API for a content attribute defined with
@attr()on a private or symbol accessor
Event delegation
The following example captures all input events fired by
<input type="number"> in the document:
import { define, subscribe } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { @subscribe(document.documentElement, "input", { predicate: (evt) => evt.target.matches("input[type-number]"), }) log(evt) { console.log(evt); // "input" events } }
1234567891011
import { define, subscribe } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
@subscribe(document.documentElement, "input", {
predicate: (evt) => evt.target.matches("input[type-number]"),
})
log(evt) {
console.log(evt); // "input" events
}
}If you'd rather catch event happening in the component's shadow dom, the syntax gets a bit more gnarly at first:
import { define, subscribe } from "@sirpepe/ornament"; @define("my-test") class Test extends HTMLElement { root = this.attachShadow({ mode: "open" }); @subscribe((instance) => this.root, "input", { predicate: (evt) => evt.target.matches("input[type-number]"), }) log(evt) { console.log(evt); // "input" events } }
123456789101112
import { define, subscribe } from "@sirpepe/ornament";
@define("my-test")
class Test extends HTMLElement {
root = this.attachShadow({ mode: "open" });
@subscribe((instance) => this.root, "input", {
predicate: (evt) => evt.target.matches("input[type-number]"),
})
log(evt) {
console.log(evt); // "input" events
}
}Decorators like @subscribe run when the class definition initializes, and at
that point, no class instances (and no shadow DOM to subscribe to) exist. We
must therefore provide a function that can return the event target on
initialization. To make this less of an eyesore, it makes sense to create a
custom decorator for event delegation based on @subscribe:
import { define, subscribe } from "@sirpepe/ornament"; const handle = (eventName, selector) => subscribe((instance) => this.root, eventName, { predicate: (evt) => evt.target.matches(selector), }); @define("my-test") class Test extends HTMLElement { root = this.attachShadow({ mode: "open" }); @handle("input", "input[type-number]") // Much better! log(evt) { console.log(evt); // "input" events } }
12345678910111213141516
import { define, subscribe } from "@sirpepe/ornament";
const handle = (eventName, selector) =>
subscribe((instance) => this.root, eventName, {
predicate: (evt) => evt.target.matches(selector),
});
@define("my-test")
class Test extends HTMLElement {
root = this.attachShadow({ mode: "open" });
@handle("input", "input[type-number]") // Much better!
log(evt) {
console.log(evt); // "input" events
}
}Note that the function that @subscribe takes to access event targets can not
access a classes private fields. The shadow root has to be publicly accessible
(unless you want to mess around with WeakMaps storing ShadowRoots indexed by
element instances or something similar).
Also note that not all events bubble, so you might want to use event capturing instead:
import { define, subscribe } from "@sirpepe/ornament"; // This can now handle all events from the shadow root const capture = (eventName, selector) => subscribe((instance) => this.root, eventName, { predicate: (evt) => evt.target.matches(selector), capture: true, });
12345678
import { define, subscribe } from "@sirpepe/ornament";
// This can now handle all events from the shadow root
const capture = (eventName, selector) =>
subscribe((instance) => this.root, eventName, {
predicate: (evt) => evt.target.matches(selector),
capture: true,
});Also also note that only composed events propagate through shadow boundaries, which may become important if you want to nest components with shadow dom and also want to use event delegation.
Custom defaults
If you don't like Ornament's defaults, remember that decorators and transformers are just functions. This means that you can use partial application to change the default options:
import { define, attr, reactive as baseReactive, string, } from "@sirpepe/ornament"; // @reactive with "keys" always set to ["foo"] const reactive = (options) => baseReactive({ ...options, keys: ["foo"] }); @define("my-test") class Test extends HTMLElement { @prop(string()) accessor foo = "A"; // included in options.keys @prop(string()) accessor bar = "A"; // excluded from options.keys @reactive() log() { console.log("Hello"); } } let test = new Test(); test.foo = "B"; // logs "Hello" test.bar = "B"; // does not log anything
123456789101112131415161718192021222324
import {
define,
attr,
reactive as baseReactive,
string,
} from "@sirpepe/ornament";
// @reactive with "keys" always set to ["foo"]
const reactive = (options) => baseReactive({ ...options, keys: ["foo"] });
@define("my-test")
class Test extends HTMLElement {
@prop(string()) accessor foo = "A"; // included in options.keys
@prop(string()) accessor bar = "A"; // excluded from options.keys
@reactive()
log() {
console.log("Hello");
}
}
let test = new Test();
test.foo = "B"; // logs "Hello"
test.bar = "B"; // does not log anythingThe same approach works when you want to create specialized decorators from existing ones...
import { define, subscribe } from "@sirpepe/ornament"; // A more convenient decorator for event delegation function listen(event, selector = "*") { return subscribe(document.documentElement, "input", (evt) => evt.target.matches(selector), ); } @define("my-test") class Test extends HTMLElement { @listen("input", "input[type-number]") log(evt) { console.log(evt); } }
12345678910111213141516
import { define, subscribe } from "@sirpepe/ornament";
// A more convenient decorator for event delegation
function listen(event, selector = "*") {
return subscribe(document.documentElement, "input", (evt) =>
evt.target.matches(selector),
);
}
@define("my-test")
class Test extends HTMLElement {
@listen("input", "input[type-number]")
log(evt) {
console.log(evt);
}
}... or when you want to create your own transformers:
import { define, attr, number } from "@sirpepe/ornament"; function nonnegativeNumber(otherOptions) { return number({ ...otherOptions, min: 0 }); } @define("my-test") class Test extends HTMLElement { @attr(nonnegativeNumber({ max: 1337 })) accessor foo = 42; }
1234567891011
import { define, attr, number } from "@sirpepe/ornament";
function nonnegativeNumber(otherOptions) {
return number({ ...otherOptions, min: 0 });
}
@define("my-test")
class Test extends HTMLElement {
@attr(nonnegativeNumber({ max: 1337 }))
accessor foo = 42;
}You can also compose decorators, since they are just functions over a target and a context object:
import { reactive as baseReactive, connected } from "@sirpepe/ornament"; // Combines @reactive() and @connected() into one handy decorator that runs // methods when components connect AND when their attributes change function reactive() { return function (target, context) { return baseReactive()(connected()(target, context), context); }; }
123456789
import { reactive as baseReactive, connected } from "@sirpepe/ornament";
// Combines @reactive() and @connected() into one handy decorator that runs
// methods when components connect AND when their attributes change
function reactive() {
return function (target, context) {
return baseReactive()(connected()(target, context), context);
};
}And while we are at it, why not compose and partially apply decorators:
import { reactive as baseReactive, connected, debounce, } from "@sirpepe/ornament"; // Combines @reactive(), @connected() and @debounce(): // - reacts to attribute updates (only while the component is connected) // - and runs its target method at most once per frame // - and also when the component connects const reactive = () => (target, context) => baseReactive({ predicate: ({ isConnected }) => isConnected })( connected()(debounce({ fn: debounce.raf() })(target, context), context), context, );
123456789101112131415
import {
reactive as baseReactive,
connected,
debounce,
} from "@sirpepe/ornament";
// Combines @reactive(), @connected() and @debounce():
// - reacts to attribute updates (only while the component is connected)
// - and runs its target method at most once per frame
// - and also when the component connects
const reactive = () => (target, context) =>
baseReactive({ predicate: ({ isConnected }) => isConnected })(
connected()(debounce({ fn: debounce.raf() })(target, context), context),
context,
);Also remember that transformer functions return plain objects that you can modify for one-off custom transformers:
import { define, attr, string } from "@sirpepe/ornament"; // The built-in string transformer always represents strings, but we want to // allow `null` in this case let nullableString = { ...string(), validate(value) { if (value === null || typeof value === "undefined") { return value; } return String(value); }, }; @define("my-test") class Test extends HTMLElement { @attr(nullableString()) accessor foo = "Hello"; }
12345678910111213141516171819
import { define, attr, string } from "@sirpepe/ornament";
// The built-in string transformer always represents strings, but we want to
// allow `null` in this case
let nullableString = {
...string(),
validate(value) {
if (value === null || typeof value === "undefined") {
return value;
}
return String(value);
},
};
@define("my-test")
class Test extends HTMLElement {
@attr(nullableString())
accessor foo = "Hello";
}Ornament's building blocks are extremely basic and you should hack, combine and extend them to get the most out of your components.