Element Creators (elements)

tosijs provides elements for easily and efficiently generating DOM elements without using innerHTML or other unsafe methods.

The design goal of elements was to make creating DOM elements using javascript simpler and faster than using HTML or JSX / TSX while requiring no build-time tooling and no DSLs.

import { elements } from 'tosijs'

const { div, input, label, span } = elements

preview.append(
  div(
    {
      style: {
        display: 'flex',
        flexDirection: 'column',
        padding: 10,
        gap: 10
      }
    },
    label(
      {
        style: {
          display: 'inline-flex'
        }
      },
      span('text'),
      input({value: 'hello world', placeholder: 'type something'})
    ),
    label(
      {
        style: {
          display: 'inline-flex'
        }
      },
      span('checkbox'),
      input({type: 'checkbox', checked: true})
    )
  )
)

ElementCreator functions

elements is a proxy whose properties are element factory functions, referred to throughout this documentation as elementCreators, functions of type ElementCreator. So elements.div is a function that returns a <div> element, elements.foo creates <foo> elements, and elements.fooBar creates <foo-bar> elements.

What a positional argument means

Every argument is classified by what it is, and there is exactly one rule per kind:

you pass you get
a node, string or number appended as a child
a tosijs proxy a live child, re-rendered as the value changes
any other value with a text form — Date, bigint, boolean, a RegExp, anything whose toString is its own appended as text
a plain object, or a Map applied as props → attributes, properties or bindings
null / undefined nothing, deliberately
an array a warning — arrays are not flattened, so spread it: ul(...items.map(li))
anything else (a function, a WeakMap, a class with neither text form nor fields) a warning, and it is ignored

null is the way to render nothing, and it is the only way. That is a deliberate choice rather than an oversight: a conditional child should say it is conditional, so write div(cond ? label : null). false is a value and renders as "false" — div(cond && label) is a JSX habit that relies on short-circuit evaluation producing a falsy value, and importing it would mean booleans could never be shown. null is the intentional version of the same idea, and it reads as intent at the call site.

Nothing is silently dropped. An argument that cannot be used says so, once, with the value attached. So div(new Date()) renders the date as text, and div(items.map(…)) with the spread forgotten warns instead of quietly doing the wrong thing.

E.g.

import { elements, tosi } from 'tosijs'

const { elementCreatorDemo } = tosi({
  elementCreatorDemo: {
    isChecked: true,
    someString: 'hello elementCreator',
    someColor: 'blue',
    clicks: 0
  }
})

const { div, button, label, input } = elements

preview.append(
  div('I am a div'),
  div(
    {
      style: { color: 'blue' }
    },
    elementCreatorDemo.someString
  ),
  label(
    'Edit someString',
    input({bindValue: elementCreatorDemo.someString})
  ),
  div(
    button(
      'Click me',
      {
        onClick() {
          elementCreatorDemo.clicks += 1
        }
      }
    ),
    div(elementCreatorDemo.clicks, ' clicks so far'),
  ),
  label(
    'isChecked?',
    input({type: 'checkbox', bindValue: elementCreatorDemo.isChecked})
  )
)

camelCase conversion

Attributes in camelCase, e.g. dataInfo, will be converted to kebab-case, so:

span({dataInfo: 'foo'})        // produces <span data-info="foo"></span>

style properties

style properties can be objects, and these are used to modify the element's style object (while a string property will just change the element's style attribute, eliminating previous changes).

span({style: 'border: 1px solid red'}, {style: 'font-size: 15px'})

…produces <span style="font-size: 15px"></span>, which is probably not what was wanted.

span({style: {border: '1px solid red'}}, {style: {fontSize: '15px'}})

…produces <span style="border: 1px solid red; font-size: 15px"></span> which is probably what was wanted.

class property

The class property accepts three forms:

Extra whitespace is tolerated. Falsy values — '', null, undefined, and false (e.g. from cond ? 'active' : undefined or cond && 'active') — add no class, so conditional class expressions work without special-casing. Array entries are treated the same way (falsy entries are skipped).

