Rating breakdown
The average score, the number of reviews, and a bar for each score showing its share.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add rating-breakdownpnpm dlx ermineui add rating-breakdownyarn dlx ermineui add rating-breakdownbunx ermineui add rating-breakdownThat copies 2 files into src/components/ui/rating-breakdown/, and the 1 component it is built from. Files you already have are kept.
Use
import { RatingBreakdown } from '@/components/ui/rating-breakdown'<script setup lang="ts">
import { RatingBreakdown } from '@/components/ui/rating-breakdown'
</script>
<template>
<RatingBreakdown />
</template>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island. The props are documented in the source below, on each one.
What gets copied
| File | Where it goes |
|---|---|
RatingBreakdown.vue | src/components/ui/rating-breakdown/RatingBreakdown.vue |
index.ts | src/components/ui/rating-breakdown/index.ts |
It is built from: Rating. These are added with it.
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
RatingBreakdown.vue 163 lines
<script setup lang="ts">
import { computed } from 'vue'
import { Rating } from '../rating'
const props = withDefaults(
defineProps<{
/** How many reviews gave each score, lowest first: [1 star, 2 stars, 3 stars, 4 stars, 5 stars]. */
counts: readonly number[]
/** Make each row a button that picks that score, to list only those reviews. */
selectable?: boolean
/** Accessible name of the whole summary. */
label?: string
}>(),
{ label: 'Customer reviews' },
)
/** The score whose reviews are being shown, when rows can be picked. Unset shows them all. */
const selected = defineModel<number | undefined>()
const total = computed(() => props.counts.reduce((sum, count) => sum + count, 0))
const average = computed(() => (total.value ? props.counts.reduce((sum, count, index) => sum + count * (index + 1), 0) / total.value : 0))
/** Highest score first, as readers expect. */
const rows = computed(() =>
props.counts
.map((count, index) => ({ stars: index + 1, count, percent: total.value ? Math.round((count / total.value) * 100) : 0 }))
.reverse(),
)
// Fixed locale, so server and browser write the same text.
const oneDecimal = new Intl.NumberFormat('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
const whole = new Intl.NumberFormat('en-US')
</script>
<template>
<section class="er-rating-breakdown" :aria-label="label">
<div class="er-rating-breakdown-score">
<strong>{{ oneDecimal.format(average) }}</strong>
<div>
<Rating :model-value="average" :max="counts.length" readonly label="Average score" />
<p>{{ whole.format(total) }} {{ total === 1 ? 'review' : 'reviews' }}</p>
</div>
</div>
<ul class="er-rating-breakdown-rows">
<li v-for="row in rows" :key="row.stars">
<component
:is="selectable ? 'button' : 'div'"
class="er-rating-breakdown-row"
:type="selectable ? 'button' : undefined"
:aria-pressed="selectable ? selected === row.stars : undefined"
:disabled="selectable && !row.count ? true : undefined"
@click="selectable && (selected = selected === row.stars ? undefined : row.stars)"
>
<span class="er-rating-breakdown-stars">{{ row.stars }} {{ row.stars === 1 ? 'star' : 'stars' }}</span>
<span class="er-rating-breakdown-bar" aria-hidden="true"><span :style="{ width: `${row.percent}%` }" /></span>
<span class="er-rating-breakdown-share">{{ row.percent }}%</span>
</component>
</li>
</ul>
</section>
</template>
<style>
@layer components {
.er-rating-breakdown {
display: grid;
gap: 1rem;
width: 100%;
min-width: 0;
}
.er-rating-breakdown-score {
display: flex;
align-items: center;
gap: 0.75rem;
}
.er-rating-breakdown-score strong {
font-size: 2.25rem;
font-weight: var(--er-weight-semibold);
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: -0.02em;
}
.er-rating-breakdown-score p {
margin: 0.125rem 0 0;
color: var(--er-muted-foreground);
font-size: 0.8125rem;
}
.er-rating-breakdown-rows {
display: grid;
gap: 0.125rem;
margin: 0;
padding: 0;
list-style: none;
}
.er-rating-breakdown-row {
display: grid;
grid-template-columns: 3.75rem minmax(0, 1fr) 2.5rem;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.25rem 0.375rem;
margin-inline: -0.375rem;
border: 0;
border-radius: var(--er-radius-sm);
background: transparent;
color: var(--er-foreground);
font: inherit;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
line-height: 1.25rem;
text-align: left;
}
button.er-rating-breakdown-row {
cursor: pointer;
transition: background-color var(--er-duration);
}
button.er-rating-breakdown-row:hover,
button.er-rating-breakdown-row[aria-pressed='true'] {
background: var(--er-accent);
}
button.er-rating-breakdown-row[aria-pressed='true'] {
font-weight: var(--er-weight-semibold);
}
button.er-rating-breakdown-row:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 1px;
}
button.er-rating-breakdown-row:disabled {
color: var(--er-muted-foreground);
cursor: default;
pointer-events: none;
}
.er-rating-breakdown-bar {
height: 0.5rem;
border-radius: 999px;
background: color-mix(in oklab, var(--er-foreground) 10%, transparent);
overflow: hidden;
}
.er-rating-breakdown-bar span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--er-warning);
}
.er-rating-breakdown-share {
color: var(--er-muted-foreground);
text-align: right;
}
}
</style>index.ts 3 lines
/** Rating breakdown — the average score, the number of reviews, and a bar for each score showing its share. */
export { default as RatingBreakdown } from './RatingBreakdown.vue'