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

A scrollable, single-selection list widget with keyboard, mouse, and scroll wheel navigation.

Each option is rendered as a row, and while the widget is focused the highlighted
row carries a `▶` prefix. An unfocused list draws no prefix, so several lists can
sit side by side with only the focused one showing a cursor.
Disabled options are skipped during keyboard navigation. Mouse wheel scrolling is
throttle-limited via `:scroll_throttle_ms` to prevent excessively fast navigation.

An option is a map with `:id`, `:label`, `:selected` and `:disabled` keys.
`option/3` builds one; `mount/1` takes options in that form only.

## Component tag

Tag `:option_list`, built by `Drafter.App` as `{:option_list, items, opts}`:

    option_list(items, opts)

The positional `items` list supplies the options, falling back to `opts[:options]`
when it is empty. `from_component_opts/2` normalises each entry, so through the
element an option may be given as a `{label, id}` tuple, a plain string used as
both id and label, or a map containing `:id`. `:visible_height` defaults to the
height of the rect the parent allocated.

## Options

  * `:options` - list of option maps, used when no positional `items` are given.
    Default `[]`. `mount/1` needs each entry to carry `:disabled` already, so
    build them with `option/3`; `from_component_opts/2` normalises looser forms.
  * `:selected` - id of the option to mark selected and highlight initially.
    Default `nil`, which highlights index `0`. Read by `from_component_opts/2`
    only — `mount/1` ignores both it and the `:highlighted_index` that function
    computes, and highlights the first enabled option instead.
  * `:visible_height` - `t:pos_integer/0` rows the scroll logic works with.
    Default `10` when mounting directly, and the height of `opts[:__rect__]`
    through the element, itself defaulting to `%{width: 40, height: 10}`.
  * `:expand_height` - `:content | :fill | pos_integer()`. Default `:content`,
    which draws `min(option_count, visible_height)` rows; `:fill` draws
    `rect.height` rows and an integer draws exactly that many.
  * `:on_select` - atom event name or `(option -> term())` called when an option
    is confirmed with `enter`, `space` or the mouse. Default `nil`. An atom is
    wrapped so that the app event carries the option's `:id`, not the whole map.
  * `:on_highlight` - atom event name or `(option -> term())` called when the
    highlighted option changes, wrapped the same way. Default `nil`.
  * `:scroll_throttle_ms` - `t:non_neg_integer/0` minimum milliseconds between
    wheel steps. Default `150`. The first wheel event is never throttled.
  * `:inverted_scroll` - `t:boolean/0`; when `true`, scrolling up moves the
    highlight down and vice versa. Default `false`.
  * `:trigger` - `:press | :mouse_up`, the mouse event that confirms a
    selection. Default `:press`. The other event bubbles.
  * `:focused` - `t:boolean/0` read by `mount/1`. Default `false`. Only a focused
    list draws the `▶` prefix; `{:focus}` and `{:blur}` maintain it, and
    `update_props_from_mount/3` leaves it alone so focus survives a re-render.

`update/2` merges `props` into the state verbatim, so any key at all can be set
through it. Through the component tree `update_props_from_mount/3` narrows a
re-render to `:options`, `:visible_height`, `:on_select`, `:on_highlight`,
`:trigger` and `:classes`, adding `:highlighted_index` only when the selected
option's id actually changed — so `:expand_height`, `:scroll_throttle_ms` and
`:inverted_scroll` are mount-only.

## Key bindings

  * `up` / `down` — move the highlight to the next enabled option, bubbling when
    there is none
  * `home` / `end` — jump to the first or last enabled option
  * `page_up` / `page_down` — jump by `:visible_height` rows
  * `enter` / `space` — confirm the highlighted option and call `:on_select`
  * mouse press or release, per `:trigger` — confirm the option on that row
  * mouse wheel — move the highlight one enabled option up or down

A change that fires `:on_highlight` or `:on_select` returns
`{:ok, state, actions}`, where each action is the value that callback returned.

## Widget value

This widget's state has both `:selected_index` and `:options`, so
`Drafter.get_widget_value/1` returns the id of the selected option. `mount/1`
leaves `:selected_index` as `nil` until something is confirmed, and that raises
`FunctionClauseError`; read `Drafter.get_widget_state/1` and its
`:highlighted_index` when a selection is not guaranteed.

