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.
128 lines
3.7 KiB
TypeScript
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;
|
|
}
|
|
}
|