A full-featured tabular data widget with column headers, sorting, row selection, and scrolling.
Rows are provided as a list of maps. Each map key corresponds to a column :key. Data can be
pre-sorted at mount time via :sort_by. Users can sort any sortable column by clicking its
header, cycling through ascending -> descending -> unsorted. Sort direction is indicated by ↑
or ↓ in the header; ↕ appears on all sortable columns that are not currently sorted.
An optional vertical scrollbar is rendered in the rightmost column when the number of rows
exceeds the visible area. The scrollbar supports click-to-jump and drag-to-scroll. Zebra
stripes alternate the background colour of odd rows when :zebra_stripes is enabled.
Column definition format
Each column is a map (or shorthand) with the following fields:
:key— atom matching the map key in each data row (required):label— header display string (required):width— column width in characters, or:auto(default::auto):align— cell alignment::left(default),:center, or:right:sortable— whether clicking the header sorts by this column (default:true):color_fn—(raw_value -> {r,g,b} | %{bg: {r,g,b}, fg: {r,g,b}} | nil)applied to cell colours when not selected
Shorthand forms are also accepted: {:key, "Label"} or just :key.
Component tag
Tag :data_table, built by Drafter.App as {:data_table, opts}:
data_table(opts)There is no positional argument; columns and rows are passed as columns: and
data: in opts. from_component_opts/2 wraps each callback with
Drafter.Widget.Callback, so every on_* option may be given as an atom event
name. :width and :height default to the rect the parent allocated, and a
:height of :auto becomes 8.
Row styles are given through the element as a single :styles map holding any
of :style, :header_style, :selected_style and :cursor_style; each
unset entry falls back to the active theme. Mounting this module directly
takes those four as separate top-level props instead.
Options
:columns- list of column definitions. Default[]:data- list of row maps. Default[]:sort_by- initial sort: an atom column key (ascending), or{key, :asc | :desc}. Defaultnil, leaving the rows in the given order:selection_mode-:none,:single(default), or:multiple:on_select-([row] -> term())called with the selected rows when a row is activated. Defaultnil:on_sort-(atom(), :asc | :desc -> term())called after a column sort. Defaultnil:show_header-boolean/0, render column headers. Defaulttrue. The header costs one row of data height:show_cursor-boolean/0, highlight the current cell column in the header. Defaulttrue:zebra_stripes-boolean/0, alternate row background colours. Defaulttrue:show_scrollbars-boolean/0, render a vertical scrollbar when the row count exceeds the data height. Defaulttrue:column_fit_mode-:fit(divide available width equally, default) or:expand(compute optimal widths from content, allowing horizontal overflow):mouse_scroll_moves_selection-boolean/0. Defaulttrue, moving the cursor row;falsescrolls the viewport instead:mouse_scroll_selects_item-boolean/0. Defaultfalse. Carried on the state but never read:width- widget width in columns. Default80when mounting directly, or the allocated rect width through the element.on_rect_change/2overwrites it with the real rect width:height- widget height in rows. Default20when mounting directly, or the allocated rect height through the element, with:autobecoming8. A non-positive or non-integer value falls back to20:fixed_columns- number of left-most columns that do not scroll horizontally, clamped to the column count. Default0. Mount-only:update/2ignores it:sortable-boolean/0, enable column sorting and sort indicators for the whole table. Defaulttrue:resizable-boolean/0, allow header drags to resize columns. Defaulttrue. Read bymount/1andupdate/2only; thedata_table/1element does not forward it:locked-boolean/0. Defaulttrue, so dragging a column header resizes it;falsemakes the drag reorder columns instead:on_layout_change-(%{col_widths: [...], col_order: [...]} -> term())called after a resize or reorder. Defaultnil:col_widths- initial list of column widths in display order, for restoring a saved layout. Defaultnil:col_order- initial list of original column indices in display order, for restoring a saved layout. Defaultnil:cursor_type-:row(default, highlights the whole row),:cell(highlights only the cell at the cursor column),:column(highlights the whole column), or:none:cell_padding- spaces padded on each side of cell content. Default0:on_row_highlight-(row :: map() -> term())called when the cursor moves to a new row. Defaultnil:on_header_select-(column_key :: atom() -> term())called when a column header is clicked. Defaultnil:style,:header_style,:selected_style,:cursor_style- style maps read bymount/1andupdate/2. Through the element they are the entries of the single:stylesmap instead. Each falls back to the active theme
Everything above is live through update/2 except :sort_by, :fixed_columns,
:mouse_scroll_moves_selection and :mouse_scroll_selects_item, which are
mount-only. Through the component tree a re-render narrows the live set further,
to the five callbacks plus :columns, :data, :selection_mode, :sortable,
:locked, :cursor_type, :cell_padding, :fixed_columns, and :width and
:height when they changed.
Widget value
Drafter.get_widget_value/1 returns nil for this widget: the value extractor
has no clause matching a state carrying :selected_indices without :options.
Read the selection through :on_select instead.
Key bindings
↑/↓— move cursor row up/down←/→— move cursor column left/rightHome/End— jump to first/last rowPage Up/Page Down— jump by viewport heightEnter— select the highlighted row and call:on_selectSpace— toggle selection in:multiplemode; otherwise same as Enter+/-— widen or narrow the cursor columnShift+←/Shift+→— reorder the cursor column left or right- Mouse click on header — sort by that column (cycles through ascending -> descending -> unsorted)
- Mouse drag on header — resize column (when
locked: true) or reorder column (whenlocked: false) - Mouse click on row — select the row
- Mouse scroll — move cursor row (or scroll viewport when
:mouse_scroll_moves_selectionisfalse) - Scrollbar click/drag — jump or drag the viewport position
Usage
data_table(
columns: [
%{key: :name, label: "Name", width: 20},
%{key: :age, label: "Age", width: 8, align: :right},
%{key: :city, label: "City"}
],
data: [
%{name: "Alice", age: 30, city: "Dublin"},
%{name: "Bob", age: 25, city: "London"}
],
sort_by: {:name, :asc},
on_select: fn rows -> IO.inspect(rows) end
)
Summary
Functions
The registry tag for this widget.
Turns the {:data_table, opts} element into a props map for mount/1.
The number of rows available for data.
The row at which data starts inside the widget: 1 with a header and 0 without.
Handles a mouse release delivered outside the normal routing, ending a resize or scrollbar drag or otherwise treating it as a click. Every other event bubbles.
Continues a gesture as the pointer moves with a button held.
Callback implementation for Drafter.Widget.handle_event/2.
Tracks the pointer with no button held, so the scrollbar can highlight itself as the cursor passes over it.
Moves the cursor, activates a row, or resizes the cursor column.
Reorders columns with Shift+← and Shift+→, firing :on_layout_change.
Ends the current gesture at widget-relative cell {x, y}.
Begins a gesture at widget-relative cell {x, y}.
Handles the mouse wheel.
The key bindings a Drafter.Widget.Footer shows for this widget.
Builds the table state from props.
Stores the laid-out geometry on the state.
opts[:height], or :auto when it is absent, letting the layout give the table
whatever space is left.
Draws the table into rect, returning exactly rect.height strips.
Callback implementation for Drafter.Widget.unmount/1.
Folds fresh props into state.
Narrows the props a re-render feeds to update/2.
Types
@type row() :: map()
@type selection_mode() :: :none | :single | :multiple
@type sort_direction() :: :asc | :desc
@type t() :: %Drafter.Widget.DataTable{ _col_order: term(), _col_widths: term(), _unsorted_data: term(), callbacks: %{ on_select: ([row()] -> term()) | nil, on_sort: (atom(), sort_direction() -> term()) | nil, on_layout_change: (%{col_widths: [pos_integer()], col_order: [non_neg_integer()]} -> term()) | nil, on_row_highlight: (row() -> term()) | nil, on_header_select: (atom() -> term()) | nil }, cell_padding: non_neg_integer(), column_fit_mode: :fit | :expand, columns: [column()], cursor_col: non_neg_integer(), cursor_type: :row | :cell | :column | :none, data: [row()], drag: %{ resize_col: non_neg_integer() | nil, resize_start_x: integer() | nil, resize_start_width: integer() | nil, reorder_col: non_neg_integer() | nil, dragging_scrollbar: boolean(), hovering_scrollbar: boolean(), scrollbar_grab: non_neg_integer() }, fixed_col_widths: [pos_integer()], fixed_columns: non_neg_integer(), height: pos_integer(), highlighted_index: integer() | nil, locked: boolean(), resizable: boolean(), scroll: %{ offset: integer(), offset_col: non_neg_integer(), mouse_scroll_moves_selection: boolean(), mouse_scroll_selects_item: boolean() }, selected_indices: MapSet.t(), selection_mode: selection_mode(), show_cursor: boolean(), show_header: boolean(), show_scrollbars: boolean(), sort_column: atom() | nil, sort_direction: sort_direction(), sortable: boolean(), styles: %{base: map(), header: map(), selected: map(), cursor: map()}, viewport_height: term(), width: pos_integer(), zebra_stripes: boolean() }
Functions
@spec component_tag() :: :data_table
The registry tag for this widget.
iex> Drafter.Widget.DataTable.component_tag()
:data_table
@spec from_component_opts( term(), keyword() ) :: Drafter.Widget.props()
Turns the {:data_table, opts} element into a props map for mount/1.
The positional argument is ignored. :width and :height default to the parent
rect passed as :__rect__, itself defaulting to %{width: 80, height: 20}, and a
:height of :auto becomes 8. Every on_* option is wrapped by
Drafter.Widget.Callback, :on_sort with wrap_2/1 and the rest with wrap_1/1.
The entries of the :styles map are unpacked into the separate :style,
:header_style, :selected_style and :cursor_style props, filled in from
:__theme__; without a theme none of the four is emitted at all. :resizable is
not forwarded.
iex> props = Drafter.Widget.DataTable.from_component_opts(nil, columns: [:n], height: :auto)
iex> {props.columns, props.height, props.width, props.selection_mode, props.cursor_type}
{[:n], 8, 80, :single, :row}
iex> props = Drafter.Widget.DataTable.from_component_opts(nil, [])
iex> {props.data, props.sort_by, props.locked, props.cell_padding, Map.has_key?(props, :style)}
{[], nil, true, 0, false}
The number of rows available for data.
Uses :viewport_height when it is a positive integer, falling back to :height,
and subtracts one row for the header when :show_header is set.
iex> Drafter.Widget.DataTable.get_data_height(%{show_header: true, viewport_height: 10})
9
iex> Drafter.Widget.DataTable.get_data_height(%{show_header: false, viewport_height: 10})
10
iex> Drafter.Widget.DataTable.get_data_height(%{show_header: true, viewport_height: 0, height: 6})
5
@spec get_data_start_y(map()) :: 0 | 1
The row at which data starts inside the widget: 1 with a header and 0 without.
iex> Drafter.Widget.DataTable.get_data_start_y(%{show_header: true})
1
iex> Drafter.Widget.DataTable.get_data_start_y(%{show_header: false})
0
Handles a mouse release delivered outside the normal routing, ending a resize or scrollbar drag or otherwise treating it as a click. Every other event bubbles.
Continues a gesture as the pointer moves with a button held.
A drag already in progress keeps scrolling, resizing or reordering. A drag that
starts on the header row (y == 0) begins a column resize when :locked and
:resizable are both set, or a column reorder when :locked is false. Any other
drag extends the row selection.
Callback implementation for Drafter.Widget.handle_event/2.
Tracks the pointer with no button held, so the scrollbar can highlight itself as the cursor passes over it.
@spec handle_key(Drafter.Widget.key() | integer(), t()) :: {:ok, t()} | {:ok, t(), [term()]} | {:bubble, t()}
Moves the cursor, activates a row, or resizes the cursor column.
?+/:+ widen and ?-/:- narrow the cursor column by two, firing
:on_layout_change. :left and :right move the cursor column, but only while
the widget is focused; unfocused they bubble. :up, :down, :home, :end,
:page_up and :page_down move the cursor row, :enter selects the highlighted
row and fires :on_select, and :" " toggles the row's selection in :multiple
mode. Every other key bubbles.
@spec handle_key(Drafter.Widget.key(), Drafter.Widget.modifiers(), t()) :: {:ok, t()} | {:ok, t(), [term()]} | {:bubble, t()}
Reorders columns with Shift+← and Shift+→, firing :on_layout_change.
Every other modified key combination bubbles, including the unmodified keys
handle_key/2 would otherwise act on, because this clause takes precedence for
every {:key, key, modifiers} event.
Ends the current gesture at widget-relative cell {x, y}.
A release ending a scrollbar drag only clears the drag state. One ending a resize
or reorder fires :on_layout_change. Any other release is treated as a click: on
the header row it sorts that column, cycling ascending, descending, unsorted, and
fires :on_header_select; on a data row it selects that row and fires
:on_select.
Begins a gesture at widget-relative cell {x, y}.
A press on the scrollbar column starts a scrollbar drag or jumps the viewport; a press elsewhere records the position so the matching release can act on it.
Handles the mouse wheel.
With :mouse_scroll_moves_selection set, which is the default, a notch moves the
cursor row exactly as :up/:down would. Otherwise it scrolls the viewport by one
row without moving the cursor.
The key bindings a Drafter.Widget.Footer shows for this widget.
iex> Drafter.Widget.DataTable.keybindings()
[{"↑↓", "Scroll"}, {"Enter", "Select"}, {"+/-", "Resize col"}, {"⇧←→", "Reorder col"}]
@spec mount(Drafter.Widget.props()) :: t()
Builds the table state from props.
Columns are normalised from their shorthand forms, :sort_by is applied to the
data immediately, and the cursor starts on row 0 when there is any data and at
nil when there is none. Nothing is selected initially, whatever
:selection_mode is.
iex> t = Drafter.Widget.DataTable.mount(%{columns: [:name], data: [%{name: "a"}]})
iex> {t.highlighted_index, MapSet.to_list(t.selected_indices), t.selection_mode, t.cursor_col}
{0, [], :single, 0}
iex> t = Drafter.Widget.DataTable.mount(%{})
iex> {t.columns, t.data, t.highlighted_index, t.width, t.height, t.sort_column}
{[], [], nil, 80, 20, nil}
iex> data = [%{n: 3}, %{n: 1}, %{n: 2}]
iex> t = Drafter.Widget.DataTable.mount(%{columns: [:n], data: data, sort_by: :n})
iex> {Enum.map(t.data, & &1.n), t.sort_column, t.sort_direction}
{[1, 2, 3], :n, :asc}
iex> t = Drafter.Widget.DataTable.mount(%{height: :auto})
iex> t.height
20
@spec on_rect_change(Drafter.Widget.rect(), t()) :: t()
Stores the laid-out geometry on the state.
Returns the state with :viewport_height and :width taken from rect, so
scroll and scrollbar calculations run against the geometry the widget was
actually given rather than the values supplied at mount.
@spec preferred_height( term(), keyword() ) :: pos_integer() | :auto
opts[:height], or :auto when it is absent, letting the layout give the table
whatever space is left.
iex> Drafter.Widget.DataTable.preferred_height(nil, [])
:auto
iex> Drafter.Widget.DataTable.preferred_height(nil, height: 15)
15
@spec render(t(), Drafter.Widget.rect()) :: [Drafter.Draw.Strip.t()]
Draws the table into rect, returning exactly rect.height strips.
Theme styles are folded in first, then :viewport_height is set from rect so
the row window matches the space actually given. The table is drawn no wider than
min(state.width, rect.width). The header takes the first row when :show_header
is set, and a vertical scrollbar is drawn in the rightmost column when
:show_scrollbars is set and the row count exceeds the data height.
Callback implementation for Drafter.Widget.unmount/1.
@spec update(Drafter.Widget.props(), t()) :: t()
Folds fresh props into state.
New :data is re-sorted by the column currently sorted on, if any, and the
highlighted row index is clamped to the new row count. :sort_by,
:fixed_columns and both :mouse_scroll_* flags are not re-read and keep their
mounted values. A change in the number of columns discards any saved
:col_widths.
iex> t = Drafter.Widget.DataTable.mount(%{columns: [:n], data: [%{n: 1}, %{n: 2}]})
iex> updated = Drafter.Widget.DataTable.update(%{data: [%{n: 9}]}, t)
iex> {updated.data, updated.highlighted_index}
{[%{n: 9}], 0}
iex> data = [%{n: 3}, %{n: 1}]
iex> t = Drafter.Widget.DataTable.mount(%{columns: [:n], data: data, sort_by: :n})
iex> updated = Drafter.Widget.DataTable.update(%{data: [%{n: 5}, %{n: 4}]}, t)
iex> Enum.map(updated.data, & &1.n)
[4, 5]
iex> t = Drafter.Widget.DataTable.mount(%{columns: [:n], fixed_columns: 1})
iex> Drafter.Widget.DataTable.update(%{fixed_columns: 0}, t).fixed_columns
1
@spec update_props_from_mount(Drafter.Widget.props(), t(), keyword()) :: Drafter.Widget.props()
Narrows the props a re-render feeds to update/2.
Always passes the five callbacks, :columns, :data, :selection_mode,
:sortable, :locked, :cursor_type, :cell_padding and :fixed_columns, and
adds :width and :height only when they differ from the state's. The style maps
and :show_header, :show_cursor, :zebra_stripes, :show_scrollbars and
:column_fit_mode are left out, so they stay as mounted.