Packages
corex
0.1.0-beta.2
0.1.2
0.1.1
0.1.0
0.1.0-rc.1
0.1.0-rc.0
0.1.0-beta.5
0.1.0-beta.4
0.1.0-beta.3
0.1.0-beta.2
0.1.0-beta.1
0.1.0-alpha.33
0.1.0-alpha.32
0.1.0-alpha.31
0.1.0-alpha.30
0.1.0-alpha.29
0.1.0-alpha.28
0.1.0-alpha.27
0.1.0-alpha.26
0.1.0-alpha.25
0.1.0-alpha.24
0.1.0-alpha.23
0.1.0-alpha.22
0.1.0-alpha.21
0.1.0-alpha.20
0.1.0-alpha.19
0.1.0-alpha.18
0.1.0-alpha.17
0.1.0-alpha.16
0.1.0-alpha.15
0.1.0-alpha.14
0.1.0-alpha.13
0.1.0-alpha.12
0.1.0-alpha.11
0.1.0-alpha.10
0.1.0-alpha.9
0.1.0-alpha.8
0.1.0-alpha.7
0.1.0-alpha.6
0.1.0-alpha.5
0.1.0-alpha.4
0.1.0-alpha.3
0.1.0-alpha.2
0.1.0-alpha.1
Accessible and unstyled UI components library written in Elixir and TypeScript that integrates Zag.js state machines into the Phoenix Framework.
Current section
Files
Jump to
Current section
Files
lib/components/listbox.ex
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
<!-- tabs-open -->
### 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</:label>
<:item_indicator>
<.heroicon name="hero-check" />
</:item_indicator>
</.listbox>
```
### 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</:label>
<:item_indicator>
<.heroicon name="hero-check" />
</:item_indicator>
</.listbox>
```
### 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
</:label>
<:item :let={%{item: entry}}>
<Flagpack.flag name={String.to_atom(entry.id)} />
{entry.label}
</:item>
<:item_indicator>
<.heroicon name="hero-check" />
</:item_indicator>
</.listbox>
```
### 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}}>
<Flagpack.flag name={String.to_atom(entry.id)} />
{entry.label}
</:item>
<:item_indicator>
<.heroicon name="hero-check" />
</:item_indicator>
</.listbox>
```
### 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</:label>
<:empty>No items</:empty>
<:item :let={%{item: entry}}>
<span class="flex items-center justify-between gap-2 w-full">
<span class="flex items-center gap-2">
<.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" />
</.action>
<span>{entry.label}</span>
</span>
</span>
</:item>
<:item_indicator>
<.heroicon name="hero-check" />
</:item_indicator>
</.listbox>
"""
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
```
<!-- tabs-close -->
## 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([])}>
</.listbox>
```
'''
@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"""
<div
id={@id}
phx-hook="Listbox"
data-loading
phx-mounted={Phoenix.LiveView.JS.ignore_attributes(["data-loading"])}
{@rest}
{Connect.props(%Props{
id: @id,
items: @items,
value: @value,
controlled: @controlled,
disabled: @disabled,
dir: @dir,
orientation: @orientation,
loop_focus: @loop_focus,
selection_mode: @selection_mode,
select_on_highlight: @select_on_highlight,
deselectable: @deselectable,
typeahead: @typeahead,
on_value_change: @on_value_change,
on_value_change_client: @on_value_change_client,
redirect: @redirect
})}
>
<div phx-mounted={Connect.ignore_root(%Root{id: @id, dir: @dir, orientation: @orientation})} {Connect.root(%Root{id: @id, dir: @dir, orientation: @orientation})}>
<label phx-mounted={Connect.ignore_label(%Label{id: @id, dir: @dir, orientation: @orientation})} {Connect.label(%Label{id: @id, dir: @dir, orientation: @orientation})}>
{if @label != [], do: render_slot(@label), else: @aria_label}
</label>
<div phx-mounted={Connect.ignore_content(%Content{id: @id, dir: @dir, orientation: @orientation})} {content_attrs(@id, @dir, @orientation, @label != [] || @aria_label != nil)}>
<div :if={@items == [] && @empty != []} data-scope="listbox" data-part="empty">
{render_slot(@empty)}
</div>
<div :for={group_id <- @groups} phx-mounted={Connect.ignore_item_group(%ItemGroup{id: @id, group_id: group_id, dir: @dir, orientation: @orientation})} {Connect.item_group(%ItemGroup{id: @id, group_id: group_id, dir: @dir, orientation: @orientation})}>
<div phx-mounted={Connect.ignore_item_group_label(%ItemGroupLabel{id: @id, html_for: group_id, dir: @dir, orientation: @orientation})} {Connect.item_group_label(%ItemGroupLabel{id: @id, html_for: group_id, dir: @dir, orientation: @orientation})}>{group_id}</div>
<div :for={entry <- Enum.filter(@items, &(&1.group == group_id))} phx-mounted={Connect.ignore_item(%Item{id: @id, item: entry, value: entry_value(entry), dir: @dir, orientation: @orientation})} {item_attrs(@id, entry, @dir, @orientation)}>
<span :if={@item == []} phx-mounted={Connect.ignore_item_text(%ItemText{id: @id, item: entry, dir: @dir, orientation: @orientation})} {Connect.item_text(%ItemText{id: @id, item: entry, dir: @dir, orientation: @orientation})}>{entry[:label]}</span>
{render_slot(@item, %{item: entry, value: entry_value(entry), label: entry[:label]})}
<span
phx-mounted={Connect.ignore_item_indicator(%ItemIndicator{id: @id, item: entry, dir: @dir, orientation: @orientation})}
{Connect.item_indicator(%ItemIndicator{id: @id, item: entry, dir: @dir, orientation: @orientation})}
hidden={!entry_selected?(entry, @value)}
>
{if @item_indicator != [], do: render_slot(@item_indicator), else: nil}
</span>
</div>
</div>
<div :for={entry <- if(@has_groups, do: [], else: @items)} phx-mounted={Connect.ignore_item(%Item{id: @id, item: entry, value: entry_value(entry), dir: @dir, orientation: @orientation})} {item_attrs(@id, entry, @dir, @orientation)}>
<span :if={@item == []} phx-mounted={Connect.ignore_item_text(%ItemText{id: @id, item: entry, dir: @dir, orientation: @orientation})} {Connect.item_text(%ItemText{id: @id, item: entry, dir: @dir, orientation: @orientation})}>{entry[:label]}</span>
{render_slot(@item, %{item: entry, value: entry_value(entry), label: entry[:label]})}
<span
phx-mounted={Connect.ignore_item_indicator(%ItemIndicator{id: @id, item: entry, dir: @dir, orientation: @orientation})}
{Connect.item_indicator(%ItemIndicator{id: @id, item: entry, dir: @dir, orientation: @orientation})}
hidden={!entry_selected?(entry, @value)}
>
{if @item_indicator != [], do: render_slot(@item_indicator), else: nil}
</span>
</div>
</div>
</div>
<div style="display: none;" data-templates="listbox">
<div :if={@empty != []} data-scope="listbox" data-part="empty" data-template="true">
{render_slot(@empty)}
</div>
<div :for={group_id <- @groups} phx-mounted={Connect.ignore_item_group(%ItemGroup{id: @id, group_id: group_id, dir: @dir, orientation: @orientation})} {Connect.item_group_template(%ItemGroup{id: @id, group_id: group_id, dir: @dir, orientation: @orientation})} data-template="true">
<div phx-mounted={Connect.ignore_item_group_label(%ItemGroupLabel{id: @id, html_for: group_id, dir: @dir, orientation: @orientation})} {Connect.item_group_label_template(%ItemGroupLabel{id: @id, html_for: group_id, dir: @dir, orientation: @orientation})}>{group_id}</div>
<div :for={entry <- Enum.filter(@items, &(&1.group == group_id))} phx-mounted={Connect.ignore_item(%Item{id: @id, item: entry, value: entry_value(entry), dir: @dir, orientation: @orientation})} {item_attrs_template(@id, entry, @dir, @orientation)} data-template="true">
<span :if={@item == []} phx-mounted={Connect.ignore_item_text(%ItemText{id: @id, item: entry, dir: @dir, orientation: @orientation})} {Connect.item_text_template(%ItemText{id: @id, item: entry, dir: @dir, orientation: @orientation})}>{entry[:label]}</span>
{render_slot(@item, %{item: entry, value: entry_value(entry), label: entry[:label]})}
<span
phx-mounted={Connect.ignore_item_indicator(%ItemIndicator{id: @id, item: entry, dir: @dir, orientation: @orientation})}
{Connect.item_indicator_template(%ItemIndicator{id: @id, item: entry, dir: @dir, orientation: @orientation})}
hidden={!entry_selected?(entry, @value)}
>
{if @item_indicator != [], do: render_slot(@item_indicator), else: nil}
</span>
</div>
</div>
<div :for={entry <- if(@has_groups, do: [], else: @items)} phx-mounted={Connect.ignore_item(%Item{id: @id, item: entry, value: entry_value(entry), dir: @dir, orientation: @orientation})} {item_attrs_template(@id, entry, @dir, @orientation)} data-template="true">
<span :if={@item == []} phx-mounted={Connect.ignore_item_text(%ItemText{id: @id, item: entry, dir: @dir, orientation: @orientation})} {Connect.item_text_template(%ItemText{id: @id, item: entry, dir: @dir, orientation: @orientation})}>{entry[:label]}</span>
{render_slot(@item, %{item: entry, value: entry_value(entry), label: entry[:label]})}
<span
phx-mounted={Connect.ignore_item_indicator(%ItemIndicator{id: @id, item: entry, dir: @dir, orientation: @orientation})}
{Connect.item_indicator_template(%ItemIndicator{id: @id, item: entry, dir: @dir, orientation: @orientation})}
hidden={!entry_selected?(entry, @value)}
>
{if @item_indicator != [], do: render_slot(@item_indicator), else: nil}
</span>
</div>
</div>
</div>
"""
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