From 4141ebe7b0bef048ad66af926afd1f2ffbe8a9d9 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 15:04:50 +0200 Subject: [PATCH] Draw the search without Tailwind 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components.css | 1 + resources/css/components/search.css | 168 +++++++++++--------- resources/css/tailwind.css | 1 - resources/js/search.js | 4 +- resources/views/components/search.blade.php | 44 ++--- resources/views/showcase/layout.blade.php | 2 +- tests/Browser/PickingTest.php | 88 +++++++++- tests/Browser/ShowcaseTest.php | 4 +- tests/Feature/Components/SearchTest.php | 50 ++++-- 9 files changed, 241 insertions(+), 121 deletions(-) diff --git a/resources/css/components.css b/resources/css/components.css index c22b2871..6301bffd 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -28,6 +28,7 @@ @import './components/chip-set.css'; @import './components/choices.css'; @import './components/slider.css'; +@import './components/search.css'; /* Containment */ diff --git a/resources/css/components/search.css b/resources/css/components/search.css index 97eb04d5..035b8bc4 100644 --- a/resources/css/components/search.css +++ b/resources/css/components/search.css @@ -12,36 +12,46 @@ * Widths are M3's: never wider than 720px, and the bar grows to that width while it is focused — * `--search-width` is its resting width (the room it is given, by default) and * `--search-open-width` the focused one, so a caller that wants M3's 360px resting bar sets - * `--search-width: 22.5rem` on a wrapper. The leading and trailing padding is 24px unfocused and + * `--search-width: 360px` on a wrapper. The leading and trailing padding is 24px unfocused and * 16px focused, as the search specs table gives it. * - * [data-search] the root; data-open, data-full-screen, data-trigger - * [data-search-scrim] over the page while the view is docked - * [data-search-trigger] data-trigger="icon" only: the icon button that expands the view - * [data-search-bar] the pill, above the view - * [data-search-leading], [data-search-field] (the combobox around [data-search-input]), - * [data-search-clear], [data-search-trailing] - * [data-search-view] the container behind the bar - * [data-search-suggestions] before the first keystroke - * [data-search-results] once something is typed - * [data-search-status] the polite live region that counts whichever list is on screen + * Hovered and focused buttons in the bar take M3's state-layer opacities (tokens/state.css), and the + * docked scrim is M3's scrim role at 32%, which no system token names. The breakpoint that makes the + * view full screen is decided in resources/js/search.js (`upTo('medium')`, 600px). + * + * [data-md-search] the root; data-md-open, data-md-full-screen, data-md-trigger + * [data-md-search-scrim] over the page while the view is docked + * [data-md-search-trigger] data-md-trigger="icon" only: the icon button that expands the view + * [data-md-search-bar] the pill, above the view + * [data-md-search-leading], [data-md-search-field] (the combobox around + * [data-md-search-input]), [data-md-search-clear], [data-md-search-trailing] + * [data-md-search-view] the container behind the bar + * [data-md-search-suggestions] before the first keystroke + * [data-md-search-results] once something is typed; [data-md-search-empty] when nothing is + * [data-md-search-status] the polite live region that counts whichever list is on screen */ -@layer components { - [data-search] { - --search-height: 3.5rem; +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './icon.css'; + +@layer material.components { + [data-md-search] { + --search-height: 56px; /* M3: min 360px, max 720px, and wider while focused. */ --search-width: 100%; - --search-open-width: 45rem; + --search-open-width: 720px; + + position: relative; } - [data-search][data-open] { + [data-md-search][data-md-open] { z-index: 50; } /* Docked, the view opens over the page: M3 puts a scrim under it. Behind the bar and the view inside the root's own stacking context, above everything the page draws outside it. */ - [data-search-scrim] { + [data-md-search-scrim] { position: fixed; inset: 0; z-index: -1; @@ -53,20 +63,20 @@ } @starting-style { - [data-search-scrim] { + [data-md-search-scrim] { opacity: 0; } } - [data-search-bar] { + [data-md-search-bar] { position: relative; z-index: 1; display: flex; align-items: center; - gap: 0.25rem; + gap: var(--md-sys-measurement-space50); width: min(100%, var(--search-width)); height: var(--search-height); - padding-inline: 0.75rem; + padding-inline: 12px; border-radius: var(--md-sys-shape-corner-full); background-color: var(--md-sys-color-surface-container-high); color: var(--md-sys-color-on-surface); @@ -77,70 +87,70 @@ } /* Focused: wider, and the padding drops from 24px to the contained style's 16px. */ - [data-search][data-open] [data-search-bar] { + [data-md-search][data-md-open] [data-md-search-bar] { width: min(100%, var(--search-open-width)); - padding-inline: 0.25rem; + padding-inline: var(--md-sys-measurement-space50); } @media (hover: hover) { - [data-search]:not([data-open]) [data-search-bar]:hover { - background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, var(--md-sys-color-surface-container-high)); + [data-md-search]:not([data-md-open]) [data-md-search-bar]:hover { + background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), var(--md-sys-color-surface-container-high)); } } - [data-search][data-open] [data-search-bar] { + [data-md-search][data-md-open] [data-md-search-bar] { background-color: transparent; } - [data-search-leading], - [data-search-clear] { + [data-md-search-leading], + [data-md-search-clear] { display: grid; flex: none; place-items: center; - width: 3rem; - height: 3rem; + width: var(--md-sys-measurement-space600); + height: var(--md-sys-measurement-space600); border-radius: var(--md-sys-shape-corner-full); } - [data-search-leading] { + [data-md-search-leading] { color: var(--md-sys-color-on-surface); } - [data-search-clear] { + [data-md-search-clear] { color: var(--md-sys-color-on-surface-variant); cursor: pointer; } - [data-search-bar] button { + [data-md-search-bar] button { outline: none; transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); } @media (hover: hover) { - [data-search-bar] button:hover { - background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 8%, transparent); + [data-md-search-bar] button:hover { + background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), transparent); } } - [data-search-bar] button:focus-visible { - background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent); + [data-md-search-bar] button:focus-visible { + background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-focus-state-layer-opacity) * 100%), transparent); outline: 3px solid var(--md-sys-color-secondary); outline-offset: -3px; } /* The combobox wrapper is only a role holder; it has to lay out as the input used to. */ - [data-search-field] { + [data-md-search-field] { display: flex; flex: 1 1 0%; min-width: 0; height: 100%; } - [data-search-input] { + [data-md-search-input] { flex: 1 1 0%; min-width: 0; height: 100%; - padding-inline: 0.25rem; + padding-inline: var(--md-sys-measurement-space50); appearance: none; background: transparent; color: var(--md-sys-color-on-surface); @@ -150,41 +160,41 @@ letter-spacing: var(--md-sys-typescale-body-lg-tracking); } - [data-search-input]::placeholder { + [data-md-search-input]::placeholder { color: var(--md-sys-color-on-surface-variant); opacity: 1; } - [data-search-input]::-webkit-search-cancel-button, - [data-search-input]::-webkit-search-decoration { + [data-md-search-input]::-webkit-search-cancel-button, + [data-md-search-input]::-webkit-search-decoration { appearance: none; display: none; } - [data-search-bar]:has([data-search-input]:placeholder-shown) [data-search-clear] { + [data-md-search-bar]:has([data-md-search-input]:placeholder-shown) [data-md-search-clear] { display: none; } - [data-search-trailing] { + [data-md-search-trailing] { display: flex; flex: none; align-items: center; - gap: 0.25rem; - padding-inline-end: 0.25rem; + gap: var(--md-sys-measurement-space50); + padding-inline-end: var(--md-sys-measurement-space50); } - [data-search][data-open] [data-search-trailing] { + [data-md-search][data-md-open] [data-md-search-trailing] { display: none; } - [data-search-view] { + [data-md-search-view] { position: absolute; inset-inline: 0; top: 0; display: flex; flex-direction: column; max-width: var(--search-open-width); - max-height: min(40rem, 70dvh); + max-height: min(640px, 70dvh); padding-top: var(--search-height); overflow: hidden; border-radius: var(--md-sys-shape-corner-xl); @@ -198,7 +208,7 @@ } @starting-style { - [data-search-view] { + [data-md-search-view] { opacity: 0; scale: 1 0.9; } @@ -206,35 +216,35 @@ /* M3: the docked container is at least 240px tall — once there is something in it to be tall about; a search with nothing to show stays the height of its bar. */ - [data-search-view]:has([data-search-results], [data-search-suggestions]) { - min-height: 15rem; + [data-md-search-view]:has([data-md-search-results], [data-md-search-suggestions]) { + min-height: 240px; } /* No divider: that belongs to the divided style, which Expressive deprecates in favour of the contained one this file draws. The suggestions stand in the same place, before the first keystroke; only one of the two is ever on screen. */ - [data-search-results], - [data-search-suggestions] { + [data-md-search-results], + [data-md-search-suggestions] { min-height: 0; overflow-y: auto; overscroll-behavior: contain; - padding-block: 0.5rem; + padding-block: var(--md-sys-measurement-space100); } /* M3's second entry point: search as a secondary action, one icon button that expands into the full-screen view. The root keeps the button's 48px whether the view is open or not, so a toolbar does not shift under it, and the bar is the view's header rather than a resting bar. */ - [data-search][data-trigger="icon"] { + [data-md-search][data-md-trigger='icon'] { flex: none; - width: 3rem; - height: 3rem; + width: var(--md-sys-measurement-space600); + height: var(--md-sys-measurement-space600); } - [data-search-trigger] { + [data-md-search-trigger] { display: grid; place-items: center; - width: 3rem; - height: 3rem; + width: var(--md-sys-measurement-space600); + height: var(--md-sys-measurement-space600); border-radius: var(--md-sys-shape-corner-full); color: var(--md-sys-color-on-surface-variant); cursor: pointer; @@ -243,45 +253,55 @@ } @media (hover: hover) { - [data-search-trigger]:hover { - background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 8%, transparent); + [data-md-search-trigger]:hover { + background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), transparent); } } - [data-search-trigger]:focus-visible { - background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent); + [data-md-search-trigger]:focus-visible { + background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-focus-state-layer-opacity) * 100%), transparent); outline: 3px solid var(--md-sys-color-secondary); outline-offset: -3px; } - [data-search-trigger]:active { - background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent); + [data-md-search-trigger]:active { + background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-pressed-state-layer-opacity) * 100%), transparent); } - [data-search][data-trigger="icon"]:not([data-open]) [data-search-bar] { + [data-md-search][data-md-trigger='icon']:not([data-md-open]) [data-md-search-bar] { display: none; } /* Full screen, on a compact window. */ - [data-search][data-full-screen] [data-search-bar] { + [data-md-search][data-md-full-screen] [data-md-search-bar] { position: fixed; inset: 0 0 auto; width: auto; - height: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top))); + height: calc(var(--md-sys-measurement-space900) + var(--material-safe-top, env(safe-area-inset-top))); padding-top: var(--material-safe-top, env(safe-area-inset-top)); - padding-inline: 0.25rem; + padding-inline: var(--md-sys-measurement-space50); border-radius: 0; } - [data-search][data-full-screen] [data-search-view] { + [data-md-search][data-md-full-screen] [data-md-search-view] { position: fixed; inset: 0; max-width: none; max-height: none; - padding-top: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top))); + padding-top: calc(var(--md-sys-measurement-space900) + var(--material-safe-top, env(safe-area-inset-top))); /* The full-screen layout has its own container role, one step from the docked one. */ background-color: var(--md-sys-color-surface-container-low); border-radius: 0; box-shadow: none; } + + /* What the caller says when nothing matches: body-medium in on-surface-variant, padded like a + list row (16px across, 12px down). */ + [data-md-search-empty] { + padding: 12px var(--md-sys-measurement-space200); + color: var(--md-sys-color-on-surface-variant); + font: var(--md-sys-typescale-body-md); + letter-spacing: var(--md-sys-typescale-body-md-tracking); + font-variation-settings: normal; + } } diff --git a/resources/css/tailwind.css b/resources/css/tailwind.css index 69952f2f..32b14226 100644 --- a/resources/css/tailwind.css +++ b/resources/css/tailwind.css @@ -22,7 +22,6 @@ @import './components/menu.css'; /* Inputs, selection and data — leaving in step 36 */ -@import './components/search.css'; @import './components/datepicker.css'; @import './components/timepicker.css'; @import './components/table.css'; diff --git a/resources/js/search.js b/resources/js/search.js index fb59822e..85612782 100644 --- a/resources/js/search.js +++ b/resources/js/search.js @@ -78,11 +78,11 @@ document.addEventListener('alpine:init', () => { } // Suggestions and results never show together; count whichever one is on screen. - const list = [...this.$refs.view.querySelectorAll('[data-search-results], [data-search-suggestions]')] + const list = [...this.$refs.view.querySelectorAll('[data-md-search-results], [data-md-search-suggestions]')] .find((element) => element.getClientRects().length > 0) const items = list ? list.querySelectorAll('[role="listitem"], li') : [] const total = items.length || (list ? this.results().length : 0) - const suggesting = Boolean(list?.hasAttribute('data-search-suggestions')) + const suggesting = Boolean(list?.hasAttribute('data-md-search-suggestions')) this.announcement = total === 0 ? (announce.none ?? '') diff --git a/resources/views/components/search.blade.php b/resources/views/components/search.blade.php index 152fab5f..742ba8af 100644 --- a/resources/views/components/search.blade.php +++ b/resources/views/components/search.blade.php @@ -25,7 +25,11 @@ The bar is a combobox: the wrapper around the input carries `role="combobox"` with `aria-expanded` and `aria-controls`, because ARIA gives a bare textbox neither. The results are a list, and a polite live region says how many of them there are whenever they change — M3 asks - for both (docs/reference/m3/components-navigation-selection-inputs.md § Search). --}} + for both (docs/reference/m3/components-navigation-selection-inputs.md § Search). + + The root renders `data-md-search` with `data-md-trigger`, and `data-md-open` and + `data-md-full-screen` while they hold; the parts are `data-md-search-*`, drawn by + resources/css/components/search.css. `class` and `wire:key` land on the root. --}} @props([ 'placeholder' => null, @@ -56,13 +60,13 @@ x-on:focusout="leave($event)" x-on:pointerdown.outside="close()" x-trap.noscroll="fullScreen" - x-bind:data-open="open ? '' : null" - x-bind:data-full-screen="fullScreen ? '' : null" - data-search - data-trigger="{{ $trigger }}" - {{ $attributes->only(['class', 'wire:key'])->class(['relative']) }} + x-bind:data-md-open="open ? '' : null" + x-bind:data-md-full-screen="fullScreen ? '' : null" + data-md-search + data-md-trigger="{{ $trigger }}" + {{ $attributes->only(['class', 'style', 'wire:key']) }} > - + @if ($trigger === 'icon') @endif -
- +
+ - - @isset($trailing) - {{ $trailing }} + {{ $trailing }} @endisset
@isset($suggestions) -
{{ $suggestions }}
+
{{ $suggestions }}
@endisset @if ($slot->hasActualContent()) -
{{ $slot }}
+
{{ $slot }}
@elseif (isset($empty)) -

{{ $empty }}

+

{{ $empty }}

@endif
- +
diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php index c9f00244..60a8e393 100644 --- a/resources/views/showcase/layout.blade.php +++ b/resources/views/showcase/layout.blade.php @@ -84,7 +84,7 @@ if (! result || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || (event.button ?? 0) !== 0) return; event.preventDefault(); const url = new URL(result.url, window.location.href); - const search = this.$root.querySelector('[data-search]'); + const search = this.$root.querySelector('[data-md-search]'); this.query = ''; if (search) window.Alpine.$data(search).close(); if (url.pathname === window.location.pathname) { diff --git a/tests/Browser/PickingTest.php b/tests/Browser/PickingTest.php index 83def53b..a8be8a59 100644 --- a/tests/Browser/PickingTest.php +++ b/tests/Browser/PickingTest.php @@ -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 No files match.
+
+ Files + + @foreach (array_filter(['holiday.zip', 'contract.pdf'], fn ($file) => str_contains($file, $iconQuery)) as $file) + + @endforeach + + + + + + +
+ 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'"); }); diff --git a/tests/Browser/ShowcaseTest.php b/tests/Browser/ShowcaseTest.php index 566dc917..01edfe32 100644 --- a/tests/Browser/ShowcaseTest.php +++ b/tests/Browser/ShowcaseTest.php @@ -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]', ''); + ->assertSeeIn('[data-md-search-view]', ''); $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'); }); diff --git a/tests/Feature/Components/SearchTest.php b/tests/Feature/Components/SearchTest.php index b7b76409..ba177aee 100644 --- a/tests/Feature/Components/SearchTest.php +++ b/tests/Feature/Components/SearchTest.php @@ -4,7 +4,10 @@ it('draws a search bar whose combobox controls the view', function () { $html = (string) $this->blade(''); 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('/]*aria-expanded/s'); + expect($html)->toMatch('/]*aria-expanded/s'); expect($html)->not->toMatch('/]*aria-expanded/s'); }); @@ -30,7 +34,7 @@ it('announces how many results there are, politely', function () { $html = (string) $this->blade(''); 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('holiday.zip')) - ->toContain('data-search-results role="list"') + ->toContain('data-md-search-results role="list"') ->toContain('holiday.zip') ->and((string) $this->blade('No shares match.')) - ->toContain('No shares match.') - ->not->toContain('data-search-results role="list"'); + ->toContain('

No shares match.

') + ->not->toContain('data-md-search-results role="list"'); }); it('offers M3\'s search-icon entry point', function () { $html = (string) $this->blade(''); 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('')) - ->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('holiday.zip')) - ->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('') ->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'); +});