feat: custom prop <> attr conversion
Override `toAttribute()` and `fromAttribute()` methods to implement custom conversion between props and attributes
This commit is contained in:
parent
1b3e251a9d
commit
f290e049bd
10 changed files with 583 additions and 40 deletions
65
demo/examples/attribute-converters/index.html
Normal file
65
demo/examples/attribute-converters/index.html
Normal 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>
|
||||
127
demo/examples/attribute-converters/index.js
Normal file
127
demo/examples/attribute-converters/index.js
Normal 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)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@
|
|||
"size-limit": [
|
||||
{
|
||||
"path": "./dist/WebComponent.js",
|
||||
"limit": "1.95 KB"
|
||||
"limit": "2 KB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/html.js",
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
80
test/e2e/attribute-converters-demo.test.mjs
Normal file
80
test/e2e/attribute-converters-demo.test.mjs
Normal 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"]')
|
||||
})
|
||||
Loading…
Reference in a new issue