## Usage

    alias Drafter.Widget.OptionList

    option_list(
      options: [
        OptionList.option("one", "Option One"),
        OptionList.option("two", "Option Two"),
        OptionList.option("three", "Option Three", true)
      ],
      on_select: fn opt -> IO.inspect(opt.id) end
    )

# `option`

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

# `t`

```elixir
@type t() :: %Drafter.Widget.OptionList{
  expand_height: Drafter.Widget.expand_option(),
  focused: boolean(),
  highlighted_index: integer() | nil,
  inverted_scroll: boolean(),
  last_scroll_time: integer(),
  on_highlight: (option() -&gt; term()) | nil,
  on_select: (option() -&gt; term()) | nil,
  options: [option()],
  scroll_offset: integer(),
  scroll_throttle_ms: integer(),
  selected_index: integer() | nil,
  trigger: :press | :mouse_up,
  visible_height: integer()
}
```

# `component_tag`

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

The component tag this widget registers under.

    iex> Drafter.Widget.OptionList.component_tag()
    :option_list

# `focused`

# `from_component_opts`

```elixir
@spec from_component_opts(
  list() | nil,
  keyword()
) :: Drafter.Widget.props()
```

Builds the props map for an `{:option_list, items, opts}` element.

`items` is used when it is a non-empty list, otherwise `opts[:options]`,
defaulting to `[]`. Each entry is normalised: a `{label, id}` tuple, a plain
string used as both id and label, or a map already carrying `:id`, which keeps
its own `:selected` and `:disabled` when it has them. The option whose id matches
`:selected` is marked selected.

`:on_select` and `:on_highlight` given as atoms are wrapped so the app event
carries the option's `:id`; given as functions they are passed through and
receive the whole option map. The emitted `:highlighted_index` and `:classes`
keys are not read by `mount/1`.

    iex> props = Drafter.Widget.OptionList.from_component_opts([{"A", "a"}, "b"], selected: "b")
    iex> props.options
    [%{id: "a", label: "A", selected: false, disabled: false}, %{id: "b", label: "b", selected: true, disabled: false}]

    iex> props = Drafter.Widget.OptionList.from_component_opts(["a", "b"], selected: "b")
    iex> {props.highlighted_index, props.visible_height, props.trigger}
    {1, 10, :press}

# `handle_custom_event`

```elixir
@spec handle_custom_event(term(), t()) ::
  {:ok, t()} | {:ok, t(), list()} | {:bubble, t()} | {:noreply, t()}
```

Confirms the highlighted option on `:activate`, exactly as `enter` does, and
returns `{:bubble, state}` for every other custom event.

    iex> options = [Drafter.Widget.OptionList.option("a", "A")]
    iex> state = Drafter.Widget.OptionList.mount(%{options: options})
    iex> {:ok, chosen} = Drafter.Widget.OptionList.handle_custom_event(:activate, state)
    iex> chosen.selected_index
    0

    iex> state = Drafter.Widget.OptionList.mount(%{})
    iex> Drafter.Widget.OptionList.handle_custom_event(:something, state) == {:bubble, state}
    true

# `handle_event`

```elixir
@spec handle_event(term(), t()) :: term()
```

Routes an event, taking `{:focus}` itself and delegating everything else to the
dispatch `use Drafter.Widget` generated.

`{:focus}` moves the highlight onto the first enabled option when there is none
yet, and sets `:focused` on the state. `{:blur}` reaches the generated dispatch,
which clears the same field.

# `handle_key`

```elixir
@spec handle_key(atom(), t()) ::
  {:ok, t()} | {:ok, t(), list()} | {:bubble, t()} | {:noreply, t()}
```

Moves the highlight or confirms the highlighted option.

