Table

v0.0.10

v0.0.10StableHeadlessStatelessAccessibleJS-moduleJSApache-2.0
Installation mix mishka.ui.gen.headless table
Source View Source

Overview of the Phoenix and LiveView Headless Table Component

The Mishka Chelekom Headless Table is a data table for Phoenix LiveView with the parts that are easy to get wrong. It renders a real table with an optional caption, header cells with scope="col", row headers with scope="row", and sort buttons with aria-sort on the sorted column. Selection is a checkbox column whose header checkbox shows none, some or all rows selected. The component ships no colors, sizes or spacing.

You pass rows and describe the columns with :col slots. The table does not sort, select or page anything itself: it shows the order, sort_by and selected you give it and pushes an event when the user changes them. Rows are keyed by row_id, so LiveView patches the row that changed instead of the whole table. The only JavaScript is the small Indeterminate hook on the select-all checkbox.

Base UI Table example - captioned table with row headers (Phoenix headless)

Each cell is drawn by its :col slot from the row it is handed, through :let, which only a template can do, so the examples with rows on this page are shown as code. The empty table further down is live. This one names the table with a caption, makes the Name column the row header and aligns the Color column to the end. The rows used on this page are assigned in mount/3.

elixir
def mount(_params, _session, socket) do
  crew = [
    %{id: 1, name: "Cy Ganderton", job: "Quality Control", color: "Blue"},
    %{id: 2, name: "Hart Hagerty", job: "Desktop Support", color: "Purple"},
    %{id: 3, name: "Brice Swyre", job: "Tax Accountant", color: "Red"}
  ]

  {:ok, assign(socket, :crew, crew)}
end
heex
<.table
  id="baseui-table-hero"
  rows={@crew}
  caption="Crew"
  row_id={&"crew-#{&1.id}"}
  class="w-96 border-collapse text-sm text-neutral-950 dark:text-white [&_[data-part=header]]:border-b [&_[data-part=header]]:border-neutral-200 [&_[data-part=header]]:p-2 [&_[data-part=header]]:text-left [&_[data-part=header]]:font-medium dark:[&_[data-part=header]]:border-neutral-800 [&_[data-part=caption]]:sr-only [&_[data-align=end]]:text-right [&_[data-align=center]]:text-center"
  cell_class="border-b border-neutral-100 p-2 dark:border-neutral-900"
>
  <:col :let={row} label="Name" row_header>{row.name}</:col>
  <:col :let={row} label="Job">{row.job}</:col>
  <:col :let={row} label="Color" align="end">{row.color}</:col>
</.table>

Base UI Table example - sortable columns (Phoenix headless)

A column with a key is sortable once on_sort is set. Pressing its header button pushes the event with the column key and the next direction: the sorted column reverses, and any other column starts ascending. The handler sorts the rows and returns the new sort_by and sort_dir.

heex
<.table
  id="baseui-table-sortable"
  rows={@crew}
  caption="Crew, sortable"
  row_id={&"crew-#{&1.id}"}
  sort_by={@sort_by}
  sort_dir={@sort_dir}
  on_sort="sort"
  class="w-96 border-collapse text-sm text-neutral-950 dark:text-white [&_[data-part=header]]:border-b [&_[data-part=header]]:border-neutral-200 [&_[data-part=header]]:p-2 [&_[data-part=header]]:text-left [&_[data-part=header]]:font-medium dark:[&_[data-part=header]]:border-neutral-800 [&_[data-part=caption]]:sr-only [&_[data-align=end]]:text-right [&_[data-align=center]]:text-center [&_[data-part=sort]]:inline-flex [&_[data-part=sort]]:items-center [&_[data-part=sort]]:gap-1 [&_[data-part=sort]]:after:text-neutral-400 [&_[data-part=sort]]:after:content-['↕'] [&_[data-part=sort][data-dir=asc]]:after:content-['↑'] [&_[data-part=sort][data-dir=desc]]:after:content-['↓'] [&_[data-part=sort][data-dir]]:after:text-neutral-950 dark:[&_[data-part=sort][data-dir]]:after:text-white"
  cell_class="border-b border-neutral-100 p-2 dark:border-neutral-900"
