Drafter.Widget.DataTable (drafter v0.3.2)

Copy Markdown View Source

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}. Default nil, 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. Default nil
  • :on_sort - (atom(), :asc | :desc -> term()) called after a column sort. Default nil

  • :show_header - boolean/0, render column headers. Default true. The header costs one row of data height
  • :show_cursor - boolean/0, highlight the current cell column in the header. Default true
  • :zebra_stripes - boolean/0, alternate row background colours. Default true
  • :show_scrollbars - boolean/0, render a vertical scrollbar when the row count exceeds the data height. Default true
  • :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. Default true, moving the cursor row; false scrolls the viewport instead
  • :mouse_scroll_selects_item - boolean/0. Default false. Carried on the state but never read
  • :width - widget width in columns. Default 80 when mounting directly, or the allocated rect width through the element. on_rect_change/2 overwrites it with the real rect width
  • :height - widget height in rows. Default 20 when mounting directly, or the allocated rect height through the element, with :auto becoming 8. A non-positive or non-integer value falls back to 20
  • :fixed_columns - number of left-most columns that do not scroll horizontally, clamped to the column count. Default 0. Mount-only: update/2 ignores it
  • :sortable - boolean/0, enable column sorting and sort indicators for the whole table. Default true
  • :resizable - boolean/0, allow header drags to resize columns. Default true. Read by mount/1 and update/2 only; the data_table/1 element does not forward it
  • :locked - boolean/0. Default true, so dragging a column header resizes it; false makes the drag reorder columns instead
  • :on_layout_change - (%{col_widths: [...], col_order: [...]} -> term()) called after a resize or reorder. Default nil
  • :col_widths - initial list of column widths in display order, for restoring a saved layout. Default nil
  • :col_order - initial list of original column indices in display order, for restoring a saved layout. Default nil
  • :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. Default 0
  • :on_row_highlight - (row :: map() -> term()) called when the cursor moves to a new row. Default nil
  • :on_header_select - (column_key :: atom() -> term()) called when a column header is clicked. Default nil
  • :style, :header_style, :selected_style, :cursor_style - style maps read by mount/1 and update/2. Through the element they are the entries of the single :styles map 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/right
  • Home / End — jump to first/last row
  • Page Up / Page Down — jump by viewport height
  • Enter — select the highlighted row and call :on_select
  • Space — toggle selection in :multiple mode; otherwise same as Enter
  • + / - — widen or narrow the cursor column
  • Shift+← / 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 (when locked: false)
  • Mouse click on row — select the row
  • Mouse scroll — move cursor row (or scroll viewport when :mouse_scroll_moves_selection is false)
  • 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.

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.

Types

column()

@type column() :: %{
  key: atom(),
  label: String.t(),
  width: pos_integer() | :auto,
  align: :left | :center | :right,
  sortable: boolean(),
  color_fn:
    (term() -> {byte(), byte(), byte()} | %{bg: tuple(), fg: tuple()} | nil)
    | nil
}

row()

@type row() :: map()

selection_mode()

@type selection_mode() :: :none | :single | :multiple

sort_direction()

@type sort_direction() :: :asc | :desc

t()

@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

component_tag()

@spec component_tag() :: :data_table

The registry tag for this widget.

iex> Drafter.Widget.DataTable.component_tag()
:data_table

focused(state)

from_component_opts(args, opts)

@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}

get_data_height(map)

@spec get_data_height(map()) :: integer()

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

get_data_start_y(arg1)

@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

handle_custom_event(arg1, state)

@spec handle_custom_event(term(), t()) ::
  {:ok, t()} | {:ok, t(), [term()]} | {:bubble, t()}

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.

handle_drag(x, y, state)

@spec handle_drag(integer(), integer(), t()) :: {:ok, t()} | {:ok, t(), [term()]}

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.

handle_event(event, state)

Callback implementation for Drafter.Widget.handle_event/2.

handle_hover(x, y, state)

@spec handle_hover(integer(), integer(), t()) :: {:ok, t()} | {:noreply, t()}

Tracks the pointer with no button held, so the scrollbar can highlight itself as the cursor passes over it.

handle_key(arg1, state)

@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.

handle_key(arg1, arg2, state)

@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.

handle_mouse_up(x, y, state)

@spec handle_mouse_up(integer(), integer(), t()) :: {:ok, t()} | {:ok, t(), [term()]}

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.

handle_press(x, y, state)

@spec handle_press(integer(), integer(), t()) :: {:ok, t()} | {:ok, t(), [term()]}

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.

handle_scroll(direction, state)

@spec handle_scroll(:up | :down, t()) :: {:ok, t()} | {:ok, t(), [term()]}

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.

keybindings()

@spec keybindings() :: [{String.t(), String.t()}]

The key bindings a Drafter.Widget.Footer shows for this widget.

iex> Drafter.Widget.DataTable.keybindings()
[{"↑↓", "Scroll"}, {"Enter", "Select"}, {"+/-", "Resize col"}, {"⇧←→", "Reorder col"}]

mount(props)

@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

on_rect_change(rect, state)

@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.

preferred_height(args, opts)

@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

render(state, rect)

@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.

unmount(state)

Callback implementation for Drafter.Widget.unmount/1.

update(props, state)

@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

update_props_from_mount(mount_props, existing_state, opts)

@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.