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>. <code>true</code>.
</div> </div>
</a> </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> </div>
<h2 class="section-label">v5 behavior demos</h2> <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. 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. on `false`, not back on the declared default.
</Aside> </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 ### 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: 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": [ "size-limit": [
{ {
"path": "./dist/WebComponent.js", "path": "./dist/WebComponent.js",
"limit": "1.95 KB" "limit": "2 KB"
}, },
{ {
"path": "./dist/html.js", "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). | | `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`. | | 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. | | 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 = {} #typeMap = {}
#reflected = false #reflected = false
#connected = false #connected = false
#reflecting
/** /**
* Declared props and their defaults. The value types of this object drive * Declared props and their defaults. The value types of this object drive
@ -153,6 +154,64 @@ export class WebComponent extends HTMLElement {
*/ */
onChanges(changes) {} 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() { constructor() {
super() super()
this.#initializeProps() this.#initializeProps()
@ -186,36 +245,25 @@ export class WebComponent extends HTMLElement {
if (previousValue === currentValue) return if (previousValue === currentValue) return
const property = getCamelCase(attribute) const property = getCamelCase(attribute)
const type = this.#typeMap[property] // when this change *is* our own reflection of a prop write, the prop is
let next // already the source of truth: parsing the attribute back would undo a
if (currentValue === null) { // `toAttribute` that removes the attribute (the default-reset below would
// boolean props follow HTML: absence *is* false, never the declared // clobber the value) and would round-trip lossy conversions through their
// default. Other props reset to the declared default (or undefined). // string form. Still fall through to render/onChanges.
next = type === 'boolean' ? false : this.constructor.props?.[property] if (this.#reflecting !== attribute) {
} else if (type === 'boolean' && /^(true|false)$/.test(currentValue)) { const next =
// a literal "true"/"false" written to a boolean attribute is almost currentValue === null
// always the pre-v6 `setAttribute(name, String(bool))` idiom — which now ? // boolean props follow HTML: absence *is* false, never the declared
// silently means true. Make the inversion loud instead of silent. // default. Other props reset to the declared default (or undefined).
console.warn( this.#typeMap[property] === 'boolean'
`${attribute}="${currentValue}" is true; use toggleAttribute("${attribute}", ${currentValue}).` ? false
) : this.constructor.props?.[property]
next = true : this.fromAttribute(property, currentValue)
} 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
}
// write through the proxy; item 25 makes this log-not-throw by default // write through the proxy; item 25 makes this log-not-throw by default
// (prop value is always applied so `props` stays current) // (prop value is always applied so `props` stays current)
this.props[property] = next this.props[property] = next
}
// defer the render/onChanges side effects until after onInit // defer the render/onChanges side effects until after onInit
if (!this.#connected) return 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 * Reflects one prop value onto its attribute via `toAttribute`, where a
* HTML convention `true` is a bare attribute, `false` removes it so host * `null` return means the attribute is removed. That is what makes a `false`
* code can use `toggleAttribute()` and `:host([flag])` CSS matches only when * boolean an *absent* attribute, so host code can use `toggleAttribute()`
* the prop is actually true. Everything else reflects as a serialized value. * and `:host([flag])` matches only when the prop is actually true.
* @param {string} camelCase the prop key * @param {string} camelCase the prop key
* @param {any} value the value to reflect * @param {any} value the value to reflect
*/ */
#reflect(camelCase, value) { #reflect(camelCase, value) {
const kebab = getKebabCase(camelCase) const kebab = getKebabCase(camelCase)
// declared type wins, so a boolean prop set to null/undefined still const attr = this.toAttribute(camelCase, value)
// removes its attribute; undeclared props fall back to the value's type // tracked per attribute name, not as a plain flag: the platform can run
if ((this.#typeMap[camelCase] ?? typeof value) === 'boolean') // another attribute's callback inside this one's reaction window, and a
this.toggleAttribute(kebab, !!value) // shared flag would swallow that unrelated parse
else this.setAttribute(kebab, serialize(value)) 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') 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"]')
})