feat!: reflect boolean props as bare attributes
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:
parent
136343962d
commit
1b3e251a9d
16 changed files with 618 additions and 39 deletions
11
AGENTS.md
11
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.
|
||||
|
|
|
|||
18
README.md
18
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.
|
||||
|
||||
[](https://www.npmjs.com/package/web-component-base)
|
||||
[](https://www.npmjs.com/package/web-component-base)
|
||||
|
|
|
|||
70
demo/examples/boolean-props/index.html
Normal file
70
demo/examples/boolean-props/index.html
Normal 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><input disabled="false"></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>
|
||||
113
demo/examples/boolean-props/index.js
Normal file
113
demo/examples/boolean-props/index.js
Normal 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)
|
||||
|
|
@ -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>
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@
|
|||
"size-limit": [
|
||||
{
|
||||
"path": "./dist/WebComponent.js",
|
||||
"limit": "1.85 KB"
|
||||
"limit": "1.95 KB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/html.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<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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
74
test/e2e/boolean-props-demo.test.mjs
Normal file
74
test/e2e/boolean-props-demo.test.mjs
Normal 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)
|
||||
})
|
||||
80
test/e2e/boolean-reflection.test.mjs
Normal file
80
test/e2e/boolean-reflection.test.mjs
Normal 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)')
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue