Skip to content

Latest commit

 

History

History
501 lines (384 loc) · 28.2 KB

File metadata and controls

501 lines (384 loc) · 28.2 KB

Usage

API, node shape, layout and style — create schema from definition, render to DOM.

Table of Contents

Quick Start

Flow: definition ({ root: Node[] }) → create(definition) → schema → render(schema, container). Each call to render appends to the container; Schema2UI never clears it.

import { create, render } from '@neabyte/schema2ui'

// Step 1: Define definition object with root array of nodes
const schema = create({
  root: [
    // node 1: div with id, layout (width/height in px), text content
    { type: 'div', id: 'box', layout: { width: 200, height: 100 }, content: 'Hello' },
    // node 2: anchor with href in attrs, text content
    { type: 'a', attrs: { href: '/about' }, content: 'About' }
  ]
})

// Step 2: Render schema into a DOM container (creates elements and appends to it)
render(schema, document.getElementById('app'))

Flow Overview

  1. Definition — Plain object { root: Node[] }. Each node has at least type (HTML tag name); optional: id, layout, style, attrs, content, src/alt (img), children.
  2. Createcreate(definition) validates the definition (root array, node shape, allowed keys, void tags must not have children). Returns a frozen, normalized Schema. Throws on invalid input.
  3. Renderrender(schema, container) walks schema.root, creates DOM elements (or SVG via createElementNS where needed), applies attrs, layout, style, sets content/src/alt, appends children. Results are appended to container (HTMLElement). The container is not cleared before appending.

Example: definition → create → render

// Step 1: Definition — plain object with root array of nodes
const def = { root: [{ type: 'div', content: 'Hi' }] }
// Step 2: Create — validate and freeze; returns schema
const schema = create(def)
// Step 3: Render — build DOM and append to container
render(schema, document.getElementById('app'))

API Overview

Function / export Returns Description
create(definition) Schema Validate and freeze definition; return schema.
render(schema, container, options?) void Build DOM from schema and append to container; optional refs and onNodeMount.
el namespace Helper: el.root, el.node, el.div, etc.

Named exports: create, render, el, and Types (re-exported). You can also use the default export as a callable or via static members:

import Schema2UI from '@neabyte/schema2ui'

// Step 1: Callable — call default export to get { create, render, el }
const api = Schema2UI()
api.create({ root: [] })
api.render(schema, container)

// Step 2: Static — use Schema2UI.create / .render / .el without calling first
Schema2UI.create({ root: [{ type: 'div', content: 'Hi' }] })
Schema2UI.render(schema, container, { refs })
Schema2UI.el.root(el.div('Hello'))

Helper (el)

Use the el namespace to build definitions with less boilerplate. Same flow: create(el.root(...)) → schema → render(schema, container).

// Step 1: Import create, render, and the el helper
import { create, render, el } from '@neabyte/schema2ui'

// Step 2: Build definition with el.root(...); each el.* is a tag alias (el.header, el.main, el.h1, el.a)
const schema = create(
  el.root(
    // header node: props object + child (h1 with content 'Hello')
    el.header(
      { id: 'header', layout: { width: '100%', height: 56 }, style: { fill: '#1a1a2e' } },
      el.h1({ id: 'title', style: { font: '20px sans-serif', fill: '#eee' } }, 'Hello')
    ),
    // main node: props + child link with href and text 'Home'
    el.main({ layout: { width: '100%', flex: 1 } }, el.a({ attrs: { href: '/' } }, 'Home'))
  )
)

// Step 3: create() validates and freezes; then render into container
render(schema, document.getElementById('app'))

Refs and Lifecycle

To get element references after render (e.g. for showModal(), focus(), or addEventListener), pass options as the third argument to render:

  • refs — A Map<string, Element> you provide; Schema2UI fills it with node.id → element for every node that has an id.
  • onNodeMount — A callback (node, element) => void invoked once per node after that node’s element has been created and appended to its parent. Use it to attach event listeners or run imperative code.
  • signal — Optional AbortSignal; use it when attaching listeners in onNodeMount so that controller.abort() removes them (e.g. on teardown).

