feat!: reflect boolean props as bare attributes
Some checks are pending
Tests / Unit (push) Waiting to run
Tests / E2E (push) Waiting to run

Boolean props follow the HTML presence/absence semantics.
- We stick close to standard HTML behavior: flag="false" is treated as
true in most cases. Standard bare attributes disabled and required for
form fields are interpreted as true with non-existence as false.
- The use-cases for aria-*="false" and contenteditable="false" are also
supported by typing them in the JS class as string ("true" | "false").
This "true" | "false" opt-in is types-only. At runtime it's just a
string, and strings already serialize literally and are never removed —
so the enumerated/aria path needs no runtime code.
This commit is contained in:
ayo 2026-07-20 19:57:23 +02:00
parent 136343962d
commit 1b3e251a9d
16 changed files with 618 additions and 39 deletions

View file

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

View file

@ -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)

View file

@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Boolean props as bare attributes</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;
}
.log code {
font-size: 0.95em;
}
</style>
</head>
<body>
<h1>Boolean props are bare attributes</h1>
<p>
A boolean prop follows the HTML convention in both directions:
<strong>presence means <code>true</code>, absence means
<code>false</code></strong>. Reflecting <code>true</code> sets the bare
attribute; reflecting <code>false</code> removes it. Nothing ever gets
stamped as <code>flag="false"</code>.
</p>
<h2>Driving the prop</h2>
<p>
Every button below reports the resulting prop value and the actual
attribute. The border lights up via
<code>:host([flag])</code> — a presence selector that now matches only
when the prop is genuinely true.
</p>
<boolean-demo></boolean-demo>
<h2>Presence in markup</h2>
<p>
All three of these mount with <code>props.flag === true</code>. The last
one is the surprise worth internalizing: any present value counts,
including the literal string <code>"false"</code>, exactly like
<code>&lt;input disabled="false"&gt;</code> is still disabled.
</p>
<div class="row">
<flag-box flag></flag-box>
<flag-box flag=""></flag-box>
<flag-box flag="false"></flag-box>
</div>
<h2>Absent means false</h2>
<p>
No attribute, no <code>[flag]</code> match, and
<code>props.flag === false</code>. Removing the attribute later also
lands on <code>false</code> rather than restoring a declared default —
which is why boolean props should default to <code>false</code> and use
an inverted name (<code>disabled</code>, not <code>enabled</code>) when
you want them on by default.
</p>
<flag-box></flag-box>
</body>
</html>

View file

@ -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`
<div>flag is <span class="state">${String(this.props.flag)}</span></div>
`
}
}
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`
<flag-box></flag-box>
<p class="log"><code>${this.props.log}</code></p>
<div class="row">
<button onclick=${() => this.#toggle()}>toggleAttribute()</button>
<button onclick=${() => this.#write(true)}>props.flag = true</button>
<button onclick=${() => this.#write(false)}>props.flag = false</button>
<button onclick=${() => this.#stringly()}>
setAttribute("flag", "false")
</button>
</div>
<p class="hint">
The last button is the migration trap:
<strong>any present value is true</strong>, so writing the string
<code>"false"</code> turns the flag <em>on</em> just like native
<code>disabled="false"</code> is still disabled. Check the console for
the warning wcb logs. Use
<code>toggleAttribute(name, bool)</code> instead.
</p>
`
}
}
customElements.define('boolean-demo', BooleanDemo)

View file

@ -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 {
<code>style</code> are present only while decorated toggling removes
them from the same element instead of building a new one.
</p>
<button id="toggle" onclick=${() => (this.props.decorated = !on)}>
<button id="toggle" onclick=${() => (this.props.plain = on)}>
${on ? 'Remove props' : 'Add props'}
</button>
`

View file

@ -25,6 +25,20 @@
</p>
</header>
<h2 class="section-label">v6 behavior demos</h2>
<div class="grid">
<a class="card" href="./examples/boolean-props/index.html">
<div class="name">
Boolean props<span class="tag">breaking</span>
</div>
<div class="desc">
Presence/absence reflection: <code>toggleAttribute</code> and
<code>[flag]</code> selectors work, and any present value is
<code>true</code>.
</div>
</a>
</div>
<h2 class="section-label">v5 behavior demos</h2>
<div class="grid">
<a class="card" href="./examples/on-changes/index.html">

View file

@ -41,6 +41,72 @@ Another advantage over `HTMLElement.dataset` is that `WebComponent.props` can ho
</Aside>
### 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 }
}
```
<!-- prettier-ignore -->
```html
<flag-box></flag-box> <!-- props.flag === false -->
<flag-box flag></flag-box> <!-- props.flag === true -->
<flag-box flag=""></flag-box> <!-- props.flag === true -->
```
Reflection works the same way in reverse — `true` sets the bare attribute,
`false` removes it entirely:
```js
el.props.flag = true // <flag-box flag>
el.props.flag = false // <flag-box>
```
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 */
}
```
<Aside type="caution" title="Any present value is true">
Just like native `disabled="false"` is still disabled, **`flag="false"` parses
as `true`** — presence wins, there is no special case for the literal string.
Writing `setAttribute('flag', String(someBool))` therefore always yields
`true`; use `toggleAttribute('flag', someBool)` instead. wcb logs a
`console.warn` when it sees a boolean attribute written as `"true"` or
`"false"` so this cannot fail silently.
</Aside>
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' }
```
<Aside type="tip" title="Default boolean props to false">
HTML has no true-default boolean attribute: absence has to mean both "false"
and "default", which only works when they coincide. Model an on-by-default
flag with an inverted name (`disabled`, not `enabled`). wcb warns once per
class on a `true` default — and for such a prop, removing the attribute lands
on `false`, not back on the declared default.
</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:

View file

@ -109,7 +109,7 @@
"size-limit": [
{
"path": "./dist/WebComponent.js",
"limit": "1.85 KB"
"limit": "1.95 KB"
},
{
"path": "./dist/html.js",

View file

@ -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<typeof props> {
* 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
}

View file

@ -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))
}

View file

@ -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" (`<el flag>`, `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: `<el flag>` / `flag=""` is true.
if (value === '') return true
// falls through
return true
case 'number':
case 'object':
case 'undefined':

View file

@ -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)

View file

@ -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 = '<boolean-demo></boolean-demo>'
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 =
'<flag-box flag></flag-box><flag-box flag=""></flag-box><flag-box flag="false"></flag-box><flag-box></flag-box>'
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)
})

View file

@ -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`<span>flag is ${this.props.flag}</span>`
}
}
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 = '<flag-box></flag-box>'
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 = '<flag-box></flag-box>'
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 = '<flag-box></flag-box>'
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 =
'<flag-box flag></flag-box><flag-box flag="false"></flag-box>'
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)')
})

View file

@ -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`<div style=${{ color: 'red', padding: '1em' }}>x</div>`)
const el = createElement(
html`<div style=${{ color: 'red', padding: '1em' }}>x</div>`
)
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`<ul><li>a</li><li>b</li></ul>`)
const el = createElement(
html`<ul>
<li>a</li>
<li>b</li>
</ul>`
)
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`<p>a</p><p>b</p>`)
const frag = createElement(
html`<p>a</p>
<p>b</p>`
)
expect(frag.querySelectorAll('p')).toHaveLength(2)
})
})

View file

@ -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'],
]