Skip to content

Instantly share code, notes, and snippets.

@secretpray
Last active February 26, 2025 17:23
Show Gist options
  • Save secretpray/13168d265f9294c00bb7de75feceacc2 to your computer and use it in GitHub Desktop.
Save secretpray/13168d265f9294c00bb7de75feceacc2 to your computer and use it in GitHub Desktop.
5 star rating Stimulus component (Rails 6+).md
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>
@yshmarov
Copy link

looks very nice! 💅

@secretpray
Copy link
Author

<div class='d-flex flex-row flex-1 -mt-1 gap-1'>
  <% 1.upto(Review::MAX_RATING) do |index| %>
    <li class='star star-lite <%= index <= service_offer.user.rating ? "star-filled" : "" %>'></li>
  <% end %>
</div>

@yshmarov
Copy link

znadobilisa :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment