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 %>
<%= if @item_indicator != [] do %>
<%= render_slot(@item_indicator) %>
<% end %>
{entry[:label]}
<%= for item_slot <- @item || [] do %>
<%= render_slot(item_slot, %{item: entry, value: entry_value(entry), label: entry[:label]}) %>
<% end %>
<%= if @item_indicator != [] do %>
<%= render_slot(@item_indicator) %>
<% 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