Example: refs and dialog

Definition as JSON (e.g. from JSON.stringify(definition, null, 2)):

{
  "root": [
    {
      "type": "dialog",
      "id": "my-dialog",
      "children": [{ "type": "p", "content": "Hello" }]
    }
  ]
}
// Step 1: Create schema with a dialog that has an id (so we can look it up via refs)
const schema = create({
  root: [{ type: 'dialog', id: 'my-dialog', children: [{ type: 'p', content: 'Hello' }] }]
})
// Step 2: Pass a Map as options.refs; render will fill it with id → element
const refs = new Map<string, Element>()
render(schema, document.getElementById('app'), { refs })
// Step 3: After render, get the dialog by id and call showModal()
const dialog = refs.get('my-dialog') as HTMLDialogElement
dialog?.showModal()

Example: onNodeMount and AbortController (event handling and cleanup)

// Step 1: Create AbortController so we can clean up listeners later
const ac = new AbortController()

// Step 2: Pass onNodeMount in options; it runs once per node after that node is mounted
render(schema, container, {
  onNodeMount(node, element) {
    // Step 3: For the button node, attach click listener with { signal } so abort() removes it
    if (node.id === 'btn') {
      element.addEventListener('click', handleClick, { signal: ac.signal })
    }
  }
})

// Step 4: When tearing down (e.g. unmount), call ac.abort() — listener is removed automatically

Pass options.signal when attaching listeners in onNodeMount so that controller.abort() cleans them up without storing references manually.

  • Focus — Get the element via refs or in onNodeMount, then call element.focus() (e.g. refs.get('input-id')?.focus() for an input with id: 'input-id').
  • Template — For type: 'template', refs store the template element itself, not template.content.

Example: focus after render

// Step 1: Pass refs map so render fills it with id → element
const refs = new Map<string, Element>()
render(schema, container, { refs })
// Step 2: After render, get input by id and focus it
;(refs.get('first-input') as HTMLInputElement)?.focus()
  • el.root() — returns Definition with empty root: []. el.root(n1, n2, ...) or el.root([n1, n2]) — returns Definition for create().
  • el.node(type, propsOrContent?, ...rest) — generic factory (e.g. custom elements). Second arg: optional props object, text content, or first child; rest: more children or content. Content (string) and children cannot be mixed. Void tags must not have content or children.
  • Tag aliasesel.div, el.span, el.main, el.header, el.h1, el.a, el.table, el.img, el.template, el.details, el.dialog, el.figure, el.figcaption, el.summary, el.search, el.fieldset, el.legend, el.select, el.option, el.optgroup, el.textarea, el.blockquote, el.pre, el.dl, el.dt, el.dd, el.datalist, el.iframe, el.picture, el.slot, el.address, el.caption, el.colgroup, el.tfoot, el.output, el.progress, el.menu, el.meter, el.audio, el.video, el.hgroup, el.abbr, el.cite, el.code, el.strong, el.em, el.mark, el.time, el.q, el.del, el.ins, and the rest from Constant.tagAliases (container + void). Container tags accept props, content (string), or children (nodes). Void tags (e.g. img, br, input) accept props only.

Node Shape

Each node is an object with:

Key Type Required Description
type string yes HTML or SVG tag name, or custom element (e.g. div, a, svg, circle). Lowercased on create. Unknown names yield HTMLUnknownElement.
id string no Element id attribute.
layout Layout no Width, height, position, flex, gap → CSS.
style Style no Fill, stroke, font, border → CSS.
attrs Attrs no HTML attributes (href, class, etc.). Keys starting with on are not applied; use addEventListener after render.
content string no Text content for the node.
src string no Image (or similar) source URL; use with type: 'img'.
alt string no Alt text for img.
children Node[] no Child nodes. Not allowed on void tags.

