chore: new demo workspace & e2e tests

- this adds a new demo web app with vite HMR for development
- all examples are covered with e2e tests
This commit is contained in:
ayo 2026-07-05 15:40:53 +02:00
parent 038674a74e
commit ce30b114a2
63 changed files with 1970 additions and 88 deletions

11
.claude/launch.json Normal file
View file

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "demo",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["-F", "demo", "dev", "--port", "5199", "--strictPort"],
"port": 5199
}
]
}

View file

@ -1,5 +1,5 @@
node_modules/
examples/
demo/
assets/
src/
.vscode/

View file

@ -16,8 +16,12 @@ 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 test:e2e` — run the browser e2e specs (`test/e2e/`) in Chromium via Vitest browser mode; `pnpm test:all` runs unit + 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 two sub-packages: `docs/` (Astro docs site) and `demo/` (Vite examples showcase, which consumes the root `web-component-base` package as a `workspace:*` dependency). 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

View 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>&lt;attr-demo label="from markup"&gt;</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>

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

View 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>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>
<styled-elements type="warn" condition></styled-elements>
</body>
</html>

View file

@ -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 shadowRootInit = {

View file

@ -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 = {

View file

@ -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 = {

View file

@ -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 = {

View file

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

View file

@ -1,4 +1,4 @@
import { WebComponent, html } from '../../src/index.js'
import { WebComponent, html } from 'web-component-base'
class Toggle extends WebComponent {
static props = {

View file

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

View 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>

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

View 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>&lt;lifecycle-order label="authored"&gt;</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>

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

View 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>

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

View file

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

View file

@ -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 = {

View file

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

View file

@ -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 = {

View 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>

View 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 &amp; 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)

View 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>

View file

@ -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 = {

View file

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

View file

@ -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 = {

View file

@ -1,4 +1,4 @@
import { WebComponent } from '../../src/index.js'
import { WebComponent } from 'web-component-base'
import {
html,
render as lit,

View file

@ -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 = {

View file

@ -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 = {

View file

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

View file

@ -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 = {

View file

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

View 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>

View file

@ -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 = {

117
demo/index.html Normal file
View file

@ -0,0 +1,117 @@
<!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">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/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&harr;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/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
View 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
View 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;
}

257
demo/shell.js Normal file
View file

@ -0,0 +1,257 @@
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}', {
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, htmlLang, css, dark, light] = await Promise.all([
import('shiki/langs/javascript.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, htmlLang.default, css.default],
themes: [dark.default, light.default],
})
})()
}
return highlighterPromise
}
const langFor = (name) => (name.endsWith('.html') ? 'html' : 'javascript')
// Single-file pens have no separate JS module — show their HTML instead, minus
// the injected shell tags so only the actual example remains.
function stripShell(html) {
return html
.split('\n')
.filter((line) => !line.includes('shell.css') && !line.includes('shell.js'))
.join('\n')
.trim()
}
// ts-check strip comments
function stripTsCheck(js) {
return js
.split('\n')
.filter((line) => !line.includes('ts-check'))
.join('\n')
.trim()
}
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
View 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() },
},
})

View file

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

View file

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

View file

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

View file

@ -26,11 +26,14 @@
"types": "./dist/index.d.ts",
"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": "vitest --run --config vitest.e2e.config.mjs",
"test:all": "pnpm test && pnpm test:e2e",
"demo": "pnpm -F demo dev",
"demo:build": "pnpm -F demo build",
"docs": "pnpm -F docs start",
"build": "pnpm run clean && tsc && pnpm run copy:source",
"size-limit": "pnpm run build && size-limit",
@ -60,6 +63,8 @@
"devDependencies": {
"@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",
@ -69,6 +74,7 @@
"happy-dom": "^20.3.7",
"husky": "^9.1.7",
"netlify-cli": "^23.13.5",
"playwright": "^1.61.1",
"prettier": "^3.8.1",
"release-it": "^19.2.4",
"simple-git": "^3.36.0",

View file

@ -14,9 +14,15 @@ importers:
'@size-limit/preset-small-lib':
specifier: ^12.0.0
version: 12.0.0(size-limit@12.0.0(jiti@2.6.1))
'@vitest/browser':
specifier: 4.0.18
version: 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/browser-playwright':
specifier: 4.0.18
version: 4.0.18(playwright@1.61.1)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/coverage-v8':
specifier: 4.0.18
version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))
version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)
bumpp:
specifier: ^11.1.0
version: 11.1.0
@ -41,6 +47,9 @@ importers:
netlify-cli:
specifier: ^23.13.5
version: 23.13.5(@types/node@25.0.10)(db0@0.3.1)(ioredis@5.6.0)(picomatch@4.0.3)(rollup@4.60.3)(supports-color@10.2.2)
playwright:
specifier: ^1.61.1
version: 1.61.1
prettier:
specifier: ^3.8.1
version: 3.8.1
@ -61,7 +70,20 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2)
version: 4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(@vitest/browser-playwright@4.0.18)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2)
demo:
dependencies:
shiki:
specifier: ^4.3.1
version: 4.3.1
web-component-base:
specifier: workspace:*
version: link:..
devDependencies:
vite:
specifier: ^7.3.1
version: 7.3.3(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.9.0)
docs:
dependencies:
@ -1481,6 +1503,9 @@ packages:
resolution: {integrity: sha512-bWLDlHsBlgKY/05wDN/V3ETcn5G2SV/SiA2ZmNvKGGlmVX4G5li7GRDhHcgYvHJHyJ8TUStqg2xtHmCs0UbAbg==}
engines: {node: '>=18'}
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@quansync/fs@1.0.0':
resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==}
@ -1776,30 +1801,58 @@ packages:
resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==}
engines: {node: '>=20'}
'@shikijs/core@4.3.1':
resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
engines: {node: '>=20'}
'@shikijs/engine-javascript@4.0.2':
resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==}
engines: {node: '>=20'}
'@shikijs/engine-javascript@4.3.1':
resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==}
engines: {node: '>=20'}
'@shikijs/engine-oniguruma@4.0.2':
resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==}
engines: {node: '>=20'}
'@shikijs/engine-oniguruma@4.3.1':
resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==}
engines: {node: '>=20'}
'@shikijs/langs@4.0.2':
resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==}
engines: {node: '>=20'}
'@shikijs/langs@4.3.1':
resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==}
engines: {node: '>=20'}
'@shikijs/primitive@4.0.2':
resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==}
engines: {node: '>=20'}
'@shikijs/primitive@4.3.1':
resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==}
engines: {node: '>=20'}
'@shikijs/themes@4.0.2':
resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==}
engines: {node: '>=20'}
'@shikijs/themes@4.3.1':
resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==}
engines: {node: '>=20'}
'@shikijs/types@4.0.2':
resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==}
engines: {node: '>=20'}
'@shikijs/types@4.3.1':
resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==}
engines: {node: '>=20'}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
@ -1990,6 +2043,17 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@vitest/browser-playwright@4.0.18':
resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==}
peerDependencies:
playwright: '*'
vitest: 4.0.18
'@vitest/browser@4.0.18':
resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==}
peerDependencies:
vitest: 4.0.18
'@vitest/coverage-v8@4.0.18':
resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
peerDependencies:
@ -3597,6 +3661,11 @@ packages:
fs-extra@6.0.1:
resolution: {integrity: sha512-GnyIkKhhzXZUWFCaJzvyDLEEgDkPfb4/TPvJCJVuS8MWZgoSsErf++QpiAlDnKFcqhRlm+tIOcencCjyJE6ZCA==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -5337,12 +5406,30 @@ packages:
resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==}
hasBin: true
pixelmatch@7.1.0:
resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==}
hasBin: true
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
pngjs@7.0.0:
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
engines: {node: '>=14.19.0'}
postcss-nested@6.2.0:
resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
engines: {node: '>=12.0'}
@ -5869,6 +5956,10 @@ packages:
resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==}
engines: {node: '>=20'}
shiki@4.3.1:
resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==}
engines: {node: '>=20'}
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
@ -5903,6 +5994,10 @@ packages:
engines: {node: '>=6'}
hasBin: true
sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@ -6268,6 +6363,10 @@ packages:
tomlify-j0.4@3.0.0:
resolution: {integrity: sha512-2Ulkc8T7mXJ2l0W476YC/A209PR38Nw8PuaCNtk9uI3t1zzFdGQeWYGQvmj2PZkVvRC/Yoi4xQKMRnWc/N29tQ==}
totalist@3.0.1:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
@ -8493,6 +8592,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@polka/url@1.0.0-next.29': {}
'@quansync/fs@1.0.0':
dependencies:
quansync: 1.0.0
@ -8665,36 +8766,74 @@ snapshots:
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/core@4.3.1':
dependencies:
'@shikijs/primitive': 4.3.1
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@4.0.2':
dependencies:
'@shikijs/types': 4.0.2
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6
'@shikijs/engine-javascript@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6
'@shikijs/engine-oniguruma@4.0.2':
dependencies:
'@shikijs/types': 4.0.2
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/engine-oniguruma@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@4.0.2':
dependencies:
'@shikijs/types': 4.0.2
'@shikijs/langs@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/primitive@4.0.2':
dependencies:
'@shikijs/types': 4.0.2
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/primitive@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/themes@4.0.2':
dependencies:
'@shikijs/types': 4.0.2
'@shikijs/themes@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/types@4.0.2':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/types@4.3.1':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/vscode-textmate@10.0.2': {}
'@simple-git/args-pathspec@1.0.3': {}
@ -8895,7 +9034,37 @@ snapshots:
- rollup
- supports-color
'@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))':
'@vitest/browser-playwright@4.0.18(playwright@1.61.1)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18)':
dependencies:
'@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))
playwright: 1.61.1
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(@vitest/browser-playwright@4.0.18)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2)
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18)':
dependencies:
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))
'@vitest/utils': 4.0.18
magic-string: 0.30.21
pixelmatch: 7.1.0
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(@vitest/browser-playwright@4.0.18)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2)
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.18
@ -8907,7 +9076,9 @@ snapshots:
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(@vitest/browser-playwright@4.0.18)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2)
optionalDependencies:
'@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/expect@4.0.18':
dependencies:
@ -8970,7 +9141,7 @@ snapshots:
'@vue/shared': 3.5.27
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.6
postcss: 8.5.14
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.27':
@ -10009,11 +10180,11 @@ snapshots:
dependencies:
node-source-walk: 7.0.1
detective-postcss@7.0.1(postcss@8.5.6):
detective-postcss@7.0.1(postcss@8.5.14):
dependencies:
is-url: 1.2.4
postcss: 8.5.6
postcss-values-parser: 6.0.2(postcss@8.5.6)
postcss: 8.5.14
postcss-values-parser: 6.0.2(postcss@8.5.14)
detective-sass@6.0.1:
dependencies:
@ -10379,7 +10550,7 @@ snapshots:
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
'@types/estree': 1.0.9
esutils@2.0.3: {}
@ -10733,6 +10904,9 @@ snapshots:
jsonfile: 4.0.0
universalify: 0.1.2
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@ -13015,6 +13189,10 @@ snapshots:
sonic-boom: 4.2.0
thread-stream: 3.1.0
pixelmatch@7.1.0:
dependencies:
pngjs: 7.0.0
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
@ -13027,6 +13205,16 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
pngjs@7.0.0: {}
postcss-nested@6.2.0(postcss@8.5.14):
dependencies:
postcss: 8.5.14
@ -13037,11 +13225,11 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-values-parser@6.0.2(postcss@8.5.6):
postcss-values-parser@6.0.2(postcss@8.5.14):
dependencies:
color-name: 1.1.4
is-url-superb: 4.0.0
postcss: 8.5.6
postcss: 8.5.14
quote-unquote: 1.0.0
postcss@8.5.14:
@ -13063,7 +13251,7 @@ snapshots:
detective-amd: 6.0.1
detective-cjs: 6.0.1
detective-es6: 5.0.1
detective-postcss: 7.0.1(postcss@8.5.6)
detective-postcss: 7.0.1(postcss@8.5.14)
detective-sass: 6.0.1
detective-scss: 5.0.1
detective-stylus: 5.0.1
@ -13071,7 +13259,7 @@ snapshots:
detective-vue2: 2.2.0(supports-color@10.2.2)(typescript@5.9.3)
module-definition: 6.0.1
node-source-walk: 7.0.1
postcss: 8.5.6
postcss: 8.5.14
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@ -13790,6 +13978,17 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
shiki@4.3.1:
dependencies:
'@shikijs/core': 4.3.1
'@shikijs/engine-javascript': 4.3.1
'@shikijs/engine-oniguruma': 4.3.1
'@shikijs/langs': 4.3.1
'@shikijs/themes': 4.3.1
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
@ -13840,6 +14039,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
sirv@3.0.2:
dependencies:
'@polka/url': 1.0.0-next.29
mrmime: 2.0.1
totalist: 3.0.1
sisteransi@1.0.5: {}
sitemap@9.0.1:
@ -14202,6 +14407,8 @@ snapshots:
tomlify-j0.4@3.0.0: {}
totalist@3.0.1: {}
tr46@0.0.3: {}
trim-lines@3.0.1: {}
@ -14518,11 +14725,26 @@ snapshots:
terser: 5.39.0
yaml: 2.8.2
vite@7.3.3(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.9.0):
dependencies:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.14
rollup: 4.60.3
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 25.0.10
fsevents: 2.3.3
jiti: 2.6.1
terser: 5.39.0
yaml: 2.9.0
vitefu@1.1.3(vite@7.3.3(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2)):
optionalDependencies:
vite: 7.3.3(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2)
vitest@4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2):
vitest@4.0.18(@opentelemetry/api@1.8.0)(@types/node@25.0.10)(@vitest/browser-playwright@4.0.18)(happy-dom@20.3.7)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))
@ -14547,6 +14769,7 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.8.0
'@types/node': 25.0.10
'@vitest/browser-playwright': 4.0.18(playwright@1.61.1)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(terser@5.39.0)(yaml@2.8.2))(vitest@4.0.18)
happy-dom: 20.3.7
transitivePeerDependencies:
- jiti

View file

@ -1,5 +1,6 @@
packages:
# include packages in subfolders (e.g. apps/ and packages/)
- 'docs/'
- 'demo/'
allowBuilds:
netlify-cli: false

49
test/e2e/README.md Normal file
View file

@ -0,0 +1,49 @@
# End-to-end example tests
These specs load each `demo/examples/*` component into a **real browser** (Chromium
via Playwright) and drive it the way a user would. They exist because the v5
correctness work targets behaviors that happy-dom cannot reproduce — custom
element upgrade ordering, the constructor-attribute rule, empty-string/removed
attributes, and adopted stylesheets — so the unit suite alone can pass while a
real browser misbehaves.
## Running
```sh
pnpm test:e2e # browser specs only (this folder)
pnpm test # fast unit suite (happy-dom), unchanged
pnpm test:all # both
```
Config lives in `vitest.e2e.config.mjs` (Vitest browser mode + the
`@vitest/browser-playwright` provider). It is kept separate from
`vitest.config.mjs` so `pnpm test` stays fast and needs no browser. The
Chromium binary is installed with `npx playwright install chromium`.
## Coverage
One spec per example folder:
| Example folder | Behavior exercised |
| --------------------- | ------------------------------------------------------------------------- |
| `on-changes` | v5 `onChanges` payload — camelCase `property` vs kebab `attribute` |
| `strict-props` | v5 default log-and-skip vs `static strictProps` throwing |
| `attribute-lifecycle` | v5 empty-string stays `''`, removal resets to default, markup wins |
| `lifecycle-order` | v5 buffering — `onInit` runs before the first render, no replayed onChanges |
| `just-parts` | `html` + `createElement` on a vanilla `HTMLElement` |
| `demo` | counter / toggle / hello-world / boolean props / onDestroy span |
| `type-restore` | number, boolean, string, and object prop round-trips |
| `props-blueprint` | non-zero default, camelCase↔kebab, authored attribute override |
| `style-objects` | computed inline style objects, type-driven style sets |
| `use-shadow` | rendering into an open shadow root |
| `constructed-styles` | `static styles` via `adoptedStyleSheets` in the shadow root |
| `templating` | interpolated lists / links via the built-in `html` |
## Intentionally not covered
- **`demo/examples/pens/counter-toggle.html`** and
**`demo/examples/templating/with-lit.js`** load their dependencies from a CDN
(`esm.sh` / `unpkg`) at runtime. They are omitted from the hermetic e2e run to
avoid a network dependency; the same `WebComponent` behaviors they show are
already covered by the local specs above (the `templating` spec covers
`demo/examples/templating/index.js`).

View file

@ -0,0 +1,37 @@
import { beforeEach, expect, test } from 'vitest'
import '../../demo/examples/attribute-lifecycle/index.js'
beforeEach(() => {
document.body.innerHTML = ''
})
test('the declared default is used when no attribute is authored', () => {
document.body.innerHTML = '<attr-demo></attr-demo>'
const el = document.querySelector('attr-demo')
expect(el.querySelector('.value').textContent).toBe('"default-label"')
expect(el.querySelector('.type').textContent).toBe('string')
})
test('empty string stays an empty string (not coerced to boolean)', () => {
document.body.innerHTML = '<attr-demo></attr-demo>'
const el = document.querySelector('attr-demo')
el.querySelector('.set-empty').click()
expect(el.getAttribute('label')).toBe('')
expect(el.querySelector('.value').textContent).toBe('""')
expect(el.querySelector('.type').textContent).toBe('string')
})
test('removeAttribute resets the prop to the declared default', () => {
document.body.innerHTML = '<attr-demo></attr-demo>'
const el = document.querySelector('attr-demo')
el.querySelector('.set-value').click()
expect(el.querySelector('.value').textContent).toBe('"hello"')
el.querySelector('.remove').click()
expect(el.querySelector('.value').textContent).toBe('"default-label"')
})
test('an attribute authored in markup wins over the default', () => {
document.body.innerHTML = '<attr-demo label="from markup"></attr-demo>'
const el = document.querySelector('attr-demo')
expect(el.querySelector('.value').textContent).toBe('"from markup"')
})

View file

@ -0,0 +1,15 @@
import { beforeEach, expect, test } from 'vitest'
import '../../demo/examples/constructed-styles/index.js'
beforeEach(() => {
document.body.innerHTML = ''
})
test('applies a constructable stylesheet via adoptedStyleSheets in the shadow root', () => {
document.body.innerHTML = '<styled-elements></styled-elements>'
const el = document.querySelector('styled-elements')
expect(el.shadowRoot).toBeTruthy()
expect(el.shadowRoot.adoptedStyleSheets.length).toBeGreaterThan(0)
expect(el.shadowRoot.querySelector('p').textContent.trim()).toBe('Wow!?')
})

48
test/e2e/demo.test.mjs Normal file
View file

@ -0,0 +1,48 @@
import { beforeEach, expect, test } from 'vitest'
import '../../demo/examples/demo/Counter.mjs'
import '../../demo/examples/demo/Toggle.js'
import '../../demo/examples/demo/HelloWorld.mjs'
import '../../demo/examples/demo/BooleanPropTest.mjs'
import '../../demo/examples/demo/SimpleText.mjs'
beforeEach(() => {
document.body.innerHTML = ''
})
test('Counter increments on click', () => {
document.body.innerHTML = '<my-counter></my-counter>'
const el = document.querySelector('my-counter')
// re-query after each render: object templates rebuild child nodes
expect(el.querySelector('#btn').textContent.trim()).toBe('0')
el.querySelector('#btn').click()
expect(el.querySelector('#btn').textContent.trim()).toBe('1')
})
test('Toggle flips its boolean value on click', () => {
document.body.innerHTML = '<my-toggle></my-toggle>'
const el = document.querySelector('my-toggle')
expect(el.querySelector('button').textContent.trim()).toBe('false')
el.querySelector('button').click()
expect(el.querySelector('button').textContent.trim()).toBe('true')
})
test('HelloWorld renders defaults and counts clicks', () => {
document.body.innerHTML = '<hello-world></hello-world>'
const el = document.querySelector('hello-world')
expect(el.textContent).toContain('Hello World')
el.querySelector('button').click()
expect(el.textContent).toContain('Clicked 1')
})
test('BooleanPropTest reflects camelCase↔kebab boolean props', () => {
document.body.innerHTML = '<boolean-prop-test></boolean-prop-test>'
const el = document.querySelector('boolean-prop-test')
expect(el.textContent).toContain('is-inline: false')
expect(el.textContent).toContain('another-one: false')
})
test('SimpleText renders its clickable span', () => {
document.body.innerHTML = '<simple-text></simple-text>'
const el = document.querySelector('simple-text')
expect(el.textContent).toContain('Click me!')
})

View file

@ -0,0 +1,19 @@
import { beforeEach, expect, test } from 'vitest'
import '../../demo/examples/just-parts/index.js'
beforeEach(() => {
document.body.innerHTML = ''
})
test('html + createElement build interactive DOM on a vanilla HTMLElement', () => {
document.body.innerHTML = '<my-quote></my-quote>'
const btn = document.querySelector('#quote-btn')
expect(btn).toBeTruthy()
expect(btn.textContent.trim()).toBe('click me')
btn.click()
expect(btn.textContent).toBe('clicked 1')
btn.click()
expect(btn.textContent).toBe('clicked 2')
})

View file

@ -0,0 +1,36 @@
import { beforeEach, expect, test } from 'vitest'
import '../../demo/examples/lifecycle-order/index.js'
beforeEach(() => {
document.body.innerHTML = ''
})
function logLines(el) {
const ol = el.nextElementSibling
return [...ol.querySelectorAll('li')].map((li) => li.textContent)
}
test('onInit runs before the first render and already sees authored attributes', () => {
document.body.innerHTML =
'<lifecycle-order id="authored" label="authored"></lifecycle-order>'
const el = document.querySelector('#authored')
const lines = logLines(el)
expect(lines[0]).toBe('onInit (props.label = "authored")')
expect(lines[1]).toBe('render (props.label = "authored")')
expect(lines[2]).toBe('afterViewInit')
// exactly one render, and the pre-connect change is not replayed via onChanges
expect(lines).toHaveLength(3)
expect(lines.some((l) => l.startsWith('onChanges'))).toBe(false)
})
test('default-value path keeps the onInit → render → afterViewInit order', () => {
document.body.innerHTML = '<lifecycle-order id="default"></lifecycle-order>'
const el = document.querySelector('#default')
const lines = logLines(el)
expect(lines[0]).toBe('onInit (props.label = "default")')
expect(lines[1]).toBe('render (props.label = "default")')
expect(lines[2]).toBe('afterViewInit')
expect(lines).toHaveLength(3)
})

View file

@ -0,0 +1,23 @@
import { beforeEach, expect, test } from 'vitest'
import '../../demo/examples/on-changes/index.js'
beforeEach(() => {
document.body.innerHTML = ''
})
test('onChanges payload separates camelCase property from kebab attribute', () => {
document.body.innerHTML = '<change-logger></change-logger>'
const el = document.querySelector('change-logger')
// nothing has changed yet
expect(el.querySelector('#no-changes')).toBeTruthy()
expect(el.querySelector('#greeting').textContent.trim()).toBe('Hello World')
el.querySelector('#rename').click()
expect(el.querySelector('#property').textContent).toBe('myName')
expect(el.querySelector('#attribute').textContent).toBe('my-name')
expect(el.querySelector('#previous').textContent).toBe('World')
expect(el.querySelector('#current').textContent).toBe('Ayo')
expect(el.querySelector('#greeting').textContent.trim()).toBe('Hello Ayo')
})

View file

@ -0,0 +1,27 @@
import { beforeEach, expect, test } from 'vitest'
import '../../demo/examples/props-blueprint/index.js'
import '../../demo/examples/props-blueprint/hello-world.js'
beforeEach(() => {
document.body.innerHTML = ''
})
test('Counter starts at its non-zero default and increments', () => {
document.body.innerHTML = '<my-counter></my-counter>'
const el = document.querySelector('my-counter')
expect(el.querySelector('#btn').textContent.trim()).toBe('123')
el.querySelector('#btn').click()
expect(el.querySelector('#btn').textContent.trim()).toBe('124')
})
test('camelCase prop myName renders', () => {
document.body.innerHTML = '<hello-world></hello-world>'
const el = document.querySelector('hello-world')
expect(el.textContent).toContain('Hello World')
})
test('authored my-name attribute overrides the default', () => {
document.body.innerHTML = '<hello-world my-name="Ayo"></hello-world>'
const el = document.querySelector('hello-world')
expect(el.textContent).toContain('Hello Ayo')
})

View file

@ -0,0 +1,33 @@
import { beforeEach, expect, test, vi } from 'vitest'
import '../../demo/examples/strict-props/index.js'
beforeEach(() => {
document.body.innerHTML = ''
})
test('default mode logs and skips a type violation, keeping the value/type', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
document.body.innerHTML = '<lenient-counter></lenient-counter>'
const el = document.querySelector('lenient-counter')
el.querySelector('#lenient-bump').click()
expect(el.querySelector('#lenient-bump').textContent.trim()).toBe('count = 1')
el.querySelector('#lenient-violate').click()
// the string write was skipped; count is still the number 1
expect(el.querySelector('#lenient-status').textContent.trim()).toBe(
'count is still 1 (number)'
)
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
test('strictProps throws a TypeError on a type violation', () => {
document.body.innerHTML = '<strict-counter></strict-counter>'
const el = document.querySelector('strict-counter')
el.querySelector('#strict-violate').click()
expect(el.querySelector('#strict-status').textContent.trim()).toBe(
'threw: TypeError'
)
})

View file

@ -0,0 +1,22 @@
import { beforeEach, expect, test } from 'vitest'
import '../../demo/examples/style-objects/index.js'
beforeEach(() => {
document.body.innerHTML = ''
})
test('style object applies computed inline styles for the default type', () => {
document.body.innerHTML = '<styled-elements></styled-elements>'
const div = document.querySelector('styled-elements div')
expect(div.style.backgroundColor).toBe('blue')
expect(div.style.padding).toBe('1em')
expect(document.querySelector('styled-elements p').textContent.trim()).toBe(
'Wow!'
)
})
test('the type attribute selects a different style set', () => {
document.body.innerHTML = '<styled-elements type="warn"></styled-elements>'
const div = document.querySelector('styled-elements div')
expect(div.style.backgroundColor).toBe('yellow')
})

View file

@ -0,0 +1,25 @@
import { beforeEach, expect, test } from 'vitest'
import '../../demo/examples/templating/index.js'
beforeEach(() => {
document.body.innerHTML = ''
})
test('renders interpolated lists and links and counts on click', () => {
document.body.innerHTML = '<my-counter></my-counter>'
const el = document.querySelector('my-counter')
expect(el.querySelector('#btn span').textContent.trim()).toBe('123')
const paragraphs = [...el.querySelectorAll('p')].map((p) =>
p.textContent.trim()
)
expect(paragraphs).toContain('what')
const links = el.querySelectorAll('li a')
expect(links.length).toBe(2)
expect(links[0].getAttribute('href')).toBe('https://ayco.io')
el.querySelector('#btn').click()
expect(el.querySelector('#btn span').textContent.trim()).toBe('124')
})

View file

@ -0,0 +1,40 @@
import { beforeEach, expect, test } from 'vitest'
import '../../demo/examples/type-restore/Counter.mjs'
import '../../demo/examples/type-restore/Toggle.mjs'
import '../../demo/examples/type-restore/HelloWorld.mjs'
import '../../demo/examples/type-restore/Object.mjs'
beforeEach(() => {
document.body.innerHTML = ''
})
test('number prop restores and increments', () => {
document.body.innerHTML = '<my-counter></my-counter>'
const el = document.querySelector('my-counter')
expect(el.querySelector('button').textContent.trim()).toBe('1')
el.querySelector('button').click()
expect(el.querySelector('button').textContent.trim()).toBe('2')
})
test('boolean prop drives On/Off label', () => {
document.body.innerHTML = '<my-toggle></my-toggle>'
const el = document.querySelector('my-toggle')
expect(el.querySelector('#toggle').textContent.trim()).toBe('Off')
el.querySelector('#toggle').click()
expect(el.querySelector('#toggle').textContent.trim()).toBe('On')
})
test('string prop appends on click', () => {
document.body.innerHTML = '<my-hello-world></my-hello-world>'
const el = document.querySelector('my-hello-world')
expect(el.querySelector('button').textContent.trim()).toBe('Wah!')
el.querySelector('button').click()
expect(el.querySelector('button').textContent.trim()).toBe('Waah!')
})
test('object prop renders its nested values', () => {
document.body.innerHTML = '<my-object></my-object>'
const el = document.querySelector('my-object')
expect(el.querySelector('#greeting-field').textContent).toContain('worldzz')
expect(el.querySelector('#age-field').value).toBe('2')
})

View file

@ -0,0 +1,23 @@
import { beforeEach, expect, test } from 'vitest'
import '../../demo/examples/use-shadow/index.js'
beforeEach(() => {
document.body.innerHTML = ''
})
test('renders into an open shadow root and increments on click', () => {
document.body.innerHTML = '<my-counter></my-counter>'
const el = document.querySelector('my-counter')
expect(el.shadowRoot).toBeTruthy()
// light DOM stays empty; content lives in the shadow root
expect(el.querySelector('#btn')).toBeNull()
expect(el.shadowRoot.querySelector('#btn span').textContent.trim()).toBe(
'123'
)
el.shadowRoot.querySelector('#btn').click()
expect(el.shadowRoot.querySelector('#btn span').textContent.trim()).toBe(
'124'
)
})

View file

@ -7,6 +7,8 @@ export default defineConfig({
'**/node_modules/**',
'**/dist/**',
'./temp/**',
// e2e specs run in a real browser via vitest.e2e.config.mjs
'test/e2e/**',
],
coverage: {
enabled: true,

35
vitest.e2e.config.mjs Normal file
View file

@ -0,0 +1,35 @@
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'
import { fileURLToPath } from 'node:url'
const src = (p) => fileURLToPath(new URL(`./src/${p}`, import.meta.url))
// End-to-end specs run against the real `src/` in a real browser (Chromium via
// Playwright), so behaviors that happy-dom cannot reproduce — custom-element
// upgrade ordering, constructor attribute rules, adopted stylesheets — are
// actually exercised. Kept separate from the unit config so `pnpm test` stays
// fast and headless-browser-free.
export default defineConfig({
// The demo examples import the `web-component-base` package; resolve it to
// local source (mirrors demo/vite.config.js) so e2e tests exercise src.
resolve: {
alias: [
{
find: /^web-component-base\/utils$/,
replacement: src('utils/index.js'),
},
{ find: /^web-component-base\/html$/, replacement: src('html.js') },
{ find: /^web-component-base$/, replacement: src('index.js') },
],
},
test: {
include: ['test/e2e/**/*.test.mjs'],
browser: {
enabled: true,
provider: playwright(),
headless: true,
screenshotFailures: false,
instances: [{ browser: 'chromium' }],
},
},
})