defmodule Corex.Listbox do @moduledoc ~S''' Phoenix implementation of [Zag.js Listbox](https://zagjs.com/components/react/listbox). Pass `items={Corex.List.new([...])}`. With `redirect`, use per-item `:to` and `:redirect` (`:href` | `:patch` | `:navigate` | `false`); Zag runs single-select when `redirect` is true. ## Examples ### Minimal ```heex <.listbox id="my-listbox" class="listbox" items={Corex.List.new([ %{label: "France", id: "fra", disabled: true}, %{label: "Belgium", id: "bel"}, %{label: "Germany", id: "deu"}, %{label: "Netherlands", id: "nld"}, %{label: "Switzerland", id: "che"}, %{label: "Austria", id: "aut"} ])} > <:label>Choose a country <:item_indicator> <.heroicon name="hero-check" /> ``` ### Grouped ```heex <.listbox class="listbox" items={Corex.List.new([ %{label: "France", id: "fra", group: "Europe"}, %{label: "Belgium", id: "bel", group: "Europe"}, %{label: "Germany", id: "deu", group: "Europe"}, %{label: "Netherlands", id: "nld", group: "Europe"}, %{label: "Switzerland", id: "che", group: "Europe"}, %{label: "Austria", id: "aut", group: "Europe"}, %{label: "Japan", id: "jpn", group: "Asia"}, %{label: "China", id: "chn", group: "Asia"}, %{label: "South Korea", id: "kor", group: "Asia"}, %{label: "Thailand", id: "tha", group: "Asia"}, %{label: "USA", id: "usa", group: "North America"}, %{label: "Canada", id: "can", group: "North America"}, %{label: "Mexico", id: "mex", group: "North America"} ])} > <:label>Choose a country <:item_indicator> <.heroicon name="hero-check" /> ``` ### Custom This example requires the installation of [Flagpack](https://hex.pm/packages/flagpack). Use the `:item` slot with `:let={%{item: entry}}` to access the entry map. ```heex <.listbox class="listbox" items={Corex.List.new([ %{label: "France", id: "fra"}, %{label: "Belgium", id: "bel"}, %{label: "Germany", id: "deu"}, %{label: "Netherlands", id: "nld"}, %{label: "Switzerland", id: "che"}, %{label: "Austria", id: "aut"} ])} > <:label> Country of residence <:item :let={%{item: entry}}> {entry.label} <:item_indicator> <.heroicon name="hero-check" /> ``` ### Custom Grouped ```heex <.listbox class="listbox" items={Corex.List.new([ %{label: "France", id: "fra", group: "Europe"}, %{label: "Belgium", id: "bel", group: "Europe"}, %{label: "Germany", id: "deu", group: "Europe"}, %{label: "Japan", id: "jpn", group: "Asia"}, %{label: "China", id: "chn", group: "Asia"}, %{label: "South Korea", id: "kor", group: "Asia"} ])} > <:item :let={%{item: entry}}> {entry.label} <:item_indicator> <.heroicon name="hero-check" /> ``` ### Stream Use with `Phoenix.LiveView.stream/3` to add or remove items dynamically. Keep a list in sync with the stream and pass it as `items`. The hook reads `data-items` and rebuilds the list when items change. For actions inside the `:item` slot (e.g. a remove button), use `data-phx-push` and `data-phx-push-id` so the listbox hook can delegate clicks to LiveView: ```heex def mount(_params, _session, socket) do {:ok, socket |> stream_configure(:items, dom_id: &"listbox:my-listbox:item:#{&1.id}") |> stream(:items, @initial_items) |> assign(:items_list, @initial_items)} end def render(assigns) do ~H""" <.listbox id="my-listbox" class="listbox" items={@items_list}> <:label>Choose an item <:empty>No items <:item :let={%{item: entry}}> <.action phx-click={JS.push("remove_item", value: %{id: entry.id})} data-phx-push="remove_item" data-phx-push-id={entry.id} class="button button--sm" > <.heroicon name="hero-trash" /> {entry.label} <:item_indicator> <.heroicon name="hero-check" /> """ end def handle_event("remove_item", %{"id" => id}, socket) do item = Enum.find(socket.assigns.items_list, &(&1.id == id)) if item do {:noreply, socket |> stream_delete(:items, item) |> assign(:items_list, List.delete(socket.assigns.items_list, item))} else {:noreply, socket} end end ``` ## Styling Use data attributes to target elements: ```css [data-scope="listbox"][data-part="root"] {} [data-scope="listbox"][data-part="content"] {} [data-scope="listbox"][data-part="item"] {} [data-scope="listbox"][data-part="item-text"] {} [data-scope="listbox"][data-part="item-indicator"] {} [data-scope="listbox"][data-part="item-group"] {} [data-scope="listbox"][data-part="item-group-label"] {} ``` If you wish to use the default Corex styling, you can use the class `listbox` 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/listbox.css"; ``` You can then use modifiers ```heex <.listbox class="listbox listbox--accent listbox--lg" items={Corex.List.new([])}> ``` ''' @doc type: :component use Phoenix.Component alias Phoenix.LiveView alias Phoenix.LiveView.JS alias Corex.Listbox.Anatomy.{ Content, Item, ItemGroup, ItemGroupLabel, ItemIndicator, ItemText, Label, Props, Root } alias Corex.Listbox.Connect import Corex.Helpers, only: [ validate_value!: 1, normalize_items: 1, normalize_groups: 1, has_groups?: 1, entry_value: 1, entry_selected?: 2, respond_to_fields: 1 ] attr(:id, :string, required: false, doc: "The id of the listbox") attr(:items, :list, required: true, doc: "Items from `Corex.List.new/1` (or maps with id/value, label, disabled, group)" ) attr(:value, :list, default: [], doc: "Selected value(s)") attr(:controlled, :boolean, default: false, doc: "Whether the listbox is controlled") attr(:disabled, :boolean, default: false, doc: "Whether the listbox is disabled") attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"], doc: "Text direction") attr(:orientation, :string, default: "vertical", values: ["horizontal", "vertical"], doc: "Layout orientation of items" ) attr(:loop_focus, :boolean, default: false, doc: "Whether to loop focus within the listbox") attr(:selection_mode, :string, default: "single", values: ["single", "multiple", "extended"], doc: "How items can be selected" ) attr(:select_on_highlight, :boolean, default: false, doc: "Select item when highlighted via keyboard" ) attr(:deselectable, :boolean, default: false, doc: "Whether selection can be cleared") attr(:typeahead, :boolean, default: false, doc: "Enable typeahead search") attr(:on_value_change, :string, default: nil, doc: "Server event name on value change") attr(:on_value_change_client, :string, default: nil, doc: "Client event name on value change") attr(:redirect, :boolean, default: false, doc: """ When true, selecting a value triggers redirect-on-select. Each item picks the navigation kind via `:redirect` (`:href` (default) | `:patch` | `:navigate` | `false`). Items may also set `:to` (overrides the destination) and `:new_tab` (opens in a new tab). When true, the client runs single-select in Zag even if `selection_mode` is multiple. """ ) attr(:aria_label, :string, default: nil, doc: "Accessible name when no label slot is provided") attr(:rest, :global) slot :label, required: false do attr(:class, :string, required: false) end slot :item, required: false do attr(:class, :string, required: false) end slot :item_indicator, required: false do attr(:class, :string, required: false) end slot :empty, required: false do attr(:class, :string, required: false) end def listbox(assigns) do items = normalize_items(assigns.items) has_groups = has_groups?(items) groups = normalize_groups(items) assigns = assigns |> assign_new(:id, fn -> "listbox-#{System.unique_integer([:positive])}" end) |> assign_new(:dir, fn -> "ltr" end) |> assign_new(:controlled, fn -> false end) |> assign(:value, validate_value!(assigns[:value] || [])) |> assign(:items, items) |> assign(:has_groups, has_groups) |> assign(:groups, groups) ~H"""
{render_slot(@empty)}
{group_id}
{entry[:label]} {render_slot(@item, %{item: entry, value: entry_value(entry), label: entry[:label]})}
{entry[:label]} {render_slot(@item, %{item: entry, value: entry_value(entry), label: entry[:label]})}
{render_slot(@empty)}
{group_id}
{entry[:label]} {render_slot(@item, %{item: entry, value: entry_value(entry), label: entry[:label]})}
{entry[:label]} {render_slot(@item, %{item: entry, value: entry_value(entry), label: entry[:label]})}
""" end defp content_attrs(id, dir, orientation, has_label) do Connect.content(%Content{id: id, dir: dir, orientation: orientation}) |> Map.put("data-layout", "list") |> then(fn attrs -> if has_label, do: Map.put(attrs, "aria-labelledby", "listbox:#{id}:label"), else: attrs end) end defp item_attrs(id, entry, dir, orientation) do base = Connect.item(%Item{ id: id, item: entry, value: entry_value(entry), dir: dir, orientation: orientation, to: Map.get(entry, :to), redirect: Map.get(entry, :redirect), new_tab: Map.get(entry, :new_tab, false) }) if Map.get(entry, :disabled) do base |> Map.put("data-disabled", "") |> Map.put("aria-disabled", "true") else base end end defp item_attrs_template(id, entry, dir, orientation) do base = Connect.item_template(%Item{ id: id, item: entry, value: entry_value(entry), dir: dir, orientation: orientation, to: Map.get(entry, :to), redirect: Map.get(entry, :redirect), new_tab: Map.get(entry, :new_tab, false) }) if Map.get(entry, :disabled) do base |> Map.put("data-disabled", "") |> Map.put("aria-disabled", "true") else base end end @doc type: :api @doc """ Sets listbox selection from the client. Dispatches `corex:listbox:set-value` on the hook root. """ def set_value(listbox_id, value) when is_binary(listbox_id) do JS.dispatch("corex:listbox:set-value", to: "##{listbox_id}", detail: %{value: validate_value!(List.wrap(value))}, bubbles: false ) end @doc type: :api @doc """ Sets listbox selection from the server via `push_event` (`listbox_set_value`). """ def set_value(socket, listbox_id, value) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(listbox_id) do LiveView.push_event(socket, "listbox_set_value", %{ id: listbox_id, value: validate_value!(List.wrap(value)) }) end @doc type: :api @doc """ Requests the listbox's current selected values from the client. See `value/2` (socket arity) for `:respond_to`. """ def value(listbox_id) when is_binary(listbox_id), do: value(listbox_id, []) def value(listbox_id, opts) when is_binary(listbox_id) and is_list(opts) do JS.dispatch("corex:listbox:value", to: "##{listbox_id}", detail: respond_to_fields(opts), bubbles: false ) end @doc type: :api @doc """ Requests the listbox's current selected values from the client via `push_event` (`listbox_value`). The hook responds with `listbox_value_response` and/or dispatches `listbox-value` depending on `:respond_to`. """ def value(socket, listbox_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(listbox_id) do value(socket, listbox_id, []) end def value(socket, listbox_id, opts) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(listbox_id) and is_list(opts) do LiveView.push_event( socket, "listbox_value", Map.merge(%{id: listbox_id}, respond_to_fields(opts)) ) end end