defmodule Corex.AngleSlider do @moduledoc ~S''' Phoenix implementation of [Zag.js Angle Slider](https://zagjs.com/components/react/angle-slider). WAI-ARIA circular angle control. Use `angle_slider/1` with an optional label slot, marks, and controlled or uncontrolled mode. ## Anatomy ### Basic ```heex <.angle_slider class="angle-slider"> <:label>Angle ``` ### With marks ```heex <.angle_slider class="angle-slider" marker_values={[0, 90, 180, 270]}> <:label>Angle ``` ### Controlled In controlled mode, use `on_value_change` and `on_value_change_client` so the thumb moves during drag. Use `on_value_change_end` and `on_value_change_end_client` if you only need to react when the user releases. ```elixir defmodule MyAppWeb.AngleSliderLive do use MyAppWeb, :live_view def mount(_params, _session, socket) do {:ok, assign(socket, :value, 0)} end def handle_event("angle_changed", %{"value" => value}, socket) do {:noreply, assign(socket, :value, value)} end def render(assigns) do ~H""" <.angle_slider id="angle" controlled value={@value} on_value_change="angle_changed" marker_values={[0, 90, 180, 270]} class="angle-slider"> <:label>Angle """ end end ``` ## API Requires a stable `id` on `<.angle_slider>`. | Function | Action | Returns | | -------- | ------ | ------- | | [`set_value/2`](#set_value/2) | Set angle in degrees (client) | `%Phoenix.LiveView.JS{}` | | [`set_value/3`](#set_value/3) | Set angle in degrees (server) | `socket` | | [`value/2`](#value/2) | Read angle (client) | `%Phoenix.LiveView.JS{}` | | [`value/3`](#value/3) | Read angle (server) | `socket` | For `value`, use `respond_to: :server | :client | :both`. LiveView receives `angle_slider_value_response`; the DOM receives `angle-slider-value`. ## Events Pick an event name and pass it to `on_*` on `<.angle_slider>`. ### Server events | Event | When | Payload | | ----- | ---- | ------- | | `on_value_change="angle_changed"` | Value changes while dragging | `%{"id" => id, "value" => number, "valueAsDegree" => number}` | | `on_value_change_end="angle_changed_end"` | User releases thumb | `%{"id" => id, "value" => number, "valueAsDegree" => number}` | ### on_value_change ```heex <.angle_slider class="angle-slider" on_value_change="angle_changed" marker_values={[0, 90, 180, 270]} > <:label>Angle ``` ```elixir def handle_event("angle_changed", %{"value" => value}, socket) do {:noreply, assign(socket, :value, value)} end ``` ### Client events | Event | When | `event.detail` | | ----- | ---- | -------------- | | `on_value_change_client="angle-changed"` | Value changes while dragging | `id`, `value`, `valueAsDegree` | | `on_value_change_end_client="angle-changed-end"` | User releases thumb | `id`, `value`, `valueAsDegree` | ## Style Use data attributes to target elements: ```css [data-scope="angle-slider"][data-part="root"] {} [data-scope="angle-slider"][data-part="control"] {} [data-scope="angle-slider"][data-part="thumb"] {} [data-scope="angle-slider"][data-part="value-text"] {} [data-scope="angle-slider"][data-part="marker-group"] {} [data-scope="angle-slider"][data-part="marker"] {} ``` If you wish to use the default Corex styling, you can use the class `angle-slider` on the component. This requires to install `Mix.Tasks.Corex.Design` first and import the component css file. ```css @import "../corex/main.css"; @import "../corex/tokens/themes/neo/light.css"; @import "../corex/components/angle-slider.css"; ``` Stack modifiers on the host (`class` on `<.angle_slider>`). ### Color | Modifier | Classes | | -------- | ------- | | Default | `angle-slider` | | Accent | `angle-slider angle-slider--accent` | | Brand | `angle-slider angle-slider--brand` | | Alert | `angle-slider angle-slider--alert` | | Info | `angle-slider angle-slider--info` | | Success | `angle-slider angle-slider--success` | ### Size | Modifier | Classes | | -------- | ------- | | SM | `angle-slider angle-slider--sm` | | MD | `angle-slider angle-slider--md` | | LG | `angle-slider angle-slider--lg` | | XL | `angle-slider angle-slider--xl` | ## Patterns ### Async and skeleton Use `assign_async/3` with `<.async_result>` and show `angle_slider_skeleton/1` while loading. ```elixir <.async_result :let={slider} assign={@slider}> <:loading> <.angle_slider_skeleton class="angle-slider" /> <:failed>Could not load. <.angle_slider id="async-angle" class="angle-slider" value={slider.value} /> ``` ## Form When using with Phoenix forms, set the form `id` in `to_form/2` (for example `to_form(changeset, as: :name, id: "my-form")`) and use `<.form for={@form}>`. For cross-cutting invalid styling and error presentation, see the [Forms](forms.html) guide. Pass `invalid={Corex.FormField.invalid?(@form[:angle])}` when you want alert borders after validation. ```elixir def angle_slider_form_page(conn, _params) do form = %MyApp.Form.AngleSliderForm{} |> MyApp.Form.AngleSliderForm.changeset(%{}) |> Phoenix.Component.to_form(as: :angle_slider_form, id: "angle-slider-form") render(conn, :angle_slider_form_page, form: form) end ``` ```heex <.form :let={f} for={@form} action={@action} method="post"> <.angle_slider field={f[:angle]} class="angle-slider" marker_values={[0, 90, 180, 270]}> <:label>Angle <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} ``` ''' @doc type: :component use Phoenix.Component import Corex.Api.Doc alias Corex.AngleSlider.Anatomy.{ Control, HiddenInput, Label, Marker, MarkerGroup, Props, Root, Text, Thumb, Value, ValueText } alias Corex.AngleSlider.Connect alias Phoenix.LiveView alias Phoenix.LiveView.JS import Corex.Helpers, only: [respond_to_fields: 1] attr(:id, :string, required: false, doc: "The id of the angle slider") attr(:value, :float, default: 0.0, doc: "The value or controlled value in degrees") attr(:controlled, :boolean, default: false, doc: "Whether the value is controlled") attr(:step, :float, default: 1.0, doc: "Step value") attr(:disabled, :boolean, default: false, doc: "Whether the slider is disabled") attr(:read_only, :boolean, default: false, doc: "Whether the slider is read-only") attr(:invalid, :boolean, default: false, doc: "Whether the slider is invalid") attr(:name, :string, default: nil, doc: "Name for form submission") attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"], doc: "Direction") attr(:orientation, :string, default: "vertical", values: ["horizontal", "vertical"]) attr(:value_text_as, :string, default: "degree", values: ["raw", "degree"], doc: "Displayed value format: raw (api.value) or degree (api.valueAsDegree)" ) attr(:compound, :boolean, default: false, doc: "Enable compound mode. Use with :let={ctx} and sub-components to fully control structure." ) attr(:on_value_change, :string, default: nil, doc: "Server event when value changes (uncontrolled)" ) attr(:on_value_change_client, :string, default: nil, doc: "Client event when value changes (uncontrolled)" ) attr(:on_value_change_end, :string, default: nil, doc: "Server event when value change ends (controlled)" ) attr(:on_value_change_end_client, :string, default: nil, doc: "Client event when value change ends (controlled)" ) attr(:marker_values, :list, default: [], doc: "List of angle values to show as markers (e.g. [0, 90, 180, 270])" ) attr(:errors, :list, default: [], doc: "List of error messages to display") attr(:field, Phoenix.HTML.FormField, doc: "A form field struct retrieved from the form, for example: @form[:angle]. Automatically sets id, name, value, and errors from the form field" ) attr(:rest, :global) slot(:inner_block, required: false) slot :label, required: false do attr(:class, :string, required: false) end slot :value_text, required: false do attr(:class, :string, required: false) end slot :error, required: false do attr(:class, :string, required: false) end def angle_slider(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do value = field.value |> angle_value_to_float() |> clamp_angle() assigns |> Corex.FormField.assign_form_field(field) |> assign(:value, value) |> angle_slider() end def angle_slider(assigns) do assigns = assigns |> assign_new(:id, fn -> "angle-slider-#{System.unique_integer([:positive])}" end) |> assign_new(:form_field, fn -> false end) |> update(:value, &clamp_angle/1) ctx = %{ id: assigns.id, dir: assigns.dir, orientation: assigns.orientation, value: assigns.value, controlled: assigns.controlled, step: assigns.step, disabled: assigns.disabled, read_only: assigns.read_only, invalid: assigns.invalid, name: assigns.name, marker_values: assigns.marker_values, value_text_as: assigns.value_text_as } assigns = assign(assigns, :ctx, ctx) ~H"""