Vue3 Composition API Magic 1: Creating a rating component with TypeScript in Node.js
A Step-by-Step Guide to writing a rating component with Vue3 Composition API, TypeScript, Node.js & Font Awesome.
I’m kicking off a series of reusable vue components that help keep our applications DRY. I wanted to kick off with one I struggled with most. The rating component. Every solution I come across feels like some sort of hack or a library. There’s nothing wrong with libraries, I just get tired of using them for everything. Some depend on icon libraries, and others have a lot of code and css to generate icons. Is it possible to make a simple rating component in Vue 3? Well… yes, let’s have a look at a solution to this.
Note: this does use font awesome but this should be easy to switch out with other icons and icon libraries. We’re also using SCSS.
Lets look at our props first. I’m thinking the following:
- id: I want a clear name for these if I have many on the page, easy to target in testing and debugging. It’s also just good practice!
- modalValue: The number of stars we want filled in
- starCount: an optional prop which defaults to 5. If I want to do a rating out of 10, then let me.
- readonly: What if this component is user facing and not on the admin side? I want to display the rating in read only in this case!
These props are represented below:
const props = withDefaults(defineProps<{
id: string,
starCount?: number,
readonly?: boolean
modelValue: number,
}>(), {
starCount: 5,
readonly: false,
});
Here I am defaulting the component props to be an input component for a form by setting readonly to false. This is implying I was this to be clickable and interactive. I am also defaulting starCount to 5. This is because it is the most common use case for my app. You can change this to suit your needs — maybe you want 10 stars by default? That would be cool!
Next, I need my events. This is the event my component will fire on. I theorize this will only need one event.
const emit = defineEmits<{
(on: "update:modelValue", rating: number): void
}>();
This means when we fire the event “update:modalValue” in our template we want the rating numeric value to be sent to the parent component.
Next, I want some types — this gives me some type safety in my components state.
type stars = {
stars: star[],
}
type star = {
active: boolean,
index: number,
}
const state = <stars>reactive({
stars: [],
});
Then I need to use the onMounted vue lifecycle event to populate my state. This is because my props aren’t directly editable. I need to create a basic check (mainly for myself) to ensure I don’t use this component incorrectly.
Then, I populate my stars array in my state. Finally I am using my modelValue prop to fill in all of the stars I have clicked.
onMounted(() => {
if(props.modelValue > props.starCount){
throw new Error("fill count is more than star count");
}
for(let i = 0; i < props.starCount; i++){
state.stars.push({
active: false,
index: i,
});
}
for(let i = 0; i < props.modelValue; i++){
state.stars[i].active = true;
};
});
Then I use a watcher to ensure my state gets updated from my props — should they change.
watch(() => props.modelValue, () => {
for(let i = 0; i < props.modelValue; i++){
state.stars[i].active = true;
};
});
Can you spot the repeated code? The for loop on props.model value is used twice in this code, perhaps you should break that out into a function!
Finally, I want a function which I can use in my template to highlightTo a particular star.
const highlightTo = (selectedIndex: number) => {
state.stars.forEach((star) => {
star.index <= selectedIndex ? star.active = true : star.active = false;
});
emit("update:modelValue", selectedIndex);
}
That’s our script tag complete.
Next we need to build our template which looks like so:
<template>
<div :id="id" class="rating-box">
<div class="stars">
<font-awesome-icon
:class="[star.active ? 'active' : '',
props.readonly ? 'readonly' : '']"
v-for="(star, index) in state.stars"
:icon="['fas', 'star']"
@click="props.readonly ? '' : highlightTo(index)" />
</div>
</div>
</template>
Here we can see our ID is in use, we have some classes for basic styling and we’re using the font awesome star icon. The only magic here is that we’re saying if the star is active use the ‘.stars svg.active’ class otherwise shown as a standard star. We’re also turning off the highlightTo function if this component is read only so that no events can fire from this component.
Finally lets add some basic scss:
.rating-box {
border-radius: 25px;
text-align: center;
margin: 10px 0;
.readonly {
cursor: default;
}
.rating-box .stars {
display: flex;
align-items: center;
gap: 5px;
}
.stars svg {
font-size: 20px;
color: #b5b8b1;
transition: all 0.2s;
cursor: pointer;
}
.stars svg.active {
color: #ffb851;
transform: scale(1.2);
}
}
All together this component looks like below:-
Call this by using something like:
<StarRating :id="example" :model-value="pinaStore.rating" />
In summary we have a nice self contained rating component to use across our frontend making use of the read only prop we can use this in our admin sections and display this on our client side. We could use this anywhere by just importing our rating component with the required props. We could have extended this further to use other icons, perhaps created extra props to handle other colors and even added transitions to make this feel a lot more polished. Try some of the challenges below:
- Can you make this component more flexible by making it count in 0.5 per star? — maybe someone likes the product but didn’t like the service and they rate 4.5 stars!
- Can you add any other transition to this component — this may make it feel a bit more complete!
- Add custom feedback [bad, meh, average, good, amazing] when selecting or hovering over a star — this may make it feel more interactive!
- Maybe you could add some tooltips on hover and whilst we’re at it, would this be easy to navigate by tab and enter, readable by screen reader?
- Add a clear button — maybe someone clicked the ‘1’ icon but really wanted to rate ‘0’. There’s no zero stars, does this warrant a clear button?
As always please comment on any improvements you may have below as I am always growing. Please give claps and follow for more in this series.