Drafter.Widget.OptionList (drafter v0.3.1)

Copy Markdown View Source

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 - 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 - non_neg_integer/0 minimum milliseconds between wheel steps. Default 150. The first wheel event is never throttled.
  • :inverted_scroll - 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 - 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
)

Summary

Functions

The component tag this widget registers under.

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

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

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

Moves the highlight or confirms the highlighted option.

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

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

Moves the highlight one enabled option per wheel step.

Builds the widget state from props.

Builds a single option map for the :options list.

The number of rows the element asks for.

Draws the visible slice of the list into rect.

Callback implementation for Drafter.Widget.unmount/1.

Merges props into state verbatim.

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.

Types

option()

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

t()

@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() -> term()) | nil,
  on_select: (option() -> term()) | nil,
  options: [option()],
  scroll_offset: integer(),
  scroll_throttle_ms: integer(),
  selected_index: integer() | nil,
  trigger: :press | :mouse_up,
  visible_height: integer()
}

Functions

component_tag()

@spec component_tag() :: :option_list

The component tag this widget registers under.

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

focused(state)

from_component_opts(items, opts)

@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(arg1, state)

@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(event, state)

@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(arg1, state)

@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(x, y, state)

@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(x, y, state)

@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(direction, state)

@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(props)

@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(id, label, disabled \\ false)

@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(args, opts)

@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(state, rect)

@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(state)

Callback implementation for Drafter.Widget.unmount/1.

update(props, state)

@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(mount_props, existing_state, opts)

@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