Handles `:up`, `:down`, `:home`, `:end`, `:page_up`, `:page_down`, `:enter` and
`:" "`; every other key returns `{:bubble, state}`. `:up` and `:down` bubble when
there is no further enabled option, while `:home` and `:end` return
`{:noreply, state}` when the list has none at all. A move that fires a callback
returns `{:ok, state, actions}`.

    iex> options = [Drafter.Widget.OptionList.option("a", "A"), Drafter.Widget.OptionList.option("b", "B")]
    iex> state = Drafter.Widget.OptionList.mount(%{options: options})
    iex> {:ok, moved} = Drafter.Widget.OptionList.handle_key(:down, state)
    iex> {moved.highlighted_index, moved.selected_index}
    {1, nil}

    iex> options = [Drafter.Widget.OptionList.option("a", "A")]
    iex> state = Drafter.Widget.OptionList.mount(%{options: options})
    iex> Drafter.Widget.OptionList.handle_key(:up, state) == {:bubble, state}
    true

    iex> options = [Drafter.Widget.OptionList.option("a", "A")]
    iex> state = Drafter.Widget.OptionList.mount(%{options: options})
    iex> {:ok, chosen} = Drafter.Widget.OptionList.handle_key(:enter, state)
    iex> chosen.selected_index
    0

    iex> state = Drafter.Widget.OptionList.mount(%{})
    iex> Drafter.Widget.OptionList.handle_key(:x, state) == {:bubble, state}
    true

# `handle_mouse_up`

```elixir
@spec handle_mouse_up(integer(), integer(), t()) ::
  {:ok, t()} | {:ok, t(), list()} | {:bubble, t()} | {:noreply, t()}
```

Confirms the option on row `y` when `:trigger` is `:mouse_up`, and returns
`{:bubble, state}` otherwise. Otherwise identical to `handle_press/3`.

    iex> options = [Drafter.Widget.OptionList.option("a", "A")]
    iex> state = Drafter.Widget.OptionList.mount(%{options: options, trigger: :mouse_up})
    iex> {:ok, clicked} = Drafter.Widget.OptionList.handle_mouse_up(0, 0, state)
    iex> clicked.selected_index
    0

    iex> options = [Drafter.Widget.OptionList.option("a", "A")]
    iex> state = Drafter.Widget.OptionList.mount(%{options: options})
    iex> Drafter.Widget.OptionList.handle_mouse_up(0, 0, state) == {:bubble, state}
    true

# `handle_press`

```elixir
@spec handle_press(integer(), integer(), t()) ::
  {:ok, t()} | {:ok, t(), list()} | {:bubble, t()} | {:noreply, t()}
```

Confirms the option on row `y` when `:trigger` is `:press`, and returns
`{:bubble, state}` otherwise.

`y` is relative to the widget's rect and is offset by `:scroll_offset` to find
the option. A row outside the list, or one holding a disabled option, returns
`{:noreply, state}`. `x` is ignored.

    iex> options = [Drafter.Widget.OptionList.option("a", "A"), Drafter.Widget.OptionList.option("b", "B")]
    iex> state = Drafter.Widget.OptionList.mount(%{options: options})
    iex> {:ok, clicked} = Drafter.Widget.OptionList.handle_press(0, 1, state)
    iex> {clicked.highlighted_index, clicked.selected_index}
    {1, 1}

    iex> options = [Drafter.Widget.OptionList.option("a", "A")]
    iex> state = Drafter.Widget.OptionList.mount(%{options: options, trigger: :mouse_up})
    iex> Drafter.Widget.OptionList.handle_press(0, 0, state) == {:bubble, state}
    true

# `handle_scroll`

```elixir
@spec handle_scroll(:up | :down, t()) ::
  {:ok, t()} | {:ok, t(), list()} | {:bubble, t()} | {:noreply, t()}
```

Moves the highlight one enabled option per wheel step.

`direction` is `:up` or `:down`, swapped when `:inverted_scroll` is set. Steps
arriving less than `:scroll_throttle_ms` after the previous one return
`{:noreply, state}` with only the timestamp updated; the first step after mount
is never throttled. `:last_scroll_time` is stamped from
`System.system_time(:millisecond)`.

# `mount`

```elixir
@spec mount(Drafter.Widget.props()) :: t()
```

Builds the widget state from `props`.

