A scrollable list widget that supports single or multiple item selection with checkbox-style indicators.
In :multiple mode each item renders a [X] checkbox. In :single mode items render
as (●) radio indicators. The :on_change callback receives a list of currently selected
IDs after every selection change.
Component tag
Tag :selection_list, built by Drafter.App as {:selection_list, options, opts}:
selection_list(options, opts)The positional options list is used when non-empty, falling back to
opts[:options]. from_component_opts/2 wraps :on_change and
:on_item_toggle with Drafter.Widget.Callback, so both may be given as atom
event names. :visible_height defaults to the rect the parent allocated.
Options
:options- list of options in any of these formats. Default[]. Anything else raisesFunctionClauseErrorfrommount/1."label"— string used as both ID and label{"label", id}— tuple with a display label and an identifier%{id: id, label: label}— map with explicit fields
:selected- list of IDs that are initially selected. Default[]. Read bymount/1only; an ID that matches no option is ignored.:selection_mode-:multiple | :single. Default:multiple. Any other value behaves like:singlewhen toggling and like:multiplewhen drawing.:on_change- atom event name or([id] -> term())called with the full list of selected IDs after every change. Defaultnil. An exception it raises is swallowed.:on_item_toggle- atom event name or((index, selected?) -> term())called with the zero-based row index and its new boolean state each time one item is toggled. Defaultnil. Not called by the select-all binding. An exception it raises is swallowed.:visible_height-non_neg_integer/0rows the scroll logic assumes. Default: the number of options when mounting directly, and the height ofopts[:__rect__]through the element.render/2uses the rect it is given instead.:focused-boolean/0read bymount/1. Defaultfalse.
Through the component tree update_props_from_mount/3 narrows a re-render to
:on_change, :on_item_toggle, :selection_mode and :classes, so
:options, :selected and :visible_height are mount-only.
Key bindings
up/down— move the cursor, clamped at the ends, scrolling to keep it inside:visible_heighthome/end— jump to the first or last itemspace/enter— toggle selection of the highlighted itemctrl+a— in:multiplemode, select every item, or clear the selection when everything is already selected- mouse up — move the cursor to the clicked row and toggle it
Widget value
Drafter.get_widget_value/1 returns the list of selected option IDs, in option
order.
Usage
selection_list(
options: [{"Elixir", :ex}, {"Erlang", :erl}, {"Gleam", :gleam}],
selected: [:ex],
selection_mode: :multiple,
on_change: fn ids -> IO.inspect(ids) end
)
Summary
Functions
The component tag this widget registers under.
Builds the props map for a {:selection_list, options, opts} element.
Handles the list's own events, replacing the dispatch use Drafter.Widget would
otherwise generate.
Builds the widget state from props.
The number of rows the element asks for.
Draws the visible slice of the list into rect, always returning exactly
rect.height strips.
Callback implementation for Drafter.Widget.unmount/1.
Merges props into state verbatim.
Narrows a re-render to :on_change, :on_item_toggle, :selection_mode and
:classes.
Types
@type t() :: %Drafter.Widget.SelectionList{ focused: boolean(), highlighted_index: non_neg_integer(), on_change: ([term()] -> term()) | nil, on_item_toggle: (non_neg_integer(), boolean() -> term()) | nil, options: [option()], scroll_offset: non_neg_integer(), selected_indices: MapSet.t(non_neg_integer()), selection_mode: :multiple | :single, visible_height: non_neg_integer() }
Functions
@spec component_tag() :: :selection_list
The component tag this widget registers under.
iex> Drafter.Widget.SelectionList.component_tag()
:selection_list
@spec from_component_opts( list() | nil, keyword() ) :: Drafter.Widget.props()
Builds the props map for a {:selection_list, options, opts} element.
options is used when it is a non-empty list, otherwise opts[:options],
defaulting to []. :on_change and :on_item_toggle go through
Drafter.Widget.Callback.wrap_1/1 and wrap_2/1, so an atom becomes a closure
that dispatches an app event. :visible_height falls back to the height of
opts[:__rect__], itself defaulting to %{width: 40, height: 10}. The emitted
:classes key is not read by mount/1.
iex> props = Drafter.Widget.SelectionList.from_component_opts([{"a", :a}], selected: [:a])
iex> {props.options, props.selected, props.selection_mode, props.visible_height}
{[{"a", :a}], [:a], :multiple, 10}
iex> props = Drafter.Widget.SelectionList.from_component_opts(["a"], on_change: :picked)
iex> is_function(props.on_change, 1)
true
Handles the list's own events, replacing the dispatch use Drafter.Widget would
otherwise generate.
Recognised events, each returning {:ok, new_state}:
{:key, :up}/{:key, :down}- move:highlighted_index, clamped at both ends, adjusting:scroll_offsetto keep it within:visible_height{:key, :home}- first item, scrolled to the top{:key, :end}- last item{:key, :enter}/{:key, :" "}- toggle the highlighted item{:char, 1}- in:multiplemode only, select every item or clear the selection when everything is already selected{:mouse, %{type: :mouse_up, y: y}}- toggle the row aty, or{:noreply, state}when it falls outside the list{:focus}/{:blur}- set or clear:focused
Every other event returns {:noreply, state}. Toggling calls :on_change with
the full list of selected IDs and :on_item_toggle with the index and its new
state; the select-all binding calls :on_change only.
iex> state = Drafter.Widget.SelectionList.mount(%{options: ["a", "b"]})
iex> {:ok, toggled} = Drafter.Widget.SelectionList.handle_event({:key, :enter}, state)
iex> MapSet.to_list(toggled.selected_indices)
[0]
iex> state = Drafter.Widget.SelectionList.mount(%{options: ["a", "b"]})
iex> {:ok, moved} = Drafter.Widget.SelectionList.handle_event({:key, :down}, state)
iex> {:ok, back} = Drafter.Widget.SelectionList.handle_event({:key, :up}, moved)
iex> {moved.highlighted_index, back.highlighted_index}
{1, 0}
iex> state = Drafter.Widget.SelectionList.mount(%{options: ["a", "b", "c"]})
iex> {:ok, all} = Drafter.Widget.SelectionList.handle_event({:char, 1}, state)
iex> {:ok, none} = Drafter.Widget.SelectionList.handle_event({:char, 1}, all)
iex> {MapSet.to_list(all.selected_indices), MapSet.to_list(none.selected_indices)}
{[0, 1, 2], []}
@spec mount(Drafter.Widget.props()) :: t()
Builds the widget state from props.
Options are normalised into %{id: id, label: label} maps and :selected is
turned into the matching set of indices. :highlighted_index and
:scroll_offset always start at 0.
iex> state = Drafter.Widget.SelectionList.mount(%{options: ["a", "b"]})
iex> {state.options, MapSet.to_list(state.selected_indices), state.selection_mode}
{[%{id: "a", label: "a"}, %{id: "b", label: "b"}], [], :multiple}
iex> options = [{"Elixir", :ex}, {"Erlang", :erl}]
iex> state = Drafter.Widget.SelectionList.mount(%{options: options, selected: [:erl]})
iex> MapSet.to_list(state.selected_indices)
[1]
iex> state = Drafter.Widget.SelectionList.mount(%{options: ["a", "b"], selected: [:missing]})
iex> {MapSet.to_list(state.selected_indices), state.visible_height}
{[], 2}
@spec preferred_height( list() | nil, keyword() ) :: non_neg_integer()
The number of rows the element asks for.
Returns opts[:height] when given, otherwise the length of the positional
options list capped at 5 — which is 0 when the options were passed under
opts[:options] instead.
iex> Drafter.Widget.SelectionList.preferred_height(["a", "b"], [])
2
iex> Drafter.Widget.SelectionList.preferred_height(Enum.to_list(1..20), [])
5
iex> Drafter.Widget.SelectionList.preferred_height(nil, options: ["a"])
0
@spec render(t(), Drafter.Widget.rect()) :: [Drafter.Draw.Strip.t()]
Draws the visible slice of the list into rect, always returning exactly
rect.height strips.
Rows start at :scroll_offset and run for min(rect.height, option_count)
rows; the rest of the rect is blank. :single mode draws (●) and ( ), every
other mode draws [X] and [ ]. The highlight is only drawn while the widget is
focused.
Callback implementation for Drafter.Widget.unmount/1.
@spec update(Drafter.Widget.props(), t()) :: t()
Merges props into state verbatim.
Every key in props lands on the state as given, including keys the struct does
not declare, and :options is stored without being normalised — so a re-render
that goes through this must already supply %{id: _, label: _} maps.
:selection_mode and :on_item_toggle keep their current value when absent.
iex> state = Drafter.Widget.SelectionList.mount(%{options: ["a", "b"]})
iex> Drafter.Widget.SelectionList.update(%{selection_mode: :single}, state).selection_mode
:single
iex> state = Drafter.Widget.SelectionList.mount(%{options: ["a", "b"]})
iex> Drafter.Widget.SelectionList.update(%{highlighted_index: 1}, state).highlighted_index
1
@spec update_props_from_mount(Drafter.Widget.props(), term(), keyword()) :: Drafter.Widget.props()
Narrows a re-render to :on_change, :on_item_toggle, :selection_mode and
:classes.
:options, :selected and :visible_height are dropped, so they are
mount-only through the component tree and the user's selection survives a
re-render.
iex> props = Drafter.Widget.SelectionList.from_component_opts(["a"], [])
iex> Drafter.Widget.SelectionList.update_props_from_mount(props, %{}, []) |> Map.keys() |> Enum.sort()
[:classes, :on_change, :on_item_toggle, :selection_mode]