# `Drafter.Widget.SelectionList`
[🔗](https://github.com/jaman/drafter/blob/main/lib/drafter/widget/selection_list.ex#L1)

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 raises `FunctionClauseError` from `mount/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 by
    `mount/1` only; an ID that matches no option is ignored.
  * `:selection_mode` - `:multiple | :single`. Default `:multiple`. Any other
    value behaves like `:single` when toggling and like `:multiple` when
    drawing.
  * `:on_change` - atom event name or `([id] -> term())` called with the full
    list of selected IDs after every change. Default `nil`. 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. Default `nil`. Not called by the select-all binding. An
    exception it raises is swallowed.
  * `:visible_height` - `t:non_neg_integer/0` rows the scroll logic assumes.
    Default: the number of options when mounting directly, and the height of
    `opts[:__rect__]` through the element. `render/2` uses the rect it is given
    instead.
  * `:focused` - `t:boolean/0` read by `mount/1`. Default `false`.

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_height`
  * `home` / `end` — jump to the first or last item
  * `space` / `enter` — toggle selection of the highlighted item
  * `ctrl+a` — in `:multiple` mode, 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
    )

# `option`

```elixir
@type option() :: %{id: term(), label: String.t()}
```

# `t`

```elixir
@type t() :: %Drafter.Widget.SelectionList{
  focused: boolean(),
  highlighted_index: non_neg_integer(),
  on_change: ([term()] -&gt; term()) | nil,
  on_item_toggle: (non_neg_integer(), boolean() -&gt; 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()
}
```

# `component_tag`

```elixir
@spec component_tag() :: :selection_list
```

The component tag this widget registers under.

    iex> Drafter.Widget.SelectionList.component_tag()
    :selection_list

# `focused`

# `from_component_opts`

```elixir
@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

# `handle_event`

```elixir
@spec handle_event(term(), t()) :: {:ok, t()} | {:noreply, t()}
```

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_offset` to 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 `:multiple` mode only, select every item or clear the
    selection when everything is already selected
  * `{:mouse, %{type: :mouse_up, y: y}}` - toggle the row at `y`, 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], []}

# `mount`

```elixir
@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}

# `preferred_height`

```elixir
@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

# `render`

```elixir
@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.

# `unmount`

# `update`

```elixir
@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

# `update_props_from_mount`

```elixir
@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]

---

*Consult [api-reference.md](api-reference.md) for complete listing*
