wc-mixin/examples/star-rating.ts
Buddy 8865b49f4e 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.
2026-11-10 09:59:09 -08:00

128 lines
3.7 KiB
TypeScript

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;
}
}