# `PhiaUi.Components.DataGrid`
[🔗](https://github.com/charlenopires/PhiaUI/blob/v0.1.17/lib/phia_ui/components/data/data_grid.ex#L1)

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

| Component                  | HTML element | Purpose                                        |
|----------------------------|--------------|------------------------------------------------|
| `data_grid/1`              | `div > table`| Scrollable outer container                     |
| `data_grid_head/1`         | `th`         | Column header; optional sort button            |
| `data_grid_body/1`         | `tbody`      | Data rows; accepts `phx-update="stream"`       |
| `data_grid_row/1`          | `tr`         | Row with hover state and optional styling      |
| `data_grid_cell/1`         | `td`         | Data cell with consistent `px-4 py-3` padding  |
| `data_grid_toolbar/1`      | `div`        | Flex toolbar for search, filters, actions      |
| `data_grid_pagination/1`   | `nav`        | First/prev/next/last + rows-per-page selector  |
| `data_grid_column_toggle/1`| `div`        | Dropdown checklist to show/hide columns        |
| `data_grid_row_checkbox/1` | `td`         | Row selection checkbox cell                    |
| `data_grid_select_all/1`   | `th`         | Select-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

# `data_grid`

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`

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`

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`

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`

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:
  - `:compact` — `px-3 py-1.5`
  - `:normal` — `px-4 py-3` (default)
  - `:comfortable` — `px-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`

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`

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`

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`

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`

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`

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`

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`

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`

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:
  - `:compact` — `px-3 py-1.5 h-9`
  - `:normal` — `px-4 h-11` (default, matches original)
  - `:comfortable` — `px-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`

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`

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`

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`

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`

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`

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.

---

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