Data Table
A client-side table whose column headers open a sort and filter panel that knows the column's kind (A → Z, Low → high, Oldest → newest; conditions like contains, equals, between, before; a pick list of the column's values, committed with Apply), with active-filter chips, a Columns menu, row selection, layout-stable pagination, badge tones, row actions, inline edits, density, sticky header and expandable rows.
Preview
|
Sort Filter Values No values |
Filter Values No values |
Sort Filter Values No values |
Sort Filter Values No values |
|
|---|---|---|---|---|
|
No results Try adjusting your search or filters. |
||||
<x-ui.data-table class="max-w-2xl" />
Installation
php artisan ui:add data-table
Note
This component ships an Alpine behavior module at
resources/js/ui/data-table.js. Import it once from your bundle so it registers on alpine:init:
import './data-table.js';
Registry contract
php artisan ui:add data-table
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/data-table.blade.php -
resources/js/ui/data-table.js -
resources/views/components/ui/data-table/filters.blade.php -
resources/views/components/ui/data-table/columns.blade.php -
resources/views/components/ui/data-table/selection.blade.php -
resources/views/components/ui/data-table/expansion.blade.php -
resources/views/components/ui/data-table/pagination.blade.php
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Data Table (`data-table`)
A client-side table whose column headers open a sort and filter panel that knows the column's kind (A → Z, Low → high, Oldest → newest; conditions like contains, equals, between, before; a pick list of the column's values, committed with Apply), with active-filter chips, a Columns menu, row selection, layout-stable pagination, badge tones, row actions, inline edits, density, sticky header and expandable rows.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add data-table
```
## Usage
```blade
<x-ui.data-table class="max-w-2xl" />
```
## Props
- `columns` (array, default `[…]`) — Column definitions, labels, keys, and sorting behavior.
- `rows` (array, default `[…]`) — Serializable row records rendered by the table.
- `perPage` (int, default `5`) — Rows displayed per client-side page.
- `selectable` (bool, default `true`) — Leading checkbox column; the selection is keyed to row identity and reported through data-table:select.
- `density` (comfortable|compact, default `comfortable`) — Row height and cell padding.
- `stickyHeader` (bool, default `false`) — Keeps the header row visible while the body scrolls inside a capped height.
- `expandable` (bool, default `false`) — Leading disclosure column that opens a detail row under each record.
- `loading` (bool, default `false`) — Shows the shared announced loading row and blocks table interaction.
- `loadingLabel` (string, default `Loading data…`) — Declared by @props in the shipped Blade source.
- `emptyHeading` (string, default `No results`) — Heading for the shared empty row.
- `emptyDescription` (string, default `Try adjusting your search or filters.`) — Guidance for the shared empty row.
- `maxClientRows` (int, default `500`) — Hard client-side row limit. Use admin-table (server-driven) above this boundary.
- `columnToggle` (bool, default `false`) — Adds a Columns menu to the toolbar and a Hide column entry to each header panel.
- `hidden` (array, default `[]`) — Column keys folded away on first render.
- `layout` (auto|fixed, default `auto`) — fixed honours each column's width and keeps widths stable while filtering; auto sizes columns to their content.
- `sortable` (bool, default `true`) — Every column sorts from its header panel unless it sets columns[].sortable to false; false turns sorting off for the table.
- `filters` (bool, default `true`) — Every column filters from its header panel unless it sets columns[].filter to false; false turns filtering off for the table and hides the active-filter chips.
- `pageSize` (int, default `5`) — Rows shown per page.
- `label` (string, default `Filter rows`) — Declared by @props in the registry Blade source.
- `placeholder` (string, default `Filter…`) — Declared by @props in the registry Blade source.
## Use when
- Use when users need exact comparison across rows and columns, plus sorting or filtering.
- You hand the table its rows as data (at most a few hundred) and want sorting, filtering, paging, selection and inline edits to happen in the browser, with the host notified through events.
- Cells are scalar values a prop-driven renderer can express: text, mono, number, date, badge with a tone, icon actions.
- Column relationships matter more than a card-based presentation.
## Avoid when
- Do not use a table for narrative browsing or small mobile contexts where cards or lists are easier to scan.
- Use when filtering, sorting, selection, and row actions are first-class tasks.
- Rows live on the server, the set is large, or bulk actions must be authorized, idempotent and confirmed; use admin-table, whose cells are your Blade and whose sort, filter and bulk actions round-trip to the host.
- The data is primarily hierarchical; use tree or nested list patterns.
- Small-screen users need task-oriented summaries; consider cards or a purpose-built responsive block.
## Anti-patterns
- Using cards when exact comparison is primary
- Forcing wide tables into narrow layouts without adaptation
## Rules
- Use the `<brok:data-table>` tag (or `<x-ui.data-table>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/data-table
- Registry JSON (files, props, contract): https://brokui.dev/r/open/data-table.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
<div class="w-full">
<x-ui.data-table density="compact" class="max-w-2xl" />
</div>
<x-ui.data-table :rows="[]" class="max-w-2xl" />
Expandable
<div class="w-full">
<x-ui.data-table :expandable="true" class="max-w-2xl" />
</div>
{{-- Everything at once. Click a column header: sort reads by the column's kind
(A → Z, Low → high, Oldest → newest), the filter offers a condition
(contains, equals, between, before…) and the column's values to pick
from; active filters show as chips; a Columns menu; a badge column with
tones; a mono column; an editable column; row actions; the events a host
listens to. --}}
<div
x-data="{ log: '—' }"
@data-table:action="log = `${$event.detail.key} → ${$event.detail.row.name}`"
@data-table:edit="log = `${$event.detail.key} of ${$event.detail.row.name}: ${$event.detail.previous} → ${$event.detail.value}`"
class="w-full space-y-3"
>
<x-ui.data-table
:per-page="5"
:column-toggle="true"
:columns="[
['key' => 'id', 'label' => 'ID', 'type' => 'mono', 'width' => '6rem'],
['key' => 'name', 'label' => 'Customer', 'editable' => true],
['key' => 'plan', 'label' => 'Plan', 'filter' => 'select'],
['key' => 'status', 'label' => 'Status', 'filter' => 'select', 'type' => 'badge', 'tones' => ['Active' => 'positive', 'Trial' => 'info', 'Past due' => 'attention', 'Churned' => 'negative']],
['key' => 'mrr', 'label' => 'MRR', 'type' => 'number', 'align' => 'end', 'width' => '7rem'],
['key' => 'since', 'label' => 'Since', 'width' => '8rem'],
['key' => 'verified', 'label' => 'Verified', 'width' => '6rem'],
['key' => 'actions', 'label' => 'Actions', 'type' => 'actions', 'align' => 'end', 'rowLabel' => 'name', 'actions' => [
['key' => 'open', 'label' => 'Open', 'icon' => '<path d=\'M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\' />'],
['key' => 'archive', 'label' => 'Archive', 'icon' => '<rect x=\'2\' y=\'3\' width=\'20\' height=\'5\' rx=\'1\' /><path d=\'M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4\' />'],
['key' => 'delete', 'label' => 'Delete', 'destructive' => true, 'icon' => '<path d=\'M3 6h18\' /><path d=\'M8 6V4h8v2\' /><path d=\'M19 6l-1 14H6L5 6\' />'],
]],
]"
:rows="[
['id' => 'C-1042', 'name' => 'Jane Doe', 'plan' => 'Pro', 'status' => 'Active', 'mrr' => 49, 'since' => '2025-11-03', 'verified' => 'Yes'],
['id' => 'C-1043', 'name' => 'Alex Brown', 'plan' => 'Starter', 'status' => 'Trial', 'mrr' => 0, 'since' => '2026-09-01', 'verified' => 'No'],
['id' => 'C-1044', 'name' => 'Maria Garcia', 'plan' => 'Enterprise', 'status' => 'Past due', 'mrr' => 499, 'since' => '2024-02-14', 'verified' => 'Yes'],
['id' => 'C-1045', 'name' => 'Liam Smith', 'plan' => 'Pro', 'status' => 'Active', 'mrr' => 49, 'since' => '2025-06-20', 'verified' => 'Yes'],
['id' => 'C-1046', 'name' => 'Noah Johnson', 'plan' => 'Starter', 'status' => 'Churned', 'mrr' => 0, 'since' => '2024-10-09', 'verified' => 'No'],
['id' => 'C-1047', 'name' => 'Olivia Lee', 'plan' => 'Enterprise', 'status' => 'Active', 'mrr' => 899, 'since' => '2023-12-01', 'verified' => 'Yes'],
['id' => 'C-1048', 'name' => 'Emma Wilson', 'plan' => 'Pro', 'status' => 'Trial', 'mrr' => 0, 'since' => '2026-08-28', 'verified' => 'No'],
['id' => 'C-1049', 'name' => 'Sofia Martin', 'plan' => 'Starter', 'status' => 'Active', 'mrr' => 19, 'since' => '2025-03-15', 'verified' => 'Yes'],
]"
/>
<p class="text-xs text-muted-foreground">{{ __('Host received:') }} <span data-slot="preview-event-log" class="font-mono text-foreground" x-text="log"></span></p>
</div>
<x-ui.data-table :loading="true" class="max-w-2xl" />
{{-- Expandable rows, a badge column, a numeric column; headers open their sort and filter. --}}
<div class="w-full">
<x-ui.data-table
density="compact"
:expandable="true"
class="max-w-3xl"
:columns="[
['key' => 'name', 'label' => 'Name', 'sortable' => true],
['key' => 'email', 'label' => 'Email', 'sortable' => false],
['key' => 'status', 'label' => 'Status', 'sortable' => true, 'type' => 'badge', 'filter' => 'select', 'tones' => ['Active' => 'positive', 'Invited' => 'info', 'Suspended' => 'negative']],
['key' => 'visits', 'label' => 'Visits', 'sortable' => true, 'align' => 'end', 'type' => 'number', 'filter' => 'number'],
]"
:rows="[
['name' => 'Jane Doe', 'email' => 'jane@example.com', 'status' => 'Active', 'visits' => '1,204'],
['name' => 'Alex Brown', 'email' => 'alex@example.com', 'status' => 'Invited', 'visits' => '320'],
['name' => 'Maria Garcia', 'email' => 'maria@example.com', 'status' => 'Suspended', 'visits' => '57'],
['name' => 'Liam Smith', 'email' => 'liam@example.com', 'status' => 'Active', 'visits' => '889'],
['name' => 'Noah Johnson', 'email' => 'noah@example.com', 'status' => 'Active', 'visits' => '2,431'],
['name' => 'Olivia Lee', 'email' => 'olivia@example.com', 'status' => 'Invited', 'visits' => '14'],
]"
/>
</div>
<div class="w-full">
<x-ui.data-table
:sticky-header="true"
:per-page="20"
class="max-w-2xl"
:rows="[
['name' => 'Jane Doe', 'email' => 'jane@example.com', 'role' => 'Admin', 'status' => 'Active'],
['name' => 'Alex Brown', 'email' => 'alex@example.com', 'role' => 'Member', 'status' => 'Active'],
['name' => 'Maria Garcia', 'email' => 'maria@example.com', 'role' => 'Member', 'status' => 'Invited'],
['name' => 'Liam Smith', 'email' => 'liam@example.com', 'role' => 'Editor', 'status' => 'Active'],
['name' => 'Noah Johnson', 'email' => 'noah@example.com', 'role' => 'Member', 'status' => 'Suspended'],
['name' => 'Olivia Lee', 'email' => 'olivia@example.com', 'role' => 'Admin', 'status' => 'Active'],
['name' => 'Emma Wilson', 'email' => 'emma@example.com', 'role' => 'Editor', 'status' => 'Invited'],
['name' => 'Sofia Martin', 'email' => 'sofia@example.com', 'role' => 'Member', 'status' => 'Active'],
['name' => 'Lucas Davis', 'email' => 'lucas@example.com', 'role' => 'Member', 'status' => 'Active'],
['name' => 'Mia Rodriguez', 'email' => 'mia@example.com', 'role' => 'Editor', 'status' => 'Invited'],
['name' => 'Ethan Clark', 'email' => 'ethan@example.com', 'role' => 'Admin', 'status' => 'Suspended'],
['name' => 'Ava Lewis', 'email' => 'ava@example.com', 'role' => 'Member', 'status' => 'Active'],
]"
/>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | array | […] | Column definitions, labels, keys, and sorting behavior. |
| rows | array | […] | Serializable row records rendered by the table. |
| perPage | int | 5 | Rows displayed per client-side page. |
| selectable | bool | true | Leading checkbox column; the selection is keyed to row identity and reported through data-table:select. |
| density | comfortable | compact | comfortable | Row height and cell padding. |
| stickyHeader | bool | false | Keeps the header row visible while the body scrolls inside a capped height. |
| expandable | bool | false | Leading disclosure column that opens a detail row under each record. |
| loading | bool | false | Shows the shared announced loading row and blocks table interaction. |
| loadingLabel | string | Loading data… | Declared by @props in the shipped Blade source. |
| emptyHeading | string | No results | Heading for the shared empty row. |
| emptyDescription | string | Try adjusting your search or filters. | Guidance for the shared empty row. |
| maxClientRows | int | 500 | Hard client-side row limit. Use admin-table (server-driven) above this boundary. |
| columnToggle | bool | false | Adds a Columns menu to the toolbar and a Hide column entry to each header panel. |
| hidden | array | [] | Column keys folded away on first render. |
| layout | auto | fixed | auto | fixed honours each column's width and keeps widths stable while filtering; auto sizes columns to their content. |
| sortable | bool | true | Every column sorts from its header panel unless it sets columns[].sortable to false; false turns sorting off for the table. |
| filters | bool | true | Every column filters from its header panel unless it sets columns[].filter to false; false turns filtering off for the table and hides the active-filter chips. |
| pageSize | int | 5 | Rows shown per page. |
| label | string | Filter rows | Declared by @props in the registry Blade source. |
| placeholder | string | Filter… | Declared by @props in the registry Blade source. |
Slots
default— Compound mode. Compose filters, columns, selection, expansion, and pagination parts explicitly.x-ui.data-table.filters— Installed subcomponent from the registry item.x-ui.data-table.columns— Installed subcomponent from the registry item.x-ui.data-table.selection— Installed subcomponent from the registry item.x-ui.data-table.expansion— Installed subcomponent from the registry item.x-ui.data-table.pagination— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Provides a compatibility preset and an explicit compound mode for client-side tables of at most 500 rows by default.
- Selection state remains keyed to row identity rather than visual position.
- Uses the shared Table State row for announced loading and empty results.
- Fails before rendering when the client row limit is exceeded and directs the application to Livewire Data Table.
- Client-side sorting, filtering, row selection, and pagination powered by Alpine.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Compare structured records across consistent attributes.
Use when
- Use when users need exact comparison across rows and columns, plus sorting or filtering.
- You hand the table its rows as data (at most a few hundred) and want sorting, filtering, paging, selection and inline edits to happen in the browser, with the host notified through events.
- Cells are scalar values a prop-driven renderer can express: text, mono, number, date, badge with a tone, icon actions.
- Column relationships matter more than a card-based presentation.
Avoid when
- Do not use a table for narrative browsing or small mobile contexts where cards or lists are easier to scan.
- Use when filtering, sorting, selection, and row actions are first-class tasks.
- Rows live on the server, the set is large, or bulk actions must be authorized, idempotent and confirmed; use admin-table, whose cells are your Blade and whose sort, filter and bulk actions round-trip to the host.
- The data is primarily hierarchical; use tree or nested list patterns.
- Small-screen users need task-oriented summaries; consider cards or a purpose-built responsive block.
Use instead
- List or cards for narrative browsing
Anti-patterns
- Using cards when exact comparison is primary
- Forcing wide tables into narrow layouts without adaptation
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Enter Escape
- Focus
managed
- Column sort controls expose the current sort direction.
- Selection checkboxes need row-specific accessible names.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="data-table-{{ $record->id }}">
<x-ui.data-table class="max-w-2xl" />
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:data-table
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'columns' => [
['key' => 'name', 'label' => 'Name', 'sortable' => true],
['key' => 'email', 'label' => 'Email', 'sortable' => false],
['key' => 'role', 'label' => 'Role', 'sortable' => true],
['key' => 'status', 'label' => 'Status', 'sortable' => true],
],
'rows' => [
['name' => 'Jane Doe', 'email' => 'jane@example.com', 'role' => 'Admin', 'status' => 'Active'],
['name' => 'Alex Brown', 'email' => 'alex@example.com', 'role' => 'Member', 'status' => 'Active'],
['name' => 'Maria Garcia', 'email' => 'maria@example.com', 'role' => 'Member', 'status' => 'Invited'],
['name' => 'Liam Smith', 'email' => 'liam@example.com', 'role' => 'Editor', 'status' => 'Active'],
['name' => 'Noah Johnson', 'email' => 'noah@example.com', 'role' => 'Member', 'status' => 'Suspended'],
['name' => 'Olivia Lee', 'email' => 'olivia@example.com', 'role' => 'Admin', 'status' => 'Active'],
['name' => 'Emma Wilson', 'email' => 'emma@example.com', 'role' => 'Editor', 'status' => 'Invited'],
['name' => 'Sofia Martin', 'email' => 'sofia@example.com', 'role' => 'Member', 'status' => 'Active'],
],
'perPage' => 5,
'selectable' => true,
'density' => 'comfortable',
'stickyHeader' => false,
'expandable' => false,
'loading' => false,
'loadingLabel' => 'Loading data…',
'emptyHeading' => 'No results',
'emptyDescription' => 'Try adjusting your search or filters.',
'maxClientRows' => 500,
// A "Columns" menu that folds columns away (columns with hideable=false stay).
'columnToggle' => false,
// Column keys hidden on load.
'hidden' => [],
// auto | fixed — fixed keeps column widths stable across pages and sorts
// (set `width` per column, e.g. '12rem' or '20%').
'layout' => 'auto',
// Every column sorts and filters from its header panel unless a column
// says otherwise (`sortable => false`, `filter => false`). Turn either off
// for the whole table here; with filters off the chip row is gone too.
'sortable' => true,
'filters' => true,
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
// Density → fixed cell padding (no variable splicing into arbitrary classes),
// and one row height per density so a text row, a badge row, an actions row
// and a filler row are all the same height (no layout shift on paging).
$cellPad = $density === 'compact' ? 'px-2 py-1' : 'px-3 py-2';
$rowHeight = $density === 'compact' ? '[&_tbody_tr]:h-10' : '[&_tbody_tr]:h-12';
// Column alignment → fixed logical-property text classes (RTL-safe).
$alignMap = [
'start' => 'text-start',
'center' => 'text-center',
'end' => 'text-end',
];
// Detail-row colspan must cover selection + expand toggle + data columns.
$totalCols = ($selectable ? 1 : 0) + ($expandable ? 1 : 0) + count($columns);
// Resolve each column's data kind (text, number, date, boolean) so the
// header panel offers the right sort labels and conditions: an explicit
// `filter` kind wins, then the render `type`, then the values themselves.
$rowValues = array_values($rows);
$inferKind = static function (string $key) use ($rowValues): string {
$values = array_values(array_filter(array_map(static fn ($r) => is_array($r) ? ($r[$key] ?? null) : null, $rowValues), static fn ($v) => $v !== null && $v !== ''));
if ($values === []) {
return 'text';
}
$all = static fn (callable $test): bool => count(array_filter($values, $test)) === count($values);
if ($all(static fn ($v) => is_bool($v) || in_array(mb_strtolower((string) $v), ['yes', 'no', 'true', 'false'], true))) {
return 'boolean';
}
if ($all(static fn ($v) => is_int($v) || is_float($v) || (is_string($v) && preg_match('/^-?\d+([.,]\d+)?$/', $v) === 1))) {
return 'number';
}
if ($all(static fn ($v) => is_string($v) && preg_match('/^\d{4}-\d{2}-\d{2}/', $v) === 1)) {
return 'date';
}
return 'text';
};
$columns = array_map(static function (array $col) use ($inferKind, $sortable, $filters): array {
$type = $col['type'] ?? 'text';
$filter = $col['filter'] ?? $filters;
$explicitKind = is_string($filter) && in_array($filter, ['text', 'number', 'date', 'boolean'], true) ? $filter : null;
$kind = $explicitKind ?? ($type === 'number' ? 'number' : $inferKind((string) $col['key']));
$col['kind'] = $kind;
$col['sortable'] = $type !== 'actions' && (bool) ($col['sortable'] ?? $sortable);
$col['filterable'] = $type !== 'actions' && $filter !== false && $filter !== null;
// A boolean or `filter => 'select'` column offers only its value list.
$col['valuesOnly'] = $kind === 'boolean' || $filter === 'select';
return $col;
}, array_values($columns));
$hasColumnFilters = collect($columns)->contains(static fn (array $c): bool => $c['filterable']);
// Sort labels per kind and the condition labels, translated once.
$sortLabels = [
'text' => [__('A → Z'), __('Z → A')],
'number' => [__('Low → high'), __('High → low')],
'date' => [__('Oldest → newest'), __('Newest → oldest')],
'boolean' => [__('No → Yes'), __('Yes → No')],
];
$opLabels = [
'contains' => __('contains'), 'notContains' => __('does not contain'), 'equals' => __('equals'), 'notEquals' => __('does not equal'),
'startsWith' => __('starts with'), 'endsWith' => __('ends with'), 'empty' => __('is empty'), 'notEmpty' => __('is not empty'),
'eq' => __('equals'), 'neq' => __('does not equal'), 'gt' => __('greater than'), 'gte' => __('at least'), 'lt' => __('less than'), 'lte' => __('at most'),
'between' => __('between'), 'on' => __('on'), 'before' => __('before'), 'after' => __('after'),
];
$fieldClass = 'h-8 w-full min-w-0 rounded-sm border border-input bg-background px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring';
$panelItem = 'flex min-h-8 w-full items-center gap-2 rounded-sm px-2 py-1 text-start text-sm text-popover-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none';
$layout = $layout === 'fixed' ? 'fixed' : 'auto';
// Badge tones for `type => 'badge'` columns: the badge recipe's soft
// variants keyed by the semantic tone a `tones` map speaks.
$badge = $styles['badge'] ?? [];
$toneClasses = [];
foreach (['positive', 'attention', 'negative', 'info', 'neutral'] as $toneName) {
$variant = $badge['tones'][$toneName] ?? 'soft-neutral';
$toneClasses[$toneName] = trim(($badge['base'] ?? '').' '.($badge['variants'][$variant] ?? '').' '.($badge['sizes']['sm'] ?? ''));
}
// Column keys → a Blade-safe id suffix for the filter inputs.
$uid = 'data-table-'.\Illuminate\Support\Str::random(6);
if (count($rows) > (int) $maxClientRows) {
throw new \InvalidArgumentException(__('Data Table supports at most :count client-side rows. Use Livewire Data Table for larger datasets.', ['count' => (int) $maxClientRows]));
}
@endphp
<div
x-data="uiDataTable({ rows: @js(array_values($rows)), columns: @js(array_values($columns)), perPage: {{ (int) $perPage }}, selectable: {{ $selectable ? 'true' : 'false' }}, expandable: {{ $expandable ? 'true' : 'false' }}, hidden: @js(array_values((array) $hidden)), toneClasses: @js($toneClasses), labels: @js($opLabels) })"
data-slot="data-table"
data-max-client-rows="{{ (int) $maxClientRows }}"
@if ($loading) aria-busy="true" @endif
{{ $attributes->merge(['class' => 'w-full space-y-4']) }}
>
<noscript>
<p data-slot="data-table-noscript" role="status" class="break-words rounded-md border border-border bg-muted px-4 py-3 text-sm text-foreground">
{{ __('Client Data Table needs JavaScript. Use Livewire Data Table when a complete no-JavaScript table is required.') }}
</p>
</noscript>
@if (! $slot->isEmpty())
{{ $slot }}
@else
{{-- Toolbar: free-text filter across all columns, the per-column filter
toggle, the Columns menu and the result count. --}}
<div class="flex flex-wrap items-center gap-2">
<div class="relative w-full max-w-xs">
<svg class="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input
type="search"
x-model="filter"
@input="onFilter()"
aria-label="{{ __('Filter rows') }}"
placeholder="{{ __('Filter…') }}"
@disabled($loading)
class="h-9 w-full rounded-md border border-input bg-background ps-9 pe-3 text-sm text-foreground shadow-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background motion-reduce:transition-none"
/>
</div>
@if ($columnToggle)
<x-ui.dropdown data-slot="data-table-columns-menu">
<x-ui.dropdown.trigger class="inline-flex h-8 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm font-medium text-foreground shadow-xs transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 3v18M15 3v18" /></svg>
{{ __('Columns') }}
</x-ui.dropdown.trigger>
<x-ui.dropdown.content align="end" class="min-w-[12rem]">
<x-ui.dropdown.label>{{ __('Show columns') }}</x-ui.dropdown.label>
@foreach ($columns as $col)
@if (($col['hideable'] ?? true) && ($col['type'] ?? 'text') !== 'actions')
<label class="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-2 text-sm text-popover-foreground hover:bg-accent">
<x-ui.checkbox
role="checkbox"
x-bind:checked="isVisible({{ \Illuminate\Support\Js::from($col['key']) }})"
@change="toggleColumn({{ \Illuminate\Support\Js::from($col['key']) }})"
/>
<span>{{ __($col['label']) }}</span>
</label>
@endif
@endforeach
</x-ui.dropdown.content>
</x-ui.dropdown>
@endif
<span class="ms-auto min-w-0 max-w-full break-words text-end text-sm tabular-nums text-muted-foreground" x-text="`${sorted().length} {{ __('result(s)') }}`"></span>
</div>
@if ($hasColumnFilters)
{{-- Active column filters as chips; each clears on its ×. --}}
<div data-slot="data-table-active-filters" class="flex flex-wrap items-center gap-2" x-show="activeFilterCount() > 0" x-cloak>
@foreach ($columns as $col)
@if ($col['filterable'])
<span x-show="hasFilter({{ \Illuminate\Support\Js::from($col['key']) }})" class="inline-flex h-7 max-w-full items-center gap-1 rounded-full border border-border bg-muted ps-3 pe-1 text-xs text-foreground">
<span class="text-muted-foreground">{{ __($col['label']) }}:</span>
<span class="truncate" x-text="filterSummary({{ \Illuminate\Support\Js::from($col) }})"></span>
<button type="button" @click="clearFilter({{ \Illuminate\Support\Js::from($col['key']) }})" aria-label="{{ __('Clear :column filter', ['column' => $col['label']]) }}" class="inline-flex size-5 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none">
<svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</span>
@endif
@endforeach
<button type="button" @click="clearFilters()" class="text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">{{ __('Clear all') }}</button>
</div>
@endif
<div @class(['rounded-md border border-border', 'max-h-96 overflow-auto' => $stickyHeader])>
<x-ui.table :class="trim($rowHeight.' '.($layout === 'fixed' ? 'table-fixed' : ''))">
<x-ui.table.header @class(['sticky top-0 z-10 bg-card' => $stickyHeader])>
<x-ui.table.row>
@if ($expandable)
<x-ui.table.head class="w-px {{ $cellPad }}">
<span class="sr-only">{{ __('Expand row') }}</span>
</x-ui.table.head>
@endif
@if ($selectable)
<x-ui.table.head @class([$cellPad, '[&:has([role=checkbox])]:w-px' => $layout !== 'fixed', '!w-12' => $layout === 'fixed', 'bg-card' => $stickyHeader])>
<x-ui.checkbox
role="checkbox"
@change="toggleAll($event.target.checked)"
x-bind:checked="allSelected()"
:disabled="$loading"
aria-label="{{ __('Select all rows') }}"
/>
</x-ui.table.head>
@endif
@foreach ($columns as $col)
@php $alignClass = $alignMap[$col['align'] ?? ''] ?? null; @endphp
<x-ui.table.head
@class([$cellPad, $alignClass, 'bg-card' => $stickyHeader, 'w-px' => ($col['type'] ?? '') === 'actions' && $layout !== 'fixed'])
:style="! empty($col['width']) ? 'width: '.$col['width'] : ((($col['type'] ?? '') === 'actions' && $layout === 'fixed') ? 'width: '.(count((array) ($col['actions'] ?? [])) * 2.25 + 1.5).'rem' : null)"
x-show="isVisible({{ \Illuminate\Support\Js::from($col['key']) }})"
x-bind:aria-sort="ariaSort({{ \Illuminate\Support\Js::from($col['key']) }})"
>
@php
$key = \Illuminate\Support\Js::from($col['key']);
$colJs = \Illuminate\Support\Js::from($col);
$canSort = $col['sortable'];
$canFilter = $col['filterable'];
$hasMenu = $canSort || $canFilter;
$panelId = $uid.'-menu-'.$col['key'];
[$ascLabel, $descLabel] = $sortLabels[$col['kind']] ?? $sortLabels['text'];
@endphp
@if ($hasMenu)
{{-- The header opens a panel with the column's sort and filter; a
sort arrow and a filter dot on the label show what is active. --}}
<x-ui.popover group="{{ $uid }}-headers" anchored class="!max-w-none">
<x-ui.popover.trigger
class="group/head inline-flex h-8 !max-w-none items-center gap-1 whitespace-nowrap rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none {{ ($col['align'] ?? '') === 'end' ? '-me-2 flex-row-reverse' : '-ms-2' }}"
aria-label="{{ $canFilter ? __(':column: sort and filter', ['column' => $col['label']]) : __(':column: sort', ['column' => $col['label']]) }}"
x-bind:data-active="(sortKey === {{ $key }} || hasFilter({{ $key }})) ? 'true' : 'false'"
>
<span>{{ __($col['label']) }}</span>
@if ($canSort)
<svg x-show="sortKey !== {{ $key }}" class="size-3 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 15 5 5 5-5M7 9l5-5 5 5" /></svg>
<svg x-show="sortKey === {{ $key }} && sortDir === 'asc'" x-cloak class="size-3 shrink-0 text-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
<svg x-show="sortKey === {{ $key }} && sortDir === 'desc'" x-cloak class="size-3 shrink-0 text-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12l7 7 7-7" /></svg>
@endif
@if ($canFilter)
<svg x-show="hasFilter({{ $key }})" x-cloak class="size-3 shrink-0 text-foreground" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" /></svg>
@endif
</x-ui.popover.trigger>
<x-ui.popover.content align="{{ ($col['align'] ?? '') === 'end' ? 'end' : 'start' }}" class="w-64 p-1" x-init="$watch('open', (value) => value && beginFilter({{ $key }}))">
@if ($canSort)
<p class="px-2 pt-1 pb-1 text-2xs font-semibold uppercase tracking-wide text-muted-foreground">{{ __('Sort') }}</p>
<button type="button" @click="setSort({{ $key }}, 'asc'); closeAndFocus()" class="{{ $panelItem }}" x-bind:aria-pressed="sortKey === {{ $key }} && sortDir === 'asc' ? 'true' : 'false'">
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
<span class="flex-1">{{ $ascLabel }}</span>
<svg x-show="sortKey === {{ $key }} && sortDir === 'asc'" x-cloak class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5L20 7" /></svg>
</button>
<button type="button" @click="setSort({{ $key }}, 'desc'); closeAndFocus()" class="{{ $panelItem }}" x-bind:aria-pressed="sortKey === {{ $key }} && sortDir === 'desc' ? 'true' : 'false'">
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12l7 7 7-7" /></svg>
<span class="flex-1">{{ $descLabel }}</span>
<svg x-show="sortKey === {{ $key }} && sortDir === 'desc'" x-cloak class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5L20 7" /></svg>
</button>
@endif
@if ($canFilter)
@if ($canSort)<div class="my-1 h-px bg-border" role="none"></div>@endif
@unless ($col['valuesOnly'])
<p class="px-2 pt-1 pb-1 text-2xs font-semibold uppercase tracking-wide text-muted-foreground">{{ __('Filter') }}</p>
{{-- A condition: the kind's operators, then one or two operands. --}}
<div class="flex flex-col gap-2 px-2 pb-2">
<x-ui.select size="sm" x-model="drafts[{{ $key }}].op" @change="setOperator({{ $key }}, $event.target.value)" aria-label="{{ __(':column condition', ['column' => $col['label']]) }}" class="!h-8 !shadow-none">
<template x-for="op in operators({{ $colJs }})" :key="op">
<option x-bind:value="op" x-text="labels[op] ?? op"></option>
</template>
</x-ui.select>
<div class="flex items-center gap-2" x-show="needsValue(drafts[{{ $key }}].op)">
<input
type="{{ $col['kind'] === 'number' ? 'number' : ($col['kind'] === 'date' ? 'date' : 'search') }}"
x-model="drafts[{{ $key }}].value"
@keydown.enter.prevent="applyFilter({{ $key }}); closeAndFocus()"
x-bind:aria-label="needsRange(drafts[{{ $key }}].op) ? '{{ __('From') }}' : '{{ __('Value') }}'"
x-bind:placeholder="needsRange(drafts[{{ $key }}].op) ? '{{ __('From') }}' : '{{ __('Value…') }}'"
class="{{ $fieldClass }}"
/>
<input
type="{{ $col['kind'] === 'number' ? 'number' : ($col['kind'] === 'date' ? 'date' : 'search') }}"
x-show="needsRange(drafts[{{ $key }}].op)"
x-model="drafts[{{ $key }}].value2"
@keydown.enter.prevent="applyFilter({{ $key }}); closeAndFocus()"
aria-label="{{ __('Until') }}"
placeholder="{{ __('Until') }}"
class="{{ $fieldClass }}"
/>
</div>
</div>
@endunless
{{-- The value list: pick any of the column's values, Excel-style. --}}
<div class="flex flex-col" data-slot="data-table-values">
@unless ($col['valuesOnly'])<div class="my-1 h-px bg-border" role="none"></div>@endunless
<div class="flex items-center justify-between gap-3 px-2 pt-1 pb-1">
<p class="text-2xs font-semibold uppercase tracking-wide text-muted-foreground">{{ $col['valuesOnly'] ? __('Filter') : __('Values') }}</p>
<span class="flex items-center gap-3 text-xs">
<button type="button" @click="setAllValues({{ $key }}, true)" class="text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">{{ __('All') }}</button>
<button type="button" @click="setAllValues({{ $key }}, false)" class="text-muted-foreground underline-offset-4 hover:text-foreground hover:underline" x-show="drafts[{{ $key }}].values.length" x-cloak>{{ __('None') }}</button>
</span>
</div>
<div class="flex items-center gap-2 px-2 pb-1" x-show="distinctValues({{ $key }}).length > 8">
<input type="search" x-model="valueSearch[{{ $key }}]" aria-label="{{ __('Search values') }}" placeholder="{{ __('Search…') }}" class="{{ $fieldClass }}" />
</div>
<div class="flex max-h-48 flex-col overflow-y-auto">
<template x-for="option in listedValues({{ $key }})" :key="option">
<label class="{{ $panelItem }} cursor-pointer">
<input type="checkbox" class="size-4 shrink-0 rounded-sm border-input accent-primary" x-bind:value="option" x-bind:checked="drafts[{{ $key }}].values.includes(option)" @change="toggleValue({{ $key }}, option)" />
<span class="truncate" x-text="option"></span>
</label>
</template>
<p class="px-2 py-1 text-xs text-muted-foreground" x-show="listedValues({{ $key }}).length === 0">{{ __('No values') }}</p>
</div>
</div>
@endif
@if ($canSort || ($columnToggle && ($col['hideable'] ?? true)))
<div class="my-1 h-px bg-border" role="none"></div>
@endif
@if ($canSort)
<button type="button" @click="setSort({{ $key }}, null); closeAndFocus()" x-show="sortKey === {{ $key }}" x-cloak class="{{ $panelItem }} text-muted-foreground">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 15 5 5 5-5M7 9l5-5 5 5" /></svg>
{{ __('Clear sort') }}
</button>
@endif
@if ($columnToggle && ($col['hideable'] ?? true))
<button type="button" @click="toggleColumn({{ $key }}); close()" class="{{ $panelItem }} text-muted-foreground">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.9 4.2A10 10 0 0 1 12 4c7 0 10 8 10 8a13 13 0 0 1-1.7 2.6M6.6 6.6A13.5 13.5 0 0 0 2 12s3 8 10 8a9.7 9.7 0 0 0 5.4-1.6M2 2l20 20" /></svg>
{{ __('Hide column') }}
</button>
@endif
@if ($canFilter)
{{-- The filter is a draft until Apply; Clear drops it at once. --}}
<div class="mt-1 flex items-center justify-between gap-2 border-t border-border px-2 pt-2 pb-1">
<button type="button" @click="clearFilter({{ $key }}); closeAndFocus()" x-show="hasFilter({{ $key }}) || draftHasFilter({{ $key }})" x-cloak class="text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">{{ __('Clear filter') }}</button>
<x-ui.button type="button" size="sm" class="ms-auto !h-8" @click="applyFilter({{ $key }}); closeAndFocus()">{{ __('Apply') }}</x-ui.button>
</div>
@endif
</x-ui.popover.content>
</x-ui.popover>
@else
{{ __($col['label']) }}
@endif
</x-ui.table.head>
@endforeach
</x-ui.table.row>
</x-ui.table.header>
@if ($loading)
<tbody data-slot="table-body" class="[&_tr:last-child]:border-0">
<x-ui.table.state state="loading" :colspan="$totalCols" :heading="$loadingLabel" />
</tbody>
@else
{{-- Body rows are rendered with raw <tbody>/<tr>/<td> mirroring the table
primitive's data-slots and classes, because Blade components cannot iterate
Alpine `x-for` state cleanly. One <tbody> per row keeps the x-for root single
while letting an optional detail <tr> follow its data <tr>. Classes copied
from table/{body,row,cell}.blade.php. --}}
<template x-for="(row, i) in pageRows()" :key="i">
<tbody data-slot="table-body" class="[&_tr:last-child]:border-0">
<tr
data-slot="table-row"
class="border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted motion-reduce:transition-none"
x-bind:data-state="isSelected(rowIndex(row)) ? 'selected' : null"
>
@if ($expandable)
<td data-slot="table-cell" class="{{ $cellPad }} w-px align-middle">
<button
type="button"
@click="toggleExpand(rowIndex(row))"
x-bind:aria-expanded="isExpanded(rowIndex(row)) ? 'true' : 'false'"
x-bind:aria-label="isExpanded(rowIndex(row)) ? '{{ __('Collapse row') }}' : '{{ __('Expand row') }}'"
class="inline-flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
>
<svg
class="size-4 transition-transform rtl:-scale-x-100 motion-reduce:transition-none"
x-bind:class="isExpanded(rowIndex(row)) ? 'rotate-90' : ''"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m9 18 6-6-6-6"></path>
</svg>
</button>
</td>
@endif
@if ($selectable)
<td data-slot="table-cell" class="{{ $cellPad }} align-middle [&:has([role=checkbox])]:w-px">
<x-ui.checkbox
role="checkbox"
x-bind:value="rowIndex(row)"
@change="toggleRow(rowIndex(row), $event.target.checked)"
x-bind:checked="isSelected(rowIndex(row))"
x-bind:aria-label="`{{ __('Select row') }} ${rowIndex(row) + 1}`"
/>
</td>
@endif
@foreach ($columns as $col)
@php
$cellAlign = $alignMap[$col['align'] ?? ''] ?? null;
$type = $col['type'] ?? 'text';
$key = \Illuminate\Support\Js::from($col['key']);
@endphp
<td
data-slot="table-cell"
{{-- Cells keep to one line; a table wider than its frame scrolls
sideways instead of folding ids and dates onto two lines. --}}
@class([$cellPad, 'whitespace-nowrap align-middle text-foreground', $cellAlign, $col['class'] ?? null, 'w-px' => $type === 'actions', 'font-mono text-xs' => $type === 'mono', 'tabular-nums' => $type === 'number'])
x-show="isVisible({{ $key }})"
>
@if ($type === 'badge')
{{-- A badge whose tone follows the value (`tones` => ['Active' => 'positive', …]). --}}
<span data-slot="badge" x-bind:class="toneClass({{ \Illuminate\Support\Js::from($col) }}, row[{{ $key }}])"><span data-slot="badge-label" class="min-w-0 truncate" x-text="row[{{ $key }}]"></span></span>
@elseif ($type === 'actions')
{{-- Row actions: icon-only buttons (developer-authored SVG path data),
the name in the accessible label and title; each dispatches
data-table:action { key, row, index }. --}}
<span class="inline-flex items-center gap-1">
@foreach ((array) ($col['actions'] ?? []) as $action)
<button
type="button"
@click="action({{ \Illuminate\Support\Js::from($action['key'] ?? '') }}, row)"
x-bind:aria-label="`{{ $action['label'] ?? '' }} ${row[{{ \Illuminate\Support\Js::from($col['rowLabel'] ?? ($columns[0]['key'] ?? '')) }}] ?? ''}`"
title="{{ $action['label'] ?? '' }}"
@class(['inline-flex size-8 items-center justify-center rounded-md transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none', 'text-muted-foreground hover:text-foreground' => empty($action['destructive']), 'text-destructive-text' => ! empty($action['destructive'])])
>
@if (! empty($action['icon']))
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">{!! $action['icon'] !!}</svg>
@else
<span class="text-xs font-medium">{{ $action['label'] ?? '' }}</span>
@endif
</button>
@endforeach
</span>
@elseif (! empty($col['editable']))
{{-- Inline edit: click the value to edit; Enter or blur commits and
dispatches data-table:edit, Escape cancels. --}}
<template x-if="!isEditing(row, {{ $key }})">
<button type="button" @click="startEdit(row, {{ $key }})" class="group/edit -mx-2 inline-flex max-w-full items-center gap-2 rounded-sm px-2 py-1 text-start hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring" x-bind:aria-label="`{{ __('Edit :column', ['column' => $col['label']]) }}: ${row[{{ $key }}] ?? ''}`">
<span class="truncate" x-text="row[{{ $key }}]"></span>
<svg class="size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/edit:opacity-100 group-focus-visible/edit:opacity-100 motion-reduce:transition-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
</button>
</template>
<template x-if="isEditing(row, {{ $key }})">
<input
type="text"
data-slot="data-table-edit"
x-model="draft"
@keydown.enter.prevent="commitEdit(row, {{ $key }})"
@keydown.escape.prevent="cancelEdit()"
@blur="commitEdit(row, {{ $key }})"
aria-label="{{ __('Edit :column', ['column' => $col['label']]) }}"
class="-mx-2 h-8 w-full min-w-24 rounded-sm border border-input bg-background px-2 text-sm text-foreground outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
/>
</template>
@else
<span x-text="row[{{ $key }}]"></span>
@endif
</td>
@endforeach
</tr>
@if ($expandable)
<tr
data-slot="table-row"
class="border-b border-border bg-muted/30"
x-show="isExpanded(rowIndex(row))"
x-cloak
>
<td data-slot="table-cell" class="{{ $cellPad }} align-top" colspan="{{ $totalCols }}">
<dl class="grid gap-x-6 gap-y-2 sm:grid-cols-2">
@foreach ($columns as $col)
<div class="flex flex-col gap-0.5">
<dt class="text-xs font-medium text-muted-foreground">{{ __($col['label']) }}</dt>
<dd class="text-sm text-foreground" x-text="row[@js($col['key'])]"></dd>
</div>
@endforeach
</dl>
</td>
</tr>
@endif
</tbody>
</template>
{{-- Filler rows pad a short last page to the page height, so paging and
filtering never move the controls below the table. --}}
<template x-for="n in fillerRows()" :key="'filler-' + n">
<tbody data-slot="table-body" aria-hidden="true" class="[&_tr:last-child]:border-0">
<tr data-slot="table-row" class="border-b border-border">
<td data-slot="table-cell" class="{{ $cellPad }}" colspan="{{ $totalCols }}"><span class="invisible text-sm"> </span></td>
</tr>
</tbody>
</template>
<tbody
data-slot="table-body"
class="[&_tr:last-child]:border-0"
x-show="pageRows().length === 0"
x-cloak
>
<x-ui.table.state
state="empty"
:colspan="$totalCols"
:heading="$emptyHeading"
:description="$emptyDescription"
/>
</tbody>
@endif
</x-ui.table>
</div>
<div
class="flex flex-col items-center justify-between gap-4 sm:flex-row"
@unless ($loading) x-show="sorted().length > 0" @endunless
>
@if ($selectable)
<span
class="text-sm text-muted-foreground"
x-show="selectable"
x-text="`${selected.length} {{ __('of') }} ${rows.length} {{ __('row(s) selected') }}`"
></span>
@endif
<x-ui.pagination class="sm:mx-0 sm:w-auto sm:justify-end">
<x-ui.pagination.content>
<x-ui.pagination.item>
<a
href="#"
data-slot="pagination-previous"
aria-label="{{ __('Go to previous page') }}"
@click.prevent="prevPage()"
x-bind:aria-disabled="page === 1 ? 'true' : null"
class="inline-flex h-9 items-center justify-center gap-1 rounded-md ps-2 pe-3 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none aria-disabled:pointer-events-none aria-disabled:opacity-50"
>
<svg class="size-4 rtl:-scale-x-100" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6"></path>
</svg>
<span>{{ __('Previous') }}</span>
</a>
</x-ui.pagination.item>
<template x-for="p in pageNumbers()" :key="p">
<li data-slot="pagination-item">
<a
href="#"
data-slot="pagination-link"
@click.prevent="goto(p)"
x-bind:data-active="p === page ? 'true' : 'false'"
x-bind:aria-current="p === page ? 'page' : null"
x-bind:class="p === page ? 'border-border bg-accent text-accent-foreground' : 'border-transparent text-foreground hover:bg-muted'"
class="inline-flex h-9 min-w-9 items-center justify-center gap-1 rounded-md border px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
x-text="p"
></a>
</li>
</template>
<x-ui.pagination.item>
<a
href="#"
data-slot="pagination-next"
aria-label="{{ __('Go to next page') }}"
@click.prevent="nextPage()"
x-bind:aria-disabled="page === totalPages() ? 'true' : null"
class="inline-flex h-9 items-center justify-center gap-1 rounded-md ps-3 pe-2 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none aria-disabled:pointer-events-none aria-disabled:opacity-50"
>
<span>{{ __('Next') }}</span>
<svg class="size-4 rtl:-scale-x-100" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6"></path>
</svg>
</a>
</x-ui.pagination.item>
</x-ui.pagination.content>
</x-ui.pagination>
</div>
@endif
</div>
/**
* Data table behavior (interactive layer for the data-table component).
*
* Composes over the static `table` primitive: it owns row data, column
* definitions, sort state, the free-text and per-column filters, column
* visibility, the current page, the selection set, expanded rows and inline
* edits. The Blade layer renders the header statically and iterates
* `pageRows()` / `columns` with Alpine `x-for` for the body, so this module is
* the single source of truth.
*
* Events (dispatched from the root, bubbling):
* data-table:action { key, row, index } — a row action was pressed
* data-table:edit { key, row, index, value, previous } — an inline edit was committed
* data-table:select { selected } — the selection changed
*
* Self-registers on `alpine:init` so import order does not matter — import this
* file once from your bundle (e.g. resources/js/ui/index.js).
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiDataTable', (config = {}) => ({
rows: config.rows ?? [],
columns: config.columns ?? [],
perPage: config.perPage ?? 5,
selectable: config.selectable ?? true,
expandable: config.expandable ?? false,
page: 1,
filter: '',
// Per-column filters keyed by column key. Every filter is one shape:
// { op, value, value2, values }
// `op` is a condition from the column's kind (text: contains, equals,
// startsWith…; number: eq, gt, between…; date: on, before, after,
// between), `value`/`value2` its operands, and `values` the picked
// entries of the column's value list (an "in" filter, Excel-style).
columnFilters: {},
// The panel edits a draft of the column's filter; Apply commits it to
// `columnFilters`, closing the panel otherwise discards it.
drafts: {},
// Search text inside a column's value list, keyed by column key.
valueSearch: {},
sortKey: null,
sortDir: 'asc',
selected: [],
expanded: [],
// Column keys folded away from the "Columns" menu.
hidden: config.hidden ?? [],
// Inline edit in progress: { index, key } and the draft value.
editing: null,
draft: '',
root: null,
// Badge recipe classes per semantic tone (positive, attention, negative,
// info, neutral), handed in by the Blade layer from _styles.php.
toneClasses: config.toneClasses ?? {},
// Translated labels for the conditions and the chip summaries.
labels: config.labels ?? {},
init() {
this.root = this.$el;
if (config.columns) {
this.columns = config.columns;
}
this.columns.forEach((c) => {
this.columnFilters[c.key] = this.emptyFilter(c);
this.drafts[c.key] = this.emptyFilter(c);
this.valueSearch[c.key] = '';
});
},
/** Copy the committed filter into the panel's draft when it opens. */
beginFilter(key) {
const f = this.columnFilters[key];
if (f) this.drafts[key] = { ...f, values: [...f.values] };
this.valueSearch[key] = '';
},
/** Commit the panel's draft. */
applyFilter(key) {
const d = this.drafts[key];
if (d) this.columnFilters[key] = { ...d, values: [...d.values] };
this.page = 1;
},
/** True when the panel's draft holds anything (drives the Apply/Clear row). */
draftHasFilter(key) {
const d = this.drafts[key];
if (!d) return false;
if (!this.needsValue(d.op)) return true;
if (this.needsRange(d.op)) return String(d.value) !== '' || String(d.value2) !== '';
return String(d.value) !== '' || (d.values ?? []).length > 0;
},
/** The data kind of a column: text, number, date or boolean. */
kindOf(col) {
return col.kind ?? 'text';
},
/** Conditions a column's kind offers, in menu order. */
operators(col) {
const kind = this.kindOf(col);
if (kind === 'number') return ['between', 'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'empty', 'notEmpty'];
if (kind === 'date') return ['between', 'on', 'before', 'after', 'empty', 'notEmpty'];
return ['contains', 'notContains', 'equals', 'notEquals', 'startsWith', 'endsWith', 'empty', 'notEmpty'];
},
/** True when the condition takes an operand. */
needsValue(op) {
return op !== 'empty' && op !== 'notEmpty';
},
/** True when the condition takes two operands. */
needsRange(op) {
return op === 'between';
},
emptyFilter(col) {
return { op: this.operators(col)[0], value: '', value2: '', values: [] };
},
/** True when the condition part of a column's filter holds anything. */
hasCondition(key) {
const f = this.columnFilters[key];
if (!f) return false;
if (!this.needsValue(f.op)) return true;
if (this.needsRange(f.op)) return String(f.value) !== '' || String(f.value2) !== '';
return String(f.value) !== '';
},
/** True when a column's filter holds anything. */
hasFilter(key) {
const f = this.columnFilters[key];
if (!f) return false;
return this.hasCondition(key) || (f.values ?? []).length > 0;
},
/** One line describing a column's active filter, for the chip row. */
filterSummary(col) {
const f = this.columnFilters[col.key];
if (!f || !this.hasFilter(col.key)) return '';
const parts = [];
if (this.hasCondition(col.key)) {
const kind = this.kindOf(col);
const symbols = { eq: '=', neq: '≠', gt: '>', gte: '≥', lt: '<', lte: '≤' };
if (!this.needsValue(f.op)) {
parts.push(this.labels[f.op] ?? f.op);
} else if (this.needsRange(f.op)) {
if (String(f.value) !== '' && String(f.value2) !== '') parts.push(`${f.value} – ${f.value2}`);
else if (String(f.value) !== '') parts.push(`≥ ${f.value}`);
else parts.push(`≤ ${f.value2}`);
} else if (kind === 'number' && symbols[f.op]) {
parts.push(`${symbols[f.op]} ${f.value}`);
} else if (kind === 'date') {
parts.push(f.op === 'on' ? f.value : `${this.labels[f.op] ?? f.op} ${f.value}`);
} else {
parts.push(`${this.labels[f.op] ?? f.op} "${f.value}"`);
}
}
if ((f.values ?? []).length) {
const list = f.values.join(', ');
// The values list already uses commas, so a condition part in
// front is set off with parentheses instead of another
// separator that would read as a third, nested list.
parts.push(parts.length ? `(${list})` : list);
}
return parts.join(' ');
},
clearFilter(key) {
const col = this.columns.find((c) => c.key === key);
if (col) {
this.columnFilters[key] = this.emptyFilter(col);
this.drafts[key] = this.emptyFilter(col);
}
this.valueSearch[key] = '';
this.page = 1;
},
/** Reset the draft's operands when the condition changes shape. */
setOperator(key, op) {
const f = this.drafts[key];
if (!f) return;
f.op = op;
if (!this.needsValue(op)) {
f.value = '';
f.value2 = '';
}
},
toggleValue(key, value) {
const f = this.drafts[key];
if (!f) return;
f.values = f.values.includes(value) ? f.values.filter((v) => v !== value) : [...f.values, value];
},
/** Pick every value currently listed (after the list search), or none. */
setAllValues(key, on) {
const f = this.drafts[key];
if (!f) return;
f.values = on ? [...this.listedValues(key)] : [];
},
setSort(key, dir) {
if (dir === null) {
if (this.sortKey === key) this.sortKey = null;
} else {
this.sortKey = key;
this.sortDir = dir;
}
this.page = 1;
},
// ---- Columns -------------------------------------------------------
isVisible(key) {
return !this.hidden.includes(key);
},
toggleColumn(key) {
this.hidden = this.isVisible(key) ? [...this.hidden, key] : this.hidden.filter((k) => k !== key);
},
/** Columns that may be folded away (an actions column never is). */
toggleableColumns() {
return this.columns.filter((c) => c.hideable !== false && c.type !== 'actions');
},
visibleColumns() {
return this.columns.filter((c) => this.isVisible(c.key));
},
hasColumnFilters() {
return this.columns.some((c) => c.filterable);
},
/** Distinct values of a column (or its declared `options`), sorted by kind. */
distinctValues(key) {
const col = this.columns.find((c) => c.key === key);
if (col?.options) return col.options.map(String);
const values = [...new Set(this.rows.map((r) => String(r[key] ?? '')))].filter((v) => v !== '');
if (this.kindOf(col ?? {}) === 'number') {
return values.sort((a, b) => parseFloat(a) - parseFloat(b));
}
return values.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
},
/** The value list after the list's own search box. */
listedValues(key) {
const q = String(this.valueSearch[key] ?? '').trim().toLowerCase();
const all = this.distinctValues(key);
return q === '' ? all : all.filter((v) => v.toLowerCase().includes(q));
},
activeFilterCount() {
return this.columns.filter((c) => this.hasFilter(c.key)).length;
},
clearFilters() {
this.filter = '';
this.columns.forEach((c) => {
this.columnFilters[c.key] = this.emptyFilter(c);
this.drafts[c.key] = this.emptyFilter(c);
});
this.page = 1;
},
// ---- Rows ----------------------------------------------------------
/** Parse a cell for a numeric comparison (currency signs and separators dropped). */
toNumber(value) {
const n = parseFloat(String(value ?? '').replace(/[^0-9.-]/g, ''));
return Number.isNaN(n) ? null : n;
},
/** The ISO date part of a cell, for a string comparison. */
toDate(value) {
const s = String(value ?? '').trim();
if (s === '') return '';
const t = Date.parse(s);
return Number.isNaN(t) ? s.slice(0, 10) : new Date(t).toISOString().slice(0, 10);
},
/** Does one cell pass its column's condition? */
matchesCondition(col, value) {
const f = this.columnFilters[col.key];
const raw = String(value ?? '').trim();
if (f.op === 'empty') return raw === '';
if (f.op === 'notEmpty') return raw !== '';
const kind = this.kindOf(col);
if (kind === 'number') {
const n = this.toNumber(value);
const a = f.value === '' ? null : parseFloat(f.value);
const b = f.value2 === '' ? null : parseFloat(f.value2);
if (f.op === 'between') return (a === null || (n !== null && n >= a)) && (b === null || (n !== null && n <= b));
if (n === null || a === null) return false;
return { eq: n === a, neq: n !== a, gt: n > a, gte: n >= a, lt: n < a, lte: n <= a }[f.op] ?? true;
}
if (kind === 'date') {
const d = this.toDate(value);
if (f.op === 'between') return (f.value === '' || d >= f.value) && (f.value2 === '' || d <= f.value2);
if (d === '' || f.value === '') return false;
return { on: d === f.value, before: d < f.value, after: d > f.value }[f.op] ?? true;
}
const text = raw.toLowerCase();
const needle = String(f.value).toLowerCase();
return {
contains: text.includes(needle),
notContains: !text.includes(needle),
equals: text === needle,
notEquals: text !== needle,
startsWith: text.startsWith(needle),
endsWith: text.endsWith(needle),
}[f.op] ?? true;
},
/** Rows matching the free-text filter across every column value and the per-column filters. */
filtered() {
const q = this.filter.trim().toLowerCase();
return this.rows.filter((row) => {
if (q !== '' && !this.columns.some((c) => String(row[c.key] ?? '').toLowerCase().includes(q))) {
return false;
}
return this.columns.every((c) => {
if (!this.hasFilter(c.key)) return true;
const f = this.columnFilters[c.key];
const value = row[c.key];
if (this.hasCondition(c.key) && !this.matchesCondition(c, value)) return false;
if (f.values.length && !f.values.includes(String(value ?? ''))) return false;
return true;
});
});
},
/** Filter, then order by the active sort column the way its kind compares. */
sorted() {
const base = this.filtered();
if (!this.sortKey) {
return base;
}
const k = this.sortKey;
const col = this.columns.find((c) => c.key === k) ?? {};
const kind = this.kindOf(col);
const dir = this.sortDir === 'asc' ? 1 : -1;
return [...base].sort((a, b) => {
if (kind === 'number') {
const na = this.toNumber(a[k]);
const nb = this.toNumber(b[k]);
if (na === null && nb === null) return 0;
if (na === null) return 1;
if (nb === null) return -1;
return (na - nb) * dir;
}
if (kind === 'date') {
return this.toDate(a[k]).localeCompare(this.toDate(b[k])) * dir;
}
return String(a[k] ?? '').localeCompare(String(b[k] ?? ''), undefined, { numeric: true, sensitivity: 'base' }) * dir;
});
},
/** Reset to the first page whenever a filter changes. */
onFilter() {
this.page = 1;
},
totalPages() {
return Math.max(1, Math.ceil(this.sorted().length / this.perPage));
},
/** The slice of sorted rows visible on the current page. */
pageRows() {
const s = this.sorted();
const start = (this.page - 1) * this.perPage;
return s.slice(start, start + this.perPage);
},
/**
* Empty rows that pad a short last page to the page height, so paging
* and filtering never move the pagination row up and down.
*/
fillerRows() {
const count = this.sorted().length === 0 ? 0 : Math.max(0, this.perPage - this.pageRows().length);
return Array.from({ length: count }, (_, i) => i);
},
pageNumbers() {
const total = this.totalPages();
const start = Math.max(1, Math.min(this.page - 2, total - 4));
const end = Math.min(total, start + 4);
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
},
/** Toggle/advance the active sort column and reset to the first page. */
sort(key) {
if (this.sortKey === key) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.sortKey = key;
this.sortDir = 'asc';
}
this.page = 1;
},
/** aria-sort token for a column header. */
ariaSort(key) {
const col = this.columns.find((c) => c.key === key);
if (!col || !col.sortable) {
return null;
}
return this.sortKey === key ? (this.sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
},
goto(p) {
this.page = Math.min(this.totalPages(), Math.max(1, p));
},
prevPage() {
this.goto(this.page - 1);
},
nextPage() {
this.goto(this.page + 1);
},
/** Stable original index of a row, so selection survives sort/paging. */
rowIndex(row) {
return this.rows.indexOf(row);
},
// ---- Selection -----------------------------------------------------
isSelected(i) {
return this.selected.includes(i);
},
toggleRow(i, checked) {
if (checked) {
if (!this.isSelected(i)) {
this.selected.push(i);
}
} else {
this.selected = this.selected.filter((x) => x !== i);
}
this.$dispatch('data-table:select', { selected: [...this.selected] });
},
allSelected() {
return this.rows.length > 0 && this.selected.length === this.rows.length;
},
toggleAll(checked) {
this.selected = checked ? this.rows.map((_, i) => i) : [];
this.$dispatch('data-table:select', { selected: [...this.selected] });
},
// ---- Expansion -----------------------------------------------------
isExpanded(i) {
return this.expanded.includes(i);
},
toggleExpand(i) {
if (this.isExpanded(i)) {
this.expanded = this.expanded.filter((x) => x !== i);
} else {
this.expanded.push(i);
}
},
// ---- Row actions and inline edits ----------------------------------
action(key, row) {
this.$dispatch('data-table:action', { key, row, index: this.rowIndex(row) });
},
isEditing(row, key) {
return this.editing !== null && this.editing.index === this.rowIndex(row) && this.editing.key === key;
},
startEdit(row, key) {
this.editing = { index: this.rowIndex(row), key };
this.draft = String(row[key] ?? '');
this.$nextTick(() => {
const input = this.root.querySelector('[data-slot="data-table-edit"]');
input?.focus();
input?.select();
});
},
commitEdit(row, key) {
if (!this.isEditing(row, key)) return;
const previous = row[key];
const value = this.draft;
this.editing = null;
if (String(previous ?? '') === value) return;
row[key] = value;
this.$dispatch('data-table:edit', { key, row, index: this.rowIndex(row), value, previous });
},
cancelEdit() {
this.editing = null;
},
/** Badge tone classes for a cell value, from the column's `tones` map. */
toneClass(col, value) {
const tone = col.tones?.[String(value ?? '')] ?? 'neutral';
return this.toneClasses?.[tone] ?? this.toneClasses?.neutral ?? '';
},
}));
});
@props([
'label' => 'Filter rows',
'placeholder' => 'Filter…',
])
<div
data-slot="data-table-filters"
{{ $attributes->merge(['class' => 'flex items-center justify-between gap-2']) }}
>
@if ($slot->isEmpty())
<div class="relative w-full max-w-xs">
<svg class="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input
type="search"
x-model="filter"
x-on:input="onFilter()"
aria-label="{{ __($label) }}"
placeholder="{{ __($placeholder) }}"
class="h-9 w-full rounded-md border border-input bg-background ps-9 pe-3 text-sm text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
/>
</div>
<span class="shrink-0 text-sm text-muted-foreground" x-text="`${sorted().length} {{ __('result(s)') }}`"></span>
@else
{{ $slot }}
@endif
</div>
<div data-slot="data-table-columns" {{ $attributes->merge(['class' => 'min-w-0']) }}>
{{ $slot }}
</div>
<div data-slot="data-table-selection" {{ $attributes->merge(['class' => 'text-sm text-muted-foreground']) }}>
@if ($slot->isEmpty())
<span x-text="`${selected.length} {{ __('of') }} ${rows.length} {{ __('row(s) selected') }}`"></span>
@else
{{ $slot }}
@endif
</div>
<div data-slot="data-table-expansion" {{ $attributes->merge(['class' => 'min-w-0']) }}>
{{ $slot }}
</div>
<nav
data-slot="data-table-pagination"
aria-label="{{ __('Table pagination') }}"
{{ $attributes->merge(['class' => 'flex items-center justify-end gap-2']) }}
>
@if ($slot->isEmpty())
<x-ui.button type="button" variant="outline" size="sm" x-on:click="prevPage()" x-bind:disabled="page === 1">
{{ __('Previous') }}
</x-ui.button>
<span class="text-sm text-muted-foreground" x-text="`${page} / ${totalPages()}`"></span>
<x-ui.button type="button" variant="outline" size="sm" x-on:click="nextPage()" x-bind:disabled="page === totalPages()">
{{ __('Next') }}
</x-ui.button>
@else
{{ $slot }}
@endif
</nav>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.10.7 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0