Skip to content
Brok UI

Loading…

No results

Data Table

Open source

A client-side table whose column headers open a sort and filter panel that knows the column's kind (A → Z, Low → high, Oldest → newest; conditions like contains, equals, between, before; a pick list of the column's values, committed with Apply), with active-filter chips, a Columns menu, row selection, layout-stable pagination, badge tones, row actions, inline edits, density, sticky header and expandable rows.

Version
v1.10.7
Stability
stable
License
MIT
Related
Admin Table
Table
Pagination
Checkbox
Badge
Popover

Preview

Name: Email: Role: Status:

No results

Try adjusting your search or filters.

Loading
previews.components.data-table.default.blade.php Blade
<x-ui.data-table class="max-w-2xl" />

Installation

terminal
php artisan ui:add data-table

Note

This component ships an Alpine behavior module at resources/js/ui/data-table.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './data-table.js';

Registry contract

php artisan ui:add data-table writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/data-table.blade.php
  • js resources/js/ui/data-table.js
  • blade resources/views/components/ui/data-table/filters.blade.php
  • blade resources/views/components/ui/data-table/columns.blade.php
  • blade resources/views/components/ui/data-table/selection.blade.php
  • blade resources/views/components/ui/data-table/expansion.blade.php
  • blade resources/views/components/ui/data-table/pagination.blade.php
Registry dependencies
badge button checkbox dropdown pagination popover select table
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

data-table.md
# Brok UI: Data Table (`data-table`)

A client-side table whose column headers open a sort and filter panel that knows the column's kind (A → Z, Low → high, Oldest → newest; conditions like contains, equals, between, before; a pick list of the column's values, committed with Apply), with active-filter chips, a Columns menu, row selection, layout-stable pagination, badge tones, row actions, inline edits, density, sticky header and expandable rows.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add data-table
```

## Usage

```blade
<x-ui.data-table class="max-w-2xl" />
```

## Props

- `columns` (array, default `[…]`) — Column definitions, labels, keys, and sorting behavior.
- `rows` (array, default `[…]`) — Serializable row records rendered by the table.
- `perPage` (int, default `5`) — Rows displayed per client-side page.
- `selectable` (bool, default `true`) — Leading checkbox column; the selection is keyed to row identity and reported through data-table:select.
- `density` (comfortable|compact, default `comfortable`) — Row height and cell padding.
- `stickyHeader` (bool, default `false`) — Keeps the header row visible while the body scrolls inside a capped height.
- `expandable` (bool, default `false`) — Leading disclosure column that opens a detail row under each record.
- `loading` (bool, default `false`) — Shows the shared announced loading row and blocks table interaction.
- `loadingLabel` (string, default `Loading data…`) — Declared by @props in the shipped Blade source.
- `emptyHeading` (string, default `No results`) — Heading for the shared empty row.
- `emptyDescription` (string, default `Try adjusting your search or filters.`) — Guidance for the shared empty row.
- `maxClientRows` (int, default `500`) — Hard client-side row limit. Use admin-table (server-driven) above this boundary.
- `columnToggle` (bool, default `false`) — Adds a Columns menu to the toolbar and a Hide column entry to each header panel.
- `hidden` (array, default `[]`) — Column keys folded away on first render.
- `layout` (auto|fixed, default `auto`) — fixed honours each column's width and keeps widths stable while filtering; auto sizes columns to their content.
- `sortable` (bool, default `true`) — Every column sorts from its header panel unless it sets columns[].sortable to false; false turns sorting off for the table.
- `filters` (bool, default `true`) — Every column filters from its header panel unless it sets columns[].filter to false; false turns filtering off for the table and hides the active-filter chips.
- `pageSize` (int, default `5`) — Rows shown per page.
- `label` (string, default `Filter rows`) — Declared by @props in the registry Blade source.
- `placeholder` (string, default `Filter…`) — Declared by @props in the registry Blade source.

## Use when

- Use when users need exact comparison across rows and columns, plus sorting or filtering.
- You hand the table its rows as data (at most a few hundred) and want sorting, filtering, paging, selection and inline edits to happen in the browser, with the host notified through events.
- Cells are scalar values a prop-driven renderer can express: text, mono, number, date, badge with a tone, icon actions.
- Column relationships matter more than a card-based presentation.

## Avoid when

- Do not use a table for narrative browsing or small mobile contexts where cards or lists are easier to scan.
- Use when filtering, sorting, selection, and row actions are first-class tasks.
- Rows live on the server, the set is large, or bulk actions must be authorized, idempotent and confirmed; use admin-table, whose cells are your Blade and whose sort, filter and bulk actions round-trip to the host.
- The data is primarily hierarchical; use tree or nested list patterns.
- Small-screen users need task-oriented summaries; consider cards or a purpose-built responsive block.

## Anti-patterns

- Using cards when exact comparison is primary
- Forcing wide tables into narrow layouts without adaptation

## Rules

- Use the `<brok:data-table>` tag (or `<x-ui.data-table>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/data-table
- Registry JSON (files, props, contract): https://brokui.dev/r/open/data-table.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

compact.blade.php Blade
<div class="w-full">
    <x-ui.data-table density="compact" class="max-w-2xl" />
</div>
expandable.blade.php Blade
<div class="w-full">
    <x-ui.data-table :expandable="true" class="max-w-2xl" />
</div>
full.blade.php Blade
{{-- Everything at once. Click a column header: sort reads by the column's kind
     (A → Z, Low → high, Oldest → newest), the filter offers a condition
     (contains, equals, between, before…) and the column's values to pick
     from; active filters show as chips; a Columns menu; a badge column with
     tones; a mono column; an editable column; row actions; the events a host
     listens to. --}}
<div
    x-data="{ log: '—' }"
    @data-table:action="log = `${$event.detail.key} → ${$event.detail.row.name}`"
    @data-table:edit="log = `${$event.detail.key} of ${$event.detail.row.name}: ${$event.detail.previous} → ${$event.detail.value}`"
    class="w-full space-y-3"
>
    <x-ui.data-table
        :per-page="5"
        :column-toggle="true"
        :columns="[
            ['key' => 'id', 'label' => 'ID', 'type' => 'mono', 'width' => '6rem'],
            ['key' => 'name', 'label' => 'Customer', 'editable' => true],
            ['key' => 'plan', 'label' => 'Plan', 'filter' => 'select'],
            ['key' => 'status', 'label' => 'Status', 'filter' => 'select', 'type' => 'badge', 'tones' => ['Active' => 'positive', 'Trial' => 'info', 'Past due' => 'attention', 'Churned' => 'negative']],
            ['key' => 'mrr', 'label' => 'MRR', 'type' => 'number', 'align' => 'end', 'width' => '7rem'],
            ['key' => 'since', 'label' => 'Since', 'width' => '8rem'],
            ['key' => 'verified', 'label' => 'Verified', 'width' => '6rem'],
            ['key' => 'actions', 'label' => 'Actions', 'type' => 'actions', 'align' => 'end', 'rowLabel' => 'name', 'actions' => [
                ['key' => 'open', 'label' => 'Open', 'icon' => '<path d=\'M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\' />'],
                ['key' => 'archive', 'label' => 'Archive', 'icon' => '<rect x=\'2\' y=\'3\' width=\'20\' height=\'5\' rx=\'1\' /><path d=\'M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4\' />'],
                ['key' => 'delete', 'label' => 'Delete', 'destructive' => true, 'icon' => '<path d=\'M3 6h18\' /><path d=\'M8 6V4h8v2\' /><path d=\'M19 6l-1 14H6L5 6\' />'],
            ]],
        ]"
        :rows="[
            ['id' => 'C-1042', 'name' => 'Jane Doe', 'plan' => 'Pro', 'status' => 'Active', 'mrr' => 49, 'since' => '2025-11-03', 'verified' => 'Yes'],
            ['id' => 'C-1043', 'name' => 'Alex Brown', 'plan' => 'Starter', 'status' => 'Trial', 'mrr' => 0, 'since' => '2026-09-01', 'verified' => 'No'],
            ['id' => 'C-1044', 'name' => 'Maria Garcia', 'plan' => 'Enterprise', 'status' => 'Past due', 'mrr' => 499, 'since' => '2024-02-14', 'verified' => 'Yes'],
            ['id' => 'C-1045', 'name' => 'Liam Smith', 'plan' => 'Pro', 'status' => 'Active', 'mrr' => 49, 'since' => '2025-06-20', 'verified' => 'Yes'],
            ['id' => 'C-1046', 'name' => 'Noah Johnson', 'plan' => 'Starter', 'status' => 'Churned', 'mrr' => 0, 'since' => '2024-10-09', 'verified' => 'No'],
            ['id' => 'C-1047', 'name' => 'Olivia Lee', 'plan' => 'Enterprise', 'status' => 'Active', 'mrr' => 899, 'since' => '2023-12-01', 'verified' => 'Yes'],
            ['id' => 'C-1048', 'name' => 'Emma Wilson', 'plan' => 'Pro', 'status' => 'Trial', 'mrr' => 0, 'since' => '2026-08-28', 'verified' => 'No'],
            ['id' => 'C-1049', 'name' => 'Sofia Martin', 'plan' => 'Starter', 'status' => 'Active', 'mrr' => 19, 'since' => '2025-03-15', 'verified' => 'Yes'],
        ]"
    />
    <p class="text-xs text-muted-foreground">{{ __('Host received:') }} <span data-slot="preview-event-log" class="font-mono text-foreground" x-text="log"></span></p>
