feat: custom prop <> attr conversion

Override `toAttribute()` and `fromAttribute()` methods to
implement custom conversion between props and attributes
This commit is contained in:
ayo 2026-07-20 20:18:06 +02:00
parent 1b3e251a9d
commit f290e049bd
10 changed files with 583 additions and 40 deletions

View file

@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Custom attribute converters</title>
<script type="module" src="./index.js"></script>
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
<link rel="stylesheet" href="../../shell.css" />
<script type="module" src="../../shell.js"></script>
<style>
.hint {
opacity: 0.75;
font-size: 0.9em;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
align-items: center;
}
event-card {
display: block;
padding: 0.4em 1em;
border: 2px solid #8884;
border-radius: 8px;
}
</style>
</head>
<body>
<h1>Custom attribute converters</h1>
<p>
Override <code>toAttribute</code> and <code>fromAttribute</code> when a
prop needs its own serialization, and delegate the rest to
<code>super</code>. Both take the <strong>camelCase prop key</strong>,
matching your <code>static props</code> declaration.
</p>
<h2>A Date prop and an Array prop</h2>
<p>
<code>when</code> is a real <code>Date</code> and <code>tags</code> is a
real <code>Array</code> — reflected as an ISO date and a comma-separated
list. Each button reports the attributes the conversion produced.
</p>
<converter-demo></converter-demo>
<h2>Parsed from markup</h2>
<p>
Attributes written from outside the component are parsed through
<code>fromAttribute</code>, so these mount with a real
<code>Date</code> and a real <code>Array</code>:
</p>
<event-card when="2026-01-02" tags="alpha,beta" title="From markup"></event-card>
<h2>Why lossy conversion is safe</h2>
<p>
Reflection does <strong>not</strong> round-trip: when you assign a prop,
wcb reflects it but never parses the attribute back — the value you
assigned is already the source of truth. That is why
<code>when</code> can keep its full timestamp while the attribute carries
only the date. <code>render()</code> and <code>onChanges</code> still fire
as normal.
</p>
</body>
</html>

View file

@ -0,0 +1,127 @@
import { WebComponent, html } from 'web-component-base'
/**
* `toAttribute` / `fromAttribute` are the escape hatch for props whose
* reflection doesn't fit the built-in rules. Override the props you care
* about and hand the rest to `super`.
*
* Two things worth noticing in this example:
*
* - `when` is a `Date`, so it declares a `Date` default the prop's runtime
* type comes from the default's `typeof`, and the props proxy would reject
* a Date written to a prop declared as a string.
* - `toAttribute` is deliberately lossy (it drops the time of day), which is
* safe: a prop write is not parsed back through `fromAttribute`, so
* `props.when` keeps the exact value you assigned.
*/
class EventCard extends WebComponent {
static props = {
when: new Date('2026-07-20T09:30:00Z'),
tags: [],
title: 'Release day',
}
// renders into a shadow root so a parent's re-render can't reconcile away
// the children this component renders for itself
static shadowRootInit = { mode: 'open' }
toAttribute(name, value) {
// ISO date only — the attribute is a summary, not the whole value
if (name === 'when') return value.toISOString().slice(0, 10)
// a list reflects as a readable comma-separated attribute...
if (name === 'tags') return value.length ? value.join(',') : null
return super.toAttribute(name, value)
}
fromAttribute(name, value) {
if (name === 'when') return new Date(`${value}T00:00:00Z`)
// ...and parses back the same way
if (name === 'tags') return value.split(',').filter(Boolean)
return super.fromAttribute(name, value)
}
get template() {
const { when, tags, title } = this.props
return html`
<h3>${title}</h3>
<p>
prop <code>when</code> (a real Date):
<strong>${when.toISOString()}</strong>
</p>
<p>
prop <code>tags</code> (a real Array):
<strong>${JSON.stringify(tags)}</strong>
</p>
`
}
}
customElements.define('event-card', EventCard)
/**
* Panel that drives the card and shows the attribute each conversion produces.
*/
class ConverterDemo extends WebComponent {
static props = { log: '' }
afterViewInit() {
this.card = this.querySelector('event-card')
this.#report('initial mount')
}
#report(action) {
const el = this.card
if (!el) return
const attr = (name) =>
el.hasAttribute(name) ? `${name}="${el.getAttribute(name)}"` : '(absent)'
this.props.log = `${action}${attr('when')} · ${attr('tags')}`
}
#setPreciseDate() {
// the time of day survives on the prop even though the attribute drops it
this.card.props.when = new Date('2026-12-25T18:45:12Z')
this.#report('props.when = 2026-12-25T18:45:12Z')
}
#setTags(tags) {
this.card.props.tags = tags
this.#report(`props.tags = ${JSON.stringify(tags)}`)
}
#writeAttribute() {
// an outside write *is* parsed through fromAttribute
this.card.setAttribute('tags', 'html,css,js')
this.#report('setAttribute("tags", "html,css,js")')
}
get template() {
return html`
<event-card></event-card>
<p class="log"><code>${this.props.log}</code></p>
<div class="row">
<button onclick=${() => this.#setPreciseDate()}>
props.when = a precise Date
</button>
<button onclick=${() => this.#setTags(['web', 'components'])}>
props.tags = ['web', 'components']
</button>
<button onclick=${() => this.#setTags([])}>
props.tags = [] (removes the attribute)
</button>
<button onclick=${() => this.#writeAttribute()}>
setAttribute("tags", "html,css,js")
</button>
</div>
<p class="hint">
Setting <code>tags</code> to an empty array makes
<code>toAttribute</code> return <code>null</code>, which
<strong>removes the attribute</strong> the same mechanism that makes a
<code>false</code> boolean an absent attribute. The last button writes
the attribute from outside, which <em>is</em> parsed back through
<code>fromAttribute</code> into a real array.
</p>
`
}
}
customElements.define('converter-demo', ConverterDemo)

