Add examples page with a context manager

DSD, open/closed template, and light-DOM components that talk through
<main-context>: children register on… callbacks and share updates as events.
Served by Vite via npm run examples; examples/ is linted, formatted and
typechecked.
This commit is contained in:
Buddy 2026-11-10 09:59:09 -08:00
parent be8ee0198f
commit 8865b49f4e
15 changed files with 1061 additions and 43 deletions

View File

@ -0,0 +1,103 @@
import { WebComponent, choice, string } from '../src/index.ts';
import { share } from './main-context.ts';
import { SWATCHES, THEMES } from './themes.ts';
/**
* Light DOM radio group. The radios are real light-DOM `<input>`s, so arrow
* keys, focus and form behaviour are native. A pick is shared with the nearest
* `<main-context>` as a `theme` update.
*/
const SPEC = {
template: `
<fieldset>
<legend></legend>
<div class="swatches">
${THEMES.map(
(theme) => `
<label class="swatch" style="--swatch: ${SWATCHES[theme]}">
<input type="radio" value="${theme}">
<span>${theme}</span>
</label>`,
).join('')}
</div>
</fieldset>
`,
css: `
color-swatch-picker { display: block; font: 0.9rem/1.3 system-ui, sans-serif; }
color-swatch-picker fieldset { border: 0; margin: 0; padding: 0; }
color-swatch-picker legend { font-weight: 600; padding: 0; margin-bottom: 0.35rem; }
color-swatch-picker .swatches { display: flex; flex-wrap: wrap; gap: 0.5rem; }
color-swatch-picker .swatch {
position: relative;
display: grid;
place-items: end center;
width: 5.5rem;
height: 3.5rem;
padding-bottom: 0.3rem;
border-radius: 12px;
background: var(--swatch);
color: #fff;
font-weight: 600;
text-transform: capitalize;
text-shadow: 0 1px 2px rgb(0 0 0 / 35%);
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease;
}
color-swatch-picker .swatch:hover { transform: translateY(-2px); }
color-swatch-picker .swatch input { position: absolute; opacity: 0; pointer-events: none; }
color-swatch-picker .swatch:has(:checked) {
box-shadow: 0 0 0 3px var(--page, #fff), 0 0 0 5px var(--ink, #1d1d29);
}
color-swatch-picker .swatch:has(:focus-visible) {
outline: 2px solid #7c5cff;
outline-offset: 6px;
}
`,
events: {
change: true,
},
element: { legend: 'legend' },
elements: { radios: 'input[type=radio]' },
props: {
label: string({ default: 'Theme' }),
value: choice(THEMES, { default: 'sunset' }),
},
} as const;
// Each picker needs its own radio group name.
let count = 0;
export class ColorSwatchPicker extends WebComponent(SPEC) {
readonly #name = `color-swatch-picker-${(count += 1)}`;
override onConnect(): void {
for (const radio of this.refs.radios) radio.name = this.#name;
this.#render();
}
override onPropChange(): void {
this.#render();
}
override handleEvent(event: Event): void {
const radio = this.refs.radios.find((input) => input === event.target);
const theme = THEMES.find((name) => name === radio?.value);
if (theme && this.value !== theme) {
this.value = theme;
share(this, 'theme', theme);
}
}
#render(): void {
if (this.refs.legend) this.refs.legend.textContent = this.label;
for (const radio of this.refs.radios) radio.checked = radio.value === this.value;
}
}
customElements.define('color-swatch-picker', ColorSwatchPicker);
declare global {
interface HTMLElementTagNameMap {
'color-swatch-picker': ColorSwatchPicker;
}
}

9
examples/dispatch.ts Normal file
View File

@ -0,0 +1,9 @@
/** Dispatch a `CustomEvent` from `el`. It bubbles by default so an ancestor can catch it. */
export function dispatch<T = unknown>(
el: HTMLElement,
name: string,
detail: T,
init: Omit<CustomEventInit<T>, 'detail'> = {},
): void {
el.dispatchEvent(new CustomEvent(name, { bubbles: true, ...init, detail }));
}

123
examples/hero-banner.ts Normal file
View File

