diff --git a/AGENTS.md b/AGENTS.md index 9b9abf2..516c7a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,17 @@ Everything is in `src/` (entry point `src/index.js` re-exports `WebComponent` an - **`src/utils/`** — the serialization layer that bridges typed JS values and string attributes: `serialize`/`deserialize` (JSON round-trip for number/boolean/object, passthrough for strings), `get-camel-case`/`get-kebab-case` (attribute ⇄ prop name conversion), `create-element` (turns a vnode tree into real DOM nodes, resolving props to DOM properties/attributes and applying `style` objects — its `applyProp` is the single source of truth for the prop→DOM rule), and `patch` (index-based, non-keyed reconciler used by re-renders; it reuses same-tag elements, applies prop adds/changes/**removals** via `applyProp`, and trims trailing nodes). `src/utils/index.js` re-exports all of them. +## Definition of done for a behavior change + +Any change to observable behavior ships as one unit — code alone is an incomplete change. Land all four together: + +1. **Tests** — unit specs in `test/` (or colocated `*.test.mjs`) covering the new contract *and* the old behavior it replaces. Add a `test/e2e/` spec whenever the behavior depends on something happy-dom cannot model faithfully — CSS selector matching, computed styles, custom-element upgrade timing. +2. **Demo examples** — a runnable example under `demo/examples/` that demonstrates the behavior, linked from a card in `demo/index.html`. Update any existing example the change affects, including ones that now emit a console warning or model a discouraged pattern. +3. **Documentation** — the guide under `docs/src/content/docs/guides/` that doubles as the behavioral spec. For a breaking change, also update the `README.md` banner with the migration consumers have to perform. +4. **Size budget** — `pnpm size-limit` stays green. If an addition genuinely needs more headroom, raise the budget in `package.json` deliberately and say so in the change description; never let it drift silently. + +Verify with `pnpm test:all` (unit + types + e2e across all engines) before calling the change done. + ## Testing notes - Environment is **happy-dom** (set in `vitest.config.mjs`), so real custom-element registration works. Any component under test must be registered with `customElements.define(...)` before instantiation, or the browser throws. diff --git a/README.md b/README.md index 4fc2ad5..e033100 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,21 @@ # Web Component Base -> [!Note] -> **Quality of Life improvements shipped (v5.1)** — We now have typed props and a [CEM Analyzer Plugin](https://webcomponent.io/cem-plugin/) that makes developer exerience so much better with code editors and other tooling. Composing stylesheets and a fix for boolean properties behavior is included as well. See the [change log](https://github.com/ayo-run/wcb/releases/tag/v5.1.0) for more details. Next up: more improvements to Boolean props and **faster templates!** +> [!Warning] +> **Breaking change in v6 — boolean props are now bare attributes.** A boolean +> prop follows the HTML convention in both directions: **presence means `true`, +> absence means `false`**. `true` reflects as a bare attribute and `false` +> removes it, so `toggleAttribute()` and `[attr]` CSS selectors finally work as +> expected. +> +> **Any present value is `true`** — including the literal `flag="false"`, just +> like native `disabled="false"` is still disabled. If you write boolean +> attributes as `setAttribute(name, String(bool))`, that now always means +> `true`; switch those call sites to `toggleAttribute(name, bool)`. wcb warns +> in the console when it sees a boolean attribute written as `"true"`/`"false"` +> so the change cannot fail silently. Attributes whose `"false"` is meaningful +> (`aria-*`, `contenteditable`) should be declared as **string** props. +> +> See [Prop Access](https://webcomponent.io/prop-access/) for details. [![Package information: NPM version](https://img.shields.io/npm/v/web-component-base)](https://www.npmjs.com/package/web-component-base) [![Package information: NPM license](https://img.shields.io/npm/l/web-component-base)](https://www.npmjs.com/package/web-component-base) diff --git a/demo/examples/boolean-props/index.html b/demo/examples/boolean-props/index.html new file mode 100644 index 0000000..c71e733 --- /dev/null +++ b/demo/examples/boolean-props/index.html @@ -0,0 +1,70 @@ + + + + + + Boolean props as bare attributes + + + + + + + +

Boolean props are bare attributes

+

+ A boolean prop follows the HTML convention in both directions: + presence means true, absence means + false. Reflecting true sets the bare + attribute; reflecting false removes it. Nothing ever gets + stamped as flag="false". +

+ +

Driving the prop

+

+ Every button below reports the resulting prop value and the actual + attribute. The border lights up via + :host([flag]) — a presence selector that now matches only + when the prop is genuinely true. +

+ + +

Presence in markup

+

+ All three of these mount with props.flag === true. The last + one is the surprise worth internalizing: any present value counts, + including the literal string "false", exactly like + <input disabled="false"> is still disabled. +

+
+ + + +
+ +

Absent means false

+

+ No attribute, no [flag] match, and + props.flag === false. Removing the attribute later also + lands on false rather than restoring a declared default — + which is why boolean props should default to false and use + an inverted name (disabled, not enabled) when + you want them on by default. +

+ + + diff --git a/demo/examples/boolean-props/index.js b/demo/examples/boolean-props/index.js new file mode 100644 index 0000000..6d1c8a4 --- /dev/null +++ b/demo/examples/boolean-props/index.js @@ -0,0 +1,113 @@ +import { WebComponent, html } from 'web-component-base' + +/** + * Boolean props reflect as **bare attributes**: `true` sets the attribute with + * no value, `false` removes it entirely — exactly how native `disabled` and + * `required` behave. + * + * Two things that silently broke under the old string reflection now work: + * + * - `el.toggleAttribute(name, force)` only ever adds/removes an attribute, + * it never rewrites a value. With `flag="false"` sitting there, forcing + * `true` changed nothing — no attributeChangedCallback, no re-render. + * - `[flag]` CSS selectors match on *presence*, so a stamped `flag="false"` + * matched too and painted the "on" style onto an element whose prop was + * false. + */ +class FlagBox extends WebComponent { + static props = { flag: false } + static shadowRootInit = { mode: 'open' } + static styles = ` + :host { + display: block; + padding: 0.8em 1em; + border: 2px solid #8884; + border-radius: 8px; + font-family: system-ui, sans-serif; + } + /* presence selector: matches only while the prop is actually true */ + :host([flag]) { + border-color: #c2410c; + background: #c2410c22; + } + .state { font-weight: 600; } + ` + + get template() { + return html` +
flag is ${String(this.props.flag)}
+ ` + } +} +customElements.define('flag-box', FlagBox) + +/** + * The panel wires the three ways host code drives a boolean prop, and prints + * what the DOM actually looks like after each one. + */ +class BooleanDemo extends WebComponent { + static props = { log: '' } + + onInit() { + this.box = null + } + + afterViewInit() { + this.box = this.querySelector('flag-box') + this.#report('initial mount') + } + + #report(action) { + const el = this.box + if (!el) return + const attr = el.hasAttribute('flag') + ? `flag="${el.getAttribute('flag')}"` + : '(absent)' + this.props.log = `${action} → prop: ${el.props.flag} · attribute: ${attr}` + } + + #toggle() { + // the platform API — a silent no-op back when flag="false" was stamped + this.box.toggleAttribute('flag', !this.box.props.flag) + this.#report('toggleAttribute()') + } + + #write(value) { + this.box.props.flag = value + this.#report(`props.flag = ${value}`) + } + + #stringly() { + // the pre-v6 idiom: any present value is true, so this turns the flag ON + // even though it reads as "off". wcb warns in the console when it sees it. + this.box.setAttribute('flag', 'false') + this.#report('setAttribute("flag", "false")') + } + + get template() { + return html` + + +

${this.props.log}

+ +
+ + + + +
+ +

+ The last button is the migration trap: + any present value is true, so writing the string + "false" turns the flag on — just like native + disabled="false" is still disabled. Check the console for + the warning wcb logs. Use + toggleAttribute(name, bool) instead. +

+ ` + } +} +customElements.define('boolean-demo', BooleanDemo) diff --git a/demo/examples/render-reconciliation/index.js b/demo/examples/render-reconciliation/index.js index 60183f5..73bea31 100644 --- a/demo/examples/render-reconciliation/index.js +++ b/demo/examples/render-reconciliation/index.js @@ -131,10 +131,13 @@ customElements.define('transition-safe', TransitionSafe) * attribute is removed, and dropped `style` rules are cleared. */ class PropRemoval extends WebComponent { - static props = { decorated: true } + // the demo starts decorated, but the boolean prop still defaults to `false` + // and carries the inverted name — absence has to mean both "false" and + // "default", which only works when they coincide + static props = { plain: false } get template() { - const on = this.props.decorated + const on = !this.props.plain // spread so the props are genuinely *absent* when off — that's the removal // path: present in the old vnode, gone from the new one const decoration = on @@ -151,7 +154,7 @@ class PropRemoval extends WebComponent { style are present only while decorated — toggling removes them from the same element instead of building a new one.

- ` diff --git a/demo/index.html b/demo/index.html index d378a3c..48ea374 100644 --- a/demo/index.html +++ b/demo/index.html @@ -25,6 +25,20 @@

+

v6 behavior demos

+
+ +
+ Boolean propsbreaking +
+
+ Presence/absence reflection: toggleAttribute and + [flag] selectors work, and any present value is + true. +
+
+
+

v5 behavior demos

diff --git a/docs/src/content/docs/guides/prop-access.mdx b/docs/src/content/docs/guides/prop-access.mdx index dc453bb..7e659e6 100644 --- a/docs/src/content/docs/guides/prop-access.mdx +++ b/docs/src/content/docs/guides/prop-access.mdx @@ -41,6 +41,72 @@ Another advantage over `HTMLElement.dataset` is that `WebComponent.props` can ho +### Boolean props + +Boolean props follow the HTML boolean-attribute convention, exactly like native +`disabled` and `required`: **presence means `true`, absence means `false`.** + +```js +class FlagBox extends WebComponent { + static props = { flag: false } +} +``` + + +```html + + + +``` + +Reflection works the same way in reverse — `true` sets the bare attribute, +`false` removes it entirely: + +```js +el.props.flag = true // +el.props.flag = false // +``` + +Because `false` is an _absent_ attribute rather than `flag="false"`, the +platform's own API and CSS presence selectors both behave as you'd expect: + +```js +el.toggleAttribute('flag', true) // prop syncs, component re-renders +``` + +```css +:host([flag]) { + /* matches only when the prop is actually true */ +} +``` + + + +Enumerated attributes like `contenteditable` and `aria-*` attributes are the +exception — they are genuine strings where `"false"` is meaningful, so declare +them as **string** props rather than booleans. Strings serialize literally and +are never removed, so they need nothing special at runtime; in TypeScript you +can narrow them to the values you accept: + +```ts +const props = { ariaChecked: 'false' as 'true' | 'false' } +``` + + + ### 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 143d2b2..28767bf 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "size-limit": [ { "path": "./dist/WebComponent.js", - "limit": "1.85 KB" + "limit": "1.95 KB" }, { "path": "./dist/html.js", diff --git a/src/WebComponent.js b/src/WebComponent.js index 5b090d6..2658382 100644 --- a/src/WebComponent.js +++ b/src/WebComponent.js @@ -32,10 +32,17 @@ function cloneDefaults(ctor) { for (const key in ctor.props) { const value = ctor.props[key] const type = typeof value - if (check && (type === 'function' || type === 'symbol')) - console.warn( - `${ctor.name}.${key}: ${type} default not reflectable; use handlers/refs.` - ) + // a true boolean default is discouraged: HTML has no true-default boolean + // attribute, so absence has to mean both "false" and "default", which only + // holds when they coincide. A way to work with this is to invert the name (`disabled`, not `enabled`) when you need a default. + if (check) { + const bad = + type === 'function' || type === 'symbol' + ? `${type} default not reflectable; use handlers/refs.` + : value === true && + 'boolean default should be false; invert the name.' + if (bad) console.warn(`${ctor.name}.${key}: ${bad}`) + } try { out[key] = structuredClone(value) } catch { @@ -56,7 +63,7 @@ function cloneDefaults(ctor) { * Pass the shape of your `static props` as a type argument to get typed * `this.props` access in TypeScript: * ```ts - * const props = { variant: 'primary', disabled: false } + * const props = { variant: 'primary', enabled: true } * class CozyButton extends WebComponent { * static props = props * } @@ -182,8 +189,17 @@ export class WebComponent extends HTMLElement { const type = this.#typeMap[property] let next if (currentValue === null) { - // removal resets to the declared default (or undefined if none) - next = this.constructor.props?.[property] + // 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 @@ -224,7 +240,7 @@ export class WebComponent extends HTMLElement { console.error(msg) } else if (obj[prop] !== value) { obj[prop] = value - setter(getKebabCase(prop), serialize(value)) + setter(prop, value) } return true @@ -243,11 +259,28 @@ export class WebComponent extends HTMLElement { if (!this.#props) { this.#props = new Proxy( initialProps, - this.#handler((key, value) => this.setAttribute(key, value), this) + this.#handler((key, value) => this.#reflect(key, value), this) ) } } + /** + * 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. + * @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)) + } + /** * Reflects default prop values onto attributes on first connect. * Runs outside the constructor (spec forbids attribute mutation there) @@ -256,9 +289,8 @@ export class WebComponent extends HTMLElement { #reflectDefaults() { if (this.#reflected) return Object.keys(this.#props).forEach((camelCase) => { - const kebab = getKebabCase(camelCase) - if (!this.hasAttribute(kebab)) - this.setAttribute(kebab, serialize(this.#props[camelCase])) + if (!this.hasAttribute(getKebabCase(camelCase))) + this.#reflect(camelCase, this.#props[camelCase]) }) this.#reflected = true } diff --git a/src/utils/create-element.mjs b/src/utils/create-element.mjs index 665d461..1188241 100644 --- a/src/utils/create-element.mjs +++ b/src/utils/create-element.mjs @@ -52,6 +52,11 @@ export function applyProp(el, prop, value) { el[prop] = value } else if (domProp in el) { el[domProp] = value + } else if (typeof value === 'boolean') { + // no DOM property to take the value: fall back to the HTML boolean + // convention rather than stamping the string "false", which any + // boolean-attribute reader (including a nested WebComponent) reads as true + el.toggleAttribute(prop, value) } else { el.setAttribute(prop, serialize(value)) } diff --git a/src/utils/deserialize.mjs b/src/utils/deserialize.mjs index 025af48..b1edc74 100644 --- a/src/utils/deserialize.mjs +++ b/src/utils/deserialize.mjs @@ -5,10 +5,12 @@ */ export function deserialize(value, type) { switch (type) { + // strict HTML boolean-attribute semantics: *any* present value is true, + // including the literal string "false" (``, `flag=""`, + // `flag="false"` are all true). Absence means false and is handled by the + // caller, which never reaches here with a null value. case 'boolean': - // bare presence follows the HTML convention: `` / `flag=""` is true. - if (value === '') return true - // falls through + return true case 'number': case 'object': case 'undefined': diff --git a/test/WebComponent.test.mjs b/test/WebComponent.test.mjs index 648781f..b96a80a 100644 --- a/test/WebComponent.test.mjs +++ b/test/WebComponent.test.mjs @@ -369,31 +369,55 @@ describe('reactive props (documented contract)', () => { expect(el.props.active).toBe(true) }) - it('honors an explicit "true"/"false" boolean attribute value', () => { + it('treats any present value on a boolean attribute as true', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const el = connected() el.setAttribute('active', 'true') expect(el.props.active).toBe(true) + // presence wins — exactly like native `disabled="false"` is still disabled el.setAttribute('active', 'false') - expect(el.props.active).toBe(false) + expect(el.props.active).toBe(true) + warn.mockRestore() }) - it('resets a boolean prop to its default when the attribute is removed', () => { + it('warns when a boolean attribute is written as a "true"/"false" string', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const el = connected() + el.setAttribute('active', 'false') + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('toggleAttribute("active", false)') + ) + warn.mockRestore() + }) + + it('sets a boolean prop to false when the attribute is removed', () => { const el = connected() el.setAttribute('active', '') el.removeAttribute('active') expect(el.props.active).toBe(false) }) - it('round-trips a boolean prop write through its reflected attribute', () => { + it('reflects a boolean prop write as a bare/absent attribute', () => { const el = connected() el.props.active = true - expect(el.getAttribute('active')).toBe('true') + expect(el.getAttribute('active')).toBe('') expect(el.props.active).toBe(true) el.props.active = false - expect(el.getAttribute('active')).toBe('false') + expect(el.hasAttribute('active')).toBe(false) expect(el.props.active).toBe(false) }) + it('round-trips a boolean prop through toggleAttribute()', () => { + const el = connected() + // the whole point of REQ-07: host code can use the platform API + el.toggleAttribute('active', true) + expect(el.props.active).toBe(true) + expect(el.getAttribute('active')).toBe('') + el.toggleAttribute('active', false) + expect(el.props.active).toBe(false) + expect(el.hasAttribute('active')).toBe(false) + }) + it('fires onChanges with property/attribute/previousValue/currentValue', () => { const changes = [] class WithChanges extends WebComponent { @@ -426,6 +450,11 @@ describe('default reflection (no setAttribute in constructor)', () => { const tag = 'reflect-defaulted' window.customElements.define(tag, Defaulted) + class TrueDefault extends WebComponent { + static props = { flag: true } + } + window.customElements.define('reflect-bool-true', TrueDefault) + it('createElement does not throw and defers defaults until connect', () => { const el = document.createElement(tag) // no attributes reflected before connect (spec-legal constructor) @@ -445,6 +474,37 @@ describe('default reflection (no setAttribute in constructor)', () => { expect(el.getAttribute('my-name')).toBe('Zoe') }) + it('reflects a false boolean default as no attribute at all', () => { + class BoolDefault extends WebComponent { + static props = { flag: false, myName: 'World' } + } + const t = 'reflect-bool-false' + window.customElements.define(t, BoolDefault) + + const el = document.createElement(t) + document.body.appendChild(el) + // no `flag="false"` noise, and `[flag]` CSS correctly does not match + expect(el.hasAttribute('flag')).toBe(false) + expect(el.props.flag).toBe(false) + expect(el.matches('[flag]')).toBe(false) + }) + + it('reflects a true boolean default as a bare attribute', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const el = document.createElement('reflect-bool-true') + document.body.appendChild(el) + expect(el.getAttribute('flag')).toBe('') + expect(el.props.flag).toBe(true) + // a true default is discouraged: absence has to mean both false and + // default, so removing the attribute lands on false, not back on true + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('boolean default should be false') + ) + el.removeAttribute('flag') + expect(el.props.flag).toBe(false) + warn.mockRestore() + }) + it('does not re-clobber a prop changed while connected on re-connect', () => { const el = document.createElement(tag) document.body.appendChild(el) diff --git a/test/e2e/boolean-props-demo.test.mjs b/test/e2e/boolean-props-demo.test.mjs new file mode 100644 index 0000000..c675991 --- /dev/null +++ b/test/e2e/boolean-props-demo.test.mjs @@ -0,0 +1,74 @@ +import { beforeEach, expect, test } from 'vitest' +import '../../demo/examples/boolean-props/index.js' + +/** + * Exercises the demo example itself, so the page shipped in `demo/` cannot + * drift from the behavior it is meant to demonstrate. + */ +beforeEach(() => { + document.body.innerHTML = '' +}) + +const mount = () => { + document.body.innerHTML = '' + const panel = document.querySelector('boolean-demo') + return [panel, panel.querySelector('flag-box')] +} + +const button = (panel, label) => + [...panel.querySelectorAll('button')].find((b) => + b.textContent.includes(label) + ) + +test('the demo mounts with no attribute and reports it', () => { + const [panel, box] = mount() + expect(box.hasAttribute('flag')).toBe(false) + expect(box.props.flag).toBe(false) + expect(panel.textContent).toContain('attribute: (absent)') +}) + +test('the toggleAttribute button drives the prop and the style', () => { + const [panel, box] = mount() + + button(panel, 'toggleAttribute()').click() + expect(box.props.flag).toBe(true) + expect(box.getAttribute('flag')).toBe('') + expect(window.getComputedStyle(box).borderColor).toBe('rgb(194, 65, 12)') + expect(panel.textContent).toContain('prop: true') + + button(panel, 'toggleAttribute()').click() + expect(box.props.flag).toBe(false) + expect(box.hasAttribute('flag')).toBe(false) +}) + +test('the prop-write buttons reflect as a bare/absent attribute', () => { + const [panel, box] = mount() + + button(panel, 'props.flag = true').click() + expect(box.getAttribute('flag')).toBe('') + expect(panel.textContent).toContain('attribute: flag=""') + + button(panel, 'props.flag = false').click() + expect(box.hasAttribute('flag')).toBe(false) + expect(panel.textContent).toContain('attribute: (absent)') +}) + +test('the trap button demonstrates that "false" means true', () => { + const [panel, box] = mount() + + button(panel, 'setAttribute').click() + // the whole point of the warning: this reads as "off" but turns the flag on + expect(box.props.flag).toBe(true) + expect(panel.textContent).toContain('prop: true') +}) + +test('markup presence turns the flag on whatever the value says', () => { + document.body.innerHTML = + '' + const [bare, empty, stringly, absent] = document.querySelectorAll('flag-box') + + expect(bare.props.flag).toBe(true) + expect(empty.props.flag).toBe(true) + expect(stringly.props.flag).toBe(true) + expect(absent.props.flag).toBe(false) +}) diff --git a/test/e2e/boolean-reflection.test.mjs b/test/e2e/boolean-reflection.test.mjs new file mode 100644 index 0000000..23313c3 --- /dev/null +++ b/test/e2e/boolean-reflection.test.mjs @@ -0,0 +1,80 @@ +import { beforeEach, expect, test } from 'vitest' +import { WebComponent, html } from '../../src/index.js' + +/** + * Boolean props reflect as bare attributes (presence/absence), so the two + * things that silently broke under string reflection work here: the platform's + * own `toggleAttribute()` API, and `[attr]` presence selectors in CSS. + * These need a real browser — happy-dom does not evaluate `:host([flag])`. + */ +class FlagBox extends WebComponent { + static props = { flag: false } + static shadowRootInit = { mode: 'open' } + static styles = ` + :host { color: rgb(0, 0, 0); } + :host([flag]) { color: rgb(255, 0, 0); } + ` + get template() { + // an object template, so `static styles` actually gets adopted + return html`flag is ${this.props.flag}` + } +} +window.customElements.define('flag-box', FlagBox) + +const color = (el) => window.getComputedStyle(el).color + +beforeEach(() => { + document.body.innerHTML = '' +}) + +test('a false boolean prop leaves no attribute to mis-match [flag]', () => { + document.body.innerHTML = '' + const el = document.querySelector('flag-box') + + expect(el.hasAttribute('flag')).toBe(false) + expect(el.props.flag).toBe(false) + // the REQ-07 bug: `flag="false"` used to match `:host([flag])` and paint the + // "on" style onto an element whose prop was false + expect(color(el)).toBe('rgb(0, 0, 0)') +}) + +test('toggleAttribute drives the prop, the render and the [flag] style', () => { + document.body.innerHTML = '' + const el = document.querySelector('flag-box') + + // used to be a silent no-op: the attribute already existed as flag="false" + el.toggleAttribute('flag', true) + expect(el.props.flag).toBe(true) + expect(el.shadowRoot.querySelector('span').textContent).toBe('flag is true') + expect(color(el)).toBe('rgb(255, 0, 0)') + + el.toggleAttribute('flag', false) + expect(el.props.flag).toBe(false) + expect(el.shadowRoot.querySelector('span').textContent).toBe('flag is false') + expect(color(el)).toBe('rgb(0, 0, 0)') +}) + +test('a prop write reflects as a bare attribute the CSS can match', () => { + document.body.innerHTML = '' + const el = document.querySelector('flag-box') + + el.props.flag = true + expect(el.getAttribute('flag')).toBe('') + expect(color(el)).toBe('rgb(255, 0, 0)') + + el.props.flag = false + expect(el.hasAttribute('flag')).toBe(false) + expect(color(el)).toBe('rgb(0, 0, 0)') +}) + +test('markup presence turns the prop on, whatever the value says', () => { + // strict HTML semantics: any present value is true, including "false" + document.body.innerHTML = + '' + const [bare, stringly] = document.querySelectorAll('flag-box') + + expect(bare.props.flag).toBe(true) + expect(stringly.props.flag).toBe(true) + expect(color(bare)).toBe('rgb(255, 0, 0)') + expect(color(stringly)).toBe('rgb(255, 0, 0)') +}) diff --git a/test/utils/create-element.test.mjs b/test/utils/create-element.test.mjs index ee805b0..d22c630 100644 --- a/test/utils/create-element.test.mjs +++ b/test/utils/create-element.test.mjs @@ -16,17 +16,44 @@ describe('createElement', () => { }) it('assigns known DOM properties directly', () => { - const el = createElement({ type: 'div', props: { id: 'foo' }, children: [] }) + const el = createElement({ + type: 'div', + props: { id: 'foo' }, + children: [], + }) expect(el.id).toBe('foo') }) it('falls back to setAttribute for unknown props', () => { - const el = createElement({ type: 'div', props: { 'data-x': 'y' }, children: [] }) + const el = createElement({ + type: 'div', + props: { 'data-x': 'y' }, + children: [], + }) expect(el.getAttribute('data-x')).toBe('y') }) + it('applies an unknown boolean prop as a bare/absent attribute', () => { + // no DOM property to take the value, so it follows the HTML boolean + // convention — stamping "false" would read back as *true* + const on = createElement({ + type: 'x-el', + props: { flag: true }, + children: [], + }) + expect(on.getAttribute('flag')).toBe('') + const off = createElement({ + type: 'x-el', + props: { flag: false }, + children: [], + }) + expect(off.hasAttribute('flag')).toBe(false) + }) + it('applies style objects', () => { - const el = createElement(html`
x
`) + const el = createElement( + html`
x
` + ) expect(el.style.color).toBe('red') expect(el.style.padding).toBe('1em') }) @@ -39,7 +66,12 @@ describe('createElement', () => { }) it('appends nested element and text children', () => { - const el = createElement(html`
  • a
  • b
`) + const el = createElement( + html`
    +
  • a
  • +
  • b
  • +
` + ) expect(el.tagName).toBe('UL') expect(el.querySelectorAll('li')).toHaveLength(2) expect(el.textContent).toBe('ab') @@ -55,7 +87,10 @@ describe('createElement', () => { }) it('handles a multi-root html template as a fragment', () => { - const frag = createElement(html`

a

b

`) + const frag = createElement( + html`

a

+

b

` + ) expect(frag.querySelectorAll('p')).toHaveLength(2) }) }) diff --git a/test/utils/deserialize.test.mjs b/test/utils/deserialize.test.mjs index 8f9798f..c80b968 100644 --- a/test/utils/deserialize.test.mjs +++ b/test/utils/deserialize.test.mjs @@ -8,13 +8,12 @@ describe('deserialize', () => { expect(deserialize('-4.5', 'number')).toBe(-4.5) }) - test('parses boolean strings', () => { - expect(deserialize('true', 'boolean')).toBe(true) - expect(deserialize('false', 'boolean')).toBe(false) - }) - - test('treats a bare/empty boolean attribute as true', () => { + test('treats any present boolean attribute value as true', () => { + // strict HTML semantics: presence wins, exactly like `disabled="false"` expect(deserialize('', 'boolean')).toBe(true) + expect(deserialize('true', 'boolean')).toBe(true) + expect(deserialize('false', 'boolean')).toBe(true) + expect(deserialize('anything', 'boolean')).toBe(true) }) test('parses object strings', () => { @@ -32,9 +31,10 @@ describe('deserialize', () => { }) test('round-trips values through serialize', () => { + // booleans are excluded: they no longer reflect through `serialize` at + // all — `false` is an *absent* attribute, which never reaches deserialize const cases = [ [3, 'number'], - [false, 'boolean'], [{ a: 1, b: [2, 3] }, 'object'], ['hello', 'string'], ]