Renders a bordered tabbed panel where each tab displays independent content.
Tabs are switched with ←/→ or by clicking the tab label. The active tab
label is wrapped in [brackets]; hovered tabs are highlighted. Tab content
can be a list of strings, a single {:label, text} tuple, or an
{:option_list, items, opts} tuple that embeds a fully interactive
OptionList widget inside the tab body.
An optional :title string is rendered in the top border, aligned according
to :title_align.
Component tag
Tag :tabbed_content, built by Drafter.App as {:tabbed_content, tabs, opts}:
tabbed_content(tabs, opts)The positional tabs list is used when non-empty, falling back to
opts[:tabs]. from_component_opts/2 wraps :on_tab_change with
Drafter.Widget.Callback, so it may be given as an atom event name. :width
defaults to the rect the parent allocated.
Options
:tabs- list of tab descriptors. Default[]. Each is normalised into%{id: id, label: label, content: list}:"label"— a string used as both id and label, with empty content{label, content}— content that is a list is kept, a string or a tuple is wrapped in a one-element list%{id: id, label: label}— content defaults to[]%{id: id, label: label, content: content}— a tuple content is wrapped in a list Anything else raisesFunctionClauseError.
:active_tab-non_neg_integer/0zero-based index of the initially active tab. Default0. Not bounds-checked at mount.:title-String.t/0shown in the top border, ornil. Defaultnil.:title_align-:left | :center | :right. Default:left.:width-pos_integer/0explicit width in columns. Defaultnilwhen mounting directly, which makesrender/2use the rect width; through the element it defaults to the width ofopts[:__rect__], itself defaulting to%{width: 80}.:on_tab_change- the app callback name fired with the newly active tab's:idwhen the tab changes. Defaultnil. Through the element it is set to the one-argument functionDrafter.Widget.Callback.wrap_1/1returns, which the widget passes on as a callback name rather than calling.:on_item_select- one-arity function receiving the highlighted item whenenteris pressed on a tab whose content is a plain list. Defaultnil. Read bymount/1only — thetabbed_content/2element does not forward it.:focused-boolean/0read bymount/1. Defaultfalse.:height-pos_integer/0read only bypreferred_height/2, never bymount/1. Default8.
update/2 accepts :tabs, :active_tab, :on_tab_change, :on_item_select,
:title, :title_align and :width, resetting the item highlight whenever the
active tab changes and remounting the embedded child widgets only when the number
of tabs changes. Through the component tree update_props_from_mount/3 narrows
that to :tabs, :title_align, :width and :classes — so :active_tab,
:title, :on_tab_change and :on_item_select are mount-only and the tab the
user switched to survives a re-render.
Key bindings
left/right- switch tabs, returning{:noreply, state}at either endup/down- move the highlight inside the active tab, or forward the key to the tab's embedded widgetenter- call:on_item_selectwith the highlighted item, or forward the key to the tab's embedded widgettab- ignored, returning{:noreply, state}
A mouse up on row 0 or 1 selects a tab, on row 3 or below goes to the
content, and anywhere else just focuses the widget. A mouse move on row 0 or
1 sets :hovered_tab, and clears it elsewhere.
Widget value
Drafter.get_widget_value/1 returns :active_tab, the zero-based index of the
active tab, not its id.
Usage
tabbed_content(tabs: [
%{id: :overview, label: "Overview", content: ["Line 1", "Line 2"]},
%{id: :details, label: "Details", content: ["More info"]}
])
tabbed_content(
tabs: [{"Files", {:option_list, file_list, on_select: :file_selected}}],
title: "Browser"
)
Summary
Functions
The component tag this widget registers under.
Builds the props map for a {:tabbed_content, tabs, opts} element.
Handles the panel's own events, replacing the dispatch use Drafter.Widget
would otherwise generate.
Builds the widget state from props.
The number of rows the element asks for: opts[:height], default 8.
Draws the panel into rect.
Callback implementation for Drafter.Widget.unmount/1.
Replaces the state fields named in props, keeping the current value for any key
that is absent.
Narrows a re-render to :tabs, :title_align, :width and :classes.
Types
@type t() :: %Drafter.Widget.TabbedContent{ active_tab: non_neg_integer(), child_widgets: [term() | nil], focused: boolean(), highlighted_item: non_neg_integer(), hovered_tab: non_neg_integer() | nil, on_item_select: (term() -> term()) | nil, on_tab_change: term(), tabs: [tab()], title: String.t() | nil, title_align: :left | :center | :right, width: pos_integer() | nil }
Functions
@spec component_tag() :: :tabbed_content
The component tag this widget registers under.
iex> Drafter.Widget.TabbedContent.component_tag()
:tabbed_content
@spec from_component_opts( list() | nil, keyword() ) :: Drafter.Widget.props()
Builds the props map for a {:tabbed_content, tabs, opts} element.
tabs is used when it is a non-empty list, otherwise opts[:tabs], defaulting
to []; the descriptors are passed through as given and mount/1 normalises
them. :on_tab_change goes through Drafter.Widget.Callback.wrap_1/1.
:width falls back to the width of opts[:__rect__], itself defaulting to
%{width: 80}. :on_item_select is not forwarded and the emitted :classes
key is not read by mount/1.
iex> props = Drafter.Widget.TabbedContent.from_component_opts(["One"], title: "Browser")
iex> {props.tabs, props.active_tab, props.title, props.title_align, props.width}
{["One"], 0, "Browser", :left, 80}
iex> props = Drafter.Widget.TabbedContent.from_component_opts(nil, tabs: ["A"], on_tab_change: :switched)
iex> {props.tabs, is_function(props.on_tab_change, 1)}
{["A"], true}
Handles the panel's own events, replacing the dispatch use Drafter.Widget
would otherwise generate.
Recognised events are the key bindings and mouse handling listed in the module
doc, plus {:focus} and {:blur}; blurring also clears :hovered_tab. A tab
change resets :highlighted_item to 0 and notifies :on_tab_change with the
new tab's :id. Anything unrecognised returns {:noreply, state}.
iex> state = Drafter.Widget.TabbedContent.mount(%{tabs: ["One", "Two"]})
iex> {:ok, switched} = Drafter.Widget.TabbedContent.handle_event({:key, :right}, state)
iex> switched.active_tab
1
iex> state = Drafter.Widget.TabbedContent.mount(%{tabs: ["One", "Two"]})
iex> Drafter.Widget.TabbedContent.handle_event({:key, :left}, state) == {:noreply, state}
true
iex> state = Drafter.Widget.TabbedContent.mount(%{tabs: [{"One", ["a", "b"]}]})
iex> {:ok, moved} = Drafter.Widget.TabbedContent.handle_event({:key, :down}, state)
iex> moved.highlighted_item
1
iex> state = Drafter.Widget.TabbedContent.mount(%{tabs: ["One"]})
iex> Drafter.Widget.TabbedContent.handle_event({:key, :tab}, state) == {:noreply, state}
true
@spec mount(Drafter.Widget.props()) :: t()
Builds the widget state from props.
Tabs are normalised into %{id: _, label: _, content: _} maps, and a tab whose
content is a single widget tuple has that widget mounted into :child_widgets
at the same index; every other tab gets nil there. :hovered_tab starts as
nil and :highlighted_item at 0.
iex> state = Drafter.Widget.TabbedContent.mount(%{tabs: ["One", {"Two", ["a", "b"]}]})
iex> state.tabs
[%{id: "One", label: "One", content: []}, %{id: "Two", label: "Two", content: ["a", "b"]}]
iex> state = Drafter.Widget.TabbedContent.mount(%{tabs: ["One"]})
iex> {state.active_tab, state.highlighted_item, state.hovered_tab, state.title_align, state.width}
{0, 0, nil, :left, nil}
iex> Drafter.Widget.TabbedContent.mount(%{tabs: ["One"]}).child_widgets
[nil]
@spec preferred_height( term(), keyword() ) :: pos_integer()
The number of rows the element asks for: opts[:height], default 8.
iex> Drafter.Widget.TabbedContent.preferred_height(nil, [])
8
iex> Drafter.Widget.TabbedContent.preferred_height(nil, height: 20)
20
@spec render(t(), Drafter.Widget.rect()) :: [Drafter.Draw.Strip.t()]
Draws the panel into rect.
The layout is a title row, the tab bar, a separator, then the active tab's
content and the bottom border. The panel is state.width columns wide, falling
back to rect.width when that is nil.
Callback implementation for Drafter.Widget.unmount/1.
@spec update(Drafter.Widget.props(), t()) :: t()
Replaces the state fields named in props, keeping the current value for any key
that is absent.
New :tabs are normalised. :highlighted_item resets to 0 when
:active_tab changes and is otherwise kept. The embedded child widgets are
remounted only when the number of tabs changes, so replacing a tab's widget
content in place does not take effect.
iex> state = Drafter.Widget.TabbedContent.mount(%{tabs: ["One", "Two"]})
iex> state = %{state | highlighted_item: 3}
iex> updated = Drafter.Widget.TabbedContent.update(%{active_tab: 1}, state)
iex> {updated.active_tab, updated.highlighted_item}
{1, 0}
iex> state = Drafter.Widget.TabbedContent.mount(%{tabs: ["One"]})
iex> Drafter.Widget.TabbedContent.update(%{title: "Browser"}, state).title
"Browser"
@spec update_props_from_mount(Drafter.Widget.props(), term(), keyword()) :: Drafter.Widget.props()
Narrows a re-render to :tabs, :title_align, :width and :classes.
:active_tab, :title, :on_tab_change and :on_item_select are dropped, so
they are mount-only through the component tree and the tab the user switched to
survives a re-render.
iex> props = Drafter.Widget.TabbedContent.from_component_opts(["One"], title: "Browser")
iex> Drafter.Widget.TabbedContent.update_props_from_mount(props, %{}, []) |> Map.keys() |> Enum.sort()
[:classes, :tabs, :title_align, :width]