JSX ui kit built for Toools™
$ npm install @tooooools/uiConfigure JSX in Vite:
// vite.config.js
export default {
esbuild: {
jsxInject: "import { h } from '@tooooools/ui'",
jsxFactory: 'h'
}
}import { $ } from '@tooooools/ui/state'
const $count = $(0)
$count.value //=> 0
$count.value = 1
$count.value //=> 1Recomputes automatically when the source changes.
import { $ } from '@tooooools/ui/state'
const $count = $(0)
const $double = $($count, n => n * 2)
$count.value = 3
$double.value //=> 6import { $ } from '@tooooools/ui/state'
const $count = $(0)
const $done = $(false)
const $label = $([$count, $done], ([n, done]) => `${n} item${n === 1 ? '' : 's'}${done ? ' ✓' : ''}`)
$count.value = 1
$label.value //=> '1 item'
$done.value = true
$label.value //=> '1 item ✓'$() accepts a signal or a plain value and always returns a signal.
import { $ } from '@tooooools/ui/state'
const a = $(existingSignal) // returns existingSignal as-is
const b = $(42) // new writable with value 42import { $ } from '@tooooools/ui/state'
const $count = $(0)
$count.subscribe(n => console.log('count:', n))
$count.value = 5 // logs: count: 5Boolean inversion of a signal.
import { $, not } from '@tooooools/ui/state'
const $count = $(0)
const $empty = not($count)
$count.value = 0
$empty.value //=> true
$count.value = 1
$empty.value //=> falselocalStorage-backed writable. Survives page reload.
import { persist } from '@tooooools/ui/state'
const $count = persist(0, 'counter')
$count.value = 3
// reload page
$count.value //=> 3Defers all signal dispatches until the end of the block. Derived signals fire once with final values.
import { $, batch } from '@tooooools/ui/state'
const $count = $(0)
const $done = $(false)
const $label = $([$count, $done], ([n, done]) => `${n} / ${done}`)
$label.subscribe(console.log)
// Without batch: $label fires twice
$count.value = 5 // logs: '5 / false'
$done.value = true // logs: '5 / true'
// With batch: $label fires once
batch(() => {
$count.value = 5
$done.value = true
})
// logs: '5 / true'import { Component, render } from '@tooooools/ui'
class Counter extends Component {
template () {
return <div>Count: 0</div>
}
}
render(<Counter />)
render(<Counter />, document.querySelector('#app'))Declare a static props schema for validation. Mismatches log a warning; required props throw.
import { Component, Props, render } from '@tooooools/ui'
class Counter extends Component {
static props = {
// optional — warns if wrong type
label: Props.string,
initial: Props.number,
// required — throws if absent or wrong type
id: Props.required(Props.string),
// union — value must match one of the types
count: [Props.number, Props.Signal],
// enum — throws if value is not one of the listed values
size: Props.enum('sm', 'md', 'lg')
}
template ({ label, initial }) {
return <div>{label}: {initial}</div>
}
}
render(<Counter label='Score' initial={0} />)Available types:
Props.string,Props.number,Props.boolean,Props.functionProps.array,Props.objectProps.Element,Props.SVGElement,Props.Component,Props.Signal
Signals declared as class fields update the DOM automatically.
import { Component, Props } from '@tooooools/ui'
import { $ } from '@tooooools/ui/state'
class Counter extends Component {
static props = {
label: Props.string,
initial: Props.number
}
$count = $(this.props.initial ?? 0)
template ({ label }) {
return <div>{label}: <span textContent={this.$count} /></div>
}
}Use event-* attributes on any element. Arrow class fields preserve this.
import { Component } from '@tooooools/ui'
import { $ } from '@tooooools/ui/state'
class Counter extends Component {
$count = $(this.props.initial ?? 0)
handleIncrement = () => { this.$count.value++ }
handleDecrement = () => { this.$count.value-- }
template ({ label }) {
return (
<div>
<button event-click={this.handleDecrement}>-</button>
{label}: <span textContent={this.$count} />
<button event-click={this.handleIncrement}>+</button>
</div>
)
}
}Pass a signal or plain value. Use objects for conditional classes.
import { Component } from '@tooooools/ui'
import { $ } from '@tooooools/ui/state'
class TodoItem extends Component {
$done = $(false)
template ({ label }) {
return (
<div class={['item', { 'is-done': this.$done }]}>
<span textContent={label} />
<button event-click={() => { this.$done.value = !this.$done.value }}>
Toggle
</button>
</div>
)
}
}$() normalises either, so callers can pass a plain value or an external signal.
import { Component, Props } from '@tooooools/ui'
import { $ } from '@tooooools/ui/state'
class Counter extends Component {
static props = {
count: [Props.number, Props.Signal]
}
$count = $(this.props.count ?? 0) // count={3} and count={$signal} both work
}Access DOM elements or child components directly via this.refs.
import { Component } from '@tooooools/ui'
class TodoList extends Component {
handleAdd = () => {
const label = this.refs.input.value.trim()
if (!label) return
this.refs.input.value = ''
}
template () {
return (
<div>
<input ref={this.ref('input')} type='text' placeholder='New item' />
<button event-click={this.handleAdd}>Add</button>
</div>
)
}
}Subscribe to a signal inside a component. Subscription is cleaned up automatically on destroy.
import { Component } from '@tooooools/ui'
import { $ } from '@tooooools/ui/state'
class Counter extends Component {
$count = $(0)
afterRender () {
this.watch(this.$count, n => {
document.title = `Count: ${n}`
}, { immediate: true })
}
}Pass an array of signals. The callback receives an array of their current values.
import { Component } from '@tooooools/ui'
import { $ } from '@tooooools/ui/state'
class TodoItem extends Component {
$count = $(0)
$done = $(false)
afterRender () {
this.watch([this.$count, this.$done], ([n, done]) => {
document.title = `${n} item${n === 1 ? '' : 's'}${done ? ' ✓' : ''}`
}, { immediate: true })
}
}Render vnodes into a container after initial mount. Rendered content is tracked and cleaned up on destroy.
import { Component, render } from '@tooooools/ui'
class TodoList extends Component {
handleAdd = label => {
this.render(<TodoItem label={label} />, this.refs.list)
}
template () {
return (
<div>
<ul ref={this.ref('list')} />
<button event-click={() => this.handleAdd('New item')}>Add</button>
</div>
)
}
}import { Component } from '@tooooools/ui'
class TodoList extends Component {
afterRender () {
// DOM exists but not yet in document — safe for attribute reads, not layout
this.watch(this.$items, this.syncTitle)
}
afterMount () {
// DOM is in document — safe for layout reads, focus, measurements
this.refs.input.focus()
}
beforeDestroy () {
// called before DOM removal and signal unsubscription
}
}See documentation.
$ yarn start # Storybook on :6006
$ yarn example # Vite dev server for example/
$ yarn build # Library build to dist/JSX and state utils heavily based on pqml’s work.
