defmodule Corex.Timer do @moduledoc ~S''' Phoenix implementation of [Zag.js Timer](https://zagjs.com/components/react/timer). Countdown leading-zero collapse is on by default when `countdown` is true. Override with `collapse_leading_zeros={false}` or fixed `segments`. ## Anatomy ### Minimal ```heex <.timer start_ms={60_000} class="timer" /> ``` ### With triggers ```heex <.timer start_ms={60_000} class="timer"> <:start_trigger><.heroicon name="hero-play" /> <:pause_trigger><.heroicon name="hero-pause" /> <:resume_trigger><.heroicon name="hero-play" /> <:reset_trigger><.heroicon name="hero-arrow-path" /> ``` ### Countdown ```heex <.timer countdown start_ms={60_000} target_ms={0} class="timer"> <:start_trigger><.heroicon name="hero-play" /> <:pause_trigger><.heroicon name="hero-pause" /> <:resume_trigger><.heroicon name="hero-play" /> <:reset_trigger><.heroicon name="hero-arrow-path" /> ``` ### Interval tick ```heex <.timer start_ms={60_000} interval={2000} auto_start class="timer"> <:start_trigger><.heroicon name="hero-play" /> <:pause_trigger><.heroicon name="hero-pause" /> <:resume_trigger><.heroicon name="hero-play" /> <:reset_trigger><.heroicon name="hero-arrow-path" /> ``` ## API Requires a stable `id` on `<.timer>`. Use trigger slots and `auto_start` for built-in controls; use the API for imperative control from LiveView or the client. | Function | Action | Returns | | -------- | ------ | ------- | | [`start/1`](#start/1) | Start timer (client) | `%Phoenix.LiveView.JS{}` | | [`start/2`](#start/2) | Start timer (server) | `socket` | | [`pause/1`](#pause/1) | Pause timer (client) | `%Phoenix.LiveView.JS{}` | | [`pause/2`](#pause/2) | Pause timer (server) | `socket` | | [`resume/1`](#resume/1) | Resume timer (client) | `%Phoenix.LiveView.JS{}` | | [`resume/2`](#resume/2) | Resume timer (server) | `socket` | | [`reset/1`](#reset/1) | Reset timer (client) | `%Phoenix.LiveView.JS{}` | | [`reset/2`](#reset/2) | Reset timer (server) | `socket` | | [`restart/1`](#restart/1) | Restart timer (client) | `%Phoenix.LiveView.JS{}` | | [`restart/2`](#restart/2) | Restart timer (server) | `socket` | | [`state/1`](#state/1) | Read machine state (client) | `%Phoenix.LiveView.JS{}` | | [`state/2`](#state/2) | Read machine state (client, opts) | `%Phoenix.LiveView.JS{}` | | [`state/3`](#state/3) | Read machine state (server) | `socket` | ### Machine state (`state/2`, `state/3`) Replies with `timer_state_response` (server) or `timer-state` on the host (client). Payload includes Zag machine fields: | Field | Type | Meaning | | ----- | ---- | ------- | | `running` | boolean | Timer is running | | `paused` | boolean | Timer is paused | | `progressPercent` | number | Progress toward target | | `time` | map | `{days, hours, minutes, seconds, milliseconds}` | | `formattedTime` | map | Same keys, string values | ## Events Pick an event name and pass it to `on_*` on `<.timer>`. ### Server events | Event | When | Payload | | ----- | ---- | ------- | | `on_tick="timer_tick"` | Each tick | `%{"id" => id, "formattedTime" => string, ...}` | | `on_complete="timer_complete"` | Countdown reaches target | `%{"id" => id}` | ### on_tick ```heex <.timer countdown start_ms={3_600_000} target_ms={0} class="timer" on_tick="timer_tick" on_complete="timer_complete" > <:start_trigger><.heroicon name="hero-play" /> <:pause_trigger><.heroicon name="hero-pause" /> <:resume_trigger><.heroicon name="hero-play" /> <:reset_trigger><.heroicon name="hero-arrow-path" /> ``` ```elixir def handle_event("timer_tick", %{"id" => id} = params, socket) do {:noreply, assign(socket, :last_tick, Map.get(params, "formattedTime"))} end def handle_event("timer_complete", %{"id" => _id}, socket) do {:noreply, socket} end ``` ### Client events | Event | When | `event.detail` | | ----- | ---- | -------------- | | `on_tick_client="timer-tick"` | Each tick | `id`, formatted time fields | | `on_complete_client="timer-complete"` | Countdown completes | `id` | ### on_tick_client ```heex <.timer id="timer-events-client" countdown start_ms={3_600_000} target_ms={0} class="timer" on_tick_client="timer-tick" on_complete_client="timer-complete" > <:start_trigger><.heroicon name="hero-play" /> <:pause_trigger><.heroicon name="hero-pause" /> <:resume_trigger><.heroicon name="hero-play" /> <:reset_trigger><.heroicon name="hero-arrow-path" /> ``` ```javascript const el = document.getElementById("timer-events-client"); el?.addEventListener("timer-tick", (e) => console.log(e.detail)); el?.addEventListener("timer-complete", (e) => console.log(e.detail)); ``` ''' @doc type: :component use Phoenix.Component import Corex.Api.Doc alias Corex.Timer.Anatomy.{ ActionTrigger, Area, Control, Item, ItemLabel, Props, Root, Segment, Separator } alias Corex.Timer.Connect alias Corex.Timer.Translation, as: TimerTranslation alias Phoenix.LiveView alias Phoenix.LiveView.JS import Corex.Helpers, only: [respond_to_fields: 1] @parts [:days, :hours, :minutes, :seconds] attr(:id, :string, required: false, doc: "DOM id for the timer root; required for imperative timer API helpers." ) attr(:countdown, :boolean, default: false, doc: "Count toward zero using target_ms when set." ) attr(:start_ms, :integer, default: 0, doc: "Initial elapsed or remaining milliseconds depending on mode." ) attr(:target_ms, :integer, default: nil, doc: "Countdown stops at this millisecond value (often 0)." ) attr(:auto_start, :boolean, default: true, doc: "Start the timer automatically on mount." ) attr(:interval, :integer, default: 1000, doc: "Tick interval in milliseconds." ) attr(:on_tick, :string, default: nil, doc: "LiveView event for each tick; see module Events section." ) attr(:on_tick_client, :string, default: nil, doc: "Browser CustomEvent name for each tick." ) attr(:on_complete, :string, default: nil, doc: "LiveView event when countdown or count-up reaches target." ) attr(:on_complete_client, :string, default: nil, doc: "Browser CustomEvent name when the run completes." ) attr(:collapse_leading_zeros, :boolean, default: nil, doc: "When nil and countdown without fixed segments, leading zero units are hidden (minimum minutes and seconds visible)." ) attr(:segments, :list, default: nil, doc: "Fixed subset of [:days, :hours, :minutes, :seconds] in natural order; disables collapse when set." ) attr(:translation, TimerTranslation, default: nil, doc: "Zag timer translations; supports area_label for the timer region aria-label." ) attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"], doc: "Text direction for styling; nil follows the document." ) attr(:orientation, :string, default: "horizontal", values: ["horizontal", "vertical"], doc: "Layout orientation for CSS." ) attr(:rest, :global) slot(:separator, required: false) slot(:day_label, required: false) slot(:hour_label, required: false) slot(:minute_label, required: false) slot(:second_label, required: false) slot :start_trigger, required: false do attr(:class, :string, required: false) end slot :pause_trigger, required: false do attr(:class, :string, required: false) end slot :resume_trigger, required: false do attr(:class, :string, required: false) end slot :reset_trigger, required: false do attr(:class, :string, required: false) end def timer(assigns) do assigns = assigns |> assign_new(:id, fn -> "timer-#{System.unique_integer([:positive])}" end) |> assign( :translation, TimerTranslation.resolve(assigns.translation) ) segments = normalize_segments(assigns.segments) time_values = time_values(assigns.start_ms) visibility_hidden = visibility_hidden( assigns.countdown, assigns.collapse_leading_zeros, segments, time_values ) id = assigns.id dir = assigns.dir orientation = assigns.orientation running = assigns.auto_start assigns = assigns |> assign(:time_values, time_values) |> assign(:running, running) |> assign(:paused, false) |> assign(:segments, segments) |> assign( :has_timer_controls?, assigns.start_trigger != [] or assigns.pause_trigger != [] or assigns.resume_trigger != [] or assigns.reset_trigger != [] ) |> assign(:visibility_hidden, visibility_hidden) |> assign(:props_struct, props_struct(assigns, segments)) |> assign(:root_struct, %Root{id: id, dir: dir, orientation: orientation}) |> assign(:area_struct, %Area{id: id, dir: dir, orientation: orientation}) |> assign(:control_struct, %Control{id: id, dir: dir, orientation: orientation}) |> assign( :start_trigger_struct, %ActionTrigger{ id: id, action: "start", hidden: running, dir: dir, orientation: orientation } ) |> assign( :pause_trigger_struct, %ActionTrigger{ id: id, action: "pause", hidden: not running, dir: dir, orientation: orientation } ) |> assign( :resume_trigger_struct, %ActionTrigger{ id: id, action: "resume", hidden: true, dir: dir, orientation: orientation } ) |> assign( :reset_trigger_struct, %ActionTrigger{ id: id, action: "reset", hidden: not running, dir: dir, orientation: orientation } ) ~H"""
<%= for {part, i} <- Enum.with_index([:days, :hours, :minutes, :seconds]) do %> <% hv = Enum.at(@visibility_hidden, i) %> <% ls = label_slot(assigns, part) %> <% segment_struct = %Segment{id: @id, type: to_string(part), hidden: hv} %> <% item_struct = %Item{id: @id, type: to_string(part), value: Map.fetch!(@time_values, part), dir: @dir, orientation: @orientation, hidden: hv} %> <% item_label_struct = %ItemLabel{id: @id, type: to_string(part), dir: @dir, orientation: @orientation} %>
<%= if ls != [] do %> {render_slot(ls)} <% end %>
<%= if i < 3 and @separator != [] do %> <% separator_struct = %Separator{id: "timer:#{@id}:sep:#{i}", dir: @dir, orientation: @orientation, hidden: hv} %>
{render_slot(@separator)}
<% end %> <% end %>
""" end defp label_slot(assigns, :days), do: assigns.day_label defp label_slot(assigns, :hours), do: assigns.hour_label defp label_slot(assigns, :minutes), do: assigns.minute_label defp label_slot(assigns, :seconds), do: assigns.second_label defp props_struct(assigns, segments) do %Props{ id: assigns.id, countdown: assigns.countdown, start_ms: assigns.start_ms, target_ms: assigns.target_ms, auto_start: assigns.auto_start, interval: assigns.interval, on_tick: assigns.on_tick, on_tick_client: assigns.on_tick_client, on_complete: assigns.on_complete, on_complete_client: assigns.on_complete_client, dir: assigns.dir, orientation: assigns.orientation, collapse_leading_zeros: assigns.collapse_leading_zeros, segments: segments, translation: assigns.translation } end defp normalize_segments(nil), do: nil defp normalize_segments([]) do raise ArgumentError, "Corex.Timer: segments must not be empty when provided" end defp normalize_segments(list) when is_list(list) do allowed = MapSet.new(@parts) unless MapSet.subset?(MapSet.new(list), allowed) do raise ArgumentError, "Corex.Timer: segments must be a subset of #{inspect(@parts)}" end idx = fn atom -> Enum.find_index(@parts, &(&1 == atom)) end indexes = list |> Enum.map(fn a -> case idx.(a) do nil -> raise ArgumentError, "Corex.Timer: invalid segment #{inspect(a)}" i -> i end end) unless indexes == Enum.sort(indexes) do raise ArgumentError, "Corex.Timer: segments must follow order #{inspect(@parts)}" end list end defp visibility_hidden(_countdown, _collapse_opt, segments, _time_values) when is_list(segments) do Enum.map(@parts, fn atom -> atom not in segments end) end defp visibility_hidden(countdown, collapse_opt, nil, time_values) do vals = Enum.map(@parts, &Map.fetch!(time_values, &1)) cond do collapse_opt == false -> [false, false, false, false] collapse_opt == true -> start_i = collapse_start_index(vals) Enum.map(0..3, fn i -> i < start_i end) countdown -> start_i = collapse_start_index(vals) Enum.map(0..3, fn i -> i < start_i end) true -> [false, false, false, false] end end defp collapse_start_index(vals) do collapse_start_index(vals, 0) end defp collapse_start_index(_vals, idx) when idx > 2 do idx end defp collapse_start_index(vals, idx) do rest_after = length(vals) - idx if idx < 3 && Enum.at(vals, idx) == 0 && rest_after > 2 do collapse_start_index(vals, idx + 1) else idx end end api_doc(~S""" Start the timer from `phx-click`. Dispatches `corex:timer:start` on the timer root (`id` matches the DOM host). ```heex <.action phx-click={Corex.Timer.start("my-timer")}>Start <.timer id="my-timer" start_ms={60_000} class="timer"> ``` ```javascript document.getElementById("my-timer")?.dispatchEvent(new CustomEvent("corex:timer:start", { bubbles: false })); ``` """) def start(timer_id) when is_binary(timer_id) do JS.dispatch("corex:timer:start", to: "##{timer_id}", bubbles: false) end api_doc(~S""" Start the timer from `handle_event` via [`push_event/3`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_event/3) (`timer_start`). ```elixir def handle_event("start_timer", _params, socket) do {:noreply, Corex.Timer.start(socket, "my-timer")} end ``` """) def start(socket, timer_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(timer_id) do LiveView.push_event(socket, "timer_start", %{id: timer_id}) end api_doc(~S""" Pause the timer from `phx-click`. Dispatches `corex:timer:pause`. ```heex <.action phx-click={Corex.Timer.pause("my-timer")}>Pause <.timer id="my-timer" start_ms={60_000} class="timer"> ``` """) def pause(timer_id) when is_binary(timer_id) do JS.dispatch("corex:timer:pause", to: "##{timer_id}", bubbles: false) end api_doc(~S""" Pause the timer from `handle_event` (`timer_pause`). ```elixir def handle_event("pause_timer", _params, socket) do {:noreply, Corex.Timer.pause(socket, "my-timer")} end ``` """) def pause(socket, timer_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(timer_id) do LiveView.push_event(socket, "timer_pause", %{id: timer_id}) end api_doc(~S""" Resume the timer from `phx-click`. Dispatches `corex:timer:resume`. ```heex <.action phx-click={Corex.Timer.resume("my-timer")}>Resume <.timer id="my-timer" start_ms={60_000} class="timer"> ``` """) def resume(timer_id) when is_binary(timer_id) do JS.dispatch("corex:timer:resume", to: "##{timer_id}", bubbles: false) end api_doc(~S""" Resume the timer from `handle_event` (`timer_resume`). ```elixir def handle_event("resume_timer", _params, socket) do {:noreply, Corex.Timer.resume(socket, "my-timer")} end ``` """) def resume(socket, timer_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(timer_id) do LiveView.push_event(socket, "timer_resume", %{id: timer_id}) end api_doc(~S""" Reset the timer from `phx-click`. Dispatches `corex:timer:reset`. ```heex <.action phx-click={Corex.Timer.reset("my-timer")}>Reset <.timer id="my-timer" start_ms={60_000} class="timer"> ``` """) def reset(timer_id) when is_binary(timer_id) do JS.dispatch("corex:timer:reset", to: "##{timer_id}", bubbles: false) end api_doc(~S""" Reset the timer from `handle_event` (`timer_reset`). ```elixir def handle_event("reset_timer", _params, socket) do {:noreply, Corex.Timer.reset(socket, "my-timer")} end ``` """) def reset(socket, timer_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(timer_id) do LiveView.push_event(socket, "timer_reset", %{id: timer_id}) end api_doc(~S""" Restart the timer from `phx-click`. Dispatches `corex:timer:restart`. ```heex <.action phx-click={Corex.Timer.restart("my-timer")}>Restart <.timer id="my-timer" start_ms={60_000} class="timer"> ``` """) def restart(timer_id) when is_binary(timer_id) do JS.dispatch("corex:timer:restart", to: "##{timer_id}", bubbles: false) end api_doc(~S""" Restart the timer from `handle_event` (`timer_restart`). ```elixir def handle_event("restart_timer", _params, socket) do {:noreply, Corex.Timer.restart(socket, "my-timer")} end ``` """) def restart(socket, timer_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(timer_id) do LiveView.push_event(socket, "timer_restart", %{id: timer_id}) end api_doc(~S""" Read machine state from `phx-click`. Dispatches `corex:timer:state`. Optional `respond_to:` `:server`, `:client`, or `:both`. | | Reply | Payload | | - | ----- | ------- | | Server | `timer_state_response` | `%{"id" => id}` plus `running`, `paused`, `progressPercent`, `time`, `formattedTime` | | Client | `timer-state` on the timer root | same fields in `detail` | ```heex <.action phx-click={Corex.Timer.state("my-timer")}>State <.timer id="my-timer" start_ms={60_000} class="timer"> ``` ```elixir def handle_event("timer_state_response", %{"id" => _, "running" => running}, socket) do {:noreply, assign(socket, :running, running)} end ``` ```javascript document.getElementById("my-timer")?.addEventListener("timer-state", (e) => { console.log(e.detail.running, e.detail.paused); }); ``` """) def state(timer_id, opts) when is_binary(timer_id) and is_list(opts) do JS.dispatch("corex:timer:state", to: "##{timer_id}", detail: respond_to_fields(opts), bubbles: false ) end def state(socket, timer_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(timer_id) do state(socket, timer_id, []) end api_doc_short("Same as [`state/2`](#state/2) with default `respond_to:`.") def state(timer_id) when is_binary(timer_id), do: state(timer_id, []) api_doc(~S""" Read machine state from `handle_event` (`timer_state`). Same replies as [`state/2`](#state/2); server-side only unless you also use [`state/2`](#state/2) for a client DOM reply. | Reply | Payload | | ----- | ------- | | `timer_state_response` | `%{"id" => id}` plus `running`, `paused`, `progressPercent`, `time`, `formattedTime` | ```elixir def handle_event("read_state", _params, socket) do {:noreply, Corex.Timer.state(socket, "my-timer")} end def handle_event("timer_state_response", %{"id" => _, "running" => running}, socket) do {:noreply, assign(socket, :running, running)} end ``` """) def state(socket, timer_id, opts) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(timer_id) and is_list(opts) do LiveView.push_event( socket, "timer_state", Map.merge(%{id: timer_id}, respond_to_fields(opts)) ) end @doc type: :component attr(:id, :string, required: false) attr(:rest, :global) def timer_skeleton(assigns) do ~H"""
:
:
:
""" end defp time_values(ms) do ms = max(0, ms) seconds = div(rem(ms, 60_000), 1_000) minutes = div(rem(ms, 3_600_000), 60_000) hours = div(rem(ms, 86_400_000), 3_600_000) days = div(ms, 86_400_000) %{days: days, hours: hours, minutes: minutes, seconds: seconds} end end