@ -0,0 +1,123 @@
import { WebComponent, choice, string, type PropChange } from '../src/index.ts';
import { THEMES } from './themes.ts';
import { register } from './main-context.ts';
const css = `
:host {
--a: #ff5f6d;
--b: #ffc371;
--c: #ff5f6d;
--glow: rgb(255 95 109 / 45%);
}
:host([theme='ocean']) {
--a: #2193b0;
--b: #6dd5ed;
--c: #b2fefa;
--glow: rgb(33 147 176 / 45%);
}
:host([theme='neon']) {
--a: #f72585;
--b: #7209b7;
--c: #4cc9f0;
--glow: rgb(114 9 183 / 55%);
}
.stage {
background:
radial-gradient(60% 80% at 15% 10%, color-mix(in srgb, var(--a) 35%, transparent), transparent),
radial-gradient(50% 70% at 85% 90%, color-mix(in srgb, var(--c) 30%, transparent), transparent),
#0d0d14;
box-shadow: 0 30px 80px -30px var(--glow);
transition: box-shadow 400ms ease;
}
h1 {
background: linear-gradient(100deg, var(--a), var(--b), var(--c), var(--a)) 0 0 / 300% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: flow 6s linear infinite;
filter: drop-shadow(0 4px 24px var(--glow));
}
.tagline {
animation: rise 700ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@keyframes flow {
to { background-position: 300% 0; }
}
@keyframes rise {
from { opacity: 0; transform: translateY(8px); }
}
@media (prefers-reduced-motion: reduce) {
h1, .tagline { animation: none; }
}
`;
/**
* Declarative shadow DOM: the shadow root and its layout `<style>` are written
* in index.html inside `<template shadowrootmode="open">`, so the banner paints
* before any script runs. The mixin finds that root and uses it (no `shadow` or
* `template` here), and still adopts `css` into it for the animated effects.
*/
const SPEC = {
css,
element: {
headlineText: 'h1',
taglineText: 'p.tagline',
},
props: {
headline: string(),
tagline: string(),
theme: choice(THEMES, { default: 'sunset' }),
},
} as const;
export class HeroBanner extends WebComponent(SPEC) {
override onConnect(): void {
register(this, {
onHeadline: (headline) => {
this.headline = headline;
},
onTagline: (tagline) => {
this.tagline = tagline;
},
onTheme: (theme) => {
this.theme = theme;
},
});
}
override onPropChange(change: PropChange<typeof SPEC>): void {
switch (change.name) {
case 'headline': {
if (this.refs.headlineText) this.refs.headlineText.textContent = change.value ?? '';
break;
}
case 'tagline': {
const el = this.refs.taglineText;
if (!el) break;
el.textContent = change.value ?? '';
// Restart the entrance animation so each edit visibly lands.
for (const animation of el.getAnimations()) {
animation.cancel();
animation.play();
}
break;
}
case 'theme': {
// `:host([theme])` does the work; the attribute is already reflected.
break;
}
}
}
}
customElements.define('hero-banner', HeroBanner);
declare global {
interface HTMLElementTagNameMap {
'hero-banner': HeroBanner;
}
}

137
examples/index.html Normal file
View File

