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:
co-authored by
Claude Opus 5
parent
281c21b0a4
commit
4141ebe7b0
@@ -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'");
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ it('finds a component from anywhere and opens its section', function () {
|
||||
->assertScript("document.activeElement.id === 'showcase-search'");
|
||||
|
||||
$page->type('#showcase-search', 'datepicker')
|
||||
->assertSeeIn('[data-search-view]', '<x-datepicker>');
|
||||
->assertSeeIn('[data-md-search-view]', '<x-datepicker>');
|
||||
|
||||
$page->keys('#showcase-search', 'Enter')
|
||||
->assertScript("location.pathname.endsWith('/material/pickers')")
|
||||
@@ -51,5 +51,5 @@ it('says so when nothing matches', function () {
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
|
||||
->click('#showcase-search')
|
||||
->type('#showcase-search', 'zzzz')
|
||||
->assertSeeIn('[data-search-view]', 'Nothing matches');
|
||||
->assertSeeIn('[data-md-search-view]', 'Nothing matches');
|
||||
});
|
||||
|
||||
@@ -4,7 +4,10 @@ it('draws a search bar whose combobox controls the view', function () {
|
||||
$html = (string) $this->blade('<x-search id="find" placeholder="Search shares" wire:model.live.debounce.300ms="query" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-search')
|
||||
->toContain('data-md-search')
|
||||
->toContain('data-md-trigger="bar"')
|
||||
->toContain('x-bind:data-md-open="open ? \'\' : null"')
|
||||
->toContain('x-bind:data-md-full-screen="fullScreen ? \'\' : null"')
|
||||
->toContain('role="search"')
|
||||
->toContain('type="search"')
|
||||
->toContain('placeholder="Search shares"')
|
||||
@@ -15,14 +18,15 @@ it('draws a search bar whose combobox controls the view', function () {
|
||||
->toContain('aria-expanded="false"')
|
||||
->toContain('wire:model.live.debounce.300ms="query"')
|
||||
->toContain('id="find-view"')
|
||||
->toContain('data-search-clear')
|
||||
->toContain('data-search-scrim')
|
||||
->toContain('data-md-search-clear')
|
||||
->toContain('data-md-search-scrim')
|
||||
->toContain('aria-label="Back"')
|
||||
->toContain('materialSearch(false,')
|
||||
->not->toContain('data-search-results');
|
||||
->not->toContain('data-md-search-results')
|
||||
->not->toContain('class="relative"');
|
||||
|
||||
// The state belongs to the combobox, never to the bare textbox ARIA gives it to.
|
||||
expect($html)->toMatch('/<span\s+data-search-field[^>]*aria-expanded/s');
|
||||
expect($html)->toMatch('/<span\s+data-md-search-field[^>]*aria-expanded/s');
|
||||
expect($html)->not->toMatch('/<input[^>]*aria-expanded/s');
|
||||
});
|
||||
|
||||
@@ -30,7 +34,7 @@ it('announces how many results there are, politely', function () {
|
||||
$html = (string) $this->blade('<x-search />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-search-status')
|
||||
->toContain('data-md-search-status class="md-visually-hidden"')
|
||||
->toContain('aria-live="polite"')
|
||||
->toContain('aria-atomic="true"')
|
||||
->toContain('No results')
|
||||
@@ -40,19 +44,19 @@ it('announces how many results there are, politely', function () {
|
||||
|
||||
it('shows the results as a list, or what to say when there are none', function () {
|
||||
expect((string) $this->blade('<x-search><a href="/shares/1">holiday.zip</a></x-search>'))
|
||||
->toContain('data-search-results role="list"')
|
||||
->toContain('data-md-search-results role="list"')
|
||||
->toContain('<a href="/shares/1">holiday.zip</a>')
|
||||
->and((string) $this->blade('<x-search><x-slot:empty>No shares match.</x-slot:empty></x-search>'))
|
||||
->toContain('No shares match.')
|
||||
->not->toContain('data-search-results role="list"');
|
||||
->toContain('<p data-md-search-empty>No shares match.</p>')
|
||||
->not->toContain('data-md-search-results role="list"');
|
||||
});
|
||||
|
||||
it('offers M3\'s search-icon entry point', function () {
|
||||
$html = (string) $this->blade('<x-search id="find" trigger="icon" label="Search shares" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-trigger="icon"')
|
||||
->toContain('data-search-trigger')
|
||||
->toContain('data-md-trigger="icon"')
|
||||
->toContain('data-md-search-trigger')
|
||||
->toContain('x-ref="trigger"')
|
||||
->toContain('x-on:click="expand()"')
|
||||
->toContain('aria-label="Search shares"')
|
||||
@@ -61,11 +65,11 @@ it('offers M3\'s search-icon entry point', function () {
|
||||
->toContain('materialSearch(false,')
|
||||
->toContain("'icon'")
|
||||
// The bar is still there: it becomes the expanded view's header.
|
||||
->toContain('data-search-bar');
|
||||
->toContain('data-md-search-bar');
|
||||
|
||||
expect((string) $this->blade('<x-search />'))
|
||||
->toContain('data-trigger="bar"')
|
||||
->not->toContain('data-search-trigger');
|
||||
->toContain('data-md-trigger="bar"')
|
||||
->not->toContain('data-md-search-trigger');
|
||||
});
|
||||
|
||||
it('shows suggestions until the first keystroke, then the results', function () {
|
||||
@@ -77,7 +81,7 @@ it('shows suggestions until the first keystroke, then the results', function ()
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('data-search-suggestions role="list"')
|
||||
->toContain('data-md-search-suggestions role="list"')
|
||||
->toContain('x-show="query === \'\'"')
|
||||
->toContain('Recent shares')
|
||||
->toContain('x-show="query !== \'\'"')
|
||||
@@ -87,7 +91,7 @@ it('shows suggestions until the first keystroke, then the results', function ()
|
||||
|
||||
// Without the slot the results stand alone and are never hidden.
|
||||
expect((string) $this->blade('<x-search><a href="/shares/1">holiday.zip</a></x-search>'))
|
||||
->not->toContain('data-search-suggestions')
|
||||
->not->toContain('data-md-search-suggestions')
|
||||
->not->toContain('x-show="query !== \'\'"');
|
||||
});
|
||||
|
||||
@@ -97,7 +101,19 @@ it('names the input, trails the bar, and stays docked on request', function () {
|
||||
expect($html)
|
||||
->toContain('placeholder="Search"')
|
||||
->toContain('aria-label="Search your shares"')
|
||||
->toContain('data-search-trailing')
|
||||
->toContain('data-md-search-trailing')
|
||||
->toContain('<button>AR</button>')
|
||||
->toContain('materialSearch(true,');
|
||||
});
|
||||
|
||||
it('draws the bar and the view from its stylesheet, at M3\'s widths', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/search.css');
|
||||
|
||||
expect($css)->toContain('@layer material.components')
|
||||
->toMatch('/\\[data-md-search\\] \\{\\s*--search-height: 56px;[^}]*--search-open-width: 720px;/')
|
||||
->toContain('[data-md-search][data-md-full-screen] [data-md-search-view] {')
|
||||
->toContain("[data-md-search][data-md-trigger='icon']:not([data-md-open]) [data-md-search-bar] {")
|
||||
->not->toMatch('/\\d(?:\\.\\d+)?rem/')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/search.css';")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('search.css');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user