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.
|
- **`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
|
## 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.
|
- 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
|
# Web Component Base
|
||||||
|
|
||||||
> [!Note]
|
> [!Warning]
|
||||||
> **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!**
|
> **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)
|
||||||
[](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.
|
* attribute is removed, and dropped `style` rules are cleared.
|
||||||
*/
|
*/
|
||||||
class PropRemoval extends WebComponent {
|
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() {
|
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
|
// spread so the props are genuinely *absent* when off — that's the removal
|
||||||
// path: present in the old vnode, gone from the new one
|
// path: present in the old vnode, gone from the new one
|
||||||
const decoration = on
|
const decoration = on
|
||||||
|
|
@ -151,7 +154,7 @@ class PropRemoval extends WebComponent {
|
||||||
<code>style</code> are present only while decorated — toggling removes
|
<code>style</code> are present only while decorated — toggling removes
|
||||||
them from the same element instead of building a new one.
|
them from the same element instead of building a new one.
|
||||||
</p>
|
</p>
|
||||||
<button id="toggle" onclick=${() => (this.props.decorated = !on)}>
|
<button id="toggle" onclick=${() => (this.props.plain = on)}>
|
||||||
${on ? 'Remove props' : 'Add props'}
|
${on ? 'Remove props' : 'Add props'}
|
||||||
</button>
|
</button>
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,20 @@
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</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>
|
<h2 class="section-label">v5 behavior demos</h2>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<a class="card" href="./examples/on-changes/index.html">
|
<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>
|
</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
|
### 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:
|
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": [
|
"size-limit": [
|
||||||
{
|
{
|
||||||
"path": "./dist/WebComponent.js",
|
"path": "./dist/WebComponent.js",
|
||||||
"limit": "1.85 KB"
|
"limit": "1.95 KB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/html.js",
|
"path": "./dist/html.js",
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,17 @@ function cloneDefaults(ctor) {
|
||||||
for (const key in ctor.props) {
|
for (const key in ctor.props) {
|
||||||
const value = ctor.props[key]
|
const value = ctor.props[key]
|
||||||
const type = typeof value
|
const type = typeof value
|
||||||
if (check && (type === 'function' || type === 'symbol'))
|
// a true boolean default is discouraged: HTML has no true-default boolean
|
||||||
console.warn(
|
// attribute, so absence has to mean both "false" and "default", which only
|
||||||
`${ctor.name}.${key}: ${type} default not reflectable; use handlers/refs.`
|
// 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 {
|
try {
|
||||||
out[key] = structuredClone(value)
|
out[key] = structuredClone(value)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -56,7 +63,7 @@ function cloneDefaults(ctor) {
|
||||||
* Pass the shape of your `static props` as a type argument to get typed
|
* Pass the shape of your `static props` as a type argument to get typed
|
||||||
* `this.props` access in TypeScript:
|
* `this.props` access in TypeScript:
|
||||||
* ```ts
|
* ```ts
|
||||||
* const props = { variant: 'primary', disabled: false }
|
* const props = { variant: 'primary', enabled: true }
|
||||||
* class CozyButton extends WebComponent<typeof props> {
|
* class CozyButton extends WebComponent<typeof props> {
|
||||||
* static props = props
|
* static props = props
|
||||||
* }
|
* }
|
||||||
|
|
@ -182,8 +189,17 @@ export class WebComponent extends HTMLElement {
|
||||||
const type = this.#typeMap[property]
|
const type = this.#typeMap[property]
|
||||||
let next
|
let next
|
||||||
if (currentValue === null) {
|
if (currentValue === null) {
|
||||||
// removal resets to the declared default (or undefined if none)
|
// boolean props follow HTML: absence *is* false, never the declared
|
||||||
next = this.constructor.props?.[property]
|
// 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') {
|
} else if (type && type !== 'string') {
|
||||||
// typed props deserialize; a malformed value falls back to the raw
|
// typed props deserialize; a malformed value falls back to the raw
|
||||||
// string so render()/onChanges() are never skipped
|
// string so render()/onChanges() are never skipped
|
||||||
|
|
@ -224,7 +240,7 @@ export class WebComponent extends HTMLElement {
|
||||||
console.error(msg)
|
console.error(msg)
|
||||||
} else if (obj[prop] !== value) {
|
} else if (obj[prop] !== value) {
|
||||||
obj[prop] = value
|
obj[prop] = value
|
||||||
setter(getKebabCase(prop), serialize(value))
|
setter(prop, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
@ -243,11 +259,28 @@ export class WebComponent extends HTMLElement {
|
||||||
if (!this.#props) {
|
if (!this.#props) {
|
||||||
this.#props = new Proxy(
|
this.#props = new Proxy(
|
||||||
initialProps,
|
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.
|
* Reflects default prop values onto attributes on first connect.
|
||||||
* Runs outside the constructor (spec forbids attribute mutation there)
|
* Runs outside the constructor (spec forbids attribute mutation there)
|
||||||
|
|
@ -256,9 +289,8 @@ export class WebComponent extends HTMLElement {
|
||||||
#reflectDefaults() {
|
#reflectDefaults() {
|
||||||
if (this.#reflected) return
|
if (this.#reflected) return
|
||||||
Object.keys(this.#props).forEach((camelCase) => {
|
Object.keys(this.#props).forEach((camelCase) => {
|
||||||
const kebab = getKebabCase(camelCase)
|
if (!this.hasAttribute(getKebabCase(camelCase)))
|
||||||
if (!this.hasAttribute(kebab))
|
this.#reflect(camelCase, this.#props[camelCase])
|
||||||
this.setAttribute(kebab, serialize(this.#props[camelCase]))
|
|
||||||
})
|
})
|
||||||
this.#reflected = true
|
this.#reflected = true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,11 @@ export function applyProp(el, prop, value) {
|
||||||
el[prop] = value
|
el[prop] = value
|
||||||
} else if (domProp in el) {
|
} else if (domProp in el) {
|
||||||
el[domProp] = value
|
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 {
|
} else {
|
||||||
el.setAttribute(prop, serialize(value))
|
el.setAttribute(prop, serialize(value))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,12 @@
|
||||||
*/
|
*/
|
||||||
export function deserialize(value, type) {
|
export function deserialize(value, type) {
|
||||||
switch (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':
|
case 'boolean':
|
||||||
// bare presence follows the HTML convention: `<el flag>` / `flag=""` is true.
|
return true
|
||||||
if (value === '') return true
|
|
||||||
// falls through
|
|
||||||
case 'number':
|
case 'number':
|
||||||
case 'object':
|
case 'object':
|
||||||
case 'undefined':
|
case 'undefined':
|
||||||
|
|
|
||||||
|
|
@ -369,31 +369,55 @@ describe('reactive props (documented contract)', () => {
|
||||||
expect(el.props.active).toBe(true)
|
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()
|
const el = connected()
|
||||||
el.setAttribute('active', 'true')
|
el.setAttribute('active', 'true')
|
||||||
expect(el.props.active).toBe(true)
|
expect(el.props.active).toBe(true)
|
||||||
|
// presence wins — exactly like native `disabled="false"` is still disabled
|
||||||
el.setAttribute('active', 'false')
|
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()
|
const el = connected()
|
||||||
el.setAttribute('active', '')
|
el.setAttribute('active', '')
|
||||||
el.removeAttribute('active')
|
el.removeAttribute('active')
|
||||||
expect(el.props.active).toBe(false)
|
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()
|
const el = connected()
|
||||||
el.props.active = true
|
el.props.active = true
|
||||||
expect(el.getAttribute('active')).toBe('true')
|
expect(el.getAttribute('active')).toBe('')
|
||||||
expect(el.props.active).toBe(true)
|
expect(el.props.active).toBe(true)
|
||||||
el.props.active = false
|
el.props.active = false
|
||||||
expect(el.getAttribute('active')).toBe('false')
|
expect(el.hasAttribute('active')).toBe(false)
|
||||||
expect(el.props.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', () => {
|
it('fires onChanges with property/attribute/previousValue/currentValue', () => {
|
||||||
const changes = []
|
const changes = []
|
||||||
class WithChanges extends WebComponent {
|
class WithChanges extends WebComponent {
|
||||||
|
|
@ -426,6 +450,11 @@ describe('default reflection (no setAttribute in constructor)', () => {
|
||||||
const tag = 'reflect-defaulted'
|
const tag = 'reflect-defaulted'
|
||||||
window.customElements.define(tag, 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', () => {
|
it('createElement does not throw and defers defaults until connect', () => {
|
||||||
const el = document.createElement(tag)
|
const el = document.createElement(tag)
|
||||||
// no attributes reflected before connect (spec-legal constructor)
|
// 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')
|
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', () => {
|
it('does not re-clobber a prop changed while connected on re-connect', () => {
|
||||||
const el = document.createElement(tag)
|
const el = document.createElement(tag)
|
||||||
document.body.appendChild(el)
|
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', () => {
|
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')
|
expect(el.id).toBe('foo')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to setAttribute for unknown props', () => {
|
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')
|
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', () => {
|
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.color).toBe('red')
|
||||||
expect(el.style.padding).toBe('1em')
|
expect(el.style.padding).toBe('1em')
|
||||||
})
|
})
|
||||||
|
|
@ -39,7 +66,12 @@ describe('createElement', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('appends nested element and text children', () => {
|
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.tagName).toBe('UL')
|
||||||
expect(el.querySelectorAll('li')).toHaveLength(2)
|
expect(el.querySelectorAll('li')).toHaveLength(2)
|
||||||
expect(el.textContent).toBe('ab')
|
expect(el.textContent).toBe('ab')
|
||||||
|
|
@ -55,7 +87,10 @@ describe('createElement', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles a multi-root html template as a fragment', () => {
|
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)
|
expect(frag.querySelectorAll('p')).toHaveLength(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,12 @@ describe('deserialize', () => {
|
||||||
expect(deserialize('-4.5', 'number')).toBe(-4.5)
|
expect(deserialize('-4.5', 'number')).toBe(-4.5)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('parses boolean strings', () => {
|
test('treats any present boolean attribute value as true', () => {
|
||||||
expect(deserialize('true', 'boolean')).toBe(true)
|
// strict HTML semantics: presence wins, exactly like `disabled="false"`
|
||||||
expect(deserialize('false', 'boolean')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('treats a bare/empty boolean attribute as true', () => {
|
|
||||||
expect(deserialize('', 'boolean')).toBe(true)
|
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', () => {
|
test('parses object strings', () => {
|
||||||
|
|
@ -32,9 +31,10 @@ describe('deserialize', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test('round-trips values through serialize', () => {
|
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 = [
|
const cases = [
|
||||||
[3, 'number'],
|
[3, 'number'],
|
||||||
[false, 'boolean'],
|
|
||||||
[{ a: 1, b: [2, 3] }, 'object'],
|
[{ a: 1, b: [2, 3] }, 'object'],
|
||||||
['hello', 'string'],
|
['hello', 'string'],
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue