domeleon
v0.6.6
Published
Build web UIs with classes encapsulating your domain model and a pluggable Preact/React/Vue VDOM.
Readme
Domeleon
Build web UIs with classes encapsulating your domain model and a pluggable Preact/React/Vue VDOM.
Installation
npm
npm install domeleonTopics
- Installation
- Features
- Architecture
- First Code Sample
- Examples / Real World Usage
- Components
- HTML Helpers
- Forms
- Validation
- Unocss & Theming
- Routing
- Serialization
- App
- Pluggable VDOM - React/Vue/Preact
- Inspector
- Maskito
- Component Communication
Features
- Pluggable VDOM rendering engines:
- Preact (default)
- React
- Vue
- Modular add-ons:
unocssfor succinct css w/ a type safe theme managerzodfor validationmaskitofor masked inputsinspectorto monitor your component's state & updates
- Core:
- Serializer - serialize component state to the server or local storage
- Router - composable router integrated with component model
- Forms - ARIA, validation, structure uniform & friendly across varied CSS
Architecture
The heart of your Domeleon app is your object model: classes encapsulating the state of your domain model instead of wrapping DOM elements. So long as your classes imply a tree from their public writable properties, Domeleon can reason about that tree. Most notably, transforming that tree into a view or serializing it to the server or local storage.
Simplicity guides the architecture:
- Views use functional style:
- because views are simpler when stateless
- Components use OO style:
- because encapsulation simplifies reasoning about state
- HTML/CSS use TypeScript (also works with Javascript):
- because it's simpler to code in one high level language
First Code Sample
Here's a Counter component in Domeleon. It assumes an HTML page with <div id="app">.
import { App, Component, div, button } from 'domeleon'
class Counter extends Component {
count = 0
view() {
return div(
button({ onClick: () => this.add()}, '+'),
this.count
)
}
add() {
this.count++
this.update()
}
}
new App({ root: new Counter(), id: 'app' })Application state lives in your components — in this case count.
Components optionally implement a view method, but since they represent your domain model, rather than a wrapper around a DOM element, you can have multiple views, e.g. summaryView or detailView. A view is a pure non-side-effecting function of the Component's state returning an immutable VElement.
Components update their state via their update method, which triggers a re-render of the root component's view. Features like databinding, routing, and serialization, all automatically call update for you, which you can monitor with domeleon's inspector module.
Examples / Real World Usage
Numerous examples here: https://typebulb.com
Typebulb runs apps in markdown files called bulbs, perfect for tools, visualizations & experiments. Many of the bulbs are written in domeleon, and typebulb.com itself is itself written in Domeleon too.
Components
Derive from the Component class to encapsulate part of your domain model. Then build out a tree of components to structure your application. Here, we build upon our introductory example, by composing two smaller components into a new component:
import { Component, div } from 'domeleon'
import { Counter } from './counter'
export class TwinCounters extends Component {
counter1 = new Counter()
counter2 = new Counter()
view() {
return div(
this.counter1.view(), // Call child's view method
this.counter2.view()
)
}
}💡Component != DOM Element
A component encapsulates a piece of application state, not a DOM element.
You can also specify arrays of components. Here's the structure of a recursive tree component:
import { Component } from 'domeleon'
export class Tree extends Component
{
trees: Tree[] = []
...
}Let's flesh this out:
- add and remove components
- enable serialization so it roundtrips & our component state hot-reloads
- add a visualisation of each node.
import { Component, button, div, VElement, SerializerMap } from 'domeleon'
export class Tree extends SampleComponent {
serializerMap: SerializerMap<SampleComponent> = { trees: [Tree] } // optional, for derialization
trees: Tree[] = []
add() {
this.trees.push(new Tree())
this.update()
}
remove() {
this.trees.pop()
this.update()
}
view(): VElement {
return div({ style: { paddingLeft: `${this.ctx.rootToHere.length * 10}px` } },
this.ctx.componentId,
button({ onClick: () => this.add() }, "add"),
this.trees.length ?
button({ onClick: () => this.remove() }, "remove") :
undefined,
this.trees.map(c => c.view())
)
}
}The serializerMap field enables our tree's children to be successfully deserialized. It's a simple map to deserialize the raw incoming json objects into classes.
💡Deep Dive: Every component is assigned an auto-incrementing unique
componentIdat construction, accessible via itsctxproperty. It's used byformFieldto automatically assign labels to inputs with unique ids, so you never need to. You rarely actually use it explicitly like we did in this example, but it's informative in understanding how domeleon works.
Component Initialization
Construction
Your component is simply a class instance, so its life begins with a constructor call.
onAttached
The onAttached method is called after your component has its ctx.app and ctx.parent property set.
override onAttached () {
// Perform setup, e.g.: // start subscriptions, fetch data
}The onAttached method is guaranteed to be called:
- once per component attached to the component tree
- synchronously, after an update (or app start) made it accessible from the root component
- before the next render
- after every component in the component tree for that update (or app start) has had its
parentandchildproperties set - before
onAttachedis called on its children
Tip: The
onNavigatemethod is often used in tandem withonAttachedto build out your component model. It's particulary useful for asynchronously fetching data from the server upon navigation.
Updates
The primary function of update is to trigger a re-render of the app. As we saw earlier:
add (x: number) {
this.count += x
this.update()
}Specifically, update triggers:
onUpdatedon the component and each ancestor up to the root- a save, if
autoPersistis enabled - a re-render of the app
By far the most expensive part of on update cycle is changes to the DOM. Since Domeleon runs atop state-of-the-art virtual DOMs, these operations are extremely fast and reliable.
Tip: Use the domeleon inspector to live view all updates to your component tree. It's helps understand the model.
Tip: In certain exceptional circumstances, such as a complex custom animations, postpone calling
update. This is exactly how the Mandelbrot Explorer written with Domeleon works while zooming, as each calculation needs to run within 16ms! Domeleon lets you be pragmatic.
Views
Views are pure functions of component state: each component's view returns an extremely light-weight immutable VElement tree.
The app's chosen renderer takes this tree and translates it into the specific VDOM nodes (e.g., Preact, Vue, or React nodes) used by the underlying renderer, which then patches the VDOM.
view methods tend to be parameterless, as components are natural units of encapsulation: they simply render their own state. However, the world isn't perfect! In domeleon, you can add optional parameters to your child components' view methods. This can be useful when a child's view needs to render state that makes more sense to live on the parent
Critically, domeleon is model-centric, not view-centric. A domeleon component represents a piece of state, not a piece of HTML. A component might be viewless or have multiple methods returning different VElements. For example:
summaryView () {
return div (...)
}
detailView () {
return div (...)
}HTML Helpers
Domeleon provides HTML helper functions (div, span, button, etc.) that create VElement objects. These helpers accept attribute objects followed by a spread of child elements or primitive values.
Examples:
import { div, button } from 'domeleon'
div() // empty <div></div>
div("hello") // <div>hello</div>
div({ id: 'my-div' }) // <div id="my-div"></div>
div({ id: 1, class: "foo" }) // <div id="1" class="foo"></div>
div({ id: 'main' }, "hello") // <div id="main">hello</div>
div(div({ class: 'inner' })) // <div><div class="inner"></div></div>
div({ id: 'combo' }, "hello", div("bye")) // <div id="combo">hello<div>bye</div></div>
button({ onClick: () => console.log('clicked') }, "Click Me") // <button>Click Me</button>Internally, div, button etc. just call the h function, passing in the element name.
class and style
Domeleon treats these properties specially:
classmay be astringorstring[]classpreprocesses utility classes (e.g. converts " " to "_")styleonly takes an object, not astringclassandstyleproperties are merged correctly when passing multiple attribute objects to anhfunction.
For example, the following 3 statements are all equivalent:
div({class: "x y" })
div({class: ["x", "y"] })
div({class: "x"}, { class: "y" }Import mergeAttrs should you need to merge attribute objects outside the context of an h function.
Event Handlers
Event handlers are specified directly in attribute objects:
button({ onClick: () => this.add(1) }, "+")JavaScript's this binding requires care. Follow these patterns within Domeleon components:
- Wrap callbacks in closures: To preserve the correct
thiscontext.
// CORRECT - this will guarantee the correct `this` pointer reference
button({ onClick: e => this.handleClick(e) }, "Click")
// WRONG - this can lose the *this* pointer reference
button({ onClick: this.handleClick }, "Click")Domeleon always expects camelCase, from events through to aria attributes. Each vdom specific renderer automatically transforms the casing appropriately (e.g. to kebab-casing etc.).
onMounted callback
The vast majority of your code won't need to touch the DOM; you simply output vdom nodes, and let the renderer patch your changes automatically.
However, sometimes you need direct access to DOM elements. It might be useful for focus management, integrating 3rd-party libraries that operate on a DOM element, or drawing on a canvas.
We can use the onMounted event on an attribute, to obtain a reference to a DOM element:
import { Component, VElement, canvas } from 'domeleon'
export class Paint extends Component {
view() {
return canvas({
width: 150,
height: 150,
onMounted: elm => {
const canvas = element as HTMLCanvasElement
}
})
}
}You can optional return a callback that is called when the element is unmounted.
DOM Keys
After each update, the virtual DOM is patched. The patcher compares the current virtual DOM tree to the previous one, and modifies the real DOM accordingly. However, the patching algorithm can't know your intent, and so occassionally does the wrong thing. It may try to reuse an element that you definitely want to replace, or it may try to replace a list of child elements that you merely wanted to reorder. To better declare your intent, provide keys for your virtual DOM nodes. For example:
div ({key: wizardPage})If the key changes, the patcher now knows to definitely recreate that DOM element. This means even if your next wizard page happened to have an input that could have been updated, that instead it will be replaced, predictably resetting DOM state like focus and selections, and invoking any animations that should occur on element creation.
Forms
Domeleon lets you choose 4 increasing layers of abstraction when building forms, to suit your needs:
| Layer | Description | API |
|-------|-------------|----------|
| Raw Input | Thin wrappers around the h function returning a VElement | input, select
| inputXXX | Functions that databind to your components | inputText, inputTextArea, inputSelect, inputRange, inputNumber (via maskito), inputMask (via maskito), inputCheckbox, inputRadioGroup, inputCheckboxGroup |
| formField | Takes an inputXXX function and automatically outputs standardized vdom elements with: labelling/description, validation, aria attributes, id generation, structural compatibility with major css frameworks | formField |
| formField wrapper | Your own helpers that wrap formField for consistent styling throughout your application. | myFormField
💡Note: By design, Domeleon never outputs css classes; this is entirely handled in your application layer. For example, you set the class on the
validationAttrsproperty offormField; domeleon is 100% not prescriptive here.
Raw Input Layer (lowest h layer)
This is simply directly using the standard auto-generated h wrappers based on the HTML spec:
input ({id: "foo", value: "name": onInput: () => { ... } })InputXXX Layer (databinding layer)
The following functions are abstraction of the raw input layer, to handle databinding for you:
inputTextinputTextAreainputSelectinputRangeinputNumber// maskitoinputMask// maskitoinputCheckboxinputRadioGroupinputCheckboxGroup
You can use these inputXxx functions directly, or you can merely pass the function into formField as the inputFn. Here's using one directly:
export class Form extends Component {
country = `UA`
view () {
return inputSelect({
target: this,
prop: () => this.country,
options: [
{value: "UA", label: "Ukraine"},
{value: "GE", label: "Georgia"}
]
})
}
}Every inputXxx function takes a target and prop value, to perform the databinding. Some inputXxx functions take additional arguments, such as inputSelect that takes an array of options.
formField
Here's how we could use an input function with formField. Note that in a typical app we'd probably write a reusable formField, to minimise boilerplate, as we'll see later.
import { Component, div } from 'domeleon';
export class Form extends Component {
country = `UA`
view () {
return formField({
target: this
prop: () => this.country,
inputFn: inputSelect,
inputProps: {
options: [
{value: "UA", label: "Ukraine"},
{value: "GE", label: "Georgia"}
]
}
})
}
}The inputProps simply passes through the properties to inputFn.
So why use formField rather than just directly use an inputXxx function?
formField will automatically:
- include a human readable label and/or description, either driven by the component's field name by default, or by the
getLabelmethod if implemented on the component - asssign unique
ids for the label and descriptions to work properly (usingcomponent.ctx.qualify(prop)). - output aria attributes
- if validation is enabled, output validation errors
- include a ubiqituous element structure compatible with all major css frameworks
Swapping from bootstrap to unocss? Your form fields stay the same: just update the css classes you feed them. formField helps you deeply separate the concerns of your form's data vs. presentation.
Writing a formField wrapper
It's common to write a wrapper for formField, for consistent styling across your app. Your usage can look like this:
appFormField ({
target: this,
prop: () => this.country
)Where you've defined a reusuable appFormField. Here is where you can set every facet of the formField, to precisely control the styling.
type SupportedInputFn = typeof inputText | typeof inputNumber | typeof inputMask
const appFormField = (props: FormFieldInputProps<SupportedInputFn>) =>
return formField({
...props,
// common customization
fieldAttrs: { class: styles.field }, // div wrapping entire field
descriptionAttrs: { class: styles.description },
validationAttrs: { class: styles.error } // e.g. red
})formField always provides a unified structure, regardless of whether we're using text inputs, select inputs, or radio buttons:
target// the component to databind toprop// the property on the component to databind to, expressed in a type safe mannerinputFn// the name of the input function, e.g.inputTextorinputSelectinputProps// the props theinputFnexpects.
Labels and Descriptions
Default Labels
By default, formField automatically generates a label name by converting yourProperty to Your Property using the humanizeIdentifer function. It translates like this:
yourPropertyName->Your Property Name
getLabels
If you need more control, or a more metadata driven approach, you can implement the getLabels function. This will automatically be picked up by formField when setting the label and description properties. Use as follows:
export class MySample extends Component {
username: string
bonus: number
getLabels() {
return {
username: { label: "What's your name?", description: "Nick names are ok!" },
bonus: "Annual Bonus"
}
}You can specify either just a string for label, or an object, if you wish to provide both a label and a description.
The description text by default appears below your input, and is swapped out by a validation message, if a validation error occurs.
Explicitly Setting label and description
For maximum control, explicitly set the label and description values directly when calling formField. Here you can set arbtirary HTML rather than just strings. For example:
return formField({
target: this,
prop: () => this.subscribeToNewsletter,
inputFn: inputCheckbox,
label: "Subscribe To Newsletter"
description: span({/* styles here */}, "Exclusive content!")
})Validation
Domeleon can integrate with validation libraries like zod or class-validator. The dedicated domeleon/zod package provides out of the box support for zod. zod has a very nice chained model for incrementally refining permissable values.
Suppose we define this zod schema:
const schema = z.object({
username : z.string().min(3).endsWith("x").max(10).regex(/^[a-z]+$/),
rating : z.number().min(0).max(10).optional()
})We can use it as follows:
export class MySample extends SampleComponent implements IValidated, z.infer<typeof schema>
{
validator: Validator = new ZodValidator(this, schema)
username!: string
rating?: number
submit () {
this.validator.validate()
...
}
override onUpdated(event: UpdateEvent) {
this.validator.revalidate(event)
}
view () {
return div (
formField({
target: this
prop: () => this.usernmae,
inputFn: inputText
}),
button({ onClick: () => this.submit()})
... // define other formField etc.
}
}Let's break this down:
Validatoris a domeleon base class that orchtestrates validating your components- Recursively validates your components by calling
validateSyncon each validator - Triggers
onValidateon your component, to handle custom asynchronous validations - Collates all the validations per property, and maintains an overall validation
state
- Recursively validates your components by calling
ZodValidatorimplementsvalidateSyncto validate your component's properties with yourZodschema
The onUpdated override gives incremental feedback to the user, by revalidating the component, as they adjust their form.
Async Validation
We can also provide custom async validation via overriding the onValidate method. This is useful for asynchronous valdation, or validation that applies to the form as a whole, rather than a particular property.
For example:
override async onValidate() {
const result = await ... // call server here
if (this.username == ...) { // check if username is unique
return [] // no validation errors
}
return [{
property: 'username',
messages: ['Username is not unique'],
value: this.username
}]
}Your component will receive two validation update events, first a synchronous one, to immediately give feedback to your users, followed by the asynchronous one.
validator.state
A validator's state is of type ValidationState:
"unvalidated" | // yet to be valiated
"validating" | // in the process of validation, no validation failed yet
"invalid" | // at least one synchronous or asynchronous validation failed
"valid" // determine valid, all async validatio complete
validator.stateis especially highlighted in theinspector.
Unocss & Theming
You can use whatever CSS framework you want with Domeleon. However, Domeleon has a specific add-on for unocss that provides a theme manager that allows you to express type-safe, terse, css code.
Canonical Usage
For example, here's styling for a tab component:
const tabStyles = themeMgr.styles("tab", theme => {
const { textPrimary, bgSecondary, bgAccent } = theme.colors
return {
active: `text-${textPrimary} border-b-${bgAccent} font-bold`,
inactive: `text-${bgSecondary} border-b-transparent font-normal`
}
})Then use styles with guaranteed type safety as follows:
view() {
div ({class: tabStyles.active }, ...)
}Benefits
For most apps, using domeleon's unocss theme manager will save you time:
- Effortlessly find or rename all usages of any specifc css variable, e.g.
bgSecondary, throughout your entire app- Confidently delete unused styles
- Class names are semantically named and scoped, such as
app-tab-active:- Zero collisions, short, & human radable
- The prefix, such as
tababove, is optional. SothemeMgr.styles(theme => { ...})is also ok
- Frictionless switching between custom themes, e.g. light vs dark
- Choose whatever organisation keeps your css code maintainable:
- Colocate styles with components
- Keep styles in their own file
Setting Up a Theme
Here is how you set up a theme:
const lightTheme = {
colors: {
textPrimary: 'rgb(0, 100, 0)',
bgPrimary: 'rgb(220, 220, 220)',
pianoRoll: {
barLine: '#808080', // both rgb & hex OK
barLabel: '#ffffff'
}
}
}
export const themeMgr = new UnoThemeManager({
id: 'app',
themes: { light: new LightTheme() },
unoCssConfig: { presets: [presetWind3()] }
})Multiple Themes
It's easy to add multiple themes. Here's adding a dark theme:
const darkTheme = {
colors = {
textPrimary: 'rgb(0, 200, 0)',
bgPrimary: 'rgb(50, 50, 50)',
pianoRoll: {
barLine: '#808080',
barLabel: '#000000'
}
}
}Where we now add both the light and dark themes to our theme manager:
themes: {
light: new LightTheme(),
dark: new DarkTheme()
}Dynamically Changing Themes
You simply set the themeName of your theme manager whenever you want to change themes, at runtime. Or you can configure it based on your users preference as follows:
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
export const themeMgr = new UnoThemeManager({
...
initialTheme: isDark ? 'dark' : 'light',
})Imports and App Setup
You'll need to pick an unocss preset, then use the UnoThemeManager and ThemeProxy for domeleon, as follows:
import presetWind3 from '@unocss/preset-wind3'
import { UnoThemeManager, type ThemeProxy } from 'domeleon/unocss'Make sure to specify your theme manager's cssAdapter in your app:
new App({
...
cssAdapter: themeMgr.unoCssAdapter
})This guarantees any fresh class properties on your VElement (div, span etc.) are sent through to the adapter just before each VDOM patch, with no FOUC (Flash of Unstyled Content).
Bypassing UnoCss Shortcuts
themeMgr.styles internally leverages unocss shortcuts, which means you get clean, short, semantically named class properties in your css. You can however, directly use utility styles on class properties if you wish. You can even reference your theme manager's styles and it will work fine. For example:
div({ class: `text-${bgSecondary} border-b-transparent font-normal`})Global Styles
globalUnoCss
Sometimes you need to specify styles globally, such as wanting your links to look a particular way across your entire application. You define them with a globalUnoCss definition:
const globalUnoCss = (theme: ThemeProxy<MotifnTheme>) => {
const { textPrimary, primaryHover, backgroundPrimary, textSecondary, } = theme.colors
return {
'a': `text-${textPrimary} no-underline`,
'a:hover': `text-${primaryHover}`,
'h1': `m-0 text-${textPrimary}`,
'body': `m-0 bg-${backgroundPrimary} text-${textSecondary} font-sans antialiased`
}
}
export const themeMgr = new UnoThemeManager({
...
globalUnoCss
})Be judicious with global styles; in most cases you don't need them.
globalRawCss
Sometimes you'll want to define raw css, not unocss utility strings. This is useful in certain cases, such as for keyframe animations. You define them with a globalRawCss definition:
const globalRawCss = (theme: ThemeProxy<MotifnTheme>) => {
const { highlightAnimateStart, highlight } = theme.colors
return `
@keyframes nowStroke {
0% { stroke: ${highlightAnimateStart.css}; }
100% { stroke: ${highlight.css}; }
}
@keyframes nowFill {
0% { fill: ${highlightAnimateStart.css}; }
100% { fill: ${highlight.css}; }
}
.now { animation: nowStroke 0.5s linear; }
.now-note-label { animation: nowFill 0.5s linear; }
`
}
export const themeMgr = new UnoThemeManager({
...
globalRawCss
})Once these global styles are defined, you can refer to them into your ordinary unocss styles.
CssVar—Advanced Usage
You almost always use theme properties such as textPrimary within a string literal. This means its toString method is called, that simply returns its name, for unocss usage.
Its type is CssVar which has other properties that are occasionally useful if you need to work at lower layer, as we did when using globalRawCss. For example:
name: textPrimary
cssVarName: --app-colors-text-primary
css: rgb(var(--app-colors-text-primary))
rawValue: rgb(0, 200, 0)Deep Dive: The proxy generated by the theme manager takes your raw string values and returns
CssVarvalues.
Transparency / Alpha / Opacity
Always separate the alpha values from your colors. For example:
const lightTheme = {
colors: {
textPrimary: 'rgb(0, 100, 0)'
},
// optional: “alphas”, “transparency”, etc.
alphas: {
panel: '0.5' // decimal-string between 0–1 (here, 50% opacity)
}
}Now, to make textPrimary 50% transparent, you have two options:
- Option 1: Simply inline
50with unocss; this is more common. - Option 2: Pass a theme variable to the
alphamethod of a color theme variable.
Both are shown below, yielding the same result:
const styles = themeMgr.styles("test", theme => {
const { textPrimary } = theme.colors
const { panel } = theme.alphas
return {
option1: `text-${textPrimary}}/50`
option2: `text-${textPrimary.alpha(panel)}`...
}
})Using an alpha theme variable is useful if your transparency value diverges per theme.
Dynamic Styling
Domeleon's unocss add-on computes all styles ar runtime, not build time. Why?
- The underlying
unocsslibrary is incredibly fast, even for the first render. - Even the presets are fairly small in kb.
- No build step = ultra simple deployment.
- Domeleon has deep integration, bypassing any FOUC issues.
Sticky Situation—Advanced Usage
One issue with dynamically generated styles, is occassionally you can build up too many styles in the head of your document. However, there's an escape hatch.
The UnoThemeManager class exposes its UnoCssAdapter:
- By default its
stickyModeisalways, so classes always persist across renders. - If you set
stickyModetoexplicitthen only classes passed through thestickyClassfunction perist across renders. Import and usestickyClassfor special cases like animation, where you need to guarantee classes "stick" around.
You can also call the UnoCssAdapter's clearStickyClasses to force clear the sticky styles.
Routing
Domeleon provides a composable router, where each Component that's routed has its own router property, and routeSegment:
export class Help extends Component
{
router: Router = new Router(this)
routeSegment = "help"
}The Help component will now match the help route.
💡Route Tree Structure
The route structure mirrors your component tree structure (but skips transparent routes).
💡Inspect Routes
Domeleon's inspector especially highlights your route structure, and informs you of router update events.
Navigation
Use the router's navigate method with relative paths. The below assumes this is the Help component:
| Code | From | To |
|------|-------------|------------|
| this.router.navigate('') | /help | /help |
| this.router.navigate('banana') | /help | /help/banana |
| this.router.root.navigate('help/banana') | / | /help/banana |
| this.router.parent.navigate('examples') | /help | /examples |
navigate takes an optional second argument action ('PUSH' | 'REPLACE', default 'PUSH'). Use 'REPLACE' to change the URL without adding a history entry — right for modal-like routes (e.g. a fullscreen toggle) where Back should leave the page, not replay the toggle.
Routes are path + query only: location.hash is never carried across navigations. This matches React Router and Vue Router; treat fragments as page-scoped state you set yourself after navigating.
Root router
Each router on your component tree also forms its own router tree. The root of that tree must define a transparent route (explained further below), meaning it has an empty routeSegment:
class Index {
router: Router = new Router(this)
routeSegment = ""
}Note that you can define
basePath, explained further below.
Intercepting Navigation
Implement your Component's' onNavigate for:
- Validation/Prevention: (return
falseto prevent navigation) - Data Fetching: (await retrieving child components)
- Redirection: (cancel, then navigate elsewhere)
Implement as follows:
async onNavigate (relativeRoute: Route) {
if (child.firstSegment == ...) {
// return false to cancel navigation
}
const child = await ... // fetch child
this.serializer.deserialize ({ child: child})
}You may also implement the synchronous onNavigated to react after a component becomes active in the route.
Route Class
The Route object provides a safe, structured view of a route. Access its segments or convenient firstSegment, that's simply segments[0].
Never engage in error-prone string manipulation of forward slashes to deal with paths! Use the
Routeclass instead.
firstSegment is the most common property to use in onNavigate as it represents the the immediate relative path, that is intended to match a child component's routeSegment.
Cancelling/Guarding Navigation
Return false to cancel/gaurd a navigation; otherwise you don't need to return anything.
If the cancelled navigation came from the browser's Back/Forward button, domeleon restores the address bar to the current route (the browser has already moved it by the time your guard runs).
Preparing Child Routes
A common pattern is to fetch data on onNavigate, so that a child can be navigated to. Domeleon strictly requires an exact match based on the routeSegment, so dynamic routes require this pattern. Think of it as Just-In-Time routing.
There's no wildcard matching in Domeleon routes! Why? There must always be a
componentthat represents the exact state of a given route. You can't navigate to"canteloupe"unless a component exists with therouteSegmentwith the value"canteloupe".
Navigation Links
The router has a link method that's very useful for creating <a href="..."> virtual DOM nodes. You simply provide a relative route (as a string|string[]|Route), and HValues. For example:
// Example: Creating a link to the 'banana' tab
this.router.link('banana', 'Go to Banana Tab')
// Renders an <a> tag with appropriate href and an `onClick` handler.Transparent Routes
Transparent routes are incredibly useful as they relax the strict 1-1 relationship between the component tree and route tree. They allow a component purely to pass through the route. For example, Sidebar has a transparent route:
class Sidebar extends Component {
router: Router = new Router(this)
routeSegment = ""
help = new Help()
}Transparent routers still receive onNavigate and onNavigated events. They just don't contribute to the path; think of it like parents who leave their kids with their grandparents.
RouteService
You can explicitly initialize the RouteService in the App construtor to set of a basePath. This will ensure your entire router setup works relatively to that path.
new App ({
...
routeService: new RouteService { basePath: "admin"}
})By default, a RouteService is created for you with an empty basePath.
Serialization
Domeleon's component tree is fully serializable, facilliating hot-reload via persisting to local storage, and transfering objects to and from the server.
ctx.keys and serializer.keys properties
Domeleon only cares about your Component's ctx.keys properties, which are:
- public
- not properties starting with
_
- not properties starting with
- read and write
- not properties with only a getter
- type is primitive, object, or class
- not functions
You can get the exact set of properties that your component will serialize by calling your component's serializer.keys property:
const keys = this.serializer.keysThese are the same as ctx.keys, but further filtered by any key set to null in your component's serializerMap if you choose to set it.
Serialization API
Every Component has a serializer:
class Component {
serializer: Serializer
...
}The Serializer exposes the twin methods serialize & deserialize, used as follows:
// turn any component into a JSON object
const snapshot = this.serializer.serialize()
// …later – deserialize the JSON object into our component
this.serializer.deserialize(snapshot)Both work recursively.
App.autoPersist
When an App is initialized with autoPersist: true, domeleon automatically saves after each update, and on loading, restores its state via local storage. It's trivial to set up:
import { App } from 'domeleon'
new App({
root : new Master(),
id : 'app',
autoPersist : true // save & restore automatically
})Internally, this calls serialize and deserialize on your root component.
Deserializing classes (and dates)
Declare a serializerMap to map plain json objects to classes:
export class Tree extends Component {
serializerMap: SerializerMap<Tree> = {
setting: Setting // component
created: Date // `Date` special case
trees : [Tree], // array of components
picker : null // transient component
}
setting: Setting
created: Date
trees: Tree[]
picker: new Picker()
}The types must be classes (Date is also handled). This is necessary because Javascript doesn't actually store runtime type information per property; that's a TypeScript only concept that is destroyed in transpilation.
Specifying a null value for a field in serializerMap prevents serialization. It's typically a useful alternative to prefixing a Component property with _ or # for sub-components that you don't want to serialize, but which you still otherwise want to treat as a Component (e.g. partaking in updates etc.)
Note: Domeleon's serializer does not use decorators for this purpose, as the latest spec is still not natively implemented by browsers as of 2025.
onDeserialized event
You can handle onDeserialized on your Component as follows:
class MyComponent extends Component {
onDeserialized() {
// e.g. perform validation
this.throwIfInvalid()
}
}After the component and its children have been deserialized, onDeserialized is called, children first, recursively.
Note:
onDeserializedhas early access toctx.parentandctx.app(when available) enabling contextual validation, even ifonAttachedhas yet to be called.onUpdatedwill always be called afterwards.
Handling Sets and Maps
For simplicity, Domeleon only allows nested components to be single instance values or arrays. This makes Domeleon easy to reason about, as well as providing a direct correspondence with our component tree and the JSON format.
We can however, still use Maps. We just need to make sure our map is:
- Private
- If public, only exposed via a read-only property, or simply a method.
- If serialized, expose publcally as an array.
Here's how we could serialize a collapseMap:
export class InspectorComponentTree extends Component {
_collapseMap = new Map<string, boolean>()
get collapseState () {
return [...this._collapse]
.map(
([id, collapsed]) =>
({id, collapsed})
)
}
set collapseState (state: { id: string; collapsed: boolean }[]) {
this._collapseMap = new Map(
state.map(
({id, collapsed }) =>
[id, collapsed]
)
)
}
}This means we get the performance benefits of a Map, but can fully partake in the domeleon object model.
Design Note: Domeleon may provide a way to reduce this boilerplate in the future; several designs are possible; still figuring out which one is best. For now this is acceptable.
App
Your Domeleon application always starts by constructing an App instnace.
Here's a minimal setup:
import { App } from 'domeleon'
new App({ root: new Root(), id: "app"}) // root component + id of element to mountHere's a more advanced setup:
import { App } from 'domeleon'
import { inspector } from 'domeleon/inspector'
import { themeMgr } from './styles/theme'
import { Master } from './master'
import { VueRenderer } from 'domeleon/vue'
new App({
root: new Root(),
id: "app",
cssAdapter: themeMgr.unoCssAdapter, // pass .class values through per render
renderer: new VueRenderer(), // altenatively a ReactRenderer
autoPersist: true, // auto serialize component tree to local storage
routeService: new RouteService { basePath: "admin"} // only needed for a custom base path
plugins: [inspector] // inspect component tree and events
})autoPersist
If true, then your component tree will automatically be serialized to local storage. This can be really nice when developing, as you don't lose your state as you update your source code. However, you may need to add serialization annotations (see Serialization section).
Global App Handle
Every App self-registers on window.domeleon.apps, keyed by mount id, and attaches itself to its mount element as __domeleon_app__. This is a devtools-style hook (like Vue's __vue_app__): window.domeleon.apps.get('app').root gives debuggers and agents direct access to the live component tree. Re-mounting on the same id replaces the registry entry.
Pluggable VDOM - React/Vue/Preact
Domeleon's VDOM is pluggable. The specific VDOM you choose should mostly be an implementation detail; you can write large applications with Domeleon, swap out the VDOM, and not even notice. All 3 VDOMs perform well and are very robust.
By default Domeleon uses Preact, as it's tiny, making it a perfect default. However, you can also use Domeleon with React or Vue, which is useful if you really need to leverage a particular component only available in that framework.
with* Attributes
We can integrate with renderer-specific hooks and components from Preact, React, and Vue, with with* attributes.
Here we animate the shuffle using useAutoAnimate with preact:
import { div, button } from 'domeleon'
import { useAutoAnimate } from '@formkit/auto-animate/preact'
export class Shuffler extends SampleComponent {
items = [1,2,3,4,5]
view() {
return div(
div({ withPreact: { hook: useAutoAnimate } },
this.items.map(i =>
div({ key: i }, i)
)
),
button({ onClick: () => this.shuffle() }, "Shuffle")
)
}
shuffle() {
this.items.sort(() => Math.random() - 0.5)
this.update()
}
}useAutoAnimate is a wonderful component, and provides hooks for all 3 frameworks. However, let's swap out our renderer with Vue and use Vue's TransitionGroup component as follows:
div({
withVue: {
component: TransitionGroup,
componentProps: { tag: 'div', name: transitionName }
}
},
this.items.map(i =>
div({ key: i }, i)
)
),You can even include both withPreact and withVue. If the renderer doesn't match, it's simply ignored.
Choosing a Renderer
You choose a renderer when you launch your app.
Preact / Default
Here's the default, that implicitly uses Preact:
import { App } from 'domeleon'
import { Root } from './root'
...
new App({root: new Root(), id: "app"})Vue
Here's using Vue:
import { VueRenderer } from 'domeleon/vue'
import { App } from 'domeleon'
import { Root } from './root'
new App({
root: new Root(),
id: "app",
renderer: new VueRenderer()
})React
Here's using React:
import { ReactRenderer } from 'domeleon/react'
import * as React from 'react'
import * as ReactDOMClient from 'react-dom/client'
import { App } from 'domeleon'
import { Root } from './root'
...
new App({
root: new Root(),
id: "app", // or "root" if you prefer that for name of the id of the element to mount on
renderer: new ReactRenderer({ ReactLib: React as any, ReactDOMClientLib: ReactDOMClient })
})React requires separated React and Client libaries that you import yourself, for configurability and versioning robustness.
Be Agnostic When you Can
Many of the best 3rd party libraries are vendor neutral, and you can use them simply via the onMounted hook, that works across all renderers.
Inspector
The inspector is a tool that lets you see your component tree and monitor update events.
Set up the inspector as follows:
import { App } from 'domeleon'
import { inspector } from 'domeleon/inspector'
new App({
...
plugins: [inspector]
})Components with a router property have their routes displayed, making it a great way to get an overview of your routing setup.
The types of update events are:
- router
- validator
- input
- serializer
- custom
inspector is itself an application written with Domeleon, and uses the @unocss/preset-wind3 preset as a peer dependency.
Maskito
Domeleon has wrappers for maskito's mask editors. The built in number input in HTML is terrible; use maskito instead. It's far cleaner, more powerful, and works uniformly across all browsers.
import { inputMask, inputNumber } from 'domeleon/maskito'You can use it exactly like you would inputText or inputRange; the inputProps exposes the maskito object model to configure it. Here's an example of using inputNumber with a formField:
formField({
prop: () => this.rating,
inputFn: inputNumber,
inputProps: {
numberParams: {
maximumFractionDigits: 1,
decimalSeparator: '.'
}
}
})Component Communication
Child-To-Parent Communication
Generally, a child doesn't need to talk to its parents; it looks after itself, and delegates to its own children. However, sometimes the best way to manage complexity is to break this generalisation. There are 4 main patterns for doing so:
Callbacks
In this child-to-parent communication pattern, the parent passes functions down to child views/components:
// Parent
view() { return this.child.view({ onSave: () => this.handleSave() }) }
handleSave() { this.update(...) }
// Child
view(props: { onSave?: () => void }) { ... }Parent Interface
In this child-to-parent communication pattern, the child accesses its parent via this.ctx.parent. The child then casts the parent to an interface that limits scope and prevents circular type references. Now the child can read state or call methods.
In this example, we want the Animal component to get its family's name, so we allow this via an IFamily interface:
interface IFamily { name: string }
class Family extends Component implements IFamily {
name: string
animals: Animal[]
}
class Animal extends Component {
get family() { return this.ctx.parent as IFamily }
foo() {
const name = this.family.name
}
}Root Interface
Building on the previous idea, sometimes it's useful for the root component to expose an interface, that any deeply nested child component can reference.
Here our root component implements IZoo:
interface IZoo {
families: IFamily[]
}
class Zoo extends Component implements IZoo {
families: Family[]
}
class Family {...}
class Animal extends Component {
get zoo () { return this.root as IZoo }
goo() {
const pride = this.zoo.families.find(f => f.name == "Lion")
}
}Key things to remember:
- Keep your interfaces limited. Don't expose more than you need to; the less you expose the less you need to reason about.
- Avoid children accessing parents, but only to a point: if you're tying yourself in knots trying to be "pure", you're merely trading one type of complexity for another.
Update Path
In this child-to-parent communication pattern, the parent overrides onUpdated (event: UpdateEvent) to react to any update originating from its children (or itself).
// Parent
onUpdated(event: UpdateEvent) {
...
} This will also catch any input, validator, router & serializer events.
Tip: To know what update events pass through
onUpdated, use the Inspector.
Component to Non-Component Communication
Within the component layer, maintain a single source of truth, i.e. avoid duplicating state across components. But when crossing boundaries, consider synchronizing between your component and the other object's state.
Synchronization Examples
Component + DOM Element: Use inputXxx databinding functions to automatically synchronize between your component state and DOM state.
Component + router: Use the onNavigate/onNavigated events on Component to synchronize between your component and the browser's location captured in your component router's selectedSegment.
💡Guideline: if the state in question should be serialized with your component, it belongs on your component, if it merely emphemerally exists elsewhere at the same time.
