Rating UI Elements
Ster-rating display of interactief. Half-sterren, review count, klikbaar input mode.
src/components/ui/Rating.astro
---
/**
* Rating
* Ster-rating: display-only of interactief (input).
* Half-sterren optioneel voor display mode.
*/
interface Props {
value?: number; /** 0-5 */
max?: number;
interactive?: boolean;
name?: string;
size?: 'sm' | 'md' | 'lg';
showValue?: boolean;
label?: string;
count?: number; /** aantal reviews (tonen naast sterren) */
}
const {
value = 0,
max = 5,
interactive = false,
name = 'rating',
size = 'md',
showValue = false,
label,
count,
} = Astro.props;
---
{interactive ? (
<!-- Interactive (input) -->
<div
class:list={['rt', `rt--${size}`, 'rt--interactive']}
data-component="rating"
data-value={value}
>
{label && <span class="rt__label">{label}</span>}
<div class="rt__stars" role="radiogroup" aria-label={label ?? 'Beoordeling'}>
{Array.from({ length: max }).map((_, i) => (
<label class="rt__star-label" title={`${i + 1} ster${i > 0 ? 'ren' : ''}`}>
<input
type="radio"
name={name}
value={i + 1}
class="rt__input"
checked={i + 1 === Math.round(value)}
/>
<svg class="rt__star" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</label>
))}
</div>
{showValue && <span class="rt__value">{value}/{max}</span>}
</div>
) : (
<!-- Display only -->
<div class:list={['rt', `rt--${size}`]} data-component="rating" data-value={value} aria-label={`${value} van ${max} sterren`}>
{label && <span class="rt__label">{label}</span>}
<div class="rt__stars-display">
{Array.from({ length: max }).map((_, i) => {
const filled = value >= i + 1;
const half = !filled && value > i;
return (
<svg class:list={['rt__star', { 'rt__star--filled': filled, 'rt__star--half': half }]} viewBox="0 0 24 24">
<defs>
<linearGradient id={`half-${i}`}>
<stop offset="50%" stop-color="currentColor"/>
<stop offset="50%" stop-color="transparent"/>
</linearGradient>
</defs>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
fill={filled ? 'currentColor' : half ? `url(#half-${i})` : 'none'}
stroke="currentColor"
stroke-width="1.5"
/>
</svg>
);
})}
</div>
<div class="rt__meta">
{showValue && <span class="rt__value">{value}</span>}
{count !== undefined && <span class="rt__count">({count.toLocaleString('nl-NL')})</span>}
</div>
</div>
)}
<script>
document.querySelectorAll<HTMLElement>('[data-component="rating"].rt--interactive').forEach(el => {
const stars = el.querySelectorAll<HTMLLabelElement>('.rt__star-label');
const inputs = el.querySelectorAll<HTMLInputElement>('.rt__input');
function updateVisual(hoveredIndex: number | null) {
const activeVal = hoveredIndex !== null
? hoveredIndex
: Number(el.dataset.value);
stars.forEach((label, i) => {
const star = label.querySelector<SVGElement>('.rt__star');
if (!star) return;
star.classList.toggle('rt__star--filled', i < activeVal);
});
}
stars.forEach((label, i) => {
label.addEventListener('mouseenter', () => updateVisual(i + 1));
label.addEventListener('mouseleave', () => updateVisual(null));
});
inputs.forEach((input, i) => {
input.addEventListener('change', () => {
el.dataset.value = String(i + 1);
updateVisual(null);
});
});
updateVisual(null);
});
</script>
<style>
.rt { display: inline-flex; flex-direction: column; gap: 0.375rem; }
.rt__label {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-primary, #0a0a0a);
}
/* Stars container */
.rt__stars, .rt__stars-display {
display: flex;
gap: 0.25rem;
align-items: center;
}
.rt__meta {
display: flex;
align-items: center;
gap: 0.375rem;
}
.rt__value {
font-size: 0.875rem;
font-weight: 700;
color: var(--color-primary, #0a0a0a);
}
.rt__count {
font-size: 0.8125rem;
color: var(--color-muted, #6b7280);
}
/* Star SVG */
.rt__star {
color: rgba(0,0,0,0.15);
transition: color 0.1s, transform 0.1s;
flex-shrink: 0;
}
.rt--sm .rt__star { width: 14px; height: 14px; }
.rt--md .rt__star { width: 20px; height: 20px; }
.rt--lg .rt__star { width: 28px; height: 28px; }
.rt__star--filled { color: #f59e0b; }
.rt__star--half { color: #f59e0b; }
/* Interactive */
.rt--interactive .rt__star-label {
cursor: pointer;
display: flex;
}
.rt--interactive .rt__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.rt--interactive .rt__star-label:hover .rt__star,
.rt--interactive .rt__star-label:hover ~ .rt__star-label .rt__star {
color: #fcd34d;
transform: scale(1.1);
}
</style>
Props
| Prop | Type | Default | Beschrijving |
|---|---|---|---|
value | number | 0 | Rating waarde (0 - max) |
max | number | 5 | Maximum sterren |
interactive | boolean | false | Input mode — klikbaar |
showValue | boolean | false | Toon numerieke waarde |
count | number | — | Review count naast sterren |
size | 'sm' | 'md' | 'lg' | 'md' | Grootte |
name | string | 'rating' | Form name voor interactieve mode |
* = verplicht