lib/petal_components/rating.ex

defmodule PetalComponents.Rating do
  use Phoenix.Component

  attr(:rating, :any, default: 0, doc: "The rating to display (integer or float)")

  attr(:round_to_nearest_half, :boolean,
    default: true,
    doc: "Whether to round the rating to the nearest half star (eg. 3.3 -> 3.5"
  )

  attr(:total, :integer, default: 5, doc: "The total number of stars to display")
  attr(:class, :string, default: nil, doc: "Any additional CSS classes for the rating wrapper")

  attr(:star_class, :string,
    default: nil,
    doc:
      "Any additional CSS classes for the individual stars. Eg. you could change the size of the stars with 'h-10 w-10'"
  )

  attr(:include_label, :boolean,
    default: false,
    doc: "Whether to include an automatically generated rating label"
  )

  attr(:label_class, :string, default: nil, doc: "Any additional CSS classes for the rating label")

  def rating(assigns) do
    assigns =
      assigns
      |> assign(:rating_as_float, to_float(assigns.rating))

    ~H"""
    <div class={["pc-rating__wrapper", @class]}>
      <%= for i <- 1..@total do %>
        <.rating_star
          class={@star_class}
          type={calculate_type(i, @rating_as_float, @round_to_nearest_half)}
        />
      <% end %>

      <%= if @include_label do %>
        <span class={["pc-rating__label", @label_class]}>
          <%= @rating_as_float %> out of <%= @total %>
        </span>
      <% end %>
    </div>
    """
  end

  def to_float(value) when is_integer(value), do: value + 0.0
  def to_float(value) when is_float(value), do: value

  def round_to_half(number) do
    round(number * 2) / 2
  end

  defp calculate_type(current_star, rating, round_to_nearest_half) do
    maybe_rounded = if round_to_nearest_half, do: round_to_half(rating), else: rating

    if current_star <= trunc(rating) do
      :filled
    else
      case maybe_rounded - (current_star - 1) do
        n when n >= 0.5 and n < 1 -> :half
        _ -> :empty
      end
    end
  end

  attr :class, :string, default: nil, doc: "Any additional CSS classes for the star"
  attr :type, :atom, default: :empty, doc: "The type of star to display"

  def rating_star(%{type: :empty} = assigns) do
    ~H"""
    <svg
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 24 24"
      class={["pc-rating__star--empty", @class]}
    >
      <path fill="none" d="M0 0h24v24H0z" /><path d="M12 18.26l-7.053 3.948 1.575-7.928L.587 8.792l8.027-.952L12 .5l3.386 7.34 8.027.952-5.935 5.488 1.575 7.928L12 18.26zm0-2.292l4.247 2.377-.949-4.773 3.573-3.305-4.833-.573L12 5.275l-2.038 4.42-4.833.572 3.573 3.305-.949 4.773L12 15.968z" />
    </svg>
    """
  end

  def rating_star(%{type: :filled} = assigns) do
    ~H"""
    <svg
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 24 24"
      class={["pc-rating__star--filled", @class]}
    >
      <path fill="none" d="M0 0h24v24H0z" /><path d="M12 18.26l-7.053 3.948 1.575-7.928L.587 8.792l8.027-.952L12 .5l3.386 7.34 8.027.952-5.935 5.488 1.575 7.928z" />
    </svg>
    """
  end

  def rating_star(%{type: :half} = assigns) do
    ~H"""
    <svg
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 24 24"
      class={["pc-rating__star--half", @class]}
    >
      <path fill="none" d="M0 0h24v24H0z" /><path d="M12 15.968l4.247 2.377-.949-4.773 3.573-3.305-4.833-.573L12 5.275v10.693zm0 2.292l-7.053 3.948 1.575-7.928L.587 8.792l8.027-.952L12 .5l3.386 7.34 8.027.952-5.935 5.488 1.575 7.928L12 18.26z" />
    </svg>
    """
  end
end