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>
97 lines
3.0 KiB
TypeScript
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;
|
|
}
|
|
}
|