binding

There are two ways to bind an element to state from a creator. Binding to the DOM is the overview; in short:

Pass a proxy as a property, or as a child. The property or text then follows the state. This is one-way, state to element:

const { app } = tosi({ app: { title: 'hello', busy: false } })

h1(app.title)                           // live text
button('Save', { disabled: app.busy })  // live property
input({ value: app.title })             // shows the title; typing does NOT change it

Use a bind<Name> property when the element should also write back, or when you have a path string instead of a proxy:

input({ bindValue: app.title })         // two-way
input({ bindValue: 'app.title' })       // the same, by path

bindValue is syntax sugar for:

import { elements, bind, bindings } from 'tosijs'
const { input } = elements
bind(input(), 'app.title', bindings.value)

The bind<Name> properties use the built-in bindings. To use a binding of your own, pass it inline with bind:

const visibleBinding = {
  toDOM(element, value) {
    element.classList.toggle('hidden', !value)
  }
}

div({ bind: { value: app.prefs.isVisible, binding: visibleBinding } })

event handlers

Properties starting with on followed by an uppercase letter become event handlers, so onMouseup attaches a mouseup listener. This works for any event name, including custom ones.

import { elements } from 'tosijs'
const { button } = elements
document.body.append(
  button('click me', {onClick() {
    alert('clicked!')
  }})
)

…is syntax sugar for:

import { elements, on } from 'tosijs'
const { button } = elements
const aButton = button('click me')
on(aButton, 'click', () => {
  alert('clicked!')
})
document.body.append(aButton)

A handler can also be a proxy (or a path string) for a function in state:

const { app } = tosi({ app: { save() { … } } })
button('Save', { onClick: app.save })

The element is given the path app.save, not the function, and the handler is looked up when the event fires. Replace app.save later and the button runs the new function.

There are some subtle but important differences between on() and addEventListener which are discussed in detail in the section on bind.

apply

A property named apply is assumed to be a function that will be called on the element.

span({
  apply(element){ element.textContent = 'foobar'}
})

…produces <span>foobar</span>.

fragment

elements.fragment produces DocumentFragments, but is otherwise just like other element factory functions.

svgElements

svgElements is a proxy just like elements but it produces SVG elements in the appropriate namespace.

import { svgElements, tosi } from 'tosijs'

const { svg, g, path, circle, polygon } = svgElements

// --- radar background ---
const outerRing = 'M128,8 C194.274,8,248,61.7258,248,128 C248,194.274,194.274,248,128,248 C61.7258,248,8.00001,194.274,8.00001,128 C8.00001,61.7258,61.7258,8,128,8 z'
const vLine = 'M128,53 C128,53,128,203,128,203'
const hRight = 'M203,128 C203,128,143,128,143,128'
const hLeft = 'M113,128 C113,128,53,128,53,128'
const guide = 'fill:#00a79e;fill-opacity:0.127;fill-rule:evenodd;stroke:#00a79e;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-width:4;'
const axis = guide + 'stroke-opacity:0.24;'

// --- two separate arrays: friendlies and hostiles ---
let nextId = 0
const RANGE = 115

function spawnFriendly() {
  const angle = Math.random() * Math.PI * 2
  const heading = angle + Math.PI * (0.6 + Math.random() * 0.8)
  const speed = 0.2 + Math.random() * 0.3
  return {
    id: nextId++,
    x: 128 + Math.cos(angle) * 105, y: 128 + Math.sin(angle) * 105,
    dx: Math.cos(heading) * speed, dy: Math.sin(heading) * speed,
  }
}

function spawnHostile() {
  const angle = Math.random() * Math.PI * 2
  const heading = angle + Math.PI * (0.7 + Math.random() * 0.6)
  const speed = 0.5 + Math.random() * 0.6
  return {
    id: nextId++,
    x: 128 + Math.cos(angle) * 110, y: 128 + Math.sin(angle) * 110,
    dx: Math.cos(heading) * speed, dy: Math.sin(heading) * speed,
  }
}

