docs: tighten the "why" page (grammar and figures)
This commit is contained in:
parent
b4ce4c8cf7
commit
cef76dfd66
13 changed files with 97 additions and 95 deletions
|
|
@ -44,7 +44,7 @@ initializer and, for every key, records in the manifest:
|
|||
|
||||
- the **member** under its camelCase name
|
||||
- the **attribute** under its kebab-case name, linked to that member
|
||||
- the **type** inferred from the default value — `boolean`, `number`, `object`
|
||||
- the **type** inferred from the default value: `boolean`, `number`, `object`
|
||||
or `string`
|
||||
- the **default value** as written
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ html`<p class="a">hi</p>`
|
|||
Because the tree is a plain object it is comparable and serializable, which is
|
||||
what lets `render()` diff one render against the next.
|
||||
|
||||
`` html`` `` returns `undefined` — this is the idiomatic way for a component to
|
||||
`` html`` `` returns `undefined`. This is the idiomatic way for a component to
|
||||
render nothing, and it empties the rendered subtree rather than leaving the
|
||||
previous render on screen.
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ Each entry in `props` is applied by [`applyProp`](/api/utils/#applypropel-prop-v
|
|||
in this order:
|
||||
|
||||
1. a `style` object is applied rule by rule
|
||||
2. a name the element owns as a **DOM property** is assigned to that property —
|
||||
2. a name the element owns as a **DOM property** is assigned to that property,
|
||||
so event handlers (`onclick=${fn}`) and non-string values keep their type
|
||||
3. a boolean value with no matching DOM property is toggled as an HTML boolean
|
||||
attribute
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ everything else pass through unchanged.
|
|||
|
||||
### `deserialize(value, type)`
|
||||
|
||||
Parses an attribute string back into a value of the given declared type — the
|
||||
Parses an attribute string back into a value of the given declared type, the
|
||||
inverse of `serialize()`.
|
||||
|
||||
| Parameter | Type | |
|
||||
|
|
@ -72,7 +72,7 @@ inverse of `serialize()`.
|
|||
| `type` | `string` | `'boolean'`, `'number'`, `'object'`, `'undefined'` or `'string'` |
|
||||
| **returns** | `any` | the parsed value |
|
||||
|
||||
`'boolean'` always returns `true` — strict HTML boolean-attribute semantics,
|
||||
`'boolean'` always returns `true`: strict HTML boolean-attribute semantics,
|
||||
where any present value is true. Absence is handled by the caller and never
|
||||
reaches here. `'number'`, `'object'` and `'undefined'` use `JSON.parse` and
|
||||
throw on malformed input; strings pass through.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: WebComponent
|
||||
slug: api/web-component
|
||||
description: The WebComponent base class — static configuration, instance members, lifecycle hooks and attribute converters.
|
||||
description: 'The WebComponent base class: static configuration, instance members, lifecycle hooks and attribute converters.'
|
||||
---
|
||||
|
||||
The base class every component extends. Import from the package root or its own
|
||||
|
|
@ -56,8 +56,8 @@ reported with `console.warn`:
|
|||
|
||||
| Default | Warning |
|
||||
| ------------------ | --------------------------------------------------- |
|
||||
| a function or symbol | not reflectable — use handlers or refs instead |
|
||||
| `true` | boolean defaults should be `false` — invert the name |
|
||||
| a function or symbol | not reflectable: use handlers or refs instead |
|
||||
| `true` | boolean defaults should be `false`: invert the name |
|
||||
|
||||
A `true` boolean default is discouraged because HTML has no true-by-default
|
||||
boolean attribute: absence would have to mean both "false" and "default". Name
|
||||
|
|
@ -80,7 +80,7 @@ per-component rules after it. Strings are compiled to a `CSSStyleSheet` once;
|
|||
existing `CSSStyleSheet` instances are adopted as-is and can be shared across
|
||||
components.
|
||||
|
||||
Adoption happens **once per instance**, when the element is constructed — not
|
||||
Adoption happens **once per instance**, when the element is constructed, not
|
||||
per render.
|
||||
|
||||
Requires [`shadowRootInit`](#static-shadowrootinit). Without a shadow root
|
||||
|
|
@ -92,7 +92,7 @@ See it live: [Constructable styles demo ↗](https://demo.webcomponent.io/exampl
|
|||
### `static shadowRootInit`
|
||||
|
||||
A [`ShadowRootInit`](https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#options)
|
||||
object. Its presence is what opts the component into shadow DOM — the shadow
|
||||
object. Its presence is what opts the component into shadow DOM. The shadow
|
||||
root is attached during construction and becomes the render target.
|
||||
|
||||
```js
|
||||
|
|
@ -143,9 +143,9 @@ Assigning the value it already holds does nothing.
|
|||
|
||||
Read-only getter returning what the component renders. Two kinds are supported:
|
||||
|
||||
- an [`html`](/api/html/) tagged template — a vnode tree, reconciled in place
|
||||
- an [`html`](/api/html/) tagged template: a vnode tree, reconciled in place
|
||||
on re-render
|
||||
- a **string** — assigned to the render target's `innerHTML`
|
||||
- a **string**: assigned to the render target's `innerHTML`
|
||||
|
||||
Both render into the same target: the shadow root when `shadowRootInit` is set,
|
||||
the element itself otherwise. Returning `` html`` `` (which is `undefined`) or
|
||||
|
|
@ -165,7 +165,7 @@ Renders `template` into the render target. Called automatically on connect and
|
|||
on every prop or attribute change; you rarely call it yourself.
|
||||
|
||||
For a vnode template, the new tree is compared against the previous one and
|
||||
re-render **patches the existing DOM in place** — see
|
||||
re-render **patches the existing DOM in place**. See
|
||||
[Template vs Render](/template-vs-render/) for what that preserves and the
|
||||
non-keyed matching caveat.
|
||||
|
||||
|
|
@ -202,7 +202,7 @@ Override these to control how one prop crosses the prop/attribute boundary, and
|
|||
call `super` for the props you do not handle.
|
||||
|
||||
The default conversion round-trips values through JSON. Types JSON cannot
|
||||
restore — `Date`, `Map`, `Set`, `URL`, class instances — need overridden
|
||||
restore (`Date`, `Map`, `Set`, `URL`, class instances) need overridden
|
||||
converters to live on `static props`; see
|
||||
[Custom attribute conversion](/prop-access/#custom-attribute-conversion) for
|
||||
worked examples, including the non-serializable cases.
|
||||
|
|
@ -229,7 +229,7 @@ toAttribute(name, value) {
|
|||
|
||||
### `fromAttribute(name, value)`
|
||||
|
||||
Converts an attribute value into the prop value it represents — the inverse of
|
||||
Converts an attribute value into the prop value it represents, the inverse of
|
||||
`toAttribute()`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|
|
@ -256,7 +256,7 @@ Boolean props follow the HTML convention in both directions: **presence means
|
|||
| `true` | present, empty value | `''` |
|
||||
| `false` | absent | `null` |
|
||||
|
||||
Any present value reads as `true` — including the literal `flag="false"`, just
|
||||
Any present value reads as `true`, including the literal `flag="false"`, just
|
||||
as native `disabled="false"` is still disabled. Removing the attribute always
|
||||
yields `false`, never the declared default.
|
||||
|
||||
|
|
@ -265,7 +265,7 @@ Use `toggleAttribute(name, bool)` to set them. Writing
|
|||
when it sees a boolean attribute written as `"true"` or `"false"` so the
|
||||
inversion cannot fail silently.
|
||||
|
||||
Attributes whose `"false"` is meaningful — `aria-*`, `contenteditable` — should
|
||||
Attributes whose `"false"` is meaningful (`aria-*`, `contenteditable`) should
|
||||
be declared as **string** props.
|
||||
|
||||
See it live: [Boolean props demo ↗](https://demo.webcomponent.io/examples/boolean-props/)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ slug: cem-plugin
|
|||
import { Aside } from '@astrojs/starlight/components'
|
||||
|
||||
A CEM (`custom-elements.json`) is a standard description of the elements a
|
||||
package defines — their tags, attributes, properties, events and slots — so
|
||||
package defines (their tags, attributes, properties, events and slots) so
|
||||
tooling can read your components without executing them. Read more at
|
||||
[custom-elements-manifest.open-wc.org](https://custom-elements-manifest.open-wc.org/)
|
||||
for the analyzer and its plugin API, or the [schema and
|
||||
|
|
@ -20,7 +20,7 @@ We provide a CEM Analyzer Plugin `web-component-base/cem-plugin` which allows us
|
|||
set up: a starter component, this plugin configured in
|
||||
`custom-elements-manifest.config.mjs`, an `analyze` script, and the
|
||||
`customElements` field set in `package.json`. The rest of this guide is the
|
||||
manual path — use it to add the manifest to an existing project, or to
|
||||
manual path: use it to add the manifest to an existing project, or to
|
||||
understand what the scaffold configured.
|
||||
</Aside>
|
||||
|
||||
|
|
@ -53,10 +53,10 @@ npx cem analyze
|
|||
Run it **from the directory holding your config and a `package.json`**. Two
|
||||
things make it appear to do nothing, neither of which prints an error:
|
||||
|
||||
- **No config found** — it falls back to a default glob of
|
||||
- **No config found**: it falls back to a default glob of
|
||||
`**/*.{js,ts,tsx}`, which does _not_ exclude `node_modules`. In a real
|
||||
project that is tens of thousands of files through the TypeScript parser.
|
||||
- **No `package.json` in the directory** — it hangs outright.
|
||||
- **No `package.json` in the directory**: it hangs outright.
|
||||
|
||||
With globs scoped to your source it should finish in well under a second.
|
||||
|
||||
|
|
@ -92,10 +92,10 @@ customElements.define('cozy-button', CozyButton)
|
|||
|
||||
Two details worth knowing:
|
||||
|
||||
- **Types come from the default literal** — `true`/`false` → `boolean`, numeric → `number`, object/array → `object`, everything else → `string`.
|
||||
- **Types come from the default literal**: `true`/`false` → `boolean`, numeric → `number`, object/array → `object`, everything else → `string`.
|
||||
- **Attribute names come from wcb's own `getKebabCase`**, the same function `observedAttributes` uses, so manifest names can't drift from what the component actually observes.
|
||||
|
||||
The defaults may be written inline or hoisted into a module-level `const` — the latter is required by the [typed props](/prop-access/#typed-props-in-typescript) pattern, and the plugin resolves it either way.
|
||||
The defaults may be written inline or hoisted into a module-level `const`. The latter is required by the [typed props](/prop-access/#typed-props-in-typescript) pattern, and the plugin resolves it either way.
|
||||
|
||||
## Storybook
|
||||
|
||||
|
|
@ -113,7 +113,7 @@ setCustomElementsManifest(manifest)
|
|||
export default { tags: ['autodocs'] }
|
||||
```
|
||||
|
||||
Bind a story to the tag name and Storybook infers the rest — a text field for `variant`, a toggle for `disabled`, a number input for `maxCount`:
|
||||
Bind a story to the tag name and Storybook infers the rest, giving a text field for `variant`, a toggle for `disabled`, a number input for `maxCount`:
|
||||
|
||||
```js
|
||||
// cozy-button.stories.js
|
||||
|
|
@ -133,19 +133,19 @@ export const Default = { args: { variant: 'primary', disabled: false } }
|
|||
|
||||
<Aside type="tip">
|
||||
Regenerate the manifest before starting Storybook (`cem analyze && storybook
|
||||
dev`) — `custom-elements.json` is a build artifact, so it is usually
|
||||
dev`). `custom-elements.json` is a build artifact, so it is usually
|
||||
gitignored and rebuilt on demand.
|
||||
</Aside>
|
||||
|
||||
This repo runs exactly this setup against the demo components in `storybook/` — see it there for a working reference.
|
||||
This repo runs exactly this setup against the demo components in `storybook/`. See it there for a working reference.
|
||||
|
||||
## Code editors
|
||||
|
||||
Once `custom-elements.json` exists, editors can offer tag-name and attribute autocomplete for your components — driven by the same `static props` the plugin reads, so the hints can't drift from the code.
|
||||
Once `custom-elements.json` exists, editors can offer tag-name and attribute autocomplete for your components, driven by the same `static props` the plugin reads, so the hints can't drift from the code.
|
||||
|
||||
<Aside type="caution">
|
||||
VS Code does **not** read `custom-elements.json` natively. Nothing happens
|
||||
just because the file exists — you need one of the two routes below.
|
||||
just because the file exists. You need one of the two routes below.
|
||||
</Aside>
|
||||
|
||||
First, point tooling at the manifest from your `package.json`. This is the field every option here uses to discover it:
|
||||
|
|
@ -156,7 +156,7 @@ First, point tooling at the manifest from your `package.json`. This is the field
|
|||
}
|
||||
```
|
||||
|
||||
### Route 1 — native VS Code, no extension
|
||||
### Route 1: native VS Code, no extension
|
||||
|
||||
VS Code's built-in HTML language service reads its own [custom data](https://github.com/microsoft/vscode-custom-data) format. A second analyzer plugin converts the manifest into it, so both files come out of one `cem analyze` run:
|
||||
|
||||
|
|
@ -187,25 +187,25 @@ export default {
|
|||
`html.customData` paths resolve from the **workspace root**, not from the
|
||||
settings file. If you run `cem analyze` in a subfolder, either point at
|
||||
`./that-folder/vscode.html-custom-data.json` or give the generator its own
|
||||
`outdir` — `generateCustomData({ outdir: '..' })` — so the file lands where
|
||||
`outdir`, `generateCustomData({ outdir: '..' })`, so the file lands where
|
||||
the setting expects it.
|
||||
</Aside>
|
||||
|
||||
Restart VS Code and `<cozy-` completes in HTML files, with `variant` / `disabled` / `max-count` offered as attributes and their types and defaults on hover.
|
||||
|
||||
**The catch:** `html.customData` only applies to `.html` files. wcb components author their markup in `html` tagged templates inside `.js` / `.ts`, and those do not go through the HTML language service. This route helps whoever writes plain HTML pages against your components — it will not light up inside your own templates.
|
||||
**The catch:** `html.customData` only applies to `.html` files. wcb components author their markup in `html` tagged templates inside `.js` / `.ts`, and those do not go through the HTML language service. This route helps whoever writes plain HTML pages against your components. It will not light up inside your own templates.
|
||||
|
||||
### Route 2 — a language server extension, for tagged templates
|
||||
### Route 2: a language server extension, for tagged templates
|
||||
|
||||
To get the same completions **inside** tagged templates, you need an extension that understands them. The most current option is the [Custom Elements Manifest Language Server](https://marketplace.visualstudio.com/items?itemName=pwrs.cem-language-server-vscode) (`pwrs.cem-language-server-vscode`). It autocompletes tag names and attributes inside template literals in both JS and TS, adds hover documentation for attributes and defaults, and discovers the manifest through the `customElements` field above — no `.vscode/settings.json` needed.
|
||||
To get the same completions **inside** tagged templates, you need an extension that understands them. The most current option is the [Custom Elements Manifest Language Server](https://marketplace.visualstudio.com/items?itemName=pwrs.cem-language-server-vscode) (`pwrs.cem-language-server-vscode`). It autocompletes tag names and attributes inside template literals in both JS and TS, adds hover documentation for attributes and defaults, and discovers the manifest through the `customElements` field above, no `.vscode/settings.json` needed.
|
||||
|
||||
Two alternatives, both worth knowing the state of:
|
||||
|
||||
- [`wc-toolkit/wc-language-server`](https://wc-toolkit.com/integrations/web-components-language-server/) — VS Code and JetBrains, also manifest-driven. Self-described as **alpha and experimental**.
|
||||
- `Matsuuu.custom-elements-language-server-project` — the one you'll find most often in older write-ups. It is alpha, and **its repository was archived in January 2026**, so prefer one of the two above.
|
||||
- [`wc-toolkit/wc-language-server`](https://wc-toolkit.com/integrations/web-components-language-server/): VS Code and JetBrains, also manifest-driven. Self-described as **alpha and experimental**.
|
||||
- `Matsuuu.custom-elements-language-server-project`: the one you'll find most often in older write-ups. It is alpha, and **its repository was archived in January 2026**, so prefer one of the two above.
|
||||
|
||||
<Aside type="note">
|
||||
This corner of the ecosystem moves quickly and every option is pre-1.0. Route
|
||||
1 is the conservative choice — it is plain VS Code configuration with no
|
||||
extension to go stale — at the cost of not covering tagged templates.
|
||||
1 is the conservative choice (it is plain VS Code configuration with no
|
||||
extension to go stale) at the cost of not covering tagged templates.
|
||||
</Aside>
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@ Numbers below are **measured** from the same minimal counter component (one reac
|
|||
| `@elenajs/core` | 1.0.0 | 9.1 kB | 3.7 kB | 3.3 kB |
|
||||
| `lit` | 3.3.3 | 15.3 kB | 5.9 kB | 5.3 kB |
|
||||
| `@microsoft/fast-element` | 3.0.1 | 44.9 kB | 13.7 kB | 12.2 kB |
|
||||
| vanilla `HTMLElement` | — | — | — | ~0.2 kB |
|
||||
| vanilla `HTMLElement` | - | - | - | ~0.2 kB |
|
||||
|
||||
For scale: even after all of the v5.2–v6.1 work, the WCB counter is **~21% smaller than Elena, ~51% smaller than Lit, and ~79% smaller than FAST**.
|
||||
|
||||
## Feature comparison
|
||||
|
||||
What each library gives you beyond extending directly from `HTMLElement` — the boilerplate you no longer write by hand:
|
||||
What each library gives you beyond extending directly from `HTMLElement`, the boilerplate you no longer write by hand:
|
||||
|
||||
| Capability | WCB 6.1 | Lit 3.3 | Elena 1.0 | FAST 3.0 |
|
||||
| -------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------- | --------------------------------------- | ------------------------------------------------ |
|
||||
|
|
@ -42,11 +42,11 @@ What each library gives you beyond extending directly from `HTMLElement` — the
|
|||
| Backing / ecosystem | solo maintainer, small surface | Google, huge ecosystem | new (2026), design-system focus | Microsoft, powers Fluent UI |
|
||||
|
||||
:::note[Why 11ty WebC isn't here]
|
||||
WebC is a compile-time tool: it resolves components during an Eleventy build and ships plain HTML with no client runtime. Every row above is about what a library does _in the browser at runtime_, so a side-by-side comparison would be measuring two different things. If your components are static at build time, WebC solves a different problem — and solves it well.
|
||||
WebC is a compile-time tool: it resolves components during an Eleventy build and ships plain HTML with no client runtime. Every row above is about what a library does _in the browser at runtime_, so a side-by-side comparison would be measuring two different things. If your components are static at build time, WebC solves a different problem, and solves it well.
|
||||
:::
|
||||
|
||||
For what these numbers and capabilities add up to — and when they don't — see [Why would anyone use WCB?](/why/).
|
||||
For what these numbers and capabilities add up to (and when they don't) see [Why would anyone use WCB?](/why/).
|
||||
|
||||
---
|
||||
|
||||
_WCB re-measured 2026-07-20 at v6.1.0; the other libraries measured 2026-07-19, with esbuild, Node zlib (gzip −9, brotli q11), at the pinned versions above. Methodology: identical counter component per library, bundled per library, compressed. Re-run them yourself — the benchmark is trivially reproducible with the versions pinned above._
|
||||
_WCB re-measured 2026-07-20 at v6.1.0; the other libraries measured 2026-07-19, with esbuild, Node zlib (gzip −9, brotli q11), at the pinned versions above. Methodology: identical counter component per library, bundled per library, compressed. Re-run them yourself. The benchmark is trivially reproducible with the versions pinned above._
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ slug: examples
|
|||
## Live demo gallery
|
||||
|
||||
Every example below runs as a standalone page at
|
||||
[demo.webcomponent.io ↗](https://demo.webcomponent.io/) — a live gallery with
|
||||
[demo.webcomponent.io ↗](https://demo.webcomponent.io/), a live gallery with
|
||||
the source alongside each demo.
|
||||
|
||||
| Demo | Shows |
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ npm create wcb@latest
|
|||
|
||||
It sets up a publishable custom element, ready to develop:
|
||||
|
||||
- **A starter component** in TypeScript using the [`static props`](/prop-access/) convention — the class, tag, and file names are derived from your project name (`my-button` → `MyButton` / `<my-button>`), so there are no rename-me TODOs
|
||||
- **A Vite setup** — a dev server with a demo page, plus a library build (`npm run build:lib`) that emits ESM + UMD bundles and `.d.ts` types, with `web-component-base` as a peerDependency
|
||||
- **`custom-elements.json` generation** — the CEM analyzer configured with wcb's plugin, an `npm run analyze` script, the `customElements` field in `package.json`, and a `prepack` hook so the manifest ships inside the published package
|
||||
- **A starter component** in TypeScript using the [`static props`](/prop-access/) convention. The class, tag, and file names are derived from your project name (`my-button` → `MyButton` / `<my-button>`), so there are no rename-me TODOs
|
||||
- **A Vite setup**: a dev server with a demo page, plus a library build (`npm run build:lib`) that emits ESM + UMD bundles and `.d.ts` types, with `web-component-base` as a peerDependency
|
||||
- **`custom-elements.json` generation**: the CEM analyzer configured with wcb's plugin, an `npm run analyze` script, the `customElements` field in `package.json`, and a `prepack` hook so the manifest ships inside the published package
|
||||
|
||||
Run the demo page to see the starter component render:
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ npm install
|
|||
npm run dev
|
||||
```
|
||||
|
||||
Vite prints a local URL — open it to see `<my-button>` rendered on the demo page. Edit `src/MyButton.ts` and the page updates as you save.
|
||||
Vite prints a local URL. Open it to see `<my-button>` rendered on the demo page. Edit `src/MyButton.ts` and the page updates as you save.
|
||||
|
||||
See the [CEM Analyzer Plugin guide](/cem-plugin/) for more details on the manifest and how to use it with Storybook and code editors.
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ import { WebComponent } from 'web-component-base'
|
|||
Continue with [Usage](/usage) to define your first component.
|
||||
|
||||
:::tip[Prefer a CDN?]
|
||||
You can also import wcb straight from a CDN in vanilla JS or HTML files — see the [CodePen examples](/examples/#codepen-examples) for working setups.
|
||||
You can also import wcb straight from a CDN in vanilla JS or HTML files. See the [CodePen examples](/examples/#codepen-examples) for working setups.
|
||||
:::
|
||||
|
||||
## Getting help
|
||||
|
|
|
|||
|
|
@ -77,9 +77,9 @@ class ClickableText extends WebComponent {
|
|||
|
||||
- Triggered when an attribute value changed
|
||||
- The `changes` object cleanly separates the **property** from the **attribute**:
|
||||
- `property` — the **camelCase** prop key, matching how you access `props` (e.g. `myName`)
|
||||
- `attribute` — the **kebab-case** attribute name that changed (e.g. `my-name`)
|
||||
- `previousValue` / `currentValue` — the values before and after the change
|
||||
- `property`: the **camelCase** prop key, matching how you access `props` (e.g. `myName`)
|
||||
- `attribute`: the **kebab-case** attribute name that changed (e.g. `my-name`)
|
||||
- `previousValue` / `currentValue`: the values before and after the change
|
||||
|
||||
Use `property` to read the value straight off `props` (`this.props[property]`); use `attribute` when you need the raw attribute name.
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ If you previously read `changes.property` for the attribute name, switch to `cha
|
|||
|
||||
## Upgrade ordering & the buffering guarantee
|
||||
|
||||
Per the Custom Elements spec, when an element is upgraded with attributes already present in the markup (e.g. `<my-el my-name="Zoe">`), the browser fires `attributeChangedCallback` **before** `connectedCallback`. Taken literally, that means `render()` and `onChanges()` could run before `onInit()` — so any setup you do in `onInit` (event wiring, reading external state) would not have happened yet on that first render. Test environments like happy-dom/jsdom don't reproduce this ordering, so components can pass in tests and then misbehave in a real browser.
|
||||
Per the Custom Elements spec, when an element is upgraded with attributes already present in the markup (e.g. `<my-el my-name="Zoe">`), the browser fires `attributeChangedCallback` **before** `connectedCallback`. Taken literally, that means `render()` and `onChanges()` could run before `onInit()`, so any setup you do in `onInit` (event wiring, reading external state) would not have happened yet on that first render. Test environments like happy-dom/jsdom don't reproduce this ordering, so components can pass in tests and then misbehave in a real browser.
|
||||
|
||||
`WebComponent` removes this footgun. Attribute changes that arrive **before** the element is connected are buffered:
|
||||
|
||||
|
|
@ -126,8 +126,8 @@ Per the Custom Elements spec, when an element is upgraded with attributes alread
|
|||
|
||||
On connect, the order is always:
|
||||
|
||||
1. `onInit()` — `this.props` already reflects any authored attributes
|
||||
2. a single `render()` — reflects all buffered props in one pass
|
||||
1. `onInit()`: `this.props` already reflects any authored attributes
|
||||
2. a single `render()`: reflects all buffered props in one pass
|
||||
3. `afterViewInit()`
|
||||
|
||||
**`onChanges()` never fires before `onInit()`.** Pre-connect attribute changes are **not** replayed through `onChanges()` — the first `render()` already reflects them, so `onChanges()` is reserved for genuine post-connect changes. After the element is connected, attribute changes behave normally: each one triggers `render()` and `onChanges()` immediately.
|
||||
**`onChanges()` never fires before `onInit()`.** Pre-connect attribute changes are **not** replayed through `onChanges()`. The first `render()` already reflects them, so `onChanges()` is reserved for genuine post-connect changes. After the element is connected, attribute changes behave normally: each one triggers `render()` and `onChanges()` immediately.
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ class FlagBox extends WebComponent {
|
|||
<flag-box flag=""></flag-box>
|
||||
```
|
||||
|
||||
Reflection works the same way in reverse — `true` sets the bare attribute,
|
||||
Reflection works the same way in reverse. `true` sets the bare attribute,
|
||||
`false` removes it entirely:
|
||||
|
||||
```js
|
||||
|
|
@ -88,7 +88,7 @@ el.toggleAttribute('flag', true) // prop syncs, component re-renders
|
|||
|
||||
<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.
|
||||
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
|
||||
|
|
@ -96,7 +96,7 @@ el.toggleAttribute('flag', true) // prop syncs, component re-renders
|
|||
</Aside>
|
||||
|
||||
Enumerated attributes like `contenteditable` and `aria-*` attributes are the
|
||||
exception — they are genuine strings where `"false"` is meaningful, so declare
|
||||
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:
|
||||
|
|
@ -109,7 +109,7 @@ const props = { ariaChecked: 'false' as 'true' | '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
|
||||
class on a `true` default, and for such a prop, removing the attribute lands
|
||||
on `false`, not back on the declared default.
|
||||
</Aside>
|
||||
|
||||
|
|
@ -118,8 +118,8 @@ const props = { ariaChecked: 'false' as 'true' | 'false' }
|
|||
See it live: [Custom attribute converters demo ↗](https://demo.webcomponent.io/examples/attribute-converters/)
|
||||
|
||||
The rules above cover the common cases. When a prop needs its own
|
||||
serialization — a `Date`, a delimited list, an enumerated attribute where
|
||||
`"false"` is meaningful — override `toAttribute` and `fromAttribute`, and
|
||||
serialization (a `Date`, a delimited list, an enumerated attribute where
|
||||
`"false"` is meaningful) override `toAttribute` and `fromAttribute`, and
|
||||
delegate everything else to `super`:
|
||||
|
||||
```js
|
||||
|
|
@ -144,7 +144,7 @@ class EventCard extends WebComponent {
|
|||
```
|
||||
|
||||
Both take the **camelCase prop key**, matching your `static props` declaration
|
||||
and `onChanges`'s `property` — not the kebab-case attribute name.
|
||||
and `onChanges`'s `property`, not the kebab-case attribute name.
|
||||
|
||||
`toAttribute` returning **`null` removes the attribute**. That is exactly how a
|
||||
`false` boolean becomes an absent attribute, and it is available to any prop:
|
||||
|
|
@ -158,7 +158,7 @@ toAttribute(name, value) {
|
|||
|
||||
<Aside type="note" title="Reflection does not round-trip">
|
||||
When a prop write reflects to its attribute, wcb does **not** parse the
|
||||
attribute back through `fromAttribute` — the prop you assigned is already the
|
||||
attribute back through `fromAttribute`. The prop you assigned is already the
|
||||
source of truth. So `toAttribute` may be lossy without corrupting the prop: in
|
||||
the example above `props.when` keeps its full timestamp even though the
|
||||
attribute carries only the date. `render()` and `onChanges` still fire as
|
||||
|
|
@ -169,7 +169,7 @@ toAttribute(name, value) {
|
|||
<Aside type="caution" title="The declared type still applies">
|
||||
A prop's runtime type comes from the `typeof` of its default, and the props
|
||||
proxy rejects writes that violate it. A `Date`-valued prop therefore needs an
|
||||
actual `Date` default (`new Date(0)`), not `''` — otherwise the prop is typed
|
||||
actual `Date` default (`new Date(0)`), not `''`. Otherwise the prop is typed
|
||||
`string` and your parsed `Date` is refused.
|
||||
</Aside>
|
||||
|
||||
|
|
@ -180,14 +180,14 @@ numbers, booleans, and plain objects/arrays exactly. Types JSON cannot
|
|||
represent don't survive the trip: a `Date` comes back as a plain string, and a
|
||||
`Map` or `Set` collapses to `"{}"` before it ever reaches the attribute.
|
||||
|
||||
Such types are still first-class props — declare a real default of that type
|
||||
Such types are still first-class props. Declare a real default of that type
|
||||
(see the caution above) and override the converters. You can hand-write a
|
||||
textual form per prop, as `EventCard` above does for its `Date`; when several
|
||||
props need this, delegate to a serializer built for the job instead:
|
||||
[devalue](https://github.com/sveltejs/devalue) round-trips `Map`, `Set`,
|
||||
`Date`, `RegExp`, `BigInt`, `undefined`, and even cyclic references. One
|
||||
generic pair of overrides then covers every structured prop, present and
|
||||
future — here is `EventCard` rewritten with it, gaining a `Set` of tags along
|
||||
future. Here is `EventCard` rewritten with it, gaining a `Set` of tags along
|
||||
the way:
|
||||
|
||||
```js
|
||||
|
|
@ -213,12 +213,12 @@ class EventCard extends WebComponent {
|
|||
```
|
||||
|
||||
The two guards are asymmetric on purpose: `toAttribute` sees the live value,
|
||||
but `fromAttribute` only ever sees a string — so it consults the declared
|
||||
but `fromAttribute` only ever sees a string, so it consults the declared
|
||||
default to decide whether the attribute is devalue-encoded. `title` misses
|
||||
both guards and reflects as a plain string, exactly as before.
|
||||
|
||||
The encoded attribute is not as hand-friendly as a bespoke `when="2026-07-20"`,
|
||||
but it is still plain text — write it in markup with single quotes, since the
|
||||
but it is still plain text. Write it in markup with single quotes, since the
|
||||
payload contains double quotes:
|
||||
|
||||
```html
|
||||
|
|
@ -229,22 +229,22 @@ payload contains double quotes:
|
|||
></event-card>
|
||||
```
|
||||
|
||||
devalue is your component's dependency, not wcb's — the base class stays
|
||||
devalue is your component's dependency, not wcb's. The base class stays
|
||||
zero-dependency.
|
||||
|
||||
<Aside type="caution" title="Reassign, don't mutate">
|
||||
The props proxy only sees *assignments*. `this.props.tags.add('x')` mutates
|
||||
the Set behind the proxy's back — no reflection, no render, and the attribute
|
||||
the Set behind the proxy's back: no reflection, no render, and the attribute
|
||||
goes stale. Assign a fresh instance instead: `this.props.tags = new
|
||||
Set(this.props.tags).add('x')`.
|
||||
</Aside>
|
||||
|
||||
Converters cover any value with a sensible **textual form** — bespoke like an
|
||||
Converters cover any value with a sensible **textual form**: bespoke like an
|
||||
ISO date, or generic like devalue's encoding. A value with no textual form at
|
||||
all — a function, an element reference, a live handle like an
|
||||
`AbortController` — is not a converter problem: even devalue refuses it
|
||||
all (a function, an element reference, a live handle like an
|
||||
`AbortController`) is not a converter problem: even devalue refuses it
|
||||
(`Cannot stringify a function`), and it doesn't belong in `static props` in
|
||||
the first place. Keep it as a plain class property instead — see
|
||||
the first place. Keep it as a plain class property instead. See
|
||||
[Unobserved properties](#unobserved-properties) below. wcb warns once per
|
||||
class when a declared default is a `function` or `symbol` for exactly this
|
||||
reason.
|
||||
|
|
@ -253,7 +253,7 @@ reason.
|
|||
|
||||
See it live: [Compile-time prop types demo ↗](https://demo.webcomponent.io/examples/typed-props/) and [Typed props demo ↗](https://demo.webcomponent.io/examples/type-restore/)
|
||||
|
||||
By default `this.props` is a permissive `{ [name: string]: any }` map. In TypeScript you can get compile-time types on your declared props: declare the shape as a named type, pass it as the class type argument, and annotate `static props` with it — the type above, the defaults within:
|
||||
By default `this.props` is a permissive `{ [name: string]: any }` map. In TypeScript you can get compile-time types on your declared props: declare the shape as a named type, pass it as the class type argument, and annotate `static props` with it (the type above, the defaults within):
|
||||
|
||||
```ts
|
||||
type CozyButtonProps = {
|
||||
|
|
@ -283,11 +283,11 @@ Unions stay narrow with nothing to cast, and the annotation cuts both ways:
|
|||
the defaults themselves are checked against the type, so a missing key or a
|
||||
default outside the union is a compile error too.
|
||||
|
||||
This is types-only — the runtime is unchanged, and `static strictProps` still guards writes that come in from attributes at runtime. Omitting the type argument keeps the previous untyped behavior.
|
||||
This is types-only. The runtime is unchanged, and `static strictProps` still guards writes that come in from attributes at runtime. Omitting the type argument keeps the previous untyped behavior.
|
||||
|
||||
<Aside type="tip">
|
||||
You can also infer the type from the defaults instead of writing it out:
|
||||
declare them in a `const` and pass its `typeof` —
|
||||
declare them in a `const` and pass its `typeof`:
|
||||
`class CozyButton extends WebComponent<typeof props> { static props = props }`.
|
||||
Inferred values widen (`variant: 'primary'` types as plain `string`), so
|
||||
narrow with a cast where it matters:
|
||||
|
|
@ -299,18 +299,18 @@ This is types-only — the runtime is unchanged, and `static strictProps` still
|
|||
Everything declared in `static props` is observed and reflected: each key gets
|
||||
an attribute, writes trigger `render()` and `onChanges()`, and the default
|
||||
shows up in the DOM on first connect. That is the right contract for a
|
||||
component's public, DOM-facing API — and unnecessary for internal state.
|
||||
component's public, DOM-facing API, and unnecessary for internal state.
|
||||
|
||||
For state that doesn't belong in the DOM, use a plain class property. A
|
||||
`WebComponent` is still just a class extending `HTMLElement`, so ordinary
|
||||
properties work exactly as on any element and are invisible to the props
|
||||
machinery — no attribute, no observation, no automatic render:
|
||||
machinery (no attribute, no observation, no automatic render):
|
||||
|
||||
```js
|
||||
class DataTable extends WebComponent {
|
||||
static props = { compact: false } // public API: <data-table compact>
|
||||
|
||||
rows = [] // internal state — never becomes an attribute
|
||||
rows = [] // internal state, never becomes an attribute
|
||||
#controller = new AbortController() // non-serializable values are fine here
|
||||
|
||||
async onInit() {
|
||||
|
|
@ -330,8 +330,8 @@ Because nothing watches a plain property, call
|
|||
update the view.
|
||||
|
||||
The rule of thumb: `static props` is for values a consumer sets from markup or
|
||||
styles against with attribute selectors; a class property — public or
|
||||
`#private` — is for everything else: large data, functions, and handles like
|
||||
styles against with attribute selectors; a class property (public or
|
||||
`#private`) is for everything else: large data, functions, and handles like
|
||||
timers or `AbortController`s that could never round-trip through an attribute
|
||||
anyway. If you catch yourself thinking "I don't want this showing up as an
|
||||
attribute", that is the signal it should be a class property, not a prop.
|
||||
|
|
|
|||
|
|
@ -91,10 +91,10 @@ customElements.define('styled-elements', StyledElement)
|
|||
|
||||
### Composing several stylesheets
|
||||
|
||||
Pass an array to adopt more than one sheet. They are applied **in order**, so later entries win on equal specificity — put shared tokens or a base sheet first and per-component styles after it:
|
||||
Pass an array to adopt more than one sheet. They are applied **in order**, so later entries win on equal specificity. Put shared tokens or a base sheet first and per-component styles after it:
|
||||
|
||||
```js
|
||||
// tokens.js — shared across every component
|
||||
// tokens.js: shared across every component
|
||||
export const tokens = `
|
||||
:host {
|
||||
--cozy-radius: 6px;
|
||||
|
|
@ -131,4 +131,4 @@ class CozyBadge extends WebComponent {
|
|||
}
|
||||
```
|
||||
|
||||
A single string keeps working exactly as before — the array form is additive.
|
||||
A single string keeps working exactly as before. The array form is additive.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ This mental model attempts to reduce the cognitive complexity of authoring compo
|
|||
1. You can _optionally_ call this `render()` method at any point to trigger a render if you need (eg, if you have private unobserved properties that need to manually trigger a render)
|
||||
1. Overriding the `render()` function for handling a custom `template` is also possible. Here's an example of using `lit-html`: [View on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/ZEwNJBR?editors=1010)
|
||||
|
||||
See it live: [Templating demo ↗](https://demo.webcomponent.io/examples/templating/) for the two template kinds, and [Render reconciliation demo ↗](https://demo.webcomponent.io/examples/render-reconciliation/) for what an in-place re-render preserves — focus, caret position and an uncommitted input value all survive.
|
||||
See it live: [Templating demo ↗](https://demo.webcomponent.io/examples/templating/) for the two template kinds, and [Render reconciliation demo ↗](https://demo.webcomponent.io/examples/render-reconciliation/) for what an in-place re-render preserves: focus, caret position and an uncommitted input value all survive.
|
||||
|
||||
## Composing components
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ class CounterBoard extends WebComponent {
|
|||
Each nested component **owns the DOM it renders for itself**. When an outer
|
||||
component re-renders, the reconciler patches the props it passes down to a
|
||||
nested element (that is how data flows from parent to child) but never touches
|
||||
the element's own children — so a nested component keeps its rendered content
|
||||
the element's own children, so a nested component keeps its rendered content
|
||||
and any internal state even when an ancestor re-renders for an unrelated
|
||||
reason. Data flows down as attributes, so pass values a nested component can
|
||||
read back from an attribute: primitives, or objects/arrays that survive a
|
||||
|
|
|
|||
|
|
@ -3,21 +3,23 @@ title: 'Why would anyone use WCB?'
|
|||
slug: why
|
||||
---
|
||||
|
||||
The `WebComponent` base class gives a full component development experience at the lightest-weight possible: the minimum code to boost productivity.
|
||||
The `WebComponent` base class gives a full component development experience at the lightest weight possible: the minimum code to boost productivity.
|
||||
|
||||
The following are the main reasons WCB exist.
|
||||
WCB exists for five reasons:
|
||||
|
||||
1. **It is the cheapest runtime reactivity you can buy.** Smallest footprint for a full authoring experience — declarative templates, typed prop⇄attribute sync, lifecycle hooks, and state-preserving re-renders. Every alternative with comparable ergonomics costs 1.4×–5× more. If your budget is "a component on a mostly-static page", WCB fits where Lit and FAST are the heaviest thing on the wire.
|
||||
2. **Zero tooling, genuinely.** No compiler, no decorators, no build step: one `import` from a CDN in a `<script type="module">` works on current browsers. The whole mental model is `static props` + `template` + four hooks, and the shipped source is readable in one sitting — the entire library is smaller than most libraries' _documentation_ on their update scheduling.
|
||||
3. **Attribute-first reactivity is HTML-native.** Because props serialize to attributes, initial state can be rendered by _any_ server in plain HTML — no JS-framework SSR integration needed — and components stay inspectable/debuggable in devtools as ordinary attributes.
|
||||
1. **It is the cheapest runtime reactivity you can buy.** Smallest footprint for a full authoring experience: declarative templates, typed prop⇄attribute sync, lifecycle hooks, and state-preserving re-renders. Lit and FAST cost 2x-4.7x more on the wire (brotli-compressed). If your budget is "a component on a mostly-static page", WCB fits where Lit and FAST are the heaviest thing on the wire.
|
||||
2. **Zero tooling, genuinely.** No compiler, no decorators, no build step: one `import` from a CDN in a `<script type="module">` works on current browsers. The whole mental model is `static props` + `template` + four hooks, and the shipped source is readable in one sitting. The runtime is 2.6 kB brotli-compressed ([measured](/comparison/)).
|
||||
3. **Attribute-first reactivity is HTML-native.** Because props serialize to attributes, initial state can be rendered by _any_ server in plain HTML (no JS-framework SSR integration needed) and components stay inspectable/debuggable in devtools as ordinary attributes.
|
||||
4. **Light DOM by default.** Global stylesheets, forms, and third-party CSS just work; and the shadow DOM is one static field away when you want encapsulation.
|
||||
5. **The size gate is a governed value.** Every byte added must justify itself in the [size change log](https://github.com/ayo-run/wcb/blob/main/size-change-log.md), enforced by `size-limit` budgets in CI. Smart diffing is the largest single addition in the project's history and it cost 0.42 kB.
|
||||
|
||||
**When WCB is the wrong choice** — pick honestly:
|
||||
## When WCB is the wrong choice
|
||||
|
||||
Pick honestly:
|
||||
|
||||
- **Reorderable lists of stateful items**: patching is positional, not keyed; preserved focus/animation follows the position, not the item. Lit's `repeat` or FAST handle this correctly.
|
||||
- **High-frequency updates on large trees**: WCB re-renders synchronously per prop write and diffs the whole vnode tree; Lit/FAST update only the affected bindings on a batched schedule.
|
||||
- **SSR with client hydration**: Lit has a real hydration story; Elena is built around progressive enhancement. WCB renders client-side after connect.
|
||||
- **Big-team, long-horizon design systems**: Google (Lit) and Microsoft (FAST) have big backing and ecosystems; while Elena is purpose-built for design systems.
|
||||
- **SSR with client hydration**: WCB renders on the client after `connectedCallback`, so there's no hydration step. If you need server-rendered markup that hydrates in place, use Lit (`@lit-labs/ssr`).
|
||||
- **Big-team, long-horizon design systems**: WCB is a solo-maintained library with a deliberately small surface. For large-org backing and ecosystem, choose Lit (Google) or FAST (Microsoft, powers Fluent UI).
|
||||
|
||||
For the measured numbers and the capability-by-capability breakdown behind these claims, see [WCB & similar libraries](/comparison/).
|
||||
|
|
|
|||
Loading…
Reference in a new issue