PhiaUi.Components.DataGrid (phia_ui v0.1.17)

Copy Markdown View Source

Feature-rich data table with server-side sorting, pagination, toolbar, column visibility toggling, and row selection — all stateless.

Every sub-component is a pure render function. All state (sort direction, current page, selected row IDs, visible columns) lives in your LiveView assigns. Events are sent via phx-click / phx-change and handled with standard handle_event/3 callbacks.

The data_grid_body/1 element is LiveView Streams compatible: add phx-update="stream" to enable incremental DOM patching for large datasets.

Sub-components

ComponentHTML elementPurpose
data_grid/1div > tableScrollable outer container
data_grid_head/1thColumn header; optional sort button
data_grid_body/1tbodyData rows; accepts phx-update="stream"
data_grid_row/1trRow with hover state and optional styling
data_grid_cell/1tdData cell with consistent px-4 py-3 padding
data_grid_toolbar/1divFlex toolbar for search, filters, actions
data_grid_pagination/1navFirst/prev/next/last + rows-per-page selector
data_grid_column_toggle/1divDropdown checklist to show/hide columns
data_grid_row_checkbox/1tdRow selection checkbox cell
data_grid_select_all/1thSelect-all checkbox header cell

Sort direction lifecycle

Sort directions are represented as atoms in Elixir (:none, :asc, :desc) but arrive as strings from phx-value ("asc", "desc", "none"). The next_dir/1 helper cycles the direction on each click:

:none  :asc  :desc  :none

In your handle_event always convert with String.to_existing_atom/1:

def handle_event("sort", %{"key" => key, "dir" => dir}, socket) do
  {:noreply, assign(socket, sort_key: key, sort_dir: String.to_existing_atom(dir))}
end

The use of String.to_existing_atom/1 (not String.to_atom/1) is safe because the only possible values are :asc, :desc, and :none, which are already atoms defined in this module.

Complete example with streams and sorting

