Table
v0.0.10
mix mishka.ui.gen.headless table
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.
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<.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.
<.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>@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)}
endTable 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.
<.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>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.
Attributes
| Name | Type | Default | Values | Required | Description |
|---|---|---|---|---|---|
id
|
string
| — | — | Yes | Unique id |
rows
|
list
| — | — | Yes | The rows to render |
caption
|
string
| — | — | No | Names the table for assistive tech |
show_caption
|
boolean
|
false
| — | No | Also show the caption; it is there for screen readers either way |
row_id
|
any
| — | — | No | A function of the row returning its DOM id, so LiveView can patch one row |
row_click
|
any
| — | — | No | A function of the row returning a JS command |
sort_by
|
string
| — | — | No | Which column is sorted |
sort_dir
|
string
|
asc
|
ascdesc
| No | Which way the sorted column runs |
on_sort
|
string
| — | — | No | LiveView event pushed as `%{key, dir}` when a sortable header is activated |
selected
|
list
|
[]
| — | No | Ids of the selected rows |
on_select
|
string
| — | — | No | Event pushed as `%{id, selected}` per row |
on_select_all
|
string
| — | — | No | Event pushed as `%{selected}` for the header |
select_label
|
string
|
Select row
| — | No | Accessible name for a row's checkbox |
select_all_label
|
string
|
Select all rows
| — | No | …and for the header's |
class
|
any
| — | — | No | Extra classes for the root |
row_class
|
any
| — | — | No | — |
cell_class
|
any
| — | — | No | — |
body_class
|
any
| — | — | No | — |
caption_class
|
any
| — | — | No | — |
empty_class
|
any
| — | — | No | — |
foot_class
|
any
| — | — | No | — |
head_class
|
any
| — | — | No | — |
header_class
|
any
| — | — | No | — |
select_class
|
any
| — | — | No | — |
sort_class
|
any
| — | — | No | — |
rest
|
global
| — | — | No | — |
Slots
| Name | Required | Description |
|---|---|---|
col
| Yes | One column |
empty
| No | Shown instead of the body when there are no rows |
foot
| No | A `<tfoot>` row; receives no rows, so totals are the caller's to compute |
Attributes of the col slot
| Name | Type | Default | Values | Required | Description |
|---|---|---|---|---|---|
label
|
string
| — | — | No | Header text |
key
|
string
| — | — | No | Sort key; a column without one is not sortable |
row_header
|
boolean
| — | — | No | Render this column's cells as `<th scope="row">` |
align
|
string
| — | — | No | start | center | end, reflected as data-align |
class
|
any
| — | — | No | — |
Options
Each option takes one or more of its values, separated by commas.
| Option | Values |
|---|---|
--type
|
table
|
--module
| A module name of your own |
What it generates
mix mishka.ui.gen.headless table