From 8865b49f4ef277941fb59830891f03d23d36ecd7 Mon Sep 17 00:00:00 2001 From: Buddy Date: Tue, 10 Nov 2026 09:59:09 -0800 Subject: [PATCH] Add examples page with a context manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DSD, open/closed template, and light-DOM components that talk through : children register on… callbacks and share updates as events. Served by Vite via npm run examples; examples/ is linted, formatted and typechecked. --- examples/color-swatch-picker.ts | 103 +++++++++++++++++++++++ examples/dispatch.ts | 9 ++ examples/hero-banner.ts | 123 +++++++++++++++++++++++++++ examples/index.html | 137 ++++++++++++++++++++++++++++++ examples/labeled-field.ts | 137 ++++++++++++++++++++++++++++++ examples/main-context.ts | 142 ++++++++++++++++++++++++++++++++ examples/main.ts | 68 +++++++++++++++ examples/secret-note.ts | 101 +++++++++++++++++++++++ examples/star-rating.ts | 127 ++++++++++++++++++++++++++++ examples/style.css | 82 ++++++++++++++++++ examples/themes.ts | 11 +++ oxlint.config.ts | 12 +++ package-lock.json | 42 +--------- package.json | 8 +- tsconfig.test.json | 2 +- 15 files changed, 1061 insertions(+), 43 deletions(-) create mode 100644 examples/color-swatch-picker.ts create mode 100644 examples/dispatch.ts create mode 100644 examples/hero-banner.ts create mode 100644 examples/index.html create mode 100644 examples/labeled-field.ts create mode 100644 examples/main-context.ts create mode 100644 examples/main.ts create mode 100644 examples/secret-note.ts create mode 100644 examples/star-rating.ts create mode 100644 examples/style.css create mode 100644 examples/themes.ts diff --git a/examples/color-swatch-picker.ts b/examples/color-swatch-picker.ts new file mode 100644 index 0000000..8998695 --- /dev/null +++ b/examples/color-swatch-picker.ts @@ -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 ``s, so arrow + * keys, focus and form behaviour are native. A pick is shared with the nearest + * `` as a `theme` update. + */ +const SPEC = { + template: ` +
+ +
+ ${THEMES.map( + (theme) => ` + `, + ).join('')} +
+
+ `, + 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; + } +} diff --git a/examples/dispatch.ts b/examples/dispatch.ts new file mode 100644 index 0000000..754e573 --- /dev/null +++ b/examples/dispatch.ts @@ -0,0 +1,9 @@ +/** Dispatch a `CustomEvent` from `el`. It bubbles by default so an ancestor can catch it. */ +export function dispatch( + el: HTMLElement, + name: string, + detail: T, + init: Omit, 'detail'> = {}, +): void { + el.dispatchEvent(new CustomEvent(name, { bubbles: true, ...init, detail })); +} diff --git a/examples/hero-banner.ts b/examples/hero-banner.ts new file mode 100644 index 0000000..67b7218 --- /dev/null +++ b/examples/hero-banner.ts @@ -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 ` +
+

Declarative shadow DOM

+

Hello, wc-mixin

+

Typed props, refs and styles without the boilerplate.

+
+ + + +
+
+

Controls light DOM

+

+ <labeled-field> and <color-swatch-picker> render + into the light DOM; their CSS lives in a <style> in + <head>. They never touch the other components: each edit is + shared with <main-context>, which calls the + on… callbacks the other components registered. +

+
+ + + + + +
+
+ +
+
+

<star-rating> template · open shadow

+

Click or hover a star. Clicking the current value clears it.

+ Rate this mixin +
+ +
+

<secret-note> template · closed shadow

+

The page can't reach inside, but the component can, through this.root.

+ +
+ +
+

From the outside

+ +
+
+
+ +
+
+

Attribute log

+ +
+

Each prop change reflects to an attribute, and this list records them as mutations.

+
    +
    +
    + + + diff --git a/examples/labeled-field.ts b/examples/labeled-field.ts new file mode 100644 index 0000000..0e95217 --- /dev/null +++ b/examples/labeled-field.ts @@ -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 = ` + + +`; + +/** + * Light DOM: `template` is appended into the element on first connect, so the + * `` is a real child of the page and works with page CSS, autofill and + * `document.querySelector`. `css` goes into `` once for every instance. + * The native `input` event bubbles out of the inner `` to the host, which + * reports it to `` 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): 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(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; + } +} diff --git a/examples/main-context.ts b/examples/main-context.ts new file mode 100644 index 0000000..563596c --- /dev/null +++ b/examples/main-context.ts @@ -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 ``: 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}`]?: (value: Updates[K]) => void; +}; + +/** The `wc:register` detail. The context drops the callbacks when `signal` aborts. */ +export type Registration = Callbacks & { readonly signal: AbortSignal }; + +/** A ``'s raw input; the context turns it into an update by `name`. */ +export interface FieldInput { + readonly name: string; + readonly value: string; +} + +/** + * Ask the nearest `` 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(el, 'wc:register', { ...callbacks, signal: el.signal }); +} + +/** Share an update; the nearest `` calls every matching `on…` callback. */ +export function share(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:` events; both bubble + * up to here, and an update is handed to every registered `on`. 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(), + 'wc:field-input': custom(), + 'wc:headline': custom(), + 'wc:tagline': custom(), + 'wc:theme': custom(), + 'wc:secret': custom(), + 'wc:stars': custom(), + }, +} as const; + +export class MainContext extends WebComponent(SPEC) { + readonly #registered = new Set(); + + override handleEvent(event: HandledEvent): 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; + } +} diff --git a/examples/main.ts b/examples/main.ts new file mode 100644 index 0000000..4a8cd17 --- /dev/null +++ b/examples/main.ts @@ -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 ; this file just reports on them. + +function $(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') ? '

    ' : '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(); +}); diff --git a/examples/secret-note.ts b/examples/secret-note.ts new file mode 100644 index 0000000..2a9ccbc --- /dev/null +++ b/examples/secret-note.ts @@ -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: ` + +

    + `, + 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): 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; + } +} diff --git a/examples/star-rating.ts b/examples/star-rating.ts new file mode 100644 index 0000000..c0577a5 --- /dev/null +++ b/examples/star-rating.ts @@ -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: ` + Rating +
    + 0 / 5 + `, + 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; + } +} diff --git a/examples/style.css b/examples/style.css new file mode 100644 index 0000000..a0c3acc --- /dev/null +++ b/examples/style.css @@ -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; +} diff --git a/examples/themes.ts b/examples/themes.ts new file mode 100644 index 0000000..307515d --- /dev/null +++ b/examples/themes.ts @@ -0,0 +1,11 @@ +/** Shared by `` and `` 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> = { + sunset: 'linear-gradient(135deg, #ff5f6d, #ffc371)', + ocean: 'linear-gradient(135deg, #2193b0, #6dd5ed)', + neon: 'linear-gradient(135deg, #f72585, #7209b7, #4cc9f0)', +}; diff --git a/oxlint.config.ts b/oxlint.config.ts index 0769296..27b0a0e 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -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, diff --git a/package-lock.json b/package-lock.json index 4da9d32..daa3c90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3079d78..e8e55aa 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/tsconfig.test.json b/tsconfig.test.json index c6012cd..c2b4766 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -6,5 +6,5 @@ "declaration": false, "declarationMap": false }, - "include": ["src/**/*", "test/**/*"] + "include": ["src/**/*", "test/**/*", "examples/**/*"] }