@ -0,0 +1,137 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>wc-mixin examples</title>
<link rel="stylesheet" href="./style.css" />
<script type="module" src="./main.ts"></script>
</head>
<body>
<main>
<!--
The context manager: siblings below register callbacks with it and
share updates through it, instead of reaching for each other.
-->
<main-context>
<!--
Declarative shadow DOM: the parser attaches this shadow root, so the
banner renders (with its layout styles) before any JS loads. The
component's `css` adds the gradient animation once it upgrades, and its
props take over the text from here.
-->
<hero-banner id="banner" theme="sunset">
<template shadowrootmode="open">
<style>
:host { display: block; }
.stage {
display: grid;
gap: 0.75rem;
padding: clamp(2rem, 6vw, 4.5rem) clamp(1.25rem, 5vw, 3.5rem);
border-radius: 24px;
background: #0d0d14;
color: #f4f4fb;
overflow: hidden;
}
.eyebrow {
margin: 0;
font: 600 0.75rem/1 system-ui, sans-serif;
letter-spacing: 0.2em;
text-transform: uppercase;
opacity: 0.6;
}
h1 {
margin: 0;
font: 800 clamp(2.25rem, 8vw, 5rem)/1.02 system-ui, sans-serif;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
}
.tagline { margin: 0; max-width: 36rem; font-size: 1.1rem; opacity: 0.8; }
</style>
<div class="stage">
<p class="eyebrow">Declarative shadow DOM</p>
<h1>Hello, wc-mixin</h1>
<p class="tagline">Typed props, refs and styles without the boilerplate.</p>
</div>
</template>
</hero-banner>
<div class="grid">
<section class="card">
<h2>Controls <span class="kind">light DOM</span></h2>
<p>
<code>&lt;labeled-field&gt;</code> and <code>&lt;color-swatch-picker&gt;</code> render
into the light DOM; their CSS lives in a <code>&lt;style&gt;</code> in
<code>&lt;head&gt;</code>. They never touch the other components: each edit is
shared with <code>&lt;main-context&gt;</code>, which calls the
<code>on…</code> callbacks the other components registered.
</p>
<div class="controls">
<labeled-field
id="headline"
name="headline"
label="Headline"
value="Hello, wc-mixin"
required
hint="Shared as headline → hero-banner"
></labeled-field>
<labeled-field
id="tagline"
name="tagline"
label="Tagline"
value="Typed props, refs and styles without the boilerplate."
hint="Shared as tagline → hero-banner"
></labeled-field>
<color-swatch-picker id="theme" label="Banner theme"></color-swatch-picker>
<labeled-field
id="stars"
name="stars"
label="Number of stars"
type="number"
value="5"
required
hint="Shared as stars → star-rating (1–10)"
></labeled-field>
<labeled-field
id="secret"
name="secret"
label="Secret message"
value="The cake is in the closed shadow root."
placeholder="Something hidden…"
hint="Shared as secret → secret-note"
></labeled-field>
</div>
</section>
<div class="controls">
<section class="card">
<h2>&lt;star-rating&gt; <span class="kind">template · open shadow</span></h2>
<p>Click or hover a star. Clicking the current value clears it.</p>
<star-rating id="rating" value="3">Rate this mixin</star-rating>
</section>
<section class="card">
<h2>&lt;secret-note&gt; <span class="kind">template · closed shadow</span></h2>
<p>The page can't reach inside, but the component can, through <code>this.root</code>.</p>
<secret-note id="note" message="The cake is in the closed shadow root."></secret-note>
</section>
<section class="card">
<h2>From the outside</h2>
<output id="probe"></output>
</section>
</div>
</div>
<section class="card">
<div class="log-head">
<h2>Attribute log</h2>
<button id="clear-log" type="button">Clear</button>
</div>
<p>Each prop change reflects to an attribute, and this list records them as mutations.</p>
<ol id="log"></ol>
</section>
</main-context>
</main>
</body>
</html>

137
examples/labeled-field.ts Normal file
View File

