Skip to content

Commit 9ebca17

Browse files
committed
Add precision, disabled state, and keyboard support
Based on #474 Add support for rating precision (including half-star selection), a disabled prop, and keyboard navigation. Introduces :precision and :disabled attributes, parses float/string values for selected rating, and makes :interactive respect the disabled flag. Adds keyboard handling (tabindex, aria-disabled and phx-keydown) and a public helper rating_keyboard/4 for processing Arrow/Home/End keys. Adds a precision_star_svg component for half-star visuals and consolidates JS behavior into form_click_js/3 and event_click_js/3, updating phx-click usages and styling (opacity/pointer-events) for the disabled state.
1 parent 7eecf54 commit 9ebca17

File tree

1 file changed

+178
-18
lines changed

1 file changed

+178
-18
lines changed

priv/components/rating.eex

Lines changed: 178 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)