Drafter.Widget.RadioSet (drafter v0.3.1)

Copy Markdown View Source

A mutually exclusive radio button group where exactly one option can be selected at a time.

Options are rendered as a vertical list of (unselected) and (selected) indicators with labels. Arrow keys move the highlighted cursor; Enter or Space confirms the selection. Mouse clicks select the clicked option immediately.

Component tag

Tag :radio_set, built by Drafter.App as {:radio_set, options, opts}:

radio_set(options, opts)

The positional options list is used when non-empty, falling back to opts[:options]. :selected and :on_change go through the binding layer, so passing bind: :some_key reads the current selection from that app-state key and writes the new one back on change. :visible_height and :width default to the rect the parent allocated, and a :width given in opts is ignored.

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 - ID of the initially selected option. Default nil. An ID that is nil or matches no option selects index 0, even when there are no options at all.
  • :bind - app-state key atom for two-way binding of the selection. Default: none.
  • :on_change - (id -> term()) called with the selected option's ID whenever the selection changes. Default nil. An exception it raises is swallowed.
  • :visible_height - non_neg_integer/0 rows allocated for the list. Default: the number of options when mounting directly, and the height of opts[:__rect__] through the element. Held on the state; render/2 uses the rect it is given instead.
  • :cols - pos_integer/0 columns the options are laid out across. Default 1. Options fill column-major. Mount-only.
  • :width - non_neg_integer/0 total width, used to resolve which column a click landed in. Default 0 when mounting directly. Through the element it always comes from the allocated rect and a :width in opts is ignored; on_rect_change/2 keeps it current.
  • :focused - boolean/0 read by mount/1. Default false.

update/2 applies :options, :selected, :on_change and :visible_height and drops every other key, so :cols, :width and :focused are mount-only. Through the component tree update_props_from_mount/3 narrows that to :options and :on_change, plus :selected only when :bind is set.

Key bindings

  • up / down - move the highlight; bubbles at either end
  • enter, space - select the highlighted option

A mouse up selects the option under the pointer immediately.

Widget value

Drafter.get_widget_value/1 returns the ID of the selected option, or nil when the index lands outside the list.

Usage

radio_set(
  options: [{"Light", :light}, {"Dark", :dark}, {"System", :system}],
  selected: :dark,
  on_change: fn theme -> IO.inspect(theme) end
)

Summary

Functions

The component tag this widget registers under.

Builds the props map for a {:radio_set, options, opts} element.

Handles the radio set's own events, replacing the dispatch use Drafter.Widget would otherwise generate.

Builds the widget state from props.

The index of the option at the given coordinates, or nil where the layout has no option.

The number of rows the element asks for.

Draws the option list into rect, always returning exactly rect.height strips.

Callback implementation for Drafter.Widget.unmount/1.

Folds :options, :selected, :on_change and :visible_height into state and drops every other key.

Narrows a re-render to :options, :on_change and :classes, adding :selected only when opts carries :bind.

Types

option()

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

t()

@type t() :: %Drafter.Widget.RadioSet{
  cols: pos_integer(),
  focused: boolean(),
  highlighted_index: non_neg_integer(),
  on_change: (term() -> term()) | nil,
  options: [option()],
  selected_index: non_neg_integer(),
  visible_height: non_neg_integer(),
  width: non_neg_integer()
}

Functions

component_tag()

@spec component_tag() :: :radio_set

The component tag this widget registers under.

iex> Drafter.Widget.RadioSet.component_tag()
:radio_set

focused(state)

from_component_opts(options, opts)

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

Builds the props map for a {:radio_set, options, opts} element.

options is used when it is a non-empty list, otherwise opts[:options], defaulting to []. :selected comes from the bound value and :on_change is the binding's writer. :visible_height falls back to the height of opts[:__rect__] and :width always comes from its width, ignoring any :width in opts; the rect itself defaults to %{width: 40, height: 10}. The emitted :classes key is not read by mount/1.

iex> props = Drafter.Widget.RadioSet.from_component_opts([{"a", :a}], selected: :a)
iex> {props.options, props.selected, props.visible_height, props.width, props.cols}
{[{"a", :a}], :a, 10, 40, 1}

iex> opts = [bind: :theme, __app_state__: %{theme: :dark}, options: [{"Dark", :dark}]]
iex> Drafter.Widget.RadioSet.from_component_opts(nil, opts).selected
:dark

handle_event(event, state)

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

Handles the radio set's own events, replacing the dispatch use Drafter.Widget would otherwise generate.

Recognised events:

  • {:key, :up} / {:key, :down} - move :highlighted_index, returning {:bubble, state} unchanged at the first and last option
  • {:key, :enter} / {:key, :" "} - set :selected_index from the highlight and call :on_change
  • {:mouse, %{type: :mouse_up, x: x, y: y}} - select the option at those coordinates and call :on_change, or {:noreply, state} when the click misses every option
  • {:focus} / {:blur} - set or clear :focused

Every other event returns {:noreply, state}.

