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