@ -0,0 +1,137 @@
import { WebComponent, choice, enable, string, type PropChange } from '../src/index.ts';
import { dispatch } from './dispatch.ts';
import type { FieldInput } from './main-context.ts';
const css = `
labeled-field {
display: grid;
gap: 0.35rem;
font: 0.9rem/1.3 system-ui, sans-serif;
}
labeled-field label { display: grid; gap: 0.35rem; }
labeled-field .field-label { font-weight: 600; color: var(--ink, #1d1d29); }
labeled-field[required] .field-label::after { content: ' *'; color: #e5484d; }
labeled-field input {
font: inherit;
padding: 0.6rem 0.75rem;
border: 1.5px solid var(--line, #d9d9e3);
border-radius: 10px;
background: var(--field, #fff);
color: inherit;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
labeled-field input:focus {
outline: none;
border-color: #7c5cff;
box-shadow: 0 0 0 4px rgb(124 92 255 / 18%);
}
labeled-field .field-hint { color: var(--muted, #6b6b7b); min-height: 1.3em; }
labeled-field:state(invalid) input { border-color: #e5484d; }
labeled-field:state(invalid) input:focus { box-shadow: 0 0 0 4px rgb(229 72 77 / 18%); }
labeled-field:state(invalid) .field-hint { color: #e5484d; }
`;
const template = `
<label>
<span class="field-label"></span>
<input>
</label>
<small class="field-hint"></small>
`;
/**
* Light DOM: `template` is appended into the element on first connect, so the
* `<input>` is a real child of the page and works with page CSS, autofill and
* `document.querySelector`. `css` goes into `<head>` once for every instance.
* The native `input` event bubbles out of the inner `<input>` to the host, which
* reports it to `<main-context>` as a `wc:field-input` keyed by `name`.
*/
const SPEC = {
template,
css,
events: {
input: true,
},
element: { label: '.field-label', input: 'input', hint: '.field-hint' },
props: {
name: string({ default: '' }),
label: string({ default: '' }),
value: string({ default: '' }),
placeholder: string(),
hint: string(),
type: choice(['text', 'number', 'email'], { default: 'text' }),
required: enable(),
},
} as const;
export class LabeledField extends WebComponent(SPEC) {
override onConnect(): void {
const { label, input, hint } = this.refs;
if (!label || !input || !hint) return;
label.textContent = this.label;
input.type = this.type;
input.value = this.value;
input.required = this.required;
if (this.placeholder !== undefined) input.placeholder = this.placeholder;
this.#validate();
}
override onPropChange(change: PropChange<typeof SPEC>): void {
// Attributes are decoded at upgrade, before the template is appended.
const { label, input } = this.refs;
if (!label || !input) return;
switch (change.name) {
case 'label': {
label.textContent = change.value;
break;
}
case 'value': {
if (input.value !== change.value) input.value = change.value;
break;
}
case 'placeholder': {
input.placeholder = change.value ?? '';
break;
}
case 'type': {
input.type = change.value;
break;
}
case 'required': {
input.required = change.value;
break;
}
case 'name':
case 'hint': {
// `name` is read on input; `hint` is shown by #validate below.
break;
}
}
this.#validate();
}
override handleEvent(): void {
if (this.refs.input) {
this.value = this.refs.input.value;
}
dispatch<FieldInput>(this, 'wc:field-input', { name: this.name, value: this.value });
}
// `internals.states` drives the `:state(invalid)` selectors above.
#validate(): void {
const { input, hint } = this.refs;
if (!input || !hint) return;
const valid = input.checkValidity();
if (valid) this.internals.states.delete('invalid');
else this.internals.states.add('invalid');
hint.textContent = valid ? (this.hint ?? '') : input.validationMessage;
}
}
customElements.define('labeled-field', LabeledField);
declare global {
interface HTMLElementTagNameMap {
'labeled-field': LabeledField;
}
}

142
examples/main-context.ts Normal file
View File