defmodule MyAppWeb.UsersLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign(sort_key: "name", sort_dir: :asc)
      |> assign(page: 1, total_pages: 10, page_size: 20)
      |> assign(selected_ids: MapSet.new())
      |> assign(columns: [
           %{key: "name",  label: "Name",  visible: true},
           %{key: "email", label: "Email", visible: true},
           %{key: "role",  label: "Role",  visible: true},
           %{key: "plan",  label: "Plan",  visible: false}
         ])
      |> stream(:users, Accounts.list_users(sort: "name", dir: :asc))

    {:ok, socket}
  end

  def handle_event("sort", %{"key" => key, "dir" => dir}, socket) do
    dir_atom = String.to_existing_atom(dir)
    users = Accounts.list_users(sort: key, dir: dir_atom)

    socket =
      socket
      |> assign(sort_key: key, sort_dir: dir_atom)
      |> stream(:users, users, reset: true)

    {:noreply, socket}
  end

  def handle_event("paginate", %{"page" => page}, socket) do
    page_int = String.to_integer(page)
    users = Accounts.list_users(page: page_int, page_size: socket.assigns.page_size)

    socket =
      socket
      |> assign(page: page_int)
      |> stream(:users, users, reset: true)

    {:noreply, socket}
  end

  def handle_event("page_size", %{"value" => size}, socket) do
    size_int = String.to_integer(size)
    users = Accounts.list_users(page: 1, page_size: size_int)

    socket =
      socket
      |> assign(page: 1, page_size: size_int)
      |> stream(:users, users, reset: true)

    {:noreply, socket}
  end

  def handle_event("select_row", %{"id" => id}, socket) do
    id_int = String.to_integer(id)
    selected =
      if MapSet.member?(socket.assigns.selected_ids, id_int),
        do: MapSet.delete(socket.assigns.selected_ids, id_int),
        else: MapSet.put(socket.assigns.selected_ids, id_int)

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

  def handle_event("select_all", _params, socket) do
    # Toggle: if any selected, deselect all; otherwise select all visible IDs
    selected =
      if MapSet.size(socket.assigns.selected_ids) > 0,
        do: MapSet.new(),
        else: MapSet.new(Enum.map(socket.assigns.streams.users, fn {_, u} -> u.id end))

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

  def handle_event("toggle_column", %{"column" => key}, socket) do
    columns =
      Enum.map(socket.assigns.columns, fn col ->
        if col.key == key, do: %{col | visible: !col.visible}, else: col
      end)

    {:noreply, assign(socket, columns: columns)}
  end

  def render(assigns) do
    ~H"""
    <.data_grid_toolbar>
      <input
        type="text"
        placeholder="Search users…"
        phx-change="search"
        phx-debounce="300"
        class="h-8 rounded-md border border-input bg-background px-3 text-sm w-64"
      />
      <div class="ml-auto flex items-center gap-2">
        <.data_grid_column_toggle
          id="user-col-toggle"
          columns={@columns}
          on_toggle="toggle_column"
        />
      </div>
    </.data_grid_toolbar>

    <.data_grid>
      <thead>
        <tr>
          <.data_grid_select_all
            checked={MapSet.size(@selected_ids) == length(@streams.users)}
            indeterminate={MapSet.size(@selected_ids) > 0 and MapSet.size(@selected_ids) < length(@streams.users)}
            on_check="select_all"
          />
          <.data_grid_head sort_key="name"  sort_dir={if @sort_key == "name",  do: @sort_dir, else: :none} on_sort="sort">Name</.data_grid_head>
          <.data_grid_head sort_key="email" sort_dir={if @sort_key == "email", do: @sort_dir, else: :none} on_sort="sort">Email</.data_grid_head>
          <.data_grid_head sort_key="role"  sort_dir={if @sort_key == "role",  do: @sort_dir, else: :none} on_sort="sort">Role</.data_grid_head>
          <.data_grid_head>Actions</.data_grid_head>
        </tr>
      </thead>
      <.data_grid_body id="users-body" phx-update="stream">
        <.data_grid_row
          :for={{dom_id, user} <- @streams.users}
          id={dom_id}
          class={if user.id in @selected_ids, do: "bg-muted/70"}
        >
          <.data_grid_row_checkbox
            row_id={to_string(user.id)}
            checked={user.id in @selected_ids}
            on_check="select_row"
          />
          <.data_grid_cell><%= user.name %></.data_grid_cell>
          <.data_grid_cell><%= user.email %></.data_grid_cell>
          <.data_grid_cell><%= user.role %></.data_grid_cell>
          <.data_grid_cell>
            <.button variant={:ghost} size={:sm} phx-click="edit_user" phx-value-id={user.id}>
              Edit
            </.button>
          </.data_grid_cell>
        </.data_grid_row>
      </.data_grid_body>
    </.data_grid>

    <.data_grid_pagination
      current_page={@page}
      total_pages={@total_pages}
      page_size={@page_size}
      on_page="paginate"
      on_page_size="page_size"
    />
    """
  end
end

Accessibility

  • Sortable headers emit aria-sort ("ascending", "descending", "none")
  • Row checkboxes use role="checkbox" and aria-checked
  • Select-all uses aria-checked="mixed" for indeterminate state
  • Pagination uses <nav aria-label="Table pagination"> and aria-label on each button

Summary

Functions

Renders the scrollable <table> container.

Flex row of active filter chips with a "Clear all" button.

Renders a visually distinct aggregation row for totals, averages, or sums.

Renders the <tbody> element.

Renders a <td> data cell with consistent px-4 py-3 padding.

Renders a <th> spanning multiple columns for two-row grouped headers.

Renders a column-visibility toggle button with a dropdown checklist.

Three-button toggle to switch between compact / normal / comfortable row density.

Renders a full-width detail panel row for expandable row content.

Renders a client-side CSV export button.

Badge-style chip showing an active filter with a remove button.

Full group header row wrapper for two-level column grouping.

Renders a collapsible row-group header with a chevron and optional count badge.

Renders a <th> column header with optional sort button.

Renders a full-featured pagination navigation bar.

Renders a sticky pinned row for summary totals or frozen header rows.

Renders a <tr> table row with hover state.

Renders a <td> selection checkbox for an individual data grid row.

Renders a <th> select-all checkbox for the data grid header row.

Renders a flex toolbar row positioned above the data grid.

Functions

data_grid(assigns)

Renders the scrollable <table> container.

The outer <div> has overflow-auto to handle wide tables on small screens without breaking the page layout. The table itself is w-full caption-bottom text-sm so it fills its container and uses bottom-positioned captions.

Example

<.data_grid>
  <thead>...</thead>
  <.data_grid_body id="rows" phx-update="stream">
    ...
  </.data_grid_body>
