Skip to content
Brok UI

Loading…

No results

Admin Table

Open source

A dense operational table with pinned columns, visibility controls, stable selection with an explicit page-vs-matching scope, opt-in row activation, and generic server-authorized bulk actions with typed-word confirmation, idempotency, progress, partial failure, and reset hooks.

Version
v2.13.3
Stability
stable
License
MIT
Related
Data Table
Table
Checkbox
Badge
Pagination
Livewire Data Table

Preview

Customers
Open
Avery Stone [email protected] Active Scale Jane Doe $2,480 2 hours ago 2024-02-14
Morgan Lee [email protected] Trial Starter Alex Brown $0 Yesterday 2026-09-01
Riley Chen [email protected] Active Growth Jane Doe $1,120 3 days ago 2025-06-20
Sam Patel [email protected] Past due Growth Liam Smith $1,120 2 weeks ago 2024-10-09
Noor Haddad [email protected] Active Scale Alex Brown $3,900 Today 2023-12-01
Emma Wilson [email protected] Churned Starter Liam Smith $0 2 months ago 2025-03-15
Showing 6 of 128 customers
Align
previews.components.admin-table.default.blade.php Blade
{{--
    A server-driven operations table. Rows are Blade slots (the consumer owns
    every cell), the identity column is pinned, tier-2 columns start hidden
    behind the Columns menu, and a column header opens a menu whose sort,
    list filter and range dispatch `admin-table:sort` / `admin-table:filter`
    for the host to run on the server. Selection and bulk actions post a form
    with an idempotency key and record version.
--}}
@php
    $columns = [
        ['key' => 'customer', 'label' => __('Customer')],
        ['key' => 'status', 'label' => __('Status'), 'type' => 'stage', 'filter' => [__('Active'), __('Trial'), __('Past due'), __('Churned')]],
        ['key' => 'plan', 'label' => __('Plan'), 'filter' => [__('Starter'), __('Growth'), __('Scale')]],
        ['key' => 'owner', 'label' => __('Owner'), 'tier' => 2, 'group' => __('Account')],
        ['key' => 'mrr', 'label' => __('MRR'), 'type' => 'money', 'align' => 'end', 'rangeMin' => __('Min $'), 'rangeMax' => __('Max $')],
        ['key' => 'seen', 'label' => __('Last seen'), 'type' => 'date', 'tier' => 2, 'group' => __('Activity')],
        ['key' => 'created', 'label' => __('Created'), 'type' => 'date', 'tier' => 2, 'group' => __('Activity')],
    ];

    $tones = ['Active' => 'positive', 'Trial' => 'info', 'Past due' => 'attention', 'Churned' => 'negative'];

    $rows = [
        ['id' => 'cus_1048', 'customer' => 'Avery Stone', 'email' => 'avery@example.com', 'status' => 'Active', 'plan' => 'Scale', 'owner' => 'Jane Doe', 'mrr' => '$2,480', 'seen' => __('2 hours ago'), 'created' => '2024-02-14'],
        ['id' => 'cus_1049', 'customer' => 'Morgan Lee', 'email' => 'morgan@example.com', 'status' => 'Trial', 'plan' => 'Starter', 'owner' => 'Alex Brown', 'mrr' => '$0', 'seen' => __('Yesterday'), 'created' => '2026-09-01'],
        ['id' => 'cus_1050', 'customer' => 'Riley Chen', 'email' => 'riley@example.com', 'status' => 'Active', 'plan' => 'Growth', 'owner' => 'Jane Doe', 'mrr' => '$1,120', 'seen' => __('3 days ago'), 'created' => '2025-06-20'],
        ['id' => 'cus_1051', 'customer' => 'Sam Patel', 'email' => 'sam@example.com', 'status' => 'Past due', 'plan' => 'Growth', 'owner' => 'Liam Smith', 'mrr' => '$1,120', 'seen' => __('2 weeks ago'), 'created' => '2024-10-09'],
        ['id' => 'cus_1052', 'customer' => 'Noor Haddad', 'email' => 'noor@example.com', 'status' => 'Active', 'plan' => 'Scale', 'owner' => 'Alex Brown', 'mrr' => '$3,900', 'seen' => __('Today'), 'created' => '2023-12-01'],
        ['id' => 'cus_1053', 'customer' => 'Emma Wilson', 'email' => 'emma@example.com', 'status' => 'Churned', 'plan' => 'Starter', 'owner' => 'Liam Smith', 'mrr' => '$0', 'seen' => __('2 months ago'), 'created' => '2025-03-15'],
    ];

    $bulkActions = [
        ['key' => 'archive', 'label' => __('Archive'), 'confirmation' => 'confirm'],
        ['key' => 'delete', 'label' => __('Delete'), 'destructive' => true, 'confirmation' => 'destructive'],
    ];
@endphp

<brok:admin-table
    :columns="$columns"
    :selectable="true"
    :pinned="true"
    :bulk-actions="$bulkActions"
    bulk-action-url="#"
    bulk-idempotency-key="preview-bulk-intent"
    bulk-record-version="7"
    :matched-count="128"
    :summary="__('Showing :shown of :matched customers', ['shown' => count($rows), 'matched' => 128])"
>
    <x-slot:toolbar>
        <span class="text-sm font-medium text-foreground">{{ __('Customers') }}</span>
    </x-slot:toolbar>

    @foreach ($rows as $row)
        <brok:admin-table.row :key="$row['id']" :href="'#'.$row['id']">
            <brok:admin-table.cell column="customer" :pinned="true">
                <span class="block font-medium text-foreground">{{ $row['customer'] }}</span>
                <span class="block text-xs text-muted-foreground">{{ $row['email'] }}</span>
            </brok:admin-table.cell>
            <brok:admin-table.cell column="status">
                <brok:badge variant="outline" :tone="$tones[$row['status']]">{{ __($row['status']) }}</brok:badge>
            </brok:admin-table.cell>
            <brok:admin-table.cell column="plan">
                {{ __($row['plan']) }}
            </brok:admin-table.cell>
            <brok:admin-table.cell column="owner" :tier="2">
                {{ $row['owner'] }}
            </brok:admin-table.cell>
            <brok:admin-table.cell column="mrr" align="end">
                <span class="font-medium tabular-nums text-foreground">{{ $row['mrr'] }}</span>
            </brok:admin-table.cell>
            <brok:admin-table.cell column="seen" :tier="2">
                <span class="text-muted-foreground">{{ $row['seen'] }}</span>
            </brok:admin-table.cell>
            <brok:admin-table.cell column="created" :tier="2">
                <span class="tabular-nums text-muted-foreground">{{ $row['created'] }}</span>
            </brok:admin-table.cell>
        </brok:admin-table.row>
    @endforeach
</brok:admin-table>
Start Current
End Current
Right Current

Installation

terminal
php artisan ui:add admin-table

Note

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

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

Registry contract

php artisan ui:add admin-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/admin-table.blade.php
  • blade resources/views/components/ui/admin-table/row.blade.php
  • blade resources/views/components/ui/admin-table/cell.blade.php
  • blade resources/views/components/ui/admin-table/empty.blade.php
  • js resources/js/ui/admin-table.js
  • js resources/js/ui/admin-table-selection.js
  • js resources/js/ui/admin-table-activation.js
Registry dependencies
table checkbox button badge alert-dialog
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.

admin-table.md
# Brok UI: Admin Table (`admin-table`)

A dense operational table with pinned columns, visibility controls, stable selection with an explicit page-vs-matching scope, opt-in row activation, and generic server-authorized bulk actions with typed-word confirmation, idempotency, progress, partial failure, and reset hooks.

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

## Install

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

## Usage

```blade
{{--
    A server-driven operations table. Rows are Blade slots (the consumer owns
    every cell), the identity column is pinned, tier-2 columns start hidden
    behind the Columns menu, and a column header opens a menu whose sort,
    list filter and range dispatch `admin-table:sort` / `admin-table:filter`
    for the host to run on the server. Selection and bulk actions post a form
    with an idempotency key and record version.
--}}
@php
    $columns = [
        ['key' => 'customer', 'label' => __('Customer')],
        ['key' => 'status', 'label' => __('Status'), 'type' => 'stage', 'filter' => [__('Active'), __('Trial'), __('Past due'), __('Churned')]],
        ['key' => 'plan', 'label' => __('Plan'), 'filter' => [__('Starter'), __('Growth'), __('Scale')]],
        ['key' => 'owner', 'label' => __('Owner'), 'tier' => 2, 'group' => __('Account')],
        ['key' => 'mrr', 'label' => __('MRR'), 'type' => 'money', 'align' => 'end', 'rangeMin' => __('Min $'), 'rangeMax' => __('Max $')],
        ['key' => 'seen', 'label' => __('Last seen'), 'type' => 'date', 'tier' => 2, 'group' => __('Activity')],
        ['key' => 'created', 'label' => __('Created'), 'type' => 'date', 'tier' => 2, 'group' => __('Activity')],
    ];

    $tones = ['Active' => 'positive', 'Trial' => 'info', 'Past due' => 'attention', 'Churned' => 'negative'];

    $rows = [
        ['id' => 'cus_1048', 'customer' => 'Avery Stone', 'email' => '[email protected]', 'status' => 'Active', 'plan' => 'Scale', 'owner' => 'Jane Doe', 'mrr' => '$2,480', 'seen' => __('2 hours ago'), 'created' => '2024-02-14'],
        ['id' => 'cus_1049', 'customer' => 'Morgan Lee', 'email' => '[email protected]', 'status' => 'Trial', 'plan' => 'Starter', 'owner' => 'Alex Brown', 'mrr' => '$0', 'seen' => __('Yesterday'), 'created' => '2026-09-01'],
        ['id' => 'cus_1050', 'customer' => 'Riley Chen', 'email' => '[email protected]', 'status' => 'Active', 'plan' => 'Growth', 'owner' => 'Jane Doe', 'mrr' => '$1,120', 'seen' => __('3 days ago'), 'created' => '2025-06-20'],
        ['id' => 'cus_1051', 'customer' => 'Sam Patel', 'email' => '[email protected]', 'status' => 'Past due', 'plan' => 'Growth', 'owner' => 'Liam Smith', 'mrr' => '$1,120', 'seen' => __('2 weeks ago'), 'created' => '2024-10-09'],
        ['id' => 'cus_1052', 'customer' => 'Noor Haddad', 'email' => '[email protected]', 'status' => 'Active', 'plan' => 'Scale', 'owner' => 'Alex Brown', 'mrr' => '$3,900', 'seen' => __('Today'), 'created' => '2023-12-01'],
        ['id' => 'cus_1053', 'customer' => 'Emma Wilson', 'email' => '[email protected]', 'status' => 'Churned', 'plan' => 'Starter', 'owner' => 'Liam Smith', 'mrr' => '$0', 'seen' => __('2 months ago'), 'created' => '2025-03-15'],
    ];

    $bulkActions = [
        ['key' => 'archive', 'label' => __('Archive'), 'confirmation' => 'confirm'],
        ['key' => 'delete', 'label' => __('Delete'), 'destructive' => true, 'confirmation' => 'destructive'],
    ];
@endphp

<brok:admin-table
    :columns="$columns"
    :selectable="true"
    :pinned="true"
    :bulk-actions="$bulkActions"
    bulk-action-url="#"
    bulk-idempotency-key="preview-bulk-intent"
    bulk-record-version="7"
    :matched-count="128"
    :summary="__('Showing :shown of :matched customers', ['shown' => count($rows), 'matched' => 128])"
>
    <x-slot:toolbar>
        <span class="text-sm font-medium text-foreground">{{ __('Customers') }}</span>
    </x-slot:toolbar>

    @foreach ($rows as $row)
        <brok:admin-table.row :key="$row['id']" :href="'#'.$row['id']">
            <brok:admin-table.cell column="customer" :pinned="true">
                <span class="block font-medium text-foreground">{{ $row['customer'] }}</span>
                <span class="block text-xs text-muted-foreground">{{ $row['email'] }}</span>
            </brok:admin-table.cell>
            <brok:admin-table.cell column="status">
                <brok:badge variant="outline" :tone="$tones[$row['status']]">{{ __($row['status']) }}</brok:badge>
            </brok:admin-table.cell>
            <brok:admin-table.cell column="plan">
                {{ __($row['plan']) }}
            </brok:admin-table.cell>
            <brok:admin-table.cell column="owner" :tier="2">
                {{ $row['owner'] }}
            </brok:admin-table.cell>
            <brok:admin-table.cell column="mrr" align="end">
                <span class="font-medium tabular-nums text-foreground">{{ $row['mrr'] }}</span>
            </brok:admin-table.cell>
            <brok:admin-table.cell column="seen" :tier="2">
                <span class="text-muted-foreground">{{ $row['seen'] }}</span>
            </brok:admin-table.cell>
            <brok:admin-table.cell column="created" :tier="2">
                <span class="tabular-nums text-muted-foreground">{{ $row['created'] }}</span>
            </brok:admin-table.cell>
        </brok:admin-table.row>
    @endforeach
</brok:admin-table>
```

## Props

