Drafter.Widget.Collapsible (drafter v0.3.1)

Copy Markdown View Source

Renders an expandable section with a title row and collapsible content.

The widget displays a arrow when collapsed and when expanded. Pressing Enter, Space, or clicking the title row toggles the expanded state. The :on_toggle callback is invoked with the new boolean state after each toggle.

Content can be a plain string (word-wrapped to fit the available width) or a list of child widgets built with the same helper functions available in Drafter.App (e.g. checkbox/2, radio_set/2, text_input/1). Child widgets are fully interactive — they receive focus, keyboard, and mouse events just like top-level widgets. Use :content_height to reserve the right number of rows for the expanded body when passing child widgets.

Component tag

This module has no component_tag/0 and is not reached through the widget registry. Drafter.App builds it as the element {:collapsible, title, content, opts}:

collapsible(title, content, opts)

Both title and content are positional. The remaining props come from opts; :on_toggle is dispatched as an app callback, so it may be given as an atom event name. The widget's identity is derived from a hash of the title, so two collapsibles sharing a title in one screen share expansion state unless one is given a distinct :id.

Options

  • :title - String.t/0 header shown in the toggle row. Default "Collapsible". Supplied positionally through the element
  • :content - body string or list of child widget descriptors. Default "". Supplied positionally through the element
  • :content_height - rows reserved for child widgets when expanded. Default 10 for any non-string content and nil for a string, which is word-wrapped to fit instead
  • :expanded - boolean/0 initial expansion state. Default false
  • :on_toggle - atom event name or one-arity function invoked with the new expanded boolean. Default nil. An exception raised inside it is caught and ignored
  • :focused - boolean/0 initial focus flag. Default false
  • :hovered - boolean/0 initial hover flag. Default false

update/2 merges the whole props map into the state, so every option is live; :content_height is recomputed from the default only when the content switches between a string and a non-string and no explicit :content_height was given.

Widget value

Drafter.get_widget_value/1 returns the expanded boolean/0.

Key bindings

Enter and Space with no modifiers toggle the section, as does a mouse release on row 0, the title row. A release on any other row is swallowed. Every other event bubbles.

Usage

collapsible("About", "Plain text is word-wrapped automatically.")

collapsible(
  "Preferences",
  [
    checkbox("Enable notifications", id: :notifs, checked: state.notifs, on_change: :notifs_changed),
    checkbox("Dark mode", id: :dark, checked: state.dark, on_change: :dark_changed)
  ],
  content_height: 2
)

collapsible(
  "Theme",
  [radio_set([{"Light", "light"}, {"Dark", "dark"}], id: :theme, selected: state.theme, on_change: :theme_changed)],
  content_height: 2,
  expanded: true
)

Summary

Functions

Handles events directly instead of going through Drafter.Widget.EventRouter.

Builds the collapsible state from props.

Draws the title row and, when expanded, the body into rect.

Callback implementation for Drafter.Widget.unmount/1.

Merges props into state, so every option is live-updatable.

Types

t()

@type t() :: %Drafter.Widget.Collapsible{
  content: String.t() | [term()],
  content_height: non_neg_integer() | nil,
  expanded: boolean(),
  focused: boolean(),
  hovered: boolean(),
  on_toggle: (boolean() -> term()) | nil,
  title: String.t()
}

Functions

focused(state)

handle_event(event, state)

@spec handle_event(Drafter.Event.t() | atom(), t()) ::
  {:ok, t()} | {:ok, t(), [tuple()]} | {:noreply, t()} | {:bubble, t()}

Handles events directly instead of going through Drafter.Widget.EventRouter.

{:key, :enter}, {:key, :" "} and a mouse release on row 0 flip :expanded, call :on_toggle with the new value, and return {:ok, state, [{:widget_layout_needed, :below}]} so the widgets below are laid out again. A mouse release on any other row returns {:noreply, state}. {:focus} sets both :focused and :hovered, {:blur} clears both, and :hover/:unhover move :hovered alone. Anything else returns {:bubble, state}.

iex> c = Drafter.Widget.Collapsible.mount(%{title: "About"})
iex> {:ok, open, actions} = Drafter.Widget.Collapsible.handle_event({:key, :enter}, c)
iex> {open.expanded, actions}
{true, [{:widget_layout_needed, :below}]}

iex> c = Drafter.Widget.Collapsible.mount(%{title: "About"})
iex> Drafter.Widget.Collapsible.handle_event({:mouse, %{type: :mouse_up, y: 4}}, c) |> elem(0)
:noreply

iex> c = Drafter.Widget.Collapsible.mount(%{title: "About"})
iex> Drafter.Widget.Collapsible.handle_event({:key, :escape}, c) |> elem(0)
:bubble

mount(props)

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

Builds the collapsible state from props.

:content_height falls back to nil for string content and 10 for anything else.

iex> c = Drafter.Widget.Collapsible.mount(%{title: "About", content: "text"})
iex> {c.title, c.content_height, c.expanded}
{"About", nil, false}

iex> c = Drafter.Widget.Collapsible.mount(%{content: [:a, :b]})
iex> {c.title, c.content_height}
{"Collapsible", 10}

iex> c = Drafter.Widget.Collapsible.mount(%{})
iex> {c.content, c.focused, c.hovered, c.on_toggle}
{"", false, false, nil}

preferred_height(args, opts)

render(state, rect)

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

Draws the title row and, when expanded, the body into rect.

Returns exactly rect.height strips, padded with blanks or truncated. Collapsed, only the title row carries content. Expanded, string content is word-wrapped to rect.width - 2 and indented two columns, while list content becomes :content_height blank rows for the component renderer to draw the children into.

unmount(state)

Callback implementation for Drafter.Widget.unmount/1.

update(props, state)

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

Merges props into state, so every option is live-updatable.

:content_height is taken from props when present. Otherwise it is recomputed from the default only when :content is present and switches between a string and a non-string; in every other case the current value is kept.

iex> c = Drafter.Widget.Collapsible.mount(%{title: "About", content: "text"})
iex> updated = Drafter.Widget.Collapsible.update(%{content: [:a]}, c)
iex> {updated.content, updated.content_height}
{[:a], 10}

iex> c = Drafter.Widget.Collapsible.mount(%{content: [:a]})
iex> Drafter.Widget.Collapsible.update(%{content_height: 3}, c).content_height
3

update_props_from_mount(mount_props, existing_state, opts)