</.data_grid>

Attributes

  • id (:string) - Optional ID for the outer wrapper div; used to build the aria-live status region ID. Defaults to nil.

  • status_message (:string) - Screen reader announcement for sort/filter/page changes. E.g.: 'Sorted by Name, ascending'. Defaults to "".

  • responsive_mode (:atom) - Controls responsive behavior on small screens.

    • :scroll — default horizontal scroll on overflow (via overflow-x-auto)
    • :stack — below md:, each row becomes a vertical card layout
    • :hide_columns — no change to the grid itself; columns with a priority attr can be hidden via hidden md:table-cell in the consuming template

    Defaults to :scroll. Must be one of :scroll, :stack, or :hide_columns.

  • sticky_header (:boolean) - When true, the <thead> is sticky at the top of the scrollable container. Adds sticky top-0 z-10 bg-background to the thead via a child selector.

    Defaults to false.

  • loading (:boolean) - When true, renders a semi-transparent overlay with a spinner icon over the table to indicate an in-flight data fetch. The table content remains visible but non-interactive beneath the overlay.

    Defaults to false.

  • class (:string) - Additional CSS classes for the outer overflow wrapper. Defaults to nil.

  • Global attributes are accepted. HTML attributes forwarded to the <table> element (e.g. aria-label, data-*).

Slots

  • inner_block (required) - Table structure: thead, data_grid_body/1, tfoot.

data_grid_active_filters(assigns)

Flex row of active filter chips with a "Clear all" button.

Renders nothing when filters is empty. Each chip fires on_remove with phx-value-key set to the filter's :value. A "Clear all" button fires on_clear_all.

Example

<.data_grid_active_filters
  :if={@filters != []}
  filters={@filters}
  on_remove="remove_filter"
  on_clear_all="clear_filters"
/>

Where @filters is e.g.:

[%{label: "Role: Admin", value: "role"}, %{label: "Status: Active", value: "status"}]

Attributes

  • filters (:list) (required) - List of active filter maps. Each must have:

    • label — display string (e.g. "Role: Admin")
    • value — key sent to on_remove handler (e.g. "role")
  • on_remove (:string) - phx-click event fired by each chip. Defaults to "remove_filter".

  • clear_all_label (:string) - Label for the clear-all button. Defaults to "Clear all".

  • on_clear_all (:string) - phx-click event for clear all. Defaults to "clear_all_filters".

  • class (:string) - Defaults to nil.

data_grid_aggregation_row(assigns)

Renders a visually distinct aggregation row for totals, averages, or sums.

