Add chips, choices and search
tests / lint (push) Successful in 1m4s
tests / feature (8.4) (push) Successful in 1m8s
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (chrome, chromium) (push) Successful in 3m6s
tests / browser (firefox, firefox) (push) Successful in 3m45s
tests / browser (safari, webkit) (push) Successful in 5m0s

M3's assist, filter, input and suggestion chips with chip sets; choices
as filter chips or a searchable combobox whose list is an anchored
popover; and the search bar that opens into a docked or full-screen
search view.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 08:07:59 +02:00
co-authored by Claude Opus 5
parent 7f92f1cb29
commit 0fee2a0952
21 changed files with 2061 additions and 2 deletions
+174
View File
@@ -0,0 +1,174 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
class ChipProbe extends Component
{
/** @var list<string> */
public array $kinds = ['photos'];
/** @var list<string> */
public array $tags = ['php', 'js', 'css'];
public function onlyVideo(): void
{
$this->kinds = ['video'];
}
public function removeTag(string $tag): void
{
$this->tags = array_values(array_diff($this->tags, [$tag]));
}
public function render(): string
{
return <<<'BLADE'
<div class="space-y-4 p-4">
<p>kinds: <span id="kinds">{{ implode(',', $kinds) }}</span></p>
<x-chip-set label="Kinds">
@foreach (['photos' => 'Photos', 'documents' => 'Documents', 'video' => 'Video'] as $value => $name)
<x-chip type="filter" :label="$name" :value="$value" wire:model.live="kinds" wire:key="kind-{{ $value }}" />
@endforeach
</x-chip-set>
<x-button label="Only video" wire:click="onlyVideo" />
<p>tags: <span id="tags">{{ implode(',', $tags) }}</span></p>
<x-chip-set label="Tags">
@foreach ($tags as $tag)
<x-chip type="input" :label="$tag" removable wire:remove="removeTag('{{ $tag }}')" wire:key="tag-{{ $tag }}" />
@endforeach
</x-chip-set>
</div>
BLADE;
}
}
function chipProbe()
{
Livewire::component('chip-probe', ChipProbe::class);
Route::middleware('web')->get('/chip-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:chip-probe />
@livewireScripts
</body>
</html>
BLADE));
return visit('/chip-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
function chipShowcase()
{
return visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
/**
* The script for a showcase filter chip's checkbox, found by its value.
*/
function filterInput(string $value): string
{
return "document.querySelector('#chips input[type=\"checkbox\"][value=\"{$value}\"]')";
}
it('toggles a filter chip with a click and with Space, and grows its check in', function () {
$check = fn (string $value): string => filterInput($value).".parentElement.querySelector('[data-chip-check]').parentElement.getBoundingClientRect().width";
$page = chipShowcase()->assertNoJavaScriptErrors();
$page->click('#chips label:has-text("Documents")')
->assertScript(filterInput('documents').'.checked')
->assertScript("Math.round({$check('documents')}) === 18")
->assertScript('getComputedStyle('.filterInput('documents').".parentElement).backgroundColor !== 'rgba(0, 0, 0, 0)'")
->assertScript("window.eval(\"Alpine.\$data(document.querySelector('#chips input[value=documents]')).kinds.join(',')\") === 'photos,documents'");
// A key press first, so the browser is in keyboard modality; focused directly, not tabbed to,
// because WebKit leaves form controls out of the Tab order unless full keyboard access is on.
$page->keys('body', 'Tab');
$page->script(filterInput('archives').'.focus()');
$page->keys(':focus', 'Space')
->assertScript(filterInput('archives').'.checked')
->assertScript("Math.round({$check('archives')}) === 18");
$page->keys(':focus', 'Space')
->assertScript('! '.filterInput('archives').'.checked')
->assertScript("Math.round({$check('archives')}) === 0");
});
it('binds filter chips to a Livewire array, and a server change reaches them after a morph', function () {
$input = fn (string $value): string => "document.querySelector('input[value=\"{$value}\"]')";
$page = chipProbe()->assertNoJavaScriptErrors();
$page->assertScript("{$input('photos')}.checked && ! {$input('documents')}.checked");
$page->click('label:has-text("Documents")')
->assertSeeIn('#kinds', 'photos,documents');
$page->click('button:has-text("Only video")')
->assertSeeIn('#kinds', 'video')
->assertScript("! {$input('photos')}.checked && ! {$input('documents')}.checked && {$input('video')}.checked")
->assertScript("getComputedStyle({$input('video')}.parentElement).backgroundColor !== 'rgba(0, 0, 0, 0)'")
->assertScript("getComputedStyle({$input('photos')}.parentElement).backgroundColor === 'rgba(0, 0, 0, 0)'");
});
it('removes a Livewire input chip with its button and with Delete, focusing the chip after it', function () {
$page = chipProbe();
$page->click('button[aria-label="Remove js"]')
->assertSeeIn('#tags', 'php,css')
->assertScript("document.querySelector('button[aria-label=\"Remove js\"]') === null");
$page->script("document.querySelector('button[aria-label=\"Remove php\"]').focus()");
$page->keys(':focus', 'Delete')
->assertScript("document.querySelector('#tags').textContent === 'css'")
->assertScript("document.querySelector('button[aria-label=\"Remove php\"]') === null")
->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove css'");
});
it('removes an Alpine input chip with Backspace, focusing the chip before it', function () {
$remove = fn (string $who): string => "document.querySelector('#chips button[aria-label=\"Remove {$who}@example.com\"]')";
$page = chipShowcase();
// Named in the browser: the chips come from x-for, so the server never knew their labels.
$page->assertScript("{$remove('ben')} !== null");
$page->script("{$remove('ben')}.focus()");
$page->keys(':focus', 'Backspace')
->assertScript("{$remove('ben')} === null")
->assertScript("{$remove('anna')} !== null && {$remove('chiara')} !== null")
->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove anna@example.com'");
});
it('scrolls a chip set sideways, fading the edge it can still scroll towards', function () {
$row = "document.querySelector('#chips [data-chip-set][x-data]')";
$page = chipShowcase();
$page->assertScript("{$row}.scrollWidth > {$row}.clientWidth")
->assertScript("{$row}.hasAttribute('data-scroll-end') && ! {$row}.hasAttribute('data-scroll-start')")
->assertScript("getComputedStyle({$row}).flexWrap === 'nowrap'");
$page->script("{$row}.querySelector('input[value=\"unopened\"]').focus()");
$page->assertScript("Math.abs({$row}.scrollLeft) > 0")
->assertScript("{$row}.hasAttribute('data-scroll-start')")
->assertScript("getComputedStyle({$row}).getPropertyValue('--chip-fade-start').trim() === '1.5rem'");
});
+168
View File
@@ -0,0 +1,168 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
class PickProbe extends Component
{
/** @var list<int> */
public array $days = [2];
public ?string $zone = 'Europe/Zurich';
public string $query = '';
public function render(): string
{
return <<<'BLADE'
<div class="grid max-w-md gap-6 p-4">
<p>days: <span id="days">{{ json_encode($days) }}</span></p>
<p>zone: <span id="zone">{{ $zone }}</span></p>
<p>query: <span id="query">{{ $query }}</span></p>
<x-choices label="Days" wire:model.live="days" :options="[['id' => 1, 'name' => 'Mon'], ['id' => 2, 'name' => 'Tue'], ['id' => 3, 'name' => 'Wed']]" />
<div id="clip" class="h-24 overflow-hidden rounded-corner-md bg-surface-container p-2">
<x-choices id="zone-field" label="Time zone" searchable wire:model.live="zone" :options="[['id' => 'Europe/Berlin', 'name' => 'Berlin'], ['id' => 'Europe/Zurich', 'name' => 'Zurich'], ['id' => 'UTC', 'name' => 'UTC', 'disabled' => true]]" />
</div>
<x-search id="find" wire:model.live="query" placeholder="Search files">
@foreach (array_filter(['holiday.zip', 'contract.pdf', 'review.mp4'], fn ($file) => $query === '' || str_contains($file, $query)) as $file)
<button type="button" wire:key="result-{{ $file }}" class="block w-full px-4 py-3 text-start">{{ $file }}</button>
@endforeach
<x-slot:empty>No files match.</x-slot:empty>
</x-search>
</div>
BLADE;
}
}
function pickProbe()
{
Livewire::component('pick-probe', PickProbe::class);
Route::middleware('web')->get('/pick-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:pick-probe />
@livewireScripts
</body>
</html>
BLADE));
return visit('/pick-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
it('keeps the values of filter chips as the property\'s own type', function () {
pickProbe()
->assertAttribute('button[aria-pressed="true"]', 'aria-pressed', 'true')
->click('button:has-text("Mon")')
->assertSeeIn('#days', '[2,1]')
->click('button:has-text("Tue")')
->assertSeeIn('#days', '[1]')
->assertScript("[...document.querySelectorAll('[aria-pressed=\"true\"]')].map((chip) => chip.textContent.trim()).join() === 'Mon'");
});
it('filters a searchable choice as it is typed and chooses from the keyboard', function () {
$list = "document.querySelector('#zone-field-list')";
$page = pickProbe()
->assertValue('#zone-field', 'Zurich')
->click('#zone-field')
->assertScript("{$list}.matches(':popover-open')")
->assertAttribute('#zone-field', 'aria-expanded', 'true');
$page->type('#zone-field', 'ber')
->assertScript("{$list}.querySelectorAll('[role=\"option\"]').length === 1");
$page->keys('#zone-field', 'Enter')
->assertSeeIn('#zone', 'Europe/Berlin')
->assertValue('#zone-field', 'Berlin')
->assertScript("! {$list}.matches(':popover-open')");
});
it('puts a searchable choice back on Escape and never chooses a disabled option', function () {
$page = pickProbe()->click('#zone-field')->type('#zone-field', 'ber');
$page->keys('#zone-field', 'Escape')
->assertValue('#zone-field', 'Zurich')
->assertSeeIn('#zone', 'Europe/Zurich');
$page->click('#zone-field')->type('#zone-field', 'UTC')->keys('#zone-field', 'Enter')
->assertSeeIn('#zone', 'Europe/Zurich');
});
it('opens a searchable choice\'s list above a container that clips', function () {
pickProbe()
->click('#zone-field')
->assertScript(<<<'JS'
(() => {
const list = document.querySelector('#zone-field-list');
const clip = document.querySelector('#clip').getBoundingClientRect();
const box = list.getBoundingClientRect();
const probe = document.elementFromPoint(box.left + box.width / 2, box.bottom - 8);
return box.bottom > clip.bottom && list.contains(probe);
})()
JS);
});
it('opens the search view with the results Livewire renders for the query', function () {
$view = "document.querySelector('#find-view')";
$page = pickProbe()
->click('#find')
->assertScript("getComputedStyle({$view}).display !== 'none'")
->assertAttribute('#find', 'aria-expanded', 'true');
$page->type('#find', 'con')
->assertSeeIn('#query', 'con')
->assertScript("[...{$view}.querySelectorAll('button')].map((button) => button.textContent.trim()).join() === 'contract.pdf'");
$page->keys('#find', 'ArrowDown')
->assertScript("document.activeElement.textContent.trim() === 'contract.pdf'");
$page->keys(':focus', 'Escape')
->assertScript("getComputedStyle({$view}).display === 'none'")
->assertScript("document.activeElement.id === 'find'")
->assertAttribute('#find', 'aria-expanded', 'false');
});
it('says so when nothing matches, and closes on a press outside or a chosen result', function () {
$view = "document.querySelector('#find-view')";
$page = pickProbe()
->click('#find')
->type('#find', 'zzz')
->assertSeeIn('#find-view', 'No files match.');
// The open view covers what is under it, so the press lands above it.
$page->click('#days')
->assertScript("getComputedStyle({$view}).display === 'none'");
$page->click('#find')
->clear('#find')
->assertScript("{$view}.querySelectorAll('button').length === 3")
->click('#find-view button:has-text("review.mp4")')
->assertScript("getComputedStyle({$view}).display === 'none'");
});
it('takes the whole screen on a compact window, with a back arrow', function () {
$page = pickProbe()->resize(400, 800);
$page->click('#find')
->assertScript("document.querySelector('[data-search]').hasAttribute('data-full-screen')")
->assertScript("(() => { const box = document.querySelector('#find-view').getBoundingClientRect(); return box.top === 0 && box.width === 400; })()")
->click('[data-search-back]')
->assertScript("! document.querySelector('[data-search]').hasAttribute('data-open')");
});