@ -0,0 +1,142 @@
import { WebComponent, custom, type HandledEvent } from '../src/index.ts';
import { dispatch } from './dispatch.ts';
import type { Theme } from './themes.ts';
/** Every update components can share through `<main-context>`: name → value. */
export interface Updates {
headline: string;
tagline: string;
theme: Theme;
secret: string;
stars: number;
}
/** `onHeadline`, `onTheme`, …: each is called with that update's value. */
export type Callbacks = {
readonly [K in keyof Updates as `on${Capitalize<K>}`]?: (value: Updates[K]) => void;
};
/** The `wc:register` detail. The context drops the callbacks when `signal` aborts. */
export type Registration = Callbacks & { readonly signal: AbortSignal };
/** A `<labeled-field>`'s raw input; the context turns it into an update by `name`. */
export interface FieldInput {
readonly name: string;
readonly value: string;
}
/**
* Ask the nearest `<main-context>` to call `callbacks` on updates. Call it from
* `onConnect`: the element's `signal` unregisters it on disconnect.
*/
export function register(
el: HTMLElement & { readonly signal: AbortSignal },
callbacks: Callbacks,
): void {
dispatch<Registration>(el, 'wc:register', { ...callbacks, signal: el.signal });
}
/** Share an update; the nearest `<main-context>` calls every matching `on…` callback. */
export function share<K extends keyof Updates>(el: HTMLElement, name: K, value: Updates[K]): void {
dispatch(el, `wc:${name}`, value);
}
/**
* The shared ancestor siblings talk through. Children register callbacks with
* a `wc:register` event and share updates with `wc:<name>` events; both bubble
* up to here, and an update is handed to every registered `on<Name>`. Neither
* side knows about the other, only about this element.
*
* It must be defined before the elements that register, or their
* `wc:register` fires before anything is listening.
*/
const SPEC = {
// Light DOM with no template: a pure wrapper that doesn't affect layout.
css: 'main-context { display: contents; }',
events: {
'wc:register': custom<Registration>(),
'wc:field-input': custom<FieldInput>(),
'wc:headline': custom<Updates['headline']>(),
'wc:tagline': custom<Updates['tagline']>(),
'wc:theme': custom<Updates['theme']>(),
'wc:secret': custom<Updates['secret']>(),
'wc:stars': custom<Updates['stars']>(),
},
} as const;
export class MainContext extends WebComponent(SPEC) {
readonly #registered = new Set<Registration>();
override handleEvent(event: HandledEvent<typeof SPEC>): void {
// The nearest context owns the event; an outer one never sees it.
event.stopPropagation();
switch (event.type) {
case 'wc:register': {
this.#register(event.detail);
break;
}
case 'wc:field-input': {
this.#fieldInput(event.detail);
break;
}
case 'wc:headline': {
for (const r of this.#registered) r.onHeadline?.(event.detail);
break;
}
case 'wc:tagline': {
for (const r of this.#registered) r.onTagline?.(event.detail);
break;
}
case 'wc:theme': {
for (const r of this.#registered) r.onTheme?.(event.detail);
break;
}
case 'wc:secret': {
for (const r of this.#registered) r.onSecret?.(event.detail);
break;
}
case 'wc:stars': {
for (const r of this.#registered) r.onStars?.(event.detail);
break;
}
}
}
#register(registration: Registration): void {
this.#registered.add(registration);
registration.signal.addEventListener(
'abort',
() => {
this.#registered.delete(registration);
},
{ once: true, signal: this.signal },
);
}
// Fields are generic inputs, so the context decides what each one means:
// it re-shares them as typed updates from itself, which comes back through
// `handleEvent` like any other update.
#fieldInput({ name, value }: FieldInput): void {
switch (name) {
case 'headline':
case 'tagline':
case 'secret': {
share(this, name, value);
break;
}
case 'stars': {
const stars = Number(value);
if (Number.isInteger(stars) && stars >= 1 && stars <= 10) share(this, 'stars', stars);
break;
}
}
}
}
customElements.define('main-context', MainContext);
declare global {
interface HTMLElementTagNameMap {
'main-context': MainContext;
}
}

68
examples/main.ts Normal file
View File

@ -0,0 +1,68 @@
// The context first: elements that register with it on connect need it to be
// listening already when they upgrade.
import './main-context.ts';
import './color-swatch-picker.ts';
import './hero-banner.ts';
import './labeled-field.ts';
import './secret-note.ts';
import './star-rating.ts';
// Components talk only through <main-context>; this file just reports on them.
function $<K extends keyof HTMLElementTagNameMap>(tag: K, id: string): HTMLElementTagNameMap[K] {
const el = document.querySelector(`${tag}#${id}`);
if (!el) throw new Error(`missing <${tag} id="${id}">`);
// oxlint-disable-next-line no-unsafe-type-assertion -- the tag in the selector guarantees it
return el as HTMLElementTagNameMap[K];
}
const rating = $('star-rating', 'rating');
const note = $('secret-note', 'note');
// ---------------------------------------------------------------------------
// Closed vs open: what page code can see from outside.
const probe = $('output', 'probe');
function updateProbe(): void {
probe.textContent = [
`rating.shadowRoot → ${rating.shadowRoot ? 'ShadowRoot (open)' : 'null'}`,
`note.shadowRoot → ${note.shadowRoot ? 'ShadowRoot' : 'null (closed)'}`,
`note.revealed → ${note.revealed}`,
`note.querySelector('p') → ${note.querySelector('p') ? '<p>' : 'null'}`,
].join('\n');
}
updateProbe();
// ---------------------------------------------------------------------------
// Attribute log: every reflected prop shows up here as an attribute mutation.
const log = $('ol', 'log');
function show(value: string | null): string {
return value === null ? '∅' : JSON.stringify(value);
}
const observer = new MutationObserver((records) => {
for (const { target, attributeName, oldValue } of records) {
if (!(target instanceof Element) || attributeName === null) continue;
const element = document.createElement('code');
element.textContent = `<${target.localName}#${target.id}>`;
const name = document.createElement('b');
name.textContent = attributeName;
const change = `${show(oldValue)} → ${show(target.getAttribute(attributeName))}`;
const item = document.createElement('li');
item.append(element, ' ', name, ' ', change);
log.prepend(item);
while (log.children.length > 40) log.lastElementChild?.remove();
}
updateProbe();
});
for (const el of document.querySelectorAll('main-context [id]')) {
if (el.localName.includes('-')) {
observer.observe(el, { attributes: true, attributeOldValue: true });
}
}
$('button', 'clear-log').addEventListener('click', () => {
log.replaceChildren();
});

101
examples/secret-note.ts Normal file
View File

@ -0,0 +1,101 @@
import { WebComponent, enable, string, type PropChange } from '../src/index.ts';
import { register } from './main-context.ts';
/**
* Closed shadow root built from `template`: `el.shadowRoot` is `null` from the
* outside, but the component still reaches its own DOM through `this.root` and
* `this.refs`. Closed hides the internals, not the data; `message` is still a
* reflected attribute.
*/
const SPEC = {
shadow: 'closed',
template: `
<button type="button" aria-expanded="false">
<span class="lock" aria-hidden="true">🔒</span>
<span class="hint">Click to reveal</span>
</button>
<p class="message"></p>
`,
css: `
:host {
display: grid;
gap: 0.75rem;
padding: 1.25rem;
border-radius: 14px;
background: repeating-linear-gradient(-45deg, #1f1b2e 0 12px, #241f36 12px 24px);
color: #f0eaff;
font: 0.95rem/1.5 ui-monospace, 'SF Mono', Menlo, monospace;
}
button {
all: unset;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.5rem;
justify-self: start;
padding: 0.4rem 0.8rem;
border-radius: 999px;
background: #3b3355;
transition: background 150ms ease;
}
button:hover { background: #4a4170; }
button:focus-visible { outline: 2px solid #b69cff; outline-offset: 2px; }
.lock { transition: transform 300ms ease; }
.message {
margin: 0;
filter: blur(6px);
user-select: none;
transition: filter 400ms ease;
}
:host([revealed]) .message { filter: none; user-select: auto; }
:host([revealed]) .lock { transform: rotate(-20deg); }
`,
element: {
toggle: 'button',
hint: '.hint',
lock: '.lock',
message: 'p.message',
},
props: {
message: string({ default: '' }),
revealed: enable(),
},
} as const;
export class SecretNote extends WebComponent(SPEC) {
override onConnect(): void {
// Not `events: { click: true }`: a listener on the host can't see into a closed
// root (`composedPath()` stops at the host), so listen on the button itself.
this.refs.toggle?.addEventListener('click', this, { signal: this.signal });
this.#render();
register(this, {
onSecret: (secret) => {
this.message = secret;
},
});
}
override onPropChange(_change: PropChange<typeof SPEC>): void {
this.#render();
}
override handleEvent(): void {
this.revealed = !this.revealed;
}
#render(): void {
const { toggle, hint, lock, message } = this.refs;
if (message) message.textContent = this.message;
if (hint) hint.textContent = this.revealed ? 'Hide' : 'Click to reveal';
if (lock) lock.textContent = this.revealed ? '🔓' : '🔒';
toggle?.setAttribute('aria-expanded', String(this.revealed));
}
}
customElements.define('secret-note', SecretNote);
declare global {
interface HTMLElementTagNameMap {
'secret-note': SecretNote;
}
}

127
examples/star-rating.ts Normal file
View File

@ -0,0 +1,127 @@
import { WebComponent, enable, number } from '../src/index.ts';
import { register } from './main-context.ts';
const css = `
:host {
display: inline-flex;
align-items: center;
gap: 0.75rem;
font: 500 0.95rem/1 system-ui, sans-serif;
color: var(--ink, #1d1d29);
}
.stars { display: inline-flex; gap: 0.15rem; }
button {
all: unset;
cursor: pointer;
font-size: 1.75rem;
line-height: 1;
color: #d4d4dc;
transition: color 120ms ease, transform 120ms ease;
}
button:focus-visible { outline: 2px solid #f5a524; outline-offset: 2px; border-radius: 4px; }
button.on { color: #f5a524; }
button.preview { color: #f8c96b; }
:host(:not([readonly])) button:hover { transform: scale(1.2); }
:host([readonly]) button { cursor: default; }
output {
min-width: 3.5em;
font-variant-numeric: tabular-nums;
color: var(--muted, #6b6b7b);
}
`;
/**
* Open shadow root built from `template`. The stars are rebuilt whenever `max`
* changes; `value` just toggles classes. Clicks and hovers inside the shadow
* root bubble to the host, where `events` routes them to `handleEvent`.
*/
const SPEC = {
shadow: 'open',
css,
template: `
<span class="label"><slot>Rating</slot></span>
<div class="stars" role="radiogroup"></div>
<output>0 / 5</output>
`,
events: { click: true, pointerover: true, pointerleave: true },
element: { group: ['.stars', HTMLDivElement], output: 'output' },
elements: { stars: 'button.star' },
props: {
value: number({ default: 0 }),
max: number({ default: 5 }),
readonly: enable(),
},
} as const;
export class StarRating extends WebComponent(SPEC) {
override onConnect(): void {
register(this, {
onStars: (stars) => {
this.max = stars;
this.value = Math.min(this.value, stars);
},
});
this.#build();
}
override onPropChange(): void {
this.#build();
}
override handleEvent(event: PointerEvent): void {
if (event.type === 'pointerleave') {
this.#paint(0);
return;
}
// A host listener sees into an open root; a closed one would hide the button.
const star = this.refs.stars.find((button) => event.composedPath().includes(button));
if (!star || this.readonly) return;
const n = Number(star.dataset['n']);
if (event.type === 'pointerover') {
this.#paint(n);
return;
}
this.value = n === this.value ? 0 : n;
this.dispatchEvent(new Event('change', { bubbles: true }));
}
#build(): void {
const { group } = this.refs;
if (!group) return;
const max = Math.max(1, Math.trunc(this.max) || 1);
if (this.refs.stars.length !== max) {
group.replaceChildren(
...Array.from({ length: max }, (_, i) => {
const button = document.createElement('button');
button.className = 'star';
button.dataset['n'] = String(i + 1);
button.textContent = '★';
button.setAttribute('role', 'radio');
button.setAttribute('aria-label', `${i + 1} star${i === 0 ? '' : 's'}`);
return button;
}),
);
}
this.#paint(0);
}
// `preview` is the hovered star (0 for none); `value` is the committed one.
#paint(preview: number): void {
for (const star of this.refs.stars) {
const n = Number(star.dataset['n']);
star.classList.toggle('on', n <= this.value);
star.classList.toggle('preview', n > this.value && n <= preview);
star.setAttribute('aria-checked', String(n === this.value));
star.disabled = this.readonly;
}
if (this.refs.output) this.refs.output.value = `${this.value} / ${this.refs.stars.length}`;
}
}
customElements.define('star-rating', StarRating);
declare global {
interface HTMLElementTagNameMap {
'star-rating': StarRating;
}
}

