<x-widget.table>
Table
Data table for arrays or paginators: sortable columns (one or several at once), a Columns menu to show and hide columns, a totals row, a filters toolbar that filters in place with option counts (dimmed rows or skeleton while loading, optional in-memory cache), row selection with bulk actions, clickable and expandable rows, status badges, a row-actions menu, compact density, sticky header, stacked cards on phones, loading placeholders, drawer/numbers/jump/infinite pagination and an empty state.
php artisan larawell:add table
Usage
In a controller
Pass a paginator (or any collection) to the view. The table renders its own pagination links.
public function index(): View
{
return view('transactions.index', [
'transactions' => Transaction::query()->latest()->paginate(5),
]);
}
// For pagination="infinite", a cursor paginator keeps every page equally fast, however deep.
Transaction::query()->latest('id')->cursorPaginate(10, cursorName: 'feed_cursor');
// Sortable columns: only ever pass orderBy() a column from your own list, never the raw query string.
$sort = in_array($request->query('sort'), ['reference', 'date', 'amount'], true) ? $request->query('sort') : 'date';
$direction = $request->query('direction') === 'desc' ? 'desc' : 'asc';
Payment::query()->orderBy($sort, $direction)->paginate(10)->withQueryString();
// Filters (the filters slot): each field arrives as a query parameter. Check every one against your own list
// before it reaches the query, bind values (never interpolate them into whereRaw), and keep the query string so
// page and sort links carry the filters.
$status = in_array($request->query('status'), ['paid', 'pending', 'refunded', 'failed'], true) ? $request->query('status') : null;
$search = mb_substr(trim((string) $request->query('q')), 0, 100);
Order::query()
->when($search !== '', fn ($query) => $query->where('number', 'like', '%'.addcslashes($search, '%_\\').'%'))
->when($status, fn ($query) => $query->where('status', $status))
->latest()
->paginate(10)
->withQueryString();
// multi-sort: sort and direction arrive as comma lists in priority order (sort=status,amount&direction=asc,desc).
// Keep only keys from your list, each once.
$directions = explode(',', (string) $request->query('direction'));
$query = Order::query();
foreach (array_unique(explode(',', (string) $request->query('sort'))) as $i => $key) {
if (in_array($key, ['status', 'date', 'amount'], true)) {
$query->orderBy($key, ($directions[$i] ?? '') === 'desc' ? 'desc' : 'asc');
}
}
// Option counts (faceted filters): each option's meta is how many rows picking it would give, counting every
// other filter but its own. The table updates them in the toolbar after each change.
$statusCounts = Order::query()->when($search !== '', fn ($query) => $query->where('number', 'like', '%'.addcslashes($search, '%_\\').'%'))
->selectRaw('status, count(*) as total')->groupBy('status')->pluck('total', 'status');
$statusOptions = collect(['paid' => 'Paid', 'pending' => 'Pending'])
->map(fn (string $label, string $value): array => ['value' => $value, 'label' => $label, 'meta' => (string) ($statusCounts[$value] ?? 0)])
->values();
// Bulk actions: the ticked rows arrive as selected[]. Authorize and scope them like any other input.
$invoices = $request->user()->invoices()->whereKey($request->validated('selected'))->get();
Livewire component
In a Livewire component, page, sort and rows-per-page links update the component in place. Use WithPagination, and name the sort properties after the table's sort-name and direction-name (sort and direction by default). Every public property can be set from the browser, so check each one before it reaches a query.
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class Invoices extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public ?string $sort = null;
#[Url]
public string $direction = 'asc';
// per-page-model="perPage"
public int $perPage = 10;
// select-model="selected": the ticked ids, kept across pages. The table counts from it, so the bulk bar
// always says what deleteSelected() will get.
public array $selected = [];
public function updatedSearch(): void
{
$this->resetPage();
}
// For a selection per page, clear it when the page changes.
// public function updatedPaginators(): void { $this->selected = []; }
public function deleteSelected(): void
{
$invoices = auth()->user()->invoices()->whereKey($this->selected)->get();
$invoices->each(fn (Invoice $invoice) => $this->authorize('delete', $invoice));
$invoices->each->delete();
$this->selected = [];
}
public function render()
{
$sort = in_array($this->sort, ['number', 'date', 'amount'], true) ? $this->sort : 'date';
$perPage = in_array($this->perPage, [10, 25, 50, 100], true) ? $this->perPage : 10;
return view('livewire.invoices', [
'invoices' => auth()->user()->invoices()
->when($this->search, fn ($query) => $query->where('number', 'like', "%{$this->search}%"))
->orderBy($sort, $this->direction === 'desc' ? 'desc' : 'asc')
->paginate($perPage),
]);
}
}
Livewire view
The view for that component. Pass :sort and :direction from the component. Fields in the filters slot are yours to bind with wire:model; Livewire filters in place, and the empty state's Clear filters resets every bound field. Give each row a wire:key, so open rows and ticks stay with their record when the component re-renders.
<div>
<x-widget.table
id="invoices"
caption="Invoices"
:rows="$invoices"
:columns="[
['label' => 'Number', 'key' => 'number', 'sortable' => true],
['label' => 'Date', 'key' => 'date', 'sortable' => true],
['label' => 'Amount', 'key' => 'amount', 'sortable' => true, 'align' => 'end'],
]"
:sort="$sort"
:direction="$direction"
selectable
select-model="selected"
pagination="footer"
per-page-model="perPage"
>
<x-slot:filters>
<x-widget.search name="search" wire:model.live.debounce.300ms="search" placeholder="Search invoices" />
</x-slot:filters>
<x-slot:bulk>
<x-widget.button type="button" variant="danger" size="sm" wire:click="deleteSelected" wire:confirm="Delete the selected invoices?">Delete</x-widget.button>
</x-slot:bulk>
@foreach ($invoices as $invoice)
<x-widget.table.row wire:key="invoice-{{ $invoice->id }}" :value="$invoice->id" :select-label="'Select '.$invoice->number">
<td>{{ $invoice->number }}</td>
<td>{{ $invoice->date->toFormattedDateString() }}</td>
<td>{{ $invoice->amount }}</td>
</x-widget.table.row>
@endforeach
</x-widget.table>
</div>
Examples
Filters
Put the fields in the filters slot: the table filters as you type (after a short pause) or pick, in place, back on page 1 and keeping the sort; the URL follows, so a reload or a shared link shows the same rows. Each option's count (its meta) says how many orders picking it would show, and updates as you filter. Shift+click a header to sort by several columns (multi-sort). Columns marked hideable get a Columns menu. The footer slot holds a totals row. while-loading="skeleton" shows placeholder rows during slower loads, and cache-for="60" makes going back to a filter seen in the last minute instant. Without JS the fields are an ordinary GET form. Your controller must check every filter and sort key against its own list, as Usage shows.
Pass
$orders,
$filters,
$statuses,
$payments,
$periods and
$totals
from your controller.
Show code Hide code
<x-widget.table
id="orders"
caption="Orders"
:rows="$orders"
pagination="numbers"
multi-sort
sort-name="order_sort"
direction-name="order_dir"
while-loading="skeleton"
cache-for="60"
empty="No orders match these filters"
:columns="[
['label' => 'Order', 'key' => 'number', 'sortable' => true],
['label' => 'Customer', 'hideable' => true],
['label' => 'Status', 'key' => 'status', 'sortable' => true, 'hideable' => true],
['label' => 'Payment', 'hideable' => true],
['label' => 'Date', 'key' => 'date', 'sortable' => true, 'hideable' => true],
['label' => 'Amount', 'key' => 'amount', 'sortable' => true, 'align' => 'end'],
]"
>
<x-slot:filters>
<x-widget.search name="order_q" placeholder="Search orders or customers" />
<x-widget.select name="order_status" label="Status" inner-label clearable placeholder="Any" :options="$statuses" :value="$filters['status']" class="sm:min-w-0 sm:flex-[1_1_13rem]" />
<x-widget.select name="order_payment" label="Payment" inner-label clearable placeholder="Any" :options="$payments" :value="$filters['payment']" class="sm:min-w-0 sm:flex-[1_1_13rem]" />
<x-widget.select name="order_period" label="Date" inner-label clearable placeholder="Any" :options="$periods" :value="$filters['period']" class="sm:min-w-0 sm:flex-[1_1_13rem]" />
</x-slot:filters>
@foreach ($orders as $order)
<x-widget.table.row>
<td>{{ $order['number'] }}</td>
<td>{{ $order['customer'] }}</td>
<td><x-widget.table.badge :tone="['paid' => 'success', 'pending' => 'warning', 'refunded' => 'info', 'failed' => 'error'][$order['status']]">{{ $order['status_label'] }}</x-widget.table.badge></td>
<td>{{ $order['payment_label'] }}</td>
<td>{{ $order['date_label'] }}</td>
<td>{{ $order['amount'] }}</td>
</x-widget.table.row>
@endforeach
<x-slot:footer>
<tr>
<td>{{ $totals['count'] }} {{ $totals['count'] === 1 ? 'order' : 'orders' }}</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td>{{ $totals['amount'] }}</td>
</tr>
</x-slot:footer>
</x-widget.table>
Clickable rows
Rows with href act as links (keyboard included) without nesting a link in every cell. Amounts sit at the end of their column so the digits line up; statuses are badges. $transactions is a paginator from your controller; see Usage.
Pass
$transactions
from your controller.
Show code Hide code
<x-widget.table caption="Recent transactions" :columns="['Reference', ['label' => 'Amount', 'align' => 'end'], 'Status']" :rows="$transactions" pagination="numbers">
@foreach ($transactions as $transaction)
<x-widget.table.row :href="route('transactions.show', $transaction)">
<td>{{ $transaction->reference }}</td>
<td>{{ $transaction->amount }}</td>
<td><x-widget.table.badge :tone="$transaction->status === 'Failed' ? 'error' : 'success'">{{ $transaction->status }}</x-widget.table.badge></td>
</x-widget.table.row>
@endforeach
</x-widget.table>
Sortable
Mark columns sortable with a key; clicking a header reloads with ?sort=amount&direction=desc (in place, with JS) and back to page 1. The table only draws the order: your controller must check sort against its own list of columns before it reaches orderBy(), as Usage shows.
Pass
$payments
from your controller.
Show code Hide code
<x-widget.table caption="Payments" :rows="$payments" pagination="numbers" :columns="[
['label' => 'Reference', 'key' => 'reference', 'sortable' => true],
'Customer',
['label' => 'Date', 'key' => 'date', 'sortable' => true],
['label' => 'Amount', 'key' => 'amount', 'sortable' => true, 'align' => 'end'],
]">
@foreach ($payments as $payment)
<x-widget.table.row>
<td>{{ $payment['reference'] }}</td>
<td>{{ $payment['customer'] }}</td>
<td>{{ $payment['date'] }}</td>
<td>{{ $payment['amount'] }}</td>
</x-widget.table.row>
@endforeach
</x-widget.table>
Selectable
A checkbox per row (give each row a :value) and a select-all box. Ticking any shows the bar with your bulk slot's buttons; the ticked values submit as selected[] with the form the table builds (POST with CSRF by default, to bulk-action).
Pass
$invoices
from your controller.
Show code Hide code
<x-widget.table id="invoices" caption="Invoices" selectable :bulk-action="url()->current()" bulk-method="GET" :columns="['Invoice', 'Client', ['label' => 'Amount', 'align' => 'end'], 'Status']" :rows="$invoices">
<x-slot:bulk>
<x-widget.button type="submit" name="action" value="export" size="sm" variant="neutral" :submit-guard="false">Export</x-widget.button>
<x-widget.button type="submit" name="action" value="remind" size="sm" :submit-guard="false">Send reminder</x-widget.button>
</x-slot:bulk>
@foreach ($invoices as $invoice)
<x-widget.table.row :value="$invoice['number']" :select-label="'Select '.$invoice['number']">
<td>{{ $invoice['number'] }}</td>
<td>{{ $invoice['client'] }}</td>
<td>{{ $invoice['amount'] }}</td>
<td><x-widget.table.badge :tone="$invoice['overdue'] ? 'warning' : 'neutral'">{{ $invoice['overdue'] ? 'Overdue' : 'Sent' }}</x-widget.table.badge></td>
</x-widget.table.row>
@endforeach
</x-widget.table>
Expandable rows
A details slot makes the row expandable. A chevron on the right shows which rows open, and turns when they do.
Pass
$payouts
from your controller.
Show code Hide code
<x-widget.table caption="Payouts" :columns="['Reference', ['label' => 'Amount', 'align' => 'end'], 'Status']" :rows="$payouts">
@foreach ($payouts as $payout)
<x-widget.table.row>
<td>{{ $payout->reference }}</td>
<td>{{ $payout->amount }}</td>
<td>{{ $payout->status }}</td>
<x-slot:details>
<p class="text-foreground/75 text-sm">Sent to the account ending {{ $payout->account }} on {{ $payout->sent_on }}.</p>
</x-slot:details>
</x-widget.table.row>
@endforeach
</x-widget.table>
Stacked
stack turns each row into a card of label and value lines below the sm breakpoint, instead of a table you scroll sideways. Try it on a narrow screen.
Pass
$orders
from your controller.
Show code Hide code
<x-widget.table caption="Orders" stack :columns="['Order', 'Customer', 'Placed', 'Status', ['label' => 'Total', 'align' => 'end']]" :rows="$orders">
@foreach ($orders as $order)
<x-widget.table.row>
<td class="font-medium">{{ $order['number'] }}</td>
<td>{{ $order['customer'] }}</td>
<td>{{ $order['placed'] }}</td>
<td><x-widget.table.badge :tone="$order['tone']">{{ $order['status'] }}</x-widget.table.badge></td>
<td>{{ $order['total'] }}</td>
</x-widget.table.row>
@endforeach
</x-widget.table>
Compact
density="compact" fits more rows; striped helps the eye along them; max-height keeps the header in view while the body scrolls. Badges carry each status in words, with colour only to back them up. The last column is a menu of actions per row: its heading is for screen readers only (hideLabel), and size="sm" keeps the button inside a compact row.
Pass
$events
from your controller.
Show code Hide code
<x-widget.table caption="Audit log" density="compact" striped max-height="20rem" :columns="['Time', 'User', 'Action', 'Result', ['label' => 'Actions', 'hideLabel' => true, 'align' => 'end']]" :rows="$events">
@foreach ($events as $event)
<x-widget.table.row>
<td>{{ $event['time'] }}</td>
<td>{{ $event['user'] }}</td>
<td>{{ $event['action'] }}</td>
<td><x-widget.table.badge :tone="$event['tone']">{{ $event['result'] }}</x-widget.table.badge></td>
<td class="w-12 text-end">
<x-widget.dropdown :label="'Actions for '.$event['action'].' by '.$event['user'].' at '.$event['time']" align="end" size="sm">
<x-widget.dropdown.item icon="eye">View details</x-widget.dropdown.item>
<x-widget.dropdown.item icon="user">View {{ $event['user'] }}</x-widget.dropdown.item>
<x-widget.dropdown.divider />
<x-widget.dropdown.item icon="shield-check">Mark as reviewed</x-widget.dropdown.item>
</x-widget.dropdown>
</td>
</x-widget.table.row>
@endforeach
</x-widget.table>
Loading
loading draws placeholder rows while data is on its way, e.g. during a Livewire or fetch request, and tells screen readers it's loading.
Show code Hide code
<x-widget.table caption="Customers" loading :skeleton-rows="4" :columns="['Name', 'Email', 'Plan', ['label' => 'Spend', 'align' => 'end']]" />
Drawer pagination
The default pagination. Tapping the current page opens a sheet listing every page, which suits phones.
Pass
$orders
from your controller.
Show code Hide code
<x-widget.table caption="Orders" :columns="['Order', 'Customer', ['label' => 'Total', 'align' => 'end']]" :rows="$orders">
@foreach ($orders as $order)
<x-widget.table.row>
<td>{{ $order->number }}</td>
<td>{{ $order->customer }}</td>
<td>{{ $order->total }}</td>
</x-widget.table.row>
@endforeach
</x-widget.table>
Jump pagination
Jump straight to a page, for long histories.
Pass
$logins
from your controller.
Show code Hide code
<x-widget.table caption="Login history" :columns="['When', 'Device', 'Location']" :rows="$logins" pagination="jump">
@foreach ($logins as $login)
<x-widget.table.row>
<td>{{ $login->when }}</td>
<td>{{ $login->device }}</td>
<td>{{ $login->location }}</td>
</x-widget.table.row>
@endforeach
</x-widget.table>
Infinite scroll
The table scrolls inside itself and loads rows at either end as you go, dropping far-off ones, so the page stays light at any size. Pair it with cursorPaginate(), which costs the same on the millionth row as the first.
Pass
$transactions
from your controller.
Show code Hide code
<x-widget.table caption="All transactions" :columns="['Reference', 'Date', ['label' => 'Amount', 'align' => 'end']]" :rows="$transactions" pagination="infinite">
@foreach ($transactions as $transaction)
<x-widget.table.row>
<td>{{ $transaction->reference }}</td>
<td>{{ $transaction->date }}</td>
<td>{{ $transaction->amount }}</td>
</x-widget.table.row>
@endforeach
</x-widget.table>
Empty
Show code Hide code
<x-widget.table :columns="['Reference', 'Date', 'Amount']" :rows="collect()" empty="No transactions yet" />
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.table>
| Prop | Default | Description |
|---|---|---|
| id |
null
|
Needed with selectable, filters or hideable columns (it names their forms and menu); a paginated table gets one from its page name. Page links end in #id, so a new page opens scrolled to the table. |
| columns |
[]
|
Labels, or arrays: ['label' => 'Amount', 'key' => 'amount', 'align' => 'end', 'sortable' => true, 'hideable' => true]. hideLabel => true keeps the heading for screen readers only, for a column whose cells say what they are (row actions). align is start (default), center or end; put numbers at the end so their digits line up. hideable lists the column in the Columns menu (add 'hidden' => true to start it hidden); up to 12 columns. |
| rows |
null
|
A paginator (paginate(), simplePaginate() or cursorPaginate()) or any array or collection; the slot draws the rows. |
| striped |
false
|
Tints every other row. |
| align-left |
false
|
Kept so older markup still renders; start alignment is now the default for every column. |
| header |
true
|
false leaves out the header row. |
| pagination |
'drawer'
|
With a paginator: drawer, numbers, segmented, jump, footer (rows per page), summary or infinite (scrolls inside itself, loading more as you go); false for none. |
| fill |
true
|
Pads a short last page with blank rows, so the pagination bar stays where it was. |
| empty |
'No data found'
|
What the empty state says. With filters, it also offers Clear filters. |
| caption |
null
|
The table's name for screen readers (a hidden <caption>); also names its scroll area and filters. |
| visible-rows |
10
|
pagination="infinite": rows in view at once (1 to 50) before the table scrolls inside itself. |
| density |
'comfortable'
|
comfortable (56px rows) or compact (44px), for dense admin screens. |
| max-height |
null
|
e.g. "24rem": the body scrolls inside the table and the header stays in view. |
| loading |
false
|
Placeholder rows while data is on its way (e.g. while a Livewire or fetch request runs). |
| skeleton-rows |
5
|
How many placeholder rows loading draws (and while-loading="skeleton", without a paginator). |
| while-loading |
'dim'
|
While a page, sort or filter change loads: dim (the rows stay, faded, with a spinner) or skeleton (placeholder rows). |
| cache-for |
null
|
Seconds to keep fetched pages in memory: going back to a page, sort or filter seen within that time is instant. In memory only, never written to storage, and gone on reload. |
| sort |
null
|
The sorted column's key. Defaults to the query string, but only for keys marked sortable. With multi-sort, a comma list in order of priority, e.g. "status,amount". Clicking a header cycles ascending, descending, then no sort. |
| direction |
null
|
asc or desc (a comma list with multi-sort, one per key); defaults to the query string. |
| multi-sort |
false
|
Lets people sort by several columns: Shift+click (or Shift+Enter) a header to add it; a plain click sorts by it alone. |
| sort-name |
'sort'
|
The query parameters the sort links set; change them when two sortable tables share a page. In a Livewire component, the properties they set. |
| direction-name |
'direction'
|
The same, for the direction parameter. |
| selectable |
false
|
A checkbox per row (give rows a :value) and a bar for the bulk slot's buttons. Needs an id. |
| select-name |
'selected'
|
What the row checkboxes submit as: selected[]. |
| select-model |
null
|
Livewire: binds the row checkboxes to this property (wire:model), e.g. "selected", so actions get the ids. |
| per-page-model |
null
|
Livewire, pagination="footer": binds rows per page to this property, e.g. "perPage", and goes back to page 1. |
| bulk-action |
null
|
Where the bulk form submits the ticked rows. |
| bulk-method |
'POST'
|
POST, GET, PUT, PATCH or DELETE. |
| stack |
false
|
Opt-in: below the sm breakpoint, each row becomes a card of label / value pairs instead of scrolling sideways. |
Slots
- <x-slot:filters>
- Fields for the toolbar above the table (search, selects). It filters as you type or pick, in place, back on page 1; your controller checks every value.
- <x-slot:bulk>
- Buttons for the bar that appears when rows are ticked (selectable); they submit the ticked rows with the bulk form.
- <x-slot:footer>
- Rows under the data, e.g. <tr><td>Total</td>…</tr>, aligned with the columns; hidden while empty or loading.
<x-widget.table.badge>
| Prop | Default | Description |
|---|---|---|
| tone |
'neutral'
|
neutral, success, warning, error or info. |
| dot |
true
|
<x-widget.table.row>
| Prop | Default | Description |
|---|---|---|
| href |
null
|
|
| value |
null
|
With a selectable table: the value this row's checkbox submits, e.g. its id. |
| select-label |
'Select row'
|
Screen reader name for the checkbox, e.g. "Select TX-00042". |
| link-label |
null
|
With href: the name of the row's link, e.g. "TX-00042". Defaults to the first cell's text. |
Slots
- <x-slot:details>
- Content shown when the row expands (click, Enter or Space); the row gets a chevron.
Source
What larawell:add table writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from
the components it also adds , and the theme and base CSS.
resources/views/components/widget/table/badge.blade.php Show
@props([
// neutral, success, warning, error or info.
'tone' => 'neutral',
'dot' => true,
])
@php
// Text is the tone mixed with the main text colour, so it stays readable (AA) on its own light tint.
// Warning's yellow is too light for text at all, so its text stays the main colour and only the dot is yellow.
$tones = [
'neutral' => ['bg-field text-foreground/75', 'bg-muted'],
'success' => ['bg-success/10 text-[color-mix(in_oklab,var(--color-success)_65%,var(--color-foreground))]', 'bg-success'],
'warning' => ['bg-warning/20 text-foreground', 'bg-[color-mix(in_oklab,var(--color-warning)_80%,var(--color-foreground))]'],
'error' => ['bg-error/10 text-[color-mix(in_oklab,var(--color-error)_75%,var(--color-foreground))]', 'bg-error'],
'info' => ['bg-link/10 text-[color-mix(in_oklab,var(--color-link)_75%,var(--color-foreground))]', 'bg-link'],
];
// A typo fails loudly, naming the values that work, instead of quietly rendering something else.
if (! array_key_exists($tone, $tones)) {
throw new \InvalidArgumentException("Unknown tone [{$tone}] for <x-widget.table.badge>. Use one of: ".implode(', ', array_keys($tones)).'.');
}
[$look, $dotColour] = $tones[$tone];
@endphp
{{-- A status in a cell: "Completed", "Failed". The words carry the meaning; colour only backs them up. --}}
<span {{ $attributes->class(['inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap', $look]) }}>
@if ($dot)
<span aria-hidden="true" class="{{ $dotColour }} size-1.5 rounded-full"></span>
@endif
{{ $slot }}
</span><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/views/components/widget/table/index.blade.php Show
@props([
// Needed with selectable, filters or hideable columns (it names their forms and menu); a paginated table gets one from
// its page name. Page links end in #id, so a new page opens scrolled to the table.
'id' => null,
// Labels, or arrays: ['label' => 'Amount', 'key' => 'amount', 'align' => 'end', 'sortable' => true, 'hideable' => true].
// hideLabel => true keeps the heading for screen readers only, for a column whose cells say what they are (row actions).
// align is start (default), center or end; put numbers at the end so their digits line up. hideable lists the
// column in the Columns menu (add 'hidden' => true to start it hidden); up to 12 columns.
'columns' => [],
// A paginator (paginate(), simplePaginate() or cursorPaginate()) or any array or collection; the slot draws the rows.
'rows' => null,
// Tints every other row.
'striped' => false,
// Kept so older markup still renders; start alignment is now the default for every column.
'alignLeft' => false,
// false leaves out the header row.
'header' => true,
// With a paginator: drawer, numbers, segmented, jump, footer (rows per page), summary or infinite (scrolls inside
// itself, loading more as you go); false for none.
'pagination' => 'drawer',
// Pads a short last page with blank rows, so the pagination bar stays where it was.
'fill' => true,
// What the empty state says. With filters, it also offers Clear filters.
'empty' => 'No data found',
// The table's name for screen readers (a hidden <caption>); also names its scroll area and filters.
'caption' => null,
// pagination="infinite": rows in view at once (1 to 50) before the table scrolls inside itself.
'visibleRows' => 10,
// comfortable (56px rows) or compact (44px), for dense admin screens.
'density' => 'comfortable',
// e.g. "24rem": the body scrolls inside the table and the header stays in view.
'maxHeight' => null,
// Placeholder rows while data is on its way (e.g. while a Livewire or fetch request runs).
'loading' => false,
// How many placeholder rows loading draws (and while-loading="skeleton", without a paginator).
'skeletonRows' => 5,
// While a page, sort or filter change loads: dim (the rows stay, faded, with a spinner) or skeleton (placeholder rows).
'whileLoading' => 'dim',
// Seconds to keep fetched pages in memory: going back to a page, sort or filter seen within that time is instant.
// In memory only, never written to storage, and gone on reload.
'cacheFor' => null,
// The sorted column's key. Defaults to the query string, but only for keys marked sortable. With multi-sort, a
// comma list in order of priority, e.g. "status,amount". Clicking a header cycles ascending, descending, then no sort.
'sort' => null,
// asc or desc (a comma list with multi-sort, one per key); defaults to the query string.
'direction' => null,
// Lets people sort by several columns: Shift+click (or Shift+Enter) a header to add it; a plain click sorts by it alone.
'multiSort' => false,
// The query parameters the sort links set; change them when two sortable tables share a page. In a Livewire
// component, the properties they set.
'sortName' => 'sort',
// The same, for the direction parameter.
'directionName' => 'direction',
// A checkbox per row (give rows a :value) and a bar for the bulk slot's buttons. Needs an id.
'selectable' => false,
// What the row checkboxes submit as: selected[].
'selectName' => 'selected',
// Livewire: binds the row checkboxes to this property (wire:model), e.g. "selected", so actions get the ids.
'selectModel' => null,
// Livewire, pagination="footer": binds rows per page to this property, e.g. "perPage", and goes back to page 1.
'perPageModel' => null,
// Where the bulk form submits the ticked rows.
'bulkAction' => null,
// POST, GET, PUT, PATCH or DELETE.
'bulkMethod' => 'POST',
// Opt-in: below the sm breakpoint, each row becomes a card of label / value pairs instead of scrolling sideways.
'stack' => false,
])
@php
// A typo fails loudly, naming the values that work, instead of quietly rendering something else.
if (! in_array($pagination, ['drawer', 'numbers', 'segmented', 'jump', 'footer', 'summary', 'infinite', false], true)) {
throw new \InvalidArgumentException("Unknown pagination [{$pagination}] for <x-widget.table>. Use one of: drawer, numbers, segmented, jump, footer, summary, infinite, or false for none.");
}
if (! in_array($density, ['comfortable', 'compact'], true)) {
throw new \InvalidArgumentException("Unknown density [{$density}] for <x-widget.table>. Use one of: comfortable, compact.");
}
if (! in_array($whileLoading, ['dim', 'skeleton'], true)) {
throw new \InvalidArgumentException("Unknown while-loading [{$whileLoading}] for <x-widget.table>. Use one of: dim, skeleton.");
}
// paginate() and simplePaginate() give a Paginator; cursorPaginate() (fastest on very large tables) a CursorPaginator.
$isCursor = $rows instanceof \Illuminate\Contracts\Pagination\CursorPaginator;
$isPaginator = $isCursor || $rows instanceof \Illuminate\Contracts\Pagination\Paginator;
// Livewire's paginators use a relative path ("orders"): from /orders, their links would open /orders/orders.
if ($isPaginator && ! preg_match('#^(/|[a-z][a-z0-9+.-]*:)#i', (string) $rows->path())) {
$rows->withPath(url()->to((string) $rows->path()));
}
// During a Livewire update the request is Livewire's own endpoint, so the page's URL and query come from
// Livewire and the Referer (the browser's current URL, filters included) instead.
$livewire = class_exists(\Livewire\Livewire::class) && \Livewire\Livewire::isLivewireRequest();
$pageUrl = $livewire ? \Livewire\Livewire::originalUrl() : request()->url();
$pageQuery = request()->query();
if ($livewire) {
$referer = (string) request()->headers->get('referer');
$pageQuery = [];
if (parse_url($referer, PHP_URL_PATH) === parse_url($pageUrl, PHP_URL_PATH)) {
parse_str((string) parse_url($referer, PHP_URL_QUERY), $pageQuery);
}
}
$isEmpty = ! $loading && $rows !== null && count($rows) === 0;
// pagination="infinite": the table scrolls inside itself, showing `visibleRows` at a time; rows load at
// either end as you scroll and far-off batches are dropped, so the DOM stays small at any data size.
$infinite = $pagination === 'infinite' && $isPaginator;
// Blank rows on a short last page keep the table height, so the pagination bar doesn't jump.
// Not with infinite scroll: the table grows as rows arrive, and blanks would sit between batches.
$fillers = ($fill && ! $infinite && ! $loading && $isPaginator && $rows->hasPages()) ? max(0, $rows->perPage() - count($rows)) : 0;
// Rows render before this template, so their markup tells whether any expands; those rows end with a
// chevron cell (table/row), which needs a header cell of its own.
$expandable = str_contains((string) $slot, 'data-expandable');
$columns = collect($columns)->values()->map(fn (mixed $column): array => [
'label' => (string) (is_array($column) ? ($column['label'] ?? '') : $column),
'key' => is_array($column) ? ($column['key'] ?? null) : null,
'sortable' => is_array($column) && ! empty($column['sortable']) && isset($column['key']),
'align' => is_array($column) && in_array($column['align'] ?? null, ['center', 'end'], true) ? $column['align'] : 'start',
'hideable' => is_array($column) && ! empty($column['hideable']),
'hidden' => is_array($column) && ! empty($column['hideable']) && ! empty($column['hidden']),
'hideLabel' => is_array($column) && ! empty($column['hideLabel']),
]);
$hideable = $columns->contains('hideable', true);
$colspan = max(1, $columns->count() + ($expandable ? 1 : 0) + ($selectable ? 1 : 0));
if ($selectable && $id === null) {
throw new \InvalidArgumentException('<x-widget.table selectable> needs an id: the row checkboxes submit with a form named after it.');
}
$hasFilters = isset($filters) && $filters->isNotEmpty();
if ($hasFilters && $id === null && ! $isPaginator) {
throw new \InvalidArgumentException('<x-widget.table> with filters needs an id: filtering swaps the table found by it in the fetched page.');
}
if ($hideable && $id === null && ! $isPaginator) {
throw new \InvalidArgumentException('<x-widget.table> with hideable columns needs an id: the Columns menu is named after it.');
}
// Filters and the Columns menu sit in a toolbar above the table, which then has a card of its own.
$hasToolbar = $hasFilters || $hideable;
$hasFooter = isset($footer) && $footer->isNotEmpty();
$skeleton = $whileLoading === 'skeleton';
$cacheFor = is_numeric($cacheFor) && (int) $cacheFor > 0 ? min((int) $cacheFor, 3600) : null;
// Page links end in #id, so the new page opens scrolled to this table instead of the top.
// Derived from the page name; ElementIds suffixes it if two tables share one (e.g. both use "page").
$id = match (true) {
$id !== null => app(\App\View\Widget\ElementIds::class)->claim($id, explicit: true),
$isPaginator => app(\App\View\Widget\ElementIds::class)->claim('table-'.($isCursor ? $rows->getCursorName() : $rows->getPageName())),
default => null,
};
if ($isPaginator && $id && ! $rows->fragment()) {
$rows->fragment($id);
}
// Sorting: only a key the columns mark sortable counts, whatever the query string says. This only draws the
// state; your controller must check the key against its own list before it reaches orderBy().
$sortable = $columns->where('sortable')->pluck('key')->all();
// The order as [key => direction], highest priority first: one key, or with multi-sort, the comma lists of sort
// and direction side by side (sort=status,amount&direction=asc,desc). A single sort reads as it always has.
$sortValue = $sort ?? $pageQuery[$sortName] ?? null;
$directionValue = $direction ?? $pageQuery[$directionName] ?? null;
$directions = explode(',', is_string($directionValue) ? $directionValue : '');
$order = [];
foreach (explode(',', is_string($sortValue) ? $sortValue : '') as $i => $key) {
if (in_array($key, $sortable, true) && ! isset($order[$key])) {
$order[$key] = ($directions[$i] ?? null) === 'desc' ? 'desc' : 'asc';
}
}
$order = $multiSort ? $order : array_slice($order, 0, 1, true);
$sort = array_key_first($order);
$direction = $sort !== null ? $order[$sort] : 'asc';
$orderUrl = function (array $next) use ($sortName, $directionName, $isPaginator, $isCursor, $rows, $id, $pageUrl, $pageQuery): string {
$query = array_merge($pageQuery, [$sortName => implode(',', array_keys($next)), $directionName => implode(',', $next)]);
if ($next === []) {
unset($query[$sortName], $query[$directionName]);
}
// A new order starts again at the first page.
if ($isPaginator) {
unset($query[$isCursor ? $rows->getCursorName() : $rows->getPageName()]);
}
return $pageUrl.($query ? '?'.\Illuminate\Support\Arr::query($query) : '').($id ? '#'.$id : '');
};
// A plain click sorts by this column alone, in three steps from its own state: ascending, descending, then no sort
// at all (back to the order the rows came in).
$sortNext = fn (string $key): ?string => match ($order[$key] ?? null) {
null => 'asc',
'asc' => 'desc',
default => null,
};
$sortUrl = fn (string $key): string => $orderUrl(($next = $sortNext($key)) ? [$key => $next] : []);
// What that click does, for screen readers: the link text ends with it.
$sortHint = fn (string $key): string => match ($sortNext($key)) {
'asc' => 'sort ascending',
'desc' => 'sort descending',
default => 'remove sort',
};
// Shift+click with multi-sort: add the column last, or flip it, or (after descending) drop it.
$addSortUrl = function (string $key) use ($order, $orderUrl): string {
$next = $order;
if (! isset($next[$key])) {
$next[$key] = 'asc';
} elseif ($next[$key] === 'asc') {
$next[$key] = 'desc';
} else {
unset($next[$key]);
}
return $orderUrl($next);
};
// Per-column alignment, written out in full for Tailwind (positions 1 to 13, which covers 12 columns plus
// the checkbox column). :where() gives these no weight, so a class on your own <td> always wins.
$alignAt = [
'center' => [1 => '[:where(&_tr>:nth-child(1))]:text-center', 2 => '[:where(&_tr>:nth-child(2))]:text-center', 3 => '[:where(&_tr>:nth-child(3))]:text-center', 4 => '[:where(&_tr>:nth-child(4))]:text-center', 5 => '[:where(&_tr>:nth-child(5))]:text-center', 6 => '[:where(&_tr>:nth-child(6))]:text-center', 7 => '[:where(&_tr>:nth-child(7))]:text-center', 8 => '[:where(&_tr>:nth-child(8))]:text-center', 9 => '[:where(&_tr>:nth-child(9))]:text-center', 10 => '[:where(&_tr>:nth-child(10))]:text-center', 11 => '[:where(&_tr>:nth-child(11))]:text-center', 12 => '[:where(&_tr>:nth-child(12))]:text-center', 13 => '[:where(&_tr>:nth-child(13))]:text-center'],
'end' => [1 => '[:where(&_tr>:nth-child(1))]:text-end', 2 => '[:where(&_tr>:nth-child(2))]:text-end', 3 => '[:where(&_tr>:nth-child(3))]:text-end', 4 => '[:where(&_tr>:nth-child(4))]:text-end', 5 => '[:where(&_tr>:nth-child(5))]:text-end', 6 => '[:where(&_tr>:nth-child(6))]:text-end', 7 => '[:where(&_tr>:nth-child(7))]:text-end', 8 => '[:where(&_tr>:nth-child(8))]:text-end', 9 => '[:where(&_tr>:nth-child(9))]:text-end', 10 => '[:where(&_tr>:nth-child(10))]:text-end', 11 => '[:where(&_tr>:nth-child(11))]:text-end', 12 => '[:where(&_tr>:nth-child(12))]:text-end', 13 => '[:where(&_tr>:nth-child(13))]:text-end'],
];
$offset = $selectable ? 1 : 0;
$alignment = $columns->map(fn (array $column, int $i): string => $alignAt[$column['align']][$i + 1 + $offset] ?? '')->filter()->implode(' ');
// Hidden columns: data-hide on the <table> lists positions (c3 c5), and these rules hide those cells in every row
// but the ones that span the table (details, empty state, fillers). Written out in full for Tailwind, as above.
$hideRules = $hideable ? implode(' ', [
'[&[data-hide~=c1]_tr:not(:has(>[colspan]))>:nth-child(1)]:hidden', '[&[data-hide~=c2]_tr:not(:has(>[colspan]))>:nth-child(2)]:hidden',
'[&[data-hide~=c3]_tr:not(:has(>[colspan]))>:nth-child(3)]:hidden', '[&[data-hide~=c4]_tr:not(:has(>[colspan]))>:nth-child(4)]:hidden',
'[&[data-hide~=c5]_tr:not(:has(>[colspan]))>:nth-child(5)]:hidden', '[&[data-hide~=c6]_tr:not(:has(>[colspan]))>:nth-child(6)]:hidden',
'[&[data-hide~=c7]_tr:not(:has(>[colspan]))>:nth-child(7)]:hidden', '[&[data-hide~=c8]_tr:not(:has(>[colspan]))>:nth-child(8)]:hidden',
'[&[data-hide~=c9]_tr:not(:has(>[colspan]))>:nth-child(9)]:hidden', '[&[data-hide~=c10]_tr:not(:has(>[colspan]))>:nth-child(10)]:hidden',
'[&[data-hide~=c11]_tr:not(:has(>[colspan]))>:nth-child(11)]:hidden', '[&[data-hide~=c12]_tr:not(:has(>[colspan]))>:nth-child(12)]:hidden',
'[&[data-hide~=c13]_tr:not(:has(>[colspan]))>:nth-child(13)]:hidden',
]) : '';
// Each hideable column's cell position (the checkbox column counts), for the menu and the initial data-hide.
$toggles = $columns->map(fn (array $column, int $i): array => [...$column, 'position' => $i + 1 + $offset])->where('hideable', true)->values();
$hiddenAtFirst = $toggles->where('hidden', true)->map(fn (array $column): string => 'c'.$column['position'])->implode(' ');
// A CSS length only; anything else is dropped rather than written into a style attribute.
$maxHeight = is_string($maxHeight) && preg_match('/^\d+(\.\d+)?(px|rem|em|vh|dvh|svh)$/', $maxHeight) ? $maxHeight : null;
$sticky = $infinite || $maxHeight !== null;
// The bordered box: the root itself, or with filters, the card below the toolbar.
$card = 'border-line bg-surface relative w-full overflow-clip rounded-3xl border';
$rowHeight = 'h-14 group-data-[density=compact]/table:h-11';
$formId = $selectable ? $id.'-selection' : null;
$bulkMethod = strtoupper((string) $bulkMethod);
@endphp
{{-- Cells are plain <th>/<td>; spacing and alignment come from these table-level rules. --}}
{{-- overflow-clip, not overflow-hidden: "hidden" makes this a scroll container and Chrome then ignores scroll-mt on anchor jumps. --}}
{{-- data-page-name / data-sort-name / data-direction-name: in a Livewire component, resources/js/widget/table turns
page and sort links into calls on the component (gotoPage, and setting these properties) instead of fetching the page. --}}
<div data-table-root @if ($id) id="{{ $id }}" @endif
@if ($isPaginator) data-page-name="{{ $isCursor ? $rows->getCursorName() : $rows->getPageName() }}" @if ($isCursor) data-page-cursor @endif @endif
@if ($sortable) data-sort-name="{{ $sortName }}" data-direction-name="{{ $directionName }}" @endif
@if ($selectable && $selectModel) data-select-model="{{ $selectModel }}" @endif
@if ($skeleton) data-while-loading="skeleton" @endif
@if ($cacheFor) data-cache-for="{{ $cacheFor }}" @endif
{{-- Read out after filtering ("24 results"). --}}
@if ($rows instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator) data-total="{{ $rows->total() }}" @endif
@if ($infinite)
data-infinite
data-url="{{ $livewire ? $pageUrl.($pageQuery ? '?'.\Illuminate\Support\Arr::query($pageQuery) : '') : request()->fullUrl() }}"
@if ($rows->nextPageUrl()) data-next="{{ $rows->nextPageUrl() }}" @endif
@if ($rows->previousPageUrl()) data-prev="{{ $rows->previousPageUrl() }}" @endif
@endif
@if ($stack) data-stack @endif
{{-- With filters, the toolbar sits above the card and the card is a box of its own inside the root. The root stays
the group (aria-busy, data-stack) and the one element the script swaps; the toolbar must stay its direct child. --}}
{{ $attributes->class(['group/root relative w-full scroll-mt-6', $card => ! $hasToolbar]) }}>
@if ($hasToolbar)
{{-- The toolbar: with filters, a GET form (filters as you type, after a pause, or as you pick); otherwise just a
row for the Columns menu. A direct child of the root, which resources/js/widget/table keeps in place while
filtering swaps the rest, so the field you're in keeps focus. Your fields keep their own values from the query
string; the sort rides along in hidden fields. --}}
<{{ $hasFilters ? 'form' : 'div' }}
data-table-toolbar
@if ($hasFilters) data-table-filters method="GET" action="{{ $pageUrl }}{{ $id ? '#'.$id : '' }}" role="search" aria-label="{{ $caption ? 'Filter '.$caption : 'Filter the table' }}" @endif
class="mb-4 flex flex-wrap items-stretch gap-3 max-sm:*:w-full"
>
@if ($hasFilters)
@if ($sort)
<input type="hidden" name="{{ $sortName }}" value="{{ implode(',', array_keys($order)) }}">
<input type="hidden" name="{{ $directionName }}" value="{{ implode(',', $order) }}">
@endif
{{ $filters }}
{{-- Only without JavaScript, when nothing filters as you type. Decided by CSS (the scripting media feature),
which the browser knows before it first paints, so it never shows and then vanishes. --}}
<x-widget.button type="submit" variant="neutral" :submit-guard="false" class="not-noscript:hidden">Apply</x-widget.button>
@endif
@if ($hideable)
{{-- Only with JavaScript, the other way round: without it every column shows and nothing would toggle. At the
end of the row, as tall as the fields beside it. The ticks are unnamed, so they never ride along with
the filters. --}}
<div class="ms-auto noscript:hidden">
<x-widget.button type="button" variant="neutral" icon-start="eye" popovertarget="{{ $id }}-columns" data-table-columns-toggle class="h-full min-h-12 max-sm:w-full">Columns</x-widget.button>
<div id="{{ $id }}-columns" popover data-table-columns data-fixed="{{ $columns->count() - $toggles->count() }}" class="border-line bg-surface text-foreground m-0 w-60 rounded-2xl border p-2 opacity-0 shadow-lg data-placed:opacity-100">
<fieldset>
<legend class="text-muted px-3 pt-1 pb-2 text-xs font-medium">Show columns</legend>
@foreach ($toggles as $column)
<label class="hover:bg-field flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2 text-sm has-disabled:cursor-not-allowed has-disabled:opacity-50">
<input type="checkbox" data-table-column="{{ $column['position'] }}" @checked(! $column['hidden']) class="accent-primary size-4">
{{ $column['label'] }}
</label>
@endforeach
</fieldset>
</div>
</div>
@endif
</{{ $hasFilters ? 'form' : 'div' }}>
<div data-table-card class="{{ $card }}">
@endif
{{-- Shown while a page change is loading (aria-busy), but only after 300ms so fast responses never flash it. --}}
@unless ($loading || $skeleton)
<div data-table-loading aria-hidden="true" class="pointer-events-none absolute inset-0 z-10 grid place-items-center opacity-0 transition-opacity delay-0 group-aria-busy/root:opacity-100 group-aria-busy/root:delay-300 motion-reduce:transition-none">
<span class="bg-surface grid size-12 place-items-center rounded-full shadow-lg">
<span class="border-line border-t-primary size-6 animate-spin rounded-full border-2"></span>
</span>
</div>
@endunless
@if ($selectable)
{{-- The bulk bar. Row checkboxes live in the table but submit with this form through their form attribute,
so the pagination forms below never end up nested inside it. resources/js/widget/table hides the bar
until something is selected; without JS it simply stays visible. --}}
<div data-table-bulk class="border-line bg-primary/5 flex min-h-14 flex-wrap items-center gap-x-4 gap-y-2 border-b px-4 py-2.5 text-sm sm:px-5">
<form id="{{ $formId }}" method="{{ $bulkMethod === 'GET' ? 'GET' : 'POST' }}" @if ($bulkAction) action="{{ $bulkAction }}" @endif class="contents">
@unless ($bulkMethod === 'GET')
@csrf
@unless ($bulkMethod === 'POST')
@method($bulkMethod)
@endunless
@endunless
<span data-table-selected-count role="status" class="text-foreground font-medium">Select rows to act on them</span>
<button type="button" data-table-clear class="text-link hover:text-link-hover focus-visible:ring-primary rounded-sm outline-none focus-visible:ring-2">Clear</button>
@isset($bulk)
<div class="ms-auto flex flex-wrap gap-2 max-sm:w-full max-sm:*:flex-1">{{ $bulk }}</div>
@endisset
</form>
</div>
@endif
@if ($selectable && $stack)
{{-- Stacked cards hide the header on phones, and the select-all box with it; this one stands in for it there. --}}
<label class="border-line flex items-center gap-2 border-b px-4 py-3 text-sm sm:hidden">
<input type="checkbox" data-table-select-all class="accent-primary size-4 cursor-pointer">
Select all
</label>
@endif
@if ($stack && $header && $columns->contains(fn (array $column): bool => $column['sortable']))
{{-- And the header's sort links, which would otherwise be out of reach on phones. --}}
<nav aria-label="Sort" class="border-line flex flex-wrap items-center gap-x-4 gap-y-2 border-b px-4 py-3 text-sm sm:hidden">
<span class="text-foreground/70">Sort by</span>
@foreach ($columns->filter(fn (array $column): bool => $column['sortable']) as $column)
@php $sorted = $sort === $column['key']; @endphp
<a
href="{{ $sortUrl($column['key']) }}"
data-table-sort
@if ($sorted) aria-current="true" @endif
@class(['focus-visible:ring-primary inline-flex items-center gap-1 rounded-md outline-none focus-visible:ring-2', 'text-foreground font-medium' => $sorted, 'text-link hover:text-link-hover' => ! $sorted])
>
{{ $column['label'] }}
@if ($sorted)
<x-widget.icon :name="$direction === 'desc' ? 'chevron-down' : 'chevron-up'" class="size-3.5" />
@endif
<span class="sr-only">, {{ $sortHint($column['key']) }}</span>
</a>
@endforeach
</nav>
@endif
{{-- resources/js/widget/table makes this focusable (and labels it) only while it actually scrolls,
so keyboard users can scroll a wide table without an extra tab stop on narrow ones. --}}
<div data-table-scroll data-label="{{ $caption ?? 'Table' }}" @class([
'focus-visible:outline-primary overflow-x-auto outline-none transition-opacity focus-visible:outline-2 focus-visible:-outline-offset-2',
// Skeleton mode swaps the rows for placeholders instead of fading them.
'group-aria-busy/root:opacity-50' => ! $skeleton,
'max-h-[var(--table-window)] overflow-y-auto [overflow-anchor:none]' => $infinite,
// A table that scrolls inside itself (infinite, or max-height) hides the native scrollbar: it runs down the
// whole edge, beside the sticky header too. resources/js/widget/table draws thin overlay thumbs instead
// (data-table-thumb), the vertical one starting below the header.
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden' => $sticky,
// Everywhere else a thin native scrollbar. Never both: the two scrollbar-width values would fight.
'[scrollbar-width:thin]' => ! $sticky,
'overflow-y-auto' => $maxHeight !== null,
// Cards on phones don't scroll sideways.
'max-sm:overflow-x-visible' => $stack,
// Classes from the ranges at the end of base.css, not style="", so a strict Content Security Policy allows them.
'[--table-window:calc(2.5rem+'.max(1, min(50, (int) $visibleRows)).'*4rem)]' => $infinite,
])
@if (! $infinite && $maxHeight) data-max-height="{{ $maxHeight }}" @endif
>
<table
@if ($striped) data-striped @endif
@if ($hiddenAtFirst) data-hide="{{ $hiddenAtFirst }}" @endif
data-density="{{ $density }}"
{{-- On the table rather than the root: the root's aria-busy means "changing page" and dims everything. --}}
@if ($loading) aria-busy="true" @endif
@class([
'group/table text-style-2 text-foreground min-w-full border-collapse tabular-nums',
// Tighter on phones, where a table that doesn't stack has to fit what it can.
'[:where(&_th,&_td)]:px-2 sm:[:where(&_th,&_td)]:px-3 [:where(&_th,&_td)]:text-start [:where(&_th,&_td)]:whitespace-nowrap [:where(&_tr>:first-child)]:ps-4 sm:[:where(&_tr>:first-child)]:ps-5 [:where(&_tr>:last-child)]:pe-4 sm:[:where(&_tr>:last-child)]:pe-5',
'[&_th]:h-10 [&_th]:text-xs [&_th]:font-medium',
// The last row's line would double up with the pagination bar's top border.
'[&>tbody>tr:last-child]:border-b-0',
$alignment,
$hideRules,
// Stacked: rows become cards below sm, each cell a "label value" line (labels come from the header).
// The header is hidden outright on phones, not sr-only: its sort links and select-all box would stay in the
// Tab order while invisible. The stand-ins above the table take their place, and each cell keeps its label.
'max-sm:block max-sm:[&_thead]:hidden max-sm:[&_tbody]:flex max-sm:[&_tbody]:flex-col max-sm:[&_tbody]:gap-3 max-sm:[&_tbody]:p-3 max-sm:[&_tr]:block max-sm:[&_tr]:h-auto! max-sm:[&_tr]:rounded-2xl max-sm:[&_tr]:border max-sm:[&_tr]:border-line max-sm:[&_tr]:py-2 max-sm:[&_td]:flex max-sm:[&_td]:items-center max-sm:[&_td]:justify-between max-sm:[&_td]:gap-4 max-sm:[&_td]:py-1.5 max-sm:[&_td]:ps-4! max-sm:[&_td]:pe-4! max-sm:[&_td]:text-end! max-sm:[&_td]:whitespace-normal max-sm:[&_td]:before:text-foreground/60 max-sm:[&_td]:before:text-start max-sm:[&_td]:before:content-[attr(data-label)] max-sm:[&_tfoot]:block max-sm:[&_tfoot]:px-3 max-sm:[&_tfoot]:pb-3' => $stack,
])
>
@if ($caption)
<caption class="sr-only">{{ $caption }}</caption>
@endif
@if ($header && $columns->isNotEmpty())
<thead class="bg-field text-foreground/70">
<tr class="border-line border-b bg-inherit">
{{-- Sticky while the table scrolls inside itself, so the columns stay labelled. It needs an opaque
background to cover the rows passing under it: inherited from the <thead>. --}}
@if ($selectable)
<th scope="col" @class(['w-12', 'sticky top-0 z-[1] bg-inherit' => $sticky])>
<input type="checkbox" data-table-select-all aria-label="Select all rows on this page" class="accent-primary size-4 cursor-pointer align-middle">
</th>
@endif
@foreach ($columns as $column)
@php
// In the order at all (multi-sort can hold several), and where: 1 leads.
$ordered = $column['sortable'] && isset($order[$column['key']]);
$sorted = $ordered && $sort === $column['key'];
$columnDirection = $ordered ? $order[$column['key']] : null;
$rank = $ordered && count($order) > 1 ? array_search($column['key'], array_keys($order), true) + 1 : null;
@endphp
<th
scope="col"
data-label="{{ $column['label'] }}"
{{-- aria-sort on the leading column only, as ARIA asks; the others say their place in the link text. --}}
@if ($sorted) aria-sort="{{ $direction === 'desc' ? 'descending' : 'ascending' }}" @endif
@class(['sticky top-0 z-[1] bg-inherit' => $sticky])
>
@if ($column['sortable'])
<a
href="{{ $sortUrl($column['key']) }}"
data-table-sort
@if ($multiSort) data-sort-add="{{ $addSortUrl($column['key']) }}" @endif
@class(['hover:text-foreground focus-visible:ring-primary -mx-1 inline-flex items-center gap-1 rounded-md px-1 py-0.5 outline-none focus-visible:ring-2', 'text-foreground' => $ordered])
>
{{ $column['label'] }}
<x-widget.icon :name="$ordered ? ($columnDirection === 'desc' ? 'chevron-down' : 'chevron-up') : 'chevrons-up-down'" :class="$ordered ? 'size-3.5' : 'size-3.5 opacity-50'" />
@if ($rank)
<span aria-hidden="true" class="bg-primary/10 text-primary grid size-4 place-items-center rounded-full text-[0.625rem] font-semibold tabular-nums">{{ $rank }}</span>
@endif
{{-- Where it stands, when it isn't the lead, and what a click will do. --}}
@if ($rank && ! $sorted)
<span class="sr-only">, sorted {{ $columnDirection === 'desc' ? 'descending' : 'ascending' }}, priority {{ $rank }}</span>
@endif
<span class="sr-only">, {{ $sortHint($column['key']) }}{{ $multiSort ? '; Shift to add to the sort' : '' }}</span>
</a>
@elseif ($column['hideLabel'])
{{-- Still the column's name for screen readers: an empty header cell is announced as nothing. --}}
<span class="sr-only">{{ $column['label'] }}</span>
@else
{{ $column['label'] }}
@endif
</th>
@endforeach
@if ($expandable)
<th scope="col" @class(['w-12', 'sticky top-0 z-[1] bg-inherit' => $sticky])><span class="sr-only">Details</span></th>
@endif
</tr>
</thead>
@endif
<tbody>
@if ($loading)
@for ($i = 0; $i < max(1, (int) $skeletonRows); $i++)
<tr aria-hidden="true" class="{{ $rowHeight }} border-line border-b last:border-b-0">
@for ($c = 0; $c < $colspan; $c++)
{{-- Varying widths so the placeholder reads as text, not a grid of bars. --}}
<td><span class="{{ ['w-[70%]', 'w-[45%]', 'w-[60%]', 'w-[35%]', 'w-[55%]'][($i + $c) % 5] }} bg-line inline-block h-3 animate-pulse rounded-full align-middle motion-reduce:animate-none"></span></td>
@endfor
</tr>
@endfor
<tr class="sr-only"><td colspan="{{ $colspan }}">Loading</td></tr>
@elseif ($isEmpty)
<tr>
<td colspan="{{ $colspan }}" @class(['py-24! text-center!', 'max-sm:block!' => $stack])>
{{-- In a table wider than the screen, centred on the visible part (width set by the JS), not the whole table. --}}
<div class="text-muted sticky start-4 flex w-[calc(var(--table-visible,100%)-2rem)] flex-col sm:start-5 sm:w-[calc(var(--table-visible,100%)-2.5rem)] items-center gap-3">
<x-widget.icon name="inbox" class="size-10" />
<p>{{ $empty }}</p>
@if ($hasFilters)
{{-- Without JS, the page without any query; resources/js/widget/table clears only this table's filters. --}}
<a href="{{ $pageUrl }}{{ $id ? '#'.$id : '' }}" data-table-filters-clear class="text-link hover:text-link-hover focus-visible:ring-primary rounded-sm outline-none focus-visible:ring-2">Clear filters</a>
@endif
</div>
</td>
</tr>
@else
{{ $slot }}
@for ($i = 0; $i < $fillers; $i++)
{{-- Same 1px bottom border as real rows (just invisible), or the table shrinks on a short last page. --}}
<tr aria-hidden="true" @class([$rowHeight, 'border-b border-transparent', 'max-sm:hidden' => $stack])><td colspan="{{ $colspan }}"></td></tr>
@endfor
@endif
</tbody>
@if ($skeleton && ! $loading)
{{-- Shown by resources/js/widget/table in place of the rows while a change loads (after a moment, so fast
responses never flash it). As many rows as a full page, so the table keeps its height. --}}
<tbody data-table-skeleton hidden aria-hidden="true">
@for ($i = 0; $i < max(1, min(50, $isPaginator ? $rows->perPage() : (int) $skeletonRows)); $i++)
<tr class="{{ $rowHeight }} border-line border-b last:border-b-0">
@for ($c = 0; $c < $colspan; $c++)
<td><span class="{{ ['w-[70%]', 'w-[45%]', 'w-[60%]', 'w-[35%]', 'w-[55%]'][($i + $c) % 5] }} bg-line inline-block h-3 animate-pulse rounded-full align-middle motion-reduce:animate-none"></span></td>
@endfor
</tr>
@endfor
</tbody>
@endif
@if ($hasFooter && ! $isEmpty && ! $loading)
{{-- Your rows (totals, averages) under the data, e.g. <tr><td>Total</td><td>…</td></tr>; they line up with the
columns like any row. On phones with stack, a labelled card of its own. --}}
<tfoot class="bg-field/60 border-line text-foreground border-t font-medium [&>tr]:h-14 group-data-[density=compact]/table:[&>tr]:h-11">
{{ $footer }}
</tfoot>
@endif
</table>
</div>
@if ($sticky)
{{-- Overlay scrollbars for a table that scrolls inside itself, placed by the JS: the vertical one over the rows
(below the header), the horizontal one along the bottom when the table is wider than its box.
Decorative for assistive tech; wheel, touch and arrow keys scroll natively. Drag them to scroll too. --}}
<div data-table-thumb aria-hidden="true" hidden class="bg-line-strong hover:bg-muted active:bg-muted absolute end-0 z-[2] w-1.5 cursor-grab touch-none rounded-full transition-colors select-none active:cursor-grabbing"></div>
<div data-table-thumb-x aria-hidden="true" hidden class="bg-line-strong hover:bg-muted active:bg-muted absolute z-[2] h-1.5 cursor-grab touch-none rounded-full transition-colors select-none active:cursor-grabbing"></div>
@endif
@if ($infinite && ! $isEmpty && ! $loading && $rows->hasPages())
{{-- Windowed scroll footer. resources/js/widget/table fills in the status and hides the fallback links;
without JS those links are a normal Previous/Next. --}}
<div data-table-window-footer class="border-line flex min-h-12 items-center justify-center border-t px-5 py-2 text-sm">
<p data-table-window-status role="status" class="text-muted hidden"></p>
<div data-table-window-fallback>
<x-widget.pagination :paginator="$rows" />
</div>
</div>
@elseif ($isPaginator && $pagination && ! $infinite && ! $isEmpty && ! $loading && $rows->hasPages())
{{-- data-table-pagination: resources/js/widget/table swaps just this table in place when these links are used. --}}
<div data-table-pagination class="border-line border-t transition-opacity group-aria-busy/root:opacity-50">
<x-widget.pagination :paginator="$rows" :type="$pagination" :per-page-model="$perPageModel" />
</div>
@endif
@if ($hasToolbar)
</div>
@endif
</div>
resources/views/components/widget/table/row.blade.php Show
@props([
'href' => null,
// With a selectable table: the value this row's checkbox submits, e.g. its id.
'value' => null,
// Screen reader name for the checkbox, e.g. "Select TX-00042".
'selectLabel' => 'Select row',
// With href: the name of the row's link, e.g. "TX-00042". Defaults to the first cell's text.
'linkLabel' => null,
])
{{-- The table's own props, so a row knows whether it needs a checkbox and which form that submits with. --}}
@aware(['id' => null, 'selectable' => false, 'selectName' => 'selected', 'selectModel' => null])
@php
$hasDetails = isset($details) && $details->isNotEmpty();
$detailsId = $hasDetails ? app(\App\View\Widget\ElementIds::class)->claim('row-details') : null;
$interactive = $href || $hasDetails;
@endphp
{{-- Clicking the row follows `href`, or toggles the `details` slot. Links, buttons and checkboxes inside the row keep working on their own. --}}
<tr
data-table-row
@if ($href) data-href="{{ $href }}" @if ($linkLabel) data-link-label="{{ $linkLabel }}" @endif @endif
@if ($hasDetails) data-expandable aria-expanded="false" aria-controls="{{ $detailsId }}" @endif
{{-- A linked row's Tab stop is the real link resources/js/widget/table puts in its first cell, so screen readers
hear "link"; an expandable row is focused itself. --}}
@if ($hasDetails) tabindex="0" @endif
{{ $attributes->class([
'group/row border-line h-14 border-b transition-colors group-data-[density=compact]/table:h-11',
// "odd of [data-table-row]" skips the hidden details rows, so stripes stay even.
'group-data-striped/table:nth-[odd_of_[data-table-row]]:bg-field/60',
// A light wash to follow the row under the pointer; stronger, with a pointer cursor, when the row does something.
'hover:bg-field/50' => ! $interactive,
// `!`: the stripe rule is equally specific and emitted later, so hover would lose on tinted rows.
'hover:bg-field! focus-visible:outline-primary cursor-pointer focus-visible:outline-2 focus-visible:-outline-offset-2' => $interactive,
// The ring goes on the whole row when its link has keyboard focus.
'has-[[data-row-link]:focus-visible]:outline-primary has-[[data-row-link]:focus-visible]:outline-2 has-[[data-row-link]:focus-visible]:-outline-offset-2' => $href,
'has-[[data-table-select]:checked]:bg-primary/5!' => $selectable,
'data-expanded:[&>td:first-child]:shadow-[inset_4px_0_0_var(--color-primary)]' => $hasDetails,
]) }}
>
@if ($selectable)
{{-- data-no-row-click: a slightly missed click on the checkbox shouldn't open the row. --}}
<td data-no-row-click class="w-12 max-sm:group-data-stack/root:justify-start!">
@if ($value !== null)
<input type="checkbox" data-table-select form="{{ $id }}-selection" name="{{ $selectName }}[]" value="{{ $value }}" @if ($selectModel) wire:model="{{ $selectModel }}" @endif aria-label="{{ $selectLabel }}" class="accent-primary size-4 cursor-pointer align-middle">
@endif
</td>
@endif
{{ $slot }}
@if ($hasDetails)
{{-- The table adds a matching header cell when any row expands (see table/index). --}}
<td class="w-12 text-end!">
<x-widget.icon name="chevron-down" class="text-foreground/60 inline size-5 transition-transform duration-300 group-data-expanded/row:rotate-180 motion-reduce:transition-none" />
</td>
@endif
</tr>
@if ($hasDetails)
{{-- hidden while collapsed: even at zero height a table row still takes half a border (0.5px) in a collapsed
table, which made a short last page shorter than a full one. The JS un-hides it just before expanding. --}}
<tr id="{{ $detailsId }}" data-table-details inert hidden class="group/details bg-field/60">
{{-- colspan larger than any real table: browsers clamp it to the actual column count. --}}
<td colspan="100" class="p-0! text-start! whitespace-normal! max-sm:group-data-stack/root:block!">
<div class="grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out group-data-open/details:grid-rows-[1fr] motion-reduce:transition-none">
<div class="overflow-hidden opacity-0 transition-opacity duration-300 group-data-open/details:opacity-100">
{{-- In a table wider than the screen, the text wraps to the visible width (set by the JS) instead of running off it. --}}
<div class="max-w-(--table-visible) px-5 py-4">{{ $details }}</div>
</div>
</div>
</td>
</tr>
@endif
resources/js/widget/table/index.js Show
// Drives <x-widget.table.row>: rows with `href` navigate, rows with a `details` slot expand.
// Pagination links are ordinary URLs (they work without JS); this script also swaps pages in place.
// A swapped-in table may bring selects in its filters toolbar, which bind per element.
import { initSelects, refreshOptions } from '../select';
// Clicks on these inside a row do their own thing instead of activating the row.
const INTERACTIVE = 'a, button, input, select, textarea, label, summary, [role="button"], [data-no-row-click]';
const ROW = 'tr[data-href], tr[data-expandable]';
// Matches the details row's duration-300 transition.
const COLLAPSE_MS = 300;
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function toggle(row) {
const open = row.getAttribute('aria-expanded') !== 'true';
const details = document.getElementById(row.getAttribute('aria-controls'));
row.setAttribute('aria-expanded', String(open));
row.toggleAttribute('data-expanded', open);
// Collapsed content stays out of the tab order and away from screen readers.
details.inert = !open;
clearTimeout(details.hideTimer);
if (open) {
// Un-hide first and force a layout, so the height transition runs from 0 instead of snapping open.
details.hidden = false;
void details.offsetHeight;
details.setAttribute('data-open', '');
} else {
details.removeAttribute('data-open');
// Hide once the collapse transition (duration-300) is over, so it takes no space at all.
details.hideTimer = setTimeout(() => {
details.hidden = true;
}, reducedMotion() ? 0 : COLLAPSE_MS);
}
}
function activate(row, newTab) {
if (row.hasAttribute('data-expandable')) {
toggle(row);
} else if (newTab) {
window.open(row.dataset.href, '_blank', 'noopener');
} else {
window.location.assign(row.dataset.href);
}
}
function rowFromEvent(event) {
const row = event.target.closest?.(ROW);
const control = event.target.closest?.(INTERACTIVE);
if (!row || (control && row.contains(control))) {
return null;
}
return row;
}
document.addEventListener('click', (event) => {
const row = rowFromEvent(event);
// Selecting text in a row shouldn't navigate away.
if (row && !window.getSelection()?.toString()) {
activate(row, event.metaKey || event.ctrlKey);
}
});
// Middle-click opens a linked row in a new tab, like a normal link.
document.addEventListener('auxclick', (event) => {
const row = rowFromEvent(event);
if (row && event.button === 1 && row.dataset.href) {
activate(row, true);
}
});
document.addEventListener('keydown', (event) => {
const row = event.target.matches?.(ROW) ? event.target : null;
if (!row) {
return;
}
if (event.key === 'Enter' || (event.key === ' ' && row.hasAttribute('data-expandable'))) {
event.preventDefault();
activate(row, event.metaKey || event.ctrlKey);
}
});
// --- Keyboard access to wide tables ---------------------------------------------------
// A table that overflows sideways can only be scrolled with a mouse or touch unless its scroll area
// can take focus. Make it focusable (and give it a name for screen readers) only while it overflows,
// so tables that fit don't get a pointless extra tab stop.
function syncScrollable(scroller) {
// How wide the visible part is, so an expanded row's text wraps to fit the screen rather than the whole table.
scroller.style.setProperty('--table-visible', `${scroller.clientWidth}px`);
// Sideways (wide table) or up/down (windowed infinite table): either way arrow keys need focus to scroll.
const overflows = scroller.scrollWidth > scroller.clientWidth + 1 || scroller.scrollHeight > scroller.clientHeight + 1;
if (overflows) {
scroller.tabIndex = 0;
scroller.setAttribute('role', 'region');
scroller.setAttribute('aria-label', scroller.dataset.label);
} else {
scroller.removeAttribute('tabindex');
scroller.removeAttribute('role');
scroller.removeAttribute('aria-label');
}
}
const resizes = new ResizeObserver((entries) => entries.forEach((entry) => syncScrollable(entry.target)));
// Run by setUp (end of this file) for every table.
function watchScrollables(scope) {
scope.querySelectorAll('[data-table-scroll]').forEach((scroller) => {
// max-height="24rem" can be any length, so it can't be a prebuilt class, and style="" is blocked by a
// strict Content Security Policy; setting it from here isn't. Without JS the table just isn't capped.
if (scroller.dataset.maxHeight) {
scroller.style.maxHeight = scroller.dataset.maxHeight;
}
syncScrollable(scroller);
resizes.observe(scroller);
});
}
// --- Page changes without a full reload -------------------------------------------------
// A pagination link is a real link, but following it reloads the whole page: the browser paints the
// top first and only then jumps to #table-…, which flickers on long pages. Instead, fetch the target
// page, swap in just this table, and update the URL.
//
// On a slow or dead connection: the spinner (CSS, after 300ms) shows it's working, a newer click
// cancels the older request, and after TIMEOUT_MS or with no connection the current page stays put
// with an error toast. An HTTP error (500, expired session…) still does a normal page load, so the
// user sees the real error or login page.
const TIMEOUT_MS = 20_000;
let inFlight = null;
// Tables the current request is for; a cancelled request must only un-fade tables no longer loading.
let loading = new Set();
function settleSuperseded(roots) {
roots.forEach((root) => {
if (!loading.has(root)) {
setBusy(root, false);
}
});
}
// aria-busy fades the rows and shows the spinner (CSS, see table/index). With while-loading="skeleton", placeholder
// rows stand in for them instead, once the load has taken SKELETON_AFTER_MS, so fast responses never flash them.
const SKELETON_AFTER_MS = 150;
const skeletonTimers = new WeakMap();
function setBusy(root, busy) {
clearTimeout(skeletonTimers.get(root));
if (!busy) {
root.removeAttribute('aria-busy');
showSkeleton(root, false);
return;
}
root.setAttribute('aria-busy', 'true');
if (root.dataset.whileLoading === 'skeleton') {
skeletonTimers.set(root, setTimeout(() => showSkeleton(root, true), SKELETON_AFTER_MS));
}
}
function showSkeleton(root, show) {
const skeleton = root.querySelector('tbody[data-table-skeleton]');
const rows = skeleton?.parentElement.querySelector(':scope > tbody:not([data-table-skeleton])');
if (skeleton && rows) {
skeleton.hidden = !show;
rows.hidden = show;
}
}
// --- cache-for: pages already fetched, in memory ------------------------------------------------------
// Only the tables of each page are kept, not the whole page, and at most CACHE_ENTRIES of them, oldest dropped
// first. Never localStorage or the like: tables often hold private data, which mustn't outlive the page.
const CACHE_ENTRIES = 20;
const pageCache = new Map();
const cacheKey = (url) => url.split('#')[0];
function cachedPage(root, url) {
const seconds = Number(root.dataset.cacheFor);
const hit = seconds ? pageCache.get(cacheKey(url)) : null;
if (!hit || Date.now() - hit.at > seconds * 1000) {
return null;
}
return new DOMParser().parseFromString(hit.html, 'text/html');
}
function remember(root, url, page) {
if (!Number(root.dataset.cacheFor)) {
return;
}
const key = cacheKey(url);
const html = [...page.querySelectorAll('[data-table-root][id]')].map((table) => table.outerHTML).join('');
// Re-inserted, so the most recently used page is the last to be dropped.
pageCache.delete(key);
pageCache.set(key, { html, at: Date.now() });
while (pageCache.size > CACHE_ENTRIES) {
pageCache.delete(pageCache.keys().next().value);
}
}
/** @returns {Promise<{ page?: Document, failure?: 'network' | 'http' | 'superseded' }>} */
async function fetchPage(url) {
inFlight?.abort();
const controller = new AbortController();
inFlight = controller;
const timer = setTimeout(() => controller.abort('timeout'), TIMEOUT_MS);
try {
const response = await fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin', signal: controller.signal });
if (!response.ok) {
return { failure: 'http' };
}
return { page: new DOMParser().parseFromString(await response.text(), 'text/html') };
} catch {
// Aborted by a newer click stays silent; a timeout or no connection is a network failure.
return { failure: controller.signal.aborted && controller.signal.reason !== 'timeout' ? 'superseded' : 'network' };
} finally {
clearTimeout(timer);
if (inFlight === controller) {
inFlight = null;
}
}
}
// One polite live region for the page, created once: a region inside the table would be replaced
// along with it, and screen readers don't announce text in an element that has only just appeared.
function announce(message) {
let region = document.getElementById('table-announcer');
if (!region) {
region = Object.assign(document.createElement('p'), { id: 'table-announcer', className: 'sr-only' });
region.setAttribute('role', 'status');
document.body.append(region);
}
region.textContent = '';
// Set in a separate task so an identical repeat message is still read out.
setTimeout(() => {
region.textContent = message;
}, 50);
}
function failedToLoad(roots) {
roots.forEach((root) => setBusy(root, false));
const message = 'Couldn’t load that page. Check your connection and try again.';
window.toast ? window.toast.error(message, { title: 'Connection problem' }) : window.alert(message);
announce(message);
}
// Replaces a table with its counterpart (same id) from a fetched page; null if the page lacks it.
function replaceTable(root, page) {
const fresh = page?.getElementById(root.id);
if (!fresh?.hasAttribute('data-table-root')) {
return null;
}
// Close the page sheet if it was open; the old one goes away with the old table.
root.querySelectorAll('dialog[open]').forEach((dialog) => dialog.close());
const adopted = document.adoptNode(fresh);
root.replaceWith(adopted);
clearTimeout(skeletonTimers.get(root));
initSelects(adopted);
watchScrollables(adopted);
enhance(adopted);
// A sorted infinite table arrives as a fresh window and starts loading again.
if (adopted.hasAttribute('data-infinite')) {
initWindow(adopted);
}
return adopted;
}
// Filtering swaps everything in the table but its toolbar (keep), which stays where it is: moving the field being
// typed in, even for a moment, takes its focus and caret away. The root stays too, with the fresh one's attributes;
// null as replaceTable, or its result if the fresh table has no toolbar to stand in for. What the server works out
// for the toolbar (option counts) is carried over from the fresh copy.
function refreshToolbar(keep, fresh) {
keep.querySelectorAll('[data-select]').forEach((select) => {
const id = select.querySelector('[data-select-trigger]')?.id;
const counterpart = id ? fresh.querySelector(`[data-select]:has(#${CSS.escape(id)})`) : null;
if (counterpart) {
refreshOptions(select, counterpart);
}
});
}
function replaceAround(root, page, keep) {
const fresh = page?.getElementById(root.id);
if (!fresh?.hasAttribute('data-table-root')) {
return null;
}
const children = [...fresh.children];
const at = children.findIndex((child) => child.matches('[data-table-toolbar]'));
if (at === -1) {
return replaceTable(root, page);
}
refreshToolbar(keep, children[at]);
root.querySelectorAll('dialog[open]').forEach((dialog) => dialog.close());
clearTimeout(skeletonTimers.get(root));
[...root.attributes].forEach((attribute) => root.removeAttribute(attribute.name));
[...fresh.attributes].forEach((attribute) => root.setAttribute(attribute.name, attribute.value));
[...root.children].forEach((child) => child !== keep && child.remove());
keep.before(...children.slice(0, at).map((child) => document.adoptNode(child)));
keep.after(...children.slice(at + 1).map((child) => document.adoptNode(child)));
// Still the same element, so what was set up once per table is set up again for its new parts.
thumbed.delete(root);
windowed.delete(root);
watchScrollables(root);
enhance(root);
if (root.hasAttribute('data-infinite')) {
initWindow(root);
}
return root;
}
// Other tables keep their rows, but their page links were built for the old URL; without this, using
// them would drop the page this swap just set. The fetched page already has fresh links for every table.
function refreshOtherPagination(page, except) {
document.querySelectorAll('[data-table-root][id]').forEach((root) => {
// A Livewire component's links are its own to render.
if (root === except || inLivewire(root)) {
return;
}
const current = root.querySelector('[data-table-pagination]');
const fresh = page.getElementById(root.id)?.querySelector('[data-table-pagination]');
if (current && fresh) {
current.querySelectorAll('dialog[open]').forEach((dialog) => dialog.close());
current.replaceWith(document.adoptNode(fresh));
}
});
}
// push: a new history entry (a page or sort link, a filter picked); otherwise a filter replaces the entry, as while
// typing. filter: focus stays where it is, in the toolbar (keep, see replaceAround), and the result count is read out.
async function swapTable(root, url, { push = false, sort = false, filter = false, keep = null }) {
loading = new Set([root]);
let page = cachedPage(root, url);
if (page) {
// A cached answer is newer than anything still on its way.
inFlight?.abort();
} else {
setBusy(root, true);
const result = await fetchPage(url);
if (result.failure === 'superseded') {
settleSuperseded([root]);
return;
}
if (result.failure === 'network') {
failedToLoad([root]);
return;
}
page = result.page;
if (page) {
remember(root, url, page);
}
}
const fresh = page && (keep ? replaceAround(root, page, keep) : replaceTable(root, page));
if (!fresh) {
window.location.assign(url);
return;
}
setBusy(fresh, false);
refreshOtherPagination(page, fresh);
if (push) {
window.history.pushState({ tableId: fresh.id }, '', url);
} else if (filter) {
window.history.replaceState({ ...window.history.state, tableId: fresh.id }, '', url);
}
if (filter) {
announceResults(fresh);
} else {
settle(fresh, { sort });
}
}
function announceResults(root) {
const total = root.dataset.total !== undefined ? Number(root.dataset.total) : root.querySelectorAll('tbody tr[data-table-row]').length;
announce(total === 0 ? 'No results' : `${total.toLocaleString()} ${total === 1 ? 'result' : 'results'}`);
}
// Keep the table in view (its top may have been scrolled away), and move focus to it so keyboard
// and screen-reader users land on the new page rather than on a link that no longer exists.
function settle(fresh, { sort }) {
if (fresh.getBoundingClientRect().top < 0) {
fresh.scrollIntoView({ block: 'start' });
}
fresh.tabIndex = -1;
fresh.focus({ preventScroll: true });
// After a sort, say the new order; after a page change, the page.
const sorted = sort ? fresh.querySelector('th[aria-sort]') : null;
if (sorted) {
announce(`Sorted by ${sorted.dataset.label}, ${sorted.getAttribute('aria-sort')}`);
return;
}
// Numbers and jump styles mark the current page with aria-current; the drawer shows it on its sheet button.
const current = fresh.querySelector('[data-table-pagination] [aria-current="page"], [data-table-pagination] [data-modal-open]')?.textContent.trim();
announce(current ? `Page ${current.replace(/\s+/g, ' ')} loaded` : 'Page loaded');
}
// Page links and sort links (in the header) both swap just their table.
const tableFor = (element) => element.closest('[data-table-pagination], [data-table-sort]')?.closest('[data-table-root][id]');
// --- In a Livewire component ------------------------------------------------------------------------
// There the component owns the rows. Swapping the table would leave its state (page, sort) behind, and its next
// render would put the old rows back. So a page or sort link becomes a call on the component instead.
const inLivewire = (root) => root.closest('[wire\\:id]') !== null;
const wireFor = (root) => window.Livewire?.find(root.closest('[wire\\:id]')?.getAttribute('wire:id'));
// Tables whose page or sort is changing: their ticked boxes and open rows start fresh, as they do without Livewire.
const resetting = new WeakSet();
async function changePage(root, href, { sort = false }) {
typingEntry.delete(root.id);
if (!inLivewire(root)) {
swapTable(root, href, { push: true, sort });
return;
}
const wire = wireFor(root);
if (!wire || !(await viaLivewire(root, wire, href, { sort }))) {
window.location.assign(href);
}
}
// The link's query says what to change: its page goes to gotoPage() (WithPagination), and the table's own sort
// and direction parameters to the component properties of those names. Only those: sort links carry the page's
// whole query, so setting any property a link names would let a crafted URL (?isAdmin=1) set it on the next
// click. Anything else that differs from the address bar can't be done in place; false, and the caller loads
// the page instead.
async function viaLivewire(root, wire, href, { sort }) {
const target = new URL(href, window.location.href);
const here = new URL(window.location.href);
const { pageName, sortName, directionName } = root.dataset;
const settable = new Set([sortName, directionName].filter(Boolean));
const updates = [];
for (const [name, value] of target.searchParams) {
if (name === pageName) {
continue;
}
if (settable.has(name) && wire.$get(name) !== undefined) {
updates.push([name, value]);
} else if (here.searchParams.get(name) !== value) {
return false;
}
}
// A sort link without the sort parameters is the third click, which removes the sort: empty the properties too,
// or the component would keep sorting.
if (sort) {
settable.forEach((name) => {
const current = wire.$get(name);
if (!target.searchParams.has(name) && current !== undefined && current !== null && current !== '') {
updates.push([name, typeof current === 'string' ? '' : null]);
}
});
}
// A sort link leaves the page out: back to the first one.
const page = pageName ? (target.searchParams.get(pageName) ?? (root.hasAttribute('data-page-cursor') ? '' : '1')) : null;
const paginated = wire.$get('paginators') !== undefined;
if (page !== null && !paginated) {
return false;
}
root.querySelectorAll('dialog[open]').forEach((dialog) => dialog.close());
resetting.add(root);
setBusy(root, true);
// Not live: they travel with the gotoPage()/$refresh() request, so it's one round trip.
updates.forEach(([name, value]) => wire.$set(name, value, false));
try {
await (page !== null ? wire.$call('gotoPage', page, pageName) : wire.$refresh());
} finally {
resetting.delete(root);
setBusy(root, false);
}
const fresh = root.isConnected ? root : document.getElementById(root.id);
if (fresh) {
settle(fresh, { sort });
}
return true;
}
document.addEventListener('click', (event) => {
const link = event.target.closest?.('a[href]');
const root = link && tableFor(link);
// With multi-sort, Shift adds the column to the sort (Shift+Enter too: it clicks with the key held).
const adding = event.shiftKey && link?.dataset.sortAdd;
// Let the browser handle new-tab/window clicks and anything that isn't a same-origin page link.
if (!root || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || (event.shiftKey && !adding) || event.altKey) {
return;
}
const url = new URL(adding || link.href, window.location.href);
if (url.origin !== window.location.origin) {
return;
}
event.preventDefault();
changePage(root, url.href, { sort: link.hasAttribute('data-table-sort') });
});
// The "go to page" form: same thing, built from its GET fields.
document.addEventListener('submit', (event) => {
const form = event.target;
const root = tableFor(form);
if (!root || form.method.toLowerCase() !== 'get') {
return;
}
event.preventDefault();
const url = new URL(form.action, window.location.href);
url.search = new URLSearchParams(new FormData(form)).toString();
changePage(root, url.href, {});
});
// --- Filters (the filters slot) ------------------------------------------------------------------------
// A GET form in the table's toolbar. Text fields filter once typing pauses for FILTER_AFTER_MS, anything else
// (selects, checkboxes) as soon as it changes; Enter does it at once. Each goes back to the first page, keeps the
// sort (in the form's hidden fields) and every other table's page, and swaps the table in place. In a Livewire
// component the fields are yours to bind (wire:model), and Livewire does the rest.
const FILTER_AFTER_MS = 300;
const TEXT_FIELD = 'input:not([type]), input[type="text"], input[type="search"], input[type="email"], input[type="number"], input[type="tel"], input[type="url"], textarea';
const typingTimers = new WeakMap();
// The query each table was last asked to show, so a change event after the same text was typed doesn't fetch again.
const lastFilter = new WeakMap();
// History: picking a filter is a step Back undoes, so it gets an entry of its own. Typing gets one entry for the
// whole burst: the first pause adds it, later ones update it. By table id; cleared by any other step.
const typingEntry = new Set();
// Empty fields are left out, so a cleared filter leaves no ?q= behind.
function filterUrl(form, root, fields = new FormData(form)) {
const url = new URL(form.action, window.location.href);
const query = new URLSearchParams(window.location.search);
new Set([...new FormData(form).keys()]).forEach((name) => query.delete(name));
if (root.dataset.pageName) {
query.delete(root.dataset.pageName);
}
for (const [name, value] of fields) {
if (typeof value === 'string' && value !== '') {
query.append(name, value);
}
}
url.search = query.toString();
return url;
}
function filter(form, { now = false } = {}) {
const toolbar = form.closest('[data-table-toolbar]');
const root = toolbar?.parentElement?.closest('[data-table-root][id]');
clearTimeout(typingTimers.get(form));
if (!root || inLivewire(root)) {
return;
}
const run = () => {
const url = filterUrl(form, root);
if (url.search === (lastFilter.get(root) ?? window.location.search)) {
return;
}
lastFilter.set(root, url.search);
const push = now || !typingEntry.has(root.id);
if (now) {
typingEntry.delete(root.id);
} else {
typingEntry.add(root.id);
}
swapTable(root, url.href, { filter: true, keep: toolbar, push });
};
if (now) {
run();
} else {
typingTimers.set(form, setTimeout(run, FILTER_AFTER_MS));
}
}
const filtersOf = (event) => event.target.closest?.('form[data-table-filters]');
document.addEventListener('input', (event) => {
const form = filtersOf(event);
if (form && event.target.matches(TEXT_FIELD)) {
filter(form);
}
});
document.addEventListener('change', (event) => {
const form = filtersOf(event);
// The Columns menu sits in the same toolbar; its ticks aren't filters.
if (form && !event.target.matches(TEXT_FIELD) && !event.target.matches('[data-table-column]')) {
filter(form, { now: true });
}
});
document.addEventListener('submit', (event) => {
const form = event.target.closest?.('form[data-table-filters]');
if (form) {
event.preventDefault();
filter(form, { now: true });
}
});
// "Clear filters" (in the empty state, or any element of yours marked data-table-filters-clear inside the table):
// drops this table's filter fields from the URL, keeping the sort, and swaps in the table with an empty toolbar.
document.addEventListener('click', async (event) => {
const clear = event.target.closest?.('[data-table-filters-clear]');
const root = clear?.closest('[data-table-root][id]');
const form = root?.querySelector(':scope > form[data-table-filters]');
if (!form || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
event.preventDefault();
if (inLivewire(root)) {
clearInLivewire(root, form);
return;
}
const sort = [root.dataset.sortName, root.dataset.directionName];
const url = filterUrl(form, root, [...new FormData(form)].filter(([name]) => sort.includes(name)));
lastFilter.set(root, url.search);
typingEntry.delete(root.id);
await swapTable(root, url.href, { filter: true, push: true });
document.getElementById(root.id)?.querySelector('[data-table-filters] :is(input:not([type="hidden"]), button, select, textarea)')?.focus();
});
// Every property the toolbar's fields are bound to goes back to empty, then one request: the first page, or a render.
function clearInLivewire(root, form) {
const wire = wireFor(root);
if (!wire) {
return;
}
const names = new Set([...form.querySelectorAll('*')].flatMap((el) => [...el.attributes].filter((attribute) => attribute.name.startsWith('wire:model')).map((attribute) => attribute.value)));
names.forEach((name) => {
const value = wire.$get(name);
wire.$set(name, Array.isArray(value) ? [] : typeof value === 'string' ? '' : null, false);
});
const { pageName } = root.dataset;
wire.$get('paginators') !== undefined && pageName ? wire.$call('gotoPage', 1, pageName) : wire.$refresh();
}
// Back/forward: bring every table back to what the URL says (several may have changed since that entry).
// Not those in a Livewire component: Livewire restores its own state from the URL.
window.addEventListener('popstate', async (event) => {
const roots = [...document.querySelectorAll('[data-table-root][id]')].filter((root) => !inLivewire(root));
if (!event.state?.tableId || roots.length === 0) {
return;
}
loading = new Set(roots);
roots.forEach((root) => setBusy(root, true));
const url = window.location.href;
const { page, failure } = await fetchPage(url);
if (failure === 'superseded') {
settleSuperseded(roots);
return;
}
if (failure === 'network') {
failedToLoad(roots);
return;
}
if (!page || roots.some((root) => !replaceTable(root, page))) {
window.location.assign(url);
}
});
// Tag the entry the page was loaded on, so going back to it also swaps instead of doing nothing. Merged into the
// entry's state rather than replacing it, which would wipe what Livewire keeps there.
if (!window.history.state?.tableId) {
const first = [...document.querySelectorAll('[data-table-root][id]')].find((root) => !inLivewire(root));
if (first) {
window.history.replaceState({ ...window.history.state, tableId: first.id }, '');
}
}
// --- Windowed infinite scroll (pagination="infinite") ---------------------------------
// The table scrolls inside itself. Rows arrive in batches (one server page each) at whichever end you
// approach, and once more than MAX_BATCHES are in the DOM the batch farthest away is dropped, so even a
// 10-lakh-row list holds only a few pages of rows. One batch alone would fit the view exactly and leave
// nothing to scroll, hence one above and one below the visible one. When the window is tall next to a page
// (visible-rows well above per-page), it keeps a batch or two more, just enough to scroll without flipping.
const MAX_BATCHES = 3;
const EDGE_PX = 256;
const RETRY_AFTER_MS = 5000;
const ID_REFERENCES = ['aria-controls', 'aria-describedby', 'aria-labelledby', 'for', 'popovertarget', 'data-modal-open'];
let batchNumber = 0;
// Every fetched page numbers its ids from scratch (row-details, row-details-2 …), so a new batch can
// clash with rows already on the page. Rename those ids and the references to them inside the batch.
function uniquifyIds(rows) {
batchNumber++;
const all = rows.flatMap((row) => [row, ...row.querySelectorAll('*')]);
const renamed = new Map();
all.forEach((el) => {
if (el.id && document.getElementById(el.id)) {
renamed.set(el.id, `${el.id}-b${batchNumber}`);
}
});
if (renamed.size === 0) {
return;
}
all.forEach((el) => {
if (renamed.has(el.id)) {
el.id = renamed.get(el.id);
}
ID_REFERENCES.forEach((attribute) => {
const value = el.getAttribute(attribute);
if (value) {
el.setAttribute(attribute, value.split(' ').map((ref) => renamed.get(ref) ?? ref).join(' '));
}
});
});
}
async function fetchTablePage(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin', signal: controller.signal });
return response.ok ? new DOMParser().parseFromString(await response.text(), 'text/html') : null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
const windows = new WeakMap();
const dataRows = (container) => [...container.children].filter((row) => row.matches('tr:not([aria-hidden="true"])'));
function batchFrom(page, root, url) {
const fresh = page?.getElementById(root.id);
if (!fresh?.hasAttribute('data-infinite')) {
return null;
}
const rows = dataRows(fresh.querySelector('tbody'));
rows.forEach((row) => document.adoptNode(row));
uniquifyIds(rows);
return { url, rows, prev: withPageQuery(root, fresh.dataset.prev), next: withPageQuery(root, fresh.dataset.next) };
}
// Livewire keeps its filters (#[Url] properties) in the address bar but not in its paginators' links, so a batch
// fetched from those links would come back unfiltered. Carry over whatever the link doesn't set itself.
function withPageQuery(root, url) {
if (!url || !inLivewire(root)) {
return url ?? null;
}
const merged = new URL(url, window.location.href);
const own = new Set(merged.searchParams.keys());
new URLSearchParams(window.location.search).forEach((value, name) => {
if (!own.has(name)) {
merged.searchParams.append(name, value);
}
});
return merged.href;
}
function setStatus(root) {
const state = windows.get(root);
const status = root.querySelector('[data-table-window-status]');
if (!status) {
return;
}
const atEnd = !state.batches.at(-1).next;
const atStart = !state.batches[0].prev;
const scroller = root.querySelector('[data-table-scroll]');
const scrollable = scroller.scrollHeight - scroller.clientHeight > 1;
status.textContent = state.loading
? 'Loading more rows…'
: atEnd && atStart ? 'Showing every row.' : atEnd ? 'That’s the end of the list.' : scrollable ? 'Scroll inside the table for more rows.' : '';
}
// Keep the scroll inside the table while there is more to load in that direction; once the first row of
// the first page (or the last row of the last page) is reached, let the scroll carry on to the page.
// overscroll-behavior only matters at an edge, so it's decided by the edge the table is sitting at.
function syncOverscroll(root) {
const state = windows.get(root);
const scroller = root.querySelector('[data-table-scroll]');
const atTop = scroller.scrollTop <= 1;
const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 1;
const release = (atTop && !state.batches[0].prev) || (atBottom && !state.batches.at(-1).next);
scroller.style.overscrollBehaviorY = release ? 'auto' : 'contain';
}
// Point the URL at the batch at the top of the view, so a reload or a shared link reopens there.
function syncUrl(root) {
const state = windows.get(root);
const scroller = root.querySelector('[data-table-scroll]');
const headerHeight = root.querySelector('thead')?.offsetHeight ?? 0;
const visible = [...state.batches].reverse().find((batch) => batch.rows[0].offsetTop - headerHeight <= scroller.scrollTop + 1) ?? state.batches[0];
if (visible.url && visible.url !== window.location.href) {
window.history.replaceState(window.history.state, '', visible.url);
}
}
async function loadBatch(root, direction) {
const state = windows.get(root);
const edge = direction === 'next' ? state.batches.at(-1) : state.batches[0];
const url = edge[direction];
if (!url || state.loading || Date.now() < state.retryAt[direction]) {
return;
}
const scroller = root.querySelector('[data-table-scroll]');
const tbody = root.querySelector('tbody');
state.loading = direction;
tbody.setAttribute('aria-busy', 'true');
setStatus(root);
const batch = batchFrom(await fetchTablePage(url), root, url);
state.loading = null;
// Swapped out while this was loading (a sort, Back): a detached table measures 0 everywhere, so it would
// count as being at the bottom edge forever and fetch every remaining page. Or re-rendered by Livewire,
// which starts the window again: this batch belongs to rows that are gone.
if (!root.isConnected || windows.get(root) !== state) {
return;
}
tbody.removeAttribute('aria-busy');
if (!batch) {
// Keep the rows, and don't hammer a dead connection on every scroll event.
state.retryAt[direction] = Date.now() + RETRY_AFTER_MS;
// At the very edge no further scroll event fires, so retry on our own if the user is still there.
setTimeout(() => checkEdges(root), RETRY_AFTER_MS + 50);
const message = 'Couldn’t load more rows. Check your connection; it will retry automatically.';
window.toast ? window.toast.error(message, { title: 'Connection problem' }) : window.alert(message);
announce(message);
setStatus(root);
return;
}
// A batch is only dropped if the view stays clear of the far edge afterwards. Otherwise, when the window is
// tall next to the batches (many visible rows, few per page), dropping at the top lands the view in the top
// edge, which loads a batch there and drops one at the bottom, and so on for ever. The window keeps the extra
// batch instead, and a later load trims it once there's room.
const batchHeight = (b) => b.rows.reduce((sum, row) => sum + row.getBoundingClientRect().height, 0);
restoreSelection(root, batch.rows);
if (direction === 'next') {
tbody.append(...batch.rows);
state.batches.push(batch);
while (state.batches.length > MAX_BATCHES && scroller.scrollTop - batchHeight(state.batches[0]) > EDGE_PX) {
// Removing rows above the view shifts everything up by their height; scroll up by the same amount.
const dropped = state.batches.shift();
const height = batchHeight(dropped);
releaseFocus(dropped.rows, scroller);
keepSelection(root, dropped.rows);
dropped.rows.forEach((row) => row.remove());
scroller.scrollTop -= height;
}
} else {
// Rows added above the view push it down by their height; scroll down by the same amount.
const before = scroller.scrollHeight;
tbody.prepend(...batch.rows);
state.batches.unshift(batch);
scroller.scrollTop += scroller.scrollHeight - before;
const belowView = () => scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
while (state.batches.length > MAX_BATCHES && belowView() - batchHeight(state.batches.at(-1)) > EDGE_PX) {
const dropped = state.batches.pop();
releaseFocus(dropped.rows, scroller);
keepSelection(root, dropped.rows);
dropped.rows.forEach((row) => row.remove());
}
}
linkRows(root);
syncSelection(root);
setStatus(root);
syncUrl(root);
// Its size never changes (so ResizeObserver stays quiet), but it may only now overflow: re-check focusability.
syncScrollable(scroller);
syncThumb(root);
syncOverscroll(root);
// A fast scroll can still be near an edge after one batch; keep going.
checkEdges(root);
}
// If a removed row had keyboard focus, hand focus to the scroll area rather than losing it to <body>.
function releaseFocus(rows, scroller) {
if (rows.some((row) => row.contains(document.activeElement))) {
scroller.focus({ preventScroll: true });
}
}
function checkEdges(root) {
// A retry timer or a finished load can outlive the table it was for; see loadBatch.
if (!root.isConnected) {
return;
}
const scroller = root.querySelector('[data-table-scroll]');
if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - EDGE_PX) {
loadBatch(root, 'next');
} else if (scroller.scrollTop <= EDGE_PX) {
loadBatch(root, 'prev');
}
}
// Overlay scrollbars for every table that scrolls inside itself (infinite, or max-height): the native bars are
// hidden, since the vertical one would run beside the sticky header too. The vertical thumb spans the scroll area
// below the header and mirrors scrollTop; the horizontal one sits along the bottom edge and mirrors scrollLeft.
const MIN_THUMB_PX = 24;
const THUMB_INSET_PX = 2;
function syncThumb(root) {
const scroller = root.querySelector('[data-table-scroll]');
const thumb = root.querySelector('[data-table-thumb]');
if (!thumb || !scroller) {
return;
}
const header = root.querySelector('thead')?.offsetHeight ?? 0;
const range = scroller.scrollHeight - scroller.clientHeight;
if (range <= 1) {
thumb.hidden = true;
} else {
const track = scroller.clientHeight - header;
const size = Math.max(MIN_THUMB_PX, track * (track / (scroller.scrollHeight - header)));
const offset = (scroller.scrollTop / range) * (track - size);
thumb.hidden = false;
thumb.style.top = `${scroller.offsetTop + header + offset}px`;
thumb.style.height = `${size}px`;
thumb.dataset.ratio = String(range / Math.max(1, track - size));
}
const across = root.querySelector('[data-table-thumb-x]');
if (!across) {
return;
}
const rangeX = scroller.scrollWidth - scroller.clientWidth;
if (rangeX <= 1) {
across.hidden = true;
return;
}
// scrollLeft runs from 0 to -range in right-to-left pages; the thumb still moves with the content.
const rtl = getComputedStyle(scroller).direction === 'rtl';
const trackX = scroller.clientWidth;
const sizeX = Math.max(MIN_THUMB_PX, trackX * (trackX / scroller.scrollWidth));
const progress = Math.abs(scroller.scrollLeft) / rangeX;
const offsetX = (rtl ? 1 - progress : progress) * (trackX - sizeX);
across.hidden = false;
across.style.top = `${scroller.offsetTop + scroller.clientHeight - across.offsetHeight - THUMB_INSET_PX}px`;
across.style.left = `${scroller.offsetLeft + offsetX}px`;
across.style.width = `${sizeX}px`;
across.dataset.ratio = String((rtl ? -1 : 1) * rangeX / Math.max(1, trackX - sizeX));
}
function dragThumb(root, selector = '[data-table-thumb]', axis = 'y') {
const thumb = root.querySelector(selector);
const scroller = root.querySelector('[data-table-scroll]');
if (!thumb) {
return;
}
thumb.addEventListener('pointerdown', (event) => {
event.preventDefault();
thumb.setPointerCapture(event.pointerId);
const start = axis === 'y' ? event.clientY : event.clientX;
const startScroll = axis === 'y' ? scroller.scrollTop : scroller.scrollLeft;
const ratio = Number(thumb.dataset.ratio || 1);
const move = (e) => {
const scrolled = startScroll + ((axis === 'y' ? e.clientY : e.clientX) - start) * ratio;
axis === 'y' ? (scroller.scrollTop = scrolled) : (scroller.scrollLeft = scrolled);
};
const stop = () => {
thumb.removeEventListener('pointermove', move);
thumb.removeEventListener('pointerup', stop);
thumb.removeEventListener('pointercancel', stop);
};
thumb.addEventListener('pointermove', move);
thumb.addEventListener('pointerup', stop);
thumb.addEventListener('pointercancel', stop);
});
}
// Once per table root (a swapped-in table is a new root): keep the thumbs on the scroll position and the size.
const thumbed = new WeakSet();
function initThumb(root) {
const scroller = root.querySelector('[data-table-scroll]');
if (thumbed.has(root) || !scroller || !root.querySelector('[data-table-thumb]')) {
return;
}
thumbed.add(root);
let queued = false;
scroller.addEventListener('scroll', () => {
if (!queued) {
queued = true;
requestAnimationFrame(() => {
queued = false;
syncThumb(root);
});
}
}, { passive: true });
dragThumb(root);
dragThumb(root, '[data-table-thumb-x]', 'x');
const resized = new ResizeObserver(() => syncThumb(root));
resized.observe(scroller);
resized.observe(scroller.querySelector('table'));
syncThumb(root);
}
// Listeners go on once per table; the state is set afresh each time, since a Livewire render replaces the rows.
const windowed = new WeakSet();
function initWindow(root) {
windows.set(root, {
batches: [{ url: root.dataset.url, rows: dataRows(root.querySelector('tbody')), prev: withPageQuery(root, root.dataset.prev), next: withPageQuery(root, root.dataset.next) }],
loading: null,
retryAt: { next: 0, prev: 0 },
});
root.querySelector('[data-table-window-fallback]')?.setAttribute('hidden', '');
root.querySelector('[data-table-window-status]')?.classList.remove('hidden');
if (!windowed.has(root)) {
windowed.add(root);
listenToWindow(root);
}
syncOverscroll(root);
setStatus(root);
// Fill both sides straight away: below so there is something to scroll to, above so scrolling up
// from a deep link works (at scrollTop 0 there is no scroll event to trigger it).
loadBatch(root, 'next').then(() => loadBatch(root, 'prev'));
}
function listenToWindow(root) {
const scroller = root.querySelector('[data-table-scroll]');
let queued = false;
let urlTimer = null;
scroller.addEventListener('scroll', () => {
if (!queued) {
queued = true;
requestAnimationFrame(() => {
queued = false;
syncOverscroll(root);
checkEdges(root);
});
}
clearTimeout(urlTimer);
urlTimer = setTimeout(() => syncUrl(root), 200);
}, { passive: true });
// Pushing past the top or bottom fires no scroll event (the position can't change), but it is
// exactly when a load is wanted: treat those attempts as edge checks too.
const nudge = () => requestAnimationFrame(() => checkEdges(root));
scroller.addEventListener('wheel', nudge, { passive: true });
scroller.addEventListener('touchmove', nudge, { passive: true });
scroller.addEventListener('keydown', nudge);
// The thumbs are initThumb's (run from enhance); this keeps the window's own state in step.
const sync = () => {
syncOverscroll(root);
setStatus(root);
};
// Also watch the table: rows expanding or collapsing change whether the body needs to scroll at all.
const resized = new ResizeObserver(sync);
resized.observe(scroller);
resized.observe(scroller.querySelector('table'));
}
// Back online: retry wherever a windowed table is waiting at an edge.
window.addEventListener('online', () => {
document.querySelectorAll('[data-table-root][data-infinite]').forEach((root) => {
const state = windows.get(root);
if (state) {
state.retryAt = { next: 0, prev: 0 };
checkEdges(root);
}
});
});
// --- Row selection ------------------------------------------------------------------------------------
// An infinite table drops rows that scroll far away. A ticked one leaves a hidden input with the same name and
// value in its place, so the bulk form still sends it and the count still counts it; the box is ticked again
// if its row comes back.
function keepSelection(root, rows) {
for (const box of rows.flatMap((row) => [...row.querySelectorAll('[data-table-select]:checked')])) {
const kept = Object.assign(document.createElement('input'), { type: 'hidden', name: box.name, value: box.value });
kept.setAttribute('form', box.getAttribute('form') ?? '');
kept.dataset.tableKept = '';
root.append(kept);
}
}
function restoreSelection(root, rows) {
const kept = [...root.querySelectorAll('[data-table-kept]')];
for (const box of rows.flatMap((row) => [...row.querySelectorAll('[data-table-select]')])) {
const match = kept.find((input) => input.isConnected && input.value === box.value && input.name === box.name);
if (match) {
box.checked = true;
match.remove();
}
}
}
const lastCount = new WeakMap();
// Checkboxes, the select-all box (checked, half-checked or clear) and the bulk bar, all from what's ticked.
// announceCount: after the user ticked or cleared something, say the new count; the label sits in a bar that
// has only just appeared, and screen readers don't read a live region they didn't see arrive.
function syncSelection(root, { announceCount = false } = {}) {
const boxes = [...root.querySelectorAll('[data-table-select]')];
// One in the header, and one above the cards when a stacked table is on a phone.
const alls = root.querySelectorAll('[data-table-select-all]');
const bar = root.querySelector('[data-table-bulk]');
if (!bar) {
return;
}
const ticked = boxes.filter((box) => box.checked).length;
const model = modelSelection(root);
const onPage = new Set(boxes.map((box) => box.value));
const elsewhere = model ? new Set(model.filter((value) => !onPage.has(value))).size : 0;
const count = ticked + (model ? elsewhere : root.querySelectorAll('[data-table-kept]').length);
alls.forEach((all) => {
all.checked = boxes.length > 0 && ticked === boxes.length;
all.indeterminate = count > 0 && !all.checked;
});
bar.hidden = count === 0;
const text = elsewhere ? `${count} selected (${elsewhere} on other pages)` : `${count} selected`;
const label = root.querySelector('[data-table-selected-count]');
if (label) {
label.textContent = text;
}
if (announceCount && lastCount.get(root) !== text) {
announce(count === 0 ? 'Selection cleared' : text);
}
lastCount.set(root, text);
}
// With select-model the selection is the Livewire property, which keeps ticks from other pages too. The count
// comes from it, so it always says what a bulk action will get; null without one.
function modelSelection(root) {
const name = root.dataset.selectModel;
const value = name && inLivewire(root) ? wireFor(root)?.$get(name) : undefined;
return Array.isArray(value) ? [...value].map(String) : null;
}
// Clear, and unticking select-all, clear the rows on other pages too, as they do out-of-view rows without Livewire.
function clearModel(root) {
const name = root.dataset.selectModel;
if (name && modelSelection(root)) {
wireFor(root).$set(name, [], false);
}
}
// Returns the boxes that changed.
function setBoxes(root, checked) {
return [...root.querySelectorAll('[data-table-select]')].filter((box) => {
const changed = box.checked !== checked;
box.checked = checked;
return changed;
});
}
// Setting .checked fires no event, so wire:model (select-model) and any script of yours would never hear of it.
// Fired once every box is set. wire:model takes them in one at a time, so the count waits for the last (notifying).
let notifying = false;
function notifyChanged(boxes) {
notifying = true;
try {
boxes.forEach((box) => box.dispatchEvent(new Event('change', { bubbles: true })));
} finally {
notifying = false;
}
}
document.addEventListener('change', (event) => {
const root = event.target.closest?.('[data-table-root]');
if (!root || notifying) {
return;
}
if (event.target.matches('[data-table-select-all]')) {
const changed = setBoxes(root, event.target.checked);
notifyChanged(changed);
// Clearing all clears the rows out of view too, not just the ones on screen.
if (!event.target.checked) {
root.querySelectorAll('[data-table-kept]').forEach((input) => input.remove());
clearModel(root);
}
}
if (event.target.matches('[data-table-select-all], [data-table-select]')) {
syncSelection(root, { announceCount: true });
}
});
document.addEventListener('click', (event) => {
const clear = event.target.closest?.('[data-table-clear]');
const root = clear?.closest('[data-table-root]');
if (root) {
const changed = setBoxes(root, false);
root.querySelectorAll('[data-table-kept]').forEach((input) => input.remove());
notifyChanged(changed);
clearModel(root);
syncSelection(root, { announceCount: true });
[...root.querySelectorAll('[data-table-select-all]')].find((all) => all.offsetParent !== null)?.focus();
}
});
// --- Stacked cards on phones --------------------------------------------------------------------------
// Each cell shows its column name before the value (CSS reads data-label). Cells you labelled yourself keep theirs.
function labelCells(root) {
const headers = [...root.querySelectorAll('thead th')].map((th) => th.dataset.label ?? '');
// The totals rows too, which are yours to write.
root.querySelectorAll('tbody tr[data-table-row], tfoot tr').forEach((row) => {
[...row.children].forEach((cell, i) => {
if (!cell.hasAttribute('data-label')) {
cell.dataset.label = headers[i] ?? '';
}
});
});
}
const stackedRows = new MutationObserver((records) => {
new Set(records.map((record) => record.target.closest('[data-table-root][data-stack]')).filter(Boolean)).forEach(labelCells);
});
// A row with href is clicked as a whole, but a <tr> can't be a link: screen readers would call it a row and not
// say where it goes. So each gets a real <a> in its first cell (not the checkbox cell), visually hidden, as its
// Tab stop; Enter, Ctrl+Enter and the context menu then work as on any link. The row draws the focus ring.
function linkRows(root) {
root.querySelectorAll('tr[data-href]:not([data-row-linked])').forEach((row) => {
const cell = [...row.children].find((td) => !td.hasAttribute('data-no-row-click'));
if (!cell) {
return;
}
const link = Object.assign(document.createElement('a'), {
href: row.dataset.href,
className: 'sr-only',
textContent: row.dataset.linkLabel || cell.textContent.trim().replace(/\s+/g, ' ') || 'Open',
});
link.dataset.rowLink = '';
cell.prepend(link);
row.dataset.rowLinked = '';
});
}
// Runs on load, on every table swapped in by sorting or paging, and after every Livewire render.
function enhance(root) {
initThumb(root);
linkRows(root);
syncSelection(root);
applyColumns(root);
if (root.hasAttribute('data-stack')) {
labelCells(root);
const tbody = root.querySelector('tbody');
if (tbody) {
stackedRows.observe(tbody, { childList: true });
}
}
}
// --- Columns menu (hideable columns) ------------------------------------------------------------------
// Which columns someone hid, by table id, for this visit only: nothing is stored, so a reload shows the defaults.
// Positions (the checkbox column counts), which stay the same across pages, sorts and filters.
const hiddenColumns = new Map();
// The menu's ticks set data-hide on the table (CSS in table/index hides those cells). Run on load and after every
// swap or Livewire render, which bring the server's defaults back.
function applyColumns(root) {
const menu = root.id ? document.getElementById(`${root.id}-columns`) : null;
const table = root.querySelector('table');
if (!menu || !table) {
return;
}
const boxes = [...menu.querySelectorAll('input[data-table-column]')];
const chosen = hiddenColumns.get(root.id);
if (chosen) {
boxes.forEach((box) => {
box.checked = !chosen.has(box.dataset.tableColumn);
});
}
const hidden = boxes.filter((box) => !box.checked).map((box) => `c${box.dataset.tableColumn}`);
if (hidden.length) {
table.dataset.hide = hidden.join(' ');
} else {
table.removeAttribute('data-hide');
}
// At least one column stays: with no fixed column, the last ticked box can't be unticked.
const shown = boxes.filter((box) => box.checked);
boxes.forEach((box) => {
box.disabled = Number(menu.dataset.fixed) === 0 && shown.length === 1 && box.checked;
});
// The table's width changed: sideways scrolling and the scrollbars may have too.
root.querySelectorAll('[data-table-scroll]').forEach(syncScrollable);
syncThumb(root);
}
document.addEventListener('change', (event) => {
const box = event.target.closest?.('input[data-table-column]');
const menu = box?.closest('[data-table-columns]');
const root = menu?.closest('[data-table-root][id]');
if (!root) {
return;
}
hiddenColumns.set(root.id, new Set([...menu.querySelectorAll('input[data-table-column]')].filter((input) => !input.checked).map((input) => input.dataset.tableColumn)));
applyColumns(root);
});
// The menu is a popover (top layer, fixed), placed under its button, its right edge on the button's, and kept there
// while the page scrolls or resizes; beforetoggle doesn't bubble, so it's caught on the way down.
function placeColumnsMenu(menu) {
const button = document.querySelector(`[popovertarget="${CSS.escape(menu.id)}"]`);
if (!button) {
return;
}
const anchor = button.getBoundingClientRect();
const left = Math.max(8, Math.min(anchor.right - menu.offsetWidth, window.innerWidth - menu.offsetWidth - 8));
const below = anchor.bottom + 8;
const top = below + menu.offsetHeight > window.innerHeight - 8 ? Math.max(8, anchor.top - menu.offsetHeight - 8) : below;
menu.style.inset = 'auto';
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
// Invisible until here (see table/index), so it never flashes where the browser first puts it.
menu.dataset.placed = '';
}
const placing = new WeakMap();
document.addEventListener('toggle', (event) => {
const menu = event.target;
if (!(menu instanceof HTMLElement) || !menu.matches('[data-table-columns]')) {
return;
}
const previous = placing.get(menu);
if (previous) {
window.removeEventListener('scroll', previous, true);
window.removeEventListener('resize', previous);
placing.delete(menu);
delete menu.dataset.placed;
}
if (event.newState === 'open') {
const place = () => placeColumnsMenu(menu);
placing.set(menu, place);
place();
window.addEventListener('scroll', place, true);
window.addEventListener('resize', place);
menu.querySelector('input:not(:disabled)')?.focus();
}
}, true);
const ready = new WeakSet();
// rerender: Livewire has just morphed the table, which undid this script's changes to it; do them again.
function setUp(root, { rerender = false } = {}) {
if (ready.has(root) && !rerender) {
return;
}
ready.add(root);
if (rerender) {
restoreRowState(root);
}
watchScrollables(root);
enhance(root);
if (root.hasAttribute('data-infinite')) {
initWindow(root);
}
}
document.querySelectorAll('[data-table-root]').forEach((root) => setUp(root));
// --- Livewire renders ---------------------------------------------------------------------------------
// A morph brings each table back to the server's markup: rows keep their checkbox elements but maybe not their
// values (without wire:key, rows are matched by position), open rows close, and the rows an infinite table
// loaded itself go. So the ticked values and open rows are noted before it and put back after.
const saved = new WeakMap();
const modelled = (box) => [...box.attributes].some((attribute) => attribute.name.startsWith('wire:model'));
// wire:key when the row has one, since it names the record; otherwise what the row links to or submits.
const rowKey = (row) => row.getAttribute('wire:key') ?? row.dataset.href ?? row.querySelector('[data-table-select]')?.value ?? null;
function saveRowState(root) {
// A new page or order: nothing carries over, and reused checkboxes mustn't stay ticked on other rows.
if (resetting.has(root)) {
saved.set(root, { ticked: [], open: new Set() });
return;
}
const boxes = [...root.querySelectorAll('[data-table-select]')].filter((box) => !modelled(box));
const ticked = [
...boxes.filter((box) => box.checked).map((box) => ({ value: box.value, name: box.name, form: box.getAttribute('form') })),
...[...root.querySelectorAll('[data-table-kept]')].map((input) => ({ value: input.value, name: input.name, form: input.getAttribute('form') })),
];
const open = new Set([...root.querySelectorAll('tr[data-expandable][aria-expanded="true"]')].map(rowKey).filter(Boolean));
saved.set(root, { ticked, open });
}
function restoreRowState(root) {
const state = saved.get(root);
saved.delete(root);
if (!state) {
return;
}
const values = new Set(state.ticked.map((box) => box.value));
const shown = new Set();
root.querySelectorAll('[data-table-select]').forEach((box) => {
if (!modelled(box)) {
box.checked = values.has(box.value);
shown.add(box.value);
}
});
// An infinite table is about to load its neighbouring rows again; ticked ones among them are ticked again
// from these (restoreSelection), as when they scroll back into view.
if (root.hasAttribute('data-infinite')) {
state.ticked.filter((box) => !shown.has(box.value)).forEach((box) => {
const kept = Object.assign(document.createElement('input'), { type: 'hidden', name: box.name, value: box.value });
kept.setAttribute('form', box.form ?? '');
kept.dataset.tableKept = '';
root.append(kept);
});
}
// Open straight away, without the expand transition: to the user the row never closed.
root.querySelectorAll('tr[data-expandable]').forEach((row) => {
const details = document.getElementById(row.getAttribute('aria-controls'));
if (details && state.open.has(rowKey(row))) {
row.setAttribute('aria-expanded', 'true');
row.toggleAttribute('data-expanded', true);
details.hidden = false;
details.inert = false;
details.setAttribute('data-open', '');
}
});
}
// Only the component's own tables: a child component's are left alone by its parent's morph.
const tablesOf = (component) => [...component.querySelectorAll('[data-table-root]')].filter((root) => root.closest('[wire\\:id]') === component);
// Livewire 4 islands morph just the part of the component between two markers.
const tablesBetween = (start, end, component) => tablesOf(component).filter((root) => (start.compareDocumentPosition(root) & Node.DOCUMENT_POSITION_FOLLOWING) && (end.compareDocumentPosition(root) & Node.DOCUMENT_POSITION_PRECEDING));
function hookIntoLivewire(Livewire) {
Livewire.hook('morph', ({ el }) => tablesOf(el).forEach(saveRowState));
Livewire.hook('morphed', ({ el }) => tablesOf(el).forEach((root) => setUp(root, { rerender: true })));
Livewire.hook('island.morph', ({ startNode, endNode, component }) => tablesBetween(startNode, endNode, component.el).forEach(saveRowState));
Livewire.hook('island.morphed', ({ startNode, endNode, component }) => tablesBetween(startNode, endNode, component.el).forEach((root) => setUp(root, { rerender: true })));
}
// None of this runs on a page without Livewire.
if (window.Livewire) {
hookIntoLivewire(window.Livewire);
} else {
document.addEventListener('livewire:init', () => hookIntoLivewire(window.Livewire));
}
// wire:navigate swaps the page without reloading, so this file doesn't run again for the new page's tables.
document.addEventListener('livewire:navigated', () => document.querySelectorAll('[data-table-root]').forEach((root) => setUp(root)));
app/View/Widget/ElementIds.php Show
<?php
declare(strict_types=1);
namespace App\View\Widget;
use Illuminate\Container\Attributes\Scoped;
use LogicException;
/**
* Keeps element ids unique within one response, so labels, aria-describedby and #fragments
* always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
*/
#[Scoped]
final class ElementIds
{
/** @var array<string, true> */
private array $used = [];
/**
* Reserves an id for this response.
*
* A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
* id is one the caller chose and may reference from JS or CSS, so silently renaming it would
* break that reference; a duplicate throws instead, which surfaces in development and tests.
*/
public function claim(string $id, bool $explicit = false): string
{
if (!isset($this->used[$id])) {
return $this->reserve($id);
}
if ($explicit) {
throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
}
$suffix = 2;
while (isset($this->used["{$id}-{$suffix}"])) {
$suffix++;
}
return $this->reserve("{$id}-{$suffix}");
}
private function reserve(string $id): string
{
$this->used[$id] = true;
return $id;
}
}