Packages

Headless-first, token-driven UI components for Phoenix LiveView — rich selects, date pickers, dialogs, menus, tables, sliders and toasts that live in the browser top layer. Themeable through CSS tokens, viewport-aware, server-authoritative, with zero third-party JS.

Current section

Files

Jump to
skua lib skua components table.ex
Raw

lib/skua/components/table.ex

defmodule Skua.Components.Table do
@moduledoc """
A token-styled, slot-driven `table` and a `pagination` control.
Both are **pure presentation bound to your server state** — they never touch
your query. The table renders the chrome (rounded edges from `--sk-r`, sticky
header, sortable headers, hover, empty state); you own the data and every
cell. Sorting and paging are emitted as `phx-click` events your LiveView
handles, so it works with Ecto, Flop, raw SQL, an API — and with LiveView
streams for large tables.
It's a superset of `CoreComponents.table/1` (same `row_id`/`row_item`/
`row_click`/`col`/`action`), so generated `phx.gen.live` tables drop in.
<.table id="users" rows={@users} sort={@sort} on_sort="sort">
<:col :let={u} field={:name} label="Name" sortable>{u.name}</:col>
<:col :let={u} label="Email">{u.email}</:col>
<:action :let={u}><.button variant="ghost">Edit</.button></:action>
<:empty>No users yet.</:empty>
</.table>
<.pagination page={@page} per_page={@per} total={@total} on_page="page" />
"""
use Phoenix.Component
@doc "A styled data table. See the module doc."
attr :id, :string, required: true
attr :rows, :any, required: true, doc: "a list, or a LiveView stream"
attr :row_id, :any, default: nil, doc: "row -> DOM id (required for streams)"
attr :row_item, :any, default: &Function.identity/1, doc: "row -> the item passed to :col :let"
attr :row_click, :any, default: nil, doc: "row -> JS, makes the row clickable"
attr :sort, :map, default: nil, doc: "%{field: atom, dir: :asc | :desc} for sort indicators"
attr :on_sort, :string,
default: nil,
doc: "event pushed (with field+dir) when a sortable header is clicked"
attr :class, :any, default: nil
slot :col, required: true do
attr :label, :string
attr :field, :atom
attr :sortable, :boolean
attr :align, :string, values: ~w(left center right)
attr :class, :any
end
slot :action, doc: "trailing actions column"
slot :empty, doc: "shown when rows is empty"
def table(assigns) do
~H"""
<div class={["sk-table-wrap sk-scroll", @class]}>
<table class="sk-table">
<thead>
<tr>
<th
:for={col <- @col}
class={["sk-th", th_align(col[:align]), col[:class]]}
aria-sort={aria_sort(@sort, col[:field])}
>
<button
:if={col[:sortable] && @on_sort}
type="button"
class="sk-th-sort"
phx-click={@on_sort}
phx-value-field={col[:field]}
phx-value-dir={next_dir(@sort, col[:field])}
>
{col[:label]}
<span class={["sk-sort-ind", sort_class(@sort, col[:field])]} aria-hidden="true">
<svg class="sk-glyph" viewBox="0 0 24 24" width="14" height="14"><path d="m6 9 6 6 6-6" /></svg>
</span>
</button>
<span :if={!(col[:sortable] && @on_sort)}>{col[:label]}</span>
</th>
<th :if={@action != []} class="sk-th sk-th--action">
<span class="sk-sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={stream?(@rows) && "stream"}>
<tr
:for={row <- @rows}
id={@row_id && @row_id.(row)}
class={["sk-tr", @row_click && "sk-tr--click"]}
phx-click={@row_click && @row_click.(row)}
>
<td :for={col <- @col} class={["sk-td", th_align(col[:align]), col[:class]]}>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="sk-td sk-td--action">
{render_slot(@action, @row_item.(row))}
</td>
</tr>
</tbody>
</table>
<div :if={@empty != [] && empty_rows?(@rows)} class="sk-table-empty">{render_slot(@empty)}</div>
</div>
"""
end
@doc """
A pagination control. Bound to your state — emits `on_page` with the target
page; your LiveView re-queries. Works for offset paging of any source.
The page-number band is **windowed**: it always shows the first and last page
with a `…` gap and `siblings` pages around the current one, so it stays a fixed
width no matter how many pages there are (`1 … 10 11 12 … 21`).
Pass `per_page_options` + `on_per_page` to render a rows-per-page picker inline
with the "Showing…" count; it pushes the chosen size in the `per_page` param:
<.pagination
page={@page} per_page={@per} total={@total} on_page="page"
per_page_options={[10, 25, 50]} on_per_page="per_page"
/>
def handle_event("per_page", %{"per_page" => n}, socket) do
{:noreply, socket |> assign(per: String.to_integer(n), page: 1) |> reload()}
end
"""
attr :id, :string,
default: "sk-pagination",
doc: "stable id (needed for the rows-per-page picker)"
attr :page, :integer, required: true
attr :per_page, :integer, required: true
attr :total, :integer, required: true
attr :on_page, :string, required: true, doc: "event pushed with phx-value-page"
attr :siblings, :integer, default: 1, doc: "page numbers shown either side of the current page"
attr :per_page_options, :list,
default: nil,
doc: "rows-per-page choices (e.g. [10, 25, 50]); renders a picker when set with on_per_page"
attr :on_per_page, :string,
default: nil,
doc: "event pushed (with the new per_page in params) when the rows-per-page picker changes"
attr :class, :any, default: nil
def pagination(assigns) do
pages = max(1, ceil(assigns.total / max(assigns.per_page, 1)))
from = if assigns.total == 0, do: 0, else: (assigns.page - 1) * assigns.per_page + 1
to = min(assigns.page * assigns.per_page, assigns.total)
assigns =
assigns
|> assign(:pages, pages)
|> assign(:from, from)
|> assign(:to, to)
|> assign(:items, page_items(assigns.page, pages, assigns.siblings))
~H"""
<nav class={["sk-pagination", @class]} aria-label="Pagination">
<div class="sk-pagination-info">
<p class="sk-pagination-count">
Showing <b>{@from}</b><b>{@to}</b> of <b>{@total}</b>
</p>
<form :if={@on_per_page && @per_page_options} phx-change={@on_per_page} class="sk-perpage">
<Skua.Components.Select.select
id={"#{@id}-per-page"}
name="per_page"
value={to_string(@per_page)}
options={Enum.map(@per_page_options, fn n -> {"#{n} / page", to_string(n)} end)}
/>
</form>
</div>
<div class="sk-pagination-controls">
<button
type="button"
class="sk-btn sk-btn--ghost sk-btn--icon sk-focusable"
disabled={@page <= 1}
phx-click={@on_page}
phx-value-page={@page - 1}
aria-label="Previous page"
>
<svg class="sk-glyph" viewBox="0 0 24 24" width="16" height="16"><path d="m15 18-6-6 6-6" /></svg>
</button>
<%= for item <- @items do %>
<span :if={item == :gap} class="sk-pagination-gap"></span>
<button
:if={item != :gap}
type="button"
class={[
"sk-btn sk-focusable sk-page-btn",
(item == @page && "sk-btn--primary") || "sk-btn--ghost"
]}
aria-current={(item == @page && "page") || nil}
phx-click={@on_page}
phx-value-page={item}
>
{item}
</button>
<% end %>
<button
type="button"
class="sk-btn sk-btn--ghost sk-btn--icon sk-focusable"
disabled={@page >= @pages}
phx-click={@on_page}
phx-value-page={@page + 1}
aria-label="Next page"
>
<svg class="sk-glyph" viewBox="0 0 24 24" width="16" height="16"><path d="m9 18 6-6-6-6" /></svg>
</button>
</div>
</nav>
"""
end
# --- helpers --------------------------------------------------------------
defp stream?(rows), do: match?(%Phoenix.LiveView.LiveStream{}, rows)
defp empty_rows?(rows) do
cond do
stream?(rows) -> rows.inserts == []
is_list(rows) -> rows == []
true -> false
end
end
defp th_align("right"), do: "sk-align-right"
defp th_align("center"), do: "sk-align-center"
defp th_align(_), do: nil
defp sort_class(%{field: f, dir: dir}, field) when not is_nil(field) and f == field,
do: "is-active is-#{dir}"
defp sort_class(_, _), do: nil
defp aria_sort(%{field: f, dir: dir}, field) when not is_nil(field) and f == field,
do: "#{dir}ending"
defp aria_sort(_, _), do: "none"
# clicking a sorted column flips its direction; a new column starts ascending
defp next_dir(%{field: f, dir: :asc}, field) when f == field, do: "desc"
defp next_dir(%{field: f, dir: :desc}, field) when f == field, do: "asc"
defp next_dir(_, _), do: "asc"
# page-number window with :gap ellipses
defp page_items(_page, pages, _siblings) when pages <= 1, do: [1]
defp page_items(page, pages, siblings) do
window = (page - siblings)..(page + siblings) |> Enum.filter(&(&1 >= 1 and &1 <= pages))
([1] ++ window ++ [pages]) |> Enum.uniq() |> Enum.sort() |> insert_gaps()
end
defp insert_gaps([a, b | rest]) when b - a > 1, do: [a, :gap | insert_gaps([b | rest])]
defp insert_gaps([a | rest]), do: [a | insert_gaps(rest)]
defp insert_gaps([]), do: []
end