82
examples/style.css Normal file
View File

@ -0,0 +1,82 @@
:root {
--page: #f6f5fb;
--card: #ffffff;
--ink: #1d1d29;
--muted: #6b6b7b;
--line: #e2e1ec;
--field: #ffffff;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--page: #111118;
--card: #1a1a24;
--ink: #ececf4;
--muted: #9c9cb0;
--line: #30303f;
--field: #13131b;
color-scheme: dark;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 2rem 1rem 4rem;
background: var(--page);
color: var(--ink);
font: 1rem/1.55 system-ui, sans-serif;
}
main { max-width: 64rem; margin: 0 auto; display: grid; gap: 1.5rem; }
h2 { margin: 0 0 0.25rem; font-size: 1.1rem; }
.kind {
display: inline-block;
margin-left: 0.4rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, #7c5cff 14%, transparent);
color: #7c5cff;
font-size: 0.72rem;
font-weight: 600;
vertical-align: middle;
}
.card {
padding: 1.5rem;
border: 1px solid var(--line);
border-radius: 18px;
background: var(--card);
}
.card > p { margin: 0 0 1rem; color: var(--muted); font-size: 0.9rem; }
.grid { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); }
.controls { display: grid; gap: 1rem; }
pre, output#probe {
display: block;
margin: 0;
padding: 0.9rem 1rem;
border-radius: 10px;
background: var(--page);
font: 0.8rem/1.6 ui-monospace, 'SF Mono', Menlo, monospace;
white-space: pre;
overflow-x: auto;
}
#log {
margin: 0;
padding: 0;
list-style: none;
max-height: 18rem;
overflow-y: auto;
font: 0.8rem/1.7 ui-monospace, 'SF Mono', Menlo, monospace;
}
#log li { padding: 0.15rem 0; border-bottom: 1px dashed var(--line); }
#log code { color: var(--muted); }
#log b { color: #7c5cff; }
.log-head { display: flex; justify-content: space-between; align-items: baseline; }
.log-head button {
font: inherit;
font-size: 0.8rem;
padding: 0.2rem 0.7rem;
border: 1px solid var(--line);
border-radius: 999px;
background: none;
color: var(--muted);
cursor: pointer;
}

