<x-widget.table>
Table Blade component for Laravel and Livewire
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. It brings in dropdown, pagination, search and select for its row menu, pages and filters; the ones a table doesn't use stay unrendered, so they add nothing to your pages.
php artisan bladewell:add table
- Also adds
- Button, Dropdown, Icon, Pagination, Search, Select
- Works with
- Laravel 12 and 13, Livewire 3 and 4, Tailwind CSS v4, PHP 8.3+
- Guide
- Laravel data table with sorting, filters, pagination
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>
On the server
The table can't stop a request that skips it. Here's what to check on your server, and what to keep cheap.
Security
-
Pass
orderBy()and filters only columns from your own list, never the raw query string: an open sort lets anyone sort by, and so learn about, columns you don't show. -
Bulk actions send
selected[], which is whatever the browser chose. Load those rows through the person's own scope and authorize each, as for any other input.OWASP: Insecure direct object references · Laravel: Authorization
Server cost
-
Always paginate, and if a
per_pagecomes from the request, cap it (min(100, …)):?per_page=100000would otherwise load the whole table.Laravel: Pagination · OWASP API4: Unrestricted resource consumption
-
Offset pages slow down as they go deeper: the database reads and throws away every row before the page. Consider
cursorPaginate()for infinite scroll or big tables, andsimplePaginate()when you don't show a page count, which skips thecount(*)query. -
Filters re-run the query as people type, and option counts add a grouped count per filter. Index the columns you filter on, consider caching the counts for a minute, and
cache-forkeeps pages already fetched in the browser so going back doesn't ask again.
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. The menu is rendered once after the table (dropdown.menu) and each row has only its trigger, whose values fill the menu's :user as it opens, so a long table carries one copy of the menu instead of one per 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 menu="audit-actions" :label="'Actions for '.$event['action'].' by '.$event['user'].' at '.$event['time']" size="sm" :values="['user' => $event['user']]" />
</td>
</x-widget.table.row>
@endforeach
</x-widget.table>
<x-widget.dropdown.menu id="audit-actions" align="end">
<x-widget.dropdown.item icon="eye">View details</x-widget.dropdown.item>
<x-widget.dropdown.item icon="user">View :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.menu>
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'
|
The bulk form's method: GET, POST, PUT, PATCH or DELETE, in any case. |
| 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
|
A small dot in the tone's colour before the text; false leaves it out. |
<x-widget.table.row>
| Prop | Default | Description |
|---|---|---|
| href |
null
|
Makes the whole row a link to this URL: a click anywhere on it goes there (Ctrl/Cmd or middle click opens a new tab), and its first cell gets a real link for keyboards and screen readers. Not with a details slot. |
| 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: a click anywhere on it, or Enter or Space on its Details button (the chevron).
Accessibility
All 12 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.
Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.
Source
What bladewell: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
resources/views/components/widget/table/index.blade.php Show
resources/views/components/widget/table/row.blade.php Show
resources/js/widget/table/index.js Show
app/View/Widget/ElementIds.php Show