View file

@ -37,6 +37,13 @@
<code>true</code>.
</div>
</a>
<a class="card" href="./examples/attribute-converters/index.html">
<div class="name">Custom attribute converters</div>
<div class="desc">
Override <code>toAttribute</code>/<code>fromAttribute</code> for Date
and Array props; <code>null</code> removes the attribute.
</div>
</a>
</div>
<h2 class="section-label">v5 behavior demos</h2>

View file

@ -5,4 +5,6 @@ slug: library-size
All the functions and the base class in the library are minimalist by design and only contains what is needed for their purpose.
The main export (with `WebComponent` + `html`) is **1.7 kB** (min + gzip) according to [bundlephobia.com](https://bundlephobia.com/package/web-component-base@latest), and the `WebComponent` base class is **1.35 kB** (min + brotli) according to [size-limit](http://github.com/ai/size-limit).
The main export (with `WebComponent` + `html`) is **1.7 kB** (min + gzip) according to [bundlephobia.com](https://bundlephobia.com/package/web-component-base@latest), and the `WebComponent` base class is **1.96 kB** (min + brotli) according to [size-limit](http://github.com/ai/size-limit).
Every change that moves this number is recorded in [`size-change-log.md`](https://github.com/ayo-run/wcb/blob/main/size-change-log.md), with the reason the bytes were spent and the benefit they bought.

View file

@ -107,6 +107,64 @@ const props = { ariaChecked: 'false' as 'true' | 'false' }
on `false`, not back on the declared default.
</Aside>
### Custom attribute conversion
The rules above cover the common cases. When a prop needs its own
serialization — a `Date`, a delimited list, an enumerated attribute where
`"false"` is meaningful — override `toAttribute` and `fromAttribute`, and
delegate everything else to `super`:
```js
class EventCard extends WebComponent {
static props = { when: new Date(0), title: '' }
toAttribute(name, value) {
if (name === 'when') return value.toISOString().slice(0, 10)
return super.toAttribute(name, value)
}
fromAttribute(name, value) {
if (name === 'when') return new Date(`${value}T00:00:00Z`)
return super.fromAttribute(name, value)
}
}
```
```html
<event-card when="2026-07-20"></event-card>
<!-- props.when is a Date -->
```
Both take the **camelCase prop key**, matching your `static props` declaration
and `onChanges`'s `property` — not the kebab-case attribute name.
`toAttribute` returning **`null` removes the attribute**. That is exactly how a
`false` boolean becomes an absent attribute, and it is available to any prop:
```js
toAttribute(name, value) {
// an empty string means "no attribute at all" for this prop
return value === '' ? null : super.toAttribute(name, value)
}
```
<Aside type="note" title="Reflection does not round-trip">
When a prop write reflects to its attribute, wcb does **not** parse the
attribute back through `fromAttribute` — the prop you assigned is already the
source of truth. So `toAttribute` may be lossy without corrupting the prop: in
the example above `props.when` keeps its full timestamp even though the
attribute carries only the date. `render()` and `onChanges` still fire as
normal. `fromAttribute` is called for attributes written from *outside* the
component (markup, `setAttribute`, `toggleAttribute`).
</Aside>
<Aside type="caution" title="The declared type still applies">
A prop's runtime type comes from the `typeof` of its default, and the props
proxy rejects writes that violate it. A `Date`-valued prop therefore needs an
actual `Date` default (`new Date(0)`), not `''` — otherwise the prop is typed
`string` and your parsed `Date` is refused.
</Aside>
### Typed props in TypeScript
By default `this.props` is a permissive `{ [name: string]: any }` map. In TypeScript you can get compile-time types on your declared props by passing the shape of your defaults as a type argument to `WebComponent`. Declare the defaults in a `const` first so the class can refer to their type:

View file

@ -109,7 +109,7 @@
"size-limit": [
{
"path": "./dist/WebComponent.js",
"limit": "1.95 KB"
"limit": "2 KB"
},
{
"path": "./dist/html.js",

View file

@ -11,3 +11,5 @@ A running record of how each correctness/feature change affects the `WebComponen
| `onChanges` gains a clear attribute-vs-property distinction (**breaking**) | 1.34 kB (+0.01 kB) | 1.35 kB | **Self-documenting change payload.** `onChanges` previously passed only `property`, holding the kebab-case _attribute_ name — confusing in a library where `props` are camelCase. `property` now holds the camelCase _prop_ key (matching `this.props` access) and the kebab attribute name moves to a new `attribute` field. Handlers can read `this.props[property]` directly without re-deriving the key. **Breaking:** code reading `changes.property` for the attribute name must switch to `changes.attribute` (see the migration note on the life-cycle-hooks docs page). |
| Buffer attribute-driven render/onChanges until after `onInit` | 1.35 kB (+0.01 kB) | 1.35 kB | **`onInit` is guaranteed to run first.** Per spec, authored attributes fire `attributeChangedCallback` (→ `render`/`onChanges`) _before_ `connectedCallback`, so components that assume `onInit` already ran break in real browsers (happy-dom hides this). A `#connected` flag now buffers the `render`/`onChanges` side effects until after `onInit`, while the prop value is still applied immediately so `props` is correct inside `onInit`. Pre-connect changes are reflected by the first render and not replayed through `onChanges`. |
| Reconcile `render()` in place instead of `replaceChildren` (WCB-REQ-06) | 1.81 kB (+0.43 kB) | 1.4 → 1.85 kB | **Re-renders no longer destroy DOM state.** The vnode path rebuilt the whole subtree and swapped it on every prop change, so focus, caret/selection, an uncommitted `<input>` value, `:hover` and running CSS transitions were wiped on the component's own rendered nodes — text fields lost focus mid-typing and a segmented control lost keyboard focus on select. Re-renders now reconcile the existing DOM against the new vnode tree in place (`src/utils/patch.mjs`: index-based, non-keyed — reuse same-tag elements, patch prop/attr adds, changes and removals by the same rule `createElement` applies, recurse children, trim extras). The first render still builds via `createElement`, the `JSON.stringify` no-op skip is unchanged, and `html`'s vnode shape and string templates are untouched. Reusing an element also means a `style` rule that is dropped or goes falsy (`{ fontStyle: condition && 'italic' }`) must now be cleared explicitly — a rebuild used to do that implicitly. The reconciler lives in its own zero-dep module, but `size-limit` measures each entry *with its dependencies*, so the core budget had to move regardless; no runtime dependency was added. |
| Reflect boolean props as bare attributes (WCB-REQ-07, **breaking**) | 1.93 kB (+0.12 kB) | 1.85 → 1.95 kB | **`toggleAttribute()` and `[attr]` CSS selectors work again.** Booleans reflected as the strings `flag="true"`/`flag="false"`, so the attribute always existed: `el.toggleAttribute('flag', true)` was a silent no-op (it only adds/removes, never rewrites a value — no `attributeChangedCallback`, no re-render), and presence selectors like `:host([flag])` matched the stamped `flag="false"` too, painting "on" styles onto elements whose prop was `false`. Boolean props now follow HTML in both directions: reflecting `true` sets the **bare** attribute, `false` **removes** it, and attribute removal means `false` rather than the declared default. Reads are strict — **any present value is `true`**, including the literal `flag="false"`, exactly like native `disabled="false"`. **Breaking:** `setAttribute(name, String(bool))` now always means `true`; migrate those call sites to `toggleAttribute(name, bool)`. Two dev warnings carry most of the cost and make the migration safe: one when a boolean attribute is written as a literal `"true"`/`"false"` string (the silent inversion), and one once-per-class when a boolean prop declares a `true` default (HTML has no true-default boolean attribute — absence must mean both "false" and "default"). `applyProp` also stops stamping `"false"` for boolean vnode props with no matching DOM property, which a nested `WebComponent` would otherwise read back as `true`. Limit raised to fit the warnings. |
| Overrideable `toAttribute` / `fromAttribute` converters (#56) | 1.96 kB (+0.03 kB) | 1.95 → 2 kB | **An escape hatch for the edge cases strict boolean semantics create.** The reflection and parsing rules were hardcoded, so a prop needing custom serialization (a `Date`, a delimited list, an enumerated `"true"`/`"false"` attribute) had no seam to hook into. Two overrideable methods now own both directions — `toAttribute(name, value)` returns the attribute value, where **`null` removes the attribute**, and `fromAttribute(name, value)` parses a present attribute back into a prop value. Both take the camelCase prop key, matching `static props` and `onChanges`'s `property`. The existing behavior became the default implementations (pure code movement — boolean presence/absence is now simply `toAttribute` returning `''` or `null`), so subclasses customize one prop and delegate the rest to `super`. Cheaper than a per-prop converter map, which would cost a lookup on every prop even for the vast majority of components that never need one. Limit raised for the two non-manglable public method names. |

View file

@ -79,6 +79,7 @@ export class WebComponent extends HTMLElement {
#typeMap = {}
#reflected = false
#connected = false
#reflecting
/**
* Declared props and their defaults. The value types of this object drive
@ -153,6 +154,64 @@ export class WebComponent extends HTMLElement {
*/
onChanges(changes) {}
/**
* Converts a prop value into the attribute value that reflects it. Override
* to customize serialization for a prop; call `super.toAttribute(...)` for
* the ones you don't handle.
*
* Returning **`null` removes the attribute** that is how a `false` boolean
* becomes an absent attribute, and it works for any prop.
* @param {string} name camelCase prop key, matching `static props`
* @param {any} value the prop value being reflected
* @returns {string | null} the attribute value, or `null` to remove it
*/
toAttribute(name, value) {
// declared type wins, so a boolean prop set to null/undefined still
// removes its attribute; undeclared props fall back to the value's type
return (this.#typeMap[name] ?? typeof value) === 'boolean'
? value
? ''
: null
: serialize(value)
}
/**
* Converts an attribute value into the prop value it represents the
* inverse of `toAttribute`. Override to customize parsing for a prop; call
* `super.fromAttribute(...)` for the ones you don't handle.
*
* Only called for attributes that are *present*: removal is handled by the
* declared-default reset (and, for boolean props, always yields `false`).
* @param {string} name camelCase prop key, matching `static props`
* @param {string} value the attribute value, never `null`
* @returns {any} the value to store on `this.props[name]`
*/
fromAttribute(name, value) {
const type = this.#typeMap[name]
if (type === 'boolean') {
// a literal "true"/"false" written to a boolean attribute is almost
// always the pre-v6 `setAttribute(name, String(bool))` idiom — which now
// silently means true. Make the inversion loud instead of silent.
if (/^(true|false)$/.test(value)) {
const attr = getKebabCase(name)
console.warn(
`${attr}="${value}" is true; use toggleAttribute("${attr}", ${value}).`
)
}
return true
}
if (type && type !== 'string')
// typed props deserialize; a malformed value falls back to the raw
// string so render()/onChanges() are never skipped
try {
return deserialize(value, type)
} catch {
return value
}
// strings (and untyped props) stay as-is; '' stays ''
return value
}
constructor() {
super()
this.#initializeProps()
@ -186,36 +245,25 @@ export class WebComponent extends HTMLElement {
if (previousValue === currentValue) return
const property = getCamelCase(attribute)
const type = this.#typeMap[property]
let next
if (currentValue === null) {
// boolean props follow HTML: absence *is* false, never the declared
// when this change *is* our own reflection of a prop write, the prop is
// already the source of truth: parsing the attribute back would undo a
// `toAttribute` that removes the attribute (the default-reset below would
// clobber the value) and would round-trip lossy conversions through their
// string form. Still fall through to render/onChanges.
if (this.#reflecting !== attribute) {
const next =
currentValue === null
? // boolean props follow HTML: absence *is* false, never the declared
// default. Other props reset to the declared default (or undefined).
next = type === 'boolean' ? false : this.constructor.props?.[property]
} else if (type === 'boolean' && /^(true|false)$/.test(currentValue)) {
// a literal "true"/"false" written to a boolean attribute is almost
// always the pre-v6 `setAttribute(name, String(bool))` idiom — which now
// silently means true. Make the inversion loud instead of silent.
console.warn(
`${attribute}="${currentValue}" is true; use toggleAttribute("${attribute}", ${currentValue}).`
)
next = true
} else if (type && type !== 'string') {
// typed props deserialize; a malformed value falls back to the raw
// string so render()/onChanges() are never skipped
try {
next = deserialize(currentValue, type)
} catch {
next = currentValue
}
} else {
// strings (and untyped props) stay as-is; '' stays ''
next = currentValue
}
this.#typeMap[property] === 'boolean'
? false
: this.constructor.props?.[property]
: this.fromAttribute(property, currentValue)
// write through the proxy; item 25 makes this log-not-throw by default
// (prop value is always applied so `props` stays current)
this.props[property] = next
}
// defer the render/onChanges side effects until after onInit
if (!this.#connected) return
@ -265,20 +313,27 @@ export class WebComponent extends HTMLElement {
}
/**
* Reflects one prop value onto its attribute. Boolean-typed props follow the
* HTML convention `true` is a bare attribute, `false` removes it so host
* code can use `toggleAttribute()` and `:host([flag])` CSS matches only when
* the prop is actually true. Everything else reflects as a serialized value.
* Reflects one prop value onto its attribute via `toAttribute`, where a
* `null` return means the attribute is removed. That is what makes a `false`
* boolean an *absent* attribute, so host code can use `toggleAttribute()`
* and `:host([flag])` matches only when the prop is actually true.
* @param {string} camelCase the prop key
* @param {any} value the value to reflect
*/
#reflect(camelCase, value) {
const kebab = getKebabCase(camelCase)
// declared type wins, so a boolean prop set to null/undefined still
// removes its attribute; undeclared props fall back to the value's type
if ((this.#typeMap[camelCase] ?? typeof value) === 'boolean')
this.toggleAttribute(kebab, !!value)
else this.setAttribute(kebab, serialize(value))
const attr = this.toAttribute(camelCase, value)
// tracked per attribute name, not as a plain flag: the platform can run
// another attribute's callback inside this one's reaction window, and a
// shared flag would swallow that unrelated parse
const previous = this.#reflecting
this.#reflecting = kebab
try {
if (attr === null) this.removeAttribute(kebab)
else this.setAttribute(kebab, attr)
} finally {
this.#reflecting = previous
}
}
/**

View file

@ -833,3 +833,150 @@ describe('attribute callback buffering (upgrade ordering)', () => {
expect(el.querySelector('h1').textContent).toBe('Hello Zoe')
})
})
/**
* `toAttribute` / `fromAttribute` are the seam for props whose reflection or
* parsing doesn't fit the built-in rules. Overriding one prop must not disturb
* the others, which keep the default behavior via `super`.
*/
describe('custom attribute converters (toAttribute / fromAttribute)', () => {
class WithConverters extends WebComponent {
// the declared type comes from the default's `typeof`, so a Date-valued
// prop must declare a Date default or the proxy's type guard rejects it
static props = {
when: new Date('2026-01-01T00:00:00Z'),
label: 'hi',
flag: false,
}
toAttribute(name, value) {
// a Date reflects as an ISO date string
if (name === 'when' && value instanceof Date)
return value.toISOString().slice(0, 10)
return super.toAttribute(name, value)
}
fromAttribute(name, value) {
if (name === 'when') return new Date(`${value}T00:00:00Z`)
return super.fromAttribute(name, value)
}
}
const tag = 'with-converters'
window.customElements.define(tag, WithConverters)
const mountEl = (markup = `<${tag}></${tag}>`) => {
document.body.innerHTML = markup
return document.querySelector(tag)
}
it('uses fromAttribute to parse a custom attribute value', () => {
const el = mountEl(`<${tag} when="2026-07-20"></${tag}>`)
expect(el.props.when).toBeInstanceOf(Date)
expect(el.props.when.toISOString()).toBe('2026-07-20T00:00:00.000Z')
})
it('uses toAttribute to serialize a custom prop write', () => {
const el = mountEl()
el.props.when = new Date('2026-01-02T00:00:00Z')
expect(el.getAttribute('when')).toBe('2026-01-02')
})
it('keeps the exact prop value when toAttribute is lossy', () => {
const el = mountEl()
const exact = new Date('2026-01-02T13:45:30Z')
el.props.when = exact
// the attribute only carries the date part, but the prop is not
// round-tripped back through fromAttribute, so the time survives
expect(el.getAttribute('when')).toBe('2026-01-02')
expect(el.props.when).toBe(exact)
expect(el.props.when.toISOString()).toBe('2026-01-02T13:45:30.000Z')
})
it('still renders and fires onChanges for a reflected prop write', () => {
const changes = []
class Reactive extends WebComponent {
static props = { label: 'a' }
onChanges(c) {
changes.push(c)
}
get template() {
return `<span>${this.props.label}</span>`
}
}
const t = 'reflect-reactive'
window.customElements.define(t, Reactive)
document.body.innerHTML = `<${t}></${t}>`
const el = document.querySelector(t)
el.props.label = 'b'
// skipping the parse-back must not skip the reactivity
expect(el.querySelector('span').textContent).toBe('b')
expect(changes.at(-1)).toMatchObject({
property: 'label',
currentValue: 'b',
})
})
it('leaves props the override delegates to super alone', () => {
const el = mountEl()
el.props.label = 'changed'
expect(el.getAttribute('label')).toBe('changed')
// the boolean default still reflects as presence/absence
expect(el.hasAttribute('flag')).toBe(false)
el.props.flag = true
expect(el.getAttribute('flag')).toBe('')
})
it('removes the attribute when toAttribute returns null', () => {
class Removable extends WebComponent {
static props = { label: 'hi' }
toAttribute(name, value) {
// an empty string means "no attribute at all" for this prop
return value === '' ? null : super.toAttribute(name, value)
}
}
const t = 'removable-attr'
window.customElements.define(t, Removable)
document.body.innerHTML = `<${t}></${t}>`
const el = document.querySelector(t)
expect(el.getAttribute('label')).toBe('hi')
el.props.label = ''
expect(el.hasAttribute('label')).toBe(false)
expect(el.props.label).toBe('')
})
it('does not call fromAttribute for a removal (default reset wins)', () => {
const seen = []
class Tracked extends WebComponent {
static props = { label: 'default' }
fromAttribute(name, value) {
seen.push(value)
return super.fromAttribute(name, value)
}
}
const t = 'tracked-converter'
window.customElements.define(t, Tracked)
document.body.innerHTML = `<${t} label="set"></${t}>`
const el = document.querySelector(t)
expect(seen).toEqual(['set'])
el.removeAttribute('label')
// removal resets to the declared default without consulting the converter
expect(seen).toEqual(['set'])
expect(el.props.label).toBe('default')
})
it('defaults reflect through toAttribute at mount time', () => {
class DefaultConv extends WebComponent {
static props = { size: 2 }
toAttribute(name, value) {
return name === 'size' ? `${value}px` : super.toAttribute(name, value)
}
}
const t = 'default-converter'
window.customElements.define(t, DefaultConv)
document.body.innerHTML = `<${t}></${t}>`
expect(document.querySelector(t).getAttribute('size')).toBe('2px')
})
})

View file

@ -0,0 +1,80 @@
import { beforeEach, expect, test } from 'vitest'
import '../../demo/examples/attribute-converters/index.js'
/**
* Exercises the demo example itself, so the page shipped in `demo/` cannot
* drift from the behavior it demonstrates.
*/
beforeEach(() => {
document.body.innerHTML = ''
})
const mount = () => {
document.body.innerHTML = '<converter-demo></converter-demo>'
const panel = document.querySelector('converter-demo')
return [panel, panel.querySelector('event-card')]
}
const button = (panel, label) =>
[...panel.querySelectorAll('button')].find((b) =>
b.textContent.includes(label)
)
test('defaults reflect through toAttribute at mount', () => {
const [panel, card] = mount()
expect(card.getAttribute('when')).toBe('2026-07-20')
// an empty array converts to null, so no attribute is stamped
expect(card.hasAttribute('tags')).toBe(false)
expect(panel.textContent).toContain('when="2026-07-20"')
})
test('markup is parsed through fromAttribute into real types', () => {
document.body.innerHTML =
'<event-card when="2026-01-02" tags="alpha,beta"></event-card>'
const card = document.querySelector('event-card')
expect(card.props.when).toBeInstanceOf(Date)
expect(card.props.when.toISOString()).toBe('2026-01-02T00:00:00.000Z')
expect(card.props.tags).toEqual(['alpha', 'beta'])
})
test('a lossy toAttribute does not corrupt the prop', () => {
const [, card] = mount()
button(document.querySelector('converter-demo'), 'a precise Date').click()
// the attribute carries only the date...
expect(card.getAttribute('when')).toBe('2026-12-25')
// ...but the prop kept the time of day, because reflection does not
// round-trip back through fromAttribute
expect(card.props.when.toISOString()).toBe('2026-12-25T18:45:12.000Z')
expect(card.shadowRoot.textContent).toContain('2026-12-25T18:45:12.000Z')
})
test('an array prop reflects as a joined attribute and re-renders', () => {
const [panel, card] = mount()
button(panel, "['web', 'components']").click()
expect(card.getAttribute('tags')).toBe('web,components')
expect(card.props.tags).toEqual(['web', 'components'])
expect(card.shadowRoot.textContent).toContain('["web","components"]')
})
test('returning null from toAttribute removes the attribute', () => {
const [panel, card] = mount()
button(panel, "['web', 'components']").click()
expect(card.hasAttribute('tags')).toBe(true)
button(panel, 'removes the attribute').click()
expect(card.hasAttribute('tags')).toBe(false)
// the prop keeps the value it was set to — no default-reset clobber
expect(card.props.tags).toEqual([])
expect(panel.textContent).toContain('(absent)')
})
test('an outside attribute write is parsed through fromAttribute', () => {
const [panel, card] = mount()
button(panel, 'setAttribute').click()
expect(card.props.tags).toEqual(['html', 'css', 'js'])
expect(card.shadowRoot.textContent).toContain('["html","css","js"]')
})