@@ -64,6 +64,14 @@ defmodule <%= @module %> do
6464 default: false,
6565 doc: "If true, stars are wrapped in a button for selecting a rating"
6666
67+ attr :disabled, :boolean,
68+ default: false,
69+ doc: "If true, the rating component is disabled and cannot be interacted with"
70+
71+ attr :precision, :float,
72+ default: 1.0,
73+ doc: "Rating precision - 1.0 for full stars, 0.5 for half-star selection"
74+
6775 attr :field, Phoenix.HTML.FormField,
6876 default: nil,
6977 doc: "A form field struct retrieved from the form, for example: @form[:rating]"
@@ -83,16 +91,24 @@ defmodule <%= @module %> do
8391 select =
8492 case field.value do
8593 val when is_integer(val) -> val
86- val when is_binary(val) and val != "" -> String.to_integer(val)
87- _ -> 0
94+ val when is_float(val) -> val
95+
96+ val when is_binary(val) and val != "" ->
97+ case Float.parse(val) do
98+ {f, ""} -> if f == trunc(f), do: trunc(f), else: f
99+ _ -> 0
100+ end
101+
102+ _ ->
103+ 0
88104 end
89105
90106 assigns
91107 |> assign(field: nil, id: assigns.id || field.id)
92108 |> assign(:errors, Enum.map(errors, &translate_error(&1)))
93109 |> assign(:name, field.name)
94110 |> assign(:select, select)
95- |> assign(:interactive, true)
111+ |> assign(:interactive, if(assigns.disabled, do: false, else: true) )
96112 |> <%= @component_prefix %>rating()
97113 end
98114
@@ -103,11 +119,27 @@ defmodule <%= @module %> do
103119 <div
104120 id={@id}
105121 role="radiogroup"
122+ tabindex={if @interactive and not @disabled, do: "0"}
123+ aria-disabled={if @disabled, do: "true"}
124+ phx-keydown={
125+ if @interactive and not @disabled do
126+ JS.push("rating",
127+ value: %{
128+ action: "keyboard",
129+ current: @select,
130+ count: @count,
131+ precision: @precision,
132+ name: @name
133+ }
134+ )
135+ end
136+ }
106137 class={[
107138 "flex flex-nowrap text-default-light-gray dark:text-natural-light",
108139 gap_class(@gap),
109140 size_class(@size),
110141 color_class(@color),
142+ @disabled && "opacity-50 pointer-events-none",
111143 @class
112144 ]}
113145 {@rest}
@@ -122,6 +154,27 @@ defmodule <%= @module %> do
122154 <%%= for item <- 1..@count do %>
123155 <%% fill_percentage = calculate_fill_percentage(item, @select) %>
124156 <%%= cond do %>
157+ <%% @interactive and @name != nil and @precision == 0.5 -> %>
158+ <div class={[
159+ "relative inline-flex rating-button [&>svg]:pointer-events-none",
160+ "[&:has(~.rating-button:hover)_.fraction-path]:opacity-0 [&:has(~.rating-button:hover)_.full-path]:opacity-100",
161+ "[&:has(.rating-half-right:hover)_.fraction-path]:opacity-0 [&:has(.rating-half-right:hover)_.full-path]:opacity-100",
162+ "[&:has(.rating-half-left:hover)_.full-path]:opacity-0 [&:has(.rating-half-left:hover)_.fraction-path]:opacity-100"
163+ ]}>
164+ <.precision_star_svg id={@id} item={item} fill_percentage={fill_percentage} />
165+ <span
166+ class="rating-half-left absolute inset-y-0 left-0 w-1/2 z-10 cursor-pointer"
167+ phx-click={form_click_js(@id, item - 0.5, @select)}
168+ >
169+ <span class="sr-only">{gettext("Rate %{count} stars", count: item - 0.5)}</span>
170+ </span>
171+ <span
172+ class="rating-half-right absolute inset-y-0 right-0 w-1/2 z-10 cursor-pointer"
173+ phx-click={form_click_js(@id, item, @select)}
174+ >
175+ <span class="sr-only">{gettext("Rate %{count} star", count: item)}</span>
176+ </span>
177+ </div>
125178 <%% @interactive and @name != nil -> %>
126179 <label
127180 role="radio"
@@ -134,17 +187,32 @@ defmodule <%= @module %> do
134187 "group",
135188 "[&:has(~.rating-button:hover)_.fraction-path]:opacity-0 [&:has(~.rating-button:hover)_.full-path]:opacity-100"
136189 ]}
137- phx-click={
138- JS.set_attribute(
139- {"value", if(item == @select, do: "0", else: to_string(item))},
140- to: "##{@id}-hidden"
141- )
142- |> JS.dispatch("change", to: "##{@id}-hidden")
143- }
190+ phx-click={form_click_js(@id, item, @select)}
144191 >
145192 <.star_svg id={@id} item={item} fill_percentage={fill_percentage} />
146193 <span class="sr-only">{gettext("Rate %{count} star", count: item)}</span>
147194 </label>
195+ <%% @interactive and @precision == 0.5 -> %>
196+ <div class={[
197+ "relative inline-flex rating-button [&>svg]:pointer-events-none",
198+ "[&:has(~.rating-button:hover)_.fraction-path]:opacity-0 [&:has(~.rating-button:hover)_.full-path]:opacity-100",
199+ "[&:has(.rating-half-right:hover)_.fraction-path]:opacity-0 [&:has(.rating-half-right:hover)_.full-path]:opacity-100",
200+ "[&:has(.rating-half-left:hover)_.full-path]:opacity-0 [&:has(.rating-half-left:hover)_.fraction-path]:opacity-100"
201+ ]}>
202+ <.precision_star_svg id={@id} item={item} fill_percentage={fill_percentage} />
203+ <span
204+ class="rating-half-left absolute inset-y-0 left-0 w-1/2 z-10 cursor-pointer"
205+ phx-click={event_click_js(@on_action, item - 0.5, @params)}
206+ >
207+ <span class="sr-only">{gettext("Rate %{count} stars", count: item - 0.5)}</span>
208+ </span>
209+ <span
210+ class="rating-half-right absolute inset-y-0 right-0 w-1/2 z-10 cursor-pointer"
211+ phx-click={event_click_js(@on_action, item, @params)}
212+ >
213+ <span class="sr-only">{gettext("Rate %{count} star", count: item)}</span>
214+ </span>
215+ </div>
148216 <%% @interactive -> %>
149217 <button
150218 role="radio"
@@ -157,12 +225,7 @@ defmodule <%= @module %> do
157225 "group",
158226 "[&:has(~.rating-button:hover)_.fraction-path]:opacity-0 [&:has(~.rating-button:hover)_.full-path]:opacity-100"
159227 ]}
160- phx-click={
161- @on_action
162- |> JS.push("rating",
163- value: Map.merge(%{action: "select", number: item}, @params)
164- )
165- }
228+ phx-click={event_click_js(@on_action, item, @params)}
166229 >
167230 <.star_svg id={@id} item={item} fill_percentage={fill_percentage} />
168231 <span class="sr-only">{gettext("Rate %{count} star", count: item)}</span>
@@ -226,6 +289,50 @@ defmodule <%= @module %> do
226289 """
227290 end
228291
292+ attr :id, :string, default: nil
293+ attr :item, :integer, required: true
294+ attr :fill_percentage, :any, required: true
295+
296+ defp precision_star_svg(assigns) do
297+ ~H"""
298+ <svg
299+ xmlns="http://www.w3.org/2000/svg"
300+ viewBox="0 0 24 24"
301+ class={["rating-icon transition-all delay-100", @fill_percentage > 0 && "rated"]}
302+ >
303+ <defs>
304+ <linearGradient id={"star-fill-#{@id}-#{@item}"} x1="0%" y1="0%" x2="100%" y2="0%">
305+ <stop offset="0%" stop-color="currentColor"></stop>
306+ <stop offset="50%" stop-color="currentColor"></stop>
307+ <stop offset="50%" stop-color="currentColor" stop-opacity="0.2"></stop>
308+ <stop offset="100%" stop-color="currentColor" stop-opacity="0.2"></stop>
309+ </linearGradient>
310+ </defs>
311+ <path
312+ fill="currentColor"
313+ fill-opacity="0.2"
314+ d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
315+ />
316+ <path
317+ class={[
318+ "fraction-path transition-all delay-100",
319+ if(@fill_percentage > 0 and @fill_percentage < 100, do: "opacity-100", else: "opacity-0")
320+ ]}
321+ fill={"url(#star-fill-#{@id}-#{@item})"}
322+ d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
323+ />
324+ <path
325+ class={[
326+ "full-path transition-all delay-100",
327+ if(@fill_percentage >= 100, do: "opacity-100", else: "opacity-0")
328+ ]}
329+ fill="currentColor"
330+ d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
331+ />
332+ </svg>
333+ """
334+ end
335+
229336 @doc """
230337 Determines the current rating value from form params or data, useful for keeping the
231338 selected state in sync when using the `field` prop with forms.
@@ -241,11 +348,50 @@ defmodule <%= @module %> do
241348
242349 case val do
243350 v when is_integer(v) -> v
244- v when is_binary(v) and v != "" -> String.to_integer(v)
245- _ -> 0
351+ v when is_float(v) -> v
352+
353+ v when is_binary(v) and v != "" ->
354+ case Float.parse(v) do
355+ {f, ""} -> if f == trunc(f), do: trunc(f), else: f
356+ _ -> 0
357+ end
358+
359+ _ ->
360+ 0
246361 end
247362 end
248363
364+ @doc """
365+ Computes the new rating value for keyboard navigation events.
366+ Use this in your `handle_event/3` to process keyboard actions from the rating component.
367+
368+ Supports `ArrowRight`/`ArrowUp` (increment), `ArrowLeft`/`ArrowDown` (decrement),
369+ `Home` (first step), and `End` (max value).
370+
371+ ## Examples
372+
373+ ```elixir
374+ def handle_event("rating", %{"action" => "keyboard", "key" => key, "current" => current, "count" => count, "precision" => precision}, socket) do
375+ new_val = Rating.rating_keyboard(key, current, count, precision)
376+ {:noreply, assign(socket, star: new_val)}
377+ end
378+ ```
379+ """
380+ def rating_keyboard(key, current, count, precision \\ 1.0) do
381+ step = precision
382+
383+ result =
384+ case key do
385+ k when k in ["ArrowRight", "ArrowUp"] -> min(current + step, count)
386+ k when k in ["ArrowLeft", "ArrowDown"] -> max(current - step, 0)
387+ "Home" -> step
388+ "End" -> count
389+ _ -> current
390+ end
391+
392+ if is_float(result) and result == trunc(result), do: trunc(result), else: result
393+ end
394+
249395 @doc type: :component
250396 attr :for, :string, default: nil, doc: "Specifies the form which is associated with"
251397 attr :class, :string, default: nil, doc: "Custom CSS class for additional styling"
@@ -278,6 +424,20 @@ defmodule <%= @module %> do
278424 """
279425 end
280426
427+ defp form_click_js(id, value, current_select) do
428+ new_value = if value == current_select, do: 0, else: value
429+
430+ JS.set_attribute({"value", to_string(new_value)}, to: "##{id}-hidden")
431+ |> JS.dispatch("change", to: "##{id}-hidden")
432+ end
433+
434+ defp event_click_js(on_action, value, params) do
435+ on_action
436+ |> JS.push("rating",
437+ value: Map.merge(%{action: "select", number: value}, params)
438+ )
439+ end
440+
281441 # Helper function to calculate the fill percentage for a star
282442 defp calculate_fill_percentage(star_position, select) do
283443 cond do
0 commit comments