</div>
rich.blade.php Blade
{{-- Expandable rows, a badge column, a numeric column; headers open their sort and filter. --}}
<div class="w-full">
    <x-ui.data-table
        density="compact"
        :expandable="true"
        class="max-w-3xl"
        :columns="[
            ['key' => 'name', 'label' => 'Name', 'sortable' => true],
            ['key' => 'email', 'label' => 'Email', 'sortable' => false],
            ['key' => 'status', 'label' => 'Status', 'sortable' => true, 'type' => 'badge', 'filter' => 'select', 'tones' => ['Active' => 'positive', 'Invited' => 'info', 'Suspended' => 'negative']],
            ['key' => 'visits', 'label' => 'Visits', 'sortable' => true, 'align' => 'end', 'type' => 'number', 'filter' => 'number'],
        ]"
        :rows="[
            ['name' => 'Jane Doe', 'email' => 'jane@example.com', 'status' => 'Active', 'visits' => '1,204'],
            ['name' => 'Alex Brown', 'email' => 'alex@example.com', 'status' => 'Invited', 'visits' => '320'],
            ['name' => 'Maria Garcia', 'email' => 'maria@example.com', 'status' => 'Suspended', 'visits' => '57'],
            ['name' => 'Liam Smith', 'email' => 'liam@example.com', 'status' => 'Active', 'visits' => '889'],
            ['name' => 'Noah Johnson', 'email' => 'noah@example.com', 'status' => 'Active', 'visits' => '2,431'],
            ['name' => 'Olivia Lee', 'email' => 'olivia@example.com', 'status' => 'Invited', 'visits' => '14'],
        ]"
    />
</div>
sticky.blade.php Blade
<div class="w-full">
    <x-ui.data-table
        :sticky-header="true"
        :per-page="20"
        class="max-w-2xl"
        :rows="[
            ['name' => 'Jane Doe', 'email' => 'jane@example.com', 'role' => 'Admin', 'status' => 'Active'],
            ['name' => 'Alex Brown', 'email' => 'alex@example.com', 'role' => 'Member', 'status' => 'Active'],
            ['name' => 'Maria Garcia', 'email' => 'maria@example.com', 'role' => 'Member', 'status' => 'Invited'],
            ['name' => 'Liam Smith', 'email' => 'liam@example.com', 'role' => 'Editor', 'status' => 'Active'],
            ['name' => 'Noah Johnson', 'email' => 'noah@example.com', 'role' => 'Member', 'status' => 'Suspended'],
            ['name' => 'Olivia Lee', 'email' => 'olivia@example.com', 'role' => 'Admin', 'status' => 'Active'],
            ['name' => 'Emma Wilson', 'email' => 'emma@example.com', 'role' => 'Editor', 'status' => 'Invited'],
            ['name' => 'Sofia Martin', 'email' => 'sofia@example.com', 'role' => 'Member', 'status' => 'Active'],
            ['name' => 'Lucas Davis', 'email' => 'lucas@example.com', 'role' => 'Member', 'status' => 'Active'],
            ['name' => 'Mia Rodriguez', 'email' => 'mia@example.com', 'role' => 'Editor', 'status' => 'Invited'],
            ['name' => 'Ethan Clark', 'email' => 'ethan@example.com', 'role' => 'Admin', 'status' => 'Suspended'],
            ['name' => 'Ava Lewis', 'email' => 'ava@example.com', 'role' => 'Member', 'status' => 'Active'],
        ]"
    />
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
columns array […] Column definitions, labels, keys, and sorting behavior.
rows array […] Serializable row records rendered by the table.
perPage int 5 Rows displayed per client-side page.
selectable bool true Leading checkbox column; the selection is keyed to row identity and reported through data-table:select.
density comfortable | compact comfortable Row height and cell padding.
stickyHeader bool false Keeps the header row visible while the body scrolls inside a capped height.
expandable bool false Leading disclosure column that opens a detail row under each record.
loading bool false Shows the shared announced loading row and blocks table interaction.
loadingLabel string Loading data… Declared by @props in the shipped Blade source.
emptyHeading string No results Heading for the shared empty row.
emptyDescription string Try adjusting your search or filters. Guidance for the shared empty row.
maxClientRows int 500 Hard client-side row limit. Use admin-table (server-driven) above this boundary.
columnToggle bool false Adds a Columns menu to the toolbar and a Hide column entry to each header panel.
hidden array [] Column keys folded away on first render.
layout auto | fixed auto fixed honours each column's width and keeps widths stable while filtering; auto sizes columns to their content.
sortable bool true Every column sorts from its header panel unless it sets columns[].sortable to false; false turns sorting off for the table.
filters bool true Every column filters from its header panel unless it sets columns[].filter to false; false turns filtering off for the table and hides the active-filter chips.
pageSize int 5 Rows shown per page.
label string Filter rows Declared by @props in the registry Blade source.
placeholder string Filter… Declared by @props in the registry Blade source.

Slots

  • default — Compound mode. Compose filters, columns, selection, expansion, and pagination parts explicitly.
  • x-ui.data-table.filters — Installed subcomponent from the registry item.
  • x-ui.data-table.columns — Installed subcomponent from the registry item.
  • x-ui.data-table.selection — Installed subcomponent from the registry item.
  • x-ui.data-table.expansion — Installed subcomponent from the registry item.
  • x-ui.data-table.pagination — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

badge badge-label data-table data-table-active-filters data-table-columns data-table-columns-menu data-table-edit data-table-expansion data-table-filters data-table-noscript data-table-pagination data-table-selection data-table-values pagination-item pagination-link pagination-next pagination-previous table-body table-cell table-row

Behavior

  • Provides a compatibility preset and an explicit compound mode for client-side tables of at most 500 rows by default.
  • Selection state remains keyed to row identity rather than visual position.
  • Uses the shared Table State row for announced loading and empty results.
  • Fails before rendering when the client row limit is exceeded and directs the application to Livewire Data Table.
  • Client-side sorting, filtering, row selection, and pagination powered by Alpine.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Structured comparison

Compare structured records across consistent attributes.

Use when

  • Use when users need exact comparison across rows and columns, plus sorting or filtering.
  • You hand the table its rows as data (at most a few hundred) and want sorting, filtering, paging, selection and inline edits to happen in the browser, with the host notified through events.
  • Cells are scalar values a prop-driven renderer can express: text, mono, number, date, badge with a tone, icon actions.
  • Column relationships matter more than a card-based presentation.

Avoid when

  • Do not use a table for narrative browsing or small mobile contexts where cards or lists are easier to scan.
  • Use when filtering, sorting, selection, and row actions are first-class tasks.
  • Rows live on the server, the set is large, or bulk actions must be authorized, idempotent and confirmed; use admin-table, whose cells are your Blade and whose sort, filter and bulk actions round-trip to the host.
  • The data is primarily hierarchical; use tree or nested list patterns.
  • Small-screen users need task-oriented summaries; consider cards or a purpose-built responsive block.

Use instead

  • List or cards for narrative browsing

Anti-patterns

  • Using cards when exact comparison is primary
  • Forcing wide tables into narrow layouts without adaptation
