The side sheet, the bottom sheet's panel and the docked search's scrim and view kept `display` alive through their exit with `transition-behavior: allow-discrete`. Firefox does not transition `display` (Chrome 117 and Safari 18 do), and `x-show` sets `display: none` in the frame the exit starts, so in Firefox the sheets vanished instead of sliding out and the search view and scrim vanished instead of fading. The docked search had a second problem in every engine: neither the view nor the scrim had a closed state to transition to, so where `display` was held (Chrome, Safari) the view stood at full opacity for its duration and then disappeared. Each element now carries `x-transition:enter`/`:leave="md-transition"`, the approach the two sheet scrims already took (renamed from `md-scrim-transition` to one name for all of them). The class only switches Alpine to CSS-transition mode, so `x-show` holds `display` for the element's computed transition-duration before hiding it, in every engine, and a reopen during the exit cancels the pending hide; nothing styles it. `display` and `allow-discrete` leave the transitions so Chrome and Safari do not hold a second time. Alpine reads the first `transition-duration` listed, which is the closing slide or fade in each list (the preset panel lists translate before height). The search view now closes back into the bar (opacity 0, `scale: 1 0.9`, the reverse of its `@starting-style` entry) and its scrim fades out on close and when the search turns full screen. Under reduced motion the durations are zero and every one of them closes at once. Four browser tests sample each exit mid-way in the page, in the same round trip as the close: the sheets part of the way to their closed offset, the search scrim and view part of the way faded, each still displayed, then `display: none`. All four fail on main in Firefox (the two search tests in Chrome too) and pass in Chrome, Firefox and Safari. OverlayTest pins the drawer's new transition and the view's markup. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
158 lines
6.9 KiB
PHP
158 lines
6.9 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; 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="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>
|