Compare commits
40 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 975b045a0a | |||
| 20e31e4796 | |||
| 4b4cbc6cc8 | |||
| 5fc24633f5 | |||
| 37552b4d65 | |||
| d230d962ec | |||
| 50a21b817d | |||
| e3fb0254c7 | |||
| 561294f72c | |||
| b26cb30b9b | |||
| f290e049bd | |||
| 1b3e251a9d | |||
| 136343962d | |||
| c7d87086a2 | |||
| 56cb1de7db | |||
| 93e4248359 | |||
| be9c6b7cf4 | |||
| fa8d1df36e | |||
| 27377746ef | |||
| 133881a127 | |||
| 2da6cd5afc | |||
| 861310c6bf | |||
| 737ef54763 | |||
| 5635e6eb4a | |||
| a83b4893ab | |||
| a2c232a3cb | |||
| 16b1bf00b4 | |||
| 3a97b6356c | |||
| 8d5ad928e8 | |||
| 231c62531b | |||
| e62d613203 | |||
| e0ec0fcb7c | |||
| b0e48e404d | |||
| f320e845bb | |||
| b5b7008abf | |||
| 02ca084197 | |||
| 6af7598973 | |||
| ef20daa140 | |||
| ce30b114a2 | |||
| 038674a74e |
135 changed files with 8798 additions and 370 deletions
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "demo",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["-F", "demo", "dev", "--port", "5199", "--strictPort"],
|
||||
"port": 5199
|
||||
}
|
||||
]
|
||||
}
|
||||
49
.github/workflows/nightly.yml
vendored
Normal file
49
.github/workflows/nightly.yml
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
name: Nightly E2E
|
||||
|
||||
# The full cross-browser e2e matrix (Chromium + Firefox + WebKit) is heavy to
|
||||
# set up (browser downloads + system deps), so it runs once a day instead of on
|
||||
# every push/PR. Push/PR CI keeps a fast Chromium-only e2e gate (test.yml).
|
||||
on:
|
||||
schedule:
|
||||
# 06:00 UTC daily
|
||||
- cron: '0 6 * * *'
|
||||
workflow_dispatch: # allow manual runs from the Actions tab
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e-all:
|
||||
name: E2E (Chromium + Firefox + WebKit)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Firefox/WebKit need system deps that `--with-deps` installs via apt.
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm exec playwright install --with-deps chromium firefox webkit
|
||||
- run: pnpm test:e2e:all
|
||||
|
||||
size:
|
||||
name: Size limit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# `pnpm size-limit` builds `dist/` (tsc + esbuild) and checks each entry
|
||||
# against the byte budgets declared in package.json `size-limit`.
|
||||
- run: pnpm size-limit
|
||||
44
.github/workflows/release.yml
vendored
44
.github/workflows/release.yml
vendored
|
|
@ -1,16 +1,16 @@
|
|||
name: Release
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
changelog:
|
||||
name: Changelog
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
|
|
@ -24,3 +24,39 @@ jobs:
|
|||
- run: npx changelogithub
|
||||
env:
|
||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: pnpm
|
||||
|
||||
- name: Ensure npm supports trusted publishing
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
VERSION="$(node -p "require('./package.json').version")"
|
||||
if [[ "$VERSION" == *-* ]]; then
|
||||
TAG="${VERSION#*-}"; TAG="${TAG%%.*}"
|
||||
else
|
||||
TAG="latest"
|
||||
fi
|
||||
echo "Publishing $VERSION to dist-tag '$TAG'"
|
||||
npm publish --access public --tag "$TAG"
|
||||
|
|
|
|||
50
.github/workflows/test.yml
vendored
Normal file
50
.github/workflows/test.yml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Cancel superseded runs on the same ref (e.g. new pushes to a PR).
|
||||
concurrency:
|
||||
group: tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: Unit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm test
|
||||
|
||||
e2e:
|
||||
name: E2E
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Chromium only on push/PR — the full Firefox/WebKit matrix runs nightly
|
||||
# (.github/workflows/nightly.yml).
|
||||
- name: Install Playwright Chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- run: pnpm test:e2e
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -15,4 +15,13 @@ dist/
|
|||
.eslintcache
|
||||
|
||||
# vitest
|
||||
coverage
|
||||
coverage
|
||||
|
||||
# storybook (all generated: `pnpm -F storybook analyze` / `build`)
|
||||
storybook/storybook-static
|
||||
storybook/custom-elements.json
|
||||
|
||||
# VS Code HTML custom data, emitted to the repo root by the same analyze run
|
||||
# so `html.customData` in .vscode/settings.json can resolve it
|
||||
vscode.html-custom-data.json
|
||||
vscode.css-custom-data.json
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
npm run lint --cache
|
||||
npm run test
|
||||
npm run build
|
||||
npx size-limit
|
||||
# Everything runs at commit time. This repo auto-pushes to multiple remotes
|
||||
# (github, forgejo, sourcehut — see post-commit), so a pre-push hook would
|
||||
# re-run these once per remote; keeping them here runs them once per commit.
|
||||
# The full cross-browser e2e matrix still runs nightly in CI. Order: cheap → expensive.
|
||||
pnpm exec lint-staged
|
||||
pnpm test
|
||||
pnpm test:e2e
|
||||
pnpm size-limit
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
node_modules/
|
||||
examples/
|
||||
demo/
|
||||
assets/
|
||||
src/
|
||||
.vscode/
|
||||
|
|
|
|||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"js/ts.implicitProjectConfig.checkJs": true,
|
||||
"editor.formatOnSave": true
|
||||
"editor.formatOnSave": true,
|
||||
"html.customData": ["./vscode.html-custom-data.json"]
|
||||
}
|
||||
|
|
|
|||
22
AGENTS.md
22
AGENTS.md
|
|
@ -16,8 +16,13 @@ This file provides guidance to AI coding agents when working with code in this r
|
|||
- `pnpm build` — `tsc` emits `.d.ts` types, then `copy:source` uses esbuild to minify/bundle `src/*.js` + `src/utils/*` into `dist/` as ESM. **`dist/` is what gets published; `src/` is shipped as the readable source.**
|
||||
- `pnpm size-limit` — enforces the byte budgets declared in `package.json` `size-limit` (e.g. `WebComponent.js` ≤ 1.2 KB). Keep additions tiny; this gate is a core project value.
|
||||
- `pnpm docs` — run the Astro docs site (`docs/` workspace) locally.
|
||||
- `pnpm demo` — run the Vite examples showcase (`demo/` workspace) locally; `pnpm demo:build` builds it.
|
||||
- `pnpm -F storybook dev` — run the Storybook testing ground (`storybook/`) locally; `pnpm -F storybook build` builds it. Both run `cem analyze` first to regenerate `custom-elements.json`. The CEM config imports the plugin from the **published subpath** (`web-component-base/cem-plugin` → `dist/`), so run `pnpm build` at the root first.
|
||||
- `pnpm test:e2e` — run the browser e2e specs (`test/e2e/`) via Vitest browser mode (Playwright). Defaults to Chromium; `pnpm test:e2e:firefox` / `:webkit` / `:all` target the other engines (the `E2E_BROWSERS` env var, comma-separated, selects instances). `pnpm test:all` runs unit + default e2e.
|
||||
|
||||
pnpm is mandatory (a `preinstall` `only-allow pnpm` guard enforces it). This is a pnpm workspace; the only sub-package is `docs/`.
|
||||
pnpm is mandatory (a `preinstall` `only-allow pnpm` guard enforces it). This is a pnpm workspace with three sub-packages: `docs/` (Astro docs site), `demo/` (Vite examples showcase, which consumes the root `web-component-base` package as a `workspace:*` dependency), and `storybook/` (Storybook testing ground — it writes no components of its own, it builds stories over the `demo/examples/` components so the CEM plugin is exercised against real ones). The runnable example sources live in `demo/examples/` and import the package by name (`web-component-base`), not via relative `src/` paths.
|
||||
|
||||
The demo shares one app shell: `demo/shell.css` (design tokens + component styles, linked by the landing page and every example) and `demo/shell.js` (defines `<app-header>` — itself a `WebComponent` — and `prepend`s it to each example page; `prepend` never reparents the example's own elements, so their lifecycle is untouched). Every example page links both. Keep new examples consistent by adding `<link rel="stylesheet" href="../../shell.css" />` and `<script type="module" src="../../shell.js"></script>`.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -28,12 +33,23 @@ Everything is in `src/` (entry point `src/index.js` re-exports `WebComponent` an
|
|||
- The constructor deep-clones `static props`, records each prop's `typeof` in a private `#typeMap`, reflects defaults to attributes, and wraps the props object in a **Proxy** (`#handler`). Writing `this.props.someProp = x` serializes the value and calls `setAttribute` (kebab-case), which triggers `attributeChangedCallback` → `render()`. That attribute-write-driven cycle *is* the reactivity model — there is no separate scheduler.
|
||||
- The Proxy enforces type stability against `#typeMap` and throws `TypeError` on a type change.
|
||||
- Lifecycle hooks authors may override: `onInit()` (connected), `afterViewInit()` (after first render), `onDestroy()` (disconnected), `onChanges({property, previousValue, currentValue})` (attribute changed). Note `onChanges` currently receives the **kebab-case** `property`.
|
||||
- `template` is a read-only getter (assigning to it throws). `render()` branches on its type: a **string** template is assigned to `innerHTML` directly; an **object** template (a vnode tree from `html`) is diffed against the previous tree with `JSON.stringify` and, on change, rebuilt via `createElement` + `replaceChildren`. `render()` can be called manually or overridden entirely (e.g. to delegate to `lit-html`).
|
||||
- `template` is a read-only getter (assigning to it throws). `render()` branches on its type: a **string** template is assigned to `innerHTML` directly; an **object** template (a vnode tree from `html`) is diffed against the previous tree with `JSON.stringify` and, on change, built via `createElement` + `replaceChildren` on the *first* render and **reconciled in place** (`patchChildren`, see `src/utils/patch.mjs`) on every render after that, so focus/caret/uncommitted input values survive. `render()` can be called manually or overridden entirely (e.g. to delegate to `lit-html`).
|
||||
- `static styles` are applied as a constructable `CSSStyleSheet` via `adoptedStyleSheets`, which **only works with a shadow root** — set `static shadowRootInit` to opt into shadow DOM.
|
||||
|
||||
- **`src/html.js`** — the `html` tagged-template function. It is a bundled/minified copy of [`htm`](https://github.com/developit/htm) bound to a hyperscript `h(type, props, children)` that produces plain `{type, props, children}` vnodes. License lives in `vendors/htm/`.
|
||||
|
||||
- **`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), and `create-element` (turns a vnode tree into real DOM nodes, resolving props to DOM properties/attributes and applying `style` objects). `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
|
||||
|
||||
|
|
|
|||
32
README.md
32
README.md
|
|
@ -1,7 +1,21 @@
|
|||
# Web Component Base
|
||||
|
||||
> [!Note]
|
||||
> **What's next: correctness fixes.** Thanks to feedback from teams building on WCB, our immediate focus is a set of state-corruption fixes: no attribute writes in the constructor, correct handling of empty-string and removed attributes (so `render()`/`onChanges` are never silently skipped), removing the props proxy's first-write type lock, and safe cloning of `static props` defaults. Detailed issues will follow.
|
||||
> **HTML boolean attributes semantics shipped in v6** - 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/#boolean-props) for details.
|
||||
|
||||
[](https://www.npmjs.com/package/web-component-base)
|
||||
[](https://www.npmjs.com/package/web-component-base)
|
||||
|
|
@ -10,17 +24,23 @@
|
|||
|
||||
🤷♂️ zero-dependency, 🤏 tiny JS base class for creating reactive [custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_Components/Using_custom_elements) easily ✨
|
||||
|
||||

|
||||
This is the base class used for web components in Ayo's projects, primarily [cozy-games](https://git.ayo.run/ayo/cozy-games), [mcfly](https://git.ayo.run/ayo/mcfly/), his [personal site](https://ayo.ayco.io), his [blog](https://ayos.blog), and [others](https://git.ayo.run/ayo).
|
||||
|
||||
Next actions:
|
||||
|
||||
1. [Read the docs](https://webcomponent.io)
|
||||
2. [View demos with code examples](https://demo.webcomponent.io/)
|
||||
3. [Play with a demo on CodePen](https://codepen.io/ayo-run/pen/ZEwoNOz?editors=1010).
|
||||
|
||||

|
||||
|
||||
When you extend the `WebComponent` class for your component, you only have to define the `template` and `properties`. Any change in any property value will automatically cause just the component UI to render.
|
||||
|
||||
The result is a reactive UI on property changes.
|
||||
|
||||
## Links
|
||||
When the `template` is an `html` tagged template (a vnode tree), a re-render **patches the existing DOM in place** instead of rebuilding it: elements of the same tag are reused, only changed props/attributes/text are touched, and leftover nodes are trimmed. Transient DOM state on the rendered nodes therefore survives a re-render — focus, caret/selection, an `<input>`'s uncommitted value, `:hover`, and running CSS transitions. A controlled text field can write every keystroke back to a prop without losing focus.
|
||||
|
||||
- [Documentation](https://webcomponent.io)
|
||||
- [Read a blog explaining the reactivity](https://ayos.blog/reactive-custom-elements-with-html-dataset/)
|
||||
- [View demo on CodePen](https://codepen.io/ayoayco-the-styleful/pen/ZEwoNOz?editors=1010)
|
||||
Patching is **index-based and non-keyed**: list items are matched by position, not identity. The resulting DOM is always correct, but preserved state follows the _position_ rather than the _item_ — remove the first row of a list and the node that held row 1 is rewritten to hold row 2, so a caret, an uncommitted value, or a running transition stays where it was while the content shifts under it. Fixed-order lists and append/remove-at-the-end are unaffected; reorderable lists of focusable or animated items are the sharp edge. A `key` prop for identity-based matching is not implemented.
|
||||
|
||||
## Want to get in touch?
|
||||
|
||||
|
|
|
|||
65
demo/examples/attribute-converters/index.html
Normal file
65
demo/examples/attribute-converters/index.html
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Custom attribute converters</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;
|
||||
}
|
||||
event-card {
|
||||
display: block;
|
||||
padding: 0.4em 1em;
|
||||
border: 2px solid #8884;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Custom attribute converters</h1>
|
||||
<p>
|
||||
Override <code>toAttribute</code> and <code>fromAttribute</code> when a
|
||||
prop needs its own serialization, and delegate the rest to
|
||||
<code>super</code>. Both take the <strong>camelCase prop key</strong>,
|
||||
matching your <code>static props</code> declaration.
|
||||
</p>
|
||||
|
||||
<h2>A Date prop and an Array prop</h2>
|
||||
<p>
|
||||
<code>when</code> is a real <code>Date</code> and <code>tags</code> is a
|
||||
real <code>Array</code> — reflected as an ISO date and a comma-separated
|
||||
list. Each button reports the attributes the conversion produced.
|
||||
</p>
|
||||
<converter-demo></converter-demo>
|
||||
|
||||
<h2>Parsed from markup</h2>
|
||||
<p>
|
||||
Attributes written from outside the component are parsed through
|
||||
<code>fromAttribute</code>, so these mount with a real
|
||||
<code>Date</code> and a real <code>Array</code>:
|
||||
</p>
|
||||
<event-card when="2026-01-02" tags="alpha,beta" title="From markup"></event-card>
|
||||
|
||||
<h2>Why lossy conversion is safe</h2>
|
||||
<p>
|
||||
Reflection does <strong>not</strong> round-trip: when you assign a prop,
|
||||
wcb reflects it but never parses the attribute back — the value you
|
||||
assigned is already the source of truth. That is why
|
||||
<code>when</code> can keep its full timestamp while the attribute carries
|
||||
only the date. <code>render()</code> and <code>onChanges</code> still fire
|
||||
as normal.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
127
demo/examples/attribute-converters/index.js
Normal file
127
demo/examples/attribute-converters/index.js
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { WebComponent, html } from 'web-component-base'
|
||||
|
||||
/**
|
||||
* `toAttribute` / `fromAttribute` are the escape hatch for props whose
|
||||
* reflection doesn't fit the built-in rules. Override the props you care
|
||||
* about and hand the rest to `super`.
|
||||
*
|
||||
* Two things worth noticing in this example:
|
||||
*
|
||||
* - `when` is a `Date`, so it declares a `Date` default — the prop's runtime
|
||||
* type comes from the default's `typeof`, and the props proxy would reject
|
||||
* a Date written to a prop declared as a string.
|
||||
* - `toAttribute` is deliberately lossy (it drops the time of day), which is
|
||||
* safe: a prop write is not parsed back through `fromAttribute`, so
|
||||
* `props.when` keeps the exact value you assigned.
|
||||
*/
|
||||
class EventCard extends WebComponent {
|
||||
static props = {
|
||||
when: new Date('2026-07-20T09:30:00Z'),
|
||||
tags: [],
|
||||
title: 'Release day',
|
||||
}
|
||||
// renders into a shadow root so a parent's re-render can't reconcile away
|
||||
// the children this component renders for itself
|
||||
static shadowRootInit = { mode: 'open' }
|
||||
|
||||
toAttribute(name, value) {
|
||||
// ISO date only — the attribute is a summary, not the whole value
|
||||
if (name === 'when') return value.toISOString().slice(0, 10)
|
||||
// a list reflects as a readable comma-separated attribute...
|
||||
if (name === 'tags') return value.length ? value.join(',') : null
|
||||
return super.toAttribute(name, value)
|
||||
}
|
||||
|
||||
fromAttribute(name, value) {
|
||||
if (name === 'when') return new Date(`${value}T00:00:00Z`)
|
||||
// ...and parses back the same way
|
||||
if (name === 'tags') return value.split(',').filter(Boolean)
|
||||
return super.fromAttribute(name, value)
|
||||
}
|
||||
|
||||
get template() {
|
||||
const { when, tags, title } = this.props
|
||||
return html`
|
||||
<h3>${title}</h3>
|
||||
<p>
|
||||
prop <code>when</code> (a real Date):
|
||||
<strong>${when.toISOString()}</strong>
|
||||
</p>
|
||||
<p>
|
||||
prop <code>tags</code> (a real Array):
|
||||
<strong>${JSON.stringify(tags)}</strong>
|
||||
</p>
|
||||
`
|
||||
}
|
||||
}
|
||||
customElements.define('event-card', EventCard)
|
||||
|
||||
/**
|
||||
* Panel that drives the card and shows the attribute each conversion produces.
|
||||
*/
|
||||
class ConverterDemo extends WebComponent {
|
||||
static props = { log: '' }
|
||||
|
||||
afterViewInit() {
|
||||
this.card = this.querySelector('event-card')
|
||||
this.#report('initial mount')
|
||||
}
|
||||
|
||||
#report(action) {
|
||||
const el = this.card
|
||||
if (!el) return
|
||||
const attr = (name) =>
|
||||
el.hasAttribute(name) ? `${name}="${el.getAttribute(name)}"` : '(absent)'
|
||||
this.props.log = `${action} → ${attr('when')} · ${attr('tags')}`
|
||||
}
|
||||
|
||||
#setPreciseDate() {
|
||||
// the time of day survives on the prop even though the attribute drops it
|
||||
this.card.props.when = new Date('2026-12-25T18:45:12Z')
|
||||
this.#report('props.when = 2026-12-25T18:45:12Z')
|
||||
}
|
||||
|
||||
#setTags(tags) {
|
||||
this.card.props.tags = tags
|
||||
this.#report(`props.tags = ${JSON.stringify(tags)}`)
|
||||
}
|
||||
|
||||
#writeAttribute() {
|
||||
// an outside write *is* parsed through fromAttribute
|
||||
this.card.setAttribute('tags', 'html,css,js')
|
||||
this.#report('setAttribute("tags", "html,css,js")')
|
||||
}
|
||||
|
||||
get template() {
|
||||
return html`
|
||||
<event-card></event-card>
|
||||
|
||||
<p class="log"><code>${this.props.log}</code></p>
|
||||
|
||||
<div class="row">
|
||||
<button onclick=${() => this.#setPreciseDate()}>
|
||||
props.when = a precise Date
|
||||
</button>
|
||||
<button onclick=${() => this.#setTags(['web', 'components'])}>
|
||||
props.tags = ['web', 'components']
|
||||
</button>
|
||||
<button onclick=${() => this.#setTags([])}>
|
||||
props.tags = [] (removes the attribute)
|
||||
</button>
|
||||
<button onclick=${() => this.#writeAttribute()}>
|
||||
setAttribute("tags", "html,css,js")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="hint">
|
||||
Setting <code>tags</code> to an empty array makes
|
||||
<code>toAttribute</code> return <code>null</code>, which
|
||||
<strong>removes the attribute</strong> — the same mechanism that makes a
|
||||
<code>false</code> boolean an absent attribute. The last button writes
|
||||
the attribute from outside, which <em>is</em> parsed back through
|
||||
<code>fromAttribute</code> into a real array.
|
||||
</p>
|
||||
`
|
||||
}
|
||||
}
|
||||
customElements.define('converter-demo', ConverterDemo)
|
||||
26
demo/examples/attribute-lifecycle/index.html
Normal file
26
demo/examples/attribute-lifecycle/index.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Attribute lifecycle</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>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Attribute lifecycle: empty string, removal, markup wins</h1>
|
||||
|
||||
<h2>Default (no authored attribute)</h2>
|
||||
<p>Shows the declared default; try the buttons.</p>
|
||||
<attr-demo id="from-default"></attr-demo>
|
||||
|
||||
<h2>Authored attribute wins over the default</h2>
|
||||
<p>
|
||||
<code><attr-demo label="from markup"></code> — the default is not
|
||||
applied because the attribute is already present.
|
||||
</p>
|
||||
<attr-demo id="from-markup" label="from markup"></attr-demo>
|
||||
</body>
|
||||
</html>
|
||||
36
demo/examples/attribute-lifecycle/index.js
Normal file
36
demo/examples/attribute-lifecycle/index.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { WebComponent, html } from 'web-component-base'
|
||||
|
||||
/**
|
||||
* v5 attribute-change correctness:
|
||||
*
|
||||
* - `label=""` (empty string) stays the string `''` — it is no longer
|
||||
* coerced to boolean `true` / echoed as `"true"`.
|
||||
* - `removeAttribute('label')` resets the prop to its **declared default**
|
||||
* instead of writing `null` and throwing before render().
|
||||
* - an attribute authored in markup **wins** over the default: defaults are
|
||||
* reflected on connect and skip any attribute already present.
|
||||
*/
|
||||
export class AttrDemo extends WebComponent {
|
||||
static props = {
|
||||
label: 'default-label',
|
||||
}
|
||||
|
||||
setValue = () => this.setAttribute('label', 'hello')
|
||||
setEmpty = () => this.setAttribute('label', '')
|
||||
remove = () => this.removeAttribute('label')
|
||||
|
||||
get template() {
|
||||
const value = this.props.label
|
||||
return html`
|
||||
<p>label prop: <code class="value">${JSON.stringify(value)}</code></p>
|
||||
<p>typeof: <code class="type">${typeof value}</code></p>
|
||||
<button class="set-value" onclick=${this.setValue}>set "hello"</button>
|
||||
<button class="set-empty" onclick=${this.setEmpty}>set "" (empty)</button>
|
||||
<button class="remove" onclick=${this.remove}>
|
||||
removeAttribute → default
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('attr-demo', AttrDemo)
|
||||
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)
|
||||
31
demo/examples/constructed-styles/index.html
Normal file
31
demo/examples/constructed-styles/index.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Constructable styles</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>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Constructable styles</h1>
|
||||
<p>
|
||||
<code>static styles</code> is adopted into the shadow root as a
|
||||
constructable stylesheet. It takes a single string, or an array adopted in
|
||||
order.
|
||||
</p>
|
||||
|
||||
<h2>A single string</h2>
|
||||
<styled-elements type="warn" condition></styled-elements>
|
||||
|
||||
<h2>An array of sheets</h2>
|
||||
<p>
|
||||
Shared tokens, then a ready-made <code>CSSStyleSheet</code>, then local
|
||||
styles — so a design system composes a base sheet with per-component CSS
|
||||
instead of inlining the base in every component.
|
||||
</p>
|
||||
<composed-styles></composed-styles>
|
||||
</body>
|
||||
</html>
|
||||
73
demo/examples/constructed-styles/index.js
Normal file
73
demo/examples/constructed-styles/index.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// @ts-check
|
||||
import { WebComponent, html } from 'web-component-base'
|
||||
|
||||
class StyledElements extends WebComponent {
|
||||
static shadowRootInit = {
|
||||
mode: 'open',
|
||||
}
|
||||
|
||||
static styles = `
|
||||
div {
|
||||
background-color: yellow;
|
||||
border: 1px solid black;
|
||||
padding: 1em;
|
||||
|
||||
p {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
get template() {
|
||||
return html`
|
||||
<div>
|
||||
<p>Wow!?</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('styled-elements', StyledElements)
|
||||
|
||||
// `static styles` also takes an array, adopted in order — a shared base sheet
|
||||
// composed with per-component styles, instead of inlining the base everywhere.
|
||||
const tokens = `
|
||||
:host {
|
||||
--demo-accent: rebeccapurple;
|
||||
--demo-radius: 6px;
|
||||
}
|
||||
`
|
||||
|
||||
// entries may be strings or ready-made CSSStyleSheet objects; a sheet is
|
||||
// adopted as-is, so one instance can be shared by many components
|
||||
const base = new CSSStyleSheet()
|
||||
base.replaceSync(`
|
||||
div {
|
||||
border: 2px solid var(--demo-accent);
|
||||
border-radius: var(--demo-radius);
|
||||
padding: 1em;
|
||||
}
|
||||
`)
|
||||
|
||||
class ComposedStyles extends WebComponent {
|
||||
static shadowRootInit = {
|
||||
mode: 'open',
|
||||
}
|
||||
|
||||
static styles = [
|
||||
tokens,
|
||||
base,
|
||||
// last one wins on equal specificity
|
||||
`p { color: var(--demo-accent); font-weight: 600; }`,
|
||||
]
|
||||
|
||||
get template() {
|
||||
return html`
|
||||
<div>
|
||||
<p>Three sheets: tokens, a shared CSSStyleSheet, and local styles.</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('composed-styles', ComposedStyles)
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { html, WebComponent } from '../../src/index.js'
|
||||
//@ts-check
|
||||
import { html, WebComponent } from 'web-component-base'
|
||||
|
||||
export class BooleanPropTest extends WebComponent {
|
||||
static props = {
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// @ts-check
|
||||
import { WebComponent, html } from '../../src/index.js'
|
||||
import { WebComponent, html } from 'web-component-base'
|
||||
|
||||
export class Counter extends WebComponent {
|
||||
static props = {
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// @ts-check
|
||||
import { html, WebComponent } from '../../src/index.js'
|
||||
import { html, WebComponent } from 'web-component-base'
|
||||
|
||||
export class HelloWorld extends WebComponent {
|
||||
static props = {
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// @ts-check
|
||||
import { html, WebComponent } from '../../src/index.js'
|
||||
import { html, WebComponent } from 'web-component-base'
|
||||
|
||||
class SimpleText extends WebComponent {
|
||||
clickCallback() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { WebComponent, html } from '../../src/index.js'
|
||||
import { WebComponent, html } from 'web-component-base'
|
||||
|
||||
class Toggle extends WebComponent {
|
||||
static props = {
|
||||
|
|
@ -3,12 +3,15 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WC demo</title>
|
||||
<title>Kitchen sink</title>
|
||||
<script type="module" src="./HelloWorld.mjs"></script>
|
||||
<script type="module" src="./SimpleText.mjs"></script>
|
||||
<script type="module" src="./BooleanPropTest.mjs"></script>
|
||||
<script type="module" src="./Counter.mjs"></script>
|
||||
<script type="module" src="./Toggle.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>
|
||||
</head>
|
||||
<body>
|
||||
<my-toggle></my-toggle>
|
||||
21
demo/examples/just-parts/index.html
Normal file
21
demo/examples/just-parts/index.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Using just some parts</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>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Using the <code>html</code> tag + <code>createElement</code> directly</h1>
|
||||
<p>
|
||||
<code>my-quote</code> extends the vanilla <code>HTMLElement</code> — not
|
||||
<code>WebComponent</code> — yet still builds its DOM from a tagged
|
||||
template.
|
||||
</p>
|
||||
<my-quote></my-quote>
|
||||
</body>
|
||||
</html>
|
||||
26
demo/examples/just-parts/index.js
Normal file
26
demo/examples/just-parts/index.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { html } from 'web-component-base/html'
|
||||
import { createElement } from 'web-component-base/utils'
|
||||
|
||||
/**
|
||||
* You don't have to extend `WebComponent` to use its parts. The `html` tag and
|
||||
* the `createElement` util work on a plain `HTMLElement`, so you can build the
|
||||
* reactive-template behavior into your own classes.
|
||||
*/
|
||||
class MyQuote extends HTMLElement {
|
||||
connectedCallback() {
|
||||
let count = 0
|
||||
const el = createElement(
|
||||
html`<button
|
||||
id="quote-btn"
|
||||
onClick=${(e) => {
|
||||
e.target.textContent = `clicked ${++count}`
|
||||
}}
|
||||
>
|
||||
click me
|
||||
</button>`
|
||||
)
|
||||
this.appendChild(el)
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('my-quote', MyQuote)
|
||||
27
demo/examples/lifecycle-order/index.html
Normal file
27
demo/examples/lifecycle-order/index.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lifecycle order & buffering</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>
|
||||
</head>
|
||||
<body>
|
||||
<h1>onInit runs before the first render</h1>
|
||||
|
||||
<h2>With an authored attribute</h2>
|
||||
<p>
|
||||
<code><lifecycle-order label="authored"></code> — note that
|
||||
<code>onInit</code> already sees <code>"authored"</code>, the single
|
||||
<code>render</code> runs after it, and the pre-connect change is not
|
||||
replayed through <code>onChanges</code>.
|
||||
</p>
|
||||
<lifecycle-order id="authored" label="authored"></lifecycle-order>
|
||||
|
||||
<h2>Without an authored attribute (uses the default)</h2>
|
||||
<lifecycle-order id="default"></lifecycle-order>
|
||||
</body>
|
||||
</html>
|
||||
57
demo/examples/lifecycle-order/index.js
Normal file
57
demo/examples/lifecycle-order/index.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { WebComponent, html } from 'web-component-base'
|
||||
|
||||
/**
|
||||
* v5 buffering guarantee. Per the Custom Elements spec, an element upgraded
|
||||
* with authored attributes (`<lifecycle-order label="authored">`) fires
|
||||
* `attributeChangedCallback` **before** `connectedCallback`. Taken literally
|
||||
* that means `render()` could run before `onInit()`.
|
||||
*
|
||||
* WebComponent buffers pre-connect attribute changes: the prop value is applied
|
||||
* immediately (so `this.props` is correct inside `onInit`), but the `render()`
|
||||
* and `onChanges()` side effects are deferred until after `onInit`. The order
|
||||
* on connect is always: onInit → render → afterViewInit, and pre-connect
|
||||
* changes are NOT replayed through onChanges.
|
||||
*
|
||||
* This component records the order it observes and surfaces it in the page.
|
||||
*/
|
||||
export class LifecycleOrder extends WebComponent {
|
||||
static props = {
|
||||
label: 'default',
|
||||
}
|
||||
|
||||
#log = []
|
||||
|
||||
onInit() {
|
||||
// props already reflect any authored attribute here, before the first render
|
||||
this.#log.push(`onInit (props.label = "${this.props.label}")`)
|
||||
}
|
||||
|
||||
render() {
|
||||
this.#log.push(`render (props.label = "${this.props.label}")`)
|
||||
super.render()
|
||||
}
|
||||
|
||||
onChanges(changes) {
|
||||
this.#log.push(
|
||||
`onChanges (${changes.attribute} → "${changes.currentValue}")`
|
||||
)
|
||||
}
|
||||
|
||||
afterViewInit() {
|
||||
this.#log.push('afterViewInit')
|
||||
const list = document.createElement('ol')
|
||||
list.className = 'lifecycle-log'
|
||||
for (const line of this.#log) {
|
||||
const li = document.createElement('li')
|
||||
li.textContent = line
|
||||
list.appendChild(li)
|
||||
}
|
||||
this.after(list)
|
||||
}
|
||||
|
||||
get template() {
|
||||
return html`<p>label: ${this.props.label}</p>`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('lifecycle-order', LifecycleOrder)
|
||||
61
demo/examples/nested-composition/index.html
Normal file
61
demo/examples/nested-composition/index.html
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nested composition</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>
|
||||
.board {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 0.5em;
|
||||
padding: 1em;
|
||||
max-width: 32em;
|
||||
}
|
||||
.board header {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.75em;
|
||||
align-items: center;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
.row-name {
|
||||
min-width: 5em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Nested composition</h1>
|
||||
<p class="lede">
|
||||
A component within a component within a component. Each
|
||||
<code><tick-counter></code> lives inside a
|
||||
<code><counter-row></code>, which lives inside a
|
||||
<code><counter-board></code> — three levels, all light DOM.
|
||||
</p>
|
||||
|
||||
<ol>
|
||||
<li>Click a few counters so their counts differ.</li>
|
||||
<li>
|
||||
Click <strong>Rename board</strong>. That changes only the board's
|
||||
title and re-renders the <em>root</em> component.
|
||||
</li>
|
||||
<li>
|
||||
The counts stay exactly as you left them. A nested component owns the
|
||||
DOM it renders for itself; an ancestor re-render patches props down but
|
||||
never trims the inner subtree.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<counter-board></counter-board>
|
||||
</body>
|
||||
</html>
|
||||
76
demo/examples/nested-composition/index.js
Normal file
76
demo/examples/nested-composition/index.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// @ts-check
|
||||
import { WebComponent, html } from 'web-component-base'
|
||||
|
||||
/**
|
||||
* Composition: a component within a component within a component.
|
||||
*
|
||||
* The three classes below nest three levels deep, all rendering to light DOM.
|
||||
* The point of interest is what happens to the inner subtrees when an *outer*
|
||||
* component re-renders for a reason unrelated to them.
|
||||
*
|
||||
* A parent's vnode never describes what a nested component rendered for
|
||||
* itself — `<tick-counter></tick-counter>` is empty in the row's template even
|
||||
* though the counter fills itself with a button and a count. The in-place
|
||||
* reconciler therefore treats a nested custom element as opaque: it patches the
|
||||
* element's props (so data still flows down) but leaves the element's own
|
||||
* children alone. Reconciling them would trim away the child's rendered content
|
||||
* on every ancestor re-render and leave it blank until it next re-rendered on
|
||||
* its own.
|
||||
*/
|
||||
|
||||
/** Level 3 (leaf): owns its own count entirely — no prop feeds it. */
|
||||
class TickCounter extends WebComponent {
|
||||
static props = { label: 'tick' }
|
||||
#count = 0
|
||||
get template() {
|
||||
return html`
|
||||
<button onclick=${() => this.#bump()}>
|
||||
${this.props.label}: <strong class="count">${this.#count}</strong>
|
||||
</button>
|
||||
`
|
||||
}
|
||||
#bump() {
|
||||
this.#count++
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
customElements.define('tick-counter', TickCounter)
|
||||
|
||||
/** Level 2 (middle): a labelled row that hosts a leaf counter. */
|
||||
class CounterRow extends WebComponent {
|
||||
static props = { name: 'row' }
|
||||
get template() {
|
||||
return html`
|
||||
<div class="row">
|
||||
<span class="row-name">${this.props.name}</span>
|
||||
<tick-counter label=${this.props.name}></tick-counter>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
customElements.define('counter-row', CounterRow)
|
||||
|
||||
/** Level 1 (root): a board that re-renders on an unrelated "rename". */
|
||||
class CounterBoard extends WebComponent {
|
||||
static props = { title: 'Board A' }
|
||||
get template() {
|
||||
return html`
|
||||
<section class="board">
|
||||
<header>
|
||||
<h3 id="board-title">${this.props.title}</h3>
|
||||
<button id="rename" onclick=${() => this.#rename()}>
|
||||
Rename board (re-renders the root)
|
||||
</button>
|
||||
</header>
|
||||
<counter-row name="alpha"></counter-row>
|
||||
<counter-row name="bravo"></counter-row>
|
||||
<counter-row name="charlie"></counter-row>
|
||||
</section>
|
||||
`
|
||||
}
|
||||
#renamed = 0
|
||||
#rename() {
|
||||
this.props.title = `Board ${String.fromCharCode(66 + (++this.#renamed % 25))}`
|
||||
}
|
||||
}
|
||||
customElements.define('counter-board', CounterBoard)
|
||||
20
demo/examples/on-changes/index.html
Normal file
20
demo/examples/on-changes/index.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>onChanges: property vs attribute</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>
|
||||
</head>
|
||||
<body>
|
||||
<h1>onChanges: property (camelCase) vs attribute (kebab-case)</h1>
|
||||
<p>
|
||||
Click <em>Rename</em> to change the <code>my-name</code> attribute and
|
||||
watch the <code>onChanges</code> payload below.
|
||||
</p>
|
||||
<change-logger></change-logger>
|
||||
</body>
|
||||
</html>
|
||||
55
demo/examples/on-changes/index.js
Normal file
55
demo/examples/on-changes/index.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { WebComponent, html } from 'web-component-base'
|
||||
|
||||
/**
|
||||
* v5 `onChanges` payload: the `changes` object cleanly separates the
|
||||
* camelCase **property** key from the kebab-case **attribute** name.
|
||||
*
|
||||
* - `property` → camelCase prop key, matching `this.props` access (`myName`)
|
||||
* - `attribute` → kebab-case attribute name that changed (`my-name`)
|
||||
* - `previousValue` / `currentValue`
|
||||
*
|
||||
* Before v5, `property` held the kebab-case attribute name. Code that read
|
||||
* `changes.property` for the attribute name must now read `changes.attribute`.
|
||||
*/
|
||||
export class ChangeLogger extends WebComponent {
|
||||
static props = {
|
||||
myName: 'World',
|
||||
}
|
||||
|
||||
#last = null
|
||||
|
||||
onChanges(changes) {
|
||||
this.#last = changes
|
||||
console.log('>>> onChanges', changes)
|
||||
// onChanges fires after render(), so re-render to surface the payload
|
||||
this.render()
|
||||
}
|
||||
|
||||
rename = () => {
|
||||
this.props.myName = this.props.myName === 'World' ? 'Ayo' : 'World'
|
||||
}
|
||||
|
||||
get template() {
|
||||
const c = this.#last
|
||||
return html`
|
||||
<button id="rename" onclick=${this.rename}>Rename</button>
|
||||
<p id="greeting">Hello ${this.props.myName}</p>
|
||||
${c
|
||||
? html`
|
||||
<dl>
|
||||
<dt>property (camelCase)</dt>
|
||||
<dd id="property">${c.property}</dd>
|
||||
<dt>attribute (kebab-case)</dt>
|
||||
<dd id="attribute">${c.attribute}</dd>
|
||||
<dt>previousValue</dt>
|
||||
<dd id="previous">${c.previousValue}</dd>
|
||||
<dt>currentValue</dt>
|
||||
<dd id="current">${c.currentValue}</dd>
|
||||
</dl>
|
||||
`
|
||||
: html`<p id="no-changes">No changes yet</p>`}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('change-logger', ChangeLogger)
|
||||
|
|
@ -3,12 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WC demo</title>
|
||||
<style>
|
||||
* {
|
||||
font-size: larger;
|
||||
}
|
||||
</style>
|
||||
<title>Single-file pen (CDN)</title>
|
||||
<script type="module">
|
||||
import {
|
||||
WebComponent,
|
||||
|
|
@ -43,6 +38,9 @@
|
|||
customElements.define('my-counter', Counter)
|
||||
customElements.define('my-toggle', Toggle)
|
||||
</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>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { html, WebComponent } from '../../src/index.js'
|
||||
import { html, WebComponent } from 'web-component-base'
|
||||
|
||||
export class HelloWorld extends WebComponent {
|
||||
static props = {
|
||||
|
|
@ -3,9 +3,12 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WC demo</title>
|
||||
<title>Props blueprint</title>
|
||||
<script type="module" src="./index.js"></script>
|
||||
<script type="module" src="./hello-world.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>
|
||||
</head>
|
||||
<body>
|
||||
<my-counter></my-counter>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { html, WebComponent } from '../../src/index.js'
|
||||
import { html, WebComponent } from 'web-component-base'
|
||||
|
||||
export class Counter extends WebComponent {
|
||||
static props = {
|
||||
83
demo/examples/render-reconciliation/index.html
Normal file
83
demo/examples/render-reconciliation/index.html
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Render reconciliation</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;
|
||||
}
|
||||
.seg-group {
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
}
|
||||
.seg.selected {
|
||||
outline: 2px solid currentColor;
|
||||
font-weight: 700;
|
||||
}
|
||||
#list li {
|
||||
display: flex;
|
||||
gap: 0.75em;
|
||||
align-items: center;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
.row-label {
|
||||
min-width: 6em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Render reconciliation</h1>
|
||||
<p class="lede">
|
||||
Re-renders of an <code>html</code> template patch the existing DOM in
|
||||
place instead of rebuilding it, so transient DOM state on the rendered
|
||||
nodes survives. Each section below demonstrates one part of that.
|
||||
</p>
|
||||
|
||||
<h2>1. Focus, caret and uncommitted value survive</h2>
|
||||
<p>
|
||||
A controlled field: every keystroke writes back to a prop and re-renders
|
||||
the component, yet the <code><input></code> is the same element
|
||||
throughout.
|
||||
</p>
|
||||
<controlled-input></controlled-input>
|
||||
|
||||
<h2>2. Attribute-only changes patch in place</h2>
|
||||
<p>
|
||||
Selecting a tab changes only <code>aria-selected</code> and
|
||||
<code>class</code>. Reach a tab with the keyboard, press Enter, and focus
|
||||
stays on it — no node was replaced.
|
||||
</p>
|
||||
<segmented-control></segmented-control>
|
||||
|
||||
<h2>3. Running CSS transitions are not restarted</h2>
|
||||
<p>
|
||||
The box animates for 2s while the component re-renders ten times. Its
|
||||
vnode is unchanged, so the element is left untouched and the transition
|
||||
runs to completion.
|
||||
</p>
|
||||
<transition-safe></transition-safe>
|
||||
|
||||
<h2>4. Removed props and attributes are undone</h2>
|
||||
<p>
|
||||
Props that disappear from the new tree are actively reset on the reused
|
||||
element — a dropped <code>disabled</code>, <code>data-*</code> attribute
|
||||
and inline <code>style</code> rules all go away.
|
||||
</p>
|
||||
<prop-removal></prop-removal>
|
||||
|
||||
<h2>5. Lists — and the non-keyed limitation</h2>
|
||||
<p>
|
||||
Appending and removing reuses the surviving rows. Reordering, however, is
|
||||
matched <em>by position</em>: the rendered result is correct, but
|
||||
preserved state stays with the slot rather than following the item.
|
||||
</p>
|
||||
<item-list></item-list>
|
||||
</body>
|
||||
</html>
|
||||
219
demo/examples/render-reconciliation/index.js
Normal file
219
demo/examples/render-reconciliation/index.js
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
// @ts-check
|
||||
import { WebComponent, html } from 'web-component-base'
|
||||
|
||||
/**
|
||||
* 1. A controlled text field.
|
||||
*
|
||||
* Every keystroke writes back to a prop, which re-renders the component. The
|
||||
* `<input>` element is reused by the reconciler, so focus, the caret position
|
||||
* and the typed-but-uncommitted value all survive the re-render. Before
|
||||
* in-place patching this rebuilt the input and kicked the user out of the field
|
||||
* on the first character.
|
||||
*/
|
||||
class ControlledInput extends WebComponent {
|
||||
static props = { value: '' }
|
||||
|
||||
get template() {
|
||||
return html`
|
||||
<label for="field"
|
||||
>Type here — focus and caret survive every render</label
|
||||
>
|
||||
<input
|
||||
id="field"
|
||||
type="text"
|
||||
placeholder="start typing..."
|
||||
oninput=${(e) => (this.props.value = e.target.value)}
|
||||
/>
|
||||
<p>
|
||||
Prop value: <output id="echo">${this.props.value || '(empty)'}</output>
|
||||
</p>
|
||||
<p class="hint">Renders: <span id="renders">${this.#renders}</span></p>
|
||||
`
|
||||
}
|
||||
|
||||
#renders = 0
|
||||
render() {
|
||||
this.#renders++
|
||||
super.render()
|
||||
}
|
||||
}
|
||||
customElements.define('controlled-input', ControlledInput)
|
||||
|
||||
/**
|
||||
* 2. Attribute-only changes patch in place.
|
||||
*
|
||||
* Selecting a tab changes nothing but `aria-selected` and `class` on the
|
||||
* existing buttons — the elements themselves are reused, so a tab you reached
|
||||
* with the keyboard keeps focus after selection. Tab into the strip and use
|
||||
* the arrow-free Tab/Enter or Space to see focus persist.
|
||||
*/
|
||||
class SegmentedControl extends WebComponent {
|
||||
static props = { value: 'one' }
|
||||
|
||||
get template() {
|
||||
const tab = (id, label) => html`
|
||||
<button
|
||||
id=${id}
|
||||
class=${this.props.value === id ? 'seg selected' : 'seg'}
|
||||
aria-selected=${String(this.props.value === id)}
|
||||
onclick=${() => (this.props.value = id)}
|
||||
>
|
||||
${label}
|
||||
</button>
|
||||
`
|
||||
return html`
|
||||
<div role="tablist" class="seg-group">
|
||||
${tab('one', 'One')} ${tab('two', 'Two')} ${tab('three', 'Three')}
|
||||
</div>
|
||||
<p>Selected: <output id="selected">${this.props.value}</output></p>
|
||||
`
|
||||
}
|
||||
}
|
||||
customElements.define('segmented-control', SegmentedControl)
|
||||
|
||||
/**
|
||||
* 3. Unrelated re-renders don't restart a CSS transition.
|
||||
*
|
||||
* The box runs a 2s width transition. Bumping the counter re-renders the whole
|
||||
* component every 200ms, but the box's vnode is unchanged, so its element is
|
||||
* left completely untouched and the transition keeps running instead of
|
||||
* snapping back to the start.
|
||||
*/
|
||||
class TransitionSafe extends WebComponent {
|
||||
static props = { ticks: 0, wide: false }
|
||||
|
||||
// self-contained so the transition is guaranteed present wherever the
|
||||
// component is used — including the e2e spec, which loads no page CSS
|
||||
static shadowRootInit = { mode: 'open' }
|
||||
static styles = `
|
||||
.anim-box {
|
||||
width: 4em;
|
||||
height: 2.5em;
|
||||
background: currentColor;
|
||||
opacity: 0.5;
|
||||
border-radius: 6px;
|
||||
transition: width 2s ease-in-out;
|
||||
}
|
||||
.anim-box.wide { width: 90%; }
|
||||
`
|
||||
|
||||
get template() {
|
||||
return html`
|
||||
<div
|
||||
id="box"
|
||||
class=${this.props.wide ? 'anim-box wide' : 'anim-box'}
|
||||
></div>
|
||||
<p>
|
||||
Unrelated re-renders while it animates:
|
||||
<output id="ticks">${this.props.ticks}</output>
|
||||
</p>
|
||||
<button id="run" onclick=${() => this.#run()}>Animate + re-render</button>
|
||||
`
|
||||
}
|
||||
|
||||
#run() {
|
||||
this.props.wide = !this.props.wide
|
||||
// hammer the component with unrelated re-renders during the transition
|
||||
let n = 0
|
||||
const id = setInterval(() => {
|
||||
this.props.ticks++
|
||||
if (++n >= 10) clearInterval(id)
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
customElements.define('transition-safe', TransitionSafe)
|
||||
|
||||
/**
|
||||
* 4. Prop and attribute removal.
|
||||
*
|
||||
* A prop that disappears from the new vnode is actively undone, not left
|
||||
* behind: a dropped `disabled` goes back to `false`, a dropped `data-*`
|
||||
* attribute is removed, and dropped `style` rules are cleared.
|
||||
*/
|
||||
class PropRemoval extends WebComponent {
|
||||
// the demo starts decorated, but the boolean prop still defaults to `false`
|
||||
// and carries the inverted name — absence has to mean both "false" and
|
||||
// "default", which only works when they coincide
|
||||
static props = { plain: false }
|
||||
|
||||
get template() {
|
||||
const on = !this.props.plain
|
||||
// spread so the props are genuinely *absent* when off — that's the removal
|
||||
// path: present in the old vnode, gone from the new one
|
||||
const decoration = on
|
||||
? {
|
||||
disabled: true,
|
||||
'data-badge': 'decorated',
|
||||
style: { outline: '2px solid currentColor', padding: '0.6em' },
|
||||
}
|
||||
: {}
|
||||
return html`
|
||||
<button id="target" ...${decoration}>target element</button>
|
||||
<p class="hint">
|
||||
<code>disabled</code>, <code>data-badge</code> and the inline
|
||||
<code>style</code> are present only while decorated — toggling removes
|
||||
them from the same element instead of building a new one.
|
||||
</p>
|
||||
<button id="toggle" onclick=${() => (this.props.plain = on)}>
|
||||
${on ? 'Remove props' : 'Add props'}
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}
|
||||
customElements.define('prop-removal', PropRemoval)
|
||||
|
||||
/**
|
||||
* 5. Lists — including the non-keyed limitation.
|
||||
*
|
||||
* Growing and shrinking reuses the surviving rows. But patching is index-based,
|
||||
* so a *reorder* matches nodes by position, not identity: the DOM ends up
|
||||
* correct, yet each row keeps its slot and has its content rewritten. Type into
|
||||
* a row, then reorder, and you'll see the caret stay on the position rather
|
||||
* than follow the item.
|
||||
*/
|
||||
class ItemList extends WebComponent {
|
||||
static props = { items: ['alpha', 'bravo', 'charlie'] }
|
||||
|
||||
get template() {
|
||||
return html`
|
||||
<ul id="list">
|
||||
${this.props.items.map(
|
||||
(item) => html`
|
||||
<li>
|
||||
<span class="row-label">${item}</span>
|
||||
<input class="row-note" placeholder="note for ${item}" />
|
||||
</li>
|
||||
`
|
||||
)}
|
||||
</ul>
|
||||
<button
|
||||
id="add"
|
||||
onclick=${() => this.#set([...this.props.items, 'delta'])}
|
||||
>
|
||||
Append
|
||||
</button>
|
||||
<button
|
||||
id="drop"
|
||||
onclick=${() => this.#set(this.props.items.slice(0, -1))}
|
||||
>
|
||||
Remove last
|
||||
</button>
|
||||
<button
|
||||
id="reverse"
|
||||
onclick=${() => this.#set([...this.props.items].reverse())}
|
||||
>
|
||||
Reverse (shows the non-keyed limitation)
|
||||
</button>
|
||||
<p class="hint">
|
||||
Type a note into the first row, then press <b>Reverse</b>: the labels
|
||||
swap but your note stays in row 1, because rows are matched by position.
|
||||
A <code>key</code> prop would be needed to make state follow the item.
|
||||
</p>
|
||||
`
|
||||
}
|
||||
|
||||
#set(items) {
|
||||
this.props.items = items
|
||||
}
|
||||
}
|
||||
customElements.define('item-list', ItemList)
|
||||
22
demo/examples/strict-props/index.html
Normal file
22
demo/examples/strict-props/index.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>strictProps type enforcement</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>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Prop type enforcement</h1>
|
||||
<p>
|
||||
By default a type violation is logged (see the console) and skipped. With
|
||||
<code>static strictProps = true</code> it throws a <code>TypeError</code>.
|
||||
</p>
|
||||
<lenient-counter></lenient-counter>
|
||||
<hr />
|
||||
<strict-counter></strict-counter>
|
||||
</body>
|
||||
</html>
|
||||
70
demo/examples/strict-props/index.js
Normal file
70
demo/examples/strict-props/index.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { WebComponent, html } from 'web-component-base'
|
||||
|
||||
/**
|
||||
* v5 derives a prop's type from its `static props` default and, by default,
|
||||
* treats a type violation as **loud but non-fatal**: the bad write is logged
|
||||
* via `console.error` and skipped, so the prop keeps its value and
|
||||
* `render()` / `onChanges()` still run.
|
||||
*
|
||||
* Here `count` is typed `number` (default `0`). Assigning a string is skipped.
|
||||
*/
|
||||
export class LenientCounter extends WebComponent {
|
||||
static props = { count: 0 }
|
||||
|
||||
#status = 'ok'
|
||||
|
||||
bump = () => ++this.props.count
|
||||
|
||||
violate = () => {
|
||||
this.props.count = 'not-a-number' // logged + skipped; count stays a number
|
||||
this.#status = `count is still ${this.props.count} (${typeof this.props.count})`
|
||||
this.render()
|
||||
}
|
||||
|
||||
get template() {
|
||||
return html`
|
||||
<h3>Default: log & skip</h3>
|
||||
<button id="lenient-bump" onclick=${this.bump}>
|
||||
count = ${this.props.count}
|
||||
</button>
|
||||
<button id="lenient-violate" onclick=${this.violate}>
|
||||
Assign a string
|
||||
</button>
|
||||
<p id="lenient-status">${this.#status}</p>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teams wanting hard enforcement opt in with `static strictProps = true`.
|
||||
* The same violation now throws a `TypeError` instead of being skipped.
|
||||
*/
|
||||
export class StrictCounter extends WebComponent {
|
||||
static strictProps = true
|
||||
static props = { count: 0 }
|
||||
|
||||
#status = 'ok'
|
||||
|
||||
violate = () => {
|
||||
try {
|
||||
this.props.count = 'not-a-number'
|
||||
this.#status = 'no error (unexpected)'
|
||||
} catch (e) {
|
||||
this.#status = `threw: ${e.name}`
|
||||
}
|
||||
this.render()
|
||||
}
|
||||
|
||||
get template() {
|
||||
return html`
|
||||
<h3>strictProps = true</h3>
|
||||
<button id="strict-violate" onclick=${this.violate}>
|
||||
Assign a string
|
||||
</button>
|
||||
<p id="strict-status">${this.#status}</p>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('lenient-counter', LenientCounter)
|
||||
customElements.define('strict-counter', StrictCounter)
|
||||
15
demo/examples/style-objects/index.html
Normal file
15
demo/examples/style-objects/index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Style objects</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>
|
||||
</head>
|
||||
<body>
|
||||
<styled-elements type="warn" condition></styled-elements>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// @ts-check
|
||||
import { WebComponent, html } from '../../src/index.js'
|
||||
import { WebComponent, html } from 'web-component-base'
|
||||
|
||||
class StyledElements extends WebComponent {
|
||||
static props = {
|
||||
|
|
@ -3,9 +3,12 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WC demo</title>
|
||||
<title>Templating</title>
|
||||
<script type="module" src="./index.js"></script>
|
||||
<script type="module" src="./with-lit.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>
|
||||
</head>
|
||||
<body>
|
||||
<h2>With our html</h2>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// @ts-check
|
||||
import { WebComponent, html } from '../../src/index.js'
|
||||
import { WebComponent, html } from 'web-component-base'
|
||||
|
||||
export class Counter extends WebComponent {
|
||||
static props = {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { WebComponent } from '../../src/index.js'
|
||||
import { WebComponent } from 'web-component-base'
|
||||
import {
|
||||
html,
|
||||
render as lit,
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// @ts-check
|
||||
import { html, WebComponent } from '../../src/index.js'
|
||||
import { html, WebComponent } from 'web-component-base'
|
||||
|
||||
export class Counter extends WebComponent {
|
||||
static props = {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { html, WebComponent } from '../../src/index.js'
|
||||
import { html, WebComponent } from 'web-component-base'
|
||||
|
||||
export class HelloWorld extends WebComponent {
|
||||
static props = {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { html, WebComponent } from '../../src/index.js'
|
||||
import { html, WebComponent } from 'web-component-base'
|
||||
|
||||
/**
|
||||
* TODO: rendering currently wipes all children so focus gets removed on fields
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// @ts-check
|
||||
import { html, WebComponent } from '../../src/index.js'
|
||||
import { html, WebComponent } from 'web-component-base'
|
||||
|
||||
export class Toggle extends WebComponent {
|
||||
static props = {
|
||||
|
|
@ -4,16 +4,14 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WC demo</title>
|
||||
<title>Typed props</title>
|
||||
<script type="module" src="./Counter.mjs"></script>
|
||||
<script type="module" src="./Toggle.mjs"></script>
|
||||
<script type="module" src="./HelloWorld.mjs"></script>
|
||||
<script type="module" src="./Object.mjs"></script>
|
||||
<style>
|
||||
* {
|
||||
font-size: larger;
|
||||
}
|
||||
</style>
|
||||
<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>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
@ -29,7 +27,7 @@
|
|||
* TODO: fix using custom events
|
||||
*/
|
||||
|
||||
// import { attachEffect } from '../../src/index.js'
|
||||
// import { attachEffect } from 'web-component-base'
|
||||
// const myObjectEl = document.querySelector('my-object')
|
||||
// const objectProp = myObjectEl.props.object
|
||||
// const displayPanelEl = document.querySelector('#display-panel')
|
||||
52
demo/examples/typed-props/index.html
Normal file
52
demo/examples/typed-props/index.html
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Compile-time prop types</title>
|
||||
<script type="module" src="./index.ts"></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>
|
||||
.btn.primary { font-weight: 600; }
|
||||
.btn.secondary { opacity: 0.85; }
|
||||
.btn.ghost { background: transparent; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Compile-time prop types</h1>
|
||||
<p>
|
||||
Pass the shape of your defaults as a type argument —
|
||||
<code>extends WebComponent<typeof props></code> — and
|
||||
<code>this.props.*</code> is inferred from them instead of being
|
||||
<code>any</code>.
|
||||
</p>
|
||||
|
||||
<typed-button></typed-button>
|
||||
|
||||
<p>
|
||||
The buttons above are ordinary reactive props; the point of this example
|
||||
is what the <em>compiler</em> now sees. In
|
||||
<code>index.ts</code> below, <code>this.props.variant</code> is
|
||||
<code>string</code>, <code>this.props.disabled</code> is
|
||||
<code>boolean</code>, and each line in <code>CompileErrors</code> is a
|
||||
real type error kept honest by <code>@ts-expect-error</code>.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Without the type argument</h2>
|
||||
<p>
|
||||
Omitting it keeps the previous behavior: every read is <code>any</code>,
|
||||
so a typo like <code>this.props.varient</code> compiles fine.
|
||||
</p>
|
||||
<untyped-button></untyped-button>
|
||||
|
||||
<p>
|
||||
<strong>Runtime is unchanged either way.</strong> Types are erased before
|
||||
the browser sees this file — <code>static strictProps</code> is still what
|
||||
guards values arriving from attributes at runtime.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
87
demo/examples/typed-props/index.ts
Normal file
87
demo/examples/typed-props/index.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { WebComponent, html } from 'web-component-base'
|
||||
|
||||
const buttonProps = {
|
||||
variant: 'primary',
|
||||
disabled: false,
|
||||
clicks: 0,
|
||||
}
|
||||
|
||||
class TypedButton extends WebComponent<typeof buttonProps> {
|
||||
static props = buttonProps
|
||||
|
||||
bump = () => {
|
||||
if (!this.props.disabled) this.props.clicks++
|
||||
}
|
||||
|
||||
toggle = () => (this.props.disabled = !this.props.disabled)
|
||||
|
||||
cycle = () => {
|
||||
const order = ['primary', 'secondary', 'ghost']
|
||||
const next = order[(order.indexOf(this.props.variant) + 1) % order.length]
|
||||
this.props.variant = next
|
||||
}
|
||||
|
||||
get template() {
|
||||
// Each read below is inferred from the default it was declared with —
|
||||
// hover them in an editor to see `string`, `boolean` and `number`.
|
||||
const variant: string = this.props.variant
|
||||
const disabled: boolean = this.props.disabled
|
||||
const clicks: number = this.props.clicks
|
||||
|
||||
return html`
|
||||
<button
|
||||
id="typed-button"
|
||||
class="btn ${variant}"
|
||||
disabled=${disabled}
|
||||
onclick=${this.bump}
|
||||
>
|
||||
${variant} — clicked ${clicks}×
|
||||
</button>
|
||||
<button id="typed-cycle" onclick=${this.cycle}>Cycle variant</button>
|
||||
<button id="typed-toggle" onclick=${this.toggle}>
|
||||
${disabled ? 'Enable' : 'Disable'}
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ERROR examples
|
||||
* `@ts-expect-error` keeps this example working
|
||||
*/
|
||||
class CompileErrors extends WebComponent<typeof buttonProps> {
|
||||
static props = buttonProps
|
||||
|
||||
demo() {
|
||||
// if you remove the ts-expect-error comment below, the editor should show red squiggly lines
|
||||
// @ts-expect-error string is not assignable to boolean
|
||||
this.props.disabled = 'yes'
|
||||
|
||||
// @ts-expect-error boolean is not assignable to string
|
||||
this.props.variant = false
|
||||
|
||||
// @ts-expect-error 'varient' is a typo — not a declared prop
|
||||
this.props.varient
|
||||
|
||||
// @ts-expect-error clicks is a number, not a string
|
||||
this.props.clicks.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Omitting the type argument keeps the previous behavior — every read is
|
||||
* `any`, so none of the mistakes above are caught. This still compiles.
|
||||
*/
|
||||
class UntypedButton extends WebComponent {
|
||||
static props = { variant: 'primary' }
|
||||
|
||||
get template() {
|
||||
this.props.varient // no error: `any` swallows the typo
|
||||
return html`<button class="btn">${this.props.variant} (untyped)</button>`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('typed-button', TypedButton)
|
||||
customElements.define('untyped-button', UntypedButton)
|
||||
|
||||
export { CompileErrors }
|
||||
18
demo/examples/typed-props/tsconfig.json
Normal file
18
demo/examples/typed-props/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
// typecheck against the emitted `.d.ts` — the same types a consumer gets.
|
||||
// (Vite aliases this specifier to `src/` at runtime; see demo/vite.config.js)
|
||||
"paths": {
|
||||
"web-component-base": ["../../../dist/index.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["./index.ts"]
|
||||
}
|
||||
16
demo/examples/use-shadow/index.html
Normal file
16
demo/examples/use-shadow/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Shadow DOM</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>
|
||||
</head>
|
||||
<body>
|
||||
<h2>With our html</h2>
|
||||
<my-counter></my-counter>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// @ts-check
|
||||
import { WebComponent, html } from '../../src/index.js'
|
||||
import { WebComponent, html } from 'web-component-base'
|
||||
|
||||
export class Counter extends WebComponent {
|
||||
static props = {
|
||||
158
demo/index.html
Normal file
158
demo/index.html
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web-component-base — examples</title>
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<header class="home-header">
|
||||
<h1><code>web-component-base</code> examples</h1>
|
||||
<p>
|
||||
A live gallery of the demos using web components. Each
|
||||
card opens a standalone example page.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">v6 behavior demos</h2>
|
||||
<div class="grid">
|
||||
<a class="card" href="./examples/boolean-props/index.html">
|
||||
<div class="name">
|
||||
Boolean props<span class="tag">breaking</span>
|
||||
</div>
|
||||
<div class="desc">
|
||||
Presence/absence reflection: <code>toggleAttribute</code> and
|
||||
<code>[flag]</code> selectors work, and any present value is
|
||||
<code>true</code>.
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="./examples/attribute-converters/index.html">
|
||||
<div class="name">Custom attribute converters</div>
|
||||
<div class="desc">
|
||||
Override <code>toAttribute</code>/<code>fromAttribute</code> for Date
|
||||
and Array props; <code>null</code> removes the attribute.
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">v5 behavior demos</h2>
|
||||
<div class="grid">
|
||||
<a class="card" href="./examples/on-changes/index.html">
|
||||
<div class="name">onChanges payload</div>
|
||||
<div class="desc">
|
||||
camelCase <code>property</code> vs kebab <code>attribute</code> (v5
|
||||
breaking change).
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="./examples/strict-props/index.html">
|
||||
<div class="name">Prop type enforcement</div>
|
||||
<div class="desc">
|
||||
Default log-and-skip vs <code>static strictProps</code> throwing.
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="./examples/typed-props/index.html">
|
||||
<div class="name">Compile-time prop types<span class="tag">TS</span></div>
|
||||
<div class="desc">
|
||||
<code>extends WebComponent<typeof props></code> infers
|
||||
<code>this.props.*</code> from the defaults.
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="./examples/attribute-lifecycle/index.html">
|
||||
<div class="name">Attribute lifecycle</div>
|
||||
<div class="desc">
|
||||
Empty string stays <code>''</code>, removal resets to default, markup
|
||||
wins.
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="./examples/lifecycle-order/index.html">
|
||||
<div class="name">Lifecycle order</div>
|
||||
<div class="desc">
|
||||
<code>onInit</code> runs before the first render (buffering
|
||||
guarantee).
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Core examples</h2>
|
||||
<div class="grid">
|
||||
<a class="card" href="./examples/demo/index.html">
|
||||
<div class="name">Kitchen sink</div>
|
||||
<div class="desc">
|
||||
Counter, toggle, hello-world, boolean props, onDestroy.
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="./examples/type-restore/index.html">
|
||||
<div class="name">Typed props</div>
|
||||
<div class="desc">number, boolean, string, and object round-trips.</div>
|
||||
</a>
|
||||
<a class="card" href="./examples/props-blueprint/index.html">
|
||||
<div class="name">Props blueprint</div>
|
||||
<div class="desc">
|
||||
Non-zero defaults and camelCase↔kebab mapping.
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="./examples/templating/index.html">
|
||||
<div class="name">Templating</div>
|
||||
<div class="desc">
|
||||
Lists, links, style attributes — plus a lit-html variant.
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="./examples/render-reconciliation/index.html">
|
||||
<div class="name">Render reconciliation</div>
|
||||
<div class="desc">
|
||||
In-place patching: focus, caret, transitions and prop removal.
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="./examples/nested-composition/index.html">
|
||||
<div class="name">Nested composition</div>
|
||||
<div class="desc">
|
||||
Components three levels deep keep their own DOM across an ancestor
|
||||
re-render.
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="./examples/style-objects/index.html">
|
||||
<div class="name">Style objects</div>
|
||||
<div class="desc">Computed, conditional inline styles.</div>
|
||||
</a>
|
||||
<a class="card" href="./examples/use-shadow/index.html">
|
||||
<div class="name">Shadow DOM</div>
|
||||
<div class="desc">Opt in with <code>static shadowRootInit</code>.</div>
|
||||
</a>
|
||||
<a class="card" href="./examples/constructed-styles/index.html">
|
||||
<div class="name">Constructable styles</div>
|
||||
<div class="desc">
|
||||
<code>static styles</code> via adopted stylesheets.
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="./examples/just-parts/index.html">
|
||||
<div class="name">Just the parts</div>
|
||||
<div class="desc">
|
||||
<code>html</code> + <code>createElement</code> on a vanilla element.
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="./examples/pens/counter-toggle.html">
|
||||
<div class="name">Single-file pen<span class="tag">CDN</span></div>
|
||||
<div class="desc">
|
||||
One self-contained HTML file importing from a CDN.
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Run with <code>pnpm --filter demo dev</code>. Source lives in
|
||||
<code>demo/examples/</code>. See the
|
||||
<a href="https://webcomponent.io">documentation</a>.
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
19
demo/package.json
Normal file
19
demo/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "demo",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"description": "Vite showcase app for web-component-base examples",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"shiki": "^4.3.1",
|
||||
"web-component-base": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
383
demo/shell.css
Normal file
383
demo/shell.css
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
/*
|
||||
* Single shared stylesheet for the demo app shell.
|
||||
* Linked by the landing page and every example page so the whole showcase
|
||||
* shares one set of design tokens and component styles.
|
||||
*/
|
||||
|
||||
/* Dark is the default (and the no-JS fallback). The theme toggle overrides it
|
||||
by setting `data-theme` on <html>; when unset, the OS preference wins. */
|
||||
:root {
|
||||
--bg: #0b0d12;
|
||||
--card: #151922;
|
||||
--card-hover: #1c2230;
|
||||
--surface: #10141c;
|
||||
--border: #2a3140;
|
||||
--text: #e6e9ef;
|
||||
--muted: #97a0b0;
|
||||
--accent: #7c9cff;
|
||||
--tag: #263042;
|
||||
--radius: 12px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Light tokens, shared by the OS preference (no explicit choice) and the
|
||||
explicit `data-theme="light"` override. */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:not([data-theme]) {
|
||||
--bg: #f6f7f9;
|
||||
--card: #ffffff;
|
||||
--card-hover: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--border: #e2e5ea;
|
||||
--text: #1a1d24;
|
||||
--muted: #5b6472;
|
||||
--accent: #3355dd;
|
||||
--tag: #eef1f7;
|
||||
color-scheme: light;
|
||||
}
|
||||
}
|
||||
:root[data-theme='light'] {
|
||||
--bg: #f6f7f9;
|
||||
--card: #ffffff;
|
||||
--card-hover: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--border: #e2e5ea;
|
||||
--text: #1a1d24;
|
||||
--muted: #5b6472;
|
||||
--accent: #3355dd;
|
||||
--tag: #eef1f7;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Full-bleed background on <html>, centered content column on <body>. */
|
||||
html {
|
||||
background: var(--bg);
|
||||
color-scheme: light dark;
|
||||
}
|
||||
body {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.25rem 4rem;
|
||||
color: var(--text);
|
||||
font:
|
||||
16px/1.6 system-ui,
|
||||
-apple-system,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
/* ---------- shared app header (injected on example pages) ---------- */
|
||||
app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-bottom: 0.9rem;
|
||||
margin-bottom: 1.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
app-header .brand {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
app-header .crumb {
|
||||
color: var(--muted);
|
||||
}
|
||||
app-header .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
app-header .back {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
app-header .back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- theme toggle (in the nav / landing header) ---------- */
|
||||
.theme-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--card);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.theme-toggle svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ---------- typography ---------- */
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.25;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.05rem;
|
||||
margin: 2rem 0 0.75rem;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 1rem;
|
||||
max-width: 62ch;
|
||||
}
|
||||
.lede {
|
||||
color: var(--muted);
|
||||
}
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 5px;
|
||||
}
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.75rem 0;
|
||||
}
|
||||
|
||||
/* ---------- interactive bits used across the examples ---------- */
|
||||
button {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.85rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--card-hover);
|
||||
}
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
}
|
||||
label {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
form {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.35rem 1rem;
|
||||
align-items: baseline;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.15rem;
|
||||
max-width: 34rem;
|
||||
}
|
||||
dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
ol.lifecycle-log {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.9rem 0.9rem 0.9rem 2.4rem;
|
||||
max-width: 34rem;
|
||||
}
|
||||
ol.lifecycle-log li {
|
||||
margin: 0.15rem 0;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Give custom-element hosts a bit of breathing room in the column. */
|
||||
change-logger,
|
||||
attr-demo,
|
||||
lenient-counter,
|
||||
strict-counter,
|
||||
lifecycle-order,
|
||||
my-object,
|
||||
my-quote {
|
||||
display: block;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
/* ---------- source preview (shown on every example page) ---------- */
|
||||
.source {
|
||||
margin-top: 2.75rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.source-title {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
details.source-file {
|
||||
/* `--code-bg` is set from Shiki's theme background once highlighting loads,
|
||||
so the whole block matches the code area (falls back to the card color). */
|
||||
background: var(--code-bg, var(--card));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin: 0 0 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
details.source-file > summary {
|
||||
cursor: pointer;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent);
|
||||
user-select: none;
|
||||
}
|
||||
details.source-file[open] > summary {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
/* Box styles shared by the plain fallback and Shiki's <pre class="shiki">.
|
||||
Both are transparent so the single block background (above) shows through —
|
||||
one uniform color across the filename bar and the code, with no seams. */
|
||||
details.source-file pre {
|
||||
margin: 0;
|
||||
padding: 0.9rem 1rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
tab-size: 2;
|
||||
background: transparent !important;
|
||||
}
|
||||
details.source-file pre.source-plain {
|
||||
color: var(--text);
|
||||
}
|
||||
details.source-file pre.source-plain code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
/* Shiki dual-theme: light token colors are inline; flip to the dark theme when
|
||||
the effective theme is dark. The source preview only renders with JS, which
|
||||
always sets a concrete `data-theme`, so key the flip off that attribute
|
||||
(background is handled by the block, not per span). */
|
||||
:root[data-theme='dark'] details.source-file {
|
||||
background: var(--code-bg-dark, var(--card));
|
||||
}
|
||||
:root[data-theme='dark'] details.source-file .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
}
|
||||
|
||||
/* ---------- landing page ---------- */
|
||||
.home-header {
|
||||
position: relative;
|
||||
}
|
||||
.home-header .theme-toggle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
.home-header p {
|
||||
color: var(--muted);
|
||||
max-width: 46rem;
|
||||
}
|
||||
.home-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.home-header h1 code {
|
||||
font-size: 1.5rem;
|
||||
color: var(--accent);
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.section-label {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
margin: 2.5rem 0 1rem;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
a.card {
|
||||
display: block;
|
||||
padding: 1.1rem 1.15rem;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
a.card:hover {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
a.card .name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
a.card .desc {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: var(--tag);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
margin-left: 0.4rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
footer {
|
||||
margin-top: 3rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
283
demo/shell.js
Normal file
283
demo/shell.js
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { WebComponent } from 'web-component-base'
|
||||
|
||||
/**
|
||||
* The shared chrome for every example page — itself built with
|
||||
* `web-component-base` (dogfooding). It renders into light DOM, so the shared
|
||||
* `shell.css` styles it like everything else.
|
||||
*/
|
||||
class AppHeader extends WebComponent {
|
||||
static props = { heading: '' }
|
||||
|
||||
get template() {
|
||||
return `
|
||||
<a class="brand" href="/">web-component-base</a>
|
||||
<span class="crumb">${this.props.heading}</span>
|
||||
<span class="spacer"></span>
|
||||
<a class="back" href="/">← All examples</a>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('app-header', AppHeader)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme toggle
|
||||
//
|
||||
// The initial theme is applied by a tiny inline <head> script (so there is no
|
||||
// flash of the wrong theme). Here we build the toggle control, keep it in sync,
|
||||
// and follow the OS while the user hasn't made an explicit choice.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const THEME_KEY = 'wcb-theme'
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const effectiveTheme = () =>
|
||||
document.documentElement.dataset.theme ||
|
||||
(prefersDark.matches ? 'dark' : 'light')
|
||||
|
||||
const SUN_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>`
|
||||
const MOON_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`
|
||||
|
||||
const themeToggle = document.createElement('button')
|
||||
themeToggle.type = 'button'
|
||||
themeToggle.className = 'theme-toggle'
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function syncToggle() {
|
||||
const dark = effectiveTheme() === 'dark'
|
||||
themeToggle.innerHTML = dark ? SUN_ICON : MOON_ICON
|
||||
const label = `Switch to ${dark ? 'light' : 'dark'} theme`
|
||||
themeToggle.setAttribute('aria-label', label)
|
||||
themeToggle.title = label
|
||||
}
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const next = effectiveTheme() === 'dark' ? 'light' : 'dark'
|
||||
document.documentElement.dataset.theme = next
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, next)
|
||||
} catch {
|
||||
/* private mode / storage disabled — the choice just won't persist */
|
||||
}
|
||||
syncToggle()
|
||||
})
|
||||
|
||||
// Follow OS changes only while the user hasn't chosen explicitly.
|
||||
prefersDark.addEventListener('change', () => {
|
||||
let stored = null
|
||||
try {
|
||||
stored = localStorage.getItem(THEME_KEY)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (!stored) {
|
||||
document.documentElement.dataset.theme = prefersDark.matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
}
|
||||
syncToggle()
|
||||
})
|
||||
|
||||
syncToggle()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source preview
|
||||
//
|
||||
// Every example page shows the source of its own implementation. Vite inlines
|
||||
// each file's raw text at build time via `?raw` glob imports; we look them up
|
||||
// by the example's folder (taken from the URL) so this works identically in dev
|
||||
// and in the hashed production build (unlike reading rewritten <script src>s).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RAW_JS = import.meta.glob('./examples/**/*.{js,mjs,ts}', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
eager: true,
|
||||
})
|
||||
const RAW_HTML = import.meta.glob('./examples/**/*.html', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
eager: true,
|
||||
})
|
||||
|
||||
const basename = (path) => path.split('/').pop()
|
||||
|
||||
// Shiki highlighter, created once and lazily. Uses the JavaScript regex engine
|
||||
// so no WASM is fetched at runtime, and only the langs/themes we actually need.
|
||||
let highlighterPromise
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function getHighlighter() {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = (async () => {
|
||||
const [{ createHighlighterCore }, { createJavaScriptRegexEngine }] =
|
||||
await Promise.all([
|
||||
import('shiki/core'),
|
||||
import('shiki/engine/javascript'),
|
||||
])
|
||||
const [js, ts, htmlLang, css, dark, light] = await Promise.all([
|
||||
import('shiki/langs/javascript.mjs'),
|
||||
import('shiki/langs/typescript.mjs'),
|
||||
import('shiki/langs/html.mjs'),
|
||||
import('shiki/langs/css.mjs'),
|
||||
import('shiki/themes/github-dark.mjs'),
|
||||
import('shiki/themes/github-light.mjs'),
|
||||
])
|
||||
return createHighlighterCore({
|
||||
engine: createJavaScriptRegexEngine(),
|
||||
langs: [js.default, ts.default, htmlLang.default, css.default],
|
||||
themes: [dark.default, light.default],
|
||||
})
|
||||
})()
|
||||
}
|
||||
return highlighterPromise
|
||||
}
|
||||
|
||||
const langFor = (name) => {
|
||||
if (name.endsWith('.html')) return 'html'
|
||||
if (name.endsWith('.ts')) return 'typescript'
|
||||
return 'javascript'
|
||||
}
|
||||
|
||||
// Single-file pens have no separate JS module — show their HTML instead, minus
|
||||
// the injected shell tags so only the actual example remains.
|
||||
/**
|
||||
*
|
||||
* @param html
|
||||
*/
|
||||
function stripShell(html) {
|
||||
return html
|
||||
.split('\n')
|
||||
.filter((line) => !line.includes('shell.css') && !line.includes('shell.js'))
|
||||
.join('\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// ts-check strip comments
|
||||
/**
|
||||
*
|
||||
* @param js
|
||||
*/
|
||||
function stripTsCheck(js) {
|
||||
return js
|
||||
.split('\n')
|
||||
.filter((line) => !line.includes('ts-check'))
|
||||
.join('\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param folder
|
||||
*/
|
||||
function sourcesForFolder(folder) {
|
||||
const prefix = `./examples/${folder}/`
|
||||
const jsKeys = Object.keys(RAW_JS)
|
||||
.filter((k) => k.startsWith(prefix))
|
||||
.sort()
|
||||
if (jsKeys.length) {
|
||||
return jsKeys.map((k) => ({
|
||||
name: basename(k),
|
||||
code: stripTsCheck(RAW_JS[k]),
|
||||
}))
|
||||
}
|
||||
return Object.keys(RAW_HTML)
|
||||
.filter((k) => k.startsWith(prefix))
|
||||
.sort()
|
||||
.map((k) => ({ name: basename(k), code: stripShell(RAW_HTML[k]) }))
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function renderSourcePreview() {
|
||||
const match = location.pathname.match(/\/examples\/([^/]+)\//)
|
||||
if (!match) return
|
||||
const files = sourcesForFolder(match[1])
|
||||
if (!files.length) return
|
||||
|
||||
const section = document.createElement('section')
|
||||
section.className = 'source'
|
||||
|
||||
const title = document.createElement('h2')
|
||||
title.className = 'source-title'
|
||||
title.textContent = 'Implementation'
|
||||
section.appendChild(title)
|
||||
|
||||
const blocks = files.map((file) => {
|
||||
const details = document.createElement('details')
|
||||
details.className = 'source-file'
|
||||
details.open = true
|
||||
|
||||
const summary = document.createElement('summary')
|
||||
summary.textContent = file.name
|
||||
|
||||
// Plain <pre><code> first; upgraded to highlighted markup once Shiki loads.
|
||||
const pre = document.createElement('pre')
|
||||
pre.className = 'source-plain'
|
||||
const code = document.createElement('code')
|
||||
code.textContent = file.code.replace(/\s+$/, '')
|
||||
pre.appendChild(code)
|
||||
|
||||
details.append(summary, pre)
|
||||
section.appendChild(details)
|
||||
return { file, details, pre }
|
||||
})
|
||||
|
||||
// Appended at the end of <body>, after the live demo — never reparents it.
|
||||
document.body.appendChild(section)
|
||||
|
||||
// Progressive enhancement: swap in Shiki-highlighted markup. Dual themes emit
|
||||
// `--shiki` / `--shiki-dark` custom props so shell.css can flip with the OS
|
||||
// color scheme. Any failure just leaves the readable plain block in place.
|
||||
let highlighter
|
||||
try {
|
||||
highlighter = await getHighlighter()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// Expose the theme backgrounds so the whole block (filename bar + code) can
|
||||
// share Shiki's background color instead of the shell card color.
|
||||
section.style.setProperty(
|
||||
'--code-bg',
|
||||
highlighter.getTheme('github-light').bg
|
||||
)
|
||||
section.style.setProperty(
|
||||
'--code-bg-dark',
|
||||
highlighter.getTheme('github-dark').bg
|
||||
)
|
||||
for (const { file, pre } of blocks) {
|
||||
try {
|
||||
const html = highlighter.codeToHtml(file.code.replace(/\s+$/, ''), {
|
||||
lang: langFor(file.name),
|
||||
themes: { light: 'github-light', dark: 'github-dark' },
|
||||
})
|
||||
pre.outerHTML = html
|
||||
} catch {
|
||||
/* keep the plain fallback for this file */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mount
|
||||
//
|
||||
// Example pages get the full nav (with the toggle on the right) plus a source
|
||||
// preview; the landing page just gets the toggle in its header. `prepend` /
|
||||
// `appendChild` only add siblings — the example's own elements are untouched.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (/\/examples\/[^/]+\//.test(location.pathname)) {
|
||||
const header = document.createElement('app-header')
|
||||
header.setAttribute('heading', document.title)
|
||||
document.body.prepend(header)
|
||||
header.appendChild(themeToggle)
|
||||
renderSourcePreview()
|
||||
} else {
|
||||
const homeHeader = document.querySelector('.home-header')
|
||||
if (homeHeader) homeHeader.appendChild(themeToggle)
|
||||
else document.body.prepend(themeToggle)
|
||||
}
|
||||
52
demo/vite.config.js
Normal file
52
demo/vite.config.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import { readdirSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
const srcDir = resolve(root, '..', 'src')
|
||||
|
||||
// Every example page becomes a build input so `vite build` emits a full
|
||||
// multi-page app (landing page + one page per example).
|
||||
function examplePages() {
|
||||
const inputs = { main: resolve(root, 'index.html') }
|
||||
const examplesDir = resolve(root, 'examples')
|
||||
for (const entry of readdirSync(examplesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
for (const file of readdirSync(resolve(examplesDir, entry.name))) {
|
||||
if (!file.endsWith('.html')) continue
|
||||
const name = `${entry.name}/${file.replace(/\.html$/, '')}`
|
||||
inputs[name] = resolve(examplesDir, entry.name, file)
|
||||
}
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
|
||||
// `web-component-base` is a real `workspace:*` dependency; for a build-free dev
|
||||
// experience (instant HMR against the library source) we resolve it to `src`
|
||||
// instead of the built `dist`. Order matters — most specific specifier first.
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^web-component-base\/utils$/,
|
||||
replacement: resolve(srcDir, 'utils/index.js'),
|
||||
},
|
||||
{
|
||||
find: /^web-component-base\/html$/,
|
||||
replacement: resolve(srcDir, 'html.js'),
|
||||
},
|
||||
{
|
||||
find: /^web-component-base$/,
|
||||
replacement: resolve(srcDir, 'index.js'),
|
||||
},
|
||||
],
|
||||
},
|
||||
server: {
|
||||
// allow serving the aliased library source that lives above the demo root
|
||||
fs: { allow: [resolve(root, '..')] },
|
||||
},
|
||||
build: {
|
||||
rollupOptions: { input: examplePages() },
|
||||
},
|
||||
})
|
||||
|
|
@ -5,11 +5,12 @@ import starlight from '@astrojs/starlight'
|
|||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
redirects: {
|
||||
'/guides/': '/guides/why',
|
||||
'/guides/': '/getting-started',
|
||||
'/api/': '/api/web-component',
|
||||
},
|
||||
integrations: [
|
||||
starlight({
|
||||
title: 'WCB (alpha)',
|
||||
title: 'WCB',
|
||||
social: [
|
||||
{
|
||||
icon: 'npm',
|
||||
|
|
@ -34,6 +35,7 @@ export default defineConfig({
|
|||
// Each item here is one entry in the navigation menu.
|
||||
'getting-started',
|
||||
'why',
|
||||
'comparison',
|
||||
'exports',
|
||||
'usage',
|
||||
'examples',
|
||||
|
|
@ -43,16 +45,23 @@ export default defineConfig({
|
|||
'styling',
|
||||
'just-parts',
|
||||
'life-cycle-hooks',
|
||||
'cem-plugin',
|
||||
'library-size',
|
||||
],
|
||||
},
|
||||
// {
|
||||
// label: 'Reference',
|
||||
// autogenerate: { directory: 'reference' },
|
||||
// },
|
||||
{
|
||||
label: 'API Reference',
|
||||
items: [
|
||||
'api/web-component',
|
||||
'api/html',
|
||||
'api/utils',
|
||||
'api/cem-plugin',
|
||||
],
|
||||
},
|
||||
],
|
||||
components: {
|
||||
Footer: './src/components/Attribution.astro',
|
||||
Hero: './src/components/Hero.astro',
|
||||
},
|
||||
head: [
|
||||
{
|
||||
|
|
|
|||
164
docs/src/components/Hero.astro
Normal file
164
docs/src/components/Hero.astro
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
---
|
||||
// Override of Starlight's Hero so the splash page's visual slot holds the
|
||||
// live size chart instead of an image. Structure and styles mirror
|
||||
// @astrojs/starlight/components/Hero.astro (0.39.1) — a page that declares
|
||||
// `hero.image` still gets the stock image treatment; pages without one get
|
||||
// the chart.
|
||||
import { Image } from 'astro:assets'
|
||||
import { LinkButton } from '@astrojs/starlight/components'
|
||||
import SizeChart from './SizeChart.astro'
|
||||
|
||||
// mirrors starlight's own constant; not a public export
|
||||
const PAGE_TITLE_ID = '_top'
|
||||
|
||||
const { data } = Astro.locals.starlightRoute.entry
|
||||
const { title = data.title, tagline, image, actions = [] } = data.hero || {}
|
||||
|
||||
const imageAttrs = {
|
||||
loading: 'eager' as const,
|
||||
decoding: 'async' as const,
|
||||
width: 400,
|
||||
height: 400,
|
||||
alt: image?.alt || '',
|
||||
}
|
||||
|
||||
let darkImage: ImageMetadata | undefined
|
||||
let lightImage: ImageMetadata | undefined
|
||||
let rawHtml: string | undefined
|
||||
if (image) {
|
||||
if ('file' in image) {
|
||||
darkImage = image.file
|
||||
} else if ('dark' in image) {
|
||||
darkImage = image.dark
|
||||
lightImage = image.light
|
||||
} else {
|
||||
rawHtml = image.html
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<div class="hero">
|
||||
{
|
||||
darkImage && (
|
||||
<Image
|
||||
src={darkImage}
|
||||
{...imageAttrs}
|
||||
class:list={{ 'light:sl-hidden': Boolean(lightImage) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{lightImage && <Image src={lightImage} {...imageAttrs} class="dark:sl-hidden" />}
|
||||
{rawHtml && <div class="hero-html sl-flex" set:html={rawHtml} />}
|
||||
{!image && <div class="hero-chart sl-flex"><SizeChart /></div>}
|
||||
<div class="sl-flex stack">
|
||||
<div class="sl-flex copy">
|
||||
<h1 id={PAGE_TITLE_ID} data-page-title set:html={title} />
|
||||
{tagline && <div class="tagline" set:html={tagline} />}
|
||||
</div>
|
||||
{
|
||||
actions.length > 0 && (
|
||||
<div class="sl-flex actions">
|
||||
{actions.map(
|
||||
({ attrs: { class: className, ...attrs } = {}, icon, link: href, text, variant }) => (
|
||||
<LinkButton {href} {variant} icon={icon?.name} class:list={[className]} {...attrs}>
|
||||
{text}
|
||||
{icon?.html && <Fragment set:html={icon.html} />}
|
||||
</LinkButton>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 100%;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.hero > img,
|
||||
.hero > .hero-html {
|
||||
object-fit: contain;
|
||||
width: min(70%, 20rem);
|
||||
height: auto;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* the chart is a layout box, not a picture: it reads better a little wider
|
||||
than the stock image slot and must not be squeezed below ~17rem */
|
||||
.hero > .hero-chart {
|
||||
width: min(100%, 24rem);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.stack {
|
||||
flex-direction: column;
|
||||
gap: clamp(1.5rem, calc(1.5rem + 1vw), 2rem);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.copy {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.copy > * {
|
||||
max-width: 50ch;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(var(--sl-text-3xl), calc(0.25rem + 5vw), var(--sl-text-6xl));
|
||||
line-height: var(--sl-line-height-headings);
|
||||
font-weight: 600;
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: clamp(var(--sl-text-base), calc(0.0625rem + 2vw), var(--sl-text-xl));
|
||||
color: var(--sl-color-gray-2);
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: 1rem 2rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 50rem) {
|
||||
.hero {
|
||||
grid-template-columns: 6fr 5fr;
|
||||
gap: 3%;
|
||||
padding-block: clamp(2.5rem, calc(1rem + 10vmin), 10rem);
|
||||
}
|
||||
|
||||
.hero > img,
|
||||
.hero > .hero-html {
|
||||
order: 2;
|
||||
width: min(100%, 25rem);
|
||||
}
|
||||
|
||||
.hero > .hero-chart {
|
||||
order: 2;
|
||||
width: min(100%, 27rem);
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.stack {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.copy {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
145
docs/src/components/SizeChart.astro
Normal file
145
docs/src/components/SizeChart.astro
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
---
|
||||
// Hero visual: emphasis-form horizontal bar chart. WCB in the accent hue,
|
||||
// every other library in the de-emphasis gray. One series, so no legend —
|
||||
// each bar is direct-labeled with its name and value. Deliberately bare:
|
||||
// the full methodology and feature matrix live in /comparison/.
|
||||
const data = [
|
||||
{ name: 'vanilla', kb: 0.2 },
|
||||
{ name: 'web-component-base', kb: 2.6, accent: true },
|
||||
{ name: '@elenajs/core', kb: 3.3 },
|
||||
{ name: 'lit', kb: 5.3 },
|
||||
{ name: 'fast-element', kb: 12.2 },
|
||||
]
|
||||
const max = Math.max(...data.map((d) => d.kb))
|
||||
---
|
||||
|
||||
<figure
|
||||
class="viz-root"
|
||||
aria-label="Bundle size of the same counter component per library, in kilobytes, minified and brotli-compressed. Vanilla 0.2, web-component-base 2.6, elenajs core 3.3, lit 5.3, fast-element 12.2."
|
||||
>
|
||||
<div class="chart">
|
||||
{
|
||||
data.map((d) => (
|
||||
<div class={`row${d.accent ? ' is-accent' : ''}`}>
|
||||
<div class="name">{d.name}</div>
|
||||
<div class="track">
|
||||
<div class="bar" style={`width: ${(d.kb / max) * 100}%`} />
|
||||
</div>
|
||||
<div class="value">{d.kb} kB</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<figcaption><a href="/comparison/">See comparison</a></figcaption>
|
||||
</figure>
|
||||
|
||||
<style>
|
||||
.viz-root {
|
||||
/* light */
|
||||
--surface: #fcfcfb;
|
||||
--text-primary: #0b0b0b;
|
||||
--text-secondary: #52514e;
|
||||
--muted: #898781;
|
||||
--baseline: #c3c2b7;
|
||||
--accent: #2a78d6;
|
||||
--context: #898781;
|
||||
--ring: rgba(11, 11, 11, 0.1);
|
||||
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 1.1rem 1.2rem 0.9rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--ring);
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:where(:not([data-theme='light'])) .viz-root {
|
||||
--surface: #1a1a19;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--baseline: #383835;
|
||||
--accent: #3987e5;
|
||||
--ring: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
:root[data-theme='dark'] .viz-root {
|
||||
--surface: #1a1a19;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--baseline: #383835;
|
||||
--accent: #3987e5;
|
||||
--ring: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 2px surface gap between adjacent bars */
|
||||
gap: 2px;
|
||||
border-left: 1px solid var(--baseline);
|
||||
padding-left: 0.7rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 9rem) minmax(0, 1fr) 3.4rem;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.25;
|
||||
color: var(--text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.is-accent .name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.track {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 13px;
|
||||
min-width: 3px;
|
||||
background: var(--context);
|
||||
/* rounded data-end only; the baseline end stays square */
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.is-accent .bar {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 0.76rem;
|
||||
text-align: right;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.is-accent .value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin-top: 0.8rem;
|
||||
padding-left: 0.7rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
figcaption a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
figcaption a:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
55
docs/src/content/docs/api/cem-plugin.md
Normal file
55
docs/src/content/docs/api/cem-plugin.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
title: cem-plugin
|
||||
slug: api/cem-plugin
|
||||
description: The wcbStaticProps CEM analyzer plugin.
|
||||
---
|
||||
|
||||
A plugin for
|
||||
[`@custom-elements-manifest/analyzer`](https://custom-elements-manifest.open-wc.org/)
|
||||
that teaches it to read wcb's `static props` object.
|
||||
|
||||
```js
|
||||
import { wcbStaticProps } from 'web-component-base/cem-plugin'
|
||||
// also available as the module's default export
|
||||
import wcbStaticProps from 'web-component-base/cem-plugin'
|
||||
```
|
||||
|
||||
See the [CEM Analyzer Plugin guide](/cem-plugin/) for setup, Storybook and
|
||||
editor integration.
|
||||
|
||||
## `wcbStaticProps()`
|
||||
|
||||
Takes no arguments and returns an analyzer plugin object.
|
||||
|
||||
| Field | Type | |
|
||||
| -------------- | ----------------------- | ----------------------------------- |
|
||||
| `name` | `string` | the plugin name |
|
||||
| `analyzePhase` | `(ctx: any) => void` | the analyzer hook |
|
||||
|
||||
```js
|
||||
// custom-elements-manifest.config.mjs
|
||||
import { wcbStaticProps } from 'web-component-base/cem-plugin'
|
||||
|
||||
export default {
|
||||
globs: ['src/**/*.js'],
|
||||
outdir: '.',
|
||||
plugins: [wcbStaticProps()],
|
||||
}
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
For each class that extends `WebComponent`, it reads the `static props`
|
||||
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`
|
||||
or `string`
|
||||
- the **default value** as written
|
||||
|
||||
Without it the analyzer sees `props` as one opaque static field and emits no
|
||||
attributes, so editor completion and Storybook controls have nothing to read.
|
||||
|
||||
The `static props` object may be declared inline or as an identifier resolved
|
||||
from the same source file.
|
||||
85
docs/src/content/docs/api/html.md
Normal file
85
docs/src/content/docs/api/html.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
---
|
||||
title: html
|
||||
slug: api/html
|
||||
description: The html tagged template function and the vnode shape it produces.
|
||||
---
|
||||
|
||||
A tagged template function that turns markup into a vnode tree, for use as a
|
||||
component's [`template`](/api/web-component/#template).
|
||||
|
||||
```js
|
||||
import { html } from 'web-component-base'
|
||||
// or
|
||||
import { html } from 'web-component-base/html.js'
|
||||
```
|
||||
|
||||
```js
|
||||
get template() {
|
||||
return html`<p class="greeting">Hello, ${this.props.name}!</p>`
|
||||
}
|
||||
```
|
||||
|
||||
It is [htm](https://github.com/developit/htm) bound to a hyperscript factory, so
|
||||
the full htm syntax applies: standard HTML, self-closing tags, `${}`
|
||||
interpolation in text and attribute positions, spread props (`...${obj}`), and
|
||||
optional closing tags (`<//>`).
|
||||
|
||||
## Return value
|
||||
|
||||
| Markup | Returns |
|
||||
| ----------------- | ------------------------------------------- |
|
||||
| a single root | one vnode object |
|
||||
| several roots | an array of vnodes |
|
||||
| nothing | `undefined` |
|
||||
|
||||
A vnode is a plain object:
|
||||
|
||||
```js
|
||||
html`<p class="a">hi</p>`
|
||||
// { type: 'p', props: { class: 'a' }, children: ['hi'] }
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------- | ----------------- | ---------------------------------------------------- |
|
||||
| `type` | `string` | the tag name |
|
||||
| `props` | `object \| null` | attributes and properties as written |
|
||||
| `children` | `any[]` | child vnodes and text; text stays as a raw value |
|
||||
|
||||
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
|
||||
render nothing, and it empties the rendered subtree rather than leaving the
|
||||
previous render on screen.
|
||||
|
||||
See it live: [Templating demo ↗](https://demo.webcomponent.io/examples/templating/)
|
||||
|
||||
## How props are applied
|
||||
|
||||
Each entry in `props` is applied by [`applyProp`](/api/utils/#applypropel-prop-value),
|
||||
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 —
|
||||
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
|
||||
4. anything else is serialized and set as an attribute
|
||||
|
||||
The same rule applies to freshly created and patched elements, so a prop behaves
|
||||
identically on first render and re-render.
|
||||
|
||||
A `style` prop accepts an object of camelCase CSS properties:
|
||||
|
||||
```js
|
||||
html`<div style=${{ color: 'red', padding: '1em' }}>x</div>`
|
||||
```
|
||||
|
||||
See it live: [Style objects demo ↗](https://demo.webcomponent.io/examples/style-objects/)
|
||||
|
||||
## Re-rendering
|
||||
|
||||
Returning a vnode tree opts into in-place reconciliation: elements of the same
|
||||
tag are reused, only changed props and text are touched, and leftover nodes are
|
||||
trimmed. See [Template vs Render](/template-vs-render/) for what that preserves
|
||||
and the non-keyed matching caveat. See it live: [Render reconciliation demo ↗](https://demo.webcomponent.io/examples/render-reconciliation/)
|
||||
136
docs/src/content/docs/api/utils.md
Normal file
136
docs/src/content/docs/api/utils.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
---
|
||||
title: Utilities
|
||||
slug: api/utils
|
||||
description: Case conversion, attribute serialization, element creation and the vnode reconciler.
|
||||
---
|
||||
|
||||
The helpers `WebComponent` uses internally, exported so you can use them
|
||||
directly. Import from the `utils` entry point or each module separately:
|
||||
|
||||
```js
|
||||
import { serialize, getKebabCase } from 'web-component-base/utils'
|
||||
// or
|
||||
import { serialize } from 'web-component-base/utils/serialize.js'
|
||||
```
|
||||
|
||||
See it live: [Just the parts demo ↗](https://demo.webcomponent.io/examples/just-parts/) builds a component from these helpers without extending the base class.
|
||||
|
||||
## Case conversion
|
||||
|
||||
### `getCamelCase(kebab)`
|
||||
|
||||
Converts a kebab-case attribute name to its camelCase prop key.
|
||||
|
||||
| Parameter | Type | |
|
||||
| ----------- | -------- | ------------------------ |
|
||||
| `kebab` | `string` | the attribute name |
|
||||
| **returns** | `string` | the prop key |
|
||||
|
||||
```js
|
||||
getCamelCase('max-count') // 'maxCount'
|
||||
```
|
||||
|
||||
### `getKebabCase(str)`
|
||||
|
||||
Converts a camelCase prop key to its kebab-case attribute name. This is the
|
||||
mapping `observedAttributes` uses.
|
||||
|
||||
| Parameter | Type | |
|
||||
| ----------- | -------- | ------------------------ |
|
||||
| `str` | `string` | the prop key |
|
||||
| **returns** | `string` | the attribute name |
|
||||
|
||||
```js
|
||||
getKebabCase('maxCount') // 'max-count'
|
||||
```
|
||||
|
||||
Consecutive capitals are treated as one word, so `parseHTML` becomes
|
||||
`parse-html`.
|
||||
|
||||
## Attribute serialization
|
||||
|
||||
### `serialize(value)`
|
||||
|
||||
Converts a value to its attribute string form.
|
||||
|
||||
| Parameter | Type | |
|
||||
| ----------- | -------- | -------------------------------------- |
|
||||
| `value` | `any` | the value to serialize |
|
||||
| **returns** | `string` | the attribute value |
|
||||
|
||||
Numbers, booleans and objects go through `JSON.stringify`; strings and
|
||||
everything else pass through unchanged.
|
||||
|
||||
### `deserialize(value, type)`
|
||||
|
||||
Parses an attribute string back into a value of the given declared type — the
|
||||
inverse of `serialize()`.
|
||||
|
||||
| Parameter | Type | |
|
||||
| ----------- | -------- | --------------------------------------------------- |
|
||||
| `value` | `string` | the attribute value |
|
||||
| `type` | `string` | `'boolean'`, `'number'`, `'object'`, `'undefined'` or `'string'` |
|
||||
| **returns** | `any` | the parsed value |
|
||||
|
||||
`'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.
|
||||
|
||||
## Elements
|
||||
|
||||
### `createElement(tree)`
|
||||
|
||||
Builds real DOM from a vnode tree.
|
||||
|
||||
| Parameter | Type | |
|
||||
| ----------- | -------- | ---------------------------------------------------- |
|
||||
| `tree` | `any` | a vnode, an array of vnodes, or a text value |
|
||||
| **returns** | `Node` | an element, a `DocumentFragment`, or a text node |
|
||||
|
||||
An array becomes a `DocumentFragment`; a value with no `type` becomes a text
|
||||
node. Props are applied with `applyProp()` and children are created
|
||||
recursively.
|
||||
|
||||
### `applyProp(el, prop, value)`
|
||||
|
||||
Applies a single vnode prop to an element, using the rule described in
|
||||
[html](/api/html/#how-props-are-applied).
|
||||
|
||||
| Parameter | Type | |
|
||||
| --------- | --------- | ---------------------------------- |
|
||||
| `el` | `Element` | the element to apply the prop to |
|
||||
| `prop` | `string` | the prop name as written in the vnode |
|
||||
| `value` | `any` | the prop value |
|
||||
|
||||
Shared with the reconciler, so a patched element gets props by exactly the same
|
||||
rule as a freshly created one.
|
||||
|
||||
## Reconciler
|
||||
|
||||
These power the in-place re-render described in
|
||||
[Template vs Render](/template-vs-render/). Matching is **index-based and
|
||||
non-keyed**.
|
||||
|
||||
### `patchChildren(parent, oldChildren, newChildren)`
|
||||
|
||||
Reconciles a parent node's children from one vnode list to another, patching
|
||||
matches in place and trimming leftovers.
|
||||
|
||||
| Parameter | Type | |
|
||||
| ------------- | ------ | ---------------------------------------------- |
|
||||
| `parent` | `Node` | the parent node to patch into |
|
||||
| `oldChildren` | `any` | the previous vnode children, or previous tree |
|
||||
| `newChildren` | `any` | the new vnode children, or new tree |
|
||||
|
||||
### `patchNode(parent, dom, oldVnode, newVnode)`
|
||||
|
||||
Reconciles a single node position. Reuses `dom` when the vnode type matches,
|
||||
otherwise replaces it.
|
||||
|
||||
| Parameter | Type | |
|
||||
| ---------- | ----------------- | ---------------------------------------- |
|
||||
| `parent` | `Node` | the parent node being patched |
|
||||
| `dom` | `Node \| null` | the existing node at this index, if any |
|
||||
| `oldVnode` | `any` | the vnode that produced `dom`, if known |
|
||||
| `newVnode` | `any` | the vnode to render |
|
||||
265
docs/src/content/docs/api/web-component.md
Normal file
265
docs/src/content/docs/api/web-component.md
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
---
|
||||
title: WebComponent
|
||||
slug: api/web-component
|
||||
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
|
||||
module:
|
||||
|
||||
```js
|
||||
import { WebComponent } from 'web-component-base'
|
||||
// or
|
||||
import { WebComponent } from 'web-component-base/WebComponent.js'
|
||||
```
|
||||
|
||||
`WebComponent` extends `HTMLElement`, so a subclass is registered with
|
||||
`customElements.define()` like any other custom element.
|
||||
|
||||
In TypeScript, pass the shape of `static props` as a type argument to get a
|
||||
typed `this.props`:
|
||||
|
||||
```ts
|
||||
const props = { variant: 'primary', disabled: false }
|
||||
|
||||
class CozyButton extends WebComponent<typeof props> {
|
||||
static props = props
|
||||
}
|
||||
```
|
||||
|
||||
## Static properties
|
||||
|
||||
### `static props`
|
||||
|
||||
An object of declared prop names and their default values.
|
||||
|
||||
```js
|
||||
static props = { count: 0, label: 'hi', disabled: false }
|
||||
```
|
||||
|
||||
It drives three things at once:
|
||||
|
||||
- **Observed attributes.** Each key is kebab-cased, so `maxCount` observes
|
||||
`max-count`.
|
||||
- **The runtime type guard.** The `typeof` each default becomes the prop's
|
||||
declared type. A write of a different type is rejected (see
|
||||
[`strictProps`](#static-strictprops)).
|
||||
- **The compile-time type of `this.props`** when the object is passed as the
|
||||
class type argument.
|
||||
|
||||
Defaults are copied per instance with `structuredClone`, so object and array
|
||||
defaults are never shared between instances. Values that cannot be cloned
|
||||
(functions, class instances) are kept by reference instead of throwing.
|
||||
|
||||
On first use of each class, defaults that cannot reflect to an attribute are
|
||||
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 `true` boolean default is discouraged because HTML has no true-by-default
|
||||
boolean attribute: absence would have to mean both "false" and "default". Name
|
||||
the prop for its `false` state (`disabled`, not `enabled`).
|
||||
|
||||
See it live: [Props blueprint demo ↗](https://demo.webcomponent.io/examples/props-blueprint/)
|
||||
|
||||
### `static styles`
|
||||
|
||||
CSS adopted into the shadow root as constructable stylesheet(s).
|
||||
|
||||
```js
|
||||
static shadowRootInit = { mode: 'open' }
|
||||
static styles = `p { color: red; }`
|
||||
```
|
||||
|
||||
Accepts a string, a `CSSStyleSheet`, or an array mixing both. An array is
|
||||
adopted in declaration order, so a shared token sheet can come first and
|
||||
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
|
||||
per render.
|
||||
|
||||
Requires [`shadowRootInit`](#static-shadowrootinit). Without a shadow root
|
||||
there is nothing to adopt into, and the failure is reported with
|
||||
`console.error` rather than thrown.
|
||||
|
||||
See it live: [Constructable styles demo ↗](https://demo.webcomponent.io/examples/constructed-styles/)
|
||||
|
||||
### `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
|
||||
root is attached during construction and becomes the render target.
|
||||
|
||||
```js
|
||||
static shadowRootInit = { mode: 'open' }
|
||||
```
|
||||
|
||||
Without it the component renders into its own light DOM.
|
||||
|
||||
See it live: [Shadow DOM demo ↗](https://demo.webcomponent.io/examples/use-shadow/)
|
||||
|
||||
### `static strictProps`
|
||||
|
||||
When `true`, assigning a value whose type does not match the declared type
|
||||
throws a `TypeError`.
|
||||
|
||||
```js
|
||||
static strictProps = true
|
||||
```
|
||||
|
||||
The default is to report the violation with `console.error` and skip the write,
|
||||
so a stray assignment cannot halt `render()` or `onChanges()`.
|
||||
|
||||
Either way, `null` and `undefined` are always allowed.
|
||||
|
||||
See it live: [Prop type enforcement demo ↗](https://demo.webcomponent.io/examples/strict-props/)
|
||||
|
||||
### `static get observedAttributes`
|
||||
|
||||
Returns the kebab-cased keys of [`static props`](#static-props). Provided by the
|
||||
base class; you do not normally define it yourself.
|
||||
|
||||
## Instance properties
|
||||
|
||||
### `props`
|
||||
|
||||
Read-only accessor returning a `Proxy` over the component's prop values. Read
|
||||
and write camelCase keys directly:
|
||||
|
||||
```js
|
||||
this.props.count += 1
|
||||
```
|
||||
|
||||
A write that changes the value reflects to the matching attribute through
|
||||
[`toAttribute()`](#toattributename-value), which in turn triggers a render.
|
||||
Assigning the value it already holds does nothing.
|
||||
|
||||
### `template`
|
||||
|
||||
Read-only getter returning what the component renders. Two kinds are supported:
|
||||
|
||||
- an [`html`](/api/html/) tagged template — a vnode tree, reconciled in place
|
||||
on re-render
|
||||
- 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
|
||||
`''` empties the rendered subtree, which is how a component renders nothing
|
||||
without disturbing light-DOM children a consumer slotted in.
|
||||
|
||||
Switching between the two kinds is safe in either direction: a string render
|
||||
resets the vnode bookkeeping so the next vnode render rebuilds from scratch.
|
||||
|
||||
The base implementation returns `''`.
|
||||
|
||||
See it live: [Templating demo ↗](https://demo.webcomponent.io/examples/templating/)
|
||||
|
||||
### `render()`
|
||||
|
||||
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
|
||||
[Template vs Render](/template-vs-render/) for what that preserves and the
|
||||
non-keyed matching caveat.
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
Override any of these; all are no-ops by default.
|
||||
|
||||
| Hook | When it runs |
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| `onInit()` | on connect, before the first render |
|
||||
| `afterViewInit()` | on connect, after the first render |
|
||||
| `onChanges(changes)` | after an observed attribute changes |
|
||||
| `onDestroy()` | when the element is disconnected |
|
||||
|
||||
On connect the order is always: default reflection → `onInit()` → `render()` →
|
||||
`afterViewInit()`. Attribute-driven renders and `onChanges()` calls that the
|
||||
platform fires *before* connect are buffered, so `onInit()` is guaranteed to run
|
||||
before the first render even for attributes written in markup.
|
||||
|
||||
`onChanges()` receives:
|
||||
|
||||
| Field | Type | Description |
|
||||
| --------------- | -------- | -------------------------------------------- |
|
||||
| `property` | `string` | camelCase prop key, matching `props` access |
|
||||
| `attribute` | `string` | kebab-case attribute name that changed |
|
||||
| `previousValue` | `any` | value before the change |
|
||||
| `currentValue` | `any` | value after the change |
|
||||
|
||||
See [Life-cycle Hooks](/life-cycle-hooks/) for worked examples. See it live: [Lifecycle order demo ↗](https://demo.webcomponent.io/examples/lifecycle-order/) and [onChanges payload demo ↗](https://demo.webcomponent.io/examples/on-changes/)
|
||||
|
||||
## Attribute converters
|
||||
|
||||
Override these to control how one prop crosses the prop/attribute boundary, and
|
||||
call `super` for the props you do not handle.
|
||||
|
||||
### `toAttribute(name, value)`
|
||||
|
||||
Converts a prop value into the attribute value that reflects it.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | -------- | ---------------------------------------- |
|
||||
| `name` | `string` | camelCase prop key |
|
||||
| `value` | `any` | the prop value being reflected |
|
||||
| **returns** | `string \| null` | the attribute value, or `null` to remove the attribute |
|
||||
|
||||
Returning `null` **removes** the attribute. That is how a `false` boolean
|
||||
becomes an absent attribute, and it works for any prop.
|
||||
|
||||
```js
|
||||
toAttribute(name, value) {
|
||||
if (name === 'point') return `${value.x},${value.y}`
|
||||
return super.toAttribute(name, value)
|
||||
}
|
||||
```
|
||||
|
||||
### `fromAttribute(name, value)`
|
||||
|
||||
Converts an attribute value into the prop value it represents — the inverse of
|
||||
`toAttribute()`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | -------- | --------------------------------------------- |
|
||||
| `name` | `string` | camelCase prop key |
|
||||
| `value` | `string` | the attribute value, never `null` |
|
||||
| **returns** | `any` | the value to store on `this.props[name]` |
|
||||
|
||||
Only called for attributes that are **present**. Removal is handled by the
|
||||
declared-default reset instead, so a converter never has to handle `null`.
|
||||
|
||||
A malformed value for a typed prop falls back to the raw string rather than
|
||||
throwing, so `render()` and `onChanges()` are never skipped.
|
||||
|
||||
See it live: [Custom attribute converters demo ↗](https://demo.webcomponent.io/examples/attribute-converters/) and [Typed props demo ↗](https://demo.webcomponent.io/examples/type-restore/)
|
||||
|
||||
## Boolean props
|
||||
|
||||
Boolean props follow the HTML convention in both directions: **presence means
|
||||
`true`, absence means `false`**.
|
||||
|
||||
| State | Attribute | `toAttribute` returns |
|
||||
| ------- | -------------------- | --------------------- |
|
||||
| `true` | present, empty value | `''` |
|
||||
| `false` | absent | `null` |
|
||||
|
||||
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.
|
||||
|
||||
Use `toggleAttribute(name, bool)` to set them. Writing
|
||||
`setAttribute(name, String(bool))` always means `true`; wcb warns in the console
|
||||
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
|
||||
be declared as **string** props.
|
||||
|
||||
See it live: [Boolean props demo ↗](https://demo.webcomponent.io/examples/boolean-props/)
|
||||
202
docs/src/content/docs/guides/cem-plugin.mdx
Normal file
202
docs/src/content/docs/guides/cem-plugin.mdx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
---
|
||||
title: CEM Analyzer Plugin
|
||||
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
|
||||
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
|
||||
specification](https://github.com/webcomponents/custom-elements-manifest) for
|
||||
the file format itself.
|
||||
|
||||
We provide a CEM Analyzer Plugin `web-component-base/cem-plugin` which allows using the `@custom-elements-manifest/analyzer` by handling wcb's `static props` object. In this guide we show how to use this plugin and the benefits for using it with [Storybook](#storybook) and [code editors](#code-editors).
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
npm i -D @custom-elements-manifest/analyzer
|
||||
```
|
||||
|
||||
## Configure
|
||||
|
||||
```js
|
||||
// custom-elements-manifest.config.mjs
|
||||
import { wcbStaticProps } from 'web-component-base/cem-plugin'
|
||||
|
||||
export default {
|
||||
globs: ['src/**/*.js'],
|
||||
outdir: '.',
|
||||
plugins: [wcbStaticProps()],
|
||||
}
|
||||
```
|
||||
|
||||
Then run the analyzer:
|
||||
|
||||
```sh
|
||||
npx cem analyze
|
||||
```
|
||||
|
||||
<Aside type="caution" title="LOOK OUT! `cem analyze` fails by hanging, not by erroring">
|
||||
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
|
||||
`**/*.{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.
|
||||
|
||||
With globs scoped to your source it should finish in well under a second.
|
||||
|
||||
</Aside>
|
||||
|
||||
## What it produces
|
||||
|
||||
Given a component:
|
||||
|
||||
```js
|
||||
const props = { variant: 'primary', disabled: false, maxCount: 3 }
|
||||
|
||||
export class CozyButton extends WebComponent {
|
||||
static props = props
|
||||
static shadowRootInit = { mode: 'open' }
|
||||
static styles = ':host { display: inline-block }'
|
||||
get template() {
|
||||
return html`<button>${this.props.variant}</button>`
|
||||
}
|
||||
}
|
||||
customElements.define('cozy-button', CozyButton)
|
||||
```
|
||||
|
||||
`custom-elements.json` gains a typed attribute and a matching public field per prop:
|
||||
|
||||
| attribute | type | field | default |
|
||||
| ----------- | --------- | ---------- | ----------- |
|
||||
| `variant` | `string` | `variant` | `'primary'` |
|
||||
| `disabled` | `boolean` | `disabled` | `false` |
|
||||
| `max-count` | `number` | `maxCount` | `3` |
|
||||
|
||||
...and `props`, `shadowRootInit`, `styles`, `strictProps`, `observedAttributes` and `template` are stripped from the public surface.
|
||||
|
||||
Two details worth knowing:
|
||||
|
||||
- **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.
|
||||
|
||||
## Storybook
|
||||
|
||||
Storybook's web-components renderer builds **autodocs and controls** from a [Custom Elements Manifest](https://github.com/webcomponents/custom-elements-manifest).
|
||||
|
||||
## Wire it into Storybook
|
||||
|
||||
```js
|
||||
// .storybook/preview.js
|
||||
import { setCustomElementsManifest } from '@storybook/web-components-vite'
|
||||
import manifest from '../custom-elements.json'
|
||||
|
||||
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`:
|
||||
|
||||
```js
|
||||
// cozy-button.stories.js
|
||||
import { html } from 'lit'
|
||||
import '../src/cozy-button.js'
|
||||
|
||||
export default {
|
||||
title: 'Cozy/Button',
|
||||
component: 'cozy-button', // ← no argTypes needed
|
||||
render: ({ variant, disabled }) => html`
|
||||
<cozy-button variant=${variant} disabled=${disabled}></cozy-button>
|
||||
`,
|
||||
}
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
<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.
|
||||
</Aside>
|
||||
|
||||
First, point tooling at the manifest from your `package.json`. This is the field every option here uses to discover it:
|
||||
|
||||
```json
|
||||
{
|
||||
"customElements": "custom-elements.json"
|
||||
}
|
||||
```
|
||||
|
||||
### 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:
|
||||
|
||||
```sh
|
||||
npm i -D cem-plugin-vs-code-custom-data-generator
|
||||
```
|
||||
|
||||
```js
|
||||
// custom-elements-manifest.config.mjs
|
||||
import { wcbStaticProps } from 'web-component-base/cem-plugin'
|
||||
import { generateCustomData } from 'cem-plugin-vs-code-custom-data-generator'
|
||||
|
||||
export default {
|
||||
globs: ['src/**/*.js'],
|
||||
outdir: '.',
|
||||
plugins: [wcbStaticProps(), generateCustomData()],
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// .vscode/settings.json
|
||||
{
|
||||
"html.customData": ["./vscode.html-custom-data.json"]
|
||||
}
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
`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
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
<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.
|
||||
</Aside>
|
||||
52
docs/src/content/docs/guides/comparison.md
Normal file
52
docs/src/content/docs/guides/comparison.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
title: 'WCB & Similar Libraries'
|
||||
slug: comparison
|
||||
---
|
||||
|
||||
The releases since v5 gave the `WebComponent` base class a stricter compliance with custom elements specifications, quality of life improvements, and overall robustness by combining JS components authoring expectations and stable HTML behaviors. We now have in-place re-rendering, HTML boolean semantics and overrideable attribute converters, among other improvements.
|
||||
|
||||
This page puts these benefits and their cost in context: how much does WCB weigh in size compared to similar web-component libraries, what does each library buy you over writing custom elements from scratch, and when is WCB the right choice.
|
||||
|
||||
## Measured size: the same component in each library
|
||||
|
||||
Numbers below are **measured** from the same minimal counter component (one reactive `count` prop, a click handler, a re-render on change) written in each library, bundled with `esbuild --bundle --minify --format=esm`, and compressed with gzip (level 9) and brotli (quality 11). This is the real "cost of your first component": library runtime + component code, everything the browser downloads.
|
||||
|
||||
| Library | Version | Minified | Gzip | Brotli |
|
||||
| ------------------------- | ------- | -------- | ------- | ---------- |
|
||||
| **web-component-base** | 6.1.0 | 6.7 kB | 2.9 kB | **2.6 kB** |
|
||||
| `@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 |
|
||||
|
||||
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:
|
||||
|
||||
| Capability | WCB 6.1 | Lit 3.3 | Elena 1.0 | FAST 3.0 |
|
||||
| -------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------- | --------------------------------------- | ------------------------------------------------ |
|
||||
| Declarative templates | ✅ `html` tagged templates (htm) or plain strings | ✅ `lit-html` tagged templates | ✅ `html` tagged templates | ✅ typed templates with binding expressions |
|
||||
| Reactive props ⇄ attributes | ✅ `static props`, overrideable converters | ✅ `static properties` with converters | ✅ `static props`, opt-in reflection | ✅ `@attr` / observables |
|
||||
| Update strategy | In-place patch (index-based, non-keyed) | Part-based: only touched bindings update, keyed `repeat` | Batched re-renders | Fine-grained observable bindings, keyed `repeat` |
|
||||
| Preserves DOM state on re-render | ✅ since v5.2 | ✅ | ✅ | ✅ |
|
||||
| Update batching / scheduling | ⚠️ renders per prop write | ✅ async batched, `updateComplete` | ✅ batched, `updateComplete` | ✅ queued/batched |
|
||||
| Keyed list reconciliation | ⚠️ positional | ✅ `repeat` directive | ❌ | ✅ `repeat` with recycling controls |
|
||||
| Light DOM by default | ✅ (shadow DOM opt-in via `static shadowRootInit`) | ❌ shadow DOM by default | ✅ (shadow opt-in) | ❌ shadow DOM by default |
|
||||
| Scoped styles | ✅ `static styles` + constructable stylesheets (needs shadow root) | ✅ shadow-scoped CSS | ✅ scoped without shadow DOM | ✅ shadow-scoped + design tokens |
|
||||
| SSR / hydration story | ✅ attribute-driven state renders from any server | ✅ `@lit-labs/ssr` + hydration | ✅ HTML/CSS-first, server utilities | ⚠️ experimental SSR |
|
||||
| Works with zero build tooling | ✅ import from CDN, no compiler | ✅ (buildless possible, decorators need tooling) | ✅ | ⚠️ practical with tooling |
|
||||
| Editor/IDE tooling | ✅ typed props + [CEM analyzer plugin](/cem-plugin/) | ✅ extensive (analyzer, TS decorators, IDE plugins) | ✅ CEM-focused | ✅ TS-first |
|
||||
| Lifecycle hooks | `onInit`, `afterViewInit`, `onChanges`, `onDestroy` | full reactive update lifecycle | `willUpdate`, `firstUpdated`, `updated` | full lifecycle + behaviors |
|
||||
| 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.
|
||||
:::
|
||||
|
||||
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._
|
||||
|
|
@ -3,6 +3,34 @@ title: Examples
|
|||
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
|
||||
the source alongside each demo.
|
||||
|
||||
| Demo | Shows |
|
||||
| ---- | ----- |
|
||||
| [Boolean props ↗](https://demo.webcomponent.io/examples/boolean-props/) | presence/absence reflection, `toggleAttribute`, `[flag]` selectors |
|
||||
| [Custom attribute converters ↗](https://demo.webcomponent.io/examples/attribute-converters/) | `toAttribute`/`fromAttribute` for `Date` and array props |
|
||||
| [Props blueprint ↗](https://demo.webcomponent.io/examples/props-blueprint/) | `static props` as the single source of defaults and types |
|
||||
| [Prop type enforcement ↗](https://demo.webcomponent.io/examples/strict-props/) | `static strictProps` and the log-not-throw default |
|
||||
| [Compile-time prop types ↗](https://demo.webcomponent.io/examples/typed-props/) | typing `this.props` in TypeScript |
|
||||
| [Typed props ↗](https://demo.webcomponent.io/examples/type-restore/) | attribute round-trips restoring the declared type |
|
||||
| [Templating ↗](https://demo.webcomponent.io/examples/templating/) | string vs `html` tagged-template rendering |
|
||||
| [Render reconciliation ↗](https://demo.webcomponent.io/examples/render-reconciliation/) | in-place patching preserving focus, caret and input state |
|
||||
| [Style objects ↗](https://demo.webcomponent.io/examples/style-objects/) | calculated and conditional styles via the `style` prop |
|
||||
| [Shadow DOM ↗](https://demo.webcomponent.io/examples/use-shadow/) | `static shadowRootInit` |
|
||||
| [Constructable styles ↗](https://demo.webcomponent.io/examples/constructed-styles/) | `static styles`, including composing several sheets |
|
||||
| [Lifecycle order ↗](https://demo.webcomponent.io/examples/lifecycle-order/) | each hook logged as it fires |
|
||||
| [Attribute lifecycle ↗](https://demo.webcomponent.io/examples/attribute-lifecycle/) | how attribute changes drive the hooks |
|
||||
| [onChanges payload ↗](https://demo.webcomponent.io/examples/on-changes/) | camelCase `property` vs kebab-case `attribute` |
|
||||
| [Just the parts ↗](https://demo.webcomponent.io/examples/just-parts/) | using `html`/`createElement` without the base class |
|
||||
| [Kitchen sink ↗](https://demo.webcomponent.io/examples/demo/) | several features together |
|
||||
| [Single-file pen ↗](https://demo.webcomponent.io/examples/pens/counter-toggle.html) | counter and toggle in one HTML file |
|
||||
|
||||
## CodePen examples
|
||||
|
||||
### 1. To-Do App
|
||||
|
||||
A simple app that allows adding / completing tasks:
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ import {
|
|||
getCamelCase,
|
||||
getKebabCase,
|
||||
createElement,
|
||||
applyProp,
|
||||
patchNode,
|
||||
patchChildren,
|
||||
} from 'web-component-base/utils'
|
||||
|
||||
// or separate files
|
||||
|
|
|
|||
|
|
@ -3,28 +3,13 @@ title: Getting Started
|
|||
slug: getting-started
|
||||
---
|
||||
|
||||
import { Aside, Badge } from '@astrojs/starlight/components';
|
||||
|
||||
**Web Component Base (WCB)**
|
||||
<Badge text="alpha" variant="danger" /> is a zero-dependency, tiny JS base class for creating reactive [custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_Components/Using_custom_elements) easily.
|
||||
**Web Component Base (WCB)** is a zero-dependency, tiny JS base class for creating reactive [custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_Components/Using_custom_elements) easily.
|
||||
|
||||
When you extend the WebComponent class for your custom element, you only have to define the template and properties. Any change in an observed property's value will automatically cause the component UI to render.
|
||||
|
||||
The result is a reactive UI on property changes.
|
||||
|
||||
Note that there's a trade off between productivity & lightweight-ness here, and the project aims to help in writing custom elements in the same way more mature and better maintained projects already do. Please look into popular options such as [Microsoft's FASTElement](https://fast.design/) & [Google's LitElement](https://lit.dev/) as well.
|
||||
|
||||
## Project Status
|
||||
|
||||
Treat it as a **stable alpha** product. Though the public APIs are stable, most examples are only useful for simple atomic use-cases due to remaining work needed on the internals.
|
||||
|
||||
<Aside type="caution" title="Important">
|
||||
For building advanced interactions, there is an in-progress work on smart diffing to prevent component children being wiped on interaction.
|
||||
</Aside>
|
||||
|
||||
<Aside type="tip">
|
||||
If you have some complex needs, we recommend using the `WebComponent` base class with a more mature rendering approach like `lit-html`, and here's a demo for that: [View on CodePen](https://codepen.io/ayoayco-the-styleful/pen/ZEwNJBR?editors=1010).
|
||||
</Aside>
|
||||
You can see every feature running at [demo.webcomponent.io ↗](https://demo.webcomponent.io/) — a live gallery with the source alongside each demo. Individual demos are linked from the guide that covers them, and the full list is in [Examples](/examples/#live-demo-gallery).
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ slug: 'just-parts'
|
|||
|
||||
You don't have to extend the whole base class to use some features. All internals are exposed and usable separately so you can practically build the behavior on your own classes.
|
||||
|
||||
Here's an example of using the `html` tag template on a class that extends from vanilla `HTMLElement`... also [View on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/bGzJQJg?editors=1010).
|
||||
Here's an example of using the `html` tag template on a class that extends from vanilla `HTMLElement`... also [View on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/bGzJQJg?editors=1010), or see it live: [Just the parts demo ↗](https://demo.webcomponent.io/examples/just-parts/).
|
||||
|
||||
```js
|
||||
import { html } from 'https://unpkg.com/web-component-base/html'
|
||||
|
|
|
|||
|
|
@ -5,4 +5,6 @@ slug: library-size
|
|||
|
||||
All the functions and the base class in the library are minimalist by design and only contains what is needed for their purpose.
|
||||
|
||||
The main export (with `WebComponent` + `html`) is **1.7 kB** (min + gzip) according to [bundlephobia.com](https://bundlephobia.com/package/web-component-base@latest), and the `WebComponent` base class is **1.35 kB** (min + brotli) according to [size-limit](http://github.com/ai/size-limit).
|
||||
The main export (with `WebComponent` + `html`) is **1.7 kB** (min + gzip) according to [bundlephobia.com](https://bundlephobia.com/package/web-component-base@latest), and the `WebComponent` base class is **1.96 kB** (min + brotli) according to [size-limit](http://github.com/ai/size-limit).
|
||||
|
||||
Every change that moves this number is recorded in [`size-change-log.md`](https://github.com/ayo-run/wcb/blob/main/size-change-log.md), with the reason the bytes were spent and the benefit they bought.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ slug: life-cycle-hooks
|
|||
|
||||
Define behavior when certain events in the component's life cycle is triggered by providing hook methods
|
||||
|
||||
See it live: [Lifecycle order demo ↗](https://demo.webcomponent.io/examples/lifecycle-order/) logs each hook as it fires, and [Attribute lifecycle demo ↗](https://demo.webcomponent.io/examples/attribute-lifecycle/) shows how attribute changes drive them.
|
||||
|
||||
### onInit()
|
||||
|
||||
- Triggered when the component is connected to the DOM
|
||||
|
|
@ -81,6 +83,8 @@ class ClickableText extends WebComponent {
|
|||
|
||||
Use `property` to read the value straight off `props` (`this.props[property]`); use `attribute` when you need the raw attribute name.
|
||||
|
||||
See it live: [onChanges payload demo ↗](https://demo.webcomponent.io/examples/on-changes/)
|
||||
|
||||
```js
|
||||
import { WebComponent } from 'https://unpkg.com/web-component-base@latest/index.js'
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,166 @@ Therefore, this will tell the browser that the UI needs a render if the attribut
|
|||
The `props` property of `WebComponent` works like `HTMLElement.dataset`, except `dataset` is only for attributes prefixed with `data-`. A camelCase counterpart using `props` will give read/write access to any attribute, with or without the `data-` prefix.
|
||||
|
||||
Another advantage over `HTMLElement.dataset` is that `WebComponent.props` can hold primitive types 'number', 'boolean', 'object' and 'string'.
|
||||
|
||||
</Aside>
|
||||
|
||||
### Boolean props
|
||||
|
||||
Boolean props follow the HTML boolean-attribute convention, exactly like native
|
||||
`disabled` and `required`: **presence means `true`, absence means `false`.**
|
||||
|
||||
See it live: [Boolean props demo ↗](https://demo.webcomponent.io/examples/boolean-props/)
|
||||
|
||||
```js
|
||||
class FlagBox extends WebComponent {
|
||||
static props = { flag: false }
|
||||
}
|
||||
```
|
||||
|
||||
```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>
|
||||
|
||||
### Custom attribute conversion
|
||||
|
||||
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
|
||||
delegate everything else to `super`:
|
||||
|
||||
```js
|
||||
class EventCard extends WebComponent {
|
||||
static props = { when: new Date(0), title: '' }
|
||||
|
||||
toAttribute(name, value) {
|
||||
if (name === 'when') return value.toISOString().slice(0, 10)
|
||||
return super.toAttribute(name, value)
|
||||
}
|
||||
|
||||
fromAttribute(name, value) {
|
||||
if (name === 'when') return new Date(`${value}T00:00:00Z`)
|
||||
return super.fromAttribute(name, value)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<event-card when="2026-07-20"></event-card>
|
||||
<!-- props.when is a Date -->
|
||||
```
|
||||
|
||||
Both take the **camelCase prop key**, matching your `static props` declaration
|
||||
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:
|
||||
|
||||
```js
|
||||
toAttribute(name, value) {
|
||||
// an empty string means "no attribute at all" for this prop
|
||||
return value === '' ? null : super.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
|
||||
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
|
||||
normal. `fromAttribute` is called for attributes written from *outside* the
|
||||
component (markup, `setAttribute`, `toggleAttribute`).
|
||||
</Aside>
|
||||
|
||||
<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
|
||||
`string` and your parsed `Date` is refused.
|
||||
</Aside>
|
||||
|
||||
### Typed props in TypeScript
|
||||
|
||||
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 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:
|
||||
|
||||
```ts
|
||||
const props = {
|
||||
variant: 'primary',
|
||||
disabled: false,
|
||||
}
|
||||
|
||||
class CozyButton extends WebComponent<typeof props> {
|
||||
static props = props
|
||||
|
||||
get template() {
|
||||
this.props.variant // string
|
||||
this.props.disabled // boolean
|
||||
this.props.notAProp // ❌ compile error: not declared
|
||||
|
||||
this.props.disabled = 'yes' // ❌ compile error: string is not boolean
|
||||
return html`<button class=${this.props.variant}></button>`
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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">
|
||||
Values in the defaults object widen as usual, so `variant: 'primary'` types as `string`. To narrow it to a union of allowed values, annotate the defaults: `const props = { variant: 'primary' as 'primary' | 'ghost' }`.
|
||||
</Aside>
|
||||
|
||||
### Alternatives
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ slug: shadow-dom
|
|||
|
||||
Add a static property `shadowRootInit` with object value of type `ShadowRootInit` (see [options on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#options)) to opt-in to using shadow dom for the whole component.
|
||||
|
||||
Try it now [on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/VwRYVPv?editors=1010)
|
||||
Try it now [on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/VwRYVPv?editors=1010), or see it live: [Shadow DOM demo ↗](https://demo.webcomponent.io/examples/use-shadow/)
|
||||
|
||||
Example:
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ It is highly recommended to use the second approach, as with it, browsers can as
|
|||
|
||||
When using the built-in `html` function for tagged templates, a style object of type `Partial<CSSStyleDeclaration>` can be passed to any element's `style` attribute. This allows for calculated and conditional styles. Read more on style objects [on MDN](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleDeclaration).
|
||||
|
||||
Try it now with this [example on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/bGzXjwQ?editors=1010)
|
||||
Try it now with this [example on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/bGzXjwQ?editors=1010), or see it live: [Style objects demo ↗](https://demo.webcomponent.io/examples/style-objects/)
|
||||
|
||||
```js
|
||||
import { WebComponent } from 'https://unpkg.com/web-component-base@latest/index.js'
|
||||
|
|
@ -55,9 +55,9 @@ customElements.define('styled-elements', StyledElement)
|
|||
|
||||
## Using the Shadow DOM and Constructable Stylesheets
|
||||
|
||||
If you [use the Shadow DOM](/shadow-dom), you can add a `static styles` property of type string which will be added in the `shadowRoot`'s [`adoptedStylesheets`](https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets).
|
||||
If you [use the Shadow DOM](/shadow-dom), you can add a `static styles` property which will be added to the `shadowRoot`'s [`adoptedStylesheets`](https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets). It accepts a string, a `CSSStyleSheet`, or an array of either.
|
||||
|
||||
Try it now with this [example on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/JojmeEe?editors=1010)
|
||||
Try it now with this [example on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/JojmeEe?editors=1010), or see it live: [Constructable styles demo ↗](https://demo.webcomponent.io/examples/constructed-styles/)
|
||||
|
||||
```js
|
||||
class StyledElement extends WebComponent {
|
||||
|
|
@ -88,3 +88,47 @@ class StyledElement extends WebComponent {
|
|||
|
||||
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:
|
||||
|
||||
```js
|
||||
// tokens.js — shared across every component
|
||||
export const tokens = `
|
||||
:host {
|
||||
--cozy-radius: 6px;
|
||||
--cozy-accent: rebeccapurple;
|
||||
}
|
||||
`
|
||||
|
||||
// cozy-button.js
|
||||
import { tokens } from './tokens.js'
|
||||
|
||||
class CozyButton extends WebComponent {
|
||||
static shadowRootInit = { mode: 'open' }
|
||||
static styles = [
|
||||
tokens,
|
||||
`
|
||||
button {
|
||||
border-radius: var(--cozy-radius);
|
||||
background: var(--cozy-accent);
|
||||
}
|
||||
`,
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Entries may be strings or ready-made [`CSSStyleSheet`](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet) objects, and the two can be mixed. A `CSSStyleSheet` is adopted as-is rather than re-created, so one shared instance can be constructed once and reused by every component that adopts it:
|
||||
|
||||
```js
|
||||
const base = new CSSStyleSheet()
|
||||
base.replaceSync(tokens)
|
||||
|
||||
class CozyBadge extends WebComponent {
|
||||
static shadowRootInit = { mode: 'open' }
|
||||
static styles = [base, `span { font-size: 0.8em; }`]
|
||||
}
|
||||
```
|
||||
|
||||
A single string keeps working exactly as before — the array form is additive.
|
||||
|
|
|
|||
|
|
@ -10,3 +10,37 @@ This mental model attempts to reduce the cognitive complexity of authoring compo
|
|||
1. This `render()` method is _automatically_ called under the hood every time an attribute value changed.
|
||||
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.
|
||||
|
||||
## Composing components
|
||||
|
||||
A component's `template` can contain other components, nested as deep as you like:
|
||||
|
||||
```js
|
||||
class CounterBoard extends WebComponent {
|
||||
static props = { title: 'Board' }
|
||||
get template() {
|
||||
return html`
|
||||
<h3>${this.props.title}</h3>
|
||||
<counter-row name="alpha"></counter-row>
|
||||
<counter-row name="bravo"></counter-row>
|
||||
`
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
JSON round-trip. See it live: [Nested composition demo ↗](https://demo.webcomponent.io/examples/nested-composition/).
|
||||
|
||||
The one exception is **slot projection**: children you write _inside_ a
|
||||
shadow-DOM component's tag are your content, projected into its `<slot>`, so the
|
||||
parent keeps reconciling those. A light-DOM component, by contrast, renders over
|
||||
its own children, so pass data to it through attributes rather than as projected
|
||||
children.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ title: Usage
|
|||
slug: usage
|
||||
---
|
||||
|
||||
See it live: [Kitchen sink demo ↗](https://demo.webcomponent.io/examples/demo/) puts several features together, or [Single-file pen ↗](https://demo.webcomponent.io/examples/pens/counter-toggle.html) for the smallest possible setup.
|
||||
|
||||
In your component class:
|
||||
|
||||
```js
|
||||
|
|
|
|||
23
docs/src/content/docs/guides/why.md
Normal file
23
docs/src/content/docs/guides/why.md
Normal file
|
|
@ -0,0 +1,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 following are the main reasons WCB exist.
|
||||
|
||||
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.
|
||||
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:
|
||||
|
||||
- **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.
|
||||
|
||||
For the measured numbers and the capability-by-capability breakdown behind these claims, see [WCB & similar libraries](/comparison/).
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
---
|
||||
title: Why use this base class?
|
||||
slug: 'why'
|
||||
---
|
||||
|
||||
import { Aside, Badge } from '@astrojs/starlight/components'
|
||||
|
||||
Often times, when simple websites need a quick custom element, the simplest way is to create one extending from `HTMLElement`. However, it can quickly reach a point where writing the code from scratch can seem confusing and hard to maintain especially when compared to other projects with more advanced setups.
|
||||
|
||||
Also, when coming from frameworks with an easy declarative coding experience (using templates), the default imperative way (e.g., creating instances of elements, manually attaching event handlers, and other DOM manipulations) is a frequent pain point we see.
|
||||
|
||||
By taking out bigger concerns that [a metaframework](https://github.com/ayo-run/mcfly) should handle, this project aims to focus on keeping the component-level matters simple and lightweight. This allows for addressing the said problems with virtually zero tooling required and thin abstractions from vanilla custom element APIs – giving you only the bare minimum code to be productive.
|
||||
|
||||
It works on current-day browsers without needing compilers, transpilers, or polyfills.
|
||||
|
||||
<Aside type="tip">
|
||||
Have questions or suggestions? There are many ways to get in touch:
|
||||
1. Open a [GitHub issue](https://github.com/ayo-run/wcb/issues/new) or [discussion](https://github.com/ayo-run/wcb/discussions)
|
||||
1. Submit a ticket via [SourceHut todo](https://todo.sr.ht/~ayoayco/wcb)
|
||||
1. Email me: [hi@ayo.run](mailto:hi@ayo.run)
|
||||
</Aside>
|
||||
|
|
@ -12,7 +12,21 @@ export default [
|
|||
'no-unused-vars': 'warn',
|
||||
},
|
||||
},
|
||||
// Config, build, and script files run in Node — give them Node globals
|
||||
// (e.g. `process`) on top of the browser defaults.
|
||||
{
|
||||
ignores: ['site/*', '**/dist/*'],
|
||||
files: ['**/*.config.{js,mjs,cjs}', 'scripts/**/*.{js,mjs}'],
|
||||
languageOptions: { globals: globals.node },
|
||||
},
|
||||
// The Storybook config files and the CEM analyzer config run in Node.
|
||||
{
|
||||
files: [
|
||||
'storybook/.storybook/*.js',
|
||||
'**/custom-elements-manifest.config.mjs',
|
||||
],
|
||||
languageOptions: { globals: globals.node },
|
||||
},
|
||||
{
|
||||
ignores: ['site/*', '**/dist/*', 'storybook/storybook-static/*'],
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WC demo</title>
|
||||
<script type="module" src="./index.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<styled-elements type="warn" condition></styled-elements>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
// @ts-check
|
||||
import { WebComponent, html } from '../../src/index.js'
|
||||
|
||||
class StyledElements extends WebComponent {
|
||||
static shadowRootInit = {
|
||||
mode: 'open',
|
||||
}
|
||||
|
||||
static styles = `
|
||||
div {
|
||||
background-color: yellow;
|
||||
border: 1px solid black;
|
||||
padding: 1em;
|
||||
|
||||
p {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
get template() {
|
||||
return html`
|
||||
<div>
|
||||
<p>Wow!?</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('styled-elements', StyledElements)
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WC demo</title>
|
||||
<script type="module" src="./index.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<styled-elements type="warn" condition></styled-elements>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WC demo</title>
|
||||
<script type="module" src="./index.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h2>With our html</h2>
|
||||
<my-counter></my-counter>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
[build]
|
||||
base = "docs"
|
||||
publish = "dist"
|
||||
47
package.json
47
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "web-component-base",
|
||||
"version": "5.0.0",
|
||||
"version": "6.1.1",
|
||||
"description": "A zero-dependency & tiny JS base class for creating reactive custom elements easily",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
|
@ -8,6 +8,10 @@
|
|||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./cem-plugin": {
|
||||
"types": "./dist/cem-plugin.d.ts",
|
||||
"import": "./dist/cem-plugin.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/*.d.ts",
|
||||
"import": "./dist/*.js"
|
||||
|
|
@ -24,14 +28,27 @@
|
|||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"start": "npx simple-server .",
|
||||
"dev": "npm start",
|
||||
"start": "pnpm -F demo dev",
|
||||
"dev": "pnpm -F demo dev",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"demo": "npx simple-server .",
|
||||
"test:e2e": "E2E_BROWSERS=chromium vitest --run --config vitest.e2e.config.mjs",
|
||||
"test:e2e:chromium": "E2E_BROWSERS=chromium vitest --run --config vitest.e2e.config.mjs",
|
||||
"test:e2e:firefox": "E2E_BROWSERS=firefox vitest --run --config vitest.e2e.config.mjs",
|
||||
"test:e2e:webkit": "E2E_BROWSERS=webkit vitest --run --config vitest.e2e.config.mjs",
|
||||
"test:e2e:all": "vitest --run --config vitest.e2e.config.mjs",
|
||||
"test:types": "pnpm run build && tsc -p test/types/tsconfig.json && tsc -p demo/examples/typed-props/tsconfig.json",
|
||||
"test:all": "pnpm test && pnpm test:types && pnpm test:e2e:all",
|
||||
"demo": "pnpm -F demo dev",
|
||||
"demo:build": "pnpm -F demo build",
|
||||
"docs": "pnpm -F docs start",
|
||||
"storybook": "pnpm run build && pnpm -F storybook dev",
|
||||
"storybook:build": "pnpm run build && pnpm -F storybook build",
|
||||
"build": "pnpm run clean && tsc && pnpm run copy:source",
|
||||
"size-limit": "pnpm run build && size-limit",
|
||||
"clean": "rm -rf dist",
|
||||
|
|
@ -41,9 +58,16 @@
|
|||
"lint": "eslint . --config eslint.config.mjs",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,mjs,cjs}": [
|
||||
"eslint --config eslint.config.mjs --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{json,css,html,md,yml,yaml}": "prettier --write"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/ayo-run/web-component-base.git"
|
||||
"url": "git+https://github.com/ayo-run/wcb.git"
|
||||
},
|
||||
"homepage": "https://WebComponent.io",
|
||||
"keywords": [
|
||||
|
|
@ -55,11 +79,14 @@
|
|||
"author": "Ayo Ayco",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ayo-run/web-component-base/issues"
|
||||
"url": "https://github.com/ayo-run/wcb/issues"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@custom-elements-manifest/analyzer": "^0.11.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@size-limit/preset-small-lib": "^12.0.0",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/browser-playwright": "4.0.18",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
"bumpp": "^11.1.0",
|
||||
"esbuild": "^0.27.2",
|
||||
|
|
@ -68,7 +95,9 @@
|
|||
"globals": "^17.1.0",
|
||||
"happy-dom": "^20.3.7",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.8",
|
||||
"netlify-cli": "^23.13.5",
|
||||
"playwright": "^1.61.1",
|
||||
"prettier": "^3.8.1",
|
||||
"release-it": "^19.2.4",
|
||||
"simple-git": "^3.36.0",
|
||||
|
|
@ -80,7 +109,7 @@
|
|||
"size-limit": [
|
||||
{
|
||||
"path": "./dist/WebComponent.js",
|
||||
"limit": "1.35 KB"
|
||||
"limit": "2.05 KB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/html.js",
|
||||
|
|
@ -90,6 +119,10 @@
|
|||
"path": "./dist/utils/create-element.js",
|
||||
"limit": "0.5 KB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/utils/patch.js",
|
||||
"limit": "0.8 KB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/utils/deserialize.js",
|
||||
"limit": "0.5 KB"
|
||||
|
|
|
|||
1878
pnpm-lock.yaml
1878
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,12 @@
|
|||
packages:
|
||||
# include packages in subfolders (e.g. apps/ and packages/)
|
||||
- 'docs/'
|
||||
- 'demo/'
|
||||
- 'storybook/'
|
||||
allowBuilds:
|
||||
'@parcel/watcher': false
|
||||
esbuild: true # insurance for core build
|
||||
netlify-cli: false
|
||||
rs-module-lexer: false
|
||||
sharp: false
|
||||
unix-dgram: false
|
||||
|
|
|
|||
|
|
@ -10,3 +10,7 @@ A running record of how each correctness/feature change affects the `WebComponen
|
|||
| Correct empty-string / removed attribute handling; drop native-setter writes | 1.33 kB (≈0) | 1.35 kB | **State no longer corrupts on common attribute changes.** `attr=""` coerced to boolean `true` (echoed as `attr="true"`); `removeAttribute` wrote `null` into the proxy and threw before `render()`/`onChanges()`; `deserialize('', 'number')` threw. `attributeChangedCallback` now keeps strings as-is (`''` stays `''`), resets removed attributes to the declared default, wraps `deserialize` so a malformed value falls back to the raw string instead of skipping render, and no longer writes vestigial `this[prop]` fields that clobbered native setters like `title`/`id`/`lang`. Logic swap — no net size cost. |
|
||||
| `onChanges` gains a clear attribute-vs-property distinction (**breaking**) | 1.34 kB (+0.01 kB) | 1.35 kB | **Self-documenting change payload.** `onChanges` previously passed only `property`, holding the kebab-case _attribute_ name — confusing in a library where `props` are camelCase. `property` now holds the camelCase _prop_ key (matching `this.props` access) and the kebab attribute name moves to a new `attribute` field. Handlers can read `this.props[property]` directly without re-deriving the key. **Breaking:** code reading `changes.property` for the attribute name must switch to `changes.attribute` (see the migration note on the life-cycle-hooks docs page). |
|
||||
| Buffer attribute-driven render/onChanges until after `onInit` | 1.35 kB (+0.01 kB) | 1.35 kB | **`onInit` is guaranteed to run first.** Per spec, authored attributes fire `attributeChangedCallback` (→ `render`/`onChanges`) _before_ `connectedCallback`, so components that assume `onInit` already ran break in real browsers (happy-dom hides this). A `#connected` flag now buffers the `render`/`onChanges` side effects until after `onInit`, while the prop value is still applied immediately so `props` is correct inside `onInit`. Pre-connect changes are reflected by the first render and not replayed through `onChanges`. |
|
||||
| Reconcile `render()` in place instead of `replaceChildren` (WCB-REQ-06) | 1.81 kB (+0.43 kB) | 1.4 → 1.85 kB | **Re-renders no longer destroy DOM state.** The vnode path rebuilt the whole subtree and swapped it on every prop change, so focus, caret/selection, an uncommitted `<input>` value, `:hover` and running CSS transitions were wiped on the component's own rendered nodes — text fields lost focus mid-typing and a segmented control lost keyboard focus on select. Re-renders now reconcile the existing DOM against the new vnode tree in place (`src/utils/patch.mjs`: index-based, non-keyed — reuse same-tag elements, patch prop/attr adds, changes and removals by the same rule `createElement` applies, recurse children, trim extras). The first render still builds via `createElement`, the `JSON.stringify` no-op skip is unchanged, and `html`'s vnode shape and string templates are untouched. Reusing an element also means a `style` rule that is dropped or goes falsy (`{ fontStyle: condition && 'italic' }`) must now be cleared explicitly — a rebuild used to do that implicitly. The reconciler lives in its own zero-dep module, but `size-limit` measures each entry *with its dependencies*, so the core budget had to move regardless; no runtime dependency was added. |
|
||||
| Reflect boolean props as bare attributes (WCB-REQ-07, **breaking**) | 1.93 kB (+0.12 kB) | 1.85 → 1.95 kB | **`toggleAttribute()` and `[attr]` CSS selectors work again.** Booleans reflected as the strings `flag="true"`/`flag="false"`, so the attribute always existed: `el.toggleAttribute('flag', true)` was a silent no-op (it only adds/removes, never rewrites a value — no `attributeChangedCallback`, no re-render), and presence selectors like `:host([flag])` matched the stamped `flag="false"` too, painting "on" styles onto elements whose prop was `false`. Boolean props now follow HTML in both directions: reflecting `true` sets the **bare** attribute, `false` **removes** it, and attribute removal means `false` rather than the declared default. Reads are strict — **any present value is `true`**, including the literal `flag="false"`, exactly like native `disabled="false"`. **Breaking:** `setAttribute(name, String(bool))` now always means `true`; migrate those call sites to `toggleAttribute(name, bool)`. Two dev warnings carry most of the cost and make the migration safe: one when a boolean attribute is written as a literal `"true"`/`"false"` string (the silent inversion), and one once-per-class when a boolean prop declares a `true` default (HTML has no true-default boolean attribute — absence must mean both "false" and "default"). `applyProp` also stops stamping `"false"` for boolean vnode props with no matching DOM property, which a nested `WebComponent` would otherwise read back as `true`. Limit raised to fit the warnings. |
|
||||
| Overrideable `toAttribute` / `fromAttribute` converters (#56) | 1.96 kB (+0.03 kB) | 1.95 → 2 kB | **An escape hatch for the edge cases strict boolean semantics create.** The reflection and parsing rules were hardcoded, so a prop needing custom serialization (a `Date`, a delimited list, an enumerated `"true"`/`"false"` attribute) had no seam to hook into. Two overrideable methods now own both directions — `toAttribute(name, value)` returns the attribute value, where **`null` removes the attribute**, and `fromAttribute(name, value)` parses a present attribute back into a prop value. Both take the camelCase prop key, matching `static props` and `onChanges`'s `property`. The existing behavior became the default implementations (pure code movement — boolean presence/absence is now simply `toAttribute` returning `''` or `null`), so subclasses customize one prop and delegate the rest to `super`. Cheaper than a per-prop converter map, which would cost a lookup on every prop even for the vast majority of components that never need one. Limit raised for the two non-manglable public method names. |
|
||||
| Don't reconcile a nested component's own light DOM | 2.01 kB (+0.05 kB) | 2 → 2.05 kB | **Nested components stop erasing each other.** The in-place reconciler recursed into every same-tag element it reused, including nested custom elements. A parent's vnode never describes what a nested light-DOM component rendered *for itself*, so the trailing-node trim in `patchChildren` deleted that content on every ancestor re-render — and since the child only re-renders when its own props change, it stayed blank. Any `WebComponent` that renders another `WebComponent` (without shadow DOM) lost the inner subtree the first time the outer one re-rendered, even for an unrelated prop. `patchNode` now treats a custom element that has no open shadow root as opaque: it still patches the element's props (that is how data flows down) but leaves the children to the child. Shadow-DOM components are exempt — their own content lives in the shadow root, invisible to the parent, so any *light* children are genuine parent-authored slot content that must still be reconciled. Limit raised 50 B to fit the one-line guard. |
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
getCamelCase,
|
||||
serialize,
|
||||
deserialize,
|
||||
patchChildren,
|
||||
} from './utils/index.js'
|
||||
|
||||
/** Component classes whose defaults have been validated (once per class). */
|
||||
|
|
@ -31,10 +32,17 @@ function cloneDefaults(ctor) {
|
|||
for (const key in ctor.props) {
|
||||
const value = ctor.props[key]
|
||||
const type = typeof value
|
||||
if (check && (type === 'function' || type === 'symbol'))
|
||||
console.warn(
|
||||
`${ctor.name}.${key}: ${type} default not reflectable; use handlers/refs.`
|
||||
)
|
||||
// a true boolean default is discouraged: HTML has no true-default boolean
|
||||
// attribute, so absence has to mean both "false" and "default", which only
|
||||
// holds when they coincide. A way to work with this is to invert the name (`disabled`, not `enabled`) when you need a default.
|
||||
if (check) {
|
||||
const bad =
|
||||
type === 'function' || type === 'symbol'
|
||||
? `${type} default not reflectable; use handlers/refs.`
|
||||
: value === true &&
|
||||
'boolean default should be false; invert the name.'
|
||||
if (bad) console.warn(`${ctor.name}.${key}: ${bad}`)
|
||||
}
|
||||
try {
|
||||
out[key] = structuredClone(value)
|
||||
} catch {
|
||||
|
|
@ -44,9 +52,25 @@ function cloneDefaults(ctor) {
|
|||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Blueprint for the Proxy props
|
||||
* @typedef {{[name: string]: any}} PropStringMap
|
||||
*/
|
||||
|
||||
/**
|
||||
* A minimal base class to reduce the complexity of creating reactive custom elements
|
||||
*
|
||||
* Pass the shape of your `static props` as a type argument to get typed
|
||||
* `this.props` access in TypeScript:
|
||||
* ```ts
|
||||
* const props = { variant: 'primary', enabled: true }
|
||||
* class CozyButton extends WebComponent<typeof props> {
|
||||
* static props = props
|
||||
* }
|
||||
* ```
|
||||
* @template {PropStringMap} [Props=PropStringMap]
|
||||
* @see https://webcomponent.io
|
||||
* @see https://webcomponent.io/prop-access/
|
||||
*/
|
||||
export class WebComponent extends HTMLElement {
|
||||
#host
|
||||
|
|
@ -55,15 +79,22 @@ export class WebComponent extends HTMLElement {
|
|||
#typeMap = {}
|
||||
#reflected = false
|
||||
#connected = false
|
||||
#reflecting
|
||||
|
||||
/**
|
||||
* Blueprint for the Proxy props
|
||||
* @typedef {{[name: string]: any}} PropStringMap
|
||||
* Declared props and their defaults. The value types of this object drive
|
||||
* both the runtime type guard and — when passed as the class type argument
|
||||
* — the compile-time type of `this.props`.
|
||||
* @type {PropStringMap}
|
||||
*/
|
||||
static props
|
||||
|
||||
// TODO: support array of styles
|
||||
/**
|
||||
* CSS adopted into the shadow root as constructable stylesheet(s). An array
|
||||
* is adopted in order, so shared/base sheets can be composed with
|
||||
* per-component ones. Requires `static shadowRootInit`.
|
||||
* @type {string | CSSStyleSheet | Array<string | CSSStyleSheet>}
|
||||
*/
|
||||
static styles
|
||||
|
||||
/**
|
||||
|
|
@ -91,7 +122,7 @@ export class WebComponent extends HTMLElement {
|
|||
/**
|
||||
* Read-only property containing camelCase counterparts of observed attributes.
|
||||
* @see https://webcomponent.io/prop-access/
|
||||
* @type {PropStringMap}
|
||||
* @returns {Props}
|
||||
*/
|
||||
get props() {
|
||||
return this.#props
|
||||
|
|
@ -123,6 +154,64 @@ export class WebComponent extends HTMLElement {
|
|||
*/
|
||||
onChanges(changes) {}
|
||||
|
||||
/**
|
||||
* Converts a prop value into the attribute value that reflects it. Override
|
||||
* to customize serialization for a prop; call `super.toAttribute(...)` for
|
||||
* the ones you don't handle.
|
||||
*
|
||||
* Returning **`null` removes the attribute** — that is how a `false` boolean
|
||||
* becomes an absent attribute, and it works for any prop.
|
||||
* @param {string} name camelCase prop key, matching `static props`
|
||||
* @param {any} value the prop value being reflected
|
||||
* @returns {string | null} the attribute value, or `null` to remove it
|
||||
*/
|
||||
toAttribute(name, value) {
|
||||
// declared type wins, so a boolean prop set to null/undefined still
|
||||
// removes its attribute; undeclared props fall back to the value's type
|
||||
return (this.#typeMap[name] ?? typeof value) === 'boolean'
|
||||
? value
|
||||
? ''
|
||||
: null
|
||||
: serialize(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an attribute value into the prop value it represents — the
|
||||
* inverse of `toAttribute`. Override to customize parsing for a prop; call
|
||||
* `super.fromAttribute(...)` for the ones you don't handle.
|
||||
*
|
||||
* Only called for attributes that are *present*: removal is handled by the
|
||||
* declared-default reset (and, for boolean props, always yields `false`).
|
||||
* @param {string} name camelCase prop key, matching `static props`
|
||||
* @param {string} value the attribute value, never `null`
|
||||
* @returns {any} the value to store on `this.props[name]`
|
||||
*/
|
||||
fromAttribute(name, value) {
|
||||
const type = this.#typeMap[name]
|
||||
if (type === 'boolean') {
|
||||
// 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.
|
||||
if (/^(true|false)$/.test(value)) {
|
||||
const attr = getKebabCase(name)
|
||||
console.warn(
|
||||
`${attr}="${value}" is true; use toggleAttribute("${attr}", ${value}).`
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (type && type !== 'string')
|
||||
// typed props deserialize; a malformed value falls back to the raw
|
||||
// string so render()/onChanges() are never skipped
|
||||
try {
|
||||
return deserialize(value, type)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
// strings (and untyped props) stay as-is; '' stays ''
|
||||
return value
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.#initializeProps()
|
||||
|
|
@ -156,27 +245,25 @@ export class WebComponent extends HTMLElement {
|
|||
if (previousValue === currentValue) return
|
||||
|
||||
const property = getCamelCase(attribute)
|
||||
const type = this.#typeMap[property]
|
||||
let next
|
||||
if (currentValue === null) {
|
||||
// removal resets to the declared default (or undefined if none)
|
||||
next = this.constructor.props?.[property]
|
||||
} else if (type && type !== 'string') {
|
||||
// typed props deserialize; a malformed value falls back to the raw
|
||||
// string so render()/onChanges() are never skipped
|
||||
try {
|
||||
next = deserialize(currentValue, type)
|
||||
} catch {
|
||||
next = currentValue
|
||||
}
|
||||
} else {
|
||||
// strings (and untyped props) stay as-is; '' stays ''
|
||||
next = currentValue
|
||||
}
|
||||
// when this change *is* our own reflection of a prop write, the prop is
|
||||
// already the source of truth: parsing the attribute back would undo a
|
||||
// `toAttribute` that removes the attribute (the default-reset below would
|
||||
// clobber the value) and would round-trip lossy conversions through their
|
||||
// string form. Still fall through to render/onChanges.
|
||||
if (this.#reflecting !== attribute) {
|
||||
const next =
|
||||
currentValue === null
|
||||
? // boolean props follow HTML: absence *is* false, never the declared
|
||||
// default. Other props reset to the declared default (or undefined).
|
||||
this.#typeMap[property] === 'boolean'
|
||||
? false
|
||||
: this.constructor.props?.[property]
|
||||
: this.fromAttribute(property, currentValue)
|
||||
|
||||
// write through the proxy; item 25 makes this log-not-throw by default
|
||||
// (prop value is always applied so `props` stays current)
|
||||
this.props[property] = next
|
||||
// write through the proxy; item 25 makes this log-not-throw by default
|
||||
// (prop value is always applied so `props` stays current)
|
||||
this.props[property] = next
|
||||
}
|
||||
|
||||
// defer the render/onChanges side effects until after onInit
|
||||
if (!this.#connected) return
|
||||
|
|
@ -201,7 +288,7 @@ export class WebComponent extends HTMLElement {
|
|||
console.error(msg)
|
||||
} else if (obj[prop] !== value) {
|
||||
obj[prop] = value
|
||||
setter(getKebabCase(prop), serialize(value))
|
||||
setter(prop, value)
|
||||
}
|
||||
|
||||
return true
|
||||
|
|
@ -220,11 +307,35 @@ export class WebComponent extends HTMLElement {
|
|||
if (!this.#props) {
|
||||
this.#props = new Proxy(
|
||||
initialProps,
|
||||
this.#handler((key, value) => this.setAttribute(key, value), this)
|
||||
this.#handler((key, value) => this.#reflect(key, value), this)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflects one prop value onto its attribute via `toAttribute`, where a
|
||||
* `null` return means the attribute is removed. That is what makes a `false`
|
||||
* boolean an *absent* attribute, so host code can use `toggleAttribute()`
|
||||
* and `:host([flag])` matches only when the prop is actually true.
|
||||
* @param {string} camelCase the prop key
|
||||
* @param {any} value the value to reflect
|
||||
*/
|
||||
#reflect(camelCase, value) {
|
||||
const kebab = getKebabCase(camelCase)
|
||||
const attr = this.toAttribute(camelCase, value)
|
||||
// tracked per attribute name, not as a plain flag: the platform can run
|
||||
// another attribute's callback inside this one's reaction window, and a
|
||||
// shared flag would swallow that unrelated parse
|
||||
const previous = this.#reflecting
|
||||
this.#reflecting = kebab
|
||||
try {
|
||||
if (attr === null) this.removeAttribute(kebab)
|
||||
else this.setAttribute(kebab, attr)
|
||||
} finally {
|
||||
this.#reflecting = previous
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflects default prop values onto attributes on first connect.
|
||||
* Runs outside the constructor (spec forbids attribute mutation there)
|
||||
|
|
@ -233,9 +344,8 @@ export class WebComponent extends HTMLElement {
|
|||
#reflectDefaults() {
|
||||
if (this.#reflected) return
|
||||
Object.keys(this.#props).forEach((camelCase) => {
|
||||
const kebab = getKebabCase(camelCase)
|
||||
if (!this.hasAttribute(kebab))
|
||||
this.setAttribute(kebab, serialize(this.#props[camelCase]))
|
||||
if (!this.hasAttribute(getKebabCase(camelCase)))
|
||||
this.#reflect(camelCase, this.#props[camelCase])
|
||||
})
|
||||
this.#reflected = true
|
||||
}
|
||||
|
|
@ -244,42 +354,63 @@ export class WebComponent extends HTMLElement {
|
|||
if (this.constructor.shadowRootInit) {
|
||||
this.#host = this.attachShadow(this.constructor.shadowRootInit)
|
||||
}
|
||||
// adoption appends, so it happens once per instance here rather than per
|
||||
// render — otherwise every re-render (and every switch between template
|
||||
// kinds) would stack another copy of each sheet
|
||||
this.#applyStyles()
|
||||
}
|
||||
|
||||
render() {
|
||||
if (typeof this.template === 'string') {
|
||||
this.innerHTML = this.template
|
||||
} else if (typeof this.template === 'object') {
|
||||
const tree = this.template
|
||||
const template = this.template
|
||||
|
||||
// TODO: smart diffing
|
||||
if (JSON.stringify(this.#prevDOM) !== JSON.stringify(tree)) {
|
||||
this.#applyStyles()
|
||||
if (template && typeof template === 'object') {
|
||||
if (JSON.stringify(this.#prevDOM) !== JSON.stringify(template)) {
|
||||
if (!this.#prevDOM) {
|
||||
/**
|
||||
* first render: create element
|
||||
* - resolve prop values
|
||||
* - attach event listeners
|
||||
*/
|
||||
const el = createElement(template)
|
||||
|
||||
/**
|
||||
* create element
|
||||
* - resolve prop values
|
||||
* - attach event listeners
|
||||
*/
|
||||
const el = createElement(tree)
|
||||
|
||||
if (el) {
|
||||
if (Array.isArray(el)) this.#host.replaceChildren(...el)
|
||||
else this.#host.replaceChildren(el)
|
||||
if (el) {
|
||||
if (Array.isArray(el)) this.#host.replaceChildren(...el)
|
||||
else this.#host.replaceChildren(el)
|
||||
}
|
||||
} else {
|
||||
// re-render: reconcile in place so focus, caret/selection, an
|
||||
// uncommitted <input> value, :hover and running transitions survive
|
||||
patchChildren(this.#host, this.#prevDOM, template)
|
||||
}
|
||||
this.#prevDOM = tree
|
||||
this.#prevDOM = template
|
||||
}
|
||||
} else {
|
||||
// string templates render into #host like vnode ones do, so they
|
||||
// respect the shadow root instead of writing over consumer-slotted
|
||||
// light-DOM children. `html``` — the natural way to render nothing —
|
||||
// yields undefined rather than a vnode, so it lands here too: both it
|
||||
// and '' empty the rendered subtree. Dropping the vnode bookkeeping
|
||||
// makes the next vnode render start fresh instead of patching against
|
||||
// a tree that is no longer on screen.
|
||||
this.#prevDOM = undefined
|
||||
this.#host.innerHTML = template ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
#applyStyles() {
|
||||
if (this.constructor.styles !== undefined)
|
||||
const styles = this.constructor.styles
|
||||
if (styles !== undefined)
|
||||
try {
|
||||
const styleObj = new CSSStyleSheet()
|
||||
styleObj.replaceSync(this.constructor.styles)
|
||||
// one sheet or many, in declaration order — a design system can put a
|
||||
// shared tokens sheet first and component styles after it
|
||||
this.#host.adoptedStyleSheets = [
|
||||
...this.#host.adoptedStyleSheets,
|
||||
styleObj,
|
||||
...[styles].flat().map((s) => {
|
||||
if (typeof s != 'string') return s
|
||||
const sheet = new CSSStyleSheet()
|
||||
sheet.replaceSync(s)
|
||||
return sheet
|
||||
}),
|
||||
]
|
||||
} catch (e) {
|
||||
console.error(
|
||||
|
|
|
|||
208
src/cem-plugin.js
Normal file
208
src/cem-plugin.js
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* @license MIT <https://opensource.org/licenses/MIT>
|
||||
* @author Ayo Ayco <https://ayo.ayco.io>
|
||||
*
|
||||
* A Custom Elements Manifest analyzer plugin that teaches
|
||||
* `@custom-elements-manifest/analyzer` about wcb's `static props` convention.
|
||||
*
|
||||
* This module is **dev-time only** — it runs in Node during `cem analyze` and
|
||||
* never reaches the browser. It is not imported by `WebComponent`, so the core
|
||||
* stays zero-dependency and within its size budget.
|
||||
* @see https://webcomponent.io/cem-plugin/
|
||||
*/
|
||||
|
||||
import { getKebabCase } from './utils/index.js'
|
||||
|
||||
/** wcb statics that are implementation detail, not public API. */
|
||||
const WCB_INTERNAL = new Set([
|
||||
'props',
|
||||
'shadowRootInit',
|
||||
'styles',
|
||||
'strictProps',
|
||||
'observedAttributes',
|
||||
'template',
|
||||
])
|
||||
|
||||
/**
|
||||
* Maps a `static props` default literal to a CEM type string.
|
||||
* @param {any} ts the TypeScript module handed to the hook
|
||||
* @param {any} init the property initializer node
|
||||
* @returns {string} `boolean` | `number` | `object` | `string`
|
||||
*/
|
||||
function typeOfDefault(ts, init) {
|
||||
if (
|
||||
init.kind === ts.SyntaxKind.TrueKeyword ||
|
||||
init.kind === ts.SyntaxKind.FalseKeyword
|
||||
)
|
||||
return 'boolean'
|
||||
if (ts.isNumericLiteral(init)) return 'number'
|
||||
if (ts.isObjectLiteralExpression(init) || ts.isArrayLiteralExpression(init))
|
||||
return 'object'
|
||||
return 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a node's modifiers across TypeScript versions.
|
||||
* @param {any} ts the TypeScript module handed to the hook
|
||||
* @param {any} node the node to read modifiers from
|
||||
* @returns {any[]} the modifiers, or an empty array
|
||||
*/
|
||||
function modifiersOf(ts, node) {
|
||||
return (
|
||||
(ts.canHaveModifiers?.(node) ? ts.getModifiers(node) : node.modifiers) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps `x as const` / `x satisfies T` down to the underlying expression.
|
||||
* @param {any} ts the TypeScript module handed to the hook
|
||||
* @param {any} node the expression node
|
||||
* @returns {any} the unwrapped expression
|
||||
*/
|
||||
function unwrap(ts, node) {
|
||||
let current = node
|
||||
while (
|
||||
current &&
|
||||
(ts.isAsExpression?.(current) ||
|
||||
ts.isSatisfiesExpression?.(current) ||
|
||||
ts.isParenthesizedExpression(current))
|
||||
)
|
||||
current = current.expression
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an identifier to the object literal of a module-level `const` in
|
||||
* the same file. This is what makes the typed-props pattern work:
|
||||
*
|
||||
* ```js
|
||||
* const props = { variant: 'primary' }
|
||||
* class Foo extends WebComponent { static props = props }
|
||||
* ```
|
||||
*
|
||||
* A class can't reference its own static in its `extends` clause, so typed
|
||||
* components must hoist the defaults into a const — without this the whole
|
||||
* pattern would yield zero attributes.
|
||||
* @param {any} ts the TypeScript module handed to the hook
|
||||
* @param {any} node any node in the source file
|
||||
* @param {string} name the identifier to resolve
|
||||
* @returns {any} the object literal, or undefined
|
||||
*/
|
||||
function resolveObjectLiteral(ts, node, name) {
|
||||
for (const statement of node.getSourceFile()?.statements ?? []) {
|
||||
if (!ts.isVariableStatement(statement)) continue
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (declaration.name?.getText() !== name || !declaration.initializer)
|
||||
continue
|
||||
const initializer = unwrap(ts, declaration.initializer)
|
||||
if (ts.isObjectLiteralExpression(initializer)) return initializer
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the object literal behind a class's `static props`, whether it is
|
||||
* written inline or hoisted into a module-level const.
|
||||
* @param {any} ts the TypeScript module handed to the hook
|
||||
* @param {any} node the class declaration node
|
||||
* @returns {any} the object literal, or undefined
|
||||
*/
|
||||
function findStaticProps(ts, node) {
|
||||
const declaration = node.members.find(
|
||||
(member) =>
|
||||
ts.isPropertyDeclaration(member) &&
|
||||
modifiersOf(ts, member).some(
|
||||
(mod) => mod.kind === ts.SyntaxKind.StaticKeyword
|
||||
) &&
|
||||
member.name?.getText() === 'props' &&
|
||||
member.initializer
|
||||
)
|
||||
if (!declaration) return undefined
|
||||
|
||||
const initializer = unwrap(ts, declaration.initializer)
|
||||
if (ts.isObjectLiteralExpression(initializer)) return initializer
|
||||
if (ts.isIdentifier(initializer))
|
||||
return resolveObjectLiteral(ts, node, initializer.getText())
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the class extends something named `WebComponent`. Used so wcb
|
||||
* components that declare no props still get their internals stripped.
|
||||
* @param {any} ts the TypeScript module handed to the hook
|
||||
* @param {any} node the class declaration node
|
||||
* @returns {boolean} whether the class extends `WebComponent`
|
||||
*/
|
||||
function extendsWebComponent(ts, node) {
|
||||
return (node.heritageClauses ?? []).some(
|
||||
(clause) =>
|
||||
clause.token === ts.SyntaxKind.ExtendsKeyword &&
|
||||
clause.types.some((t) => t.expression.getText().endsWith('WebComponent'))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Teaches the CEM analyzer to read wcb's `static props`: every key becomes a
|
||||
* public field plus a reflected attribute, named with wcb's own
|
||||
* `getKebabCase` so manifest attribute names match `observedAttributes`
|
||||
* exactly. wcb internals are stripped from the public surface.
|
||||
* @example
|
||||
* // custom-elements-manifest.config.mjs
|
||||
* import { wcbStaticProps } from 'web-component-base/cem-plugin'
|
||||
* export default { globs: ['src/**\/*.js'], plugins: [wcbStaticProps()] }
|
||||
* @returns {{name: string, analyzePhase: (ctx: any) => void}} a CEM analyzer plugin
|
||||
*/
|
||||
export function wcbStaticProps() {
|
||||
return {
|
||||
name: 'wcb-static-props',
|
||||
analyzePhase({ ts, node, moduleDoc }) {
|
||||
if (!ts.isClassDeclaration(node) || !node.name) return
|
||||
|
||||
const className = node.name.getText()
|
||||
const classDoc = (moduleDoc.declarations ?? []).find(
|
||||
(declaration) =>
|
||||
declaration.kind === 'class' && declaration.name === className
|
||||
)
|
||||
if (!classDoc) return
|
||||
|
||||
const props = findStaticProps(ts, node)
|
||||
if (!props && !extendsWebComponent(ts, node)) return
|
||||
|
||||
classDoc.members = (classDoc.members ?? []).filter(
|
||||
(member) => !WCB_INTERNAL.has(member.name)
|
||||
)
|
||||
classDoc.attributes = classDoc.attributes ?? []
|
||||
if (!props) return
|
||||
|
||||
for (const prop of props.properties) {
|
||||
// shorthand (`{ variant }`) and spreads carry no inspectable default
|
||||
if (!ts.isPropertyAssignment(prop)) continue
|
||||
|
||||
const fieldName = prop.name.getText().replace(/['"]/g, '')
|
||||
const attribute = getKebabCase(fieldName)
|
||||
const type = { text: typeOfDefault(ts, prop.initializer) }
|
||||
const defaultValue = prop.initializer.getText()
|
||||
|
||||
classDoc.members.push({
|
||||
kind: 'field',
|
||||
name: fieldName,
|
||||
privacy: 'public',
|
||||
type,
|
||||
default: defaultValue,
|
||||
attribute,
|
||||
description: `Reactive prop, reflected to the \`${attribute}\` attribute.`,
|
||||
})
|
||||
|
||||
classDoc.attributes.push({
|
||||
name: attribute,
|
||||
fieldName,
|
||||
type,
|
||||
default: defaultValue,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default wcbStaticProps
|
||||
|
|
@ -17,18 +17,9 @@ export function createElement(tree) {
|
|||
* handle props
|
||||
*/
|
||||
if (tree.props) {
|
||||
Object.entries(tree.props).forEach(([prop, value]) => {
|
||||
const domProp = prop.toLowerCase()
|
||||
if (domProp === 'style' && typeof value === 'object' && !!value) {
|
||||
applyStyles(el, value)
|
||||
} else if (prop in el) {
|
||||
el[prop] = value
|
||||
} else if (domProp in el) {
|
||||
el[domProp] = value
|
||||
} else {
|
||||
el.setAttribute(prop, serialize(value))
|
||||
}
|
||||
})
|
||||
Object.entries(tree.props).forEach(([prop, value]) =>
|
||||
applyProp(el, prop, value)
|
||||
)
|
||||
}
|
||||
/**
|
||||
* handle children
|
||||
|
|
@ -43,6 +34,34 @@ export function createElement(tree) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one vnode prop to an element: a `style` object is applied rule by
|
||||
* rule, a name the element owns as a DOM property is assigned (so event
|
||||
* handlers and non-string values keep their type), anything else becomes a
|
||||
* serialized attribute. Shared with the reconciler so a patched element gets
|
||||
* props by exactly the same rule as a freshly created one.
|
||||
* @param {Element} el the element to apply the prop to
|
||||
* @param {string} prop the prop name as written in the vnode
|
||||
* @param {any} value the prop value
|
||||
*/
|
||||
export function applyProp(el, prop, value) {
|
||||
const domProp = prop.toLowerCase()
|
||||
if (domProp === 'style' && typeof value === 'object' && !!value) {
|
||||
applyStyles(el, value)
|
||||
} else if (prop in el) {
|
||||
el[prop] = value
|
||||
} else if (domProp in el) {
|
||||
el[domProp] = value
|
||||
} else if (typeof value === 'boolean') {
|
||||
// no DOM property to take the value: fall back to the HTML boolean
|
||||
// convention rather than stamping the string "false", which any
|
||||
// boolean-attribute reader (including a nested WebComponent) reads as true
|
||||
el.toggleAttribute(prop, value)
|
||||
} else {
|
||||
el.setAttribute(prop, serialize(value))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param el
|
||||
|
|
|
|||
|
|
@ -5,8 +5,13 @@
|
|||
*/
|
||||
export function deserialize(value, type) {
|
||||
switch (type) {
|
||||
case 'number':
|
||||
// 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':
|
||||
return true
|
||||
case 'number':
|
||||
case 'object':
|
||||
case 'undefined':
|
||||
return JSON.parse(value)
|
||||
|
|
|
|||
|
|
@ -2,4 +2,5 @@ export { serialize } from './serialize.mjs'
|
|||
export { deserialize } from './deserialize.mjs'
|
||||
export { getCamelCase } from './get-camel-case.mjs'
|
||||
export { getKebabCase } from './get-kebab-case.mjs'
|
||||
export { createElement } from './create-element.mjs'
|
||||
export { createElement, applyProp } from './create-element.mjs'
|
||||
export { patchChildren, patchNode } from './patch.mjs'
|
||||
|
|
|
|||
176
src/utils/patch.mjs
Normal file
176
src/utils/patch.mjs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { createElement, applyProp } from './create-element.mjs'
|
||||
|
||||
/**
|
||||
* Flattens a vnode or a (possibly nested) children array into the flat list of
|
||||
* nodes `createElement` would have produced — it nests arrays into document
|
||||
* fragments, which append flat. `null`/`undefined` entries are dropped so a
|
||||
* conditional branch removes its node instead of rendering "null".
|
||||
* @param {any} children a vnode, a children array, or nothing
|
||||
* @returns {any[]} the flat list of vnodes
|
||||
*/
|
||||
function flatten(children) {
|
||||
return children == null
|
||||
? []
|
||||
: [children].flat(Infinity).filter((c) => c != null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes a prop that is present in the old vnode but gone from the new one,
|
||||
* mirroring how `applyProp` set it.
|
||||
* @param {Element} el the element to patch
|
||||
* @param {string} prop the prop name as written in the vnode
|
||||
* @param {any} oldValue the value the prop had in the old vnode
|
||||
*/
|
||||
function removeProp(el, prop, oldValue) {
|
||||
const domProp = prop.toLowerCase()
|
||||
|
||||
if (domProp === 'style' && typeof oldValue === 'object' && !!oldValue) {
|
||||
Object.keys(oldValue).forEach((rule) => {
|
||||
if (rule in el.style) el.style[rule] = ''
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const key = prop in el ? prop : domProp in el ? domProp : null
|
||||
if (key === null) {
|
||||
el.removeAttribute(prop)
|
||||
} else {
|
||||
// reset to the DOM's own empty value for that property's type: `false` for
|
||||
// booleans (disabled), `''` for strings (id, className), `null` otherwise
|
||||
// (event handlers, object-valued props)
|
||||
const current = el[key]
|
||||
el[key] =
|
||||
typeof current === 'boolean'
|
||||
? false
|
||||
: typeof current === 'string'
|
||||
? ''
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches a `style` object rule by rule. `applyProp` only ever *sets* truthy
|
||||
* rules, so on a reused element a rule that was dropped from the object — or
|
||||
* that went falsy, the usual `{ fontStyle: condition && 'italic' }` shape —
|
||||
* has to be cleared explicitly. A full rebuild used to do this implicitly.
|
||||
* @param {Element} el the element to patch
|
||||
* @param {any} oldValue the previous `style` prop value
|
||||
* @param {any} newValue the new `style` prop value
|
||||
*/
|
||||
function patchStyle(el, oldValue, newValue) {
|
||||
const previous = typeof oldValue === 'object' && !!oldValue ? oldValue : {}
|
||||
const next = typeof newValue === 'object' && !!newValue ? newValue : {}
|
||||
|
||||
Object.keys(previous).forEach((rule) => {
|
||||
if (!next[rule] && rule in el.style) el.style[rule] = ''
|
||||
})
|
||||
applyProp(el, 'style', next)
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches an element's props in place: applies additions and changes, then
|
||||
* removes props that are gone. Function props (event handlers) are always
|
||||
* re-applied because `render()`'s `JSON.stringify` diff can't see them.
|
||||
* @param {Element} el the element to patch
|
||||
* @param {object | null | undefined} oldProps props from the previous vnode
|
||||
* @param {object | null | undefined} newProps props from the new vnode
|
||||
*/
|
||||
function patchProps(el, oldProps, newProps) {
|
||||
const previous = oldProps ?? {}
|
||||
const next = newProps ?? {}
|
||||
|
||||
Object.entries(next).forEach(([prop, value]) => {
|
||||
const isStyleObject =
|
||||
prop.toLowerCase() === 'style' &&
|
||||
(typeof value === 'object' || typeof previous[prop] === 'object')
|
||||
|
||||
if (isStyleObject) patchStyle(el, previous[prop], value)
|
||||
else if (typeof value === 'function' || !Object.is(previous[prop], value))
|
||||
applyProp(el, prop, value)
|
||||
})
|
||||
|
||||
Object.keys(previous).forEach((prop) => {
|
||||
if (!(prop in next)) removeProp(el, prop, previous[prop])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts `node` where `dom` is, or appends it when there is no node to replace.
|
||||
* @param {Node} parent the parent node being patched
|
||||
* @param {Node | null | undefined} dom the node being replaced, if any
|
||||
* @param {Node | null | undefined} node the replacement
|
||||
*/
|
||||
function place(parent, dom, node) {
|
||||
if (!(node instanceof Node)) return
|
||||
if (dom) parent.replaceChild(node, dom)
|
||||
else parent.appendChild(node)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles one DOM node against a new vnode, reusing it whenever it is the
|
||||
* same kind of node so focus, selection, uncommitted input values, `:hover`
|
||||
* and running transitions survive the re-render.
|
||||
* @param {Node} parent the parent node being patched
|
||||
* @param {Node | null | undefined} dom the existing node at this index, if any
|
||||
* @param {any} oldVnode the vnode that produced `dom`, if known
|
||||
* @param {any} newVnode the vnode to render
|
||||
*/
|
||||
export function patchNode(parent, dom, oldVnode, newVnode) {
|
||||
if (newVnode == null) {
|
||||
if (dom) parent.removeChild(dom)
|
||||
return
|
||||
}
|
||||
|
||||
// primitive: keep the text node and retarget its data
|
||||
if (typeof newVnode !== 'object') {
|
||||
const data = String(newVnode)
|
||||
if (dom && dom.nodeType === 3) {
|
||||
if (dom.data !== data) dom.data = data
|
||||
} else {
|
||||
place(parent, dom, document.createTextNode(data))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const sameTag =
|
||||
dom &&
|
||||
dom.nodeType === 1 &&
|
||||
dom.tagName.toLowerCase() === String(newVnode.type).toLowerCase()
|
||||
|
||||
if (!sameTag) {
|
||||
place(parent, dom, createElement(newVnode))
|
||||
return
|
||||
}
|
||||
|
||||
const previous =
|
||||
typeof oldVnode === 'object' && oldVnode !== null ? oldVnode : {}
|
||||
patchProps(dom, previous.props, newVnode.props)
|
||||
|
||||
// A nested custom element that renders its own light DOM owns that subtree:
|
||||
// the parent's vnode never describes what the child rendered for itself, so
|
||||
// reconciling those children here would trim them away and leave the child
|
||||
// blank until it happens to re-render on its own. Patch the element's props
|
||||
// (that is how data flows down) but leave its children to the child.
|
||||
// A shadow-DOM component is exempt — its own content lives in the shadow
|
||||
// root, invisible here, so any *light* children are genuine parent-authored
|
||||
// slot content that the parent must keep reconciling.
|
||||
if (dom.tagName.includes('-') && !dom.shadowRoot) return
|
||||
|
||||
patchChildren(dom, previous.children, newVnode.children)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles a parent's child nodes against a new children list by index
|
||||
* (non-keyed), trimming any leftover trailing nodes.
|
||||
* @param {Node} parent the parent node to patch into
|
||||
* @param {any} oldChildren the previous vnode children (or previous tree)
|
||||
* @param {any} newChildren the new vnode children (or new tree)
|
||||
*/
|
||||
export function patchChildren(parent, oldChildren, newChildren) {
|
||||
const previous = flatten(oldChildren)
|
||||
const next = flatten(newChildren)
|
||||
const nodes = [...parent.childNodes]
|
||||
|
||||
next.forEach((vnode, i) => patchNode(parent, nodes[i], previous[i], vnode))
|
||||
for (let i = next.length; i < nodes.length; i++) parent.removeChild(nodes[i])
|
||||
}
|
||||
41
storybook/.storybook/main.js
Normal file
41
storybook/.storybook/main.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const repo = resolve(root, '..')
|
||||
const srcDir = resolve(repo, 'src')
|
||||
|
||||
/** @type {import('@storybook/web-components-vite').StorybookConfig} */
|
||||
export default {
|
||||
stories: ['../stories/**/*.stories.js', '../stories/**/*.mdx'],
|
||||
addons: ['@storybook/addon-docs'],
|
||||
framework: { name: '@storybook/web-components-vite', options: {} },
|
||||
|
||||
// Same aliasing the demo uses: resolve the library to `src/` so stories get
|
||||
// instant HMR against the real source instead of the built bundle. (The CEM
|
||||
// config deliberately does the opposite and imports the plugin from `dist/`
|
||||
// via its published subpath.)
|
||||
viteFinal: (config) => ({
|
||||
...config,
|
||||
resolve: {
|
||||
...config.resolve,
|
||||
alias: [
|
||||
...(config.resolve?.alias ?? []),
|
||||
{
|
||||
find: /^web-component-base\/utils$/,
|
||||
replacement: resolve(srcDir, 'utils/index.js'),
|
||||
},
|
||||
{
|
||||
find: /^web-component-base\/html$/,
|
||||
replacement: resolve(srcDir, 'html.js'),
|
||||
},
|
||||
{
|
||||
find: /^web-component-base$/,
|
||||
replacement: resolve(srcDir, 'index.js'),
|
||||
},
|
||||
],
|
||||
},
|
||||
// stories import components from the sibling `demo/` workspace
|
||||
server: { ...config.server, fs: { allow: [repo] } },
|
||||
}),
|
||||
}
|
||||
16
storybook/.storybook/preview.js
Normal file
16
storybook/.storybook/preview.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { setCustomElementsManifest } from '@storybook/web-components-vite'
|
||||
import manifest from '../custom-elements.json'
|
||||
|
||||
// This single line is what turns the manifest into autodocs + inferred
|
||||
// controls. Without the wcb CEM plugin the manifest has no `attributes` at
|
||||
// all, so every story would have to hand-write `argTypes`.
|
||||
setCustomElementsManifest(manifest)
|
||||
|
||||
/** @type {import('@storybook/web-components-vite').Preview} */
|
||||
export default {
|
||||
parameters: {
|
||||
docs: { toc: true },
|
||||
controls: { expanded: true },
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue