Wirelist

Wirelist draws collections of records that are read one at a time rather than compared column by column: a feed of tickets, a page of episodes, a wall of property cards. It is three anonymous Blade components and plain CSS, with the row menu, the footer, load more and the themes of wiretables underneath.
A list or a table?
Both packages render records and are designed to share a screen.
- A table (wiretables) when every record has the same fields and the reader scans down a column: due dates, amounts, statuses.
- A list when each record is a small story of its own: a name, a line of context, a couple of tags, one or two numbers. No headers, because nothing is compared vertically, and records can sit side by side as cards.
A list paints with wiretables' tokens, so a table and a list on the same page share colours, radius and hover, and change together with the theme.
Installation
composer require edulazaro/wirelist
PHP 8.2+, Laravel 11+. wiretables comes with it. The service providers are auto discovered; there is no config and nothing to migrate.
The list reads its colours from wiretables, so both packages' CSS go on the page, wiretables first, core before theme. With Vite, at the very top of resources/css/app.css (postcss-import drops an @import placed after other rules):
@import "../../vendor/edulazaro/wiretables/resources/css/wiretables-core.css"; @import "../../vendor/edulazaro/wiretables/resources/css/themes/minimal.css"; @import "../../vendor/edulazaro/wirelist/resources/css/wirelist-core.css"; @import "../../vendor/edulazaro/wirelist/resources/css/themes/minimal.css";
Or publish them to public/vendor and link the same four files:
php artisan vendor:publish --tag=wiretables-assets php artisan vendor:publish --tag=wirelist-assets
wirelist.css and wiretables.css are bundles with every theme, handy for trying them out. A static list needs no JavaScript; the row menu needs Alpine with its anchor plugin and load more needs Livewire (Livewire 3 and 4 bundle both).
A first list
<x-wirelist> @forelse ($episodes as $episode) <x-wirelist.item wire:key="episode-{{ $episode->id }}" :title="$episode->title" :subtitle="$episode->guest_name" :href="route('episodes.show', $episode)" > <x-slot:aside> <span>{{ $episode->duration_minutes }} min</span> </x-slot:aside> </x-wirelist.item> @empty <x-wirelist.empty>No episodes yet.</x-wirelist.empty> @endforelse </x-wirelist>
One framed block with a line between episodes, the duration on the right, and a click anywhere on a row opens the episode. <x-wirelist.empty> is a <li>, so it goes inside the list and the frame stays in place.
Shapes
The items never change; the wrapper decides the shape.
| Attribute | Result |
|---|---|
items="divided" |
Default. One frame, a hairline between records |
items="separated" |
One card per record, with a gap between them |
layout="grid" |
Cards in as many columns as fit (at least --wtl-grid-min wide each). Always separated |
flush |
No side padding and no clipping, for a list inside a card that already has its own |
In a grid, an <img> in the leading slot becomes an edge to edge cover, the aside drops to the foot of the card and the actions float in the top right corner. Since the markup is the same, a list/grid switch is one bound attribute:
<x-wirelist items="separated" :layout="$view">...</x-wirelist>
A footer slot on the list renders under the records: pagination links, a total, or load more.
What an item carries
| Place | Given as | For |
|---|---|---|
title |
attribute, or slot when it needs markup | The record's name |
subtitle |
attribute | One muted line, skipped when empty |
leading |
slot | Avatar, icon, thumbnail; the cover in a grid |
badges |
slot | Labels beside the title (bring your own badge markup) |
aside |
slot | Short figures on the right: a count, a status, a date |
actions |
slot | The "⋯" menu, or a single button |
header |
slot | A strip across the top: "Overdue by 4 days" |
| default | slot | More lines under the subtitle: an excerpt, a progress bar |
What a click does:
href: the title becomes a link stretched over the whole item. Addnavigateforwire:navigate.action: an Alpine expression, run by a stretched<button>, for opening a modal or a panel in place.- neither: a plain title and no hover, for read-only records.
The link is stretched rather than wrapped around the item because HTML does not allow buttons or links inside a link. Badges, aside, actions and any link, button, input, select or label in the content sit above it and keep their own clicks.
<x-wirelist.item wire:key="order-{{ $order->id }}" :title="'Order '.$order->reference" :subtitle="$order->customer_name" :href="route('orders.show', $order)" navigate > <x-slot:leading><img src="{{ $order->thumbnail }}" alt="" width="48" height="48"></x-slot:leading> <x-slot:badges><span class="tag">Gift wrap</span></x-slot:badges> <x-slot:aside> <span>{{ $order->items_count }} items</span> <span>€{{ number_format($order->total, 2) }}</span> </x-slot:aside> </x-wirelist.item>
Under 40rem wide, a list keeps the actions next to the title and moves the aside to a line of its own below.
The row menu
The menu is wiretables' <x-wiretable.menu>, placed in the actions slot. Its panel is teleported to <body> and anchored to the button, so a rounded frame or a scrolling card never crops it, and opening it never opens the record underneath.
<x-slot:actions> <x-wiretable.menu :label="'Actions for '.$article->headline"> <x-wiretable.menu-item :href="route('articles.preview', $article)">Preview</x-wiretable.menu-item> <x-wiretable.menu-item wire:click="duplicate({{ $article->id }})">Duplicate</x-wiretable.menu-item> <x-wiretable.menu-separator /> <x-wiretable.menu-item wire:click="unpublish({{ $article->id }})" danger>Unpublish</x-wiretable.menu-item> </x-wiretable.menu> </x-slot:actions>
label is required: it is the icon button's accessible name, so name the record in it. A menu item is a link with href and a button otherwise, takes danger and a leading slot for an icon. The items are left aligned by default; align="right" moves their text to the right edge of the panel without moving the panel.
Load more
wiretables' WithLoadMore trait and its button work on a list unchanged:
use EduLazaro\Wiretables\Concerns\WithLoadMore; class NewsroomFeed extends Component { use WithLoadMore; public function render() { return view('livewire.newsroom-feed', [ 'articles' => $this->loadMoreFrom(Article::query()->latest('published_at')), ]); } }
<x-slot:footer> <x-wiretable.load-more :show="$hasMore" /> </x-slot:footer>
It reads every page shown so far from the start (30 per load, override perLoad()), so a record published between clicks never shifts the feed. Keep the wire:key on each item. WithSorting also works, with your own buttons calling sortBy(); see wiretables.
Theming
Themes are chosen with data-wire-theme, the attribute shared with wiremodal, wiretoast, wirepicker and wiretables, usually on <html> but on any element for one section. Load the theme from both packages:
<html data-wire-theme="toxic">
Twelve themes: soft, glass, gradient, neon, minimal, claude, chatgpt, studio, synthwave, megaflow, brutalist and toxic (swamp dark with a lime accent; titles take the accent and a linked card lights its edge on hover). wiretables' theme file already colours the list; wirelist's adds only the list touches: stripes, glows, the header strip. Dark mode follows wiretables (data-wire-theme-mode="dark" or .dark); neon and toxic are always dark.
Colours come in layers: --wire-* for the whole family, --wtb-* from wiretables (what a list paints with), and --wtl-* for the list's own geometry.
[data-wire-theme="minimal"] { --wire-accent: #0f766e; /* lists, tables, modals and toasts */ } .photo-wall { --wtl-grid-min: 12rem; --wtl-grid-gap: 8px; --wtl-cover-ratio: 1; }
Set --wtb-* overrides on the list or a container, not on :root: wiretables declares them again on every themed element.
Reference
| Component | Attribute | Default | |
|---|---|---|---|
<x-wirelist> |
items |
divided |
divided or separated |
layout |
list |
list or grid |
|
flush |
false |
No side padding, no clipping | |
<x-wirelist.item> |
title |
null |
Escaped; a title slot replaces it |
subtitle |
null |
Rendered only when filled | |
href |
null |
Stretched link over the item | |
navigate |
false |
wire:navigate on that link |
|
action |
null |
Alpine expression; ignored with href |
|
<x-wirelist.empty> |
The slot is the message |
Slots: the list takes footer; the item takes leading, badges, aside, actions, header, title and the default slot. Other attributes merge onto the outer element.
| Token | Default |
|---|---|
--wtl-item-padding |
14px 20px |
--wtl-item-gap |
8px |
--wtl-subtitle-font-size |
0.875rem |
--wtl-grid-min |
16rem |
--wtl-grid-gap |
16px |
--wtl-cover-ratio |
16 / 10 |
To change an item's markup, copy it to resources/views/components/wirelist/item.blade.php in your app, which takes precedence over the package's; it then stops receiving updates.
built and maintained by Edu Lazaro · MIT license