defmodule PhiaUi.Components.Rating do @moduledoc """ Star Rating component for PhiaUI. CSS-only implementation using native radio inputs and SVG star icons. Supports read-only display and interactive selection. Form integration is available via `form_rating/1`. ## Examples <%!-- Display only --%> <.rating value={4} readonly={true} /> <%!-- Interactive --%> <.rating value={@rating} phx-change="set_rating" /> <%!-- Form-integrated --%> <.form_rating field={@form[:rating]} label="Rating" /> <%!-- Large size --%> <.rating value={3} size="lg" /> <%!-- Half-star display --%> <.rating value={3.5} half={true} readonly={true} /> <%!-- Heart icon --%> <.rating value={4} icon={:heart} /> <%!-- With hover labels --%> <.rating value={3} labels={["Poor", "Fair", "Good", "Great", "Excellent"]} /> """ use Phoenix.Component import PhiaUi.ClassMerger, only: [cn: 1] attr(:value, :any, default: 0, doc: "Current rating value (0 to max). Float when half=true.") attr(:max, :integer, default: 5, doc: "Maximum number of stars") attr(:readonly, :boolean, default: false, doc: "Whether the rating is read-only") attr(:size, :string, default: "default", values: ~w(sm default lg), doc: "Size of the stars") attr(:name, :string, default: "rating", doc: "Input name attribute") attr(:half, :boolean, default: false, doc: "Enable half-star display for float values") attr(:icon, :atom, values: [:star, :heart, :thumb], default: :star, doc: "Icon style") attr(:labels, :list, default: [], doc: "Hover label strings per position (e.g. ['Poor','Good'])") attr(:class, :string, default: nil, doc: "Additional CSS classes") attr(:rest, :global, include: ~w(phx-change phx-blur id), doc: "HTML attributes forwarded to the fieldset" ) @doc """ Renders a star rating with radio inputs. When `half=false` (default): integer-only values, simple SVG path per star. When `half=true`: float values display with a clip overlay (50% fill for .5 values). The `icon` attr switches the SVG shape to `:star`, `:heart`, or `:thumb`. The `labels` list adds `title` tooltip text to each star label. """ def rating(assigns) do assigns = assign(assigns, :stars, Enum.to_list(1..assigns.max)) ~H"""
<%= for star <- @stars do %> <% hover_label = Enum.at(@labels, star - 1) %> <% end %>
""" end attr(:field, Phoenix.HTML.FormField, required: true, doc: "A Phoenix.HTML.FormField struct") attr(:label, :string, default: nil, doc: "Label text") attr(:description, :string, default: nil, doc: "Helper text") attr(:max, :integer, default: 5, doc: "Maximum number of stars") attr(:size, :string, default: "default", values: ~w(sm default lg), doc: "Star size") attr(:half, :boolean, default: false, doc: "Enable half-star display for float values") attr(:icon, :atom, values: [:star, :heart, :thumb], default: :star, doc: "Icon style") attr(:labels, :list, default: [], doc: "Hover label strings per position") attr(:class, :string, default: nil, doc: "Additional CSS classes") attr(:rest, :global, include: ~w(phx-change phx-blur phx-debounce), doc: "HTML attributes forwarded to fieldset" ) @doc """ Renders a form-integrated star rating with label, description, and errors. """ def form_rating(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do assigns = assigns |> assign(:errors, Enum.map(field.errors, &translate_error/1)) |> assign_new(:name, fn -> field.name end) |> assign_new(:value, fn -> parse_value(field.value) end) ~H"""

{@description}

<.rating value={@value} max={@max} size={@size} name={@name} half={@half} icon={@icon} labels={@labels} class={@class} {@rest} />

{error}

""" end # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- defp star_label_class("sm"), do: "text-sm" defp star_label_class("default"), do: "text-base" defp star_label_class("lg"), do: "text-lg" defp star_icon_class("sm"), do: "h-4 w-4 transition-colors" defp star_icon_class("default"), do: "h-5 w-5 transition-colors" defp star_icon_class("lg"), do: "h-7 w-7 transition-colors" # Returns integer value for checked comparison (half=false mode) defp int_value(v) when is_integer(v), do: v defp int_value(v) when is_float(v), do: round(v) defp int_value(v) when is_binary(v), do: parse_value(v) |> int_value() defp int_value(_), do: 0 # Returns fill percentage (0, 50, or 100) for half-star overlay mode defp fill_pct(star, value) when is_integer(value) do if star <= value, do: 100, else: 0 end defp fill_pct(star, value) when is_float(value) do cond do star <= trunc(value) -> 100 star - 0.5 <= value -> 50 true -> 0 end end defp fill_pct(star, value) when is_binary(value) do fill_pct(star, parse_value(value)) end defp fill_pct(_, _), do: 0 # SVG path data for each icon style defp icon_path(:star), do: "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" defp icon_path(:heart), do: "M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" defp icon_path(:thumb), do: "M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3m0 11V9l4-9a3 3 0 0 1 3 3v4h5.28a2 2 0 0 1 2 2l-1.38 9a2 2 0 0 1-2 2H7z" defp parse_value(nil), do: 0 defp parse_value(v) when is_integer(v), do: v defp parse_value(v) when is_float(v), do: v defp parse_value(v) when is_binary(v) do case Float.parse(v) do {n, ""} -> n _ -> case Integer.parse(v) do {n, _} -> n :error -> 0 end end end defp translate_error({msg, opts}) do Enum.reduce(opts, msg, fn {key, value}, acc when is_binary(acc) -> String.replace(acc, "%{#{key}}", to_string(value)) _other, acc -> acc end) end end