Draw the search without Tailwind

<x-search> renders data-md-search with data-md-trigger and binds
data-md-open and data-md-full-screen; its parts become data-md-search-*
and the empty row data-md-search-empty. search.css moves into
material.components on px, spacing and state tokens (plan step 36).
search.js and the showcase layout follow the renamed hooks.

Browser tests cover the polite result count as it changes, the icon
entry point expanding full screen and returning focus, and suggestions
swapping to results on the first key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 15:04:50 +02:00
co-authored by Claude Opus 5
parent 281c21b0a4
commit 4141ebe7b0
9 changed files with 241 additions and 121 deletions
+84 -4
View File
@@ -14,6 +14,8 @@ class PickProbe extends Component
public string $query = '';
public string $iconQuery = '';
public function render(): string
{
return <<<'BLADE'
@@ -35,6 +37,20 @@ class PickProbe extends Component
<x-slot:empty>No files match.</x-slot:empty>
</x-search>
<div id="toolbar" style="display: flex; align-items: center; gap: 8px">
<span>Files</span>
<x-search id="find-icon" trigger="icon" label="Find files" wire:model.live="iconQuery">
@foreach (array_filter(['holiday.zip', 'contract.pdf'], fn ($file) => str_contains($file, $iconQuery)) as $file)
<button type="button" wire:key="icon-result-{{ $file }}">{{ $file }}</button>
@endforeach
<x-slot:suggestions>
<button type="button">Recent: holiday.zip</button>
<button type="button">Recent: review.mp4</button>
<button type="button">Recent: notes.txt</button>
</x-slot:suggestions>
</x-search>
</div>
</div>
BLADE;
}
@@ -149,7 +165,7 @@ it('says so when nothing matches, and closes on a press outside or a chosen resu
// The docked view opens over a scrim that covers the rest of the page (inputs.md IN-09), so a
// press outside the view lands on the scrim.
$page->click('[data-search-scrim]')
$page->click('[data-md-search]:has(#find) [data-md-search-scrim]')
->assertScript("getComputedStyle({$view}).display === 'none'");
$page->click('#find')
@@ -163,8 +179,72 @@ 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("document.querySelector('[data-md-search]').hasAttribute('data-md-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')");
->click('[data-md-search]:has(#find) [data-md-search-back]')
->assertScript("! document.querySelector('[data-md-search]').hasAttribute('data-md-open')");
});
it('says how many results there are, politely, as they change', function () {
$status = "document.querySelector('[data-md-search]:has(#find) [data-md-search-status]')";
$page = pickProbe()
->assertScript("{$status}.getAttribute('aria-live') === 'polite' && {$status}.getAttribute('aria-atomic') === 'true'")
->click('#find')
->assertScript("{$status}.textContent === '3 results'");
$page->type('#find', 'con')
->assertSeeIn('#query', 'con')
->assertScript("{$status}.textContent === '1 result'");
$page->type('#find', 'zzz')
->assertSeeIn('#query', 'zzz')
->assertScript("{$status}.textContent === 'No results'");
// Closed from a result (Escape in the field itself would first clear the query), the region
// falls silent.
$page->type('#find', 'hol')
->assertSeeIn('#query', 'hol')
->assertScript("{$status}.textContent === '1 result'");
$page->keys('#find', 'ArrowDown')
->assertScript("document.activeElement.textContent.trim() === 'holiday.zip'");
$page->keys(':focus', 'Escape')
->assertScript("{$status}.textContent === ''");
});
it('expands the search icon into the full-screen view, and hands focus back to the icon on close', function () {
$root = "document.querySelector('[data-md-search]:has(#find-icon)')";
$trigger = "{$root}.querySelector('[data-md-search-trigger]')";
$page = pickProbe()
->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-bar]')).display === 'none'")
->assertScript("{$root}.getBoundingClientRect().width === 48 && {$trigger}.getAttribute('aria-expanded') === 'false'");
$page->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]')
->assertScript("{$root}.hasAttribute('data-md-open') && {$root}.hasAttribute('data-md-full-screen')")
->assertScript("document.activeElement.id === 'find-icon'")
->assertScript("(() => { const view = {$root}.querySelector('[data-md-search-view]').getBoundingClientRect(); return view.top === 0 && view.left === 0 && view.width === innerWidth; })()")
// The toolbar does not shift under the expanded search.
->assertScript("{$root}.getBoundingClientRect().width === 48");
$page->keys('#find-icon', 'Escape')
->assertScript("! {$root}.hasAttribute('data-md-open')")
->assertScript("document.activeElement === {$trigger}")
->assertScript("getComputedStyle({$trigger}).display !== 'none'");
});
it('shows the suggestions until the first key, then the results, and counts whichever is on screen', function () {
$root = "document.querySelector('[data-md-search]:has(#find-icon)')";
$shown = fn (string $list): string => "{$root}.querySelector('[data-md-search-{$list}]').checkVisibility()";
$page = pickProbe()
->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]')
->assertScript("({$shown('suggestions')}) && ! ({$shown('results')})")
->assertScript("{$root}.querySelector('[data-md-search-status]').textContent === '3 suggestions'");
$page->type('#find-icon', 'h')
->assertScript("! ({$shown('suggestions')}) && ({$shown('results')})")
->assertScript("{$root}.querySelector('[data-md-search-status]').textContent === '1 result'");
});