Files
livewire-material/resources/views/components/search.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 94a376a4fd Close only the topmost layer on Escape, and keep a layer opened over a sheet readable
A modal side sheet, bottom sheet or the modal rail closed on any Escape
the window heard, so a dialog opened from a sheet, a menu, select list
or searchable choice inside one, a sheet opened from a sheet, and a
sheet inside a dialog each closed two layers on one press. And a dialog
or a second sheet rendered elsewhere on the page sat inside the
`aria-hidden` the first sheet's `x-trap.inert` put on its siblings, so a
screen reader could not read it, while the sheet's focus trap took every
Tab inside the dialog back to the inert sheet.

resources/js/layers.js adds `x-layer`, on each of those panels beside
its `x-trap`. An Escape is the panel's only when nothing has handled it
and the nearest open layer around its target is the panel itself - not
an open dialog, popover or customizable select, nor a panel inside it;
the panel claims it with preventDefault(), which also keeps a dialog
around it from cancelling, and dispatches `material-escape`, which the
views close on. A panel that opens lifts `aria-hidden` from its own
ancestors and puts it back on close only where a panel still open hides
them; materialShowModal() does the same for `<x-modal>`, whose new
`x-trap.noautofocus.noreturn` pauses the sheet's focus trap while it is
open and moves no focus of its own. The searchable choice, the search
view and the supporting pane's sheet now preventDefault() the Escape
they act on, so the dialog or sheet around them stays.

Four browser tests stack the layers every way above and fail without
the change in Chrome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 05:59:32 +02:00

159 lines
7.1 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.preventDefault(); $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>