Use at the bottom of data_grid_body/1 (or after a group's rows). Applies a top double-border and font-medium bg-muted/30 styling.

Example

<.data_grid_aggregation_row>
  <.data_grid_cell class="font-semibold">Total</.data_grid_cell>
  <.data_grid_cell class="tabular-nums">{@sum}</.data_grid_cell>
</.data_grid_aggregation_row>

Attributes

  • class (:string) - Defaults to nil.
  • Global attributes are accepted.

Slots

  • inner_block (required) - data_grid_cell/1 children with aggregate values.

data_grid_body(assigns)

Renders the <tbody> element.

The [&_tr:last-child]:border-0 rule removes the bottom border from the last row so the table does not appear to have a double border with the pagination row below it.

Supports phx-update="stream" for incremental LiveView Streams updates. Always provide an id attribute when using streams so LiveView can track the element across patches.

Example

<.data_grid_body id="user-rows" phx-update="stream">
  <.data_grid_row :for={{dom_id, user} <- @streams.users} id={dom_id}>
    <.data_grid_cell><%= user.name %></.data_grid_cell>
  </.data_grid_row>
</.data_grid_body>

Attributes

  • class (:string) - Additional CSS classes. Defaults to nil.

  • Global attributes are accepted. HTML attributes forwarded to the <tbody> element. The most important use is phx-update="stream" for LiveView Streams:

    <.data_grid_body id="users" phx-update="stream">
      <.data_grid_row :for={{dom_id, user} <- @streams.users} id={dom_id}>
        ...
      </.data_grid_row>
    </.data_grid_body>

    When using streams, id is also required on the <tbody> itself.

Slots

  • inner_block (required) - data_grid_row/1 children.

data_grid_cell(assigns)

Renders a <td> data cell with consistent px-4 py-3 padding.

The [&:has([role=checkbox])]:pr-0 rule removes right padding from cells containing a checkbox (data_grid_row_checkbox/1), keeping checkboxes visually aligned with the select-all header.

Example

<.data_grid_cell>John Smith</.data_grid_cell>
<.data_grid_cell class="text-right font-mono">$1,234.56</.data_grid_cell>
<.data_grid_cell>
  <.badge variant={:secondary}><%= user.role %></.badge>
</.data_grid_cell>

Attributes

  • density (:atom) - Padding density for the data cell:

    • :compactpx-3 py-1.5
    • :normalpx-4 py-3 (default)
    • :comfortablepx-4 py-4

    Defaults to :normal. Must be one of :compact, :normal, or :comfortable.

  • class (:string) - Additional CSS classes for the cell. Defaults to nil.

  • Global attributes are accepted. HTML attributes forwarded to the <td> element (e.g. colspan, rowspan).

Slots

  • inner_block (required) - Cell content — text, badges, buttons, avatars, etc.

data_grid_column_group(assigns)

Renders a <th> spanning multiple columns for two-row grouped headers.

Use inside a top <tr> in <thead> above the individual column headers.

Example

<thead>
  <tr>
    <.data_grid_column_group colspan={2} label="Personal Info" />
    <.data_grid_column_group colspan={3} label="Work Details" />
  </tr>
  <tr>
    <.data_grid_head>Name</.data_grid_head>
    <.data_grid_head>Email</.data_grid_head>
    ...
  </tr>
</thead>

Attributes

  • colspan (:integer) (required) - Number of columns this group spans.
  • label (:string) - Group header text. Defaults to "".
  • class (:string) - Defaults to nil.
  • Global attributes are accepted.

data_grid_column_toggle(assigns)

Renders a column-visibility toggle button with a dropdown checklist.

Clicking the "Columns" button calls JS.toggle/1 to show/hide a dropdown menu listing all columns with checkboxes. Checking/unchecking a box fires on_toggle with phx-value-column set to the column key. All visibility state is managed in the LiveView — this component is stateless.

The dropdown is positioned absolute right-0 top-10 so it opens below and to the right of the button. Ensure the parent has position: relative (the wrapper div adds this automatically).

Example

<.data_grid_column_toggle
  id="user-columns"
  columns={@columns}
  on_toggle="toggle_column"
/>

Attributes

  • id (:string) (required) - Unique element ID. Used to build the dropdown menu ID (e.g. "my-grid-menu") and as the toggle target for JS.toggle/1. Must be unique on the page.

  • columns (:list) (required) - List of column definition maps. Each map must have:

    • key — String used as phx-value-column when the checkbox is clicked
    • label — Human-readable column name shown in the dropdown
    • visible — Boolean controlling the checked state of the checkbox

    Example: [%{key: "name", label: "Name", visible: true}, ...]

  • on_toggle (:string) - phx-click event fired when a column checkbox is toggled. Receives phx-value-column set to the column key. Handle with:

    def handle_event("toggle_column", %{"column" => key}, socket) do
      columns = Enum.map(socket.assigns.columns, fn col ->
        if col.key == key, do: %{col | visible: !col.visible}, else: col
      end)
      {:noreply, assign(socket, columns: columns)}
    end

    Defaults to "toggle_column".

  • class (:string) - Additional CSS classes for the wrapper div. Defaults to nil.

data_grid_density_toggle(assigns)

Three-button toggle to switch between compact / normal / comfortable row density.

Fires on_change with phx-value-density set to the selected value string. Pass the resulting density atom to data_grid_head/1 and data_grid_cell/1.

Example

<.data_grid_toolbar>
  <div class="ml-auto flex items-center gap-2">
    <.data_grid_density_toggle density={@density} on_change="set_density" />
  </div>
</.data_grid_toolbar>

Handler

def handle_event("set_density", %{"density" => d}, socket) do
  {:noreply, assign(socket, density: String.to_existing_atom(d))}
end

Attributes

  • density (:atom) - Currently active density. Defaults to :normal. Must be one of :compact, :normal, or :comfortable.
  • on_change (:string) - phx-click event name. Defaults to "change_density".
  • class (:string) - Defaults to nil.

data_grid_detail_row(assigns)

Renders a full-width detail panel row for expandable row content.

Place immediately after the expanded data_grid_row/1. Toggle visibility in your LiveView by conditionally rendering this row.

Example

<.data_grid_row id={dom_id}>
  ...
  <.data_grid_cell>
    <button phx-click="toggle_row" phx-value-id={row.id}>Expand</button>
  </.data_grid_cell>
</.data_grid_row>
<.data_grid_detail_row :if={row.id in @expanded} colspan={5}>
  <div class="p-4 bg-muted/20">Detailed view for {row.name}</div>
</.data_grid_detail_row>

Attributes

  • colspan (:integer) (required) - Number of columns to span (equals total column count).
  • class (:string) - Defaults to nil.
  • Global attributes are accepted.

Slots

  • inner_block (required)

data_grid_export_button(assigns)

Renders a client-side CSV export button.

The PhiaGridExport hook reads visible <th> columns (skipping those with data-hidden) and all <tr> in the <tbody> to build a CSV blob and trigger a browser download. Entirely client-side — no server call.

Example

<.data_grid_toolbar>
  <div class="ml-auto flex items-center gap-2">
    <.data_grid_export_button
      id="users-export"
      target_id="users-grid"
      filename="users.csv"
    />
  </div>
</.data_grid_toolbar>
<.data_grid id="users-grid">...</.data_grid>

Attributes

  • id (:string) (required) - Unique ID (required for phx-hook).
  • target_id (:string) (required) - ID of the data_grid table to export.
  • filename (:string) - Downloaded filename. Defaults to "export.csv".
  • label (:string) - Defaults to "Export CSV".
  • class (:string) - Defaults to nil.
  • Global attributes are accepted.

data_grid_filter_chip(assigns)

Badge-style chip showing an active filter with a remove button.

Fires on_remove with phx-value-key={value} when the × is clicked.

Example

<.data_grid_filter_chip label="Role: Admin" value="role" on_remove="clear_filter" />

Attributes

  • label (:string) (required) - Display text for the filter chip.
  • value (:string) (required) - phx-value-key sent when chip is removed.
  • on_remove (:string) - phx-click event name. Defaults to "remove_filter".
  • class (:string) - Defaults to nil.

data_grid_group_head(assigns)

Full group header row wrapper for two-level column grouping.

Renders a <tr> that should be placed as the first row in <thead>. Fill it with data_grid_column_group/1 cells.

Example

<.data_grid_group_head>
  <.data_grid_column_group colspan={2} label="Identity" />
  <.data_grid_column_group colspan={2} label="Status" />
</.data_grid_group_head>

Attributes

  • class (:string) - Defaults to nil.
  • Global attributes are accepted.

Slots

  • inner_block (required)

data_grid_group_row(assigns)

Renders a collapsible row-group header with a chevron and optional count badge.

Fires on_toggle with phx-value-value={value} when clicked. Your LiveView maintains the expanded/collapsed state per group key.

Example

<.data_grid_group_row
  label="Active Users"
  count={length(@active_users)}
  expanded={@groups_expanded["active"]}
  on_toggle="toggle_group"
  value="active"
  colspan={5}
/>

Attributes

  • label (:string) (required) - Group name displayed in the header row.
  • count (:any) - Item count shown as a badge (nil to hide). Defaults to nil.
  • expanded (:boolean) - Controls chevron direction. Defaults to true.
  • on_toggle (:string) - phx-click event name. Defaults to "toggle_group".
  • value (:string) (required) - phx-value-value identifying this group.
  • colspan (:integer) - Colspan for the group label cell. Defaults to 1.
  • class (:string) - Defaults to nil.
  • Global attributes are accepted.

data_grid_head(assigns)

Renders a <th> column header with optional sort button.

When :sort_key is set, the header content is wrapped in a <button> that:

  • Fires :on_sort via phx-click
  • Sends phx-value-key={sort_key} and phx-value-dir={next_direction}
  • Renders a sort direction icon (chevrons-up-down, chevron-up, or chevron-down)
  • Sets aria-sort on the <th> for screen reader announcements

Without :sort_key, renders a plain, non-interactive header cell suitable for action columns or columns where sorting is not meaningful.

Example

<%!-- Sortable column --%>
<.data_grid_head sort_key="name" sort_dir={@sort[:name]} on_sort="sort">
  Name
</.data_grid_head>

<%!-- Non-sortable actions column --%>
<.data_grid_head>Actions</.data_grid_head>

Attributes

  • sort_key (:string) - Column key sent as phx-value-key when the sort button is clicked. When nil, the header renders as plain non-interactive text (no sort button). Use the same string your LiveView will receive in handle_event("sort", ...).

    Defaults to nil.

  • sort_dir (:atom) - Current sort direction for this column. Pass :none for all columns except the currently sorted one. Controls the aria-sort attribute and which chevron icon is displayed. The next direction (sent on click) cycles: :none → :asc → :desc → :none.

    Defaults to :none. Must be one of :none, :asc, or :desc.

  • on_sort (:string) - phx-click event name fired when the sort button is clicked. Receives two phx-value params: key (the column key) and dir (the next direction as a string: "asc", "desc", or "none").

    Defaults to "sort".

  • density (:atom) - Padding density for the header cell:

    • :compactpx-3 py-1.5 h-9
    • :normalpx-4 h-11 (default, matches original)
    • :comfortablepx-4 h-12

    Defaults to :normal. Must be one of :compact, :normal, or :comfortable.

  • class (:string) - Additional CSS classes. Defaults to nil.

  • Global attributes are accepted.

Slots

  • inner_block (required) - Column header label text.

data_grid_pagination(assigns)

Renders a full-featured pagination navigation bar.

Includes:

  • Rows per page selector (left side) — fires on_page_size via phx-change
  • Page counter display: "Page X of Y"
  • First / Prev / Next / Last buttons — fire on_page via phx-click

Navigation buttons are automatically disabled (pointer-events-none + opacity-50) when they would navigate out of range:

  • First and Prev are disabled on page 1
  • Next and Last are disabled on the last page

Example

<.data_grid_pagination
  current_page={@page}
  total_pages={@total_pages}
  page_size={@page_size}
  page_size_options={[10, 25, 50]}
  on_page="paginate"
  on_page_size="change_page_size"
/>

LiveView handlers

def handle_event("paginate", %{"page" => page}, socket) do
  {:noreply, assign(socket, page: String.to_integer(page))}
end

def handle_event("change_page_size", %{"value" => size}, socket) do
  {:noreply, assign(socket, page: 1, page_size: String.to_integer(size))}
end

Attributes

  • current_page (:integer) (required) - Current page number (1-indexed). Controls disabled state of prev/first buttons.

  • total_pages (:integer) (required) - Total number of pages. Controls disabled state of next/last buttons.

  • page_size (:integer) - Currently active page size — used to pre-select the correct option in the size selector. Defaults to 10.

  • page_size_options (:list) - Available rows-per-page options for the <select> element. Each option renders as <option value={n}>{n}</option>. Default: [10, 20, 50, 100].

    Defaults to [10, 20, 50, 100].

  • on_page (:string) - phx-click event fired by all navigation buttons (first, prev, next, last). Receives phx-value-page set to the target page number as a string. Handle with: String.to_integer(page).

    Defaults to "paginate".

  • on_page_size (:string) - phx-change event fired when the rows-per-page selector changes. Receives %{"value" => "20"}. Handle with: String.to_integer(size).

    Defaults to "page_size".

  • class (:string) - Additional CSS classes for the nav element. Defaults to nil.

  • Global attributes are accepted.

data_grid_pinned_row(assigns)

Renders a sticky pinned row for summary totals or frozen header rows.

Use :top for pinned column headers within the body, :bottom for totals. Place inside data_grid_body/1.

Example

<.data_grid_body id="rows">
  <.data_grid_row :for={{id, row} <- @streams.rows} id={id}>
    <.data_grid_cell>{row.name}</.data_grid_cell>
  </.data_grid_row>
  <.data_grid_pinned_row position={:bottom}>
    <.data_grid_cell class="font-semibold">Total</.data_grid_cell>
    <.data_grid_cell class="font-semibold tabular-nums">{@total}</.data_grid_cell>
  </.data_grid_pinned_row>
</.data_grid_body>

Attributes

  • position (:atom) - :top sticks to top of table body; :bottom sticks to bottom. Defaults to :bottom. Must be one of :top, or :bottom.
  • class (:string) - Defaults to nil.
  • Global attributes are accepted.

Slots

  • inner_block (required)

data_grid_row(assigns)

Renders a <tr> table row with hover state.

Rows have hover:bg-muted/50 for a subtle highlight on pointer-over and transition-colors so the background change is smooth. The base border-b border-border creates row dividers.

Example

<.data_grid_row id={dom_id} class={if user.suspended, do: "opacity-50"}>
  <.data_grid_cell><%= user.name %></.data_grid_cell>
</.data_grid_row>

Attributes

  • class (:string) - Additional CSS classes for conditional row styling (e.g. "bg-amber-50" to highlight rows with warnings). Applied after the base hover styles.

    Defaults to nil.

  • Global attributes are accepted. HTML attributes forwarded to the <tr> element. When using LiveView Streams, pass id={dom_id} here so LiveView can identify the row.

Slots

  • inner_block (required) - data_grid_cell/1 and data_grid_row_checkbox/1 children.

data_grid_row_checkbox(assigns)

Renders a <td> selection checkbox for an individual data grid row.

The checkbox cell has a fixed width (w-10) and minimal padding (p-2) to stay compact. The role="checkbox" attribute enables the CSS rule [&:has([role=checkbox])]:pr-0 on parent cells to trim padding.

Pass checked={row_id in @selected_ids} and maintain the selected set in your LiveView. There is no internal state here.

Example

<.data_grid_row_checkbox
  row_id={to_string(user.id)}
  checked={user.id in @selected_ids}
  on_check="select_row"
/>

Attributes

  • row_id (:string) (required) - Row identifier sent as phx-value-id when the checkbox is clicked. Typically the record's database ID as a string: to_string(user.id). Your handle_event receives it as a string and should convert back with String.to_integer/1 or pattern-match directly.

  • checked (:boolean) - Whether this row is currently selected. Derive from your LiveView state: checked={user.id in @selected_ids} (for MapSet) or checked={user.id in @selected_ids} (for a plain list).

    Defaults to false.

  • on_check (:string) - phx-click event fired when the checkbox is toggled. Receives phx-value-id set to row_id. Toggle the ID in/out of your selection set in the handler.

    Defaults to "select_row".

  • class (:string) - Additional CSS classes for the <td> wrapper. Defaults to nil.

data_grid_select_all(assigns)

Renders a <th> select-all checkbox for the data grid header row.

When indeterminate is true, aria-checked is set to "mixed" to communicate partial selection to screen readers. This is the WAI-ARIA pattern for tri-state checkboxes. Note that the visual indeterminate state (the dash inside the checkbox) requires JavaScript on the client side — the PhiaDataGrid hook (if installed) handles this automatically.

Example

<.data_grid_select_all
  checked={@all_selected}
  indeterminate={@some_selected}
  on_check="select_all"
/>

Attributes

  • checked (:boolean) - Whether all visible rows are selected. Set to true when every row ID is in @selected_ids.

    Defaults to false.

  • indeterminate (:boolean) - When true, renders aria-checked="mixed" to signal partial selection to assistive technology. Set to true when some (but not all) rows are selected. Takes precedence over :checked for the aria-checked value.

    Defaults to false.

  • on_check (:string) - phx-click event fired when the checkbox is clicked. Receives no phx-value params — the handler should toggle between selecting all and deselecting all based on current state.

    Defaults to "select_all".

  • class (:string) - Additional CSS classes for the <th> cell. Defaults to nil.

data_grid_toolbar(assigns)

Renders a flex toolbar row positioned above the data grid.

The toolbar uses flex items-center justify-between with gap-2 so that left-side controls (search, filters) and right-side controls (column toggle, export) sit at opposite ends naturally.

Example

<.data_grid_toolbar>
  <input type="text" placeholder="Search…" phx-change="search" phx-debounce="300"
         class="h-8 w-64 rounded-md border border-input bg-background px-3 text-sm" />
  <div class="ml-auto flex items-center gap-2">
    <.data_grid_column_toggle id="col-toggle" columns={@columns} on_toggle="toggle_column" />
    <.button variant={:outline} size={:sm} phx-click="export">Export CSV</.button>
  </div>
</.data_grid_toolbar>

Attributes

  • class (:string) - Additional CSS classes for the toolbar wrapper. Defaults to nil.
  • Global attributes are accepted.

Slots

  • inner_block (required) - Toolbar content — typically a search input on the left and action buttons (column toggle, filters, export) on the right. Use ml-auto on the right group to push it to the trailing edge.