/*{ "order": 2, "description": "The elements proxy: build DOM elements with live bindings and event handlers in plain JS, no JSX or transpilation required." }*/
/*#
# Creating 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.
```js
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 `elementCreator`s, functions
of type `ElementCreator`. So `elements.div` is a function that returns a `
`
element, `elements.foo` creates elements, and elements.fooBar creates
`` 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 — because the previous behaviour was to treat every
unrecognised argument as a props bag, which meant `div(new Date())` rendered an
empty `
` and `div(items.map(…))` with the spread forgotten turned array
indices into attributes. Both were silent.
E.g.
```js
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
## 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 ``, which is probably
not what was wanted.
span({style: {border: '1px solid red'}, {style: {fontSize: '15px'}}})
…produces `
`
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="..."
*/
import { bind, on } from './bind'
import { bindings } from './bindings'
import {
ElementPart,
ElementProps,
ElementCreator,
StringMap,
TosiBinding,
EventType,
} from './xin-types'
import { camelToKabob } from './string-case'
import { processProp } from './css'
import { tosiPath, TAKE_DESCRIPTOR, setElementContract } from './metadata'
import { MATH, SVG, type ElementsProxy } from './elements-types'
const templates: { [key: string]: Element } = {}
const elementStyle = (elt: HTMLElement, prop: string, value: any) => {
const processed = processProp(camelToKabob(prop), value)
if (processed.prop.startsWith('--')) {
elt.style.setProperty(processed.prop, processed.value)
} else {
;(elt.style as unknown as { [key: string]: string })[prop] = processed.value
}
}
const elementStyleBinding = (prop: string): TosiBinding => {
return {
toDOM(element, value) {
elementStyle(element as HTMLElement, prop, value)
},
}
}
// per-element record of the classes the `class` prop binding last applied,
// so a reactive class binding can replace (not accumulate) on each update
const appliedClasses = new WeakMap>()
const elementProp = (elt: HTMLElement, key: string, value: any) => {
if (key === 'style') {
if (typeof value === 'object') {
for (const prop of Object.keys(value)) {
if (tosiPath(value[prop])) {
bind(elt, value[prop], elementStyleBinding(prop))
} else {
elementStyle(elt, prop, value[prop])
}
}
} else {
elt.setAttribute('style', value)
}
} else {
const attr = camelToKabob(key)
// Check if declared in observedAttributes (works for third-party web components)
const observedAttrs = (elt.constructor as any).observedAttributes as
| string[]
| undefined
const isObservedAttr =
observedAttrs?.includes(key) || observedAttrs?.includes(attr)
if (isObservedAttr) {
// tosijs#24: only treat a boolean VALUE as an HTML boolean attribute
// when the attribute is actually boolean-TYPED. Writing `false` to an
// attribute declared `'on' | 'off'` used to land here and REMOVE the
// attribute, so the element silently read back its default — a
// feature explicitly turned off stayed on.
//
// The type must come from a DECLARATION, never from the property's
// current value: a third-party element whose `count` initialises to
// `null` would otherwise look "object-typed" (typeof null === 'object'),
// and every numeric write would be routed away from the attribute —
// its attributeChangedCallback would never fire and it would render
// nothing. Only tosijs Components declare attribute types, so only
// they get the mismatch routing.
const declaredAttrs = (
elt.constructor as unknown as {
_resolveInitAttributes?: () => Record | undefined
}
)._resolveInitAttributes?.()
const declaredDefault = declaredAttrs?.[key] ?? declaredAttrs?.[attr]
const typeMismatch =
declaredDefault !== undefined &&
declaredDefault !== null &&
value !== null &&
value !== undefined &&
typeof value !== typeof declaredDefault
if (typeMismatch) {
;(elt as { [key: string]: any })[key] = value
} else if (typeof value === 'boolean') {
if (value) {
elt.setAttribute(attr, '')
} else {
elt.removeAttribute(attr)
}
} else {
elt.setAttribute(attr, value)
}
} else if ((elt as { [key: string]: any })[key] !== undefined) {
// MathML is only supported on 91% of browsers, and not on the Raspberry Pi Chromium
const { MathMLElement } = globalThis
if (
elt instanceof SVGElement ||
(MathMLElement !== undefined && elt instanceof MathMLElement)
) {
elt.setAttribute(key, value)
} else {
;(elt as { [key: string]: any })[key] = value
}
} else if (attr === 'class') {
// `v || ''` collapses null/undefined/false — idiomatic conditional
// "no class" (`cond ? 'active' : undefined`, `cond && 'active'`, or an
// explicit null) — to an empty string, so it adds NO class rather than a
// literal "null"/"undefined"/"false". Also tolerates extra whitespace.
const splitClasses = (v: any): string[] =>
String(v || '')
.split(/\s+/)
.filter(Boolean)
// classes this binding wants ON, and (boolean-map only) explicitly OFF
const on = new Set()
const off = new Set()
if (Array.isArray(value)) {
// ['foo', 'bar'] (each entry may itself be space-separated)
for (const entry of value)
for (const c of splitClasses(entry)) on.add(c)
} else if (value != null && typeof value === 'object') {
// { foo: true, bar: false } adds foo, removes bar
for (const [key, isOn] of Object.entries(value))
for (const c of splitClasses(key)) (isOn ? on : off).add(c)
} else {
// 'foo bar baz' (and null/undefined/false/'' -> no class)
for (const c of splitClasses(value)) on.add(c)
}
// Diff against what THIS binding applied last time, so a reactive
// `class` binding REPLACES rather than accumulates (a change from
// 'red' to 'blue' must not leave 'red bl ue'). Only classes this
// binding previously added are candidates for removal — classes from
// other sources (a static `class`, `-tosi-data`, etc.) are untouched.
const prev = appliedClasses.get(elt)
if (prev) for (const c of prev) if (!on.has(c)) elt.classList.remove(c)
for (const c of off) elt.classList.remove(c)
for (const c of on) elt.classList.add(c)
appliedClasses.set(elt, on)
} else if ((elt as { [key: string]: any })[attr] !== undefined) {
;(elt as StringMap)[attr] = value
} else if (typeof value === 'boolean') {
if (value) {
elt.setAttribute(attr, '')
} else {
elt.removeAttribute(attr)
}
} else {
elt.setAttribute(attr, value)
}
}
}
const propBindingCache: Record = {}
// reverse lookup: which element prop does this (cached) prop binding drive?
// used by the agent surface to name bindings in describe() output
export const propBindingKey = (binding: TosiBinding): string | undefined => {
for (const key of Object.keys(propBindingCache)) {
if (propBindingCache[key] === binding) return key
}
return undefined
}
const elementPropBinding = (key: string): TosiBinding => {
if (!propBindingCache[key]) {
propBindingCache[key] = {
toDOM(element, value) {
elementProp(element as HTMLElement, key, value)
},
}
}
return propBindingCache[key]
}
/**
* Fold one props object into another — `bind` ACCUMULATES, everything else is
* last-write-wins.
*
* Last-write-wins is right for scalar props and WRONG for `bind`: since
* `.tosi.listBinding()` started emitting `bind` instead of the deprecated
* `bindList`, a plain `Object.assign` silently destroyed one of two bindings.
* Both orders failed without a word — caller's bind first dropped the caller's
* binding, listBinding first destroyed the ENTIRE LIST, template unconsumed.
*
* EXPORTED AND SHARED because there are TWO addresses that fold props this
* way: `create()` here and `Component.hydrate()`. The first fix landed only
* here, so the identical bug survived in hydrate() — where host props in a
* content array are documented to apply "just as they would be applied to the
* element being created by div()". One helper, so they cannot drift again.
*/
/**
* Is this a CONFIG BAG rather than a value?
*
* A plain object always is — including `{}`, which a spread of a conditional
* (`{...(cond ? {class:'x'} : {})}`) legitimately produces, so it must stay
* silent. Anything else counts only if it actually carries enumerable own
* properties, which preserves the existing behaviour for class instances used
* as config (`div(new Settings())` sets its fields as attributes).
*/
const isPropsBag = (item: any): boolean => {
if (typeof item !== 'object') return false
// A MAP IS A PROPS BAG — a cleaner one than an object literal, in fact: no
// prototype to collide with, insertion-ordered, and it cannot accidentally
// inherit anything. Allowed, never required.
if (item instanceof Map) return true
const proto = Object.getPrototypeOf(item)
if (proto === Object.prototype || proto === null) return true
return Object.keys(item).length > 0
}
/** does this value know how to render itself as text? */
const renderableAsText = (item: any): boolean => {
if (Array.isArray(item)) return false
// A FUNCTION IS NEVER A VALUE YOU MEANT TO SHOW. `Function.prototype
// .toString` is not `Object.prototype`'s, so functions passed the text test
// and rendered their own SOURCE into the DOM — silently, and in the release
// whose headline is "mistakes complain":
// div(span) -> the factory's source, from a forgotten ()
// div(class Foo { … }) -> the whole class body, method bodies and all
// Falls through to the "neither a child nor a props object" warning instead.
if (typeof item === 'function') return false
const toString = (item as any).toString
return (
typeof toString === 'function' && toString !== Object.prototype.toString
)
}
/**
* WHAT IS THIS POSITIONAL ARGUMENT? One answer, for the two places that ask.
*
* `create()` and `Component.hydrate()`'s content filter both classify the same
* arguments, and `mergeElementProps` was extracted precisely to keep them in
* step — its comment says so. They drifted anyway: the value/array/warning
* rules landed in `create()` only, so `content = [span('a'), new Date()]`
* still dropped the Date silently and a nested array still turned its INDICES
* into host attributes — verbatim the symptom the release notes call fixed.
*
* So the classification is the shared thing now, not just the merge.
*
* `Node`, not `Element | DocumentFragment`: hydrate() already used the wider
* test and it is the correct one — a `Text` or `Comment` node is legitimate
* content, and create() rejecting it was the narrower of two disagreeing
* copies.
*/
export type PositionalKind =
| 'child'
| 'proxy'
| 'text'
| 'props'
| 'array'
| 'unusable'
export const classifyPositional = (item: any): PositionalKind => {
const Node = (globalThis as any).Node
if (
(Node != null && item instanceof Node) ||
typeof item === 'string' ||
typeof item === 'number'
) {
return 'child'
}
// BEFORE the array test: `Array.isArray` is true for a Proxy over an array,
// so testing arrays first turned `div(app.items)` from a bound child into a
// warn-and-drop.
if (tosiPath(item)) return 'proxy'
if (Array.isArray(item)) return 'array'
// null/undefined stay the nothing-signal conditional children rely on:
// merged (a no-op) and silent.
if (item == null) return 'props'
if (isPropsBag(item)) return 'props'
if (renderableAsText(item)) return 'text'
return 'unusable'
}
/** the message for an argument that will do nothing, so both sites say it identically */
export const positionalWarning = (
kind: PositionalKind,
tagName: string
): string | undefined => {
const tag = tagName.toLowerCase()
if (kind === 'array') {
return (
`<${tag}> was passed an array as a child. Arrays are not flattened — ` +
`did you mean to spread it? \`${tag}(...items.map(…))\``
)
}
if (kind === 'unusable') {
return (
`<${tag}> was passed a value that is neither a child nor a props ` +
`object — it has no text form and no properties to apply, so it was ` +
`ignored.`
)
}
return undefined
}
/**
* APPLY a positional argument. One implementation, for the three call sites.
*
* `classifyPositional` shared the QUESTION; this shares the ANSWER, which is
* what actually stops them diverging. Sharing only the classifier was not
* enough and the drift kept coming back at a new address each round:
*
* round 1 `create()` fixed, `hydrate()` still dropped values silently
* round 2 both classified alike — and `hydrate()` still emitted text at the
* END of the child list, because it FILTERED and re-appended, so
* `[10n, span(' each')]` rendered " each10" against create()'s
* "10 each"
* round 3 `fragment()` turned out to be the third site, never classified at
* all, rendering `null` as the literal string "null" — directly
* contradicting the contract added in the same release
*
* `append` is the caller's placement (append to an element, push into an
* ordered array, append to a fragment) and is called IN ARGUMENT ORDER, which
* is what makes position correct by construction rather than by remembering.
*/
export const applyPositional = (
item: any,
tagName: string,
append: (node: any) => void,
mergeProps?: (props: any) => void
): void => {
const kind = classifyPositional(item)
const warning = positionalWarning(kind, tagName)
if (warning != null) {
console.warn(warning, item)
return
}
if (kind === 'child') {
append(item)
} else if (kind === 'text') {
append(String(item))
} else if (kind === 'proxy') {
// `elements.div(proxy)` — the most idiomatic call form in the library
append(elements.span({ bind: { value: item, binding: 'text' } }))
} else if (mergeProps != null) {
// `bind` ACCUMULATES rather than clobbering — a container can be
// list-bound and carry its own binding
mergeProps(item instanceof Map ? Object.fromEntries(item) : item)
} else if (item != null) {
// a fragment has nothing to apply props TO
console.warn(
`fragment() was passed a props object, which has no element to apply ` +
`to — it was ignored.`,
item
)
}
}
export const mergeElementProps = (target: any, item: any): void => {
if (item?.bind != null && target.bind != null) {
target.bind = ([] as any[]).concat(target.bind, item.bind)
const { bind: _accumulated, ...rest } = item
Object.assign(target, rest)
return
}
Object.assign(target, item)
}
export const elementSet = (elt: HTMLElement, key: string, value: any) => {
if (key === 'apply') {
value(elt)
} else if (key === 'contract') {
// inline contract: declared where the element is built, harvested by the
// agent surface, curated/overridden at enableAgentInterface if desired
setElementContract(elt, value)
} else if (key.match(/^on[A-Z]/) != null) {
// tosijs#22: the on sugar is for CONFIG KEYS, not for a
// component's own methods. `onSceneAddition` is ordinary OO naming for
// "what to do when a scene addition happens", and a component author
// has no reason to expect the framework to claim that namespace — so
// when the element already defines a FUNCTION under this name (its own
// method or class field), assigning it is what the caller meant.
// THE DISCRIMINATOR IS THE EXISTING MEMBER, NOT THE PASSED VALUE. An
// earlier version of this comment said "a non-function value (the usual
// `onClick: () => …`) is event sugar", which cannot be right — an arrow
// function IS a function. What decides is whether the element already
// HOLDS a function under this key:
//
// plain element, or custom element with no such member → event sugar
// custom element already holding a function there → assignment
//
// Custom elements only, deliberately: on a plain DOM element `onclick`
// and friends are functions too, so testing `typeof existing` there would
// hijack every ordinary handler.
//
// Known sharp edge (tracked on the tosijs task board): this reads the member off the
// INSTANCE, so it depends on the element having been upgraded. A
// component delivered by `` and created before
// `customElements.define` runs sees `undefined` here and takes the sugar
// branch — so an identical call site can mean two different things
// depending on timing.
const existing = (elt as { [key: string]: any })[key]
if (
elt.tagName.includes('-') &&
typeof existing === 'function' &&
typeof value === 'function'
) {
;(elt as { [key: string]: any })[key] = value
} else {
const eventType = key.substring(2).toLowerCase()
on(elt, eventType as EventType, value)
}
} else if (key === 'bind') {
// may be a single inline binding or several accumulated by create()
if (Array.isArray(value)) {
for (const one of value) elementSet(elt, 'bind', one)
return
}
const binding =
typeof value.binding === 'string'
? bindings[value.binding]
: value.binding
if (binding !== undefined && value.value !== undefined) {
// pass the RESOLVED binding, not value.binding — a string name
// (`binding: 'text'`) was passed through raw, so bind() got a string
// with no toDOM and silently did nothing
bind(
elt,
value.value,
binding instanceof Function ? { toDOM: binding } : binding,
// OPTIONS WERE SILENTLY DROPPED HERE. `bind()` has always taken a
// fourth argument, but the inline form ignored anything beyond
// value/binding — so a list binding could not be expressed inline and
// had to go through the DEPRECATED `bindList` key, which is how
// `.tosi.listBinding()` ended up warning its own callers (tosijs#31).
value.options
)
} else {
throw new Error(`bad binding`)
}
} else if (key.match(/^bind[A-Z]/) != null) {
const bindingType = key.substring(4, 5).toLowerCase() + key.substring(5)
/*
* NO `bind*` SHORTCUT IS DEPRECATED. (1.9.1 — removed, not narrowed.)
*
* This warned for `bindText`/`bindEnabled`/`bindDisabled`, latterly only
* for the proxy form, on the rule "deprecated iff a plain prop expresses
* it exactly". The rule is sound and the narrowing was right, but it
* ended somewhere incoherent: deprecation-ness depended on the VALUE,
* which TypeScript cannot express, so the typings carried no
* `@deprecated` while the runtime still warned — a typings/runtime
* mismatch, reported by tosijs-ui, that we introduced trying to fix the
* previous one.
*
* The nudge was never worth much: `bindText` is barely more writing than
* `textContent`, and the shortcut is the ONLY form that binds a path
* string. So it is console spam in someone else's build for a stylistic
* preference. It belongs in the docs, and that is where it now lives.
*
* `bindValue` and `bindList` were never deprecated (two-way; and the
* primitive `.tosi.listBinding()` emits). Now nothing here is, and the
* types say exactly that.
*/
const binding = bindings[bindingType]
if (binding !== undefined) {
bind(elt, value, binding)
} else {
throw new Error(
`${key} is not allowed, bindings.${bindingType} is not defined`
)
}
} else if (
value != null &&
typeof value === 'object' &&
value[TAKE_DESCRIPTOR]
) {
// TakeDescriptor used as a bare property binding
bind(elt, value, elementPropBinding(key))
} else if (tosiPath(value)) {
bind(elt, value, elementPropBinding(key))
} else {
elementProp(elt, key, value)
}
}
const create = (tagType: string, ...contents: ElementPart[]): HTMLElement => {
if (templates[tagType] === undefined) {
const [tag, namespace] = tagType.split('|')
if (namespace === undefined) {
templates[tagType] = globalThis.document.createElement(tag)
} else {
templates[tagType] = globalThis.document.createElementNS(namespace, tag)
}
}
const elt = templates[tagType].cloneNode() as HTMLElement
const elementProps: ElementProps = {}
for (const item of contents) {
applyPositional(
item,
elt.tagName,
(node) => {
if (elt instanceof HTMLTemplateElement) {
elt.content.append(node)
} else {
elt.append(node)
}
},
(props) => mergeElementProps(elementProps, props)
)
}
for (const key of Object.keys(elementProps)) {
const value: any = elementProps[key]
elementSet(elt, key, value)
}
return elt
}
const fragment = (...contents: ElementPart[]): DocumentFragment => {
const frag = globalThis.document.createDocumentFragment()
for (const item of contents) {
// THE THIRD CALL SITE, and it was never classified at all: it appended
// every argument raw, so `fragment('a', null, 'b')` rendered the literal
// string "null" — contradicting the contract added in the same release —
// a props object became "[object Object]", and a proxy became a DEAD
// one-time string where `div(proxy)` gives a live binding. The type
// widening in this release made those calls newly legal, so it invited
// them. Documented as "otherwise just like other element factory
// functions"; now actually is.
applyPositional(item, 'fragment', (node) => frag.append(node))
}
return frag
}
/**
* elements is a proxy that produces ElementCreators, e.g.
* elements.div() creates
elements and
* elements.myElement() creates elements.
*/
export const elements = new Proxy(
{ fragment },
{
get(target, tagName: string) {
tagName = tagName.replace(/[A-Z]/g, (c) => `-${c.toLocaleLowerCase()}`)
if ((target as StringMap)[tagName] === undefined) {
;(target as StringMap)[tagName] = (...contents: ElementPart[]) =>
create(tagName, ...contents)
}
return (target as StringMap)[tagName]
},
set() {
throw new Error('You may not add new properties to elements')
},
}
) as unknown as ElementsProxy
interface SVGElementsProxy {
[key: string]: ElementCreator
}
export const svgElements = new Proxy(
{ fragment },
{
get(target, tagName: string) {
if ((target as StringMap)[tagName] === undefined) {
;(target as StringMap)[tagName] = (...contents: ElementPart[]) =>
create(`${tagName}|${SVG}`, ...contents)
}
return (target as StringMap)[tagName]
},
set() {
throw new Error('You may not add new properties to elements')
},
}
) as unknown as SVGElementsProxy
interface MathMLElementsProxy {
[key: string]: ElementCreator
}
export const mathML = new Proxy(
{ fragment },
{
get(target, tagName: string) {
if ((target as StringMap)[tagName] === undefined) {
;(target as StringMap)[tagName] = (...contents: ElementPart[]) =>
create(`${tagName}|${MATH}`, ...contents)
}
return (target as StringMap)[tagName]
},
set() {
throw new Error('You may not add new properties to elements')
},
}
) as unknown as MathMLElementsProxy
const boundParts = new WeakSet()
export function bindParts(
root: Element,
bindingMap: Record,
dataAttribute = 'part'
): void {
const selector = `[data-${dataAttribute}]`
for (const el of Array.from(root.querySelectorAll(selector))) {
if (boundParts.has(el)) continue
const key = el.getAttribute(`data-${dataAttribute}`)
if (key == null) continue
const props = bindingMap[key]
if (props == null) continue
boundParts.add(el)
for (const k of Object.keys(props)) {
elementSet(el as HTMLElement, k, (props as any)[k])
}
}
}