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.
Preview
Columns
Show, reorder and pin columns
|
Sort Contains |
Sort Filter |
Sort Filter |
Sort Contains |
Sort Range
to
|
Sort Between
to
|
Sort Between
to
|
Open | |
|---|---|---|---|---|---|---|---|---|
| Avery Stone [email protected] | Scale | Jane Doe | $2,480 | 2 hours ago | 2024-02-14 | |||
| Morgan Lee [email protected] | Starter | Alex Brown | $0 | Yesterday | 2026-09-01 | |||
| Riley Chen [email protected] | Growth | Jane Doe | $1,120 | 3 days ago | 2025-06-20 | |||
| Sam Patel [email protected] | Growth | Liam Smith | $1,120 | 2 weeks ago | 2024-10-09 | |||
| Noor Haddad [email protected] | Scale | Alex Brown | $3,900 | Today | 2023-12-01 | |||
| Emma Wilson [email protected] | Starter | Liam Smith | $0 | 2 months ago | 2025-03-15 |
{{--
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>
Installation
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:
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.
-
resources/views/components/ui/admin-table.blade.php -
resources/views/components/ui/admin-table/row.blade.php -
resources/views/components/ui/admin-table/cell.blade.php -
resources/views/components/ui/admin-table/empty.blade.php -
resources/js/ui/admin-table.js -
resources/js/ui/admin-table-selection.js -
resources/js/ui/admin-table-activation.js
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.
# 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
{{--
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
{{--
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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<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.
<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.
{{--
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>
{{--
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>
{{--
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
{{--
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>
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' : '';
});
},
}));
});
/** 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',
});
}
/**
* 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