- `columns` (array, default `[]`) — Column definitions: key, label, align, and tier (1 = always shown, 2 = opt-in).
- `selectable` (bool, default `false`) — Render the leading checkbox column and the bulk action bar.
- `pinned` (bool, default `false`) — Keep the leading identity column fixed while the table scrolls sideways.
- `persist` (mixed|null, default `null`) — Declared by @props in the shipped Blade source.
- `stickyHeader` (bool, default `true`) — Keep the header visible while the body scrolls; needs maxHeight to bite.
- `maxHeight` (string, default `null`) — CSS height for the scroll container, enabling vertical scroll.
- `density` (string, default `compact`) — compact or comfortable row padding.
- `summary` (string, default `null`) — Footer text, typically a result count.
- `columnsLabel` (string, default `Columns`) — Declared by @props in the shipped Blade source.
- `columnsHintLabel` (string, default `Show, reorder and pin columns`) — Heading text inside the column menu.
- `resetLabel` (string, default `Reset`) — Label for the control that restores the default column set, order and pinning.
- `toggleColumnsLabel` (string, default `Toggle columns`) — Declared by @props in the shipped Blade source.
- `showAllLabel` (string, default `Show all`) — Declared by @props in the shipped Blade source.
- `selectAllLabel` (string, default `Select all rows on this page`) — Declared by @props in the shipped Blade source.
- `selectedLabel` (string, default `selected`) — Declared by @props in the shipped Blade source.
- `clearLabel` (string, default `Clear`) — Declared by @props in the shipped Blade source.
- `bulkActions` (list<BulkAction>, default `[]`) — Up to twelve server-authorized actions with stable key, label, destructive, confirmation, and disabled values.
- `bulkActionUrl` (mixed|null, default `null`) — Declared by @props in the shipped Blade source.
- `bulkSelectionName` (string, default `selected_ids`) — Declared by @props in the shipped Blade source.
- `bulkIdempotencyKey` (string|null, default `null`) — Application intent key used to reject repeat submissions.
- `bulkRecordVersion` (string|null, default `null`) — Version value used by the server to reject stale selections.
- `bulkState` (idle|progress|partial|complete, default `idle`) — Server-owned bulk workflow presentation state.
- `bulkFailures` (array, default `[]`) — Declared by @props in the shipped Blade source.
- `matchedCount` (int|null, default `null`) — Total records the current filters match, across every page. Unset, select-all-on-page has no affordance to widen beyond the rendered rows — today's behaviour. Set it once it exceeds the page's own row count to offer extending the selection.
- `selectAllMatchingLabel` (string, default `Select all :count matching`) — Label for the control that widens a full-page selection to every matching record.
- `allMatchingSelectedLabel` (string, default `All :count matching selected`) — Badge text shown while the selection is scoped to every matching record.
- `pageOnlyLabel` (string, default `Just this page`) — Label for the control that falls back from the matching-scope back to just this page.
- `bulkScopeName` (string, default `selection_scope`) — Field name carrying the submitted scope ('page' or 'all'). Only sent once `matchedCount` is set.
- `bulkFilters` (string|null, default `null`) — Opaque filter-state payload (e.g. the current query string) submitted under `bulkFiltersName` when the operator has extended the selection to every matching record. The component does not interpret it.
- `bulkFiltersName` (string, default `selection_filters`) — Field name carrying `bulkFilters` when the scope is 'all'.
- `bulkConfirmCancelLabel` (string, default `Cancel`) — Cancel label inside a typed-word confirmation dialog.
- `bulkConfirmWordLabel` (string, default `Type :word to confirm`) — Instruction label above the typed-word confirmation input; `:word` is replaced with the action's `confirmWord`.
- `align` (start|end|right, default `start`) — Documented catalog control used by the preview workbench.
- `key` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `selectLabel` (string, default `Select :record`) — Declared by @props in the registry Blade source.
- `href` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `activateLabel` (string, default `Open :record`) — Declared by @props in the registry Blade source.
- `column` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `tier` (int, default `1`) — Declared by @props in the registry Blade source.
- `pinnedEnd` (bool, default `false`) — Declared by @props in the registry Blade source.
- `colspan` (int, default `99`) — Declared by @props in the registry Blade source.

## Use when

- Use to summarize, sequence, or present data so users can scan it quickly.
- Rows come from the server: the table renders your Blade in every cell and asks the host to sort, filter and page (admin-table:sort, admin-table:filter) instead of doing it in the browser.
- Back-office screens where operators scan wide, dense record sets all day, with pinned identity columns and an opt-in column set.
- Bulk actions must be server-authorized, idempotent, versioned and deliberately confirmed, and a selection may span every matching record, not only the page.

## Avoid when

- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- The rows fit in the browser (a few hundred) and scalar cells, client-side sorting, filtering and inline edits are enough; use data-table, which takes rows as data and needs no server round-trip.
- The record set is small and task-oriented; consider cards or a description list.

## Anti-patterns

- Adding display ornament without informational value

## Rules

- Use the `<brok:admin-table>` tag (or `<x-ui.admin-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/admin-table
- Registry JSON (files, props, contract): https://brokui.dev/r/open/admin-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

bulk-safety.blade.php Blade
{{--
    Demonstrates the two bulk-operation safety additions:

    1. `confirmWord` on the "Delete" action gates its confirm control behind a
       typed-word alert-dialog instead of a reflex-dismissable window.confirm().
    2. `matchedCount` (larger than the 3 rows rendered) opts into the explicit
       page-vs-matching selection scope: selecting the full page offers
       extending to every matching record, with a fallback back to the page.
--}}
@php
    $columns = [
        ['key' => 'customer', 'label' => __('Customer')],
        ['key' => 'status', 'label' => __('Status')],
        ['key' => 'total', 'label' => __('Total'), 'align' => 'end'],
    ];

    $rows = [
        ['id' => 'cus_2048', 'customer' => 'Avery Stone', 'status' => __('Active'), 'total' => '$2,480'],
        ['id' => 'cus_2049', 'customer' => 'Morgan Lee', 'status' => __('Active'), 'total' => '$320'],
        ['id' => 'cus_2050', 'customer' => 'Riley Chen', 'status' => __('Active'), 'total' => '$1,120'],
    ];

    $bulkActions = [
        ['key' => 'archive', 'label' => __('Archive'), 'confirmation' => 'confirm'],
        [
            'key' => 'delete',
            'label' => __('Delete'),
            'destructive' => true,
            'confirmWord' => 'DELETE',
            'confirmDescription' => __('This permanently deletes every selected customer. This cannot be undone.'),
        ],
    ];
@endphp

<brok:admin-table
    :columns="$columns"
    :selectable="true"
    :bulk-actions="$bulkActions"
    bulk-action-url="#"
    :matched-count="128"
    bulk-filters="status=active"
    :summary="__('Showing :shown of :matched customers', ['shown' => count($rows), 'matched' => 128])"
>
    <x-slot:toolbar>
        <span class="text-sm font-medium text-foreground">{{ __('Customers') }}</span>
    </x-slot:toolbar>

    @foreach ($rows as $row)
        <brok:admin-table.row :key="$row['id']">
            <brok:admin-table.cell column="customer">
                <span class="block font-medium text-foreground">{{ $row['customer'] }}</span>
            </brok:admin-table.cell>
            <brok:admin-table.cell column="status">
                <brok:badge variant="secondary">{{ $row['status'] }}</brok:badge>
            </brok:admin-table.cell>
            <brok:admin-table.cell column="total" align="end">
                <span class="font-medium tabular-nums text-foreground">{{ $row['total'] }}</span>
            </brok:admin-table.cell>
        </brok:admin-table.row>
    @endforeach
</brok:admin-table>
row-activation.blade.php Blade
{{--
    Demonstrates opt-in row activation: `<brok:admin-table.row>` given an
    `href` opens the record on a click anywhere in the row, not only on the
    small trailing link. Selecting a row (the checkbox) and working its
    inline action button both leave activation alone — that conflict is
    handled by the component, not left for a consumer to patch around.

    The actions column here is intentionally NOT `pinnedEnd`: the activation
    link renders as the row's own end-pinned cell (see row.blade.php), and
    pinning a second trailing column would overlap it at the same offset.
--}}
@php
    $columns = [
        ['key' => 'customer', 'label' => __('Customer')],
        ['key' => 'status', 'label' => __('Status')],
        ['key' => 'total', 'label' => __('Total'), 'align' => 'end'],
        ['key' => 'actions', 'label' => __('Actions'), 'align' => 'end', 'sortable' => false],
    ];

    $rows = [
        ['id' => 'cus_4048', 'customer' => 'Avery Stone', 'status' => __('Active'), 'total' => '$2,480'],
        ['id' => 'cus_4049', 'customer' => 'Morgan Lee', 'status' => __('Trial'), 'total' => '$320'],
        ['id' => 'cus_4050', 'customer' => 'Riley Chen', 'status' => __('Active'), 'total' => '$1,120'],
    ];
@endphp

<brok:admin-table :columns="$columns" :selectable="true">
    <x-slot:toolbar>
        <span class="text-sm font-medium text-foreground">{{ __('Customers') }}</span>
    </x-slot:toolbar>

    @foreach ($rows as $row)
        <brok:admin-table.row :key="$row['id']" :href="'#'.$row['id']">
            <brok:admin-table.cell column="customer">
                <span class="block font-medium text-foreground">{{ $row['customer'] }}</span>
            </brok:admin-table.cell>
            <brok:admin-table.cell column="status">
                <brok:badge variant="secondary">{{ $row['status'] }}</brok:badge>
            </brok:admin-table.cell>
            <brok:admin-table.cell column="total" align="end">
                <span class="font-medium tabular-nums text-foreground">{{ $row['total'] }}</span>
            </brok:admin-table.cell>
            <brok:admin-table.cell column="actions" align="end">
                <x-ui.button type="button" variant="outline" size="sm">{{ __('Edit') }}</x-ui.button>
            </brok:admin-table.cell>
        </brok:admin-table.row>
    @endforeach
</brok:admin-table>

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: key, label, align, and tier (1 = always shown, 2 = opt-in).
selectable bool false Render the leading checkbox column and the bulk action bar.
pinned bool false Keep the leading identity column fixed while the table scrolls sideways.
persist mixed | null null Declared by @props in the shipped Blade source.
stickyHeader bool true Keep the header visible while the body scrolls; needs maxHeight to bite.
maxHeight string null CSS height for the scroll container, enabling vertical scroll.
density string compact compact or comfortable row padding.
summary string null Footer text, typically a result count.
columnsLabel string Columns Declared by @props in the shipped Blade source.
columnsHintLabel string Show, reorder and pin columns Heading text inside the column menu.
resetLabel string Reset Label for the control that restores the default column set, order and pinning.
toggleColumnsLabel string Toggle columns Declared by @props in the shipped Blade source.
showAllLabel string Show all Declared by @props in the shipped Blade source.
selectAllLabel string Select all rows on this page Declared by @props in the shipped Blade source.
selectedLabel string selected Declared by @props in the shipped Blade source.
clearLabel string Clear Declared by @props in the shipped Blade source.
bulkActions list<BulkAction> [] Up to twelve server-authorized actions with stable key, label, destructive, confirmation, and disabled values.
bulkActionUrl mixed | null null Declared by @props in the shipped Blade source.
bulkSelectionName string selected_ids Declared by @props in the shipped Blade source.
bulkIdempotencyKey string | null null Application intent key used to reject repeat submissions.
bulkRecordVersion string | null null Version value used by the server to reject stale selections.
bulkState idle | progress | partial | complete idle Server-owned bulk workflow presentation state.
bulkFailures array [] Declared by @props in the shipped Blade source.
matchedCount int | null null Total records the current filters match, across every page. Unset, select-all-on-page has no affordance to widen beyond the rendered rows — today's behaviour. Set it once it exceeds the page's own row count to offer extending the selection.
selectAllMatchingLabel string Select all :count matching Label for the control that widens a full-page selection to every matching record.
allMatchingSelectedLabel string All :count matching selected Badge text shown while the selection is scoped to every matching record.
pageOnlyLabel string Just this page Label for the control that falls back from the matching-scope back to just this page.
bulkScopeName string selection_scope Field name carrying the submitted scope ('page' or 'all'). Only sent once `matchedCount` is set.
bulkFilters string | null null Opaque filter-state payload (e.g. the current query string) submitted under `bulkFiltersName` when the operator has extended the selection to every matching record. The component does not interpret it.
bulkFiltersName string selection_filters Field name carrying `bulkFilters` when the scope is 'all'.
bulkConfirmCancelLabel string Cancel Cancel label inside a typed-word confirmation dialog.
bulkConfirmWordLabel string Type :word to confirm Instruction label above the typed-word confirmation input; `:word` is replaced with the action's `confirmWord`.
align start | end | right start Documented catalog control used by the preview workbench.
key mixed | null null Declared by @props in the registry Blade source.
selectLabel string Select :record Declared by @props in the registry Blade source.
href mixed | null null Declared by @props in the registry Blade source.
activateLabel string Open :record Declared by @props in the registry Blade source.
column mixed | null null Declared by @props in the registry Blade source.
tier int 1 Declared by @props in the registry Blade source.
pinnedEnd bool false Declared by @props in the registry Blade source.
colspan int 99 Declared by @props in the registry Blade source.

Slots

  • default — Primary Blade slot rendered by the component.
  • x-ui.admin-table.row — Installed subcomponent from the registry item.
  • x-ui.admin-table.cell — Installed subcomponent from the registry item.
  • x-ui.admin-table.empty — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

admin-table admin-table-bulk-confirm admin-table-bulk-status admin-table-cell admin-table-empty admin-table-grid admin-table-row admin-table-row-activate admin-table-row-link admin-table-scope-all

Behavior

  • Selection is keyed to the record identifier, not row position, so sorting or paging never moves a selection onto a different record.
  • Tier-2 columns are hidden from layout until enabled, so they do not consume table width.
  • The bulk bar is fixed to the bottom of the viewport and floats over content, so it never lands on top of the rows just selected and never moves the row under the cursor.
  • Sorting is not performed by the component. A sortable header dispatches an event and the consumer reorders, so the same markup serves a client-side prototype and a server-side query.
  • Select-all covers only visible rows by default; setting `matchedCount` opts into an explicit scope model that offers extending the selection to every record the current filters match, showing the count, with an unmistakable badge and an easy fallback while extended — so a filtered list cannot silently select records the operator cannot see, and a bulk action cannot silently run on more than the operator saw.
  • A matching-scoped selection is never approximated by a list of ids: the bulk form submits the scope plus the consumer-supplied filter state instead, and any single row toggled by hand reverts the scope to the page, since the claim "every matching record" is no longer true the moment one is hand-picked.
  • An action opts into typed-word confirmation with `confirmWord`; the confirm control in a composed alert-dialog stays disabled until the typed value matches (trimmed, case-insensitive) — deliberate friction for the actions whose blast radius a window.confirm() undersells.
  • Pinned cells inherit the row background, so hover and selection cover the full row width instead of stopping at the sticky columns.
  • The column menu toggles visibility, pins a column to the start, reorders with earlier/later controls, and resets to the declared defaults.
  • Reordering rearranges cells by their data-column attribute, so consumer markup needs no knowledge of column order.
  • Filtering is offered on the column header rather than in a separate bar, so the control sits where the data is. The component publishes the intent and mirrors applied filters back onto the headers; the consumer owns what gets hidden.
  • Column menus are teleported to the body so the horizontal scroll container cannot clip them.
  • Filter and sort events carry the resulting state rather than an instruction to toggle, so the control and the data cannot desync.
  • Sort entries are labelled by what the column holds. Alphabetical order on a date or a status is technically valid and useless, so a date column offers Newest/Oldest and a lifecycle enum offers Earliest/Latest stage.
  • The column label itself opens the menu, and a column with nothing to sort or filter renders as plain text with no affordance at all.
  • Component members are specifically named because Alpine scopes nest: a generic name here shadows the same name in the consumer and breaks their markup silently.
  • Selection size is published as 'admin-table:selection' so a consumer can offer to extend the selection beyond the visible page.
  • Built-in bulk actions submit selected stable IDs, an allowlisted action key, an idempotency key, and a record version to an application endpoint.
  • Progress, partial failure, and completion are server-owned states; completion can dispatch admin-table:bulk-complete to reset the selection.
  • A row opts into activation by giving `<brok:admin-table.row>` an `href`; unset, a row has no click handling at all and renders byte-identical to before. A click anywhere in an activatable row opens that href, except a click that started on the row's own controls (the selection checkbox, a button, a link, a menu) or a click that ends a text-selection drag rather than a genuine click.
  • Keyboard and screen-reader access to an activated row goes through a real, focusable link the row renders, not through the row-wide click handler — the handler only extends that same destination to a mouse click landing anywhere else in the row.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, alpine.

Guidance

Scannable data display

Present data for rapid scanning.

Use when

  • Use to summarize, sequence, or present data so users can scan it quickly.
  • Rows come from the server: the table renders your Blade in every cell and asks the host to sort, filter and page (admin-table:sort, admin-table:filter) instead of doing it in the browser.
  • Back-office screens where operators scan wide, dense record sets all day, with pinned identity columns and an opt-in column set.
  • Bulk actions must be server-authorized, idempotent, versioned and deliberately confirmed, and a selection may span every matching record, not only the page.

Avoid when

  • Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
  • The rows fit in the browser (a few hundred) and scalar cells, client-side sorting, filtering and inline edits are enough; use data-table, which takes rows as data and needs no server round-trip.
  • The record set is small and task-oriented; consider cards or a description list.

Use instead

  • Table for exact comparison
  • Plain text for a single value

Anti-patterns

  • Adding display ornament without informational value
Anatomy
toolbar column menu pin control reorder controls scroll container sticky header pinned column row selection control row activation link bulk action bar footer
Theming hooks
table header pinned identity column background selected row bulk bar elevation

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Escape
Focus
managed
  • The select-all control reports an indeterminate state for partial selections.
  • Row checkboxes carry a record-specific accessible name.
  • The column menu is a labelled group and closes on Escape and outside click.
  • The selection scope banner is a `role="status"`/`aria-live="polite"` region, so the page-vs-matching transition is announced, not only shown.
  • The typed-word confirmation dialog composes alert-dialog: focus-trapped, closes on Escape or Cancel without performing anything, and the required word is rendered as real selectable text tied to the input via a `<label for>`.
  • An activatable row's link carries a record-specific accessible name and sits in the natural tab order; a mouse click anywhere else in the row goes to the same destination, but keyboard and screen-reader access never depends on that click handler.
  • 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:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<div wire:ignore>
    {{--
        A server-driven operations table. Rows are Blade slots (the consumer owns
        every cell), the identity column is pinned, tier-2 columns start hidden
        behind the Columns menu, and a column header opens a menu whose sort,
        list filter and range dispatch `admin-table:sort` / `admin-table:filter`
        for the host to run on the server. Selection and bulk actions post a form
        with an idempotency key and record version.
    --}}
    @php
        $columns = [
            ['key' => 'customer', 'label' => __('Customer')],
            ['key' => 'status', 'label' => __('Status'), 'type' => 'stage', 'filter' => [__('Active'), __('Trial'), __('Past due'), __('Churned')]],
            ['key' => 'plan', 'label' => __('Plan'), 'filter' => [__('Starter'), __('Growth'), __('Scale')]],
            ['key' => 'owner', 'label' => __('Owner'), 'tier' => 2, 'group' => __('Account')],
            ['key' => 'mrr', 'label' => __('MRR'), 'type' => 'money', 'align' => 'end', 'rangeMin' => __('Min $'), 'rangeMax' => __('Max $')],
            ['key' => 'seen', 'label' => __('Last seen'), 'type' => 'date', 'tier' => 2, 'group' => __('Activity')],
            ['key' => 'created', 'label' => __('Created'), 'type' => 'date', 'tier' => 2, 'group' => __('Activity')],
        ];
    
        $tones = ['Active' => 'positive', 'Trial' => 'info', 'Past due' => 'attention', 'Churned' => 'negative'];
    
        $rows = [
            ['id' => 'cus_1048', 'customer' => 'Avery Stone', 'email' => 'avery@example.com', 'status' => 'Active', 'plan' => 'Scale', 'owner' => 'Jane Doe', 'mrr' => '$2,480', 'seen' => __('2 hours ago'), 'created' => '2024-02-14'],
            ['id' => 'cus_1049', 'customer' => 'Morgan Lee', 'email' => 'morgan@example.com', 'status' => 'Trial', 'plan' => 'Starter', 'owner' => 'Alex Brown', 'mrr' => '$0', 'seen' => __('Yesterday'), 'created' => '2026-09-01'],
            ['id' => 'cus_1050', 'customer' => 'Riley Chen', 'email' => 'riley@example.com', 'status' => 'Active', 'plan' => 'Growth', 'owner' => 'Jane Doe', 'mrr' => '$1,120', 'seen' => __('3 days ago'), 'created' => '2025-06-20'],
            ['id' => 'cus_1051', 'customer' => 'Sam Patel', 'email' => 'sam@example.com', 'status' => 'Past due', 'plan' => 'Growth', 'owner' => 'Liam Smith', 'mrr' => '$1,120', 'seen' => __('2 weeks ago'), 'created' => '2024-10-09'],
            ['id' => 'cus_1052', 'customer' => 'Noor Haddad', 'email' => 'noor@example.com', 'status' => 'Active', 'plan' => 'Scale', 'owner' => 'Alex Brown', 'mrr' => '$3,900', 'seen' => __('Today'), 'created' => '2023-12-01'],
            ['id' => 'cus_1053', 'customer' => 'Emma Wilson', 'email' => 'emma@example.com', 'status' => 'Churned', 'plan' => 'Starter', 'owner' => 'Liam Smith', 'mrr' => '$0', 'seen' => __('2 months ago'), 'created' => '2025-03-15'],
        ];
    
        $bulkActions = [
            ['key' => 'archive', 'label' => __('Archive'), 'confirmation' => 'confirm'],
            ['key' => 'delete', 'label' => __('Delete'), 'destructive' => true, 'confirmation' => 'destructive'],
        ];
    @endphp
    
    <brok:admin-table
        :columns="$columns"
        :selectable="true"
        :pinned="true"
        :bulk-actions="$bulkActions"
        bulk-action-url="#"
        bulk-idempotency-key="preview-bulk-intent"
        bulk-record-version="7"
        :matched-count="128"
        :summary="__('Showing :shown of :matched customers', ['shown' => count($rows), 'matched' => 128])"
    >
        <x-slot:toolbar>
            <span class="text-sm font-medium text-foreground">{{ __('Customers') }}</span>
        </x-slot:toolbar>
    
        @foreach ($rows as $row)
            <brok:admin-table.row :key="$row['id']" :href="'#'.$row['id']">
                <brok:admin-table.cell column="customer" :pinned="true">
                    <span class="block font-medium text-foreground">{{ $row['customer'] }}</span>
                    <span class="block text-xs text-muted-foreground">{{ $row['email'] }}</span>
                </brok:admin-table.cell>
                <brok:admin-table.cell column="status">
                    <brok:badge variant="outline" :tone="$tones[$row['status']]">{{ __($row['status']) }}</brok:badge>
                </brok:admin-table.cell>
                <brok:admin-table.cell column="plan">
                    {{ __($row['plan']) }}
                </brok:admin-table.cell>
                <brok:admin-table.cell column="owner" :tier="2">
                    {{ $row['owner'] }}
                </brok:admin-table.cell>
                <brok:admin-table.cell column="mrr" align="end">
                    <span class="font-medium tabular-nums text-foreground">{{ $row['mrr'] }}</span>
                </brok:admin-table.cell>
                <brok:admin-table.cell column="seen" :tier="2">
                    <span class="text-muted-foreground">{{ $row['seen'] }}</span>
                </brok:admin-table.cell>
                <brok:admin-table.cell column="created" :tier="2">
                    <span class="tabular-nums text-muted-foreground">{{ $row['created'] }}</span>
                </brok:admin-table.cell>
            </brok:admin-table.row>
        @endforeach
    </brok:admin-table>
</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:admin-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/admin-table.blade.php Blade
{{--
    Admin table: a dense operational table for back-office work.

    `data-table` covers the common case: scalar cells, client-side sort,
    selection, pagination. Operational admin screens need more, and they need
    rich cells (status pills, two-line identity cells, flag rows) that a
    prop-driven renderer cannot express.

    This component therefore owns the CHROME and lets the consumer own the
    CELLS:

      chrome (here)                       cells (your slot)
      ─────────────────────────────       ────────────────────────
      search + filter bar                 <brok:admin-table.row>
      column visibility menu                <brok:admin-table.cell>
      select-all + bulk action bar        arbitrary markup
      horizontal scroll container
      sticky header, pinned identity column
      footer / result count

    Columns marked `tier: 2` are hidden from layout until enabled in the column
    menu, so a dense table does not reserve width for columns nobody asked for.

    Usage
    ------------------------------------------------------------------
    <brok:admin-table :columns="$columns" :selectable="true" :pinned="true">
        <x-slot:toolbar>…filters…</x-slot:toolbar>

        @foreach ($rows as $row)
            <brok:admin-table.row :key="$row['id']">
                <brok:admin-table.cell>{{ $row['id'] }}</brok:admin-table.cell>
                …
            </brok:admin-table.row>
        @endforeach

        <x-slot:bulk>…actions for the selection…</x-slot:bulk>
    </brok:admin-table>

    Column definition
    ------------------------------------------------------------------
    ['key' => 'total', 'label' => 'Total', 'align' => 'end', 'tier' => 1]

    tier   1 = always shown, 2 = opt-in via the column menu (default 1)
    align  'start' | 'end' (default 'start')
--}}
@props([
    // list<array{key:string,label:string,align?:string,tier?:int}>
    'columns' => [],
    // Render a leading checkbox column and the bulk action bar.
    'selectable' => false,
    // Keep the leading identity column in view while the table scrolls sideways.
    // The identity column is what operators track a row by, so it is the
    // sensible default when a table is wide enough to need this at all.
    'pinned' => false,
    // Names this table in local storage, so its layout survives navigation.
    'persist' => null,
    // Keep the header visible while the body scrolls. Needs a height on the
    // scroll container (see `maxHeight`) to have any effect.
    'stickyHeader' => true,
    'maxHeight' => null,
    'density' => 'compact',
    // Shown under the table; typically "Showing 7 of 128".
    'summary' => null,
    'columnsLabel' => 'Columns',
    // Says what the menu does. The previous text promised drag-to-resize,
    // which the component has never supported.
    'columnsHintLabel' => 'Show, reorder and pin columns',
    'resetLabel' => 'Reset',
    'toggleColumnsLabel' => 'Toggle columns',
    'showAllLabel' => 'Show all',
    'selectAllLabel' => 'Select all rows on this page',
    'selectedLabel' => 'selected',
    'clearLabel' => 'Clear',
    // Server-authorized actions only. Each entry accepts key, label,
    // destructive, confirmation, disabled, confirmWord and confirmDescription.
    // The server must still authorize the submitted key and selected record
    // IDs. `confirmWord`, left unset, changes nothing: an action opts in with
    // an exact word or phrase (e.g. "DELETE") the operator must type before
    // the confirm control in a composed `alert-dialog` enables — a reflex-
    // dismissable window.confirm() is not enough deliberation for an action
    // whose blast radius can be thousands of rows. `confirmDescription`
    // overrides the dialog's default "This cannot be undone." sentence.
    'bulkActions' => [],
    'bulkActionUrl' => null,
    'bulkSelectionName' => 'selected_ids',
    'bulkIdempotencyKey' => null,
    'bulkRecordVersion' => null,
    'bulkState' => 'idle',
    'bulkFailures' => [],
    // Total records the current filters match, across every page. Unset (the
    // default), the bulk bar behaves exactly as before: select-all covers only
    // the rendered page and there is no affordance to widen it. Set it once the
    // page's own row count is smaller than this to offer extending the
    // selection to every matching record.
    'matchedCount' => null,
    'selectAllMatchingLabel' => 'Select all :count matching',
    'allMatchingSelectedLabel' => 'All :count matching selected',
    'pageOnlyLabel' => 'Just this page',
    // Field name carrying the submitted scope ('page' or 'all'). Only sent
    // once `matchedCount` is set.
    'bulkScopeName' => 'selection_scope',
    // Opaque filter-state payload (e.g. the current query string) the
    // consumer wants submitted when the operator extends the selection to
    // every matching record. The component does not interpret it — a
    // matching-scoped selection is meaningless to the server without the
    // filters that defined "matching", so this is what carries them.
    'bulkFilters' => null,
    'bulkFiltersName' => 'selection_filters',
    // Labels for the typed-word confirmation dialog an action opts into via
    // its own `confirmWord` (see the `bulkActions` doc comment below).
    'bulkConfirmCancelLabel' => 'Cancel',
    'bulkConfirmWordLabel' => 'Type :word to confirm',
])

@php
    $normalised = [];
    $bulkActions = array_slice(array_values(array_filter(
        (array) $bulkActions,
        static fn ($action): bool => is_array($action) && filled($action['key'] ?? null) && filled($action['label'] ?? null),
    )), 0, 12);
    $bulkState = in_array($bulkState, ['idle', 'progress', 'partial', 'complete'], true) ? $bulkState : 'idle';
    $bulkFailures = array_slice(array_values((array) $bulkFailures), 0, 20);
    $matchedCount = $matchedCount !== null ? max(0, (int) $matchedCount) : null;

    /*
     * Whether any action opted into typed-word confirmation, decided once so
     * the form only grows an id (and each dialog's submit button only grows a
     * `form="…"` attribute) when something actually needs it.
     */
    $hasTypedConfirmations = (bool) array_filter(
        $bulkActions,
        static fn (array $action): bool => trim((string) ($action['confirmWord'] ?? '')) !== '',
    );

    /*
     * The alert-dialog composed below teleports its content to <body>, so its
     * submit button is no longer a descendant of this <form> by the time the
     * operator clicks it. The HTML `form` attribute re-associates a button
     * with a form from anywhere in the document — but only once the form has
     * an id to point at.
     */
    $bulkFormId = $hasTypedConfirmations ? 'admin-table-bulk-'.uniqid() : null;
    foreach ($columns as $index => $column) {
        $normalised[] = [
            'key' => $column['key'] ?? (string) $index,
            'label' => $column['label'] ?? '',
            'align' => in_array($column['align'] ?? 'start', ['end', 'right'], true) ? 'end' : 'start',
            'tier' => (int) ($column['tier'] ?? 1),
            'sortable' => (bool) ($column['sortable'] ?? true),
            'type' => (string) ($column['type'] ?? 'text'),
            /*
             * How this column is filtered, decided by what its data is.
             *
             * A single min/max pair for everything meant dates were typed as
             * numbers and free text could not be filtered at all. `date` gets
             * real date inputs, `text` gets a contains search, numbers keep the
             * range, and anything with a vocabulary gets the multi-select list.
             */
            'filterKind' => $column['filterKind'] ?? match ((string) ($column['type'] ?? 'text')) {
                'date' => 'date',
                'money', 'number', 'percent' => 'range',
                default => ($column['filter'] ?? []) !== [] ? 'list' : 'text',
            },
            'rangeMin' => $column['rangeMin'] ?? null,
            'rangeMax' => $column['rangeMax'] ?? null,
            'filter' => array_values((array) ($column['filter'] ?? [])),
            'group' => (string) ($column['group'] ?? ''),
            /*
             * A column pins to the start or the end of the row. The identity
             * column belongs at the start; an actions column belongs at the
             * end, where it stays reachable however wide the table gets.
             */
            'pinned' => $column['pinned'] ?? ($index === 0 && (bool) $pinned),
            'pinnedEnd' => ($column['pinnedEnd'] ?? false) === true,
        ];
    }

    /*
     * A column pinned to the end is rendered last, whatever order the caller
     * declared.
     *
     * `position: sticky` with an end offset only holds while the cell is the
     * last one in its row, so a trailing actions column declared before the
     * optional columns pinned only while those stayed hidden.
     */
    usort($normalised, static fn (array $a, array $b): int => ($a['pinnedEnd'] ? 1 : 0) <=> ($b['pinnedEnd'] ? 1 : 0));

    /*
     * Everything a person or a view may choose. Only the identity and the
     * actions are fixed: a row without them cannot be opened or operated.
     */
    $optional = array_values(array_filter(
        $normalised,
        static fn (array $c): bool => ! in_array($c['key'], ['id', 'actions'], true),
    ));

    /*
     * Density is a runtime attribute, not a class chosen here.
     *
     * These classes are fixed when the page renders, so a density control
     * driven by Alpine could flip its own state and change nothing on screen.
     * The root carries `data-density` instead and the stylesheet sets the
     * padding, so the toggle works without a re-render.
     */
@endphp

<div
    x-data="uiAdminTable({
        persistKey: '{{ $persist ?? '' }}',
        columns: {{ Illuminate\Support\Js::from(array_map(static fn (array $c): array => [
            'key' => $c['key'],
            'label' => $c['label'],
            'tier' => $c['tier'],
            'group' => $c['group'],
            'pinned' => $c['pinned'],
            'filter' => $c['filter'],
            'type' => $c['type'],
        ], $normalised)) }},
        @if ($matchedCount !== null)
        matchedCount: {{ $matchedCount }},
        @endif
    })"
    {{-- The page owns "all matching"; it tells the table to check every row. --}}
    @admin-table:select-all-matching.window="selectEveryRow()"
    {{-- The page does the sorting, so the header follows what it reports. --}}
    @admin-table:sync-sort.window="syncSort($event.detail.key, $event.detail.dir)"
    {{-- Saved views carry columns, so the page can ask for them and set them. --}}
    @admin-table:capture-columns.window="$event.detail.columns = columnState()"
    @admin-table:apply-columns.window="applyColumnState($event.detail.columns)"
    @admin-table:bulk-complete.window="clear()"
    {{ $attributes->merge(['class' => 'border-border bg-card relative w-full overflow-hidden rounded-md border']) }}
    data-density="{{ $density }}"
    data-slot="admin-table"
>
    @if (isset($toolbar) || $optional !== [])
        <div class="border-border flex flex-wrap items-center gap-2 border-b px-3 py-2">
            {{ $toolbar ?? '' }}

            @if ($optional !== [])
                <div
                    class="relative ms-auto"
                    x-data="{
                        open: false,
                        start: 0,
                        top: 0,
                        toggleMenu() {
                            this.open = ! this.open;
                            if (this.open) this.$nextTick(() => this.place());
                        },
                        place() {
                            const rect = this.$refs.columnsTrigger.getBoundingClientRect();
                            const width = Math.min(290, window.innerWidth - 16);
                            const rtl = getComputedStyle(this.$root).direction === 'rtl';
                            const desiredLeft = rtl ? rect.left : rect.right - width;
                            const left = Math.max(8, Math.min(desiredLeft, window.innerWidth - width - 8));
                            const height = this.$refs.columnsMenu?.offsetHeight || 370;
                            const below = rect.bottom + 4;
                            this.start = rtl ? window.innerWidth - left - width : left;
                            this.top = below + height <= window.innerHeight - 8
                                ? below
                                : Math.max(8, rect.top - height - 4);
                        },
                    }"
                    @keydown.escape="open = false"
                    @resize.window="open && place()"
                    @scroll.window="open && place()"
                >
                    <button
                        x-ref="columnsTrigger"
                        type="button"
                        @click="toggleMenu()"
                        :aria-expanded="open ? 'true' : 'false'"
                        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"
                    >
                        {{ __($columnsLabel) }}
                        <span class="text-xs tabular-nums text-muted-foreground" x-text="`${visibleCount}/${columns.length}`"></span>
                    </button>

                    <template x-teleport="body">
                        <div
                            x-ref="columnsMenu"
                            x-show="open"
                            x-cloak
                            @click.outside="open = false"
                            :style="`position: fixed; inset-inline-start: ${start}px; top: ${top}px; width: min(288px, calc(100vw - 16px));`"
                            class="border-border bg-popover text-popover-foreground z-popover rounded-md border p-1 shadow-md"
                            role="group"
                            aria-label="{{ __($toggleColumnsLabel) }}"
                        >
                        <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">{{ __($columnsLabel) }}</p>
                            <span class="flex shrink-0 items-center gap-3 text-xs">
                                {{--
                                    Turning every column on one checkbox at a
                                    time is not a thing anyone should have to
                                    do, so offer the whole set at once.
                                --}}
                                <button type="button" @click="showAllColumns()" class="text-muted-foreground underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:rounded-sm focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring">{{ __($showAllLabel) }}</button>
                                <button type="button" @click="resetColumns()" class="text-muted-foreground underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:rounded-sm focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring">{{ __($resetLabel) }}</button>
                            </span>
                        </div>
                        <p class="px-2 pb-1 text-xs text-muted-foreground">{{ __($columnsHintLabel) }}</p>
                        <div class="my-1 h-px bg-border" role="none"></div>

                        <div class="flex max-h-80 flex-col overflow-y-auto">
                            <template x-for="column in orderedColumns" :key="column.key">
                                <div class="flex min-h-8 items-center gap-2 rounded-sm px-2 text-sm text-popover-foreground transition-colors hover:bg-accent motion-reduce:transition-none">
                                    <x-ui.checkbox
                                        role="checkbox"
                                        {{-- Only the identity and the actions are fixed. --}}
                                        x-bind:disabled="['id', 'actions'].includes(column.key)"
                                        x-bind:checked="isVisible(column.key)"
                                        @change="toggle(column.key)"
                                        x-bind:aria-label="column.label"
                                    />

                                    <span
                                        class="flex-1 truncate"
                                        :class="isVisible(column.key) ? '' : 'text-muted-foreground'"
                                        x-text="column.label"
                                    ></span>

                                    <span
                                        x-show="column.group"
                                        class="shrink-0 text-2xs text-muted-foreground"
                                        x-text="column.group"
                                    ></span>

                                    {{-- Pin: keep this column in view while the table scrolls sideways. --}}
                                    <button
                                        type="button"
                                        @click="togglePinned(column.key)"
                                        :aria-pressed="isPinned(column.key) ? 'true' : 'false'"
                                        :class="isPinned(column.key) ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground'"
                                        class="inline-flex size-7 shrink-0 items-center justify-center rounded-sm transition-colors focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
                                        :aria-label="`Pin ${column.label}`"
                                    >
                                        <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="M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" /></svg>
                                    </button>

                                    <button
                                        type="button"
                                        @click="moveColumn(column.key, -1)"
                                        class="inline-flex size-7 shrink-0 items-center justify-center rounded-sm 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 disabled:pointer-events-none disabled:opacity-30 motion-reduce:transition-none"
                                        :disabled="orderedColumns.indexOf(column) === 0"
                                        :aria-label="`Move ${column.label} earlier`"
                                    >
                                        <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 6-6 6 6 6" /></svg>
                                    </button>

                                    <button
                                        type="button"
                                        @click="moveColumn(column.key, 1)"
                                        class="inline-flex size-7 shrink-0 items-center justify-center rounded-sm 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 disabled:pointer-events-none disabled:opacity-30 motion-reduce:transition-none"
                                        :disabled="orderedColumns.indexOf(column) === orderedColumns.length - 1"
                                        :aria-label="`Move ${column.label} later`"
                                    >
                                        <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 6 6 6-6 6" /></svg>
                                    </button>
                                </div>
                            </template>
                        </div>
                        </div>
                    </template>
                </div>
            @endif
        </div>
    @endif

    <div
        {{-- Tell any open column menu to follow its header sideways. --}}
        @scroll.passive="$dispatch('admin-table:scrolled')"
        class="overflow-x-auto {{ $maxHeight ? 'overflow-y-auto' : '' }}"
        @if ($maxHeight) style="max-height: {{ $maxHeight }}" @endif
    >
        <table class="w-full border-collapse text-start text-sm" data-slot="admin-table-grid">
            <thead @class(['bg-card', 'sticky top-0 z-10' => $stickyHeader])>
                <tr class="border-border border-b">
                    @if ($selectable)
                        <th scope="col" class="bg-card sticky start-0 z-20 h-10 w-12 px-3">
                            {{--
                                `x-effect` rather than `:checked` / `:indeterminate`.

                                `indeterminate` is a DOM property with no
                                matching attribute, so an attribute binding
                                cannot express it, and `x-effect` is the only
                                way to keep it in step with the selection.

                                The handler deliberately ignores the checkbox's
                                own `checked` value and derives the intent from
                                state instead. Reading the DOM here races with
                                the effect that writes it, and the intent is
                                unambiguous anyway: if the page is not fully
                                selected, select it; otherwise clear it.
                            --}}
                            {{-- Same 32px target as the row boxes below it. --}}
                            <label class="-m-3 grid size-10 cursor-pointer place-items-center">
                            <input
                                type="checkbox"
                                class="accent-primary size-4 align-middle"
                                aria-label="{{ __($selectAllLabel) }}"
                                x-effect="$el.checked = allSelected; $el.indeterminate = someSelected && ! allSelected"
                                @change="toggleAll(! allSelected)"
                            />
                            </label>
                        </th>
                    @endif

                    @foreach ($normalised as $column)
                        <th
                            scope="col"
                            {{--
                                A width the operator dragged wins over the
                                layout's own sizing, and the binding returns an
                                object on purpose: a string style binding
                                replaces the whole attribute, which wiped the
                                pinned offset rendered below. A sticky cell with
                                no offset does not stay in view.
                            --}}
                            x-bind:style="columnStyle('{{ $column['key'] }}')"
                            data-column="{{ $column['key'] }}"
                            @class([
                                // `relative` anchors the resize handle below.
                                'text-muted-foreground relative h-10 px-3 text-xs font-medium whitespace-nowrap',
                                // `<th>` centres by default, which leaves every
                                // header floating over left-aligned cells.
                                'text-start' => $column['align'] !== 'end',
                                'text-end' => $column['align'] === 'end',
                                'bg-card sticky z-20' => $column['pinned'] || $column['pinnedEnd'],
                            ])
                            @if ($column['pinnedEnd'])
                                style="inset-inline-end: 0"
                            @elseif ($column['pinned'])
                                style="inset-inline-start: {{ $selectable ? '3rem' : '0' }}"
                            @endif
                        >
                            {{--
                                Drag the trailing edge to resize.

                                A wide column of order numbers and a narrow one
                                of long exception text is a layout only the
                                person reading it can judge, so let them.
                            --}}
                            <span
                                role="separator"
                                aria-orientation="vertical"
                                aria-label="Resize {{ $column['label'] }}"
                                @pointerdown.prevent.stop="startResize('{{ $column['key'] }}', $event)"
                                class="hover:bg-info-line absolute inset-y-0 end-0 z-10 w-1 cursor-col-resize"
                            ></span>

                            @php
                                /*
                                 * Sort labels say what the order MEANS for this
                                 * kind of data. "A to Z" on a date column, or
                                 * on a status, is technically true and useless:
                                 * nobody wants orders alphabetised by
                                 * "Blocked, Handed over, Not started".
                                 */
                                $sortLabels = [
                                    'text' => ['A to Z', 'Z to A'],
                                    'number' => ['Lowest first', 'Highest first'],
                                    'money' => ['Lowest first', 'Highest first'],
                                    'percent' => ['Lowest first', 'Highest first'],
                                    'date' => ['Newest first', 'Oldest first'],
                                    'stage' => ['Earliest stage first', 'Latest stage first'],
                                ];

                                [$ascLabel, $descLabel] = $sortLabels[$column['type']] ?? $sortLabels['text'];
                                /*
                                 * A date column ranges too: its sort value is
                                 * elapsed time, so "everything over four hours
                                 * old" is a min/max question and exactly what
                                 * an operations list is asked for all day.
                                 */
                                $ranged = in_array($column['type'], ['number', 'money', 'percent', 'date'], true)
                                    && ($column['rangeMin'] !== null || $column['rangeMax'] !== null);
                                $hasMenu = $column['filterKind'] !== 'none'
                                    && ($column['sortable']
                                        || $column['filter'] !== []
                                        || in_array($column['filterKind'], ['range', 'date', 'text'], true));
                            @endphp

                            @if (! $hasMenu)
                                {{-- Nothing to sort or filter: no control, no affordance. --}}
                                <span @class(['inline-flex', 'justify-end' => $column['align'] === 'end'])>
                                    {{ $column['label'] }}
                                </span>
                            @else
                                <span
                                    @class(['relative inline-flex', 'justify-end' => $column['align'] === 'end'])
                                    x-data="{
                                        anchor: { x: 0, y: 0 },
                                        /*
                                         * Which menu is open lives on the table,
                                         * not here. With per-menu state the first
                                         * click on another header only dismissed
                                         * the one already open, so switching
                                         * columns always took two clicks.
                                         */
                                        get open() { return openMenu === '{{ $column['key'] }}'; },
                                        set open(value) { openMenu = value ? '{{ $column['key'] }}' : null; },
                                        place() {
                                            const r = this.$refs.trigger.getBoundingClientRect();
                                            const width = 256;
                                            const margin = 8;
                                            // An end-aligned column opens its menu from the trigger's end edge.
                                            const preferred = {{ $column['align'] === 'end' ? 'r.right - width' : 'r.left' }};
                                            const x = Math.min(
                                                Math.max(margin, preferred),
                                                window.innerWidth - width - margin,
                                            );
                                            this.anchor = { x, y: r.bottom + 8 };
                                        },
                                    }"
                                    @keydown.escape.window="openMenu = null"
                                    {{--
                                        A teleported menu is positioned in
                                        viewport coordinates, so it stays put
                                        while the table scrolls out from under
                                        it. Following the header keeps them
                                        together; closing on a page scroll
                                        avoids a menu floating over content it
                                        no longer belongs to.
                                    --}}
                                    @scroll.window.passive="open && (openMenu = null)"
                                    @admin-table:scrolled.window="open && place()"
                                >
                                    {{--
                                        The whole label is the trigger. A 20px
                                        chevron is a small target for something
                                        used all day, and people click the column
                                        name expecting exactly this.
                                    --}}
                                    <button
                                        type="button"
                                        x-ref="trigger"
                                        data-admin-table-menu-trigger
                                        @click="place(); openMenu = open ? null : '{{ $column['key'] }}'"
                                        :aria-expanded="open ? 'true' : 'false'"
                                        aria-label="Sort and filter {{ $column['label'] }}"
                                        class="inline-flex h-8 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 {{ $column['align'] === 'end' ? '-me-2 flex-row-reverse' : '-ms-2' }}"
                                        :data-active="hasFilter('{{ $column['key'] }}') || columnSortKey === '{{ $column['key'] }}' ? 'true' : 'false'"
                                    >
                                        <span>{{ $column['label'] }}</span>
                                        @if ($column['sortable'])
                                            <svg x-show="columnSortKey !== '{{ $column['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="isSorted('{{ $column['key'] }}', '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="isSorted('{{ $column['key'] }}', '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
                                        <span x-show="hasFilter('{{ $column['key'] }}')" x-cloak class="size-2 shrink-0 rounded-full bg-ring" aria-hidden="true"></span>
                                    </button>

                                    <template x-teleport="body">
                                        <div
                                            x-show="open"
                                            x-cloak
                                            {{--
                                                Ignore clicks on another column's
                                                trigger. Otherwise this runs after
                                                that trigger has opened its own
                                                menu and closes it again, so
                                                switching columns does nothing.
                                            --}}
                                            @click.outside="
                                                if (! $event.target.closest('[data-admin-table-menu-trigger]')) {
                                                    openMenu = null;
                                                }
                                            "
                                            @scroll.window="openMenu = null"
                                            :style="`position:fixed; left:${anchor.x}px; top:${anchor.y}px`"
                                            class="border-border bg-popover text-popover-foreground z-popover max-h-[70vh] w-64 overflow-y-auto rounded-md border p-1 text-start text-sm font-normal shadow-md"
                                            role="group"
                                            aria-label="{{ $column['label'] }}"
                                        >
                                            @if ($column['sortable'])
                                                <p class="px-2 pt-1 pb-1 text-2xs font-semibold uppercase tracking-wide text-muted-foreground">{{ __('Sort') }}</p>
                                                @foreach ([['asc', $ascLabel], ['desc', $descLabel]] as [$dir, $label])
                                                    <button
                                                        type="button"
                                                        @click="sortColumn('{{ $column['key'] }}', '{{ $dir }}'); openMenu = null"
                                                        class="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"
                                                        :aria-pressed="isSorted('{{ $column['key'] }}', '{{ $dir }}') ? 'true' : 'false'"
                                                    >
                                                        @if ($dir === 'asc')
                                                            <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>
                                                        @else
                                                            <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>
                                                        @endif
                                                        <span class="flex-1">{{ $label }}</span>
                                                        <svg x-show="isSorted('{{ $column['key'] }}', '{{ $dir }}')" 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>
                                                @endforeach
                                            @endif

                                            @if ($ranged && $column['filterKind'] === 'range')
                                                <div class="my-1 h-px bg-border" role="none"></div>
                                                <p class="px-2 pt-1 pb-1 text-2xs font-semibold uppercase tracking-wide text-muted-foreground">{{ __('Range') }}</p>
                                                <div class="flex items-center gap-2 px-2 pb-2" data-range-pair>
                                                    <input
                                                        type="number"
                                                        inputmode="decimal"
                                                        placeholder="{{ $column['rangeMin'] ?? 'Min' }}"
                                                        aria-label="Minimum {{ $column['label'] }}"
                                                        data-range-bound="min"
                                                        :value="rangeFilter('{{ $column['key'] }}').min"
                                                        @input.debounce.300ms="setRangeFrom('{{ $column['key'] }}', $event.target)"
                                                        class="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"
                                                    />
                                                    <span class="text-xs text-muted-foreground">{{ __('to') }}</span>
                                                    <input
                                                        type="number"
                                                        inputmode="decimal"
                                                        placeholder="{{ $column['rangeMax'] ?? 'Max' }}"
                                                        aria-label="Maximum {{ $column['label'] }}"
                                                        data-range-bound="max"
                                                        :value="rangeFilter('{{ $column['key'] }}').max"
                                                        @input.debounce.300ms="setRangeFrom('{{ $column['key'] }}', $event.target)"
                                                        class="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"
                                                    />
                                                </div>
                                            @endif

                                            @if ($column['filterKind'] === 'date')
                                                <div class="my-1 h-px bg-border" role="none"></div>
                                                <p class="px-2 pt-1 pb-1 text-2xs font-semibold uppercase tracking-wide text-muted-foreground">{{ __('Between') }}</p>
                                                <div class="flex items-center gap-2 px-2 pb-2" data-range-pair>
                                                    <input
                                                        type="date"
                                                        aria-label="{{ $column['label'] }} from"
                                                        data-range-bound="min"
                                                        :value="rangeFilter('{{ $column['key'] }}').min"
                                                        @change="setRangeFrom('{{ $column['key'] }}', $event.target)"
                                                        class="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"
                                                    />
                                                    <span class="text-xs text-muted-foreground">{{ __('to') }}</span>
                                                    <input
                                                        type="date"
                                                        aria-label="{{ $column['label'] }} to"
                                                        data-range-bound="max"
                                                        :value="rangeFilter('{{ $column['key'] }}').max"
                                                        @change="setRangeFrom('{{ $column['key'] }}', $event.target)"
                                                        class="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"
                                                    />
                                                </div>
                                            @endif

                                            @if ($column['filterKind'] === 'text')
                                                <div class="my-1 h-px bg-border" role="none"></div>
                                                <p class="px-2 pt-1 pb-1 text-2xs font-semibold uppercase tracking-wide text-muted-foreground">{{ __('Contains') }}</p>
                                                <div class="px-2 pb-2">
                                                    <input
                                                        type="search"
                                                        placeholder="{{ $column['label'] }} contains"
                                                        aria-label="{{ $column['label'] }} contains"
                                                        :value="textFilter('{{ $column['key'] }}')"
                                                        @input.debounce.300ms="setText('{{ $column['key'] }}', $event.target.value)"
                                                        class="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"
                                                    />
                                                </div>
                                            @endif

                                            @if ($column['filter'] !== [] || $column['filterKind'] === 'list')
                                                <div class="my-1 h-px bg-border" role="none"></div>
                                                <p class="px-2 pt-1 pb-1 text-2xs font-semibold uppercase tracking-wide text-muted-foreground">{{ __('Filter') }}</p>
                                                {{--
                                                    Options come from the rows as well as the declared
                                                    vocabulary, so a value the operator can see is never
                                                    missing from the menu they would use to filter for it.
                                                --}}
                                                <div class="max-h-48 overflow-y-auto">
                                                    <template
                                                        x-for="option in filterOptions('{{ $column['key'] }}', {{ Illuminate\Support\Js::from($column['filter']) }})"
                                                        :key="option"
                                                    >
                                                        <label class="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 cursor-pointer">
                                                            <input
                                                                type="checkbox"
                                                                class="size-4 shrink-0 rounded-sm border-input accent-primary"
                                                                :checked="isChecked('{{ $column['key'] }}', option)"
                                                                @change="toggleValue('{{ $column['key'] }}', option)"
                                                            />
                                                            <span class="truncate" x-text="option"></span>
                                                        </label>
                                                    </template>
                                                </div>
                                            @endif

                                            <div class="my-1 h-px bg-border" role="none"></div>
                                            <button
                                                type="button"
                                                @click="clearFilter('{{ $column['key'] }}'); openMenu = null"
                                                class="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 text-muted-foreground"
                                                x-show="hasFilter('{{ $column['key'] }}')"
                                                x-cloak
                                            >
                                                <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="M13.013 3H2l8 9.46V19l4 2v-8.54l.9-1.055M22 3l-5 5m0-5 5 5" /></svg>
                                                {{ __('Clear filter') }}
                                            </button>
                                            <button type="button" @click="openMenu = null; $refs.trigger?.focus()" class="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 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="m5 12 5 5L20 7" /></svg>
                                                {{ __('Done') }}
                                            </button>
                                        </div>
                                    </template>
                                </span>
                            @endif
                        </th>
                    @endforeach
                    {{-- Mirrors the trailing activation cell a row with `href` renders
                         (row.blade.php), so the header paints across it too. --}}
                    <th scope="col" x-show="hasActivation" x-cloak class="bg-card sticky z-20 w-12 px-3" style="inset-inline-end: 0">
                        <span class="sr-only">{{ __('Open') }}</span>
                    </th>
                </tr>
            </thead>

            <tbody>
                {{ $slot }}
            </tbody>
        </table>
    </div>

    @if ($summary !== null || isset($footer))
        <div class="border-border bg-card text-muted-foreground flex items-center justify-between gap-3 border-t px-3 py-2 text-sm">
            <span>{{ $summary }}</span>
            <div class="flex items-center gap-2">{{ $footer ?? '' }}</div>
        </div>
    @endif

    @if ($selectable && (isset($bulk) || $bulkActions !== []))
        {{--
            Bulk bar.

            Fixed to the bottom of the VIEWPORT, not absolutely positioned
            inside the table. Anchoring it to the table put it wherever the
            table happened to end: on a short filtered list that is halfway up
            the screen, sitting on top of the rows the operator just selected.
            The viewport is the one reference that is always in the same place.

            It floats rather than pushing layout, so selecting a row never moves
            the row under the cursor.
        --}}
        <div
            x-show="someSelected"
            x-cloak
            x-transition.opacity.duration.120ms
            class="pointer-events-none fixed inset-x-0 bottom-5 z-popover flex justify-center px-4"
        >
            <div class="border-border-strong bg-popover pointer-events-auto flex max-w-full items-center gap-3 overflow-x-auto rounded-md border px-3 py-2 shadow-lg">
                @if ($matchedCount === null)
                    <span class="shrink-0 text-xs font-medium" aria-live="polite">
                        <span x-text="selectedCount"></span> {{ __($selectedLabel) }}
                    </span>
                @else
                    {{--
                        Scope is announced, not only shown.

                        "Selected everything on the page" and "selected every
                        matching record" are different facts, and a bulk action
                        acts on whichever one this region currently states. The
                        `role="status"`/`aria-live` pair is what carries the
                        transition between them to assistive tech — a sighted
                        operator sees the badge change colour, a screen-reader
                        user needs the same change spoken, since it is about to
                        change what every action below does.
                    --}}
                    <span class="shrink-0 text-xs font-medium" role="status" aria-live="polite">
                        <template x-if="selectionScope !== 'all'">
                            <span><span x-text="selectedCount"></span> {{ __($selectedLabel) }}</span>
                        </template>
                        <template x-if="selectionScope === 'all'">
                            <span
                                class="border-info-line bg-info-soft text-info-text inline-flex items-center gap-2 rounded-md border px-2 py-0.5"
                                data-slot="admin-table-scope-all"
                            >
                                {{ __($allMatchingSelectedLabel, ['count' => $matchedCount]) }}
                            </span>
                        </template>
                    </span>

                    <template x-if="canExtendSelection">
                        <button
                            type="button"
                            @click="extendSelectionToAllMatching()"
                            class="text-info shrink-0 text-xs underline underline-offset-2"
                        >
                            {{ __($selectAllMatchingLabel, ['count' => $matchedCount]) }}
                        </button>
                    </template>

                    <template x-if="selectionScope === 'all'">
                        <button
                            type="button"
                            @click="revertSelectionToPage()"
                            class="text-muted-foreground hover:text-foreground shrink-0 text-xs underline underline-offset-2"
                        >
                            {{ __($pageOnlyLabel) }}
                        </button>
                    </template>
                @endif
                <button
                    type="button"
                    @click="clear()"
                    class="text-muted-foreground hover:text-foreground shrink-0 text-xs underline underline-offset-2"
                >
                    {{ __($clearLabel) }}
                </button>
                <span class="bg-border h-4 w-px shrink-0" aria-hidden="true"></span>
                {{ $bulk ?? '' }}
                @if ($bulkActions !== [])
                    <form
                        method="POST"
                        @if (filled($bulkActionUrl)) action="{{ $bulkActionUrl }}" @endif
                        @if ($bulkFormId) id="{{ $bulkFormId }}" @endif
                        class="flex items-center gap-2"
                        @if ($bulkState === 'progress') aria-busy="true" @endif
                    >
                        @csrf
                        @if (filled($bulkIdempotencyKey))
                            <input type="hidden" name="idempotency_key" value="{{ $bulkIdempotencyKey }}" />
                        @endif
                        @if (filled($bulkRecordVersion))
                            <input type="hidden" name="record_version" value="{{ $bulkRecordVersion }}" />
                        @endif
                        @if ($matchedCount === null)
                            <template x-for="key in Array.from(selected)" :key="key">
                                <input type="hidden" name="{{ $bulkSelectionName }}[]" :value="key" />
                            </template>
                        @else
                            {{--
                                A matching-scoped selection never submits a list
                                of ids: the whole point of extending it was that
                                the ids on THIS page are not the ids that
                                matter. The server is told the scope plus the
                                filter state instead; only a page-scoped
                                selection still sends ids, exactly as before.
                            --}}
                            <template x-if="selectionScope !== 'all'">
                                <template x-for="key in Array.from(selected)" :key="key">
                                    <input type="hidden" name="{{ $bulkSelectionName }}[]" :value="key" />
                                </template>
                            </template>
                            <input type="hidden" name="{{ $bulkScopeName }}" x-bind:value="selectionScope" />
                            @if ($bulkFilters !== null)
                                <template x-if="selectionScope === 'all'">
                                    <input type="hidden" name="{{ $bulkFiltersName }}" value="{{ $bulkFilters }}" />
                                </template>
                            @endif
                        @endif
                        @foreach ($bulkActions as $action)
                            @php
                                $actionKey = preg_replace('/[^a-z0-9_-]/i', '', (string) $action['key']);
                                $confirmation = (string) ($action['confirmation'] ?? 'none');
                                $needsConfirmation = in_array($confirmation, ['confirm', 'destructive'], true);
                                $confirmWord = trim((string) ($action['confirmWord'] ?? ''));
                                $needsTypedConfirmation = $confirmWord !== '';
                                $staticallyDisabled = $bulkState === 'progress' || ($action['disabled'] ?? false);
                            @endphp
                            @if ($actionKey !== '')
                                @if ($needsTypedConfirmation)
                                    @php
                                        $confirmDescription = (string) ($action['confirmDescription'] ?? __('This cannot be undone.'));
                                        $confirmInputId = "{$bulkFormId}-confirm-{$actionKey}";
                                    @endphp
                                    {{--
                                        A typed-word gate composes alert-dialog
                                        rather than window.confirm(): a
                                        reflex-dismissable "OK" is not enough
                                        deliberation for an action that can
                                        touch thousands of rows. `typed` lives
                                        on this wrapper — an ancestor of both
                                        the trigger and the dialog's teleported
                                        content — so both can read and reset it
                                        even though alert-dialog moves the
                                        panel to <body>.
                                    --}}
                                    <div x-data="{ typed: '' }" class="contents" data-slot="admin-table-bulk-confirm">
                                        <x-ui.alert-dialog>
                                            <x-ui.button
                                                type="button"
                                                :variant="($action['destructive'] ?? false) ? 'destructive' : 'outline'"
                                                :disabled="$staticallyDisabled"
                                                x-on:click="typed = ''; show()"
                                            >
                                                {{ $action['label'] }}
                                            </x-ui.button>

                                            <x-ui.alert-dialog.content>
                                                <x-ui.alert-dialog.header>
                                                    <x-ui.alert-dialog.title>{{ $action['label'] }}</x-ui.alert-dialog.title>
                                                    <x-ui.alert-dialog.description>{{ $confirmDescription }}</x-ui.alert-dialog.description>
                                                </x-ui.alert-dialog.header>

                                                <div class="mt-4 flex flex-col gap-2">
                                                    <label for="{{ $confirmInputId }}" class="text-foreground text-sm">
                                                        {{ __($bulkConfirmWordLabel, ['word' => $confirmWord]) }}
                                                    </label>
                                                    {{-- The word is real, selectable text — never a placeholder or an image — so it can be copied and read by assistive tech. --}}
                                                    <p class="text-muted-foreground text-sm">
                                                        <code class="bg-muted text-foreground rounded px-1 py-0.5 font-mono select-all">{{ $confirmWord }}</code>
                                                    </p>
                                                    <input
                                                        id="{{ $confirmInputId }}"
                                                        type="text"
                                                        autocomplete="off"
                                                        autocorrect="off"
                                                        autocapitalize="off"
                                                        spellcheck="false"
                                                        x-model="typed"
                                                        class="border-input bg-background focus-visible:ring-ring h-10 rounded-md border px-3 font-mono text-sm focus-visible:ring-[3px] focus-visible:outline-none"
                                                    />
                                                </div>

                                                <x-ui.alert-dialog.footer>
                                                    <x-ui.alert-dialog.cancel>{{ __($bulkConfirmCancelLabel) }}</x-ui.alert-dialog.cancel>
                                                    {{--
                                                        Teleported to <body> by alert-dialog.content, so it is
                                                        no longer inside this <form> — `form="…"` re-associates
                                                        it. The match is forgiving (trimmed, case-insensitive):
                                                        the point is deliberation, not a spelling test.
                                                    --}}
                                                    <x-ui.button
                                                        type="submit"
                                                        form="{{ $bulkFormId }}"
                                                        name="bulk_action"
                                                        :value="$actionKey"
                                                        :variant="($action['destructive'] ?? false) ? 'destructive' : 'default'"
                                                        x-bind:disabled="{{ $staticallyDisabled ? 'true' : 'false' }} || typed.trim().toLowerCase() !== {{ Illuminate\Support\Js::from(mb_strtolower($confirmWord)) }}"
                                                    >
                                                        {{ $action['label'] }}
                                                    </x-ui.button>
                                                </x-ui.alert-dialog.footer>
                                            </x-ui.alert-dialog.content>
                                        </x-ui.alert-dialog>
                                    </div>
                                @else
                                    <x-ui.button
                                        type="submit"
                                        name="bulk_action"
                                        :value="$actionKey"
                                        :variant="($action['destructive'] ?? false) ? 'destructive' : 'outline'"
                                        :disabled="$staticallyDisabled"
                                        :data-requires-confirmation="$needsConfirmation ? 'true' : 'false'"
                                        :data-confirmation="$action['confirmationLabel'] ?? __('Confirm this bulk action?')"
                                        x-on:click="if ($el.dataset.requiresConfirmation === 'true' && ! window.confirm($el.dataset.confirmation)) $event.preventDefault()"
                                    >
                                        {{ $action['label'] }}
                                    </x-ui.button>
                                @endif
                            @endif
                        @endforeach
                    </form>
                @endif
            </div>
        </div>
    @endif

    @if ($bulkState !== 'idle')
        <div
            data-slot="admin-table-bulk-status"
            role="status"
            aria-live="polite"
            @class([
                'border-t border-border px-3 py-2 text-sm',
                'bg-info-soft text-info-text' => $bulkState === 'progress',
                'bg-warning-soft text-warning-text' => $bulkState === 'partial',
                'bg-success-soft text-success-text' => $bulkState === 'complete',
            ])
        >
            {{ match ($bulkState) {
                'progress' => __('The bulk action is in progress.'),
                'partial' => __('The bulk action completed with some failures.'),
                'complete' => __('The bulk action is complete. The selection was reset.'),
            } }}
            @if ($bulkFailures !== [])
                <ul class="mt-1 list-disc ps-5">
                    @foreach ($bulkFailures as $failure)
                        <li>{{ is_scalar($failure) ? $failure : __('One selected record failed.') }}</li>
                    @endforeach
                </ul>
            @endif
        </div>
    @endif
</div>
resources/views/components/ui/admin-table/row.blade.php Blade
{{--
    Admin table row.

    Owns selection wiring and the hover/selected treatment so consumers only
    write cells. `key` must be the record's stable identifier: selection is
    keyed to identity, not to visual position, so sorting or paging a table
    never moves the selection to a different record.

    `href`, unset by default, opts a row into activation: the record opens on
    a plain click anywhere in the row, not only on the small link this
    renders. A whole `<tr>` cannot be wrapped in an anchor, and a click
    handler on the row alone is reachable by neither keyboard nor a screen
    reader — so this renders a REAL, focusable `<a>` (keyboard Tab + native
    Enter, announced as a link with its own accessible name) and lets the
    table's Alpine scope (`admin-table.js` / `admin-table-activation.js`)
    forward a mouse click anywhere else in the row to that same destination.
    That forwarding ignores a click that started on one of the row's own
    controls (the selection checkbox, a button, a link, a menu) and a click
    that ends a text-selection drag rather than a genuine click, so neither
    ever fires a navigation the operator did not intend.

    The link renders as the row's own trailing, end-pinned cell (matching a
    `pinnedEnd` column's own `inset-inline-end: 0`, see cell.blade.php) so it
    stays reachable while the table scrolls sideways. A consumer column that
    is ALSO declared `pinnedEnd` (e.g. a hand-rolled actions column) will
    overlap it at that same offset — pin at most one trailing element, same
    as any other `pinnedEnd` column, and prefer leaving a custom actions
    column unpinned when a row is also activatable.

    TRUST BOUNDARY: `href` is escaped for both the attribute and the JS
    expression it is forwarded through, but it is NOT scheme-validated. A row
    destination is a route your own application resolves, so it must be
    developer-authored or a server-generated URL — never a value an end user
    can set. A `javascript:` URL supplied there would still execute.

    The activation markup below is assembled as plain strings (each dynamic
    value escaped on the way in) and printed with `{!! !!}` fused directly
    onto the neighbouring `{{ }}` output, rather than wrapped in `@if`/`@endif`.
    That is deliberate, not a style slip: it is what keeps a row with no
    `href` byte-identical to a row from before this feature existed — an
    `@if`/`@endif` pair inserted anywhere here would leave its own blank line
    behind even while false, and this component's Blade compiler additionally
    requires whitespace on both sides of `@endif`, which rules out closing
    that gap by butting directives against each other.
--}}
@aware([
    'selectable' => false,
])

@props([
    'key' => null,
    'selectLabel' => 'Select :record',
    'href' => null,
    'activateLabel' => 'Open :record',
])

@php
    $rowKey = (string) ($key ?? '');
    $activatable = filled($href);

    $activationRowAttributes = $activatable
        ? ' data-activatable="true" @mousedown="recordRowPointerDown($event)" @click="activateRow($event, '.\Illuminate\Support\Js::from($href)->toHtml().')"'
        : '';

    $activationCell = $activatable
        ? '<td class="sticky z-10 w-12 bg-inherit px-3 align-middle" style="inset-inline-end: 0" data-slot="admin-table-row-activate">'
            .'<a href="'.e($href).'" aria-label="'.e(__($activateLabel, ['record' => $rowKey])).'" data-slot="admin-table-row-link" '
            .'class="text-faint-foreground hover:text-foreground focus-visible:ring-ring -m-2 grid size-8 place-items-center rounded focus-visible:ring-[3px] focus-visible:outline-none">'
            .'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 rtl:rotate-180"><path d="m9 18 6-6-6-6" /></svg>'
            .'</a></td>'
        : '';
@endphp

{{--
    The row owns its background, and sticky cells inherit it.

    Pinned cells need an opaque background or the horizontally scrolled content
    shows through them. If they set their own (`bg-card`), they stop tracking
    the row: hovering highlighted the scrollable part of the row while the
    pinned Order and Actions cells stayed pale, so the highlight ended halfway
    across. Putting the background on the `<tr>` and giving pinned cells
    `bg-inherit` makes every row state, hover, selection, and anything added
    later, cover the full width for free.

    The selected and default backgrounds are bound as one expression rather than
    layered: `bg-info-soft` and `bg-card` are both background utilities, and
    which one wins would otherwise depend on Tailwind's class order, not ours.
--}}
<tr
    @if ($rowKey !== '') data-key="{{ $rowKey }}" @endif
    @if ($rowKey !== '' && $selectable)
        x-bind:class="
            isSelected(@js($rowKey))
                ? 'bg-info-soft'
                : 'bg-card hover:bg-surface-2'
        "
    @endif
    {{ $attributes->merge(['class' => trim(
        'border-border bg-card hover:bg-surface-2 border-b transition-colors last:border-b-0'
        . ($activatable ? ' cursor-pointer' : '')
    )]) }}{!! $activationRowAttributes !!}
    data-slot="admin-table-row"
>
    @if ($selectable)
        <td class="sticky start-0 z-10 w-12 bg-inherit px-3 align-middle">
            {{--
                The box stays small; the target does not.

                A 14px checkbox is below the 24px minimum and painful to hit on
                a dense row, which is exactly where selection errors are most
                expensive. The label gives it a 32px square to be clicked in
                without changing how the row looks.
            --}}
            <label class="-m-3 grid size-10 cursor-pointer place-items-center">
            <input
                type="checkbox"
                class="accent-primary size-4 align-middle"
                aria-label="{{ __($selectLabel, ['record' => $rowKey]) }}"
                x-effect="$el.checked = isSelected(@js($rowKey))"
                @change="toggleRow(@js($rowKey))"
            />
            </label>
        </td>
    @endif

    {{ $slot }}{!! $activationCell !!}
</tr>
resources/views/components/ui/admin-table/cell.blade.php Blade
{{--
    Admin table cell.

    `column` ties the cell to its definition so tier-2 columns hide and show
    together with their header, and pinned cells inherit the same offset. Pass
    it whenever the parent table declares tiers or pinning.
--}}
@aware([
    'selectable' => false,
    'columns' => null,
])

@props([
    'column' => null,
    'align' => 'start',
    'tier' => 1,
    'pinned' => false,
    'pinnedEnd' => false,
])

@php
    /*
     * A cell for a column the table does not declare is not rendered.
     *
     * Rows are written by hand, so a column the table dropped, because the
     * viewer's role may not see it, would otherwise still ship its cell. The
     * values would reach the page and every later cell would sit under the
     * wrong header. Skipping here keeps the row honest against whatever set
     * of columns the table was given.
     */
    $declared = is_array($columns)
        ? array_column($columns, 'key')
        : null;

    $omitted = $column !== null && $declared !== null && ! in_array($column, $declared, true);
@endphp

@if (! $omitted)

<td
    @if ($column) data-column="{{ $column }}" @endif
    {{--
        Every column answers to the table's visibility, not only the optional
        ones. A saved view chooses its own columns, and it cannot do that if
        some of them are pinned open by their tier.
    --}}

    {{ $attributes->merge(['class' => trim(
        'px-3 py-2 align-middle'
        . (in_array($align, ['end', 'right'], true) ? ' text-end' : '')
        . ($pinned || $pinnedEnd ? ' sticky z-10 bg-inherit' : '')
    )]) }}
    @if ($pinnedEnd)
        style="inset-inline-end: 0"
    @elseif ($pinned)
        style="inset-inline-start: {{ $selectable ? '3rem' : '0' }}"
    @endif
    data-slot="admin-table-cell"
>
    {{ $slot }}
</td>
@endif
resources/views/components/ui/admin-table/empty.blade.php Blade
{{--
    Admin table empty row. Spans the grid so the message centres under the
    header instead of hugging the first column.
--}}
@props(['colspan' => 99])

<tr>
    <td
        colspan="{{ $colspan }}"
        {{ $attributes->merge(['class' => 'px-6 py-14 text-center']) }}
        data-slot="admin-table-empty"
    >
        {{ $slot }}
    </td>
</tr>
resources/js/ui/admin-table.js JS
import {
    activateRow,
    recordRowPointerDown,
} from './admin-table-activation.js';
import {
    clearSelection,
    publishSelection,
    selectEveryVisibleRow,
    toggleAllVisibleRows,
    toggleSelectedRow,
    visibleRowKeys,
} from './admin-table-selection.js';

/**
 * Admin table: selection and column visibility.
 *
 * Selection is keyed to the record identifier the consumer passes as `key`,
 * never to row position, so sorting, filtering, or paging the table cannot
 * silently move a selection onto a different record.
 *
 * Column visibility only ever concerns tier-2 columns. Tier-1 columns are the
 * ones an operator needs to do the job and are not hideable.
 *
 * 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('uiAdminTable', (config = {}) => ({
        columns: config.columns ?? [],
        selected: new Set(),

        /**
         * Selection scope: 'page' (the rendered rows, today's only behaviour)
         * or 'all' (every record the consumer's filters currently match).
         *
         * Null unless the consumer sets `matchedCount`, so a table that never
         * opts in can never end up in the 'all' state.
         */
        selectionScope: 'page',

        /**
         * Total records the current filters match, across every page, or
         * null when the consumer has not supplied one.
         *
         * This is what makes "select all matching" possible at all: without a
         * server-reported total, offering to select "everything" would be a
         * promise the component cannot keep.
         */
        matchedCount: config.matchedCount ?? null,

        /**
         * This component's own root element, captured once in `init()`.
         *
         * Neither `$el` nor `$root` is safe here. Alpine resolves both against
         * the scope the expression is running in: `$el` is whichever element
         * fired, and `$root` is the nearest `x-data` ancestor: which, for a
         * control inside a nested `x-data` (a dropdown holding its own open
         * state, say), is that dropdown and not this table. A row query would
         * then find nothing and the action would silently do nothing.
         *
         * In `init()`, `$el` is reliably the component root, so it is stored.
         */
        tableRoot: null,
        // True when at least one row opted into activation (row.blade.php
        // `href`), so the header renders the matching trailing cell.
        hasActivation: false,

        /**
         * Where the pointer went down for whichever activatable row is
         * currently being interacted with, so the row's own `click` can tell
         * a drag from a click (see admin-table-activation.js). Only one row
         * can be mid-interaction at a time, so one field is enough.
         */
        rowPointerDown: null,

        /** Names this table in storage; empty disables remembering. */
        persistKey: config.persistKey ?? '',

        /**
         * Column filters and sort, owned here.
         *
         * The table owns the CONTROL state and the consumer owns the DATA. An
         * earlier version had the consumer own both and the table mirror the
         * intent back, which desynced the moment a toggle cleared a value: the
         * header stayed lit while the list was unfiltered.
         *
         * So: the table decides what the control says, then publishes the
         * resulting state. Events carry the new value, never a "please toggle".
         *
         *   columnFilters[key] = ['Paid', 'Partly paid']   list columns
         *   columnFilters[key] = { min: '10', max: '500' } numeric columns
         */
        columnFilters: {},

        /** Column key whose menu is open, or null. One at a time. */
        openMenu: null,
        columnSortKey: null,
        columnSortDir: 'asc',

        /** Display order, and which columns are pinned, both by column key. */
        order: [],
        pinned: new Set(),
        defaults: null,
        hidden: new Set(
            (config.columns ?? [])
                .filter((column) => column.tier === 2 && !['id', 'actions'].includes(column.key))
                .map((column) => column.key),
        ),

        init() {
            this.tableRoot = this.$el;
            this.hasActivation = this.tableRoot.querySelector('[data-slot="admin-table-row-activate"]') !== null;

            this.order = this.columns.map((column) => column.key);

            /*
             * The defaults are recorded before anything remembered is applied,
             * so Reset returns to what the product ships rather than to
             * whatever the operator last left behind.
             */
            this.defaults = {
                order: [...this.order],
                hidden: new Set(this.hidden),
                pinned: new Set(this.pinned),
            };

            // Last, because it needs the order it is going to rearrange.
            this.restoreColumns();

            // Visibility is applied from here, so it has to run once on boot
            // and again whenever the hidden set changes.
            this.$nextTick(() => this.applyVisibility());
            this.$watch('hidden', () => this.applyVisibility());
        },

        get optionalColumns() {
            return this.columns.filter((column) => !['id', 'actions'].includes(column.key));
        },

        /** Columns in their current display order. */
        get orderedColumns() {
            return this.order
                .map((key) => this.columns.find((column) => column.key === key))
                .filter(Boolean);
        },

        get visibleCount() {
            return this.columns.length - this.hidden.size;
        },

        get selectedCount() {
            return this.selected.size;
        },

        get someSelected() {
            return this.selected.size > 0;
        },

        get allSelected() {
            const keys = this.rowKeys();

            return keys.length > 0 && keys.every((key) => this.selected.has(key));
        },

        /**
         * Whether "select all matching" should be offered right now.
         *
         * Only once the page itself is fully selected — extending a partial
         * page selection to "everything" would silently drop the rows the
         * operator deliberately left unchecked — and only when there is
         * something to extend TO: a matched count larger than what is on
         * this page.
         */
        get canExtendSelection() {
            return (
                this.matchedCount !== null &&
                this.selectionScope === 'page' &&
                this.allSelected &&
                this.matchedCount > this.rowKeys().length
            );
        },

        rowKeys() {
            return visibleRowKeys(this);
        },

        isSelected(key) {
            return this.selected.has(key);
        },

        toggleRow(key) {
            toggleSelectedRow(this, key);
        },

        toggleAll(checked) {
            toggleAllVisibleRows(this, checked);
        },

        clear() {
            clearSelection(this);
        },

        publishSelection() {
            publishSelection(this);
        },

        selectEveryRow() {
            selectEveryVisibleRow(this);
        },

        recordRowPointerDown(event) {
            recordRowPointerDown(this, event);
        },

        activateRow(event, href) {
            activateRow(this, event, href);
        },

        /**
         * Widen the selection from "everything on this page" to "everything
         * the current filters match".
         *
         * This does not invent a list of ids — `selected` still only holds the
         * page's own keys, which is exactly why the bulk form (see the Blade)
         * stops submitting them once the scope is 'all' and sends the scope
         * plus the filter state instead. Rows selected one at a time revert
         * the scope (see `admin-table-selection.js`): only a full,
         * un-tampered page-then-extend path can honestly claim "every
         * matching record".
         */
        extendSelectionToAllMatching() {
            if (this.matchedCount === null) {
                return;
            }

            this.selectionScope = 'all';
            this.$dispatch('admin-table:selection-scope', { scope: 'all', matched: this.matchedCount });
        },

        /** Fall back from "every matching record" to just this page. */
        revertSelectionToPage() {
            this.selectionScope = 'page';
            this.$dispatch('admin-table:selection-scope', { scope: 'page', matched: this.selected.size });
        },

        /**
         * Remember how this table was set up.
         *
         * Column choice and order is work the operator did, and losing it on
         * every navigation teaches them not to bother. Stored per table rather
         * than per store: which columns you want is a property of how you work,
         * not of which shop you are looking at.
         */
        storageKey() {
            return `admin-table:${this.persistKey}`;
        },

        restoreColumns() {
            if (!this.persistKey) {
                return;
            }

            try {
                const stored = window.localStorage.getItem(this.storageKey());

                if (stored) {
                    this.applyColumnState(JSON.parse(stored));
                }
            } catch {
                // A blocked or full store is not worth failing the table over.
            }
        },

        rememberColumns() {
            if (!this.persistKey) {
                return;
            }

            try {
                window.localStorage.setItem(this.storageKey(), JSON.stringify(this.columnState()));
            } catch {
                // As above: the table works without remembering.
            }
        },

        /*
         * Column widths, set by dragging the edge of a header.
         *
         * Kept with the rest of the layout rather than in their own store, so
         * one Reset puts everything back and a saved view carries widths along
         * with the columns it chose.
         */
        widths: {},

        startResize(key, event) {
            // A second pointer (or a re-entrant call) must not stack listeners.
            this._stopResize?.();

            const th = event.target.closest('th');
            const startX = event.clientX;
            const startWidth = th.getBoundingClientRect().width;

            const move = (moveEvent) => {
                // A floor, or a column can be dragged to nothing and then
                // cannot be grabbed again.
                this.widths = {
                    ...this.widths,
                    [key]: Math.max(64, Math.round(startWidth + moveEvent.clientX - startX)),
                };
            };

            const stop = () => {
                window.removeEventListener('pointermove', move);
                window.removeEventListener('pointerup', stop);
                window.removeEventListener('pointercancel', stop);
                document.body.style.userSelect = '';
                this._stopResize = null;
                this.rememberColumns();
            };

            // Dragging over text selects it, which makes the whole table flash
            // blue while you resize.
            document.body.style.userSelect = 'none';
            window.addEventListener('pointermove', move);
            window.addEventListener('pointerup', stop);
            window.addEventListener('pointercancel', stop);

            // Kept on the instance so destroy() can end a drag the table
            // unmounted in the middle of (Livewire re-render, x-if flip,
            // navigation). Otherwise both window listeners keep the component
            // alive and `user-select: none` stays on <body> for the session.
            this._stopResize = stop;
        },

        destroy() {
            this._stopResize?.();
        },

        /**
         * The inline style of a header cell, as an object.
         *
         * Alpine replaces the whole style attribute when a style binding is a
         * string, which wiped the offset a pinned cell is rendered with, and a
         * sticky cell without an offset has nothing to stick to. An object
         * makes Alpine set the properties one at a time.
         */
        columnStyle(key) {
            const width = this.widthOf(key);

            return width ? { width, 'min-width': width } : {};
        },

        widthOf(key) {
            return this.widths[key] ? `${this.widths[key]}px` : null;
        },

        columnState() {
            return {
                order: [...this.order],
                visible: this.order.filter((key) => !this.hidden.has(key)),
                widths: { ...this.widths },
            };
        },

        /** Apply a column state a view carried. */
        applyColumnState(state) {
            if (!state || !Array.isArray(state.visible)) {
                return;
            }

            if (state.widths && typeof state.widths === 'object') {
                this.widths = { ...state.widths };
            }

            const known = new Set(this.columns.map((column) => column.key));

            if (Array.isArray(state.order) && state.order.length > 0) {
                const ordered = state.order.filter((key) => known.has(key));
                const rest = this.columns
                    .map((column) => column.key)
                    .filter((key) => !ordered.includes(key));

                this.order = [...ordered, ...rest];
            }

            /*
             * A view chooses its own columns: that is the point of having
             * views. Only the two a row cannot work without are forced, the
             * identity that opens it and the actions that operate on it.
             */
            const forced = this.columns
                .filter((column) => ['id', 'actions'].includes(column.key))
                .map((column) => column.key);

            this.hidden = new Set(
                this.order.filter(
                    (key) => !state.visible.includes(key) && !forced.includes(key),
                ),
            );
        },

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

        toggle(key) {
            const column = this.columns.find((c) => c.key === key);

            // Only the identity and the actions are fixed: a row without
            // them cannot be opened or operated on.
            if (!column || ['id', 'actions'].includes(key)) {
                return;
            }

            if (this.hidden.has(key)) {
                this.hidden.delete(key);
            } else {
                this.hidden.add(key);
            }

            this.hidden = new Set(this.hidden);
            this.rememberColumns();
        },

        /**
         * Values chosen for a list column.
         *
         * NOT `selected`: that is the row-selection Set, and shadowing it broke
         * every row checkbox on the page with `selected.has is not a function`.
         */
        selectedValues(key) {
            const value = this.columnFilters[key];

            return Array.isArray(value) ? value : [];
        },

        isChecked(key, value) {
            return this.selectedValues(key).includes(value);
        },

        /** Multi-select: a column can be filtered to several values at once. */
        toggleValue(key, value) {
            const current = this.selectedValues(key);
            const next = current.includes(value)
                ? current.filter((v) => v !== value)
                : [...current, value];

            this.columnFilters = {
                ...this.columnFilters,
                [key]: next.length ? next : undefined,
            };

            this.publishFilter(key);
        },

        /**
         * The range filter for a numeric column.
         *
         * NOT `range`: Alpine scopes nest, so anything this component defines
         * shadows the same name in the consumer's scope. A page with its own
         * `range` string rendered "[object Object]" the moment this method
         * existed. Component members carry a specific name for that reason.
         */
        rangeFilter(key) {
            const value = this.columnFilters[key];

            return value && !Array.isArray(value) ? value : { min: '', max: '' };
        },

        /**
         * Read both bounds off the inputs and publish them together.
         *
         * Each input debounces on its own timer, so filling one and then the
         * other quickly interleaves: the first timer fires, the list
         * re-renders the menu, and the keystroke still waiting in the second
         * is lost. Taking both values from the DOM at publish time means the
         * filter always matches what the two boxes show.
         */
        /**
         * Every value this column actually holds, plus the vocabulary it
         * declares.
         *
         * A declared list can fall behind the data, and then a value sitting in
         * front of the operator is missing from the menu they would use to
         * filter for it. Reading the rows keeps the two in step, and the
         * declared list still supplies the states that happen to have no rows
         * right now so the options do not flicker as the list narrows.
         */
        filterOptions(key, declared = []) {
            const seen = new Set(declared);

            for (const row of this.tableRoot?.querySelectorAll('[data-filters]') ?? []) {
                let values = {};

                try {
                    values = JSON.parse(row.dataset.filters ?? '{}');
                } catch {
                    continue;
                }

                const value = values[key];

                if (Array.isArray(value)) {
                    value.filter(Boolean).forEach((entry) => seen.add(entry));
                } else if (value !== null && value !== undefined && value !== '') {
                    seen.add(value);
                }
            }

            return [...seen].sort((a, b) =>
                String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }),
            );
        },

        /** The contains-text a free-text column is filtered by. */
        textFilter(key) {
            const value = this.columnFilters[key];

            return value && !Array.isArray(value) && typeof value.contains === 'string'
                ? value.contains
                : '';
        },

        setText(key, value) {
            const text = value.trim();

            this.columnFilters = {
                ...this.columnFilters,
                [key]: text === '' ? undefined : { contains: text },
            };

            this.publishFilter(key);
        },

        setRangeFrom(key, input) {
            const pair = input.closest('[data-range-pair]') ?? input.parentElement;
            const read = (bound) =>
                pair?.querySelector(`[data-range-bound="${bound}"]`)?.value ?? '';

            this.setRange(key, { min: read('min'), max: read('max') });
        },

        setRange(key, bound, value) {
            const next =
                typeof bound === 'object' && bound !== null
                    ? { ...this.rangeFilter(key), ...bound }
                    : { ...this.rangeFilter(key), [bound]: value };
            const empty = next.min === '' && next.max === '';

            this.columnFilters = {
                ...this.columnFilters,
                [key]: empty ? undefined : next,
            };

            this.publishFilter(key);
        },

        hasFilter(key) {
            const value = this.columnFilters[key];

            if (Array.isArray(value)) {
                return value.length > 0;
            }

            return Boolean(
                value && (value.min !== '' || value.max !== '' || (value.contains ?? '') !== ''),
            );
        },

        clearFilter(key) {
            this.columnFilters = { ...this.columnFilters, [key]: undefined };
            this.publishFilter(key);
        },

        /** Publish the resulting state, not an instruction to toggle. */
        publishFilter(key) {
            this.$dispatch('admin-table:filter', {
                key,
                value: this.columnFilters[key] ?? null,
            });
        },

        sortColumn(key, dir) {
            this.columnSortKey = key;
            this.columnSortDir = dir;
            this.$dispatch('admin-table:sort', { key, dir });
        },

        /*
         * Mirror the sort the page applied.
         *
         * The page can change the sort without anyone touching a header, from
         * a saved view or a shared URL. Without this the header would keep
         * marking whichever column was last clicked here, which is a quietly
         * wrong answer to "what is this list sorted by".
         */
        syncSort(key, dir) {
            this.columnSortKey = key ?? null;
            this.columnSortDir = dir === 'desc' ? 'desc' : 'asc';
        },

        isSorted(key, dir) {
            return this.columnSortKey === key && this.columnSortDir === dir;
        },

        isPinned(key) {
            return this.pinned.has(key);
        },

        /**
         * Pin a column so it stays in view while the table scrolls sideways.
         *
         * Pinning also moves the column to the front: a pinned column that sat
         * in the middle would have to jump there anyway, and doing it silently
         * is less confusing than leaving a gap where it used to be.
         */
        togglePinned(key) {
            if (this.pinned.has(key)) {
                this.pinned.delete(key);
            } else {
                this.pinned.add(key);
                this.order = [key, ...this.order.filter((k) => k !== key)];
                this.rememberColumns();
            }

            this.pinned = new Set(this.pinned);
            this.applyColumns();
        },

        moveColumn(key, delta) {
            const from = this.order.indexOf(key);
            const to = from + delta;

            if (from === -1 || to < 0 || to >= this.order.length) {
                return;
            }

            const next = [...this.order];
            next.splice(to, 0, next.splice(from, 1)[0]);
            this.order = next;
            this.rememberColumns();
            this.applyColumns();
        },

        /** Every column at once, for the times you want the whole picture. */
        showAllColumns() {
            this.hidden = new Set();
            this.applyVisibility();
            this.rememberColumns();
        },

        resetColumns() {
            if (!this.defaults) {
                return;
            }

            this.order = [...this.defaults.order];
            this.hidden = new Set(this.defaults.hidden);
            this.pinned = new Set(this.defaults.pinned);
            this.widths = {};
            this.applyColumns();

            // Reset means reset: forget what was remembered too, or the next
            // page load quietly brings the old layout back.
            if (this.persistKey) {
                try {
                    window.localStorage.removeItem(this.storageKey());
                } catch {
                    // Nothing to clean up if the store is unavailable.
                }
            }
        },

        /**
         * Reorder the DOM to match `order`.
         *
         * Cells are matched by `data-column`, so the consumer's markup needs no
         * knowledge of ordering: it writes the cells once in any order and this
         * arranges them. The header row and every body row are moved together
         * or the columns would stop lining up.
         */
        applyColumns() {
            const rows = [
                ...this.tableRoot.querySelectorAll('thead tr'),
                ...this.tableRoot.querySelectorAll('[data-slot="admin-table-row"]'),
            ];

            rows.forEach((row) => {
                this.order.forEach((key) => {
                    const cell = row.querySelector(`[data-column="${key}"]`);

                    if (cell) {
                        row.appendChild(cell);
                    }
                });

                /*
                 * Row activation (see row.blade.php) renders its own trailing,
                 * end-pinned cell outside the consumer's declared columns.
                 * `pinnedEnd` sticks a cell only while it is the row's last
                 * child, so it has to be re-appended after every reorder or a
                 * moved column would end up after it instead.
                 */
                row.querySelectorAll('[data-slot="admin-table-row-activate"]').forEach((cell) => {
                    row.appendChild(cell);
                });
            });

            this.applyVisibility();
        },

        /**
         * Show and hide columns from here, rather than from a binding on each
         * cell.
         *
         * Reordering appends each cell, which is a remove and an insert, so
         * Alpine tears that element's bindings down and builds them again. With
         * an `x-show` per column the rebuilt cells came back visible, and Reset
         * restored the right hidden set while still showing all thirty-six
         * columns. One owner, applied after every change, cannot get out of
         * step with itself.
         */
        applyVisibility() {
            const cells = this.tableRoot?.querySelectorAll('[data-column]') ?? [];

            cells.forEach((cell) => {
                cell.style.display = this.hidden.has(cell.dataset.column) ? 'none' : '';
            });
        },
    }));
});
resources/js/ui/admin-table-selection.js JS
/** Selection is keyed by stable record IDs and operates on visible rows only. */
export function visibleRowKeys(state) {
    return Array.from(
        state.tableRoot.querySelectorAll('[data-slot="admin-table-row"][data-key]'),
    )
        .filter((row) => !row.hidden)
        .map((row) => row.getAttribute('data-key'));
}

export function toggleSelectedRow(state, key) {
    if (state.selected.has(key)) {
        state.selected.delete(key);
    } else {
        state.selected.add(key);
    }
    state.selected = new Set(state.selected);

    /*
     * Hand-picking a row no longer means "every matching record": the
     * operator just excluded or re-added one by hand, so the scope can no
     * longer honestly claim to be everything the filters match.
     */
    if (state.selectionScope === 'all') {
        state.selectionScope = 'page';
    }

    publishSelection(state);
}

export function toggleAllVisibleRows(state, checked) {
    state.selected = checked ? new Set(visibleRowKeys(state)) : new Set();

    // Unchecking "select all" is leaving the extended scope, not narrowing it.
    if (!checked) {
        state.selectionScope = 'page';
    }

    publishSelection(state);
}

export function clearSelection(state) {
    state.selected = new Set();
    state.selectionScope = 'page';
    publishSelection(state);
}

export function selectEveryVisibleRow(state) {
    state.selected = new Set(visibleRowKeys(state));
    publishSelection(state);
}

export function publishSelection(state) {
    state.$dispatch('admin-table:selection', {
        count: state.selected.size,
        scope: state.selectionScope ?? 'page',
    });
}
resources/js/ui/admin-table-activation.js JS
/**
 * Row activation: a click anywhere in an opted-in row opens the record it
 * represents, without hijacking a click on the row's own controls or a
 * text-selection drag that happens to end over the row.
 *
 * The keyboard and screen-reader path never touches this file: the row
 * renders a real, focusable `<a>` (see row.blade.php) that a keyboard user
 * tabs to and activates with Enter using the browser's native link behavior.
 * This module only extends the SAME destination to a mouse click landing
 * anywhere else in the row — something a bare click handler could never
 * offer assistive technology on its own.
 */

/** How far the pointer may move between mousedown and click before it counts as a drag, not a click. */
const DRAG_THRESHOLD_PX = 6;

/**
 * Elements a click must not activate the row through: the row's own
 * controls. A click that lands here is the operator working the control, not
 * asking to open the record — the exact conflict a consumer once had to
 * patch `stopPropagation()` around before the component owned this at all.
 */
const INTERACTIVE_SELECTOR = [
    'a',
    'button',
    'input',
    'select',
    'textarea',
    'label',
    '[role="button"]',
    '[role="menuitem"]',
    '[role="menuitemcheckbox"]',
    '[role="menuitemradio"]',
    '[role="checkbox"]',
    '[role="switch"]',
    '[contenteditable="true"]',
    '[tabindex]:not([tabindex="-1"])',
].join(', ');

/** Remember where the pointer went down, so the eventual click can tell a drag from a click. */
export function recordRowPointerDown(state, event) {
    state.rowPointerDown = { x: event.clientX, y: event.clientY };
}

/**
 * Open the row's record, unless the click does not actually mean that.
 *
 * Three reasons a `click` on an activatable row is not an activation:
 * it targeted one of the row's own controls, it is the tail end of a pointer
 * drag (measured from the `mousedown` this pairs with, since a `click` still
 * fires after a drag once the pointer settles), or it left behind a live text
 * selection (a same-spot double-click that selected a word moves the pointer
 * nowhere, so the distance check alone would miss it).
 */
export function activateRow(state, event, href) {
    if (!href) {
        return;
    }

    if (event.target.closest(INTERACTIVE_SELECTOR)) {
        return;
    }

    const origin = state.rowPointerDown;
    state.rowPointerDown = null;

    if (origin && Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > DRAG_THRESHOLD_PX) {
        return;
    }

    const selection = window.getSelection?.();
    if (selection && !selection.isCollapsed && selection.toString() !== '') {
        return;
    }

    if (event.ctrlKey || event.metaKey) {
        window.open(href, '_blank', 'noopener');

        return;
    }

    window.location.href = href;
}

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
2.13.3
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