Every option listed in the module doc is read here with the default stated there,
except `:selected` and `:highlighted_index`, which are ignored: the highlight
always starts on the first option whose `:disabled` is falsy, and is `nil` for an
empty or fully disabled list. `:selected_index`, `:scroll_offset` and
`:last_scroll_time` all start at their zero values.

    iex> options = [Drafter.Widget.OptionList.option("a", "A")]
    iex> state = Drafter.Widget.OptionList.mount(%{options: options})
    iex> {state.highlighted_index, state.selected_index, state.visible_height}
    {0, nil, 10}

    iex> options = [Drafter.Widget.OptionList.option("a", "A", true), Drafter.Widget.OptionList.option("b", "B")]
    iex> Drafter.Widget.OptionList.mount(%{options: options}).highlighted_index
    1

    iex> state = Drafter.Widget.OptionList.mount(%{})
    iex> {state.highlighted_index, state.expand_height, state.trigger, state.scroll_throttle_ms}
    {nil, :content, :press, 150}

    iex> Drafter.Widget.OptionList.mount(%{focused: true}).focused
    true

# `option`

```elixir
@spec option(String.t(), String.t(), boolean()) :: option()
```

Builds a single option map for the `:options` list.

`id` is the value handed to `:on_select` and `:on_highlight`; `label` is the
text drawn in the row. Both must be binaries. A `disabled` option is drawn but
skipped by keyboard navigation and cannot be selected.

Returns a map with `:id`, `:label`, `:disabled` and `:selected`, where
`:selected` starts as `false`.

    iex> Drafter.Widget.OptionList.option("two", "Option Two")
    %{id: "two", label: "Option Two", disabled: false, selected: false}

    iex> Drafter.Widget.OptionList.option("three", "Option Three", true)
    %{id: "three", label: "Option Three", disabled: true, selected: false}

# `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 items
list — which is `0` when the options were passed under `opts[:options]` instead.

    iex> Drafter.Widget.OptionList.preferred_height(["a", "b"], [])
    2

    iex> Drafter.Widget.OptionList.preferred_height(nil, options: ["a"])
    0

    iex> Drafter.Widget.OptionList.preferred_height(nil, height: 6)
    6

# `render`

```elixir
@spec render(t(), Drafter.Widget.rect()) :: [Drafter.Draw.Strip.t()]
```

Draws the visible slice of the list into `rect`.

The number of strips comes from `:expand_height`, not from `rect.height`:
`:content` gives `min(option_count, visible_height)`, `:fill` gives
`rect.height`, and an integer gives itself. Rows start at `:scroll_offset`, each
is truncated and padded to `rect.width`, and the highlighted row is prefixed with
`▶` when `:focused` is true.

# `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` entries that have not been normalised.

    iex> options = [Drafter.Widget.OptionList.option("a", "A")]
    iex> state = Drafter.Widget.OptionList.mount(%{options: options})
    iex> Drafter.Widget.OptionList.update(%{visible_height: 4}, state).visible_height
    4

# `update_props_from_mount`

```elixir
@spec update_props_from_mount(Drafter.Widget.props(), t(), keyword()) ::
  Drafter.Widget.props()
```

Narrows a re-render to `:options`, `:visible_height`, `:on_select`,
`:on_highlight`, `:trigger` and `:classes`, adding `:highlighted_index` only when
the id of the selected option changed.

`:expand_height`, `:scroll_throttle_ms` and `:inverted_scroll` are dropped, so
they are mount-only through the component tree, and the highlight the user moved
survives a re-render that does not change the selection.

    iex> props = Drafter.Widget.OptionList.from_component_opts(["a", "b"], [])
    iex> state = Drafter.Widget.OptionList.mount(props)
    iex> Drafter.Widget.OptionList.update_props_from_mount(props, state, []) |> Map.keys() |> Enum.sort()
    [:classes, :on_highlight, :on_select, :options, :trigger, :visible_height]

    iex> props = Drafter.Widget.OptionList.from_component_opts(["a", "b"], selected: "b")
    iex> state = Drafter.Widget.OptionList.mount(Drafter.Widget.OptionList.from_component_opts(["a", "b"], []))
    iex> Drafter.Widget.OptionList.update_props_from_mount(props, state, []).highlighted_index
    1

---

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