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:
parent
038674a74e
commit
ce30b114a2
63 changed files with 1970 additions and 88 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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
node_modules/
|
||||
examples/
|
||||
demo/
|
||||
assets/
|
||||
src/
|
||||
.vscode/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
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)
|
||||
15
demo/examples/constructed-styles/index.html
Normal file
15
demo/examples/constructed-styles/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>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>
|
||||
|
|
@ -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 = {
|
||||
|
|
@ -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)
|
||||
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 = {
|
||||
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')
|
||||
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 = {
|
||||
117
demo/index.html
Normal file
117
demo/index.html
Normal 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↔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
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;
|
||||
}
|
||||
257
demo/shell.js
Normal file
257
demo/shell.js
Normal 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
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() },
|
||||
},
|
||||
})
|
||||
|
|
@ -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,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>
|
||||
12
package.json
12
package.json
|
|
@ -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",
|
||||
|
|
|
|||
251
pnpm-lock.yaml
251
pnpm-lock.yaml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
49
test/e2e/README.md
Normal 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`).
|
||||
37
test/e2e/attribute-lifecycle.test.mjs
Normal file
37
test/e2e/attribute-lifecycle.test.mjs
Normal 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"')
|
||||
})
|
||||
15
test/e2e/constructed-styles.test.mjs
Normal file
15
test/e2e/constructed-styles.test.mjs
Normal 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
48
test/e2e/demo.test.mjs
Normal 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!')
|
||||
})
|
||||
19
test/e2e/just-parts.test.mjs
Normal file
19
test/e2e/just-parts.test.mjs
Normal 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')
|
||||
})
|
||||
36
test/e2e/lifecycle-order.test.mjs
Normal file
36
test/e2e/lifecycle-order.test.mjs
Normal 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)
|
||||
})
|
||||
23
test/e2e/on-changes.test.mjs
Normal file
23
test/e2e/on-changes.test.mjs
Normal 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')
|
||||
})
|
||||
27
test/e2e/props-blueprint.test.mjs
Normal file
27
test/e2e/props-blueprint.test.mjs
Normal 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')
|
||||
})
|
||||
33
test/e2e/strict-props.test.mjs
Normal file
33
test/e2e/strict-props.test.mjs
Normal 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'
|
||||
)
|
||||
})
|
||||
22
test/e2e/style-objects.test.mjs
Normal file
22
test/e2e/style-objects.test.mjs
Normal 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')
|
||||
})
|
||||
25
test/e2e/templating.test.mjs
Normal file
25
test/e2e/templating.test.mjs
Normal 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')
|
||||
})
|
||||
40
test/e2e/type-restore.test.mjs
Normal file
40
test/e2e/type-restore.test.mjs
Normal 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')
|
||||
})
|
||||
23
test/e2e/use-shadow.test.mjs
Normal file
23
test/e2e/use-shadow.test.mjs
Normal 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'
|
||||
)
|
||||
})
|
||||
|
|
@ -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
35
vitest.e2e.config.mjs
Normal 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' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue