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