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>
159 lines
7.1 KiB
PHP
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>
|