diff --git a/demo/examples/attribute-converters/index.html b/demo/examples/attribute-converters/index.html new file mode 100644 index 0000000..af654c1 --- /dev/null +++ b/demo/examples/attribute-converters/index.html @@ -0,0 +1,65 @@ + + + + + + Custom attribute converters + + + + + + + +

Custom attribute converters

+

+ 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. +

+ ` + } +} +customElements.define('converter-demo', ConverterDemo) diff --git a/demo/index.html b/demo/index.html index 48ea374..8050ab6 100644 --- a/demo/index.html +++ b/demo/index.html @@ -37,6 +37,13 @@ true. + +
Custom attribute converters
+
+ Override toAttribute/fromAttribute for Date + and Array props; null removes the attribute. +
+

v5 behavior demos

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}>`) => { + 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">`) + 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}>` + 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}>` + 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">` + 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}>` + 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"]') +})