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

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` - `t:boolean/0`, render column headers. Default `true`. The
    header costs one row of data height
  * `:show_cursor` - `t:boolean/0`, highlight the current cell column in the
    header. Default `true`
  * `:zebra_stripes` - `t:boolean/0`, alternate row background colours. Default
    `true`
  * `:show_scrollbars` - `t: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` - `t:boolean/0`. Default `true`, moving the
    cursor row; `false` scrolls the viewport instead
  * `:mouse_scroll_selects_item` - `t: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` - `t:boolean/0`, enable column sorting and sort indicators for the
    whole table. Default `true`
  * `:resizable` - `t: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` - `t: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
    )

# `column`

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

# `row`

```elixir
@type row() :: map()
```

# `selection_mode`

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

# `sort_direction`

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

# `t`

```elixir
@type t() :: %Drafter.Widget.DataTable{
  _col_order: term(),
  _col_widths: term(),
  _unsorted_data: term(),
  callbacks: %{
    on_select: ([row()] -&gt; term()) | nil,
    on_sort: (atom(), sort_direction() -&gt; term()) | nil,
    on_layout_change:
      (%{col_widths: [pos_integer()], col_order: [non_neg_integer()]} -&gt; term())
      | nil,
    on_row_highlight: (row() -&gt; term()) | nil,
    on_header_select: (atom() -&gt; 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()
}
```

# `component_tag`

```elixir
@spec component_tag() :: :data_table
```

The registry tag for this widget.

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

# `focused`

# `from_component_opts`

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

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

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

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

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

# `handle_hover`

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

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

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

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

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

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

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

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

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

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

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

# `update`

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

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

---

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