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/0header 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. Default10for any non-string content andnilfor a string, which is word-wrapped to fit instead:expanded-boolean/0initial expansion state. Defaultfalse:on_toggle- atom event name or one-arity function invoked with the newexpandedboolean. Defaultnil. An exception raised inside it is caught and ignored:focused-boolean/0initial focus flag. Defaultfalse:hovered-boolean/0initial hover flag. Defaultfalse
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
Functions
@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
@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}
@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.
Callback implementation for Drafter.Widget.unmount/1.
@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