iex> state = Drafter.Widget.RadioSet.mount(%{options: [{"a", :a}, {"b", :b}]})
iex> {:ok, moved} = Drafter.Widget.RadioSet.handle_event({:key, :down}, state)
iex> {moved.highlighted_index, moved.selected_index}
{1, 0}

iex> state = Drafter.Widget.RadioSet.mount(%{options: [{"a", :a}, {"b", :b}]})
iex> Drafter.Widget.RadioSet.handle_event({:key, :up}, state) == {:bubble, state}
true

iex> state = Drafter.Widget.RadioSet.mount(%{options: [{"a", :a}, {"b", :b}]})
iex> {:ok, moved} = Drafter.Widget.RadioSet.handle_event({:key, :down}, state)
iex> {:ok, chosen} = Drafter.Widget.RadioSet.handle_event({:key, :enter}, moved)
iex> chosen.selected_index
1

mount(props)

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

Builds the widget state from props.

Options are normalised into %{id: id, label: label} maps. :selected_index and :highlighted_index both start at the index of :selected, or at 0 when it is nil or matches nothing.

iex> state = Drafter.Widget.RadioSet.mount(%{options: ["Light", "Dark"]})
iex> state.options
[%{id: "Light", label: "Light"}, %{id: "Dark", label: "Dark"}]

iex> options = [{"Light", :light}, {"Dark", :dark}]
iex> state = Drafter.Widget.RadioSet.mount(%{options: options, selected: :dark})
iex> {state.selected_index, state.highlighted_index, state.visible_height}
{1, 1, 2}

iex> options = [{"Light", :light}]
iex> state = Drafter.Widget.RadioSet.mount(%{options: options, selected: :nope})
iex> state.selected_index
0

option_index_at(state, x, y)

@spec option_index_at(t(), integer(), integer()) :: non_neg_integer() | nil

The index of the option at the given coordinates, or nil where the layout has no option.

Options fill column-major — the first column top to bottom, then the next — so both x and y are used to resolve the index when :cols exceeds one. With a single column x only has to be non-negative.

iex> state = Drafter.Widget.RadioSet.mount(%{options: ["a", "b", "c"]})
iex> {Drafter.Widget.RadioSet.option_index_at(state, 0, 1), Drafter.Widget.RadioSet.option_index_at(state, 0, 3)}
{1, nil}

iex> state = Drafter.Widget.RadioSet.mount(%{options: ["a", "b", "c", "d"], cols: 2, width: 40})
iex> {Drafter.Widget.RadioSet.option_index_at(state, 25, 1), Drafter.Widget.RadioSet.option_index_at(state, 0, 1)}
{3, 1}

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

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

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

iex> Drafter.Widget.RadioSet.preferred_height(nil, height: 5)
5

render(state, rect)

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

Draws the option list into rect, always returning exactly rect.height strips.

With :cols at 1 the options run down the rect, capped at rect.height, and the remaining rows are blank. With more columns the options fill column-major over ceil(count / cols) rows, each column div(rect.width, cols) wide. The highlight is only drawn while the widget is focused.

unmount(state)

Callback implementation for Drafter.Widget.unmount/1.

update(props, state)

@spec update(Drafter.Widget.props(), t()) :: t()

Folds :options, :selected, :on_change and :visible_height into state and drops every other key.

New :options are normalised. A :selected that matches no option keeps the current index; new options without a :selected clamp the index to the last option. :highlighted_index follows :selected_index only when that index actually moved.

iex> state = Drafter.Widget.RadioSet.mount(%{options: [{"a", :a}, {"b", :b}]})
iex> updated = Drafter.Widget.RadioSet.update(%{selected: :b}, state)
iex> {updated.selected_index, updated.highlighted_index}
{1, 1}

iex> state = Drafter.Widget.RadioSet.mount(%{options: [{"a", :a}, {"b", :b}]})
iex> Drafter.Widget.RadioSet.update(%{selected: :missing}, state).selected_index
0

iex> state = Drafter.Widget.RadioSet.mount(%{options: [{"a", :a}, {"b", :b}], selected: :b})
iex> Drafter.Widget.RadioSet.update(%{options: [{"a", :a}]}, state).selected_index
0

update_props_from_mount(mount_props, existing_state, opts)

@spec update_props_from_mount(Drafter.Widget.props(), term(), keyword()) ::
  Drafter.Widget.props()

Narrows a re-render to :options, :on_change and :classes, adding :selected only when opts carries :bind.

:visible_height, :cols and :width are dropped, so they are mount-only through the component tree, and an unbound radio set keeps whatever the user selected.

iex> props = Drafter.Widget.RadioSet.from_component_opts([{"a", :a}], [])
iex> Drafter.Widget.RadioSet.update_props_from_mount(props, %{}, []) |> Map.keys() |> Enum.sort()
[:classes, :on_change, :options]

iex> opts = [bind: :theme, __app_state__: %{theme: :dark}]
iex> props = Drafter.Widget.RadioSet.from_component_opts([{"Dark", :dark}], opts)
iex> Drafter.Widget.RadioSet.update_props_from_mount(props, %{}, opts).selected
:dark