11
examples/themes.ts Normal file
View File

@ -0,0 +1,11 @@
/** Shared by `<hero-banner theme>` and `<color-swatch-picker>` so the two stay in step. */
export const THEMES = ['sunset', 'ocean', 'neon'] as const;
export type Theme = (typeof THEMES)[number];
/** Swatch colours for the picker; the banner defines its own gradients in CSS. */
export const SWATCHES: Readonly<Record<Theme, string>> = {
sunset: 'linear-gradient(135deg, #ff5f6d, #ffc371)',
ocean: 'linear-gradient(135deg, #2193b0, #6dd5ed)',
neon: 'linear-gradient(135deg, #f72585, #7209b7, #4cc9f0)',
};

View File

@ -49,6 +49,18 @@ export default defineConfig({
'eslint/max-lines': 'off',
},
},
{
files: ['examples/**/*.ts'],
rules: {
// Examples import the library from `../src`, like the tests.
'import/no-relative-parent-imports': 'off',
// Importing a component module is how it gets registered.
'import/no-unassigned-import': 'off',
// `onPropChange` switches are exhaustive over prop names, which
// `switch-exhaustiveness-check` enforces; a `default` would hide a missed one.
'eslint/default-case': 'off',
},
},
],
env: {
builtin: true,

42
package-lock.json generated
View File

@ -15,6 +15,7 @@
"oxlint-tsgolint": "^7.0.2006",
"playwright": "^1.65.0",
"typescript": "^7.0.2",
"vite": "^8.3.6",
"vitest": "^5.1.0"
}
},
@ -59,7 +60,6 @@
"integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/oxc-project"
}
@ -862,7 +862,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@ -880,7 +879,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@ -898,7 +896,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@ -916,7 +913,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@ -934,7 +930,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@ -952,7 +947,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@ -973,7 +967,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@ -994,7 +987,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@ -1015,7 +1007,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@ -1036,7 +1027,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@ -1057,7 +1047,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@ -1078,7 +1067,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@ -1096,7 +1084,6 @@
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@ -1114,7 +1101,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@ -1132,7 +1118,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@ -1142,8 +1127,7 @@
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
@ -1678,7 +1662,6 @@
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8"
}
@ -1753,7 +1736,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
@ -1764,7 +1746,6 @@
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true,
"license": "MPL-2.0",
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
@ -1802,7 +1783,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@ -1824,7 +1804,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@ -1846,7 +1825,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@ -1868,7 +1846,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@ -1890,7 +1867,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@ -1915,7 +1891,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@ -1940,7 +1915,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@ -1965,7 +1939,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@ -1990,7 +1963,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@ -2012,7 +1984,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@ -2034,7 +2005,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@ -2075,7 +2045,6 @@
}
],
"license": "MIT",
"peer": true,
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@ -2232,8 +2201,7 @@
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.7",
@ -2307,7 +2275,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.20",
"picocolors": "^1.1.1",
@ -2323,7 +2290,6 @@
"integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "=0.156.0",
"@rolldown/pluginutils": "^1.0.0"
@ -2373,7 +2339,6 @@
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -2493,7 +2458,6 @@
"integrity": "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",

