defmodule Corex.Listbox do @moduledoc ~S''' Phoenix implementation of [Zag.js Listbox](https://zagjs.com/components/react/listbox). ## Examples ### Minimal ```heex <.listbox id="my-listbox" class="listbox" collection={[ %{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> <.icon name="hero-check" /> ``` ### Grouped ```heex <.listbox class="listbox" collection={[ %{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> <.icon 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" collection={[ %{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> <.icon name="hero-check" /> ``` ### Custom Grouped ```heex <.listbox class="listbox" collection={[ %{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> <.icon name="hero-check" /> ``` ## 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" collection={[]}> ``` Learn more about modifiers and [Corex Design](https://corex-ui.com/components/listbox#modifiers) ''' @doc type: :component use Phoenix.Component alias Corex.Listbox.Anatomy.{ Props, Root, Label, ValueText, Input, Content, ItemGroup, ItemGroupLabel, Item, ItemText, ItemIndicator } alias Corex.Listbox.Connect import Corex.Helpers, only: [validate_value!: 1] attr(:id, :string, required: false) attr(:collection, :list, required: true, doc: "List of Corex.List.Item or maps with id/value, label, disabled, group" ) attr(:value, :list, default: [], doc: "Selected value(s)") attr(:controlled, :boolean, default: false) attr(:disabled, :boolean, default: false) attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"]) attr(:orientation, :string, default: "vertical", values: ["horizontal", "vertical"]) attr(:loop_focus, :boolean, default: false) attr(:selection_mode, :string, default: "single", values: ["single", "multiple", "extended"]) attr(:select_on_highlight, :boolean, default: false) attr(:deselectable, :boolean, default: false) attr(:typeahead, :boolean, default: false) attr(:on_value_change, :string, default: nil) attr(:on_value_change_client, :string, default: nil) attr(:aria_label, :string, default: nil, doc: "Accessible name when no label slot is provided") attr(:rest, :global) slot(:label, required: false) slot(:item, required: false) slot(:item_indicator, required: false) def listbox(assigns) do items = normalize_collection(assigns.collection) has_groups = Enum.any?(items, &Map.get(&1, :group)) groups = if has_groups, do: items |> Enum.map(& &1.group) |> Enum.uniq() |> Enum.reject(&is_nil/1), else: [] assigns = assigns |> assign_new(:id, fn -> "listbox-#{System.unique_integer([:positive])}" end) |> assign_new(:dir, fn -> "ltr" end) |> assign(:value, validate_value!(assigns[:value] || [])) |> assign(:collection, items) |> assign(:items, items) |> assign(:has_groups, has_groups) |> assign(:groups, groups) ~H"""
{group_id}
{entry[:label]} <%= for item_slot <- @item || [] do %> <%= render_slot(item_slot, %{item: entry, value: entry_value(entry), label: entry[:label]}) %> <% end %>
{entry[:label]} <%= for item_slot <- @item || [] do %> <%= render_slot(item_slot, %{item: entry, value: entry_value(entry), label: entry[:label]}) %> <% end %>
""" end defp entry_value(entry) do to_string( Map.get(entry, :value) || Map.get(entry, :id) || Map.get(entry, "value") || Map.get(entry, "id") || "" ) end defp entry_selected?(entry, value_list) do Enum.member?(value_list, entry_value(entry)) end defp content_attrs(id, dir, orientation, has_label) do Connect.content(%Content{id: id, dir: dir}) |> Map.put("data-layout", "list") |> Map.put("data-orientation", orientation) |> then(fn attrs -> if has_label, do: Map.put(attrs, "aria-labelledby", "select:#{id}:label"), else: attrs end) end defp item_attrs(id, entry) do base = Connect.item(%Item{id: id, item: entry, value: entry_value(entry)}) if Map.get(entry, :disabled) do base |> Map.put("data-disabled", "") |> Map.put("aria-disabled", "true") else base end end defp normalize_collection(items) when is_list(items) do Enum.map(items, fn %Corex.List.Item{} = item -> %{ id: item.id, value: item.id, label: item.label, disabled: item.disabled, group: item.group } m when is_map(m) -> %{ id: Map.get(m, :id), value: Map.get(m, :value) || Map.get(m, :id), label: Map.get(m, :label), disabled: !!Map.get(m, :disabled), group: Map.get(m, :group) } end) end end