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'); +});