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
+1
View File
@@ -28,6 +28,7 @@
@import './components/chip-set.css';
@import './components/choices.css';
@import './components/slider.css';
@import './components/search.css';
/* Containment */
+94 -74
View File
@@ -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;
}
}
-1
View File
@@ -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';
+2 -2
View File
@@ -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 ?? '')
+24 -20
View File
@@ -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']) }}
>
<div data-search-scrim x-cloak x-show="open && ! fullScreen" x-on:pointerdown="close()" aria-hidden="true"></div>
<div data-md-search-scrim x-cloak x-show="open && ! fullScreen" x-on:pointerdown="close()" aria-hidden="true"></div>
@if ($trigger === 'icon')
<button
@@ -75,23 +79,23 @@
aria-controls="{{ $id }}-view"
aria-expanded="false"
x-bind:aria-expanded="open.toString()"
data-search-trigger
data-md-search-trigger
>
<x-livewire-material::icon :name="$icon" />
</button>
@endif
<div data-search-bar role="search" x-on:click="if ($event.target === $el) $refs.input.focus()">
<span data-search-leading x-show="! fullScreen">
<div data-md-search-bar role="search" x-on:click="if ($event.target === $el) $refs.input.focus()">
<span data-md-search-leading x-show="! fullScreen">
<x-livewire-material::icon :name="$icon" />
</span>
<button type="button" data-search-leading data-search-back x-show="fullScreen" x-cloak x-on:click="close(true)" aria-label="{{ __('Back') }}">
<button type="button" data-md-search-leading data-md-search-back x-show="fullScreen" x-cloak x-on:click="close(true)" aria-label="{{ __('Back') }}">
<x-livewire-material::icon name="arrow_back" />
</button>
<span
data-search-field
data-md-search-field
role="combobox"
aria-haspopup="dialog"
aria-controls="{{ $id }}-view"
@@ -111,23 +115,23 @@
x-on:click="show()"
x-on:input="typed($event)"
x-on:keydown.arrow-down.prevent="show(); $nextTick(() => step(1))"
data-search-input
data-md-search-input
/>
</span>
<button type="button" data-search-clear x-on:click="clear()" aria-label="{{ __('Clear') }}">
<button type="button" data-md-search-clear x-on:click="clear()" aria-label="{{ __('Clear') }}">
<x-livewire-material::icon name="close" />
</button>
@isset($trailing)
<span data-search-trailing>{{ $trailing }}</span>
<span data-md-search-trailing>{{ $trailing }}</span>
@endisset
</div>
<div
x-ref="view"
id="{{ $id }}-view"
data-search-view
data-md-search-view
x-cloak
x-show="open"
x-on:keydown.arrow-down.prevent="step(1)"
@@ -135,15 +139,15 @@
x-on:click="choose($event)"
>
@isset($suggestions)
<div data-search-suggestions role="list" x-show="query === ''">{{ $suggestions }}</div>
<div data-md-search-suggestions role="list" x-show="query === ''">{{ $suggestions }}</div>
@endisset
@if ($slot->hasActualContent())
<div data-search-results role="list" @isset($suggestions) x-cloak x-show="query !== ''" @endisset>{{ $slot }}</div>
<div data-md-search-results role="list" @isset($suggestions) x-cloak x-show="query !== ''" @endisset>{{ $slot }}</div>
@elseif (isset($empty))
<div data-search-results @isset($suggestions) x-cloak x-show="query !== ''" @endisset><p class="px-4 py-3 type-body-md text-on-surface-variant">{{ $empty }}</p></div>
<div data-md-search-results @isset($suggestions) x-cloak x-show="query !== ''" @endisset><p data-md-search-empty>{{ $empty }}</p></div>
@endif
</div>
<span data-search-status class="sr-only" aria-live="polite" aria-atomic="true" x-text="announcement"></span>
<span data-md-search-status class="md-visually-hidden" aria-live="polite" aria-atomic="true" x-text="announcement"></span>
</div>
+1 -1
View File
@@ -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) {
+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'");
});
+2 -2
View File
@@ -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');
});
+33 -17
View File
@@ -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');
});