const { friendlies, hostiles } = tosi({
  friendlies: Array.from({ length: 6 }, spawnFriendly),
  hostiles: Array.from({ length: 4 }, spawnHostile),
})

// custom binding: position a <g> from its list item's x,y
const position = (el, item) => {
  if (item) el.setAttribute('transform', `translate(${item.x},${item.y})`)
}

// --- list-bound blip layers (one per array, no filter needed) ---
const friendlyLayer = g(
  g(
    circle({ r: '5', fill: 'none', stroke: '#8cc63f', 'stroke-width': '1' }),
    { bind: { value: '^', binding: position } }
  ),
  { bind: { value: friendlies, binding: 'list', options: { idPath: 'id' } } }
)
const hostileLayer = g(
  g(
    polygon({ points: '0,-6 5.2,3 -5.2,3', fill: 'none', stroke: '#ff1d25', 'stroke-width': '1.5', 'stroke-linejoin': 'round' }),
    { bind: { value: '^', binding: position } }
  ),
  { bind: { value: hostiles, binding: 'list', options: { idPath: 'id' } } }
)

preview.append(
  svg(
    { width: '256', height: '256', viewBox: '0 0 256 256' },
    g(
      path({ style: guide + 'stroke-opacity:0.5;', d: outerRing }),
      path({ style: axis, d: vLine }),
      path({ style: axis, d: hRight }),
      path({ style: axis, d: hLeft }),
    ),
    friendlyLayer,
    hostileLayer,
  )
)

// animate: advance, cull out-of-range, spawn new
function tick(arr) {
  const kept = []
  for (const b of arr) {
    const nx = b.x + b.dx, ny = b.y + b.dy
    if (Math.sqrt((nx - 128) ** 2 + (ny - 128) ** 2) < RANGE) {
      kept.push({ ...b, x: nx, y: ny })
    }
  }
  return kept
}
setInterval(() => {
  const f = tick(friendlies.value)
  if (Math.random() < 0.06) f.push(spawnFriendly())
  friendlies.value = f

  const h = tick(hostiles.value)
  if (Math.random() < 0.04) h.push(spawnHostile())
  hostiles.value = h
}, 50)

mathML

mathML is a proxy just like elements but it products MathML elements in the appropriate namespace.

Caution

Both svgElements and mathML are experimental and do not have anything like the degree of testing behind them as elements. In particular, the properties of SVG elements (and possible MathML elements) are quite different from ordinary elements, so the underlying ElementCreator will never try to set properties directly and will always use setAttribute(...).

E.g. svgElements.svg({viewBox: '0 0 100 100'}) will call setAttribute() and not set the property directly, because the viewBox property is… weird, but setting the attribute works.

Again, use with caution!

bindParts()

bindParts(
  root: Element,
  bindingMap: Record<string, ElementProps>,
  dataAttribute?: string  // default: 'part'
): void

bindParts() applies ElementProps to elements inside root that are identified by a data- attribute. This lets you take an existing chunk of DOM — from innerHTML, a CMS, a server-rendered page, or an HTML <template> — and wire up bindings, event handlers, and properties without having to build the DOM programmatically.

const root = document.querySelector('.my-widget')
root.innerHTML = `
  <h2 data-part="title"></h2>
  <input data-part="search">
  <button data-part="submit">Go</button>
`

bindParts(root, {
  title:  { textContent: app.title },
  search: { bindValue: app.query },
  submit: { onClick: () => performSearch() },
})

Each key in bindingMap is matched against the value of data-part (or whatever dataAttribute you specify). Matching elements receive the full ElementProps treatment — the same logic used by element creators — so bind, textContent, on* handlers, style, class, apply, and proxy values all work.

Elements are tracked via a WeakSet so calling bindParts() again on the same root is safe — already-bound elements are skipped.

Custom data attribute

Pass a third argument to use a different attribute name:

bindParts(root, map, 'role')  // matches data-role="..."