+ Override toAttribute and fromAttribute when a
+ prop needs its own serialization, and delegate the rest to
+ super. Both take the camelCase prop key,
+ matching your static props declaration.
+
+
+
A Date prop and an Array prop
+
+ when is a real Date and tags is a
+ real Array — reflected as an ISO date and a comma-separated
+ list. Each button reports the attributes the conversion produced.
+
+
+
+
Parsed from markup
+
+ Attributes written from outside the component are parsed through
+ fromAttribute, so these mount with a real
+ Date and a real Array:
+
+
+
+
Why lossy conversion is safe
+
+ Reflection does not 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
+ when can keep its full timestamp while the attribute carries
+ only the date. render() and onChanges still fire
+ as normal.
+
+
+
diff --git a/demo/examples/attribute-converters/index.js b/demo/examples/attribute-converters/index.js
new file mode 100644
index 0000000..acff589
--- /dev/null
+++ b/demo/examples/attribute-converters/index.js
@@ -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`
+
${title}
+
+ prop when (a real Date):
+ ${when.toISOString()}
+
+
+ prop tags (a real Array):
+ ${JSON.stringify(tags)}
+
+ `
+ }
+}
+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`
+
+
+
${this.props.log}
+
+
+
+
+
+
+
+
+
+ Setting tags to an empty array makes
+ toAttribute return null, which
+ removes the attribute — the same mechanism that makes a
+ false boolean an absent attribute. The last button writes
+ the attribute from outside, which is parsed back through
+ fromAttribute into a real array.
+
diff --git a/docs/src/content/docs/guides/library-size.md b/docs/src/content/docs/guides/library-size.md
index 67a8562..f22da9c 100644
--- a/docs/src/content/docs/guides/library-size.md
+++ b/docs/src/content/docs/guides/library-size.md
@@ -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.
diff --git a/docs/src/content/docs/guides/prop-access.mdx b/docs/src/content/docs/guides/prop-access.mdx
index 7e659e6..0788498 100644
--- a/docs/src/content/docs/guides/prop-access.mdx
+++ b/docs/src/content/docs/guides/prop-access.mdx
@@ -107,6 +107,64 @@ const props = { ariaChecked: 'false' as 'true' | 'false' }
on `false`, not back on the declared default.
+### 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
+
+
+```
+
+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)
+}
+```
+
+
+
+
+
### 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:
diff --git a/package.json b/package.json
index 28767bf..aee2cb1 100644
--- a/package.json
+++ b/package.json
@@ -109,7 +109,7 @@
"size-limit": [
{
"path": "./dist/WebComponent.js",
- "limit": "1.95 KB"
+ "limit": "2 KB"
},
{
"path": "./dist/html.js",
diff --git a/size-change-log.md b/size-change-log.md
index d571e47..260bd40 100644
--- a/size-change-log.md
+++ b/size-change-log.md
@@ -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 `` 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. |
diff --git a/src/WebComponent.js b/src/WebComponent.js
index 2658382..a577666 100644
--- a/src/WebComponent.js
+++ b/src/WebComponent.js
@@ -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
- // 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
- }
+ // 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).
+ 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
+ // 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
+ }
}
/**
diff --git a/test/WebComponent.test.mjs b/test/WebComponent.test.mjs
index b96a80a..9e89f72 100644
--- a/test/WebComponent.test.mjs
+++ b/test/WebComponent.test.mjs
@@ -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 `${this.props.label}`
+ }
+ }
+ 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')
+ })
+})
diff --git a/test/e2e/attribute-converters-demo.test.mjs b/test/e2e/attribute-converters-demo.test.mjs
new file mode 100644
index 0000000..505a001
--- /dev/null
+++ b/test/e2e/attribute-converters-demo.test.mjs
@@ -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 = ''
+ 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 =
+ ''
+ 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"]')
+})