Bulk Selection
A list your server renders shows one page of its matches, and "select all" means two things
there: the rows of this page, or every match on every page. <x-wirekit:: keeps the
two apart around a table of your own:
<x-wirekit::is the header checkbox. It reads the page only: checked when every row of the page is, mixed when some are.bulk-selection. toggle /> - Once the whole page is selected and there are more matches, a notice above the rows names both numbers and offers every match. Taken, it says so and offers the way back to the page.
- "Every match" is a boolean your component binds with
wire:model, since only your component knows which records match.
The rows are the checkboxes inside the block, bound however you bind them. A click with Shift held picks a range, as on a checkbox list.
Usage
Select the four rows, by the header checkbox or one by one: the notice offers all 1,284 matches.
With Livewire
Your component holds the selected ids, as it does today, and one boolean for every match. The block
needs the number of matches and something that names the filter. The example assumes a Product
model with a name and a status:
{{-- resources/views/livewire/product-list.blade.php --}}
<div>
{{-- 1. The search names the filter: another one clears every match. --}}
<x-wirekit::input type="search" wire:model.live.debounce.300ms="search" label="Search products" />
{{-- 2. `wire:model` binds every match; `total` and `filter` come with each render. --}}
<x-wirekit::bulk-selection wire:model.live="allMatches" :total="$products->total()" :filter="$search">
<x-wirekit::table>
<x-wirekit::table.head>
<x-wirekit::table.row>
{{-- 3. The header checkbox reads and selects this page. --}}
<x-wirekit::table.th><x-wirekit::bulk-selection.toggle /></x-wirekit::table.th>
<x-wirekit::table.th>Product</x-wirekit::table.th>
</x-wirekit::table.row>
</x-wirekit::table.head>
<x-wirekit::table.body>
{{-- 4. The rows are bound as before: one array of ids. --}}
@foreach ($products as $product)
<x-wirekit::table.row wire:key="product-{{ $product->id }}">
<x-wirekit::table.td>
<x-wirekit::checkbox wire:model.live="selected" :value="$product->id" :label="'Select '.$product->name" hide-label />
</x-wirekit::table.td>
<x-wirekit::table.td>{{ $product->name }}</x-wirekit::table.td>
</x-wirekit::table.row>
@endforeach
</x-wirekit::table.body>
</x-wirekit::table>
</x-wirekit::bulk-selection>
{{-- 5. Livewire's pager: a turned page is a request, so every match holds across it. --}}
{{ $products->links() }}
</div>
<?php
// app/Livewire/ProductList.php
namespace App\Livewire;
use App\Models\Product;
use Illuminate\Database\Eloquent\Builder;
use Livewire\Component;
use Livewire\WithPagination;
class ProductList extends Component
{
// 6. Without it, `links()` renders plain links: each page is a new visit, and every match
// starts over as false.
use WithPagination;
// 7. The ids of the rows the reader selected, and whether every match is.
public array $selected = [];
public bool $allMatches = false;
public string $search = '';
// 8. Another search starts on its first page.
public function updatingSearch(): void
{
$this->resetPage();
}
// 9. A bulk action reaches every match through the query, not through the ids.
public function archive(): void
{
$products = $this->allMatches
? $this->query()
: Product::query()->whereKey($this->selected);
$products->update(['status' => 'archived']);
$this->reset('selected', 'allMatches');
}
protected function query(): Builder
{
return Product::query()->where('name', 'like', '%'.$this->search.'%');
}
public function render()
{
return view('livewire.product-list', [
'products' => $this->query()->orderBy('name')->paginate(50),
]);
}
}
While every match is selected, the rows of a page the reader turns to are selected as they render:
each box is clicked, so selected holds them as it holds a reader's click. A render with another
filter clears every match, sends false through the binding and says why, since the matches of
the new filter are not what the reader selected. A row the reader clears ends it as well; the rest
stay selected.
The Count in Your Action Bar
The count beside your bulk actions is yours to write, and while every match is selected it is the
total, on all pages. In the action bar's summary
slot it is said on each change as well:
{{-- 1. The total and "on all pages" while every match is selected, the ids otherwise. --}}
<x-wirekit::action-bar :visible="$allMatches || count($selected) > 0">
<x-slot:summary>
{{ $allMatches
? number_format($products->total()).' selected, on all pages'
: count($selected).' selected' }}
</x-slot:summary>
<x-wirekit::button size="sm" wire:click="archive">Archive</x-wirekit::button>
</x-wirekit::action-bar>
In a Data Table
A data table in server mode does the same itself:
give it total, and selection-change carries allMatches.
Props
<x-wirekit::bulk-selection>
| Prop | Type | Default | Description |
|---|---|---|---|
total |
int|null |
null |
The number of matches on all pages. Without it, or when the page holds every match, the block offers nothing beyond the page |
filter |
string|array|null |
null |
Anything that names the filter the rows answer, such as the search text. A render with another value while every match is selected clears it |
scope |
string|null |
null |
Scoped personalization name |
wire:model or x-model on the block binds every match, a boolean, in both directions.
<x-wirekit::bulk-selection.toggle>
| Prop | Type | Default | Description |
|---|---|---|---|
label |
string|null |
null |
The checkbox's accessible name. "Select all rows on this page" in the reader's language without one |
scope |
string|null |
null |
Scoped personalization name |
Events
| Event | Detail | When |
|---|---|---|
wirekit:bulk-selection-change |
allMatches |
When every match becomes selected or stops being selected: the notice, the header checkbox, a cleared row or another filter |
Accessibility
- The header checkbox is a native checkbox named "Select all rows on this page", and its mixed state
is the native
indeterminateproperty, which a screen reader reports as "mixed". It is disabled while the page has no row to choose, unless you give it adisabledof your own. - The notice's action is a button: it changes the selection and goes nowhere.
- A status region in the block says each change of the selection's reach once: the page selected with every match on offer, every match taken, the page alone again, and a filter that cleared every match. It is in the page before its first sentence, so a screen reader speaks it, and it says its numbers as bare digits.
- A disabled row is no part of the page: the page counts as selected when every row that can be chosen is.
Keyboard Interaction
| Key | Action |
|---|---|
Space on the header checkbox |
Select the page's rows, or clear them when all are selected |
Shift + Space on a row |
Select or clear the range from the row last changed |
Tab |
Reaches the notice's action, which stands above the rows |