>
  <:col :let={row} label="Name" key="name" row_header>{row.name}</:col>
  <:col :let={row} label="Job" key="job">{row.job}</:col>
</.table>
elixir
@sortable %{"name" => :name, "job" => :job}

def handle_event("sort", %{"key" => key, "dir" => dir}, socket)
    when is_map_key(@sortable, key) and dir in ["asc", "desc"] do
  field = Map.fetch!(@sortable, key)
  order = String.to_existing_atom(dir)
  crew = Enum.sort_by(socket.assigns.crew, &Map.fetch!(&1, field), order)

  {:noreply, assign(socket, crew: crew, sort_by: key, sort_dir: dir)}
end

Table example - row selection (Phoenix headless)

Setting on_select adds the checkbox column. selected lists the ids of the selected rows, and a selected row gets data-selected. A row checkbox pushes on_select with that row's id. The header checkbox pushes on_select_all and sends no row id, so your handler decides what "all" means. The header checkbox is checked when every row is selected and mixed when only some are.

heex
<.table
  id="baseui-table-selectable"
  rows={@crew}
  caption="Crew, selectable"
  row_id={&row_id/1}
  selected={@selected}
  on_select="select_row"
  on_select_all="select_all"
  class="w-96 border-collapse text-sm text-neutral-950 dark:text-white [&_[data-part=header]]:border-b [&_[data-part=header]]:border-neutral-200 [&_[data-part=header]]:p-2 [&_[data-part=header]]:text-left [&_[data-part=header]]:font-medium dark:[&_[data-part=header]]:border-neutral-800 [&_[data-part=caption]]:sr-only [&_[data-align=end]]:text-right [&_[data-align=center]]:text-center"
  row_class="data-[selected]:bg-neutral-100 dark:data-[selected]:bg-neutral-900"
  cell_class="border-b border-neutral-100 p-2 dark:border-neutral-900"
  select_class="size-4"
>
  <:col :let={row} label="Name" row_header>{row.name}</:col>
  <:col :let={row} label="Job">{row.job}</:col>
</.table>
elixir
def handle_event("select_row", %{"id" => id}, socket) do
  selected = socket.assigns.selected
  next = if id in selected, do: List.delete(selected, id), else: [id | selected]

  {:noreply, assign(socket, :selected, next)}
end

def handle_event("select_all", _params, socket) do
  ids = Enum.map(socket.assigns.crew, &row_id/1)
  next = if Enum.all?(ids, &(&1 in socket.assigns.selected)), do: [], else: ids

  {:noreply, assign(socket, :selected, next)}
end

defp row_id(row), do: "crew-#{row.id}"

Attributes and slots

id and rows are required. caption names the table for assistive technology and is drawn as a caption element. With show_caption left false, the caption carries data-hidden, and your CSS decides how to hide it, for example with sr-only. row_id is a function of the row that returns its DOM id. row_click is a function of the row that returns a JS command, set as phx-click on each row.

sort_by names the sorted column's key and sort_dir is "asc" (the default) or "desc". on_sort receives key and dir. selected, on_select and on_select_all drive selection. select_label and select_all_label are the accessible names of the checkboxes and default to "Select row" and "Select all rows".

A :col slot takes label (the header text), key (the sort key; a column without one is not sortable), row_header (draw its cells as th scope="row"), align (start, center or end, reflected as data-align) and class. The slot content receives each row through :let.

The parts have a data-part value and a class: caption, head, header, sort, body, row, cell, select, select-all, empty and foot. Each has a matching attribute: class for the table itself, then caption_class, head_class, header_class, sort_class, body_class, row_class, cell_class, select_class (both checkboxes), empty_class and foot_class.

State is reflected as data attributes: data-sort-by and data-sort-dir on the table, data-sortable and data-align on header cells, data-dir on the sorted column's button, data-selected on selected rows and data-indeterminate on the select-all checkbox.

Accessibility

The table follows the WAI-ARIA Table pattern with native elements. The caption names the table, column headers and row headers carry scope, and only the sorted column carries aria-sort, so a screen reader does not announce sortability on every header. Sort buttons and checkboxes are reached with Tab and activated with Enter or Space. The select-all checkbox is mixed when only some rows are selected, which HTML cannot express in markup, so the Indeterminate hook sets it.