clip.mp4
Stimulus Controller (name: 'star-rating')
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static values = {
createInput: { type: Boolean, default: false },
inputName: { type: String, default: "rating" },
noOfStars: { type: Number, default: 5 },
rating: { type: Number, default: 0 }
}
initialize() {
this.createStarComponent();
this.setupActionController();
this.setInitialRating();
}
createStarComponent() {
if (!this.starComponent) {
this.starComponent = document.createElement("ul");
this.starComponent.className = "stcomp";
for (let i = 0; i < this.noOfStarsValue; i++) {
const li = document.createElement("li");
li.setAttribute("data-rating", i + 1);
li.className = "star";
if (i === 0) li.tabIndex = 0;
this.starComponent.append(li);
}
this.element.append(this.starComponent);
}
}
setupActionController() {
const actions = `
mouseover->star-rating#onMouseOver
mouseleave->star-rating#onMouseLeave
click->star-rating#onMouseClick
keyup->star-rating#onKeyUp
`;
this.starComponent.setAttribute('data-action', actions);
}
setInitialRating() {
const initialRating = parseInt(this.element.getAttribute('data-rating'));
if (!isNaN(initialRating) && initialRating > 0) {
this.changeRating(initialRating);
this.renderChanges(initialRating);
}
}
onMouseOver(event) {
let isStar = event.target.classList.contains("star");
if (!isStar) return;
const { rating } = event.target.dataset;
this.renderChanges(rating);
}
onMouseLeave() {
this.renderChanges(this.ratingValue);
}
onMouseClick(event) {
let star = event.target ?? event;
let isStar = star.classList.contains("star");
if (isStar) {
this.activate(star);
let { rating } = star.dataset;
if (event.key !== "Tab" && rating === this.ratingValue) {
rating = 0;
this.resetTabIndex();
this.starComponent.firstElementChild.tabIndex = 0;
}
this.changeRating(rating);
this.renderChanges(rating);
}
}
onKeyUp(event) {
switch (event.key) {
case "Tab": {
this.onMouseClick(event);
break;
}
case "ArrowLeft": {
this.focusPrevStar();
break;
}
case "ArrowRight": {
this.focusNextStar();
break;
}
default:
return;
}
}
changeRating(newRating) {
this.ratingValue = newRating;
if (this.createInputValue) this.createOrUpdateHiddenInput();
}
createOrUpdateHiddenInput() {
const form = this.element.closest("form");
if (!form) return;
this.hiddenInput(form).value = this.ratingValue;
}
hiddenInput(form) {
let _hiddenInput = form.querySelector(`input[name="${this.inputNameValue}"]`);
if (!_hiddenInput) {
_hiddenInput = document.createElement("input");
_hiddenInput.type = "hidden";
_hiddenInput.name = this.inputNameValue;
form.appendChild(_hiddenInput);
}
return _hiddenInput;
}
renderChanges(rating) {
for (let index = 0; index < this.noOfStarsValue; index++) {
if (index < rating) {
this.starComponent.children[index].classList.add("star-filled");
} else {
this.starComponent.children[index].classList.remove("star-filled");
}
}
}
focusPrevStar() {
let focusedStar = document.activeElement;
if (focusedStar.previousElementSibling) {
this.onMouseClick(focusedStar.previousElementSibling);
}
}
focusNextStar() {
let focusedStar = document.activeElement;
if (focusedStar.nextElementSibling) {
this.onMouseClick(focusedStar.nextElementSibling);
}
}
activate(element) {
this.resetTabIndex();
element.tabIndex = 0;
element.focus();
}
resetTabIndex() {
this.starComponent.childNodes.forEach((star) => {
star.tabIndex = -1;
});
}
}
css
.star {
background: lightgrey;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
display: inline-block;
height: 50px;
width: 50px;
margin-left: 2px;
transition: transform 0.1s;
}
.star:hover {
transform: scale(1.2);
}
.star:focus {
transform: scale(1.2);
}
.stcomp {
cursor: pointer;
display: flex;
list-style-type: none;
}
.star-filled {
background: orange;
}
html.erb
<div class='d-flex'
data-controller='star-rating'
data-rating='<%= f&.object&.rating || 5 %>'
data-star-rating-create-input-value='true'
data-star-rating-input-name-value="user[rating]"></div>
looks very nice! 💅