wc-mixin/examples/star-rating.ts
Buddy 76d78e1925 Accept a CSSStyleSheet as css; move example CSS and templates into files
css is now string | CSSStyleSheet. A sheet is adopted as-is: into its own
shadow root, an enclosing shadow root, or document.adoptedStyleSheets for
light DOM at the document level. Strings behave as before.

The examples import their styles with `with { type: 'css' }` and their
static templates with `with { type: 'text' }`. Safari doesn't support those
yet, so vite.config.ts rewrites them into modules Vite builds: a constructed
CSSStyleSheet, or the file's text. npm run examples now just runs vite,
with root set in the config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:44:27 -08:00

97 lines
3.0 KiB
TypeScript

import { WebComponent, enable, number } from '../src/index.ts';
import { register } from './main-context.ts';
import css from './star-rating.css' with { type: 'css' };
import template from './star-rating.html' with { type: 'text' };
/**
* 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,
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;
}
}