getodden / ui
Blade components, Livewire traits and Tailwind 4 theme tokens for accessible Laravel Livewire screens.
Requires
- php: ^8.3
- laravel/framework: ^13.0
- livewire/livewire: ^4.0
- owenvoke/blade-fontawesome: ^3.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.32
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0 || ^5.0
- pestphp/pest-plugin-browser: ^4.0 || ^5.0
- pestphp/pest-plugin-laravel: ^4.0 || ^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-08 05:42:08 UTC
README
Accessible Blade components, Livewire traits and Tailwind 4 theme tokens for Laravel Livewire screens.
Status: 0.1. Pre-1.0, so a minor version may still change the API. It is the UI layer behind the Odden Livewire screens, and it knows nothing about CRMs or any host application.
Install
composer require getodden/ui
Requires PHP 8.3, Laravel 13, Livewire 4 (which bundles Alpine with its Focus plugin) and Blade FontAwesome (free set). The service provider is discovered automatically.
Import the tokens in your Tailwind 4 entry file. The import also tells Tailwind to scan the component views, and it adds the dark variant, which follows a .dark class on a parent element.
@import 'tailwindcss'; @import '../../vendor/getodden/ui/resources/css/theme.css';
The theme defines primary-50 to primary-950, a gray scale, the rounded-control and rounded-surface radii and the Inter Variable font stack. Override any --color-primary-* in your own @theme to rebrand.
Put <x-ui::toasts /> once in your layout, outside any element that is hidden or re-rendered.
Components
All components are anonymous Blade components under the ui prefix. Extra attributes land on the main element.
| Component | Props |
|---|---|
<x-ui::button> |
variant primary, secondary, outline, ghost, danger; size sm, md, lg; href (renders a link); type (default button); disabled; icon; label (the accessible name of an icon-only button, required when the slot is empty) |
<x-ui::badge> |
variant gray, primary, success, warning, danger, info; icon |
<x-ui::card> |
heading (prop or slot); level (default 2); footer slot |
<x-ui::alert> |
variant info, success, warning, danger; heading; icon; dismissible |
<x-ui::modal> |
name; heading (required, it names the dialog); description; size sm, md, lg, xl; footer slot; wire:model |
<x-ui::confirm> |
confirmation (the $confirmation property of WithConfirmation) |
<x-ui::toasts> |
duration in milliseconds before a non-danger toast leaves (default 5000) |
<x-ui::input> |
label (required unless you pass aria-label or aria-labelledby), name, id, hint, error, required, type |
<x-ui::textarea> |
the field props, rows |
<x-ui::select> |
the field props, options (value => label), placeholder, value, and a slot |
<x-ui::toggle> |
the field props; renders a switch |
<x-ui::icon> |
name (for example fas-check), size sm, md, lg |
<x-ui::date>, <x-ui::datetime> |
the field props; native date and datetime-local inputs |
<x-ui::number> |
the field props; a native number input, so min, max and step pass through |
<x-ui::currency> |
the field props, symbol (default $), currency (a code shown after the amount and read as the input's description), step (default 0.01) |
<x-ui::checkbox> |
the field props; the label sits beside the box |
<x-ui::radio-group> |
the field props, options (value => label, or value => ['label' => ..., 'hint' => ...]), value; a fieldset whose label is the legend |
<x-ui::multiselect> |
the field props, options (value => label), value (list), placeholder |
<x-ui::combobox> |
the field props, search (a method on your component, required), value, value-label, debounce in milliseconds (default 250), minimum characters before searching (default 0) |
<x-ui::file> |
the field props; accept and multiple pass through |
<x-ui::collapsible> |
heading (required), description, level (default 2), open; wire:model binds the open state |
<x-ui::tabs> and <x-ui::tab> |
id (required) and label on tabs, tabs (key => label), selected, wire:model; name on each tab |
<x-ui::dropdown> and <x-ui::dropdown.item> |
label (required), id, icon, icon-only, variant, size, align start or end on the dropdown; href, icon, variant default or danger, disabled on the item |
<x-ui::page-header> |
title (required), description, breadcrumbs, level (default 1); actions slot |
<x-ui::description-list> and <x-ui::description-list.item> |
items (label => value) and item children with label |
<x-ui::empty-state> |
heading (required), description, icon, level (default 3); actions slot |
<x-ui::editor> |
the field props, format html or json, toolbar (list of tools, [] for none), mentions, mention-search, mention-char |
<x-ui::board> |
label (required unless you pass aria-label or aria-labelledby), columns (key => label, offered in each card's Move to select) |
<x-ui::board.column> |
column (key), label, count, total (text, optional), has-more |
<x-ui::board.card> |
id, label |
<x-ui::bar-chart> |
label (required, names the chart), label-visible, categories, series, orientation vertical or horizontal, stacked |
<x-ui::line-chart> |
label, label-visible, categories, series |
<x-ui::sparkline> |
label (required), values |
<x-ui::donut-chart> |
label, label-visible, segments |
<x-ui::stat> |
label (required), value, delta, direction up, down or flat, sentiment positive, negative or neutral, delta-label, sparkline (list of values) |
<x-ui::repeater> |
label (required), name (the array property, required), rows (that property), item-label (default "Item"), add-label, hint, error; rows go in the slot |
<x-ui::repeater.row> |
index (required); the row's fields go in the slot |
<x-ui::table> |
caption (required unless you pass aria-label or aria-labelledby), caption-visible, columns, state, records, selectable-keys, row-actions; toolbar and empty slots |
Components that take an icon accept a Blade FontAwesome name as a string, or a named icon slot rendered as given.
Fields
name is taken from wire:model* when you do not pass it, and id from name (address[city] becomes ui-address-city). The error is read from the $errors bag by the same key when you do not pass error.
Two fields bound to the same key on one page get the same id, so give one of them an explicit id. An aria-describedby you pass is appended to the hint and error ids, not substituted for them. A field without a label throws unless it has an aria-label or aria-labelledby.
<x-ui::input label="Email" wire:model.blur="form.email" hint="We never share it" required />
Form controls
wire:model works on all of them and the error comes from the $errors bag by the same key. An error on a list item such as photos.0 is shown on the field bound to photos, unless the key itself has an error.
<x-ui::date label="Starts on" wire:model="startsOn" /> <x-ui::currency label="Amount" wire:model="amount" currency="USD" /> <x-ui::checkbox label="Send a receipt" wire:model="receipt" /> <x-ui::radio-group label="Contact by" wire:model="method" :options="['email' => 'Email', 'phone' => ['label' => 'Phone', 'hint' => 'Weekdays only']]" /> <x-ui::multiselect label="Tags" wire:model="tags" :options="$tagOptions" /> <x-ui::file label="Attachment" wire:model="attachment" />
Two checkboxes bound to the same key get the same id, so give one an explicit id. A radio group puts wire:model on every radio, not on the fieldset.
multiselect follows the select-only combobox pattern: the button holds focus, arrows move the highlighted option, Space and Enter toggle it, Escape closes the list. It writes strings once the visitor toggles an option, so a property that holds integers comes back as strings. With a name (and no wire:model) it posts name[] fields.
combobox calls a public method of your component with what the visitor typed, after a pause, and shows what it returns as options. The method returns [['id' => '7', 'label' => 'Ada']]. Only the latest search is shown. Anyone can call that method with any string, so authorise inside it, cap the query length and the number of results, return only id and label, and mark it #[Renderless]. The bound value can be set to any id from the browser, so resolve it through the same scoped query when you use it; never trust that it came from your search method. The same goes for multiselect values. If your application refuses every component method that is not marked as a read while a session is read-only or a workspace is locked, mark the search method as a read too, or every search is refused there:
<x-ui::combobox label="Customer" wire:model="customerId" search="searchCustomers" :value-label="$customerName" />
The bound value is the chosen id, or an empty string while the text is edited or nothing is chosen. Text that matches no choice is cleared when focus leaves the field. value-label is the text to show for a value that is already set; change the property and the label together and the field shows the new label without being replaced, so focus is kept. The search value must be a plain method name.
Layout pieces
<x-ui::page-header title="Ada Lovelace" :breadcrumbs="[['label' => 'Contacts', 'href' => route('contacts')], ['label' => 'Ada Lovelace']]"> <x-slot:actions> <x-ui::dropdown label="Actions" align="end"> <x-ui::dropdown.item wire:click="archive">Archive</x-ui::dropdown.item> <x-ui::dropdown.item variant="danger" wire:click="askToDelete">Delete</x-ui::dropdown.item> </x-ui::dropdown> </x-slot:actions> </x-ui::page-header> <x-ui::tabs id="contact-tabs" label="Contact sections" wire:model="tab" :selected="$tab" :tabs="['details' => 'Details', 'notes' => 'Notes']"> <x-ui::tab name="details">...</x-ui::tab> <x-ui::tab name="notes">...</x-ui::tab> </x-ui::tabs>
The last breadcrumb is the current page. tabs activates a tab as soon as it gets focus with the arrow keys, Home or End; all panels are rendered and hidden with CSS, so state inside them is kept. The dropdown is the menu-button pattern: Enter, Space or the arrow keys open it onto the first (or with Up, the last) item; arrows, Home, End and the first letter of an item move focus; Escape, Tab, an outside click or choosing an item closes it. A disabled item is skipped by the keyboard. Give <x-ui::dropdown> an id when a test or label needs a stable one; the button is then {id}-trigger. Without one, the ids come from the label, the wire:key and the content, so identical dropdowns, collapsibles or cards on one page need an id or a different wire:key. Pass :selected with wire:model on tabs so the server renders the bound tab.
Modal
<x-ui::modal wire:model="showEdit" heading="Edit account">...</x-ui::modal> <x-ui::modal name="invite" heading="Invite someone">...</x-ui::modal> <x-ui::button x-on:click="$dispatch('ui-modal-open', { name: 'invite' })">Invite</x-ui::button>
The ui-modal-open and ui-modal-close window events take the modal's name in detail.name. Put data-autofocus on an element inside the modal to start focus there instead of on the first focusable element. The dialog is teleported to <body>, and the most recently opened one is always on top, so a ui::confirm can sit anywhere in the page. If the modal is removed from the page while open, focus returns to its opener. Escape closes only the modal that holds focus, so a modal opened from inside another closes first. A modal without a heading throws.
Editor
A TipTap rich-text field. It reads and writes the same HTML and JSON as Filament's RichEditor for paragraphs, headings 1 to 4, bold, italic, underline, strike, inline code, code blocks, links, lists, blockquotes, rules and mentions. Text alignment, sub- and superscript, colours, tables, images and custom blocks are not supported: stored HTML loses them when saved, and a stored JSON document holding them opens read-only with a notice, so it is never overwritten. Install TipTap 3 in your app and register the Alpine data before Alpine starts:
npm install @tiptap/core@^3 @tiptap/pm@^3 @tiptap/starter-kit@^3 @tiptap/extension-mention@^3 @tiptap/suggestion@^3
import { registerEditor } from '../../vendor/getodden/ui/resources/js/editor.js' document.addEventListener('alpine:init', () => registerEditor(window.Alpine))
<x-ui::editor label="Reply" wire:model="body" mention-search="searchPeople" />
Tools are bold, italic, link, bullet-list, ordered-list, heading-2 and heading-3. Links accept http, https, mailto, tel and relative addresses. wire:model follows Livewire 4: plain and .blur update the component without a request, .live sends after a 150 ms pause (.live.debounce.500ms and .live.throttle.500ms change it), and .live.blur sends on blur when the text changed. As in Livewire, .live.throttle alone can hold the last keystrokes until the next change or request.
Mentions come from a static :mentions="[['id' => '7', 'label' => 'Ada']]" list, which is read once and is visible in the page source, or from mention-search, a method on your component that takes the query and returns that same shape. Anyone can call that method with any string, so check who may see the results, cap the query and the number of results, return only id and label, and mark it #[Renderless].
The bound value is whatever the browser sends, so treat it as untrusted. Before you save it or print it with {!! !!}, pass HTML through Odden\Ui\Support\RichHtml::clean() and a format="json" document through RichJson::clean(). Both keep only what the editor can produce. Both throw a LengthException above their size limits (RichHtml::MAXIMUM_BYTES, MAXIMUM_TAGS, MAXIMUM_TAG_BYTES, RichJson::MAXIMUM_NODES, MAXIMUM_DEPTH), so validate the length first. A single tag over 8 KiB, such as a very long link or a large pasted comment, counts as too long.
Board
<x-ui::board label="Deals" :columns="$labels"> @foreach ($columns as $key => $column) <x-ui::board.column :column="$key" :label="$column['label']" :count="$column['count']" :total="$column['total']" :has-more="$column['slice']->hasMore"> @foreach ($column['slice']->records as $deal) <x-ui::board.card wire:key="deal-{{ $deal->id }}" :id="$deal->id" :label="$deal->name">{{ $deal->name }}</x-ui::board.card> @endforeach </x-ui::board.column> @endforeach </x-ui::board>
use Odden\Ui\Livewire\Concerns\WithBoard; class Deals extends Component { use WithBoard; protected function boardColumnKeys(): array { return ['new', 'won']; } protected function moveBoardCard(string $cardId, string $toColumn, int $position, ?string $afterCardId, bool $toEnd): bool { $deal = Deal::query()->find($cardId); if ($deal === null) { return false; } $this->authorize('update', $deal); return $deal->update(['stage' => $toColumn]); } protected function boardPageSize(): int { return 20; } }
A card moves by dragging it, or with the controls on the card, which work with the keyboard and on touch screens: pick a column in the Move to select and press Move to send the card to the end of that column, or use the up and down buttons to move it one place within its column. Choosing in the select does not move anything by itself. The page moves the card at once and calls moveBoardCard(). When the server answers, the status region announces the position the card has in the list you rendered, and focus goes to the card's control, or to the column heading if your query no longer shows the card. Return false, throw an AuthorizationException or a ValidationException, or abort(403), to refuse the move: the card goes back and the status region says so. A request that fails does the same.
The card id arrives from the browser as a string and is not trusted: look the card up through your tenant or policy scope. The column is checked against boardColumnKeys() and the position is a non-negative integer. The write method is never called for anything that fails those checks.
$position is an index among the cards the browser is showing in that column, and $afterCardId is the id of the card the moved card now sits directly below, or null when it is first. Prefer $afterCardId when you store a manual order: it stays right when other people change the column in the meantime. When the Move to select sends a card to a column that has more cards than are shown, $position is BoardSlice::END_POSITION and $toEnd is true; $afterCardId is null, as it is for a card dropped first in its column, so read $toEnd first and place the card at the end of the whole column. $afterCardId is as untrusted as the card id: look it up through the same scope.
Set :reorderable="false" on the board when the query decides the order: the up and down buttons go away and a drop inside the same column does nothing. Set :movable="false" on the board, or locked on a card, when the user may not move cards: those cards have no drag handle and no move controls. That only changes the markup: moveCard can still be called from the browser, so moveBoardCard() must refuse on its own. Give a column :heading-level="2" (2 to 6, default 3) to fit the heading outline of your page. Cards get a wire:key of ui-board-card-{id} unless you pass your own, which the rollback after a refused move relies on.
boardSlice($query, $column) returns the first boardPageSize() cards of a column and whether more exist. The column's Show more button calls loadMoreCards(), a read. If your component runs behind a write guard that classifies public methods as reads or writes, the guard must be told that loadMoreCards from WithBoard is a read; a trait method cannot carry your own attribute, so the guard has to list it itself, otherwise a read-only user can never page past the first cards. It raises that column's limit by one page, to at most 1000 cards. Your query decides the order, so a card that was moved shows up where the query puts it once the server answers. The count and total are yours to compute.
Repeater
A list of row sub-forms bound to an array property, with add, remove and reorder. Use WithRepeater for the actions and register each repeater property with its limits:
use Odden\Ui\Livewire\Concerns\WithRepeater; use Odden\Ui\Support\RepeaterSpec; class EditContact extends Component { use WithRepeater; public array $phones = [['label' => 'Office', 'number' => '']]; protected function repeaters(): array { return ['phones' => new RepeaterSpec(min: 1, max: 5, blank: ['label' => '', 'number' => ''])]; } }
<x-ui::repeater label="Phone numbers" name="phones" :rows="$phones" item-label="Phone" add-label="Add phone"> @foreach ($phones as $index => $phone) <x-ui::repeater.row :index="$index"> <x-ui::input label="Label" wire:model="phones.{{ $index }}.label" /> <x-ui::input label="Number" wire:model="phones.{{ $index }}.number" /> </x-ui::repeater.row> @endforeach </x-ui::repeater>
Each row carries a _key entry that the trait adds when the component boots and when the property is updated from the browser. The row <li> is keyed by it, so Livewire moves the same DOM element when rows are reordered and wire:model never lands on another row. Validate with phones.*.number rules: the error of phones.3.number shows on the field in the fourth row, and an error on phones itself shows under the list. The trait also keys the rows before each render, so assigning or resetting the property in an action is safe. A row that is not an array throws; scalar lists are not repeaters. The _key entry is part of the data: a rule such as phones.* => array:label,number fails on it, so list Odden\Ui\Support\RepeaterRows::KEY too, and strip the keys with RepeaterRows::withoutKeys($validated['phones']) before you save. A message on a row itself (phones.3) shows inside that row. Moving, reordering and removing rows carries each row's errors with it. Repeaters cannot be nested.
The trait adds addRepeaterRow($property), removeRepeaterRow($property, $key), moveRepeaterRow($property, $key, 'up'|'down') and reorderRepeaterRows($property, $keys). They are public like any Livewire action, so they only act on properties listed in repeaters(), ignore unknown keys and directions, refuse to go above max or below min, and accept a reorder only when it lists exactly the current keys. The component reads min and max from the RepeaterSpec, so the buttons and the server cannot disagree, and a property set straight from the browser is cut to max and padded with blank rows up to min. The buttons move focus to where you would expect and announce the result in a polite live region. Dragging uses the browser's drag and drop on the handle, so it works with a mouse only; the arrow buttons are the keyboard and touch route.
Table
<x-ui::table caption="Contacts" :columns="['name' => 'Name', 'email' => 'Email']" :state="$this->recordTableState()" :records="$contacts"> <x-slot:toolbar> <x-ui::input type="search" label="Search" wire:model.live.debounce.250ms="search" /> </x-slot:toolbar> @foreach ($contacts as $contact) <tr wire:key="{{ $contact->id }}">...</tr> @endforeach </x-ui::table>
Sortable headers are buttons, and the sorted column carries aria-sort. When there are no records, the table shows the empty slot. While a request runs, it is marked aria-busy. Pagination links appear when there is more than one page.
Charts
Charts are server-rendered SVG with no JavaScript library. Data is plain arrays: categories is a list of labels and series a list of ['name' => 'Won', 'values' => [4, 7, 5]], with one value per category. A null or non-numeric value is a gap. A donut takes segments as a list of ['label' => 'Web', 'value' => 40]; zero and negative segments are left out.
<x-ui::bar-chart label="Deals by month" :categories="['Jan', 'Feb']" :series="[['name' => 'Won', 'values' => [4, 7]], ['name' => 'Lost', 'values' => [2, 3]]]" stacked /> <x-ui::stat label="New contacts" value="1,248" delta="+12%" delta-label="vs last month" :sparkline="[3, 5, 8, 12]" />
The label is the accessible name (a figcaption the SVG is labelled by) and is hidden unless you pass label-visible. Every chart also holds its values in a data table that screen readers can reach, and the bar, line and donut charts add a "Show data table" button that reveals it (it needs Alpine, which Livewire bundles). With no data the chart shows "No data to show" instead of an empty plot. A line with one point draws a marker. Chart ids are numbered per request, so pass an id to each chart inside a Livewire component, or two charts with the same label can share ids after a partial re-render. Charts take at most 366 categories and 6 series (one per palette colour) and throw an InvalidArgumentException for more, rather than dropping data.
Series and segments take their colours in order from --ui-chart-1 to --ui-chart-6, which theme.css defines for light and dark; override them to rebrand, and override both the :root and the .dark declarations, or the dark palette keeps the package colours (or the later :root rule wins in dark mode too when .dark sits on <html>). Colour is never the only carrier: every mark has a tooltip and a row in the table, and the legend names each series. A stat's direction is inferred from a leading + or - in delta, from the sign of a numeric one, or from a zero, when you do not pass it (otherwise the tile states no direction), and its colour follows sentiment (up is positive, down negative, unless you say otherwise, for example for churn). A donut with more than six segments folds the tail into "Other", and values beyond 1e15 in magnitude are ignored and values below 1e-9 in magnitude count as zero.
Pass :selectable-keys="$contacts->pluck('id')->all()" to add a selection column with a select-all-on-page checkbox, and put <x-ui::table.select :value="$contact->id" label="Select {{ $contact->name }}" /> in each row. Pass row-actions for a trailing column and put a menu in it:
Pass :selectable-keys="$contacts->pluck('id')" to add a selection column with a select-all-on-page checkbox, and put <x-ui::table.select :value="$contact->id" :label="'Select '.$contact->name" /> in each row. Pass row-actions for a trailing column and put a menu in it:
<x-ui::table.actions :label="'Actions for '.$contact->name"> <x-ui::table.action wire:click="edit({{ \Illuminate\Support\Js::from($contact->id) }})">Edit</x-ui::table.action> <x-ui::table.action :href="route('contacts.show', $contact)">Open</x-ui::table.action> <x-ui::table.action danger wire:click="confirmDelete({{ \Illuminate\Support\Js::from($contact->id) }})">Delete</x-ui::table.action> </x-ui::table.actions>
The menu button opens with Enter, Space or the arrow keys; arrows, Home and End move between items, Escape closes it and returns focus to the button. It opens above the button when there is no room below, and it closes when the page scrolls. When the state has tabs, they render as buttons above the table, and the active one carries aria-current.
Livewire traits
WithRecordTable holds search, sort, filters, per-page and pagination state in the URL. Every key comes from your allow-lists and is checked each time it is used, so a value from the URL or $wire.$set that is not on a list falls back to the default.
use Odden\Ui\Livewire\Concerns\WithRecordTable; class Contacts extends Component { use WithRecordTable; protected function sortableColumns(): array { return ['name' => 'name', 'created' => 'created_at']; } protected function searchableColumns(): array { return ['name', 'email']; } protected function recordFilters(): array { return ['status' => fn (Builder $query, string $value) => $query->where('status', Status::tryFrom($value) ?? Status::Active)]; } public function render(): View { return view('contacts', ['contacts' => $this->paginateRecords(Contact::query())]); } }
A filter closure receives the raw string from the request, so validate it there. Override defaultSort(), defaultDirection(), perPageOptions() or applySearch() when you need to.
A chosen sort replaces any order on your base query, and the model key always breaks ties, so pages stay stable. Search compares lower(column) like lower(term). On PostgreSQL that fails for columns that are not text, such as integers, uuids and json, so search those in your own applySearch(). On SQLite only ASCII letters match regardless of case. A page past the end shows the last page.
Tabs are named scopes. Declare them with recordTabs(); the first one is the default unless you override defaultTab(). The active tab is kept in the URL (tab), and a value that is not a key falls back to the default. A tab scope may only add where clauses; one that adds a join, order, group, having, union or column list throws a LogicException, so put those on the base query.
protected function recordTabs(): array { return [ 'all' => ['label' => 'All', 'scope' => fn (Builder $query) => $query], 'open' => ['label' => 'Open', 'scope' => fn (Builder $query) => $query->where('status', Status::Open)], ]; }
Bulk actions are methods on your component that receive the selected keys. List them in bulkActions() as method => label; only listed methods can be reached through runBulkAction(), and they must be protected: a public one can be called straight from the browser without the checks below, so runBulkAction() throws a LogicException for it. The keys come from the browser: the trait trims them to scalars, removes duplicates and caps them at maximumSelection() (100), but it cannot know which rows the visitor may touch. After each render the selection keeps only the keys of the rows on the current page; the key is the model key (or the id field of a plain row), and a host that keys its checkboxes by something else overrides recordKey($record) and recordKeyColumn(), so selectedRecords() matches the selected keys against that column instead of the primary key. Call paginateRecords() before recordTableState(), which throws otherwise. List the methods that should look destructive in dangerousBulkActions() and their buttons use the danger style. To act on the rows the visitor sees, build the query with selectedRecords(Model::query()), which applies the active search, filters and tab and keeps only the selected model keys (on an integer-keyed model, keys that are not digits are dropped, so a host that keys its checkboxes by something else must override recordKeyColumn() too). Load them through your own scoped query and authorise every record before changing it.
protected function bulkActions(): array { return ['archiveSelected' => 'Archive']; } protected function archiveSelected(array $keys): void { foreach ($this->selectedRecords(Contact::query())->get() as $contact) { $this->authorize('archive', $contact); app(ArchiveContact::class)->handle($contact); } }
Pass labels as :label with a PHP expression, as above, because Blade already escapes {{ }} inside an attribute and the component escapes it again. maximumSelection() defaults to the largest of 100 and your perPageOptions(), so selecting a whole page is never cut short.
The selection is cleared after a bulk action succeeds, and whenever the page, search, sort, filters, per-page or tab changes. If the action throws, the selection stays. If it calls confirm() from WithConfirmation, the selection also stays while the dialog is open and after Cancel; call clearSelection() in the confirmed method. When the element that held focus leaves the table, including after a confirmation dialog closes, focus moves to the select-all checkbox.
A write guard that denies public Livewire methods by default can allow the trait's own pure reads from Odden\Ui\Support\RecordTableReads::METHODS: sortBy, resetFilters, setTab, clearSelection, gotoPage, nextPage, previousPage, setPage and resetPage. runBulkAction is not on the list, because it runs your methods.
WithToasts adds toast($message, ToastVariant::Success), which shows a toast now, and toastAfterRedirect(...), which flashes it to the session for the next page. Danger toasts stay until dismissed; the others leave after five seconds, and hovering or focusing the toasts holds them.
WithConfirmation asks before a destructive action and keeps what was asked in a #[Locked] property, so the browser cannot change which method runs or with what arguments. The confirmed method may be protected, so confirming is the only way to reach it.
use Odden\Ui\Livewire\Concerns\WithConfirmation; class Contacts extends Component { use WithConfirmation; public function askToDelete(int $id): void { $this->confirm('delete', [$id], 'Delete this contact?', 'This cannot be undone.', 'Delete'); } protected function delete(int $id): void { } }
<x-ui::confirm :confirmation="$confirmation" />
confirm() is protected, so only your own methods can call it. It refuses a name that is not a method on the component, the framework's own methods and lifecycle names such as mount*, boot*, updated* and render. Closing the dialog cancels the confirmation. Focus starts on Cancel for a danger confirmation and on the confirm button otherwise.
Accessibility
- Text contrast follows the rules in the components, not overrides in your app: secondary text is
gray-500or darker on light andgray-400on dark; the primary fill isprimary-700with white text on light andprimary-400withgray-950text on dark; danger, success and warning text use the 700, 700 and 800 shades on light. - Fields render a
labelfor the control, a hint and an error with ids,aria-describedbylisting only what is rendered,aria-invalidonly with an error, and anaria-hiddenrequired mark beside therequiredattribute. - Modals are
role="dialog"witharia-modal,aria-labelledbyand optionalaria-describedby; focus is trapped, Escape and the backdrop close them, body scroll is locked and focus returns to the opener. - The toast region is
aria-live="polite"and is in the page from the start. Danger and warning alerts userole="alert", the othersrole="status". - Control borders (inputs, selects, textareas, outline buttons) meet the 3:1 non-text contrast of WCAG 1.4.11. A disabled link button has no
hrefand no click handlers. - Icon-only buttons need a
label. Icons arearia-hidden. - The editor's toolbar is one tab stop with arrow-key movement and
aria-pressedon toggles; the editable area is a labelledtextbox, and mention suggestions are alistboxit points at witharia-activedescendant. - The repeater is a labelled group with an ordered list of labelled row groups. Each row has move up, move down and remove buttons named after the row, disabled at the ends and at the minimum; moving, adding and removing announce the result and keep focus in the list. The drag handle is hidden from assistive technology because the buttons do the same job.
- The board is a group of labelled column sections. Each card has a labelled Move to select, so a move never needs a pointer, and a polite status region announces each move or refusal. The board is
aria-busywhile a move is saved. - Radio groups are fieldsets with a legend; the multiselect and combobox expose
role=comboboxwitharia-expanded,aria-controlsandaria-activedescendant, and the combobox announces the result count in a status region. - Tabs follow the ARIA tabs pattern with one tab stop and arrow-key movement; the dropdown follows the menu-button pattern; the collapsible heading holds a button with
aria-expandedandaria-controls; breadcrumbs sit in a labellednavwitharia-current="page"on the last. - Table headers are column headers, row checkboxes and the select-all checkbox are labelled, the status region announces how many rows are selected, the row menu follows the menu-button pattern with arrow-key movement, the sorted one carries
aria-sort, the table isaria-busywhile its own requests run, and a status region announces loading and the result count. The current page stays focusable.
Development
composer install
npm install && npx playwright install chromium
vendor/bin/pest --testsuite=Feature
vendor/bin/pest --testsuite=Browser
vendor/bin/phpstan analyse
vendor/bin/pint
The browser suite drives the demo page in workbench/ and runs axe on it. The demo is not compiled with Tailwind, so axe cannot judge colour contrast there.
MIT licensed.