docs: put up comparison page and new why
This commit is contained in:
parent
5fc24633f5
commit
40eae2c93a
20 changed files with 991 additions and 47 deletions
|
|
@ -29,9 +29,10 @@ This is the base class used for web components in Ayo's projects, primarily [coz
|
||||||
Next actions:
|
Next actions:
|
||||||
|
|
||||||
1. [Read the docs](https://webcomponent.io)
|
1. [Read the docs](https://webcomponent.io)
|
||||||
2. [View a demo on CodePen](https://codepen.io/ayoayco-the-styleful/pen/ZEwoNOz?editors=1010).
|
2. [View demos with code examples](https://demo.webcomponent.io/)
|
||||||
|
3. [Play with a demo on CodePen](https://codepen.io/ayo-run/pen/ZEwoNOz?editors=1010).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
When you extend the `WebComponent` class for your component, you only have to define the `template` and `properties`. Any change in any property value will automatically cause just the component UI to render.
|
When you extend the `WebComponent` class for your component, you only have to define the `template` and `properties`. Any change in any property value will automatically cause just the component UI to render.
|
||||||
|
|
||||||
|
|
@ -39,7 +40,7 @@ The result is a reactive UI on property changes.
|
||||||
|
|
||||||
When the `template` is an `html` tagged template (a vnode tree), a re-render **patches the existing DOM in place** instead of rebuilding it: elements of the same tag are reused, only changed props/attributes/text are touched, and leftover nodes are trimmed. Transient DOM state on the rendered nodes therefore survives a re-render — focus, caret/selection, an `<input>`'s uncommitted value, `:hover`, and running CSS transitions. A controlled text field can write every keystroke back to a prop without losing focus.
|
When the `template` is an `html` tagged template (a vnode tree), a re-render **patches the existing DOM in place** instead of rebuilding it: elements of the same tag are reused, only changed props/attributes/text are touched, and leftover nodes are trimmed. Transient DOM state on the rendered nodes therefore survives a re-render — focus, caret/selection, an `<input>`'s uncommitted value, `:hover`, and running CSS transitions. A controlled text field can write every keystroke back to a prop without losing focus.
|
||||||
|
|
||||||
Patching is **index-based and non-keyed**: list items are matched by position, not identity. The resulting DOM is always correct, but preserved state follows the *position* rather than the *item* — remove the first row of a list and the node that held row 1 is rewritten to hold row 2, so a caret, an uncommitted value, or a running transition stays where it was while the content shifts under it. Fixed-order lists and append/remove-at-the-end are unaffected; reorderable lists of focusable or animated items are the sharp edge. A `key` prop for identity-based matching is not implemented.
|
Patching is **index-based and non-keyed**: list items are matched by position, not identity. The resulting DOM is always correct, but preserved state follows the _position_ rather than the _item_ — remove the first row of a list and the node that held row 1 is rewritten to hold row 2, so a caret, an uncommitted value, or a running transition stays where it was while the content shifts under it. Fixed-order lists and append/remove-at-the-end are unaffected; reorderable lists of focusable or animated items are the sharp edge. A `key` prop for identity-based matching is not implemented.
|
||||||
|
|
||||||
## Want to get in touch?
|
## Want to get in touch?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,12 @@ import starlight from '@astrojs/starlight'
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
redirects: {
|
redirects: {
|
||||||
'/guides/': '/guides/why',
|
'/guides/': '/getting-started',
|
||||||
|
'/api/': '/api/web-component',
|
||||||
},
|
},
|
||||||
integrations: [
|
integrations: [
|
||||||
starlight({
|
starlight({
|
||||||
title: 'WCB (alpha)',
|
title: 'WCB',
|
||||||
social: [
|
social: [
|
||||||
{
|
{
|
||||||
icon: 'npm',
|
icon: 'npm',
|
||||||
|
|
@ -34,6 +35,7 @@ export default defineConfig({
|
||||||
// Each item here is one entry in the navigation menu.
|
// Each item here is one entry in the navigation menu.
|
||||||
'getting-started',
|
'getting-started',
|
||||||
'why',
|
'why',
|
||||||
|
'comparison',
|
||||||
'exports',
|
'exports',
|
||||||
'usage',
|
'usage',
|
||||||
'examples',
|
'examples',
|
||||||
|
|
@ -47,13 +49,19 @@ export default defineConfig({
|
||||||
'library-size',
|
'library-size',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// {
|
{
|
||||||
// label: 'Reference',
|
label: 'API Reference',
|
||||||
// autogenerate: { directory: 'reference' },
|
items: [
|
||||||
// },
|
'api/web-component',
|
||||||
|
'api/html',
|
||||||
|
'api/utils',
|
||||||
|
'api/cem-plugin',
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
components: {
|
components: {
|
||||||
Footer: './src/components/Attribution.astro',
|
Footer: './src/components/Attribution.astro',
|
||||||
|
Hero: './src/components/Hero.astro',
|
||||||
},
|
},
|
||||||
head: [
|
head: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
164
docs/src/components/Hero.astro
Normal file
164
docs/src/components/Hero.astro
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
---
|
||||||
|
// Override of Starlight's Hero so the splash page's visual slot holds the
|
||||||
|
// live size chart instead of an image. Structure and styles mirror
|
||||||
|
// @astrojs/starlight/components/Hero.astro (0.39.1) — a page that declares
|
||||||
|
// `hero.image` still gets the stock image treatment; pages without one get
|
||||||
|
// the chart.
|
||||||
|
import { Image } from 'astro:assets'
|
||||||
|
import { LinkButton } from '@astrojs/starlight/components'
|
||||||
|
import SizeChart from './SizeChart.astro'
|
||||||
|
|
||||||
|
// mirrors starlight's own constant; not a public export
|
||||||
|
const PAGE_TITLE_ID = '_top'
|
||||||
|
|
||||||
|
const { data } = Astro.locals.starlightRoute.entry
|
||||||
|
const { title = data.title, tagline, image, actions = [] } = data.hero || {}
|
||||||
|
|
||||||
|
const imageAttrs = {
|
||||||
|
loading: 'eager' as const,
|
||||||
|
decoding: 'async' as const,
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
alt: image?.alt || '',
|
||||||
|
}
|
||||||
|
|
||||||
|
let darkImage: ImageMetadata | undefined
|
||||||
|
let lightImage: ImageMetadata | undefined
|
||||||
|
let rawHtml: string | undefined
|
||||||
|
if (image) {
|
||||||
|
if ('file' in image) {
|
||||||
|
darkImage = image.file
|
||||||
|
} else if ('dark' in image) {
|
||||||
|
darkImage = image.dark
|
||||||
|
lightImage = image.light
|
||||||
|
} else {
|
||||||
|
rawHtml = image.html
|
||||||
|
}
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
{
|
||||||
|
darkImage && (
|
||||||
|
<Image
|
||||||
|
src={darkImage}
|
||||||
|
{...imageAttrs}
|
||||||
|
class:list={{ 'light:sl-hidden': Boolean(lightImage) }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{lightImage && <Image src={lightImage} {...imageAttrs} class="dark:sl-hidden" />}
|
||||||
|
{rawHtml && <div class="hero-html sl-flex" set:html={rawHtml} />}
|
||||||
|
{!image && <div class="hero-chart sl-flex"><SizeChart /></div>}
|
||||||
|
<div class="sl-flex stack">
|
||||||
|
<div class="sl-flex copy">
|
||||||
|
<h1 id={PAGE_TITLE_ID} data-page-title set:html={title} />
|
||||||
|
{tagline && <div class="tagline" set:html={tagline} />}
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
actions.length > 0 && (
|
||||||
|
<div class="sl-flex actions">
|
||||||
|
{actions.map(
|
||||||
|
({ attrs: { class: className, ...attrs } = {}, icon, link: href, text, variant }) => (
|
||||||
|
<LinkButton {href} {variant} icon={icon?.name} class:list={[className]} {...attrs}>
|
||||||
|
{text}
|
||||||
|
{icon?.html && <Fragment set:html={icon.html} />}
|
||||||
|
</LinkButton>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero > img,
|
||||||
|
.hero > .hero-html {
|
||||||
|
object-fit: contain;
|
||||||
|
width: min(70%, 20rem);
|
||||||
|
height: auto;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the chart is a layout box, not a picture: it reads better a little wider
|
||||||
|
than the stock image slot and must not be squeezed below ~17rem */
|
||||||
|
.hero > .hero-chart {
|
||||||
|
width: min(100%, 24rem);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: clamp(1.5rem, calc(1.5rem + 1vw), 2rem);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy > * {
|
||||||
|
max-width: 50ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(var(--sl-text-3xl), calc(0.25rem + 5vw), var(--sl-text-6xl));
|
||||||
|
line-height: var(--sl-line-height-headings);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--sl-color-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
font-size: clamp(var(--sl-text-base), calc(0.0625rem + 2vw), var(--sl-text-xl));
|
||||||
|
color: var(--sl-color-gray-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
gap: 1rem 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 50rem) {
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 6fr 5fr;
|
||||||
|
gap: 3%;
|
||||||
|
padding-block: clamp(2.5rem, calc(1rem + 10vmin), 10rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero > img,
|
||||||
|
.hero > .hero-html {
|
||||||
|
order: 2;
|
||||||
|
width: min(100%, 25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero > .hero-chart {
|
||||||
|
order: 2;
|
||||||
|
width: min(100%, 27rem);
|
||||||
|
margin-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
145
docs/src/components/SizeChart.astro
Normal file
145
docs/src/components/SizeChart.astro
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
---
|
||||||
|
// Hero visual: emphasis-form horizontal bar chart. WCB in the accent hue,
|
||||||
|
// every other library in the de-emphasis gray. One series, so no legend —
|
||||||
|
// each bar is direct-labeled with its name and value. Deliberately bare:
|
||||||
|
// the full methodology and feature matrix live in /comparison/.
|
||||||
|
const data = [
|
||||||
|
{ name: 'vanilla', kb: 0.2 },
|
||||||
|
{ name: 'web-component-base', kb: 2.6, accent: true },
|
||||||
|
{ name: '@elenajs/core', kb: 3.3 },
|
||||||
|
{ name: 'lit', kb: 5.3 },
|
||||||
|
{ name: 'fast-element', kb: 12.2 },
|
||||||
|
]
|
||||||
|
const max = Math.max(...data.map((d) => d.kb))
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure
|
||||||
|
class="viz-root"
|
||||||
|
aria-label="Bundle size of the same counter component per library, in kilobytes, minified and brotli-compressed. Vanilla 0.2, web-component-base 2.6, elenajs core 3.3, lit 5.3, fast-element 12.2."
|
||||||
|
>
|
||||||
|
<div class="chart">
|
||||||
|
{
|
||||||
|
data.map((d) => (
|
||||||
|
<div class={`row${d.accent ? ' is-accent' : ''}`}>
|
||||||
|
<div class="name">{d.name}</div>
|
||||||
|
<div class="track">
|
||||||
|
<div class="bar" style={`width: ${(d.kb / max) * 100}%`} />
|
||||||
|
</div>
|
||||||
|
<div class="value">{d.kb} kB</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<figcaption><a href="/comparison/">See comparison</a></figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.viz-root {
|
||||||
|
/* light */
|
||||||
|
--surface: #fcfcfb;
|
||||||
|
--text-primary: #0b0b0b;
|
||||||
|
--text-secondary: #52514e;
|
||||||
|
--muted: #898781;
|
||||||
|
--baseline: #c3c2b7;
|
||||||
|
--accent: #2a78d6;
|
||||||
|
--context: #898781;
|
||||||
|
--ring: rgba(11, 11, 11, 0.1);
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1.1rem 1.2rem 0.9rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--ring);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:where(:not([data-theme='light'])) .viz-root {
|
||||||
|
--surface: #1a1a19;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #c3c2b7;
|
||||||
|
--baseline: #383835;
|
||||||
|
--accent: #3987e5;
|
||||||
|
--ring: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme='dark'] .viz-root {
|
||||||
|
--surface: #1a1a19;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #c3c2b7;
|
||||||
|
--baseline: #383835;
|
||||||
|
--accent: #3987e5;
|
||||||
|
--ring: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* 2px surface gap between adjacent bars */
|
||||||
|
gap: 2px;
|
||||||
|
border-left: 1px solid var(--baseline);
|
||||||
|
padding-left: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 9rem) minmax(0, 1fr) 3.4rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.is-accent .name {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 13px;
|
||||||
|
min-width: 3px;
|
||||||
|
background: var(--context);
|
||||||
|
/* rounded data-end only; the baseline end stays square */
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
.is-accent .bar {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.is-accent .value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
figcaption {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
padding-left: 0.7rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
figcaption a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.2em;
|
||||||
|
}
|
||||||
|
figcaption a:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
55
docs/src/content/docs/api/cem-plugin.md
Normal file
55
docs/src/content/docs/api/cem-plugin.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
---
|
||||||
|
title: cem-plugin
|
||||||
|
slug: api/cem-plugin
|
||||||
|
description: The wcbStaticProps CEM analyzer plugin.
|
||||||
|
---
|
||||||
|
|
||||||
|
A plugin for
|
||||||
|
[`@custom-elements-manifest/analyzer`](https://custom-elements-manifest.open-wc.org/)
|
||||||
|
that teaches it to read wcb's `static props` object.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { wcbStaticProps } from 'web-component-base/cem-plugin'
|
||||||
|
// also available as the module's default export
|
||||||
|
import wcbStaticProps from 'web-component-base/cem-plugin'
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [CEM Analyzer Plugin guide](/cem-plugin/) for setup, Storybook and
|
||||||
|
editor integration.
|
||||||
|
|
||||||
|
## `wcbStaticProps()`
|
||||||
|
|
||||||
|
Takes no arguments and returns an analyzer plugin object.
|
||||||
|
|
||||||
|
| Field | Type | |
|
||||||
|
| -------------- | ----------------------- | ----------------------------------- |
|
||||||
|
| `name` | `string` | the plugin name |
|
||||||
|
| `analyzePhase` | `(ctx: any) => void` | the analyzer hook |
|
||||||
|
|
||||||
|
```js
|
||||||
|
// custom-elements-manifest.config.mjs
|
||||||
|
import { wcbStaticProps } from 'web-component-base/cem-plugin'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
globs: ['src/**/*.js'],
|
||||||
|
outdir: '.',
|
||||||
|
plugins: [wcbStaticProps()],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
For each class that extends `WebComponent`, it reads the `static props`
|
||||||
|
initializer and, for every key, records in the manifest:
|
||||||
|
|
||||||
|
- the **member** under its camelCase name
|
||||||
|
- the **attribute** under its kebab-case name, linked to that member
|
||||||
|
- the **type** inferred from the default value — `boolean`, `number`, `object`
|
||||||
|
or `string`
|
||||||
|
- the **default value** as written
|
||||||
|
|
||||||
|
Without it the analyzer sees `props` as one opaque static field and emits no
|
||||||
|
attributes, so editor completion and Storybook controls have nothing to read.
|
||||||
|
|
||||||
|
The `static props` object may be declared inline or as an identifier resolved
|
||||||
|
from the same source file.
|
||||||
85
docs/src/content/docs/api/html.md
Normal file
85
docs/src/content/docs/api/html.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
---
|
||||||
|
title: html
|
||||||
|
slug: api/html
|
||||||
|
description: The html tagged template function and the vnode shape it produces.
|
||||||
|
---
|
||||||
|
|
||||||
|
A tagged template function that turns markup into a vnode tree, for use as a
|
||||||
|
component's [`template`](/api/web-component/#template).
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { html } from 'web-component-base'
|
||||||
|
// or
|
||||||
|
import { html } from 'web-component-base/html.js'
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
get template() {
|
||||||
|
return html`<p class="greeting">Hello, ${this.props.name}!</p>`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It is [htm](https://github.com/developit/htm) bound to a hyperscript factory, so
|
||||||
|
the full htm syntax applies: standard HTML, self-closing tags, `${}`
|
||||||
|
interpolation in text and attribute positions, spread props (`...${obj}`), and
|
||||||
|
optional closing tags (`<//>`).
|
||||||
|
|
||||||
|
## Return value
|
||||||
|
|
||||||
|
| Markup | Returns |
|
||||||
|
| ----------------- | ------------------------------------------- |
|
||||||
|
| a single root | one vnode object |
|
||||||
|
| several roots | an array of vnodes |
|
||||||
|
| nothing | `undefined` |
|
||||||
|
|
||||||
|
A vnode is a plain object:
|
||||||
|
|
||||||
|
```js
|
||||||
|
html`<p class="a">hi</p>`
|
||||||
|
// { type: 'p', props: { class: 'a' }, children: ['hi'] }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ---------- | ----------------- | ---------------------------------------------------- |
|
||||||
|
| `type` | `string` | the tag name |
|
||||||
|
| `props` | `object \| null` | attributes and properties as written |
|
||||||
|
| `children` | `any[]` | child vnodes and text; text stays as a raw value |
|
||||||
|
|
||||||
|
Because the tree is a plain object it is comparable and serializable, which is
|
||||||
|
what lets `render()` diff one render against the next.
|
||||||
|
|
||||||
|
`` html`` `` returns `undefined` — this is the idiomatic way for a component to
|
||||||
|
render nothing, and it empties the rendered subtree rather than leaving the
|
||||||
|
previous render on screen.
|
||||||
|
|
||||||
|
See it live: [Templating demo ↗](https://demo.webcomponent.io/examples/templating/)
|
||||||
|
|
||||||
|
## How props are applied
|
||||||
|
|
||||||
|
Each entry in `props` is applied by [`applyProp`](/api/utils/#applypropel-prop-value),
|
||||||
|
in this order:
|
||||||
|
|
||||||
|
1. a `style` object is applied rule by rule
|
||||||
|
2. a name the element owns as a **DOM property** is assigned to that property —
|
||||||
|
so event handlers (`onclick=${fn}`) and non-string values keep their type
|
||||||
|
3. a boolean value with no matching DOM property is toggled as an HTML boolean
|
||||||
|
attribute
|
||||||
|
4. anything else is serialized and set as an attribute
|
||||||
|
|
||||||
|
The same rule applies to freshly created and patched elements, so a prop behaves
|
||||||
|
identically on first render and re-render.
|
||||||
|
|
||||||
|
A `style` prop accepts an object of camelCase CSS properties:
|
||||||
|
|
||||||
|
```js
|
||||||
|
html`<div style=${{ color: 'red', padding: '1em' }}>x</div>`
|
||||||
|
```
|
||||||
|
|
||||||
|
See it live: [Style objects demo ↗](https://demo.webcomponent.io/examples/style-objects/)
|
||||||
|
|
||||||
|
## Re-rendering
|
||||||
|
|
||||||
|
Returning a vnode tree opts into in-place reconciliation: elements of the same
|
||||||
|
tag are reused, only changed props and text are touched, and leftover nodes are
|
||||||
|
trimmed. See [Template vs Render](/template-vs-render/) for what that preserves
|
||||||
|
and the non-keyed matching caveat. See it live: [Render reconciliation demo ↗](https://demo.webcomponent.io/examples/render-reconciliation/)
|
||||||
136
docs/src/content/docs/api/utils.md
Normal file
136
docs/src/content/docs/api/utils.md
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
---
|
||||||
|
title: Utilities
|
||||||
|
slug: api/utils
|
||||||
|
description: Case conversion, attribute serialization, element creation and the vnode reconciler.
|
||||||
|
---
|
||||||
|
|
||||||
|
The helpers `WebComponent` uses internally, exported so you can use them
|
||||||
|
directly. Import from the `utils` entry point or each module separately:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { serialize, getKebabCase } from 'web-component-base/utils'
|
||||||
|
// or
|
||||||
|
import { serialize } from 'web-component-base/utils/serialize.js'
|
||||||
|
```
|
||||||
|
|
||||||
|
See it live: [Just the parts demo ↗](https://demo.webcomponent.io/examples/just-parts/) builds a component from these helpers without extending the base class.
|
||||||
|
|
||||||
|
## Case conversion
|
||||||
|
|
||||||
|
### `getCamelCase(kebab)`
|
||||||
|
|
||||||
|
Converts a kebab-case attribute name to its camelCase prop key.
|
||||||
|
|
||||||
|
| Parameter | Type | |
|
||||||
|
| ----------- | -------- | ------------------------ |
|
||||||
|
| `kebab` | `string` | the attribute name |
|
||||||
|
| **returns** | `string` | the prop key |
|
||||||
|
|
||||||
|
```js
|
||||||
|
getCamelCase('max-count') // 'maxCount'
|
||||||
|
```
|
||||||
|
|
||||||
|
### `getKebabCase(str)`
|
||||||
|
|
||||||
|
Converts a camelCase prop key to its kebab-case attribute name. This is the
|
||||||
|
mapping `observedAttributes` uses.
|
||||||
|
|
||||||
|
| Parameter | Type | |
|
||||||
|
| ----------- | -------- | ------------------------ |
|
||||||
|
| `str` | `string` | the prop key |
|
||||||
|
| **returns** | `string` | the attribute name |
|
||||||
|
|
||||||
|
```js
|
||||||
|
getKebabCase('maxCount') // 'max-count'
|
||||||
|
```
|
||||||
|
|
||||||
|
Consecutive capitals are treated as one word, so `parseHTML` becomes
|
||||||
|
`parse-html`.
|
||||||
|
|
||||||
|
## Attribute serialization
|
||||||
|
|
||||||
|
### `serialize(value)`
|
||||||
|
|
||||||
|
Converts a value to its attribute string form.
|
||||||
|
|
||||||
|
| Parameter | Type | |
|
||||||
|
| ----------- | -------- | -------------------------------------- |
|
||||||
|
| `value` | `any` | the value to serialize |
|
||||||
|
| **returns** | `string` | the attribute value |
|
||||||
|
|
||||||
|
Numbers, booleans and objects go through `JSON.stringify`; strings and
|
||||||
|
everything else pass through unchanged.
|
||||||
|
|
||||||
|
### `deserialize(value, type)`
|
||||||
|
|
||||||
|
Parses an attribute string back into a value of the given declared type — the
|
||||||
|
inverse of `serialize()`.
|
||||||
|
|
||||||
|
| Parameter | Type | |
|
||||||
|
| ----------- | -------- | --------------------------------------------------- |
|
||||||
|
| `value` | `string` | the attribute value |
|
||||||
|
| `type` | `string` | `'boolean'`, `'number'`, `'object'`, `'undefined'` or `'string'` |
|
||||||
|
| **returns** | `any` | the parsed value |
|
||||||
|
|
||||||
|
`'boolean'` always returns `true` — strict HTML boolean-attribute semantics,
|
||||||
|
where any present value is true. Absence is handled by the caller and never
|
||||||
|
reaches here. `'number'`, `'object'` and `'undefined'` use `JSON.parse` and
|
||||||
|
throw on malformed input; strings pass through.
|
||||||
|
|
||||||
|
## Elements
|
||||||
|
|
||||||
|
### `createElement(tree)`
|
||||||
|
|
||||||
|
Builds real DOM from a vnode tree.
|
||||||
|
|
||||||
|
| Parameter | Type | |
|
||||||
|
| ----------- | -------- | ---------------------------------------------------- |
|
||||||
|
| `tree` | `any` | a vnode, an array of vnodes, or a text value |
|
||||||
|
| **returns** | `Node` | an element, a `DocumentFragment`, or a text node |
|
||||||
|
|
||||||
|
An array becomes a `DocumentFragment`; a value with no `type` becomes a text
|
||||||
|
node. Props are applied with `applyProp()` and children are created
|
||||||
|
recursively.
|
||||||
|
|
||||||
|
### `applyProp(el, prop, value)`
|
||||||
|
|
||||||
|
Applies a single vnode prop to an element, using the rule described in
|
||||||
|
[html](/api/html/#how-props-are-applied).
|
||||||
|
|
||||||
|
| Parameter | Type | |
|
||||||
|
| --------- | --------- | ---------------------------------- |
|
||||||
|
| `el` | `Element` | the element to apply the prop to |
|
||||||
|
| `prop` | `string` | the prop name as written in the vnode |
|
||||||
|
| `value` | `any` | the prop value |
|
||||||
|
|
||||||
|
Shared with the reconciler, so a patched element gets props by exactly the same
|
||||||
|
rule as a freshly created one.
|
||||||
|
|
||||||
|
## Reconciler
|
||||||
|
|
||||||
|
These power the in-place re-render described in
|
||||||
|
[Template vs Render](/template-vs-render/). Matching is **index-based and
|
||||||
|
non-keyed**.
|
||||||
|
|
||||||
|
### `patchChildren(parent, oldChildren, newChildren)`
|
||||||
|
|
||||||
|
Reconciles a parent node's children from one vnode list to another, patching
|
||||||
|
matches in place and trimming leftovers.
|
||||||
|
|
||||||
|
| Parameter | Type | |
|
||||||
|
| ------------- | ------ | ---------------------------------------------- |
|
||||||
|
| `parent` | `Node` | the parent node to patch into |
|
||||||
|
| `oldChildren` | `any` | the previous vnode children, or previous tree |
|
||||||
|
| `newChildren` | `any` | the new vnode children, or new tree |
|
||||||
|
|
||||||
|
### `patchNode(parent, dom, oldVnode, newVnode)`
|
||||||
|
|
||||||
|
Reconciles a single node position. Reuses `dom` when the vnode type matches,
|
||||||
|
otherwise replaces it.
|
||||||
|
|
||||||
|
| Parameter | Type | |
|
||||||
|
| ---------- | ----------------- | ---------------------------------------- |
|
||||||
|
| `parent` | `Node` | the parent node being patched |
|
||||||
|
| `dom` | `Node \| null` | the existing node at this index, if any |
|
||||||
|
| `oldVnode` | `any` | the vnode that produced `dom`, if known |
|
||||||
|
| `newVnode` | `any` | the vnode to render |
|
||||||
265
docs/src/content/docs/api/web-component.md
Normal file
265
docs/src/content/docs/api/web-component.md
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
---
|
||||||
|
title: WebComponent
|
||||||
|
slug: api/web-component
|
||||||
|
description: The WebComponent base class — static configuration, instance members, lifecycle hooks and attribute converters.
|
||||||
|
---
|
||||||
|
|
||||||
|
The base class every component extends. Import from the package root or its own
|
||||||
|
module:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { WebComponent } from 'web-component-base'
|
||||||
|
// or
|
||||||
|
import { WebComponent } from 'web-component-base/WebComponent.js'
|
||||||
|
```
|
||||||
|
|
||||||
|
`WebComponent` extends `HTMLElement`, so a subclass is registered with
|
||||||
|
`customElements.define()` like any other custom element.
|
||||||
|
|
||||||
|
In TypeScript, pass the shape of `static props` as a type argument to get a
|
||||||
|
typed `this.props`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const props = { variant: 'primary', disabled: false }
|
||||||
|
|
||||||
|
class CozyButton extends WebComponent<typeof props> {
|
||||||
|
static props = props
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Static properties
|
||||||
|
|
||||||
|
### `static props`
|
||||||
|
|
||||||
|
An object of declared prop names and their default values.
|
||||||
|
|
||||||
|
```js
|
||||||
|
static props = { count: 0, label: 'hi', disabled: false }
|
||||||
|
```
|
||||||
|
|
||||||
|
It drives three things at once:
|
||||||
|
|
||||||
|
- **Observed attributes.** Each key is kebab-cased, so `maxCount` observes
|
||||||
|
`max-count`.
|
||||||
|
- **The runtime type guard.** The `typeof` each default becomes the prop's
|
||||||
|
declared type. A write of a different type is rejected (see
|
||||||
|
[`strictProps`](#static-strictprops)).
|
||||||
|
- **The compile-time type of `this.props`** when the object is passed as the
|
||||||
|
class type argument.
|
||||||
|
|
||||||
|
Defaults are copied per instance with `structuredClone`, so object and array
|
||||||
|
defaults are never shared between instances. Values that cannot be cloned
|
||||||
|
(functions, class instances) are kept by reference instead of throwing.
|
||||||
|
|
||||||
|
On first use of each class, defaults that cannot reflect to an attribute are
|
||||||
|
reported with `console.warn`:
|
||||||
|
|
||||||
|
| Default | Warning |
|
||||||
|
| ------------------ | --------------------------------------------------- |
|
||||||
|
| a function or symbol | not reflectable — use handlers or refs instead |
|
||||||
|
| `true` | boolean defaults should be `false` — invert the name |
|
||||||
|
|
||||||
|
A `true` boolean default is discouraged because HTML has no true-by-default
|
||||||
|
boolean attribute: absence would have to mean both "false" and "default". Name
|
||||||
|
the prop for its `false` state (`disabled`, not `enabled`).
|
||||||
|
|
||||||
|
See it live: [Props blueprint demo ↗](https://demo.webcomponent.io/examples/props-blueprint/)
|
||||||
|
|
||||||
|
### `static styles`
|
||||||
|
|
||||||
|
CSS adopted into the shadow root as constructable stylesheet(s).
|
||||||
|
|
||||||
|
```js
|
||||||
|
static shadowRootInit = { mode: 'open' }
|
||||||
|
static styles = `p { color: red; }`
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepts a string, a `CSSStyleSheet`, or an array mixing both. An array is
|
||||||
|
adopted in declaration order, so a shared token sheet can come first and
|
||||||
|
per-component rules after it. Strings are compiled to a `CSSStyleSheet` once;
|
||||||
|
existing `CSSStyleSheet` instances are adopted as-is and can be shared across
|
||||||
|
components.
|
||||||
|
|
||||||
|
Adoption happens **once per instance**, when the element is constructed — not
|
||||||
|
per render.
|
||||||
|
|
||||||
|
Requires [`shadowRootInit`](#static-shadowrootinit). Without a shadow root
|
||||||
|
there is nothing to adopt into, and the failure is reported with
|
||||||
|
`console.error` rather than thrown.
|
||||||
|
|
||||||
|
See it live: [Constructable styles demo ↗](https://demo.webcomponent.io/examples/constructed-styles/)
|
||||||
|
|
||||||
|
### `static shadowRootInit`
|
||||||
|
|
||||||
|
A [`ShadowRootInit`](https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#options)
|
||||||
|
object. Its presence is what opts the component into shadow DOM — the shadow
|
||||||
|
root is attached during construction and becomes the render target.
|
||||||
|
|
||||||
|
```js
|
||||||
|
static shadowRootInit = { mode: 'open' }
|
||||||
|
```
|
||||||
|
|
||||||
|
Without it the component renders into its own light DOM.
|
||||||
|
|
||||||
|
See it live: [Shadow DOM demo ↗](https://demo.webcomponent.io/examples/use-shadow/)
|
||||||
|
|
||||||
|
### `static strictProps`
|
||||||
|
|
||||||
|
When `true`, assigning a value whose type does not match the declared type
|
||||||
|
throws a `TypeError`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
static strictProps = true
|
||||||
|
```
|
||||||
|
|
||||||
|
The default is to report the violation with `console.error` and skip the write,
|
||||||
|
so a stray assignment cannot halt `render()` or `onChanges()`.
|
||||||
|
|
||||||
|
Either way, `null` and `undefined` are always allowed.
|
||||||
|
|
||||||
|
See it live: [Prop type enforcement demo ↗](https://demo.webcomponent.io/examples/strict-props/)
|
||||||
|
|
||||||
|
### `static get observedAttributes`
|
||||||
|
|
||||||
|
Returns the kebab-cased keys of [`static props`](#static-props). Provided by the
|
||||||
|
base class; you do not normally define it yourself.
|
||||||
|
|
||||||
|
## Instance properties
|
||||||
|
|
||||||
|
### `props`
|
||||||
|
|
||||||
|
Read-only accessor returning a `Proxy` over the component's prop values. Read
|
||||||
|
and write camelCase keys directly:
|
||||||
|
|
||||||
|
```js
|
||||||
|
this.props.count += 1
|
||||||
|
```
|
||||||
|
|
||||||
|
A write that changes the value reflects to the matching attribute through
|
||||||
|
[`toAttribute()`](#toattributename-value), which in turn triggers a render.
|
||||||
|
Assigning the value it already holds does nothing.
|
||||||
|
|
||||||
|
### `template`
|
||||||
|
|
||||||
|
Read-only getter returning what the component renders. Two kinds are supported:
|
||||||
|
|
||||||
|
- an [`html`](/api/html/) tagged template — a vnode tree, reconciled in place
|
||||||
|
on re-render
|
||||||
|
- a **string** — assigned to the render target's `innerHTML`
|
||||||
|
|
||||||
|
Both render into the same target: the shadow root when `shadowRootInit` is set,
|
||||||
|
the element itself otherwise. Returning `` html`` `` (which is `undefined`) or
|
||||||
|
`''` empties the rendered subtree, which is how a component renders nothing
|
||||||
|
without disturbing light-DOM children a consumer slotted in.
|
||||||
|
|
||||||
|
Switching between the two kinds is safe in either direction: a string render
|
||||||
|
resets the vnode bookkeeping so the next vnode render rebuilds from scratch.
|
||||||
|
|
||||||
|
The base implementation returns `''`.
|
||||||
|
|
||||||
|
See it live: [Templating demo ↗](https://demo.webcomponent.io/examples/templating/)
|
||||||
|
|
||||||
|
### `render()`
|
||||||
|
|
||||||
|
Renders `template` into the render target. Called automatically on connect and
|
||||||
|
on every prop or attribute change; you rarely call it yourself.
|
||||||
|
|
||||||
|
For a vnode template, the new tree is compared against the previous one and
|
||||||
|
re-render **patches the existing DOM in place** — see
|
||||||
|
[Template vs Render](/template-vs-render/) for what that preserves and the
|
||||||
|
non-keyed matching caveat.
|
||||||
|
|
||||||
|
## Lifecycle hooks
|
||||||
|
|
||||||
|
Override any of these; all are no-ops by default.
|
||||||
|
|
||||||
|
| Hook | When it runs |
|
||||||
|
| ----------------- | ----------------------------------------------------- |
|
||||||
|
| `onInit()` | on connect, before the first render |
|
||||||
|
| `afterViewInit()` | on connect, after the first render |
|
||||||
|
| `onChanges(changes)` | after an observed attribute changes |
|
||||||
|
| `onDestroy()` | when the element is disconnected |
|
||||||
|
|
||||||
|
On connect the order is always: default reflection → `onInit()` → `render()` →
|
||||||
|
`afterViewInit()`. Attribute-driven renders and `onChanges()` calls that the
|
||||||
|
platform fires *before* connect are buffered, so `onInit()` is guaranteed to run
|
||||||
|
before the first render even for attributes written in markup.
|
||||||
|
|
||||||
|
`onChanges()` receives:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --------------- | -------- | -------------------------------------------- |
|
||||||
|
| `property` | `string` | camelCase prop key, matching `props` access |
|
||||||
|
| `attribute` | `string` | kebab-case attribute name that changed |
|
||||||
|
| `previousValue` | `any` | value before the change |
|
||||||
|
| `currentValue` | `any` | value after the change |
|
||||||
|
|
||||||
|
See [Life-cycle Hooks](/life-cycle-hooks/) for worked examples. See it live: [Lifecycle order demo ↗](https://demo.webcomponent.io/examples/lifecycle-order/) and [onChanges payload demo ↗](https://demo.webcomponent.io/examples/on-changes/)
|
||||||
|
|
||||||
|
## Attribute converters
|
||||||
|
|
||||||
|
Override these to control how one prop crosses the prop/attribute boundary, and
|
||||||
|
call `super` for the props you do not handle.
|
||||||
|
|
||||||
|
### `toAttribute(name, value)`
|
||||||
|
|
||||||
|
Converts a prop value into the attribute value that reflects it.
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | -------- | ---------------------------------------- |
|
||||||
|
| `name` | `string` | camelCase prop key |
|
||||||
|
| `value` | `any` | the prop value being reflected |
|
||||||
|
| **returns** | `string \| null` | the attribute value, or `null` to remove the attribute |
|
||||||
|
|
||||||
|
Returning `null` **removes** the attribute. That is how a `false` boolean
|
||||||
|
becomes an absent attribute, and it works for any prop.
|
||||||
|
|
||||||
|
```js
|
||||||
|
toAttribute(name, value) {
|
||||||
|
if (name === 'point') return `${value.x},${value.y}`
|
||||||
|
return super.toAttribute(name, value)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `fromAttribute(name, value)`
|
||||||
|
|
||||||
|
Converts an attribute value into the prop value it represents — the inverse of
|
||||||
|
`toAttribute()`.
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | -------- | --------------------------------------------- |
|
||||||
|
| `name` | `string` | camelCase prop key |
|
||||||
|
| `value` | `string` | the attribute value, never `null` |
|
||||||
|
| **returns** | `any` | the value to store on `this.props[name]` |
|
||||||
|
|
||||||
|
Only called for attributes that are **present**. Removal is handled by the
|
||||||
|
declared-default reset instead, so a converter never has to handle `null`.
|
||||||
|
|
||||||
|
A malformed value for a typed prop falls back to the raw string rather than
|
||||||
|
throwing, so `render()` and `onChanges()` are never skipped.
|
||||||
|
|
||||||
|
See it live: [Custom attribute converters demo ↗](https://demo.webcomponent.io/examples/attribute-converters/) and [Typed props demo ↗](https://demo.webcomponent.io/examples/type-restore/)
|
||||||
|
|
||||||
|
## Boolean props
|
||||||
|
|
||||||
|
Boolean props follow the HTML convention in both directions: **presence means
|
||||||
|
`true`, absence means `false`**.
|
||||||
|
|
||||||
|
| State | Attribute | `toAttribute` returns |
|
||||||
|
| ------- | -------------------- | --------------------- |
|
||||||
|
| `true` | present, empty value | `''` |
|
||||||
|
| `false` | absent | `null` |
|
||||||
|
|
||||||
|
Any present value reads as `true` — including the literal `flag="false"`, just
|
||||||
|
as native `disabled="false"` is still disabled. Removing the attribute always
|
||||||
|
yields `false`, never the declared default.
|
||||||
|
|
||||||
|
Use `toggleAttribute(name, bool)` to set them. Writing
|
||||||
|
`setAttribute(name, String(bool))` always means `true`; wcb warns in the console
|
||||||
|
when it sees a boolean attribute written as `"true"` or `"false"` so the
|
||||||
|
inversion cannot fail silently.
|
||||||
|
|
||||||
|
Attributes whose `"false"` is meaningful — `aria-*`, `contenteditable` — should
|
||||||
|
be declared as **string** props.
|
||||||
|
|
||||||
|
See it live: [Boolean props demo ↗](https://demo.webcomponent.io/examples/boolean-props/)
|
||||||
52
docs/src/content/docs/guides/comparison.md
Normal file
52
docs/src/content/docs/guides/comparison.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
---
|
||||||
|
title: 'WCB & Similar Libraries'
|
||||||
|
slug: comparison
|
||||||
|
---
|
||||||
|
|
||||||
|
The releases since v5 gave the `WebComponent` base class a stricter compliance with custom elements specifications, quality of life improvements, and overall robustness by combining JS components authoring expectations and stable HTML behaviors. We now have in-place re-rendering, HTML boolean semantics and overrideable attribute converters, among other improvements.
|
||||||
|
|
||||||
|
This page puts these benefits and their cost in context: how much does WCB weigh in size compared to similar web-component libraries, what does each library buy you over writing custom elements from scratch, and when is WCB the right choice.
|
||||||
|
|
||||||
|
## Measured size: the same component in each library
|
||||||
|
|
||||||
|
Numbers below are **measured, not quoted from marketing pages**. The same minimal counter component — one reactive `count` prop, a click handler, a re-render on change — was written idiomatically in each library, bundled with `esbuild --bundle --minify --format=esm`, and compressed with gzip (level 9) and brotli (quality 11). This is the real "cost of your first component": library runtime + component code, everything the browser downloads.
|
||||||
|
|
||||||
|
| Library | Version | Minified | Gzip | Brotli |
|
||||||
|
| ------------------------- | ------- | -------- | ------- | ---------- |
|
||||||
|
| **web-component-base** | 6.1.0 | 6.7 kB | 2.9 kB | **2.6 kB** |
|
||||||
|
| `@elenajs/core` | 1.0.0 | 9.1 kB | 3.7 kB | 3.3 kB |
|
||||||
|
| `lit` | 3.3.3 | 15.3 kB | 5.9 kB | 5.3 kB |
|
||||||
|
| `@microsoft/fast-element` | 3.0.1 | 44.9 kB | 13.7 kB | 12.2 kB |
|
||||||
|
| vanilla `HTMLElement` | — | — | — | ~0.2 kB |
|
||||||
|
|
||||||
|
For scale: even after all of the v5.2–v6.1 work, the WCB counter is **~21% smaller than Elena, ~51% smaller than Lit, and ~79% smaller than FAST**.
|
||||||
|
|
||||||
|
## Feature comparison
|
||||||
|
|
||||||
|
What each library gives you beyond extending directly from `HTMLElement` — the boilerplate you no longer write by hand:
|
||||||
|
|
||||||
|
| Capability | WCB 6.1 | Elena 1.0 | Lit 3.3 | FAST 3.0 |
|
||||||
|
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------- | ------------------------------------------------ |
|
||||||
|
| Declarative templates | ✅ `html` tagged templates (htm) or plain strings | ✅ `html` tagged templates | ✅ `lit-html` tagged templates | ✅ typed templates with binding expressions |
|
||||||
|
| Reactive props ⇄ attributes | ✅ automatic, typed serialization (string/number/boolean/object), camelCase ⇄ kebab-case, HTML boolean semantics, overrideable `toAttribute`/`fromAttribute` converters | ✅ `static props`, opt-in reflection | ✅ `static properties` with converters | ✅ `@attr` / observables |
|
||||||
|
| Update strategy | In-place patch (index-based, non-keyed) | Batched re-renders | Part-based: only touched bindings update, keyed `repeat` | Fine-grained observable bindings, keyed `repeat` |
|
||||||
|
| Preserves DOM state on re-render | ✅ since v5.2 | ✅ | ✅ | ✅ |
|
||||||
|
| Update batching / scheduling | ❌ renders per prop write | ✅ batched, `updateComplete` | ✅ async batched, `updateComplete` | ✅ queued/batched |
|
||||||
|
| Keyed list reconciliation | ❌ positional only | ❌ | ✅ `repeat` directive | ✅ `repeat` with recycling controls |
|
||||||
|
| Light DOM by default | ✅ (shadow DOM opt-in via `static shadowRootInit`) | ✅ (shadow opt-in) | ❌ shadow DOM by default | ❌ shadow DOM by default |
|
||||||
|
| Scoped styles | ✅ `static styles` + constructable stylesheets (needs shadow root) | ✅ scoped without shadow DOM | ✅ shadow-scoped CSS | ✅ shadow-scoped + design tokens |
|
||||||
|
| SSR / hydration story | ⚠️ attribute-driven state renders from any server; no hydration runtime | ✅ HTML/CSS-first, server utilities | ✅ `@lit-labs/ssr` + hydration | ⚠️ experimental SSR |
|
||||||
|
| Works with zero build tooling | ✅ import from CDN, no compiler | ✅ | ✅ (buildless possible, decorators need tooling) | ⚠️ practical with tooling |
|
||||||
|
| Editor/IDE tooling | ✅ typed props + [CEM analyzer plugin](/cem-plugin/) | ✅ CEM-focused | ✅ extensive (analyzer, TS decorators, IDE plugins) | ✅ TS-first |
|
||||||
|
| Lifecycle hooks | `onInit`, `afterViewInit`, `onChanges`, `onDestroy` | `willUpdate`, `firstUpdated`, `updated` | full reactive update lifecycle | full lifecycle + behaviors |
|
||||||
|
| Backing / ecosystem | solo maintainer, small surface | new (2026), design-system focus | Google, huge ecosystem | Microsoft, powers Fluent UI |
|
||||||
|
|
||||||
|
:::note[Why 11ty WebC isn't here]
|
||||||
|
WebC is a compile-time tool: it resolves components during an Eleventy build and ships plain HTML with no client runtime. Every row above is about what a library does _in the browser at runtime_, so a side-by-side comparison would be measuring two different things. If your components are static at build time, WebC solves a different problem — and solves it well.
|
||||||
|
:::
|
||||||
|
|
||||||
|
For what these numbers and capabilities add up to — and when they don't — see [Why would anyone use WCB?](/why-wcb/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_WCB re-measured 2026-07-20 at v6.1.0; the other libraries measured 2026-07-19, with esbuild, Node zlib (gzip −9, brotli q11), at the pinned versions above. Methodology: identical counter component per library, bundled per library, compressed. Re-run them yourself — the benchmark is trivially reproducible with the versions pinned above._
|
||||||
|
|
@ -3,6 +3,34 @@ title: Examples
|
||||||
slug: examples
|
slug: examples
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Live demo gallery
|
||||||
|
|
||||||
|
Every example below runs as a standalone page at
|
||||||
|
[demo.webcomponent.io ↗](https://demo.webcomponent.io/) — a live gallery with
|
||||||
|
the source alongside each demo.
|
||||||
|
|
||||||
|
| Demo | Shows |
|
||||||
|
| ---- | ----- |
|
||||||
|
| [Boolean props ↗](https://demo.webcomponent.io/examples/boolean-props/) | presence/absence reflection, `toggleAttribute`, `[flag]` selectors |
|
||||||
|
| [Custom attribute converters ↗](https://demo.webcomponent.io/examples/attribute-converters/) | `toAttribute`/`fromAttribute` for `Date` and array props |
|
||||||
|
| [Props blueprint ↗](https://demo.webcomponent.io/examples/props-blueprint/) | `static props` as the single source of defaults and types |
|
||||||
|
| [Prop type enforcement ↗](https://demo.webcomponent.io/examples/strict-props/) | `static strictProps` and the log-not-throw default |
|
||||||
|
| [Compile-time prop types ↗](https://demo.webcomponent.io/examples/typed-props/) | typing `this.props` in TypeScript |
|
||||||
|
| [Typed props ↗](https://demo.webcomponent.io/examples/type-restore/) | attribute round-trips restoring the declared type |
|
||||||
|
| [Templating ↗](https://demo.webcomponent.io/examples/templating/) | string vs `html` tagged-template rendering |
|
||||||
|
| [Render reconciliation ↗](https://demo.webcomponent.io/examples/render-reconciliation/) | in-place patching preserving focus, caret and input state |
|
||||||
|
| [Style objects ↗](https://demo.webcomponent.io/examples/style-objects/) | calculated and conditional styles via the `style` prop |
|
||||||
|
| [Shadow DOM ↗](https://demo.webcomponent.io/examples/use-shadow/) | `static shadowRootInit` |
|
||||||
|
| [Constructable styles ↗](https://demo.webcomponent.io/examples/constructed-styles/) | `static styles`, including composing several sheets |
|
||||||
|
| [Lifecycle order ↗](https://demo.webcomponent.io/examples/lifecycle-order/) | each hook logged as it fires |
|
||||||
|
| [Attribute lifecycle ↗](https://demo.webcomponent.io/examples/attribute-lifecycle/) | how attribute changes drive the hooks |
|
||||||
|
| [onChanges payload ↗](https://demo.webcomponent.io/examples/on-changes/) | camelCase `property` vs kebab-case `attribute` |
|
||||||
|
| [Just the parts ↗](https://demo.webcomponent.io/examples/just-parts/) | using `html`/`createElement` without the base class |
|
||||||
|
| [Kitchen sink ↗](https://demo.webcomponent.io/examples/demo/) | several features together |
|
||||||
|
| [Single-file pen ↗](https://demo.webcomponent.io/examples/pens/counter-toggle.html) | counter and toggle in one HTML file |
|
||||||
|
|
||||||
|
## CodePen examples
|
||||||
|
|
||||||
### 1. To-Do App
|
### 1. To-Do App
|
||||||
|
|
||||||
A simple app that allows adding / completing tasks:
|
A simple app that allows adding / completing tasks:
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,13 @@ title: Getting Started
|
||||||
slug: getting-started
|
slug: getting-started
|
||||||
---
|
---
|
||||||
|
|
||||||
import { Aside, Badge } from '@astrojs/starlight/components';
|
**Web Component Base (WCB)** is a zero-dependency, tiny JS base class for creating reactive [custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_Components/Using_custom_elements) easily.
|
||||||
|
|
||||||
**Web Component Base (WCB)**
|
|
||||||
<Badge text="alpha" variant="danger" /> is a zero-dependency, tiny JS base class for creating reactive [custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_Components/Using_custom_elements) easily.
|
|
||||||
|
|
||||||
When you extend the WebComponent class for your custom element, you only have to define the template and properties. Any change in an observed property's value will automatically cause the component UI to render.
|
When you extend the WebComponent class for your custom element, you only have to define the template and properties. Any change in an observed property's value will automatically cause the component UI to render.
|
||||||
|
|
||||||
The result is a reactive UI on property changes.
|
The result is a reactive UI on property changes.
|
||||||
|
|
||||||
Note that there's a trade off between productivity & lightweight-ness here, and the project aims to help in writing custom elements in the same way more mature and better maintained projects already do. Please look into popular options such as [Microsoft's FASTElement](https://fast.design/) & [Google's LitElement](https://lit.dev/) as well.
|
You can see every feature running at [demo.webcomponent.io ↗](https://demo.webcomponent.io/) — a live gallery with the source alongside each demo. Individual demos are linked from the guide that covers them, and the full list is in [Examples](/examples/#live-demo-gallery).
|
||||||
|
|
||||||
## Project Status
|
|
||||||
|
|
||||||
Treat it as a **stable alpha** product. Though the public APIs are stable, most examples are only useful for simple atomic use-cases due to remaining work needed on the internals.
|
|
||||||
|
|
||||||
<Aside type="tip" title="Rendering">
|
|
||||||
As of v5.2, the re-rendering of an `html` template patches the existing DOM in place to PRESERVE focus, caret/selection, an uncommitted `<input>` value, and running CSS transitions during an interaction. Patching is index-based and non-keyed: list items are matched by position, so preserved state follows the position rather than the item when a list reorders. A `key` prop for identity-based matching is not implemented.
|
|
||||||
</Aside>
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ slug: 'just-parts'
|
||||||
|
|
||||||
You don't have to extend the whole base class to use some features. All internals are exposed and usable separately so you can practically build the behavior on your own classes.
|
You don't have to extend the whole base class to use some features. All internals are exposed and usable separately so you can practically build the behavior on your own classes.
|
||||||
|
|
||||||
Here's an example of using the `html` tag template on a class that extends from vanilla `HTMLElement`... also [View on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/bGzJQJg?editors=1010).
|
Here's an example of using the `html` tag template on a class that extends from vanilla `HTMLElement`... also [View on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/bGzJQJg?editors=1010), or see it live: [Just the parts demo ↗](https://demo.webcomponent.io/examples/just-parts/).
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { html } from 'https://unpkg.com/web-component-base/html'
|
import { html } from 'https://unpkg.com/web-component-base/html'
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ slug: life-cycle-hooks
|
||||||
|
|
||||||
Define behavior when certain events in the component's life cycle is triggered by providing hook methods
|
Define behavior when certain events in the component's life cycle is triggered by providing hook methods
|
||||||
|
|
||||||
|
See it live: [Lifecycle order demo ↗](https://demo.webcomponent.io/examples/lifecycle-order/) logs each hook as it fires, and [Attribute lifecycle demo ↗](https://demo.webcomponent.io/examples/attribute-lifecycle/) shows how attribute changes drive them.
|
||||||
|
|
||||||
### onInit()
|
### onInit()
|
||||||
|
|
||||||
- Triggered when the component is connected to the DOM
|
- Triggered when the component is connected to the DOM
|
||||||
|
|
@ -81,6 +83,8 @@ class ClickableText extends WebComponent {
|
||||||
|
|
||||||
Use `property` to read the value straight off `props` (`this.props[property]`); use `attribute` when you need the raw attribute name.
|
Use `property` to read the value straight off `props` (`this.props[property]`); use `attribute` when you need the raw attribute name.
|
||||||
|
|
||||||
|
See it live: [onChanges payload demo ↗](https://demo.webcomponent.io/examples/on-changes/)
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { WebComponent } from 'https://unpkg.com/web-component-base@latest/index.js'
|
import { WebComponent } from 'https://unpkg.com/web-component-base@latest/index.js'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ Another advantage over `HTMLElement.dataset` is that `WebComponent.props` can ho
|
||||||
Boolean props follow the HTML boolean-attribute convention, exactly like native
|
Boolean props follow the HTML boolean-attribute convention, exactly like native
|
||||||
`disabled` and `required`: **presence means `true`, absence means `false`.**
|
`disabled` and `required`: **presence means `true`, absence means `false`.**
|
||||||
|
|
||||||
|
See it live: [Boolean props demo ↗](https://demo.webcomponent.io/examples/boolean-props/)
|
||||||
|
|
||||||
```js
|
```js
|
||||||
class FlagBox extends WebComponent {
|
class FlagBox extends WebComponent {
|
||||||
static props = { flag: false }
|
static props = { flag: false }
|
||||||
|
|
@ -108,6 +110,8 @@ const props = { ariaChecked: 'false' as 'true' | 'false' }
|
||||||
|
|
||||||
### Custom attribute conversion
|
### Custom attribute conversion
|
||||||
|
|
||||||
|
See it live: [Custom attribute converters demo ↗](https://demo.webcomponent.io/examples/attribute-converters/)
|
||||||
|
|
||||||
The rules above cover the common cases. When a prop needs its own
|
The rules above cover the common cases. When a prop needs its own
|
||||||
serialization — a `Date`, a delimited list, an enumerated attribute where
|
serialization — a `Date`, a delimited list, an enumerated attribute where
|
||||||
`"false"` is meaningful — override `toAttribute` and `fromAttribute`, and
|
`"false"` is meaningful — override `toAttribute` and `fromAttribute`, and
|
||||||
|
|
@ -166,6 +170,8 @@ toAttribute(name, value) {
|
||||||
|
|
||||||
### Typed props in TypeScript
|
### Typed props in TypeScript
|
||||||
|
|
||||||
|
See it live: [Compile-time prop types demo ↗](https://demo.webcomponent.io/examples/typed-props/) and [Typed props demo ↗](https://demo.webcomponent.io/examples/type-restore/)
|
||||||
|
|
||||||
By default `this.props` is a permissive `{ [name: string]: any }` map. In TypeScript you can get compile-time types on your declared props by passing the shape of your defaults as a type argument to `WebComponent`. Declare the defaults in a `const` first so the class can refer to their type:
|
By default `this.props` is a permissive `{ [name: string]: any }` map. In TypeScript you can get compile-time types on your declared props by passing the shape of your defaults as a type argument to `WebComponent`. Declare the defaults in a `const` first so the class can refer to their type:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ slug: shadow-dom
|
||||||
|
|
||||||
Add a static property `shadowRootInit` with object value of type `ShadowRootInit` (see [options on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#options)) to opt-in to using shadow dom for the whole component.
|
Add a static property `shadowRootInit` with object value of type `ShadowRootInit` (see [options on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#options)) to opt-in to using shadow dom for the whole component.
|
||||||
|
|
||||||
Try it now [on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/VwRYVPv?editors=1010)
|
Try it now [on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/VwRYVPv?editors=1010), or see it live: [Shadow DOM demo ↗](https://demo.webcomponent.io/examples/use-shadow/)
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ It is highly recommended to use the second approach, as with it, browsers can as
|
||||||
|
|
||||||
When using the built-in `html` function for tagged templates, a style object of type `Partial<CSSStyleDeclaration>` can be passed to any element's `style` attribute. This allows for calculated and conditional styles. Read more on style objects [on MDN](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleDeclaration).
|
When using the built-in `html` function for tagged templates, a style object of type `Partial<CSSStyleDeclaration>` can be passed to any element's `style` attribute. This allows for calculated and conditional styles. Read more on style objects [on MDN](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleDeclaration).
|
||||||
|
|
||||||
Try it now with this [example on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/bGzXjwQ?editors=1010)
|
Try it now with this [example on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/bGzXjwQ?editors=1010), or see it live: [Style objects demo ↗](https://demo.webcomponent.io/examples/style-objects/)
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { WebComponent } from 'https://unpkg.com/web-component-base@latest/index.js'
|
import { WebComponent } from 'https://unpkg.com/web-component-base@latest/index.js'
|
||||||
|
|
@ -57,7 +57,7 @@ customElements.define('styled-elements', StyledElement)
|
||||||
|
|
||||||
If you [use the Shadow DOM](/shadow-dom), you can add a `static styles` property which will be added to the `shadowRoot`'s [`adoptedStylesheets`](https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets). It accepts a string, a `CSSStyleSheet`, or an array of either.
|
If you [use the Shadow DOM](/shadow-dom), you can add a `static styles` property which will be added to the `shadowRoot`'s [`adoptedStylesheets`](https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets). It accepts a string, a `CSSStyleSheet`, or an array of either.
|
||||||
|
|
||||||
Try it now with this [example on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/JojmeEe?editors=1010)
|
Try it now with this [example on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/JojmeEe?editors=1010), or see it live: [Constructable styles demo ↗](https://demo.webcomponent.io/examples/constructed-styles/)
|
||||||
|
|
||||||
```js
|
```js
|
||||||
class StyledElement extends WebComponent {
|
class StyledElement extends WebComponent {
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,5 @@ This mental model attempts to reduce the cognitive complexity of authoring compo
|
||||||
1. This `render()` method is _automatically_ called under the hood every time an attribute value changed.
|
1. This `render()` method is _automatically_ called under the hood every time an attribute value changed.
|
||||||
1. You can _optionally_ call this `render()` method at any point to trigger a render if you need (eg, if you have private unobserved properties that need to manually trigger a render)
|
1. You can _optionally_ call this `render()` method at any point to trigger a render if you need (eg, if you have private unobserved properties that need to manually trigger a render)
|
||||||
1. Overriding the `render()` function for handling a custom `template` is also possible. Here's an example of using `lit-html`: [View on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/ZEwNJBR?editors=1010)
|
1. Overriding the `render()` function for handling a custom `template` is also possible. Here's an example of using `lit-html`: [View on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/ZEwNJBR?editors=1010)
|
||||||
|
|
||||||
|
See it live: [Templating demo ↗](https://demo.webcomponent.io/examples/templating/) for the two template kinds, and [Render reconciliation demo ↗](https://demo.webcomponent.io/examples/render-reconciliation/) for what an in-place re-render preserves — focus, caret position and an uncommitted input value all survive.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ title: Usage
|
||||||
slug: usage
|
slug: usage
|
||||||
---
|
---
|
||||||
|
|
||||||
|
See it live: [Kitchen sink demo ↗](https://demo.webcomponent.io/examples/demo/) puts several features together, or [Single-file pen ↗](https://demo.webcomponent.io/examples/pens/counter-toggle.html) for the smallest possible setup.
|
||||||
|
|
||||||
In your component class:
|
In your component class:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
|
|
||||||
23
docs/src/content/docs/guides/why.md
Normal file
23
docs/src/content/docs/guides/why.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
title: 'Why would anyone use WCB?'
|
||||||
|
slug: why
|
||||||
|
---
|
||||||
|
|
||||||
|
The `WebComponent` base class gives a full component development experience at the lightest-weight possible: minimal code to boost productivity.
|
||||||
|
|
||||||
|
The following are the main reasons WCB exist.
|
||||||
|
|
||||||
|
1. **It is the cheapest runtime reactivity you can buy.** Smallest footprint for a full authoring experience — declarative templates, typed prop⇄attribute sync, lifecycle hooks, and state-preserving re-renders. Every alternative with comparable ergonomics costs 1.4×–5× more. If your budget is "a component on a mostly-static page", WCB fits where Lit and FAST are the heaviest thing on the wire.
|
||||||
|
2. **Zero tooling, genuinely.** No compiler, no decorators, no build step: one `import` from a CDN in a `<script type="module">` works on current browsers. The whole mental model is `static props` + `template` + four hooks, and the shipped source is readable in one sitting — the entire library is smaller than most libraries' _documentation_ on their update scheduling.
|
||||||
|
3. **Attribute-first reactivity is HTML-native.** Because props serialize to attributes, initial state can be rendered by _any_ server in plain HTML — no JS-framework SSR integration needed — and components stay inspectable/debuggable in devtools as ordinary attributes.
|
||||||
|
4. **Light DOM by default.** Global stylesheets, forms, and third-party CSS just work; and the shadow DOM is one static field away when you want encapsulation.
|
||||||
|
5. **The size gate is a governed value.** Every byte added must justify itself in the [size change log](https://github.com/ayo-run/wcb/blob/main/size-change-log.md), enforced by `size-limit` budgets in CI. Smart diffing is the largest single addition in the project's history and it cost 0.42 kB.
|
||||||
|
|
||||||
|
**When WCB is the wrong choice** — pick honestly:
|
||||||
|
|
||||||
|
- **Reorderable lists of stateful items**: patching is positional, not keyed; preserved focus/animation follows the position, not the item. Lit's `repeat` or FAST handle this correctly.
|
||||||
|
- **High-frequency updates on large trees**: WCB re-renders synchronously per prop write and diffs the whole vnode tree; Lit/FAST update only the affected bindings on a batched schedule.
|
||||||
|
- **SSR with client hydration**: Lit has a real hydration story; Elena is built around progressive enhancement. WCB renders client-side after connect.
|
||||||
|
- **Big-team, long-horizon design systems**: Google (Lit) and Microsoft (FAST) have big backing and ecosystems; while Elena is purpose-built for design systems.
|
||||||
|
|
||||||
|
For the measured numbers and the capability-by-capability breakdown behind these claims, see [WCB & similar libraries](/comparison/).
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
---
|
|
||||||
title: Why use this base class?
|
|
||||||
slug: 'why'
|
|
||||||
---
|
|
||||||
|
|
||||||
import { Aside, Badge } from '@astrojs/starlight/components'
|
|
||||||
|
|
||||||
Often times, when simple websites need a quick custom element, the simplest way is to create one extending from `HTMLElement`. However, it can quickly reach a point where writing the code from scratch can seem confusing and hard to maintain especially when compared to other projects with more advanced setups.
|
|
||||||
|
|
||||||
Also, when coming from frameworks with an easy declarative coding experience (using templates), the default imperative way (e.g., creating instances of elements, manually attaching event handlers, and other DOM manipulations) is a frequent pain point we see.
|
|
||||||
|
|
||||||
By taking out bigger concerns that [a metaframework](https://github.com/ayo-run/mcfly) should handle, this project aims to focus on keeping the component-level matters simple and lightweight. This allows for addressing the said problems with virtually zero tooling required and thin abstractions from vanilla custom element APIs – giving you only the bare minimum code to be productive.
|
|
||||||
|
|
||||||
It works on current-day browsers without needing compilers, transpilers, or polyfills.
|
|
||||||
|
|
||||||
<Aside type="tip">
|
|
||||||
Have questions or suggestions? There are many ways to get in touch:
|
|
||||||
1. Open a [GitHub issue](https://github.com/ayo-run/wcb/issues/new) or [discussion](https://github.com/ayo-run/wcb/discussions)
|
|
||||||
1. Submit a ticket via [SourceHut todo](https://todo.sr.ht/~ayoayco/wcb)
|
|
||||||
1. Email me: [hi@ayo.run](mailto:hi@ayo.run)
|
|
||||||
</Aside>
|
|
||||||
Loading…
Reference in a new issue