EN
Getting started

List pages

This page shows you how to build a record list with search, filters, sorting and pagination by hand, using the WithListing trait and Acun components.

The trait handles search, sorting, pagination and bulk selection for you; you write the table and the filters. If you want a ready-made table whose columns and filters you define in PHP, see the DataTable guide.

Note

WithListing and search are part of the PRO package, which comes installed with Acun themes. All components on this page (acun:table, acun:sortable-th, acun:search, acun:selection-bar…) are free. You can also use them with your own query.

Quick example

app/Livewire/Customers/Index.php:

namespace App\Livewire\Customers;

use Acun\Ui\Livewire\Concerns\WithListing;
use Acun\Ui\Search\SearchField;
use Acun\Ui\Search\SearchMode;
use App\Models\Customer;
use Illuminate\Database\Eloquent\Builder;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;

#[Layout('layouts.admin.vertical')]
#[Title('Customers')]
final class Index extends Component
{
    use WithListing;

    // The list is sorted by this column when it first opens.
    protected function defaultSortField(): string
    {
        return 'name';
    }

    // Columns the user can sort by.
    protected function sortableFields(): array
    {
        return ['name', 'email', 'created_at'];
    }

    // Search: a word anywhere in the name, the start of the email.
    protected function searchFields(): array
    {
        return [
            SearchField::make('name', SearchMode::Contains),
            SearchField::make('email'),
        ];
    }

    // IDs of the rows on the current page (bulk selection uses this).
    protected function currentPageKeys(): array
    {
        return $this->query()->forPage($this->getPage(), $this->perPage)->pluck('id')->all();
    }

    private function query(): Builder
    {
        return $this->applySearch(Customer::query())
            ->orderBy($this->sortField, $this->sortDirection);
    }

    public function render()
    {
        $this->fillPageSelection();

        return view('admin.customers.index', ['customers' => $this->query()->paginate($this->perPage)]);
    }
}

resources/views/admin/customers/index.blade.php:

<section class="mx-auto w-full max-w-7xl space-y-6">
    <acun:page-header :title="__('Customers')" />

    <acun:list-toolbar>
        <acun:slot:start><acun:per-page-select :options="$this->pageSizeOptions()" /></acun:slot:start>
        <acun:search wire:model.live.debounce.300ms="search" placeholder="Search by name or email" />
    </acun:list-toolbar>

    <acun:table>
        <table class="ui-table">
            <thead>
                <tr>
                    <acun:sortable-th field="name" :sort-field="$sortField" :sort-direction="$sortDirection">Name</acun:sortable-th>
                    <acun:sortable-th field="email" :sort-field="$sortField" :sort-direction="$sortDirection">Email</acun:sortable-th>
                </tr>
            </thead>
            <tbody>
                @foreach ($customers as $customer)
                    <tr wire:key="customer-{{ $customer->id }}">
                        <td>{{ $customer->name }}</td>
                        <td>{{ $customer->email }}</td>
                    </tr>
                @endforeach
            </tbody>
        </table>
    </acun:table>

    {{ $customers->links() }}
</section>

The search runs on the name_search and email_search columns that the database generates. Add these columns in a migration:

Schema::table('customers', function (Blueprint $table) {
    $table->searchColumn('name', index: false);   // searched anywhere; an index wouldn't help
    $table->searchColumn('email');                // searched from the start; indexed
});

Now the list narrows as you type in the search box, and clicking a header sorts it. Turkish letters and letter case don't matter: typing "ayse" finds "Ayşe". The search text and the sort order are kept in the address bar as ?q=, ?sort= and ?direction=. Add the route and the menu entry as shown in Your first page.

Common options

Methods you write in your component:

Method What it does Example
defaultSortField() The default sort column (required) 'name'
currentPageKeys() IDs of the rows on the current page (required) the example above
sortableFields() Columns that can be sorted; by default only defaultSortField() ['name', 'email']
perPageOptions() Page sizes to choose from; by default [10, 25, 50, 100] [10, 25, 50]
searchFields() Fields to search; see Search [SearchField::make('email')]

Adding a filter

A filter is a property of your own component. With #[Url], it's kept in the address bar. When it changes, go back to the first page and clear the selection:

use Livewire\Attributes\Url;

#[Url(history: true)]
public string $status = '';

public function updated(string $property): void
{
    if ($property === 'status') {
        $this->resetPage();
        $this->clearSelection();
    }
}

