Skip to main content
Copy for LLM

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::bulk-selection> keeps the two apart around a table of your own:

  • <x-wirekit::bulk-selection.toggle /> is the header checkbox. It reads the page only: checked when every row of the page is, mixed when some are.
  • 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.

The whole page selected, every match on offer
Product Status
Trail runner Active
Rain shell Draft
Wool socks Active
Day pack Archived

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 indeterminate property, which a screen reader reports as "mixed". It is disabled while the page has no row to choose, unless you give it a disabled of 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

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store