Anatomy
toolbar table header sortable column row selection control pagination empty state
Theming hooks
table header selected row pagination

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Enter Escape
Focus
managed
  • Column sort controls expose the current sort direction.
  • Selection checkboxes need row-specific accessible names.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="data-table-{{ $record->id }}">
    <x-ui.data-table class="max-w-2xl" />
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:data-table
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/data-table.blade.php Blade
@props([
    'columns' => [
        ['key' => 'name', 'label' => 'Name', 'sortable' => true],
        ['key' => 'email', 'label' => 'Email', 'sortable' => false],
        ['key' => 'role', 'label' => 'Role', 'sortable' => true],
        ['key' => 'status', 'label' => 'Status', 'sortable' => true],
    ],
    'rows' => [
        ['name' => 'Jane Doe', 'email' => 'jane@example.com', 'role' => 'Admin', 'status' => 'Active'],
        ['name' => 'Alex Brown', 'email' => 'alex@example.com', 'role' => 'Member', 'status' => 'Active'],
        ['name' => 'Maria Garcia', 'email' => 'maria@example.com', 'role' => 'Member', 'status' => 'Invited'],
        ['name' => 'Liam Smith', 'email' => 'liam@example.com', 'role' => 'Editor', 'status' => 'Active'],
        ['name' => 'Noah Johnson', 'email' => 'noah@example.com', 'role' => 'Member', 'status' => 'Suspended'],
        ['name' => 'Olivia Lee', 'email' => 'olivia@example.com', 'role' => 'Admin', 'status' => 'Active'],
        ['name' => 'Emma Wilson', 'email' => 'emma@example.com', 'role' => 'Editor', 'status' => 'Invited'],
        ['name' => 'Sofia Martin', 'email' => 'sofia@example.com', 'role' => 'Member', 'status' => 'Active'],
    ],
    'perPage' => 5,
    'selectable' => true,
    'density' => 'comfortable',
    'stickyHeader' => false,
    'expandable' => false,
    'loading' => false,
    'loadingLabel' => 'Loading data…',
    'emptyHeading' => 'No results',
    'emptyDescription' => 'Try adjusting your search or filters.',
    'maxClientRows' => 500,
    // A "Columns" menu that folds columns away (columns with hideable=false stay).
    'columnToggle' => false,
    // Column keys hidden on load.
    'hidden' => [],
    // auto | fixed — fixed keeps column widths stable across pages and sorts
    // (set `width` per column, e.g. '12rem' or '20%').
    'layout' => 'auto',
    // Every column sorts and filters from its header panel unless a column
    // says otherwise (`sortable => false`, `filter => false`). Turn either off
    // for the whole table here; with filters off the chip row is gone too.
    'sortable' => true,
    'filters' => true,
])

@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    // Density → fixed cell padding (no variable splicing into arbitrary classes),
    // and one row height per density so a text row, a badge row, an actions row
    // and a filler row are all the same height (no layout shift on paging).
    $cellPad = $density === 'compact' ? 'px-2 py-1' : 'px-3 py-2';
    $rowHeight = $density === 'compact' ? '[&_tbody_tr]:h-10' : '[&_tbody_tr]:h-12';

    // Column alignment → fixed logical-property text classes (RTL-safe).
    $alignMap = [
        'start' => 'text-start',
        'center' => 'text-center',
        'end' => 'text-end',
    ];

    // Detail-row colspan must cover selection + expand toggle + data columns.
    $totalCols = ($selectable ? 1 : 0) + ($expandable ? 1 : 0) + count($columns);

    // Resolve each column's data kind (text, number, date, boolean) so the
    // header panel offers the right sort labels and conditions: an explicit
    // `filter` kind wins, then the render `type`, then the values themselves.
    $rowValues = array_values($rows);
    $inferKind = static function (string $key) use ($rowValues): string {
        $values = array_values(array_filter(array_map(static fn ($r) => is_array($r) ? ($r[$key] ?? null) : null, $rowValues), static fn ($v) => $v !== null && $v !== ''));
        if ($values === []) {
            return 'text';
        }
        $all = static fn (callable $test): bool => count(array_filter($values, $test)) === count($values);
        if ($all(static fn ($v) => is_bool($v) || in_array(mb_strtolower((string) $v), ['yes', 'no', 'true', 'false'], true))) {
            return 'boolean';
        }
        if ($all(static fn ($v) => is_int($v) || is_float($v) || (is_string($v) && preg_match('/^-?\d+([.,]\d+)?$/', $v) === 1))) {
            return 'number';
        }
        if ($all(static fn ($v) => is_string($v) && preg_match('/^\d{4}-\d{2}-\d{2}/', $v) === 1)) {
            return 'date';
        }
        return 'text';
    };
    $columns = array_map(static function (array $col) use ($inferKind, $sortable, $filters): array {
        $type = $col['type'] ?? 'text';
        $filter = $col['filter'] ?? $filters;
        $explicitKind = is_string($filter) && in_array($filter, ['text', 'number', 'date', 'boolean'], true) ? $filter : null;
        $kind = $explicitKind ?? ($type === 'number' ? 'number' : $inferKind((string) $col['key']));
        $col['kind'] = $kind;
        $col['sortable'] = $type !== 'actions' && (bool) ($col['sortable'] ?? $sortable);
        $col['filterable'] = $type !== 'actions' && $filter !== false && $filter !== null;
        // A boolean or `filter => 'select'` column offers only its value list.
        $col['valuesOnly'] = $kind === 'boolean' || $filter === 'select';

        return $col;
    }, array_values($columns));
    $hasColumnFilters = collect($columns)->contains(static fn (array $c): bool => $c['filterable']);

    // Sort labels per kind and the condition labels, translated once.
    $sortLabels = [
        'text' => [__('A → Z'), __('Z → A')],
        'number' => [__('Low → high'), __('High → low')],
        'date' => [__('Oldest → newest'), __('Newest → oldest')],
        'boolean' => [__('No → Yes'), __('Yes → No')],
    ];
    $opLabels = [
        'contains' => __('contains'), 'notContains' => __('does not contain'), 'equals' => __('equals'), 'notEquals' => __('does not equal'),
        'startsWith' => __('starts with'), 'endsWith' => __('ends with'), 'empty' => __('is empty'), 'notEmpty' => __('is not empty'),
        'eq' => __('equals'), 'neq' => __('does not equal'), 'gt' => __('greater than'), 'gte' => __('at least'), 'lt' => __('less than'), 'lte' => __('at most'),
        'between' => __('between'), 'on' => __('on'), 'before' => __('before'), 'after' => __('after'),
    ];
    $fieldClass = 'h-8 w-full min-w-0 rounded-sm border border-input bg-background px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring';
    $panelItem = 'flex min-h-8 w-full items-center gap-2 rounded-sm px-2 py-1 text-start text-sm text-popover-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none';
    $layout = $layout === 'fixed' ? 'fixed' : 'auto';

    // Badge tones for `type => 'badge'` columns: the badge recipe's soft
    // variants keyed by the semantic tone a `tones` map speaks.
    $badge = $styles['badge'] ?? [];
    $toneClasses = [];
    foreach (['positive', 'attention', 'negative', 'info', 'neutral'] as $toneName) {
        $variant = $badge['tones'][$toneName] ?? 'soft-neutral';
        $toneClasses[$toneName] = trim(($badge['base'] ?? '').' '.($badge['variants'][$variant] ?? '').' '.($badge['sizes']['sm'] ?? ''));
    }
    // Column keys → a Blade-safe id suffix for the filter inputs.
    $uid = 'data-table-'.\Illuminate\Support\Str::random(6);

    if (count($rows) > (int) $maxClientRows) {
        throw new \InvalidArgumentException(__('Data Table supports at most :count client-side rows. Use Livewire Data Table for larger datasets.', ['count' => (int) $maxClientRows]));
    }
@endphp

<div
    x-data="uiDataTable({ rows: @js(array_values($rows)), columns: @js(array_values($columns)), perPage: {{ (int) $perPage }}, selectable: {{ $selectable ? 'true' : 'false' }}, expandable: {{ $expandable ? 'true' : 'false' }}, hidden: @js(array_values((array) $hidden)), toneClasses: @js($toneClasses), labels: @js($opLabels) })"

    data-slot="data-table"
    data-max-client-rows="{{ (int) $maxClientRows }}"
    @if ($loading) aria-busy="true" @endif
    {{ $attributes->merge(['class' => 'w-full space-y-4']) }}