public function activeFilterCount(): int
{
    return $this->status !== '' ? 1 : 0;
}

Apply the filter in the query: ->when($this->status !== '', fn (Builder $q) => $q->where('status', $this->status)). Put the filter fields in a drawer; acun:filter-button opens the drawer and shows the number of active filters:

<acun:list-toolbar>
    <acun:search wire:model.live.debounce.300ms="search" placeholder="Search by name or email" />
    <acun:slot:actions>
        <acun:filter-button drawer="customer-filters" :count="$this->activeFilterCount()" />
    </acun:slot:actions>
</acun:list-toolbar>

<acun:drawer name="customer-filters" docked title="Filters" compact>
    <acun:select wire:model.live="status" label="Status" :options="['' => 'All', 'active' => 'Active', 'passive' => 'Passive']" />
</acun:drawer>

Adding bulk actions

Add a checkbox to the header and to every row. Place the selection bar after the table and the pagination:

{{-- first cell of the <thead> row --}}
<th class="w-10"><acun:checkbox wire:model.live="pageSelected" :checked="$pageSelected" aria-label="Select all" /></th>

{{-- first cell of each row inside @foreach --}}
<td><acun:checkbox wire:model.live="selected" :value="$customer->id" :id="'customer-'.$customer->id" aria-label="Select" /></td>

{{-- after the {{ $customers->links() }} line --}}
<acun:selection-bar :count="$this->selectionCount($customers->total())" :all-matching="$allMatchingSelected" :total="$this->selectionCount($customers->total())">
    <acun:button variant="soft-danger" size="sm" wire:click="bulkArchive">Archive</acun:button>
</acun:selection-bar>

In a bulk action, always apply the selection to the page's own query with applySelection():

public function bulkArchive(): void
{
    $this->query()
        ->tap(fn (Builder $q) => $this->applySelection($q))
        ->update(['archived' => true]);

    $this->clearSelection();
}

The header checkbox selects every record matching the search and filters, across all pages. In that state, a row you uncheck goes into the $excluded list, and the bulk action doesn't touch it. Changing the page or the page size doesn't clear the selection. Changing the search or a filter does. The bar's "Clear selection" button calls clearSelection().

Security

  • orderBy($this->sortField, $this->sortDirection) is safe to use. On every request, the trait checks the sort and page size values against the allowed lists.
  • In bulk actions, don't use the selected IDs directly with whereKey($this->selected). Apply them to the page's query with applySelection(). That way, records the user can't see are never touched, and the other pages and unchecked rows are taken into account.

Advanced

The trait's properties and methods

Property / method What it does
$search The search text; ?q= in the address bar
$sortField, $sortDirection Sorting; ?sort= and ?direction= in the address bar
$perPage Page size; 25 by default
$selected, $pageSelected The selected records and the header checkbox
$allMatchingSelected, $excluded Whether all matching records are selected; rows unchecked meanwhile
applySelection($query) Narrows a bulk action query to the selection
selectionCount($total) The number of selected records
sortBy($field) Changes the sort order; acun:sortable-th calls it
clearSelection() Clears the selection; called automatically when the search changes
applySearch($query, $term = null, $fields = null) Searches for $search in the searchFields() fields
searchDriver() The search driver; acun-ui.search.driver by default
pageSizeOptions() Page sizes for the view; comes from perPageOptions()
fillPageSelection() While "all" is selected, adds the new page's rows to the selection

Call fillPageSelection() at the start of render(). In single-file components, call it at the start of with().

How values are checked

$sortField, $sortDirection and $perPage can be changed from the browser. The trait corrects them on every request:

  • A column not in sortableFields() falls back to the default column.
  • A direction other than asc or desc becomes asc.
  • A size not in perPageOptions() falls back to the first value in the list.

Any value you assign to $perPage in mount() must also be in perPageOptions(). The example below sets the page size to 10. If the address bar has no ?direction=, it opens the list in descending order:

public function mount(): void
{
    $this->perPage = 10;

    if (! request()->query->has('direction')) {
        $this->sortDirection = 'desc';
    }
}

applySearch() also accepts another term and field list. For details and search columns, see the Search guide.

Learn more

  • A complete, working example: in the demo app, the "Manually with WithListing" tab of Tables › DataTable › Server-side (Ajax).
  • DataTable: a ready-made table whose columns you define in PHP.
  • DataTable examples
  • Search: search columns, modes and large tables.
  • Form modal: adding and editing records from the list.
Acun UIDesigned for people.