Allowed keys are exactly the above. Extra keys cause validation to throw. You can use any tag name (e.g. custom elements): unknown names become HTMLUnknownElement unless the browser defines a custom element for that name.

Example: custom element with el.node

// Step 1: With el helper — any tag name (custom elements become HTMLUnknownElement)
el.node('my-component', { attrs: { 'data-foo': 'bar' } })
// Step 2: Or raw definition — same shape
{ type: 'my-component', attrs: { 'data-foo': 'bar' } }

Layout and Style

  • Layout — Sizing: width, height, minWidth, maxWidth, minHeight, maxHeight. Flex: display, flexDirection, flexWrap, alignItems, justifyContent, gap, flex. Position: x, y (set left/top and position: relative when needed). Overflow: overflow, overflowX, overflowY. Number values are converted to Npx; strings (e.g. '100%', 'flex', 'center') are used as-is. Setting gap sets display: flex when not already set; setting flex sets display: block when needed.
  • Style — Only the following properties are applied from node.style; each value must be a string. Semantic: fillbackgroundColor, strokeborder. Direct: font, border, color, padding, margin, borderRadius, boxShadow, opacity, transition, scrollMargin, textAlign, textDecoration, letterSpacing, lineHeight, cursor, visibility, background, outline, zIndex. The library does not apply arbitrary CSS keys from node.style. For other properties (e.g. width, maxWidth, display, objectFit when not using layout), use attrs.style as a string.
  • Inline CSS via attrs — Use attrs.style as a string (e.g. 'color: red; margin: 8px; width: 100%') to set any CSS; this is applied via element.style.setProperty and supports all properties.
  • Scope — Layout and style are applied only to HTML elements. For SVG nodes, use attrs (e.g. attrs.style as CSS string, or attrs.fill, attrs.stroke).

Example: layout + style + attrs.style

