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

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` - `t: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` - `t: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` - `t:boolean/0` initial focus flag. Default `false`
  * `:hovered` - `t: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 `t: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
    )

# `t`

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

# `focused`

# `handle_event`

```elixir
@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`

```elixir
@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`

# `render`

```elixir
@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`

# `update`

```elixir
@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`

---

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