defmodule StarRating do @moduledoc """ Canvas-based star rating widget. Renders 5 stars. Interactive by default (click to rate, hover to preview, Tab/arrow keys to navigate, Enter/Space to select). Pass `readonly: true` for a display-only version. # Interactive (full size) StarRating.render("my-rating", model.rating, hover: model.hover_star, focused: model.focused_star, theme_progress: p) # Read-only (small, for review display) StarRating.render("review-stars", 4, readonly: true, scale: 0.5) Events: - `canvas_shape_click` with shape_id `"star-0"` through `"star-4"` - `canvas_shape_enter`/`canvas_shape_leave` for hover - `canvas_shape_focused` with shape_id for keyboard focus """ import Plushie.Canvas.Shape, only: [move_to: 2, line_to: 2, close: 0] @doc "Renders a star rating canvas widget." def render(id, rating, opts \\ []) do import Plushie.UI hover = Keyword.get(opts, :hover) focused = Keyword.get(opts, :focused) theme_progress = Keyword.get(opts, :theme_progress, 0.0) readonly = Keyword.get(opts, :readonly, false) scale = Keyword.get(opts, :scale, 1.0) outer_r = 13 * scale inner_r = 5 * scale size = round(30 * scale) gap = round(2 * scale) display = hover || rating width = 5 * size + 4 * gap focus_r = outer_r + 3 * scale commands = star_commands(outer_r, inner_r) canvas id, width: width, height: size do layer "stars" do for i <- 0..4 do cx = i * (size + gap) + size / 2 cy = size / 2 filled = i < display preview = not readonly and hover != nil and i < hover and i >= rating is_focused = not readonly and focused == i if readonly do group x: cx, y: cy do path(commands, fill: star_color(filled, false, theme_progress)) end else group x: cx, y: cy do interactive "star-#{i}" do on_click on_hover cursor("pointer") a11y(%{role: :button, label: "#{i + 1} star#{if i == 0, do: "", else: "s"}"}) end if is_focused do circle(0, 0, focus_r, stroke: Plushie.Canvas.Shape.stroke("#3b82f6", 2 * scale) ) end path(commands, fill: star_color(filled, preview, theme_progress)) end end end end end end defp star_commands(outer_r, inner_r) do points = for i <- 0..9 do angle = i * :math.pi() / 5 - :math.pi() / 2 r = if rem(i, 2) == 0, do: outer_r, else: inner_r {r * :math.cos(angle), r * :math.sin(angle)} end [{fx, fy} | rest] = points [move_to(fx, fy) | Enum.map(rest, fn {x, y} -> line_to(x, y) end)] ++ [close()] end defp star_color(true, false, _progress), do: "#f59e0b" defp star_color(_, true, _progress), do: "#fcd34d" defp star_color(false, false, progress) do r = round(209 + (74 - 209) * progress) g = round(213 + (74 - 213) * progress) b = round(219 + (94 - 219) * progress) "#" <> hex(r) <> hex(g) <> hex(b) end defp hex(n), do: n |> Integer.to_string(16) |> String.pad_leading(2, "0") end