// layout: sizing, flex, overflow (numbers → px where applicable)
{ type: 'div', layout: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', minWidth: 200, overflow: 'auto' } }
// layout: width, height, position
{ type: 'div', layout: { width: 200, height: 100, x: 10, y: 0 }, style: { fill: '#eee', borderRadius: '8px' } }
// style: typography and cursor
{ type: 'h2', style: { textAlign: 'center', letterSpacing: '0.5px', cursor: 'pointer' }, content: 'Title' }
// inline CSS via attrs.style (string)
{ type: 'span', attrs: { style: 'color: red; font-weight: bold' }, content: 'Alert' }

Void Tags and Special Elements

  • Void tags — No children. List includes: area, base, br, col, embed, hr, img, input, link, meta, param, source, track, wbr. If a void tag has non-empty children, create() throws.
  • template — Child nodes are appended to element.content (DocumentFragment), not to the template element itself. Refs store the template element, not the content.
  • svg — SVG elements are created with createElementNS; descendants stay in SVG namespace when under an svg node.

Example: void tags and template

// Step 1: Void tag — no children (br)
{ type: 'br' }
// Step 2: Void tag — img with src/alt
{ type: 'img', src: '/logo.png', alt: 'Logo' }
// Step 3: template — children go into template.content; refs store the template element
{ type: 'template', id: 'tpl', children: [{ type: 'div', content: 'Cloned later' }] }

Security

  • Text contentcontent is applied via createTextNode(), so it is safe from HTML injection. Do not interpret content as HTML.
  • Attributesattrs values are set with setAttribute() or property assignment. Use only attribute names and values from trusted sources, or sanitize them. Do not pass unsanitized user input into attrs (e.g. href, src) without validation or sanitization. For strict policies, consider Trusted Types where supported.
  • Event handlers — Attribute keys that start with on (e.g. onclick, onerror) are skipped during render and are never set on the element. Use addEventListener() on the rendered element instead.

Example: safe content vs attrs

// Step 1: content → text node; safe from HTML injection
{ type: 'p', content: 'User text here' }
// Step 2: attrs (e.g. href) — sanitize user input before passing
{ type: 'a', attrs: { href: userUrl } }

Boolean Attributes

These attributes are treated as boolean (MDN: presence = true, absence = false): autofocus, checked, disabled, hidden, inert, multiple, open, readonly, required, selected. Use true or false (or string 'true'/'false'). When the value is false, the attribute is removed from the DOM and the corresponding property is set to false; when true, only the property is set (no attribute string is written). This avoids invalid markup such as disabled="false" (which would still disable the element).

// Step 1: Use true/false; when false, attribute is removed from DOM (avoids disabled="false")
{ type: 'input', attrs: { checked: false } }   // unchecked
{ type: 'button', attrs: { disabled: true } }  // disabled — not clickable
// Step 2: hidden false → attribute removed (visible); open true → details expanded
{ type: 'div', attrs: { hidden: false } }
{ type: 'details', attrs: { open: true } }
  • autofocus — On input, button, select, and textarea the autofocus property is set; on other elements the attribute is set/removed. For accessibility, use sparingly: at most one autofocus control per view, and avoid moving focus away from user context (e.g. modals).
  • open — On details and dialog the open property is set (disclosure expanded or dialog visible); when false the attribute is removed.
  • hidden — For the value 'until-found', the attribute is set as-is (see MDN); otherwise treated as boolean (presence = hidden, removed when false).

Form Elements

  • disabled — Applied via property on input, button, select, textarea, fieldset, optgroup, and option. Boolean handling as above.
  • readonly — Applied via readOnly property on input and textarea.
  • required — Applied via required property on input, select, and textarea.
  • multiple — Applied via multiple property on input (e.g. file, email) and select.
  • selected — Applied via selected property on option.
  • checked — Applied via checked property on input (e.g. checkbox/radio).
  • Form UX — Use attrs.for on labels (applied as htmlFor), and attrs.form, attrs.inputmode, attrs.enterkeyhint on inputs; all are applied via attribute or property. For imperative APIs (e.g. focus, form submit), get the element via refs or onNodeMount (see Refs and Lifecycle).

Example: label + input with for, inputmode, enterkeyhint

// Step 1: label with attrs.for (→ htmlFor) and content
// Step 2: input with id matching for; inputmode and enterkeyhint for UX
;[
  { type: 'label', attrs: { for: 'email' }, content: 'Email' },
  { type: 'input', id: 'email', attrs: { type: 'text', inputmode: 'email', enterkeyhint: 'done' } }
]

Image

For type: 'img', node.src and node.alt are set on the element; attrs.loading ('lazy' or 'eager') and attrs.decoding ('sync', 'async', or 'auto') are applied as properties for lazy loading and decode behavior.

Example

// Step 1: img with src/alt on node; loading and decoding in attrs
{ type: 'img', src: '/hero.jpg', alt: 'Hero', attrs: { loading: 'lazy', decoding: 'async' } }

Responsive Images (picture, srcset, sizes)

Use a tree with type: 'picture', one or more type: 'source' (void), and one type: 'img'. Set attrs.srcset, attrs.sizes on img and on each source; on source you can also set attrs.media and attrs.type. All are applied via setAttribute. Example:

{
  type: 'picture',
  children: [
    // Step 1: source for narrow viewport — media query, srcset, sizes, type
    { type: 'source', attrs: { srcset: 'small.webp 480w', sizes: '100vw', media: '(max-width: 600px)', type: 'image/webp' } },
    // Step 2: source for larger viewport — no media, browser picks when appropriate
    { type: 'source', attrs: { srcset: 'large.webp 1200w', type: 'image/webp' } },
    // Step 3: fallback img — src/alt on node; srcset/sizes in attrs for responsive fallback
    { type: 'img', src: 'fallback.jpg', alt: '…', attrs: { srcset: 'fallback.jpg 1x', sizes: '(min-width: 800px) 800px, 100vw' } }
  ]
}

Iframe

For type: 'iframe', attrs.loading ('lazy' or 'eager') is applied as the loading property. Use attrs.sandbox and attrs.srcdoc for sandboxed inline content; avoid unsanitized user input in srcdoc (XSS risk).

Example

// Step 1: iframe with src; loading and sandbox via attrs
{ type: 'iframe', attrs: { src: '/embed', loading: 'lazy', sandbox: 'allow-scripts' } }

Links (Anchor)

For type: 'a', attrs.download is applied as the anchor’s download property: use true for “download with default filename” (empty string) or a string for the suggested filename (e.g. 'report.pdf'). When download is false or omitted, the attribute is removed so the link behaves as a normal navigation link.

Example: normal link and download link

// Normal navigation (no download)
{ type: 'a', attrs: { href: '/about' }, content: 'About' }

// Download with suggested filename
{ type: 'a', attrs: { href: '/file.pdf', download: 'report.pdf' }, content: 'Download PDF' }

// Download with default filename (browser decides)
{ type: 'a', attrs: { href: '/data.json', download: true }, content: 'Download' }

SVG Attributes

Layout and style from the schema are applied only to HTML elements. For SVG nodes (e.g. circle, rect, path under svg), set dimensions and appearance via attrs (e.g. attrs.fill, attrs.stroke, attrs.width, attrs.height, or attrs.viewBox on svg). Use attrs.style for a CSS string if needed.

Example

// Step 1: svg with attrs.viewBox; SVG children use attrs for dimensions and fill/stroke
{ type: 'svg', attrs: { viewBox: '0 0 100 100' }, children: [{ type: 'circle', attrs: { cx: '50', cy: '50', r: '40', fill: 'blue' } }] }

Accessibility

  • Prefer semantic HTML (type: 'header', main, nav, button, etc.) so built-in roles and behavior are available.
  • ARIA — Pass ARIA attributes through attrs (e.g. attrs['aria-label'], attrs['aria-live'], attrs['aria-hidden']). For dynamic content that updates without a full re-render, consider aria-live regions so assistive technologies can announce changes.
  • inert and hidden — Supported as boolean attributes (see Boolean Attributes) for visibility and focus behavior.
  • popover — Use attrs.popover with values such as 'auto', 'manual', or 'hint' (HTML Popover API). Open/close behavior is controlled by the browser or by your script after render.
  • dialog — For type: 'dialog', the element is only created and appended. Use refs or onNodeMount to get the element, then call element.showModal() or element.close() (see Refs and Lifecycle).

Example: ARIA and focus

// Step 1: button with id and aria-label via attrs
{ type: 'button', id: 'submit', attrs: { 'aria-label': 'Submit form' }, content: 'Submit' }
// Step 2: After render — get element via refs and focus when needed
refs.get('submit')?.focus()

API Reference

Create (definition)

Validate the definition and return a frozen schema. Normalizes node keys (e.g. lowercases type). Deep-freezes the schema and all nested nodes.

  • definition <Definition | unknown>: Object with root: Node[]. Can be a plain object; validation ensures shape.
  • Returns: <Schema> Frozen object { root: readonly Node[] }.
  • Throws: When definition is not an object, root is not an array, or any node is invalid (unknown key, wrong type for a field, void tag with children).

Example: create and use schema

import { create, render } from '@neabyte/schema2ui'

const definition = {
  root: [
    { type: 'h1', content: 'Title' },
    { type: 'p', id: 'intro', content: 'Intro text' }
  ]
}
const schema = create(definition)
// schema is frozen; schema.root has 2 nodes
render(schema, document.getElementById('app'))

Validation Errors

create() throws in these cases:

  • Definition is not a plain object.
  • root is missing or not an array.
  • A node has an unknown key (not in: type, id, layout, style, attrs, content, src, alt, children).
  • A node has wrong type for a field (e.g. content not a string, layout not an object of number/string).
  • A void tag has children (or non-empty children array).

Example: invalid definition throws

import { create } from '@neabyte/schema2ui'

create({ root: [{ type: 'div', unknownKey: 'x' }] })
// throws Error: root[0]: unknown key "unknownKey"

create({ root: [{ type: 'img', children: [{ type: 'span', content: 'x' }] }] })
// throws Error: root[0]: void tag "img" must not have children

Render (schema, container, options?)

Build DOM from schema.root and append each resulting node into container. Uses the container’s ownerDocument (or document). Handles template (children into template.content) and SVG (namespace). Does not clear container before appending.

  • schema <Schema>: Frozen schema from create().
  • container <HTMLElement>: Target element to append to.
  • options <RenderOptions | undefined>: Optional. refs: map to fill with id → element; onNodeMount: callback per node after mount; signal: optional AbortSignal for listener cleanup.
  • Returns: void.

Example: render with refs and onNodeMount

import { create, render } from '@neabyte/schema2ui'

const schema = create({
  root: [
    { type: 'button', id: 'btn', content: 'Click me' },
    { type: 'dialog', id: 'my-dialog', children: [{ type: 'p', content: 'Dialog body' }] }
  ]
})
const refs = new Map()
const ac = new AbortController()

render(schema, document.getElementById('app'), {
  refs,
  signal: ac.signal,
  onNodeMount(node, element) {
    if (node.id === 'btn') {
      element.addEventListener('click', () => refs.get('my-dialog')?.showModal(), {
        signal: ac.signal
      })
    }
  }
})
// Later: ac.abort() removes the listener

Type Reference

Types are re-exported under the Types namespace. Import and use like this:

import type * as Types from '@neabyte/schema2ui'
// Then: Types.Node, Types.Schema, Types.RenderOptions, Types.Attrs, Types.Layout, Types.Style, etc.
  • Definition: { root: readonly Node[] } — Input to create.
  • Schema: { readonly root: readonly Node[] } — Output of create; frozen.
  • RenderOptions: Optional refs?: Map<string, Element>, onNodeMount?: (node, element) => void, signal?: AbortSignal. Pass as third argument to render.
  • Node: Object with type (string) and optional id, layout, style, attrs, content, src, alt, children (readonly array of Node). Void tags must not have children.
  • Layout: Optional alignItems, display, flex, flexDirection, flexWrap, gap, height, justifyContent, maxHeight, maxWidth, minHeight, minWidth, overflow, overflowX, overflowY, width, x, y — sizing/position values number | string, flex/overflow values string.
  • Style: Optional background, border, borderRadius, boxShadow, color, cursor, fill, font, letterSpacing, lineHeight, margin, opacity, outline, padding, scrollMargin, stroke, textAlign, textDecoration, transition, visibility, zIndex — each string.
  • Attrs: Record<string, string | number | boolean> — HTML attributes. Keys starting with on are skipped. Special handling: class/className, style (string), value, boolean attributes, img loading/decoding, iframe loading, label for (as htmlFor), anchor download. Other attrs (e.g. form, inputmode, enterkeyhint, sandbox, srcdoc, srcset, sizes, media, type) applied via setAttribute.
  • Schema2UIDefault: { create, render } — base API shape. Calling the default export returns this plus el (i.e. { create, render, el }).
  • Schema2UIDefaultExport: the default export (callable); has .create, .render, .el; calling it returns { create, render, el }.

Reference

  • README — Installation and quick start.
  • docs/ — Browser demo: each use case above (create, render, refs, onNodeMount, dialog, form, focus, etc.) has a runnable example in docs/assets/ (e.g. demo.js, sections/dialog.js, sections/form.js). Build then serve and open http://localhost:3000/docs/.
  • Tests under tests/ — Constant, Create, Helper, Index, Render, Validator.