`fullScreen` followed `open`, so the moment a full-screen search closed, `data-md-full-screen` went with it: the fixed header bar went back to its resting pill — or to `display: none`, behind the search icon — and the view, still fading out under Alpine's hold, dropped to the docked layout for its exit. In every engine the bar vanished on the first frame while the view went on fading somewhere else. search.js: a close from full screen now `hold()`s the layout — `leaving` keeps `fullScreen`, and with it `data-md-full-screen`, the back arrow and the fixed bar, for the view's own closing duration, read from its computed style a frame on (zero under reduced motion); reopening lets a pending end go by. search.css fades the header bar out with the view on the view's spring and keeps the root above the page while it leaves, and the icon trigger's bar stays displayed until the layout settles. The focus trap now binds to `open && fullScreen`, so it still lets go at the close and its return of focus lands inside RETURN_GUARD_MS rather than reopening the view at the end of the exit. PickingTest samples both exits in the page — the icon trigger, and the bar trigger on a compact window — for a moment where the root is still full screen with a fixed bar and both bar and view part-way through their fade, then checks the settled layout and focus back on the icon or the field; both fail on the previous code in Chrome, Firefox and Safari. The tests wait for the entry's own transitions first: Firefox reads a transition's end value until its next refresh tick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
159 lines
7.0 KiB
PHP
159 lines
7.0 KiB
PHP
{{-- M3's search: a search bar that opens into a search view with the results.
|
|
|
|
Bind the input like any other (`wire:model.live.debounce.300ms="query"`) and render the
|
|
results in the slot, from a Livewire property or computed property that follows the query;
|
|
`empty` is shown instead when the slot renders nothing (say, "No shares match"). Results are
|
|
usually `<x-list-item>`s with a `link`, or buttons: choosing one closes the view.
|
|
|
|
Docked under the bar from `medium` over a scrim, full screen below it with a back arrow
|
|
(resources/css/components/search.css); `docked` keeps it docked at every width. The bar is
|
|
never wider than M3's 720px and grows to that width while it is focused: set `--search-width`
|
|
on a wrapper for a narrower resting bar (M3's own is 360px). `placeholder` ("Search"),
|
|
`label` (the input's name when it differs from the placeholder), leading `icon` (`search`), and
|
|
a `trailing` slot for an avatar or icon buttons in the bar. Every other attribute reaches the
|
|
`<input type="search">`.
|
|
|
|
`trigger="icon"` is M3's second entry point, the search icon button: search as a secondary
|
|
action in a toolbar or an app bar, a single 48px button that expands into the full-screen view
|
|
and hands focus to the field (so `docked` has nothing to say about it). The bar itself is the
|
|
view's header there, and closing gives the button its focus back.
|
|
|
|
`suggestions` is a slot shown in the view until the first keystroke — M3's suggestions
|
|
behaviour: recent searches, popular queries. The results slot takes over once something is
|
|
typed, and whichever of the two is on screen is what the live region counts.
|
|
|
|
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).
|
|
|
|
The root renders `data-md-search` with `data-md-trigger`, and `data-md-open` and
|
|
`data-md-full-screen` while they hold — `data-md-full-screen` a little longer, through a
|
|
full-screen view's exit back into the bar or the icon (resources/js/search.js); the parts are `data-md-search-*`, drawn by
|
|
resources/css/components/search.css. `class`, `style` and `wire:key` land on the root. --}}
|
|
|
|
@props([
|
|
'placeholder' => null,
|
|
'label' => null,
|
|
'icon' => 'search',
|
|
'docked' => false,
|
|
'trigger' => 'bar',
|
|
])
|
|
|
|
@php
|
|
$model = $attributes->wire('model')->value() ?: null;
|
|
$placeholder ??= __('Search');
|
|
$id = $attributes->get('id') ?? 'material-search-'.substr(md5($model.'|'.$placeholder), 0, 10);
|
|
$trigger = $trigger === 'icon' ? 'icon' : 'bar';
|
|
|
|
$announce = [
|
|
'none' => __('No results'),
|
|
'one' => __('1 result'),
|
|
'many' => __(':count results'),
|
|
'suggestionOne' => __('1 suggestion'),
|
|
'suggestionMany' => __(':count suggestions'),
|
|
];
|
|
@endphp
|
|
|
|
<div
|
|
x-data="materialSearch({{ $docked ? 'true' : 'false' }}, @js($announce), @js($trigger))"
|
|
x-on:keydown.escape="if (open) { $event.stopPropagation(); close(true); }"
|
|
x-on:focusout="leave($event)"
|
|
x-on:pointerdown.outside="close()"
|
|
x-trap.noscroll="open && fullScreen"
|
|
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']) }}
|
|
>
|
|
{{-- `md-transition` turns on Alpine's CSS transition, which holds the scrim and the view
|
|
displayed while they fade out (search.css): Firefox does not transition `display`. --}}
|
|
<div data-md-search-scrim x-cloak x-show="open && ! fullScreen" x-transition:enter="md-transition" x-transition:leave="md-transition" x-on:pointerdown="close()" aria-hidden="true"></div>
|
|
|
|
@if ($trigger === 'icon')
|
|
<button
|
|
type="button"
|
|
x-ref="trigger"
|
|
x-show="! open"
|
|
x-on:click="expand()"
|
|
aria-label="{{ $label ?? $placeholder }}"
|
|
aria-haspopup="dialog"
|
|
aria-controls="{{ $id }}-view"
|
|
aria-expanded="false"
|
|
x-bind:aria-expanded="open.toString()"
|
|
data-md-search-trigger
|
|
>
|
|
<x-livewire-material::icon :name="$icon" />
|
|
</button>
|
|
@endif
|
|
|
|
<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-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-md-search-field
|
|
role="combobox"
|
|
aria-haspopup="dialog"
|
|
aria-controls="{{ $id }}-view"
|
|
aria-expanded="false"
|
|
x-bind:aria-expanded="open.toString()"
|
|
>
|
|
<input
|
|
{{ $attributes->except(['class', 'style', 'wire:key', 'id', 'placeholder', 'type']) }}
|
|
x-ref="input"
|
|
id="{{ $id }}"
|
|
type="search"
|
|
autocomplete="off"
|
|
enterkeyhint="search"
|
|
placeholder="{{ $placeholder }}"
|
|
aria-label="{{ $label ?? $placeholder }}"
|
|
x-on:focus="focused()"
|
|
x-on:click="show()"
|
|
x-on:input="typed($event)"
|
|
x-on:keydown.arrow-down.prevent="show(); $nextTick(() => step(1))"
|
|
data-md-search-input
|
|
/>
|
|
</span>
|
|
|
|
<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-md-search-trailing>{{ $trailing }}</span>
|
|
@endisset
|
|
</div>
|
|
|
|
<div
|
|
x-ref="view"
|
|
id="{{ $id }}-view"
|
|
data-md-search-view
|
|
x-cloak
|
|
x-show="open"
|
|
x-transition:enter="md-transition"
|
|
x-transition:leave="md-transition"
|
|
x-on:keydown.arrow-down.prevent="step(1)"
|
|
x-on:keydown.arrow-up.prevent="step(-1)"
|
|
x-on:click="choose($event)"
|
|
>
|
|
@isset($suggestions)
|
|
<div data-md-search-suggestions role="list" x-show="query === ''">{{ $suggestions }}</div>
|
|
@endisset
|
|
|
|
@if ($slot->hasActualContent())
|
|
<div data-md-search-results role="list" @isset($suggestions) x-cloak x-show="query !== ''" @endisset>{{ $slot }}</div>
|
|
@elseif (isset($empty))
|
|
<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-md-search-status class="md-visually-hidden" aria-live="polite" aria-atomic="true" x-text="announcement"></span>
|
|
</div>
|