View File

@ -22,11 +22,12 @@
"LICENSE"
],
"scripts": {
"examples": "vite examples",
"build": "rm -rf dist && tsc",
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
"lint": "oxlint --type-aware src test",
"format": "oxfmt '*.md' '*.ts' 'src/**/*.ts' 'test/**/*.ts'",
"format:check": "oxfmt --check '*.md' '*.ts' 'src/**/*.ts' 'test/**/*.ts'",
"lint": "oxlint --type-aware src test examples",
"format": "oxfmt '*.md' '*.ts' 'src/**/*.ts' 'test/**/*.ts' 'examples/**/*.ts'",
"format:check": "oxfmt --check '*.md' '*.ts' 'src/**/*.ts' 'test/**/*.ts' 'examples/**/*.ts'",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm run format:check && npm run lint && npm run typecheck && npm run test && npm run build"
@ -38,6 +39,7 @@
"oxlint-tsgolint": "^7.0.2006",
"playwright": "^1.65.0",
"typescript": "^7.0.2",
"vite": "^8.3.6",
"vitest": "^5.1.0"
}
}

View File

@ -6,5 +6,5 @@
"declaration": false,
"declarationMap": false
},
"include": ["src/**/*", "test/**/*"]
"include": ["src/**/*", "test/**/*", "examples/**/*"]
}