>
    <noscript>
        <p data-slot="data-table-noscript" role="status" class="break-words rounded-md border border-border bg-muted px-4 py-3 text-sm text-foreground">
            {{ __('Client Data Table needs JavaScript. Use Livewire Data Table when a complete no-JavaScript table is required.') }}
        </p>
    </noscript>
    @if (! $slot->isEmpty())
        {{ $slot }}
    @else
    {{-- Toolbar: free-text filter across all columns, the per-column filter
         toggle, the Columns menu and the result count. --}}
    <div class="flex flex-wrap items-center gap-2">
        <div class="relative w-full max-w-xs">
            <svg class="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
            <input
                type="search"
                x-model="filter"
                @input="onFilter()"
                aria-label="{{ __('Filter rows') }}"
                placeholder="{{ __('Filter…') }}"
                @disabled($loading)
                class="h-9 w-full rounded-md border border-input bg-background ps-9 pe-3 text-sm text-foreground shadow-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background motion-reduce:transition-none"
            />
        </div>

        @if ($columnToggle)
            <x-ui.dropdown data-slot="data-table-columns-menu">
                <x-ui.dropdown.trigger class="inline-flex h-8 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm font-medium text-foreground shadow-xs transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none">
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 3v18M15 3v18" /></svg>
                    {{ __('Columns') }}
                </x-ui.dropdown.trigger>
                <x-ui.dropdown.content align="end" class="min-w-[12rem]">
                    <x-ui.dropdown.label>{{ __('Show columns') }}</x-ui.dropdown.label>
                    @foreach ($columns as $col)
                        @if (($col['hideable'] ?? true) && ($col['type'] ?? 'text') !== 'actions')
                            <label class="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-2 text-sm text-popover-foreground hover:bg-accent">
                                <x-ui.checkbox
                                    role="checkbox"
                                    x-bind:checked="isVisible({{ \Illuminate\Support\Js::from($col['key']) }})"
                                    @change="toggleColumn({{ \Illuminate\Support\Js::from($col['key']) }})"
                                />
                                <span>{{ __($col['label']) }}</span>
                            </label>
                        @endif
                    @endforeach
                </x-ui.dropdown.content>
            </x-ui.dropdown>
        @endif

        <span class="ms-auto min-w-0 max-w-full break-words text-end text-sm tabular-nums text-muted-foreground" x-text="`${sorted().length} {{ __('result(s)') }}`"></span>
    </div>

    @if ($hasColumnFilters)
        {{-- Active column filters as chips; each clears on its ×. --}}
        <div data-slot="data-table-active-filters" class="flex flex-wrap items-center gap-2" x-show="activeFilterCount() > 0" x-cloak>
            @foreach ($columns as $col)
                @if ($col['filterable'])
                    <span x-show="hasFilter({{ \Illuminate\Support\Js::from($col['key']) }})" class="inline-flex h-7 max-w-full items-center gap-1 rounded-full border border-border bg-muted ps-3 pe-1 text-xs text-foreground">
                        <span class="text-muted-foreground">{{ __($col['label']) }}:</span>
                        <span class="truncate" x-text="filterSummary({{ \Illuminate\Support\Js::from($col) }})"></span>
                        <button type="button" @click="clearFilter({{ \Illuminate\Support\Js::from($col['key']) }})" aria-label="{{ __('Clear :column filter', ['column' => $col['label']]) }}" class="inline-flex size-5 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none">
                            <svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
                        </button>
                    </span>
                @endif
            @endforeach
            <button type="button" @click="clearFilters()" class="text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">{{ __('Clear all') }}</button>
        </div>
    @endif

    <div @class(['rounded-md border border-border', 'max-h-96 overflow-auto' => $stickyHeader])>
        <x-ui.table :class="trim($rowHeight.' '.($layout === 'fixed' ? 'table-fixed' : ''))">
            <x-ui.table.header @class(['sticky top-0 z-10 bg-card' => $stickyHeader])>
                <x-ui.table.row>
                    @if ($expandable)
                        <x-ui.table.head class="w-px {{ $cellPad }}">
                            <span class="sr-only">{{ __('Expand row') }}</span>
                        </x-ui.table.head>
                    @endif

                    @if ($selectable)
                        <x-ui.table.head @class([$cellPad, '[&:has([role=checkbox])]:w-px' => $layout !== 'fixed', '!w-12' => $layout === 'fixed', 'bg-card' => $stickyHeader])>
                            <x-ui.checkbox
                                role="checkbox"
                                @change="toggleAll($event.target.checked)"
                                x-bind:checked="allSelected()"
                                :disabled="$loading"
                                aria-label="{{ __('Select all rows') }}"
                            />
                        </x-ui.table.head>
                    @endif

                    @foreach ($columns as $col)
                        @php $alignClass = $alignMap[$col['align'] ?? ''] ?? null; @endphp
                        <x-ui.table.head
                            @class([$cellPad, $alignClass, 'bg-card' => $stickyHeader, 'w-px' => ($col['type'] ?? '') === 'actions' && $layout !== 'fixed'])
                            :style="! empty($col['width']) ? 'width: '.$col['width'] : ((($col['type'] ?? '') === 'actions' && $layout === 'fixed') ? 'width: '.(count((array) ($col['actions'] ?? [])) * 2.25 + 1.5).'rem' : null)"
                            x-show="isVisible({{ \Illuminate\Support\Js::from($col['key']) }})"
                            x-bind:aria-sort="ariaSort({{ \Illuminate\Support\Js::from($col['key']) }})"
                        >
                            @php
                                $key = \Illuminate\Support\Js::from($col['key']);
                                $colJs = \Illuminate\Support\Js::from($col);
                                $canSort = $col['sortable'];
                                $canFilter = $col['filterable'];
                                $hasMenu = $canSort || $canFilter;
                                $panelId = $uid.'-menu-'.$col['key'];
                                [$ascLabel, $descLabel] = $sortLabels[$col['kind']] ?? $sortLabels['text'];
                            @endphp
                            @if ($hasMenu)
                                {{-- The header opens a panel with the column's sort and filter; a
                                     sort arrow and a filter dot on the label show what is active. --}}
                                <x-ui.popover group="{{ $uid }}-headers" anchored class="!max-w-none">
                                    <x-ui.popover.trigger
                                        class="group/head inline-flex h-8 !max-w-none items-center gap-1 whitespace-nowrap rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none {{ ($col['align'] ?? '') === 'end' ? '-me-2 flex-row-reverse' : '-ms-2' }}"
                                        aria-label="{{ $canFilter ? __(':column: sort and filter', ['column' => $col['label']]) : __(':column: sort', ['column' => $col['label']]) }}"
                                        x-bind:data-active="(sortKey === {{ $key }} || hasFilter({{ $key }})) ? 'true' : 'false'"
                                    >
                                        <span>{{ __($col['label']) }}</span>
                                        @if ($canSort)
                                            <svg x-show="sortKey !== {{ $key }}" class="size-3 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 15 5 5 5-5M7 9l5-5 5 5" /></svg>
                                            <svg x-show="sortKey === {{ $key }} && sortDir === 'asc'" x-cloak class="size-3 shrink-0 text-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
                                            <svg x-show="sortKey === {{ $key }} && sortDir === 'desc'" x-cloak class="size-3 shrink-0 text-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12l7 7 7-7" /></svg>
                                        @endif
                                        @if ($canFilter)
                                            <svg x-show="hasFilter({{ $key }})" x-cloak class="size-3 shrink-0 text-foreground" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" /></svg>
                                        @endif
                                    </x-ui.popover.trigger>

                                    <x-ui.popover.content align="{{ ($col['align'] ?? '') === 'end' ? 'end' : 'start' }}" class="w-64 p-1" x-init="$watch('open', (value) => value && beginFilter({{ $key }}))">
                                        @if ($canSort)
                                            <p class="px-2 pt-1 pb-1 text-2xs font-semibold uppercase tracking-wide text-muted-foreground">{{ __('Sort') }}</p>
                                            <button type="button" @click="setSort({{ $key }}, 'asc'); closeAndFocus()" class="{{ $panelItem }}" x-bind:aria-pressed="sortKey === {{ $key }} && sortDir === 'asc' ? 'true' : 'false'">
                                                <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
                                                <span class="flex-1">{{ $ascLabel }}</span>
                                                <svg x-show="sortKey === {{ $key }} && sortDir === 'asc'" x-cloak class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5L20 7" /></svg>
                                            </button>
                                            <button type="button" @click="setSort({{ $key }}, 'desc'); closeAndFocus()" class="{{ $panelItem }}" x-bind:aria-pressed="sortKey === {{ $key }} && sortDir === 'desc' ? 'true' : 'false'">
                                                <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12l7 7 7-7" /></svg>
                                                <span class="flex-1">{{ $descLabel }}</span>
                                                <svg x-show="sortKey === {{ $key }} && sortDir === 'desc'" x-cloak class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5L20 7" /></svg>
                                            </button>
                                        @endif

                                        @if ($canFilter)
                                            @if ($canSort)<div class="my-1 h-px bg-border" role="none"></div>@endif
                                            @unless ($col['valuesOnly'])
                                                <p class="px-2 pt-1 pb-1 text-2xs font-semibold uppercase tracking-wide text-muted-foreground">{{ __('Filter') }}</p>
                                                {{-- A condition: the kind's operators, then one or two operands. --}}
                                                <div class="flex flex-col gap-2 px-2 pb-2">
                                                    <x-ui.select size="sm" x-model="drafts[{{ $key }}].op" @change="setOperator({{ $key }}, $event.target.value)" aria-label="{{ __(':column condition', ['column' => $col['label']]) }}" class="!h-8 !shadow-none">
                                                        <template x-for="op in operators({{ $colJs }})" :key="op">
                                                            <option x-bind:value="op" x-text="labels[op] ?? op"></option>
                                                        </template>
                                                    </x-ui.select>
                                                    <div class="flex items-center gap-2" x-show="needsValue(drafts[{{ $key }}].op)">
                                                        <input
                                                            type="{{ $col['kind'] === 'number' ? 'number' : ($col['kind'] === 'date' ? 'date' : 'search') }}"
                                                            x-model="drafts[{{ $key }}].value"
                                                            @keydown.enter.prevent="applyFilter({{ $key }}); closeAndFocus()"
                                                            x-bind:aria-label="needsRange(drafts[{{ $key }}].op) ? '{{ __('From') }}' : '{{ __('Value') }}'"
                                                            x-bind:placeholder="needsRange(drafts[{{ $key }}].op) ? '{{ __('From') }}' : '{{ __('Value…') }}'"
                                                            class="{{ $fieldClass }}"
                                                        />
                                                        <input
                                                            type="{{ $col['kind'] === 'number' ? 'number' : ($col['kind'] === 'date' ? 'date' : 'search') }}"
                                                            x-show="needsRange(drafts[{{ $key }}].op)"
                                                            x-model="drafts[{{ $key }}].value2"
                                                            @keydown.enter.prevent="applyFilter({{ $key }}); closeAndFocus()"
                                                            aria-label="{{ __('Until') }}"
                                                            placeholder="{{ __('Until') }}"
                                                            class="{{ $fieldClass }}"
                                                        />
                                                    </div>
                                                </div>
                                            @endunless
                                            {{-- The value list: pick any of the column's values, Excel-style. --}}
                                            <div class="flex flex-col" data-slot="data-table-values">
                                                @unless ($col['valuesOnly'])<div class="my-1 h-px bg-border" role="none"></div>@endunless
                                                <div class="flex items-center justify-between gap-3 px-2 pt-1 pb-1">
                                                    <p class="text-2xs font-semibold uppercase tracking-wide text-muted-foreground">{{ $col['valuesOnly'] ? __('Filter') : __('Values') }}</p>
                                                    <span class="flex items-center gap-3 text-xs">
                                                        <button type="button" @click="setAllValues({{ $key }}, true)" class="text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">{{ __('All') }}</button>
                                                        <button type="button" @click="setAllValues({{ $key }}, false)" class="text-muted-foreground underline-offset-4 hover:text-foreground hover:underline" x-show="drafts[{{ $key }}].values.length" x-cloak>{{ __('None') }}</button>
                                                    </span>
                                                </div>
                                                <div class="flex items-center gap-2 px-2 pb-1" x-show="distinctValues({{ $key }}).length > 8">
                                                    <input type="search" x-model="valueSearch[{{ $key }}]" aria-label="{{ __('Search values') }}" placeholder="{{ __('Search…') }}" class="{{ $fieldClass }}" />
                                                </div>
                                                <div class="flex max-h-48 flex-col overflow-y-auto">
                                                    <template x-for="option in listedValues({{ $key }})" :key="option">
                                                        <label class="{{ $panelItem }} cursor-pointer">
                                                            <input type="checkbox" class="size-4 shrink-0 rounded-sm border-input accent-primary" x-bind:value="option" x-bind:checked="drafts[{{ $key }}].values.includes(option)" @change="toggleValue({{ $key }}, option)" />
                                                            <span class="truncate" x-text="option"></span>
                                                        </label>
                                                    </template>
                                                    <p class="px-2 py-1 text-xs text-muted-foreground" x-show="listedValues({{ $key }}).length === 0">{{ __('No values') }}</p>
                                                </div>
                                            </div>
                                        @endif

                                        @if ($canSort || ($columnToggle && ($col['hideable'] ?? true)))
                                            <div class="my-1 h-px bg-border" role="none"></div>
                                        @endif
                                        @if ($canSort)
                                            <button type="button" @click="setSort({{ $key }}, null); closeAndFocus()" x-show="sortKey === {{ $key }}" x-cloak class="{{ $panelItem }} text-muted-foreground">
                                                <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 15 5 5 5-5M7 9l5-5 5 5" /></svg>
                                                {{ __('Clear sort') }}
                                            </button>
                                        @endif
                                        @if ($columnToggle && ($col['hideable'] ?? true))
                                            <button type="button" @click="toggleColumn({{ $key }}); close()" class="{{ $panelItem }} text-muted-foreground">
                                                <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.9 4.2A10 10 0 0 1 12 4c7 0 10 8 10 8a13 13 0 0 1-1.7 2.6M6.6 6.6A13.5 13.5 0 0 0 2 12s3 8 10 8a9.7 9.7 0 0 0 5.4-1.6M2 2l20 20" /></svg>
                                                {{ __('Hide column') }}
                                            </button>
                                        @endif
                                        @if ($canFilter)
                                            {{-- The filter is a draft until Apply; Clear drops it at once. --}}
                                            <div class="mt-1 flex items-center justify-between gap-2 border-t border-border px-2 pt-2 pb-1">
                                                <button type="button" @click="clearFilter({{ $key }}); closeAndFocus()" x-show="hasFilter({{ $key }}) || draftHasFilter({{ $key }})" x-cloak class="text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">{{ __('Clear filter') }}</button>
                                                <x-ui.button type="button" size="sm" class="ms-auto !h-8" @click="applyFilter({{ $key }}); closeAndFocus()">{{ __('Apply') }}</x-ui.button>
                                            </div>
                                        @endif
                                    </x-ui.popover.content>
                                </x-ui.popover>
                            @else
                                {{ __($col['label']) }}
                            @endif
                        </x-ui.table.head>
                    @endforeach
                </x-ui.table.row>

            </x-ui.table.header>

            @if ($loading)
                <tbody data-slot="table-body" class="[&_tr:last-child]:border-0">
                    <x-ui.table.state state="loading" :colspan="$totalCols" :heading="$loadingLabel" />
                </tbody>
            @else
                {{-- Body rows are rendered with raw <tbody>/<tr>/<td> mirroring the table
                     primitive's data-slots and classes, because Blade components cannot iterate
                     Alpine `x-for` state cleanly. One <tbody> per row keeps the x-for root single
                     while letting an optional detail <tr> follow its data <tr>. Classes copied
                     from table/{body,row,cell}.blade.php. --}}
                <template x-for="(row, i) in pageRows()" :key="i">
                    <tbody data-slot="table-body" class="[&_tr:last-child]:border-0">
                    <tr
                        data-slot="table-row"
                        class="border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted motion-reduce:transition-none"
                        x-bind:data-state="isSelected(rowIndex(row)) ? 'selected' : null"
                    >
                        @if ($expandable)
                            <td data-slot="table-cell" class="{{ $cellPad }} w-px align-middle">
                                <button
                                    type="button"
                                    @click="toggleExpand(rowIndex(row))"
                                    x-bind:aria-expanded="isExpanded(rowIndex(row)) ? 'true' : 'false'"
                                    x-bind:aria-label="isExpanded(rowIndex(row)) ? '{{ __('Collapse row') }}' : '{{ __('Expand row') }}'"
                                    class="inline-flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
                                >
                                    <svg
                                        class="size-4 transition-transform rtl:-scale-x-100 motion-reduce:transition-none"
                                        x-bind:class="isExpanded(rowIndex(row)) ? 'rotate-90' : ''"
                                        viewBox="0 0 24 24"
                                        fill="none"
                                        stroke="currentColor"
                                        stroke-width="2"
                                        stroke-linecap="round"
                                        stroke-linejoin="round"
                                        aria-hidden="true"
                                    >
                                        <path d="m9 18 6-6-6-6"></path>
                                    </svg>
                                </button>
                            </td>
                        @endif

                        @if ($selectable)
                            <td data-slot="table-cell" class="{{ $cellPad }} align-middle [&:has([role=checkbox])]:w-px">
                                <x-ui.checkbox
                                    role="checkbox"
                                    x-bind:value="rowIndex(row)"
                                    @change="toggleRow(rowIndex(row), $event.target.checked)"
                                    x-bind:checked="isSelected(rowIndex(row))"
                                    x-bind:aria-label="`{{ __('Select row') }} ${rowIndex(row) + 1}`"
                                />
                            </td>
                        @endif

                        @foreach ($columns as $col)
                            @php
                                $cellAlign = $alignMap[$col['align'] ?? ''] ?? null;
                                $type = $col['type'] ?? 'text';
                                $key = \Illuminate\Support\Js::from($col['key']);
                            @endphp
                            <td
                                data-slot="table-cell"
                                {{-- Cells keep to one line; a table wider than its frame scrolls
                                     sideways instead of folding ids and dates onto two lines. --}}
                                @class([$cellPad, 'whitespace-nowrap align-middle text-foreground', $cellAlign, $col['class'] ?? null, 'w-px' => $type === 'actions', 'font-mono text-xs' => $type === 'mono', 'tabular-nums' => $type === 'number'])
                                x-show="isVisible({{ $key }})"
                            >
                                @if ($type === 'badge')
                                    {{-- A badge whose tone follows the value (`tones` => ['Active' => 'positive', …]). --}}
                                    <span data-slot="badge" x-bind:class="toneClass({{ \Illuminate\Support\Js::from($col) }}, row[{{ $key }}])"><span data-slot="badge-label" class="min-w-0 truncate" x-text="row[{{ $key }}]"></span></span>
                                @elseif ($type === 'actions')
                                    {{-- Row actions: icon-only buttons (developer-authored SVG path data),
                                         the name in the accessible label and title; each dispatches
                                         data-table:action { key, row, index }. --}}
                                    <span class="inline-flex items-center gap-1">
                                        @foreach ((array) ($col['actions'] ?? []) as $action)
                                            <button
                                                type="button"
                                                @click="action({{ \Illuminate\Support\Js::from($action['key'] ?? '') }}, row)"
                                                x-bind:aria-label="`{{ $action['label'] ?? '' }} ${row[{{ \Illuminate\Support\Js::from($col['rowLabel'] ?? ($columns[0]['key'] ?? '')) }}] ?? ''}`"
                                                title="{{ $action['label'] ?? '' }}"
                                                @class(['inline-flex size-8 items-center justify-center rounded-md transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none', 'text-muted-foreground hover:text-foreground' => empty($action['destructive']), 'text-destructive-text' => ! empty($action['destructive'])])
                                            >
                                                @if (! empty($action['icon']))
                                                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">{!! $action['icon'] !!}</svg>
                                                @else
                                                    <span class="text-xs font-medium">{{ $action['label'] ?? '' }}</span>
                                                @endif
                                            </button>
                                        @endforeach
                                    </span>
                                @elseif (! empty($col['editable']))
                                    {{-- Inline edit: click the value to edit; Enter or blur commits and
                                         dispatches data-table:edit, Escape cancels. --}}
                                    <template x-if="!isEditing(row, {{ $key }})">
                                        <button type="button" @click="startEdit(row, {{ $key }})" class="group/edit -mx-2 inline-flex max-w-full items-center gap-2 rounded-sm px-2 py-1 text-start hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring" x-bind:aria-label="`{{ __('Edit :column', ['column' => $col['label']]) }}: ${row[{{ $key }}] ?? ''}`">
                                            <span class="truncate" x-text="row[{{ $key }}]"></span>
                                            <svg class="size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/edit:opacity-100 group-focus-visible/edit:opacity-100 motion-reduce:transition-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
                                        </button>
                                    </template>
                                    <template x-if="isEditing(row, {{ $key }})">
                                        <input
                                            type="text"
                                            data-slot="data-table-edit"
                                            x-model="draft"
                                            @keydown.enter.prevent="commitEdit(row, {{ $key }})"
                                            @keydown.escape.prevent="cancelEdit()"
                                            @blur="commitEdit(row, {{ $key }})"
                                            aria-label="{{ __('Edit :column', ['column' => $col['label']]) }}"
                                            class="-mx-2 h-8 w-full min-w-24 rounded-sm border border-input bg-background px-2 text-sm text-foreground outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
                                        />
                                    </template>
                                @else
                                    <span x-text="row[{{ $key }}]"></span>
                                @endif
                            </td>
                        @endforeach
                    </tr>

                    @if ($expandable)
                        <tr
                            data-slot="table-row"
                            class="border-b border-border bg-muted/30"
                            x-show="isExpanded(rowIndex(row))"
                            x-cloak
                        >
                            <td data-slot="table-cell" class="{{ $cellPad }} align-top" colspan="{{ $totalCols }}">
                                <dl class="grid gap-x-6 gap-y-2 sm:grid-cols-2">
                                    @foreach ($columns as $col)
                                        <div class="flex flex-col gap-0.5">
                                            <dt class="text-xs font-medium text-muted-foreground">{{ __($col['label']) }}</dt>
                                            <dd class="text-sm text-foreground" x-text="row[@js($col['key'])]"></dd>
                                        </div>
                                    @endforeach
                                </dl>
                            </td>
                        </tr>
                    @endif
                    </tbody>
                </template>

                {{-- Filler rows pad a short last page to the page height, so paging and
                     filtering never move the controls below the table. --}}
                <template x-for="n in fillerRows()" :key="'filler-' + n">
                    <tbody data-slot="table-body" aria-hidden="true" class="[&_tr:last-child]:border-0">
                        <tr data-slot="table-row" class="border-b border-border">
                            <td data-slot="table-cell" class="{{ $cellPad }}" colspan="{{ $totalCols }}"><span class="invisible text-sm">&nbsp;</span></td>
                        </tr>
                    </tbody>
                </template>

                <tbody
                    data-slot="table-body"
                    class="[&_tr:last-child]:border-0"
                    x-show="pageRows().length === 0"
                    x-cloak
                >
                    <x-ui.table.state
                        state="empty"
                        :colspan="$totalCols"
                        :heading="$emptyHeading"
                        :description="$emptyDescription"
                    />
                </tbody>
            @endif
        </x-ui.table>
    </div>

    <div
        class="flex flex-col items-center justify-between gap-4 sm:flex-row"
        @unless ($loading) x-show="sorted().length > 0" @endunless
    >
        @if ($selectable)
            <span
                class="text-sm text-muted-foreground"
                x-show="selectable"
                x-text="`${selected.length} {{ __('of') }} ${rows.length} {{ __('row(s) selected') }}`"
            ></span>
        @endif

        <x-ui.pagination class="sm:mx-0 sm:w-auto sm:justify-end">
            <x-ui.pagination.content>
                <x-ui.pagination.item>
                    <a
                        href="#"
                        data-slot="pagination-previous"
                        aria-label="{{ __('Go to previous page') }}"
                        @click.prevent="prevPage()"
                        x-bind:aria-disabled="page === 1 ? 'true' : null"
                        class="inline-flex h-9 items-center justify-center gap-1 rounded-md ps-2 pe-3 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none aria-disabled:pointer-events-none aria-disabled:opacity-50"
                    >
                        <svg class="size-4 rtl:-scale-x-100" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                            <path d="m15 18-6-6 6-6"></path>
                        </svg>
                        <span>{{ __('Previous') }}</span>
                    </a>
                </x-ui.pagination.item>

                <template x-for="p in pageNumbers()" :key="p">
                    <li data-slot="pagination-item">
                        <a
                            href="#"
                            data-slot="pagination-link"
                            @click.prevent="goto(p)"
                            x-bind:data-active="p === page ? 'true' : 'false'"
                            x-bind:aria-current="p === page ? 'page' : null"
                            x-bind:class="p === page ? 'border-border bg-accent text-accent-foreground' : 'border-transparent text-foreground hover:bg-muted'"
                            class="inline-flex h-9 min-w-9 items-center justify-center gap-1 rounded-md border px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
                            x-text="p"
                        ></a>
                    </li>
                </template>

                <x-ui.pagination.item>
                    <a
                        href="#"
                        data-slot="pagination-next"
                        aria-label="{{ __('Go to next page') }}"
                        @click.prevent="nextPage()"
                        x-bind:aria-disabled="page === totalPages() ? 'true' : null"
                        class="inline-flex h-9 items-center justify-center gap-1 rounded-md ps-3 pe-2 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none aria-disabled:pointer-events-none aria-disabled:opacity-50"
                    >
                        <span>{{ __('Next') }}</span>
                        <svg class="size-4 rtl:-scale-x-100" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                            <path d="m9 18 6-6-6-6"></path>
                        </svg>
                    </a>
                </x-ui.pagination.item>
            </x-ui.pagination.content>
        </x-ui.pagination>
    </div>
    @endif
</div>
resources/js/ui/data-table.js JS
/**
 * Data table behavior (interactive layer for the data-table component).
 *
 * Composes over the static `table` primitive: it owns row data, column
 * definitions, sort state, the free-text and per-column filters, column
 * visibility, the current page, the selection set, expanded rows and inline
 * edits. The Blade layer renders the header statically and iterates
 * `pageRows()` / `columns` with Alpine `x-for` for the body, so this module is
 * the single source of truth.
 *
 * Events (dispatched from the root, bubbling):
 *   data-table:action { key, row, index }  — a row action was pressed
 *   data-table:edit   { key, row, index, value, previous } — an inline edit was committed
 *   data-table:select { selected }         — the selection changed
 *
 * Self-registers on `alpine:init` so import order does not matter — import this
 * file once from your bundle (e.g. resources/js/ui/index.js).
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiDataTable', (config = {}) => ({
        rows: config.rows ?? [],
        columns: config.columns ?? [],
        perPage: config.perPage ?? 5,
        selectable: config.selectable ?? true,
        expandable: config.expandable ?? false,
        page: 1,
        filter: '',
        // Per-column filters keyed by column key. Every filter is one shape:
        //   { op, value, value2, values }
        // `op` is a condition from the column's kind (text: contains, equals,
        // startsWith…; number: eq, gt, between…; date: on, before, after,
        // between), `value`/`value2` its operands, and `values` the picked
        // entries of the column's value list (an "in" filter, Excel-style).
        columnFilters: {},
        // The panel edits a draft of the column's filter; Apply commits it to
        // `columnFilters`, closing the panel otherwise discards it.
        drafts: {},
        // Search text inside a column's value list, keyed by column key.
        valueSearch: {},
        sortKey: null,
        sortDir: 'asc',
        selected: [],
        expanded: [],
        // Column keys folded away from the "Columns" menu.
        hidden: config.hidden ?? [],
        // Inline edit in progress: { index, key } and the draft value.
        editing: null,
        draft: '',
        root: null,
        // Badge recipe classes per semantic tone (positive, attention, negative,
        // info, neutral), handed in by the Blade layer from _styles.php.
        toneClasses: config.toneClasses ?? {},
        // Translated labels for the conditions and the chip summaries.
        labels: config.labels ?? {},

        init() {
            this.root = this.$el;
            if (config.columns) {
                this.columns = config.columns;
            }
            this.columns.forEach((c) => {
                this.columnFilters[c.key] = this.emptyFilter(c);
                this.drafts[c.key] = this.emptyFilter(c);
                this.valueSearch[c.key] = '';
            });
        },

        /** Copy the committed filter into the panel's draft when it opens. */
        beginFilter(key) {
            const f = this.columnFilters[key];
            if (f) this.drafts[key] = { ...f, values: [...f.values] };
            this.valueSearch[key] = '';
        },

        /** Commit the panel's draft. */
        applyFilter(key) {
            const d = this.drafts[key];
            if (d) this.columnFilters[key] = { ...d, values: [...d.values] };
            this.page = 1;
        },

        /** True when the panel's draft holds anything (drives the Apply/Clear row). */
        draftHasFilter(key) {
            const d = this.drafts[key];
            if (!d) return false;
            if (!this.needsValue(d.op)) return true;
            if (this.needsRange(d.op)) return String(d.value) !== '' || String(d.value2) !== '';
            return String(d.value) !== '' || (d.values ?? []).length > 0;
        },

        /** The data kind of a column: text, number, date or boolean. */
        kindOf(col) {
            return col.kind ?? 'text';
        },

        /** Conditions a column's kind offers, in menu order. */
        operators(col) {
            const kind = this.kindOf(col);
            if (kind === 'number') return ['between', 'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'empty', 'notEmpty'];
            if (kind === 'date') return ['between', 'on', 'before', 'after', 'empty', 'notEmpty'];
            return ['contains', 'notContains', 'equals', 'notEquals', 'startsWith', 'endsWith', 'empty', 'notEmpty'];
        },

        /** True when the condition takes an operand. */
        needsValue(op) {
            return op !== 'empty' && op !== 'notEmpty';
        },

        /** True when the condition takes two operands. */
        needsRange(op) {
            return op === 'between';
        },

        emptyFilter(col) {
            return { op: this.operators(col)[0], value: '', value2: '', values: [] };
        },

        /** True when the condition part of a column's filter holds anything. */
        hasCondition(key) {
            const f = this.columnFilters[key];
            if (!f) return false;
            if (!this.needsValue(f.op)) return true;
            if (this.needsRange(f.op)) return String(f.value) !== '' || String(f.value2) !== '';
            return String(f.value) !== '';
        },

        /** True when a column's filter holds anything. */
        hasFilter(key) {
            const f = this.columnFilters[key];
            if (!f) return false;
            return this.hasCondition(key) || (f.values ?? []).length > 0;
        },

        /** One line describing a column's active filter, for the chip row. */
        filterSummary(col) {
            const f = this.columnFilters[col.key];
            if (!f || !this.hasFilter(col.key)) return '';
            const parts = [];
            if (this.hasCondition(col.key)) {
                const kind = this.kindOf(col);
                const symbols = { eq: '=', neq: '≠', gt: '>', gte: '≥', lt: '<', lte: '≤' };
                if (!this.needsValue(f.op)) {
                    parts.push(this.labels[f.op] ?? f.op);
                } else if (this.needsRange(f.op)) {
                    if (String(f.value) !== '' && String(f.value2) !== '') parts.push(`${f.value} – ${f.value2}`);
                    else if (String(f.value) !== '') parts.push(`≥ ${f.value}`);
                    else parts.push(`≤ ${f.value2}`);
                } else if (kind === 'number' && symbols[f.op]) {
                    parts.push(`${symbols[f.op]} ${f.value}`);
                } else if (kind === 'date') {
                    parts.push(f.op === 'on' ? f.value : `${this.labels[f.op] ?? f.op} ${f.value}`);
                } else {
                    parts.push(`${this.labels[f.op] ?? f.op} "${f.value}"`);
                }
            }
            if ((f.values ?? []).length) {
                const list = f.values.join(', ');
                // The values list already uses commas, so a condition part in
                // front is set off with parentheses instead of another
                // separator that would read as a third, nested list.
                parts.push(parts.length ? `(${list})` : list);
            }
            return parts.join(' ');
        },

        clearFilter(key) {
            const col = this.columns.find((c) => c.key === key);
            if (col) {
                this.columnFilters[key] = this.emptyFilter(col);
                this.drafts[key] = this.emptyFilter(col);
            }
            this.valueSearch[key] = '';
            this.page = 1;
        },

        /** Reset the draft's operands when the condition changes shape. */
        setOperator(key, op) {
            const f = this.drafts[key];
            if (!f) return;
            f.op = op;
            if (!this.needsValue(op)) {
                f.value = '';
                f.value2 = '';
            }
        },

        toggleValue(key, value) {
            const f = this.drafts[key];
            if (!f) return;
            f.values = f.values.includes(value) ? f.values.filter((v) => v !== value) : [...f.values, value];
        },

        /** Pick every value currently listed (after the list search), or none. */
        setAllValues(key, on) {
            const f = this.drafts[key];
            if (!f) return;
            f.values = on ? [...this.listedValues(key)] : [];
        },

        setSort(key, dir) {
            if (dir === null) {
                if (this.sortKey === key) this.sortKey = null;
            } else {
                this.sortKey = key;
                this.sortDir = dir;
            }
            this.page = 1;
        },

        // ---- Columns -------------------------------------------------------

        isVisible(key) {
            return !this.hidden.includes(key);
        },

        toggleColumn(key) {
            this.hidden = this.isVisible(key) ? [...this.hidden, key] : this.hidden.filter((k) => k !== key);
        },

        /** Columns that may be folded away (an actions column never is). */
        toggleableColumns() {
            return this.columns.filter((c) => c.hideable !== false && c.type !== 'actions');
        },

        visibleColumns() {
            return this.columns.filter((c) => this.isVisible(c.key));
        },

        hasColumnFilters() {
            return this.columns.some((c) => c.filterable);
        },

        /** Distinct values of a column (or its declared `options`), sorted by kind. */
        distinctValues(key) {
            const col = this.columns.find((c) => c.key === key);
            if (col?.options) return col.options.map(String);
            const values = [...new Set(this.rows.map((r) => String(r[key] ?? '')))].filter((v) => v !== '');
            if (this.kindOf(col ?? {}) === 'number') {
                return values.sort((a, b) => parseFloat(a) - parseFloat(b));
            }
            return values.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
        },

        /** The value list after the list's own search box. */
        listedValues(key) {
            const q = String(this.valueSearch[key] ?? '').trim().toLowerCase();
            const all = this.distinctValues(key);
            return q === '' ? all : all.filter((v) => v.toLowerCase().includes(q));
        },

        activeFilterCount() {
            return this.columns.filter((c) => this.hasFilter(c.key)).length;
        },

        clearFilters() {
            this.filter = '';
            this.columns.forEach((c) => {
                this.columnFilters[c.key] = this.emptyFilter(c);
                this.drafts[c.key] = this.emptyFilter(c);
            });
            this.page = 1;
        },

        // ---- Rows ----------------------------------------------------------

        /** Parse a cell for a numeric comparison (currency signs and separators dropped). */
        toNumber(value) {
            const n = parseFloat(String(value ?? '').replace(/[^0-9.-]/g, ''));
            return Number.isNaN(n) ? null : n;
        },

        /** The ISO date part of a cell, for a string comparison. */
        toDate(value) {
            const s = String(value ?? '').trim();
            if (s === '') return '';
            const t = Date.parse(s);
            return Number.isNaN(t) ? s.slice(0, 10) : new Date(t).toISOString().slice(0, 10);
        },

        /** Does one cell pass its column's condition? */
        matchesCondition(col, value) {
            const f = this.columnFilters[col.key];
            const raw = String(value ?? '').trim();
            if (f.op === 'empty') return raw === '';
            if (f.op === 'notEmpty') return raw !== '';
            const kind = this.kindOf(col);
            if (kind === 'number') {
                const n = this.toNumber(value);
                const a = f.value === '' ? null : parseFloat(f.value);
                const b = f.value2 === '' ? null : parseFloat(f.value2);
                if (f.op === 'between') return (a === null || (n !== null && n >= a)) && (b === null || (n !== null && n <= b));
                if (n === null || a === null) return false;
                return { eq: n === a, neq: n !== a, gt: n > a, gte: n >= a, lt: n < a, lte: n <= a }[f.op] ?? true;
            }
            if (kind === 'date') {
                const d = this.toDate(value);
                if (f.op === 'between') return (f.value === '' || d >= f.value) && (f.value2 === '' || d <= f.value2);
                if (d === '' || f.value === '') return false;
                return { on: d === f.value, before: d < f.value, after: d > f.value }[f.op] ?? true;
            }
            const text = raw.toLowerCase();
            const needle = String(f.value).toLowerCase();
            return {
                contains: text.includes(needle),
                notContains: !text.includes(needle),
                equals: text === needle,
                notEquals: text !== needle,
                startsWith: text.startsWith(needle),
                endsWith: text.endsWith(needle),
            }[f.op] ?? true;
        },

        /** Rows matching the free-text filter across every column value and the per-column filters. */
        filtered() {
            const q = this.filter.trim().toLowerCase();
            return this.rows.filter((row) => {
                if (q !== '' && !this.columns.some((c) => String(row[c.key] ?? '').toLowerCase().includes(q))) {
                    return false;
                }
                return this.columns.every((c) => {
                    if (!this.hasFilter(c.key)) return true;
                    const f = this.columnFilters[c.key];
                    const value = row[c.key];
                    if (this.hasCondition(c.key) && !this.matchesCondition(c, value)) return false;
                    if (f.values.length && !f.values.includes(String(value ?? ''))) return false;
                    return true;
                });
            });
        },

        /** Filter, then order by the active sort column the way its kind compares. */
        sorted() {
            const base = this.filtered();
            if (!this.sortKey) {
                return base;
            }
            const k = this.sortKey;
            const col = this.columns.find((c) => c.key === k) ?? {};
            const kind = this.kindOf(col);
            const dir = this.sortDir === 'asc' ? 1 : -1;
            return [...base].sort((a, b) => {
                if (kind === 'number') {
                    const na = this.toNumber(a[k]);
                    const nb = this.toNumber(b[k]);
                    if (na === null && nb === null) return 0;
                    if (na === null) return 1;
                    if (nb === null) return -1;
                    return (na - nb) * dir;
                }
                if (kind === 'date') {
                    return this.toDate(a[k]).localeCompare(this.toDate(b[k])) * dir;
                }
                return String(a[k] ?? '').localeCompare(String(b[k] ?? ''), undefined, { numeric: true, sensitivity: 'base' }) * dir;
            });
        },

        /** Reset to the first page whenever a filter changes. */
        onFilter() {
            this.page = 1;
        },

        totalPages() {
            return Math.max(1, Math.ceil(this.sorted().length / this.perPage));
        },

        /** The slice of sorted rows visible on the current page. */
        pageRows() {
            const s = this.sorted();
            const start = (this.page - 1) * this.perPage;
            return s.slice(start, start + this.perPage);
        },

        /**
         * Empty rows that pad a short last page to the page height, so paging
         * and filtering never move the pagination row up and down.
         */
        fillerRows() {
            const count = this.sorted().length === 0 ? 0 : Math.max(0, this.perPage - this.pageRows().length);
            return Array.from({ length: count }, (_, i) => i);
        },

        pageNumbers() {
            const total = this.totalPages();
            const start = Math.max(1, Math.min(this.page - 2, total - 4));
            const end = Math.min(total, start + 4);
            return Array.from({ length: end - start + 1 }, (_, i) => start + i);
        },

        /** Toggle/advance the active sort column and reset to the first page. */
        sort(key) {
            if (this.sortKey === key) {
                this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
            } else {
                this.sortKey = key;
                this.sortDir = 'asc';
            }
            this.page = 1;
        },

        /** aria-sort token for a column header. */
        ariaSort(key) {
            const col = this.columns.find((c) => c.key === key);
            if (!col || !col.sortable) {
                return null;
            }
            return this.sortKey === key ? (this.sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
        },

        goto(p) {
            this.page = Math.min(this.totalPages(), Math.max(1, p));
        },

        prevPage() {
            this.goto(this.page - 1);
        },

        nextPage() {
            this.goto(this.page + 1);
        },

        /** Stable original index of a row, so selection survives sort/paging. */
        rowIndex(row) {
            return this.rows.indexOf(row);
        },

        // ---- Selection -----------------------------------------------------

        isSelected(i) {
            return this.selected.includes(i);
        },

        toggleRow(i, checked) {
            if (checked) {
                if (!this.isSelected(i)) {
                    this.selected.push(i);
                }
            } else {
                this.selected = this.selected.filter((x) => x !== i);
            }
            this.$dispatch('data-table:select', { selected: [...this.selected] });
        },

        allSelected() {
            return this.rows.length > 0 && this.selected.length === this.rows.length;
        },

        toggleAll(checked) {
            this.selected = checked ? this.rows.map((_, i) => i) : [];
            this.$dispatch('data-table:select', { selected: [...this.selected] });
        },

        // ---- Expansion -----------------------------------------------------

        isExpanded(i) {
            return this.expanded.includes(i);
        },

        toggleExpand(i) {
            if (this.isExpanded(i)) {
                this.expanded = this.expanded.filter((x) => x !== i);
            } else {
                this.expanded.push(i);
            }
        },

        // ---- Row actions and inline edits ----------------------------------

        action(key, row) {
            this.$dispatch('data-table:action', { key, row, index: this.rowIndex(row) });
        },

        isEditing(row, key) {
            return this.editing !== null && this.editing.index === this.rowIndex(row) && this.editing.key === key;
        },

        startEdit(row, key) {
            this.editing = { index: this.rowIndex(row), key };
            this.draft = String(row[key] ?? '');
            this.$nextTick(() => {
                const input = this.root.querySelector('[data-slot="data-table-edit"]');
                input?.focus();
                input?.select();
            });
        },

        commitEdit(row, key) {
            if (!this.isEditing(row, key)) return;
            const previous = row[key];
            const value = this.draft;
            this.editing = null;
            if (String(previous ?? '') === value) return;
            row[key] = value;
            this.$dispatch('data-table:edit', { key, row, index: this.rowIndex(row), value, previous });
        },

        cancelEdit() {
            this.editing = null;
        },

        /** Badge tone classes for a cell value, from the column's `tones` map. */
        toneClass(col, value) {
            const tone = col.tones?.[String(value ?? '')] ?? 'neutral';
            return this.toneClasses?.[tone] ?? this.toneClasses?.neutral ?? '';
        },
    }));
});
resources/views/components/ui/data-table/filters.blade.php Blade
@props([
    'label' => 'Filter rows',
    'placeholder' => 'Filter…',
])

<div
    data-slot="data-table-filters"
    {{ $attributes->merge(['class' => 'flex items-center justify-between gap-2']) }}
>
    @if ($slot->isEmpty())
        <div class="relative w-full max-w-xs">
            <svg class="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
            <input
                type="search"
                x-model="filter"
                x-on:input="onFilter()"
                aria-label="{{ __($label) }}"
                placeholder="{{ __($placeholder) }}"
                class="h-9 w-full rounded-md border border-input bg-background ps-9 pe-3 text-sm text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
            />
        </div>
        <span class="shrink-0 text-sm text-muted-foreground" x-text="`${sorted().length} {{ __('result(s)') }}`"></span>
    @else
        {{ $slot }}
    @endif
</div>
resources/views/components/ui/data-table/columns.blade.php Blade
<div data-slot="data-table-columns" {{ $attributes->merge(['class' => 'min-w-0']) }}>
    {{ $slot }}
</div>
resources/views/components/ui/data-table/selection.blade.php Blade
<div data-slot="data-table-selection" {{ $attributes->merge(['class' => 'text-sm text-muted-foreground']) }}>
    @if ($slot->isEmpty())
        <span x-text="`${selected.length} {{ __('of') }} ${rows.length} {{ __('row(s) selected') }}`"></span>
    @else
        {{ $slot }}
    @endif
</div>
resources/views/components/ui/data-table/expansion.blade.php Blade
<div data-slot="data-table-expansion" {{ $attributes->merge(['class' => 'min-w-0']) }}>
    {{ $slot }}
</div>
resources/views/components/ui/data-table/pagination.blade.php Blade
<nav
    data-slot="data-table-pagination"
    aria-label="{{ __('Table pagination') }}"
    {{ $attributes->merge(['class' => 'flex items-center justify-end gap-2']) }}
>
    @if ($slot->isEmpty())
        <x-ui.button type="button" variant="outline" size="sm" x-on:click="prevPage()" x-bind:disabled="page === 1">
            {{ __('Previous') }}
        </x-ui.button>
        <span class="text-sm text-muted-foreground" x-text="`${page} / ${totalPages()}`"></span>
        <x-ui.button type="button" variant="outline" size="sm" x-on:click="nextPage()" x-bind:disabled="page === totalPages()">
            {{ __('Next') }}
        </x-ui.button>
    @else
        {{ $slot }}
    @endif
</nav>

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.10.7
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0