Give search M3's icon entry point and its suggestions
M3 § Search names three entry points and only the bar existed; suggestions before the first keystroke were missing too (plan step 24, audit docs/audits/m3-alignment/inputs.md § Missing). `trigger="icon"` is the search icon button — search as a secondary action, one 48px button that expands into the full-screen view at any width, since an icon button has nothing to dock under, and takes its focus back on close. The `suggestions` slot stands where the results do until something is typed; the live region counts whichever of the two is on screen and names suggestions as suggestions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
2662040c24
commit
549d96b0dc
@@ -671,7 +671,7 @@ Bind with `wire:model` (entangled) or, without Livewire, `x-model`. The options
|
||||
|
||||
### `<x-search>`
|
||||
|
||||
M3 search bar that opens into a search view: docked under the bar from `medium` (600px) over a scrim, full screen with a back arrow on a compact window (`docked` keeps it docked). Bind the input like any other and render the results in the slot; `empty` is shown when the slot renders nothing. The results are a list and a live region says how many there are. Choosing a result (a link or button) closes the view; ArrowDown walks the results, Escape closes. Props: `placeholder` ("Search"), `label`, `icon`; `trailing` slot (avatar, icon buttons).
|
||||
M3 search bar that opens into a search view: docked under the bar from `medium` (600px) over a scrim, full screen with a back arrow on a compact window (`docked` keeps it docked). Bind the input like any other and render the results in the slot; `empty` is shown when the slot renders nothing. The results are a list and a live region says how many there are. Choosing a result (a link or button) closes the view; ArrowDown walks the results, Escape closes. Props: `placeholder` ("Search"), `label`, `icon`, `trigger`; `trailing` slot (avatar, icon buttons) and `suggestions` slot.
|
||||
|
||||
```blade
|
||||
<x-search wire:model.live.debounce.300ms="query" placeholder="Search shares">
|
||||
@@ -684,6 +684,22 @@ M3 search bar that opens into a search view: docked under the bar from `medium`
|
||||
|
||||
The docked view overlaps what is under it; never place a search inside an element with `overflow-hidden` (a card), which clips it. The bar is never wider than M3's 720px and grows to that width while it is focused; for M3's 360px resting bar, wrap it in an element carrying `style="--search-width: 22.5rem"`.
|
||||
|
||||
- `trigger="icon"` is M3's other entry point — search as a secondary action: one 48px search icon button that expands into the full-screen view at any width (so `docked` does not apply) and gives the button its focus back on close. Put it in a toolbar or an app bar row where a bar would not fit.
|
||||
- The `suggestions` slot is shown in the view until the first keystroke — recent or popular searches — and the results slot takes over once something is typed. The live region counts whichever list is on screen and names suggestions as such.
|
||||
|
||||
```blade
|
||||
<x-search trigger="icon" label="Search shares" wire:model.live.debounce.300ms="query">
|
||||
<x-slot:suggestions>
|
||||
@foreach ($this->recent as $term)
|
||||
<x-list-item :title="$term" icon="history" wire:click="$set('query', '{{ $term }}')" wire:key="recent-{{ $term }}" />
|
||||
@endforeach
|
||||
</x-slot:suggestions>
|
||||
@foreach ($this->results as $share)
|
||||
<x-list-item :title="$share->name" :link="route('shares.show', $share)" wire:key="result-{{ $share->id }}" />
|
||||
@endforeach
|
||||
</x-search>
|
||||
```
|
||||
|
||||
### `<x-app-shell>`
|
||||
|
||||
The adaptive app shell, a whole layout's body: one navigation per M3 window size class, the page as `<main id="content" wire:transition.navigate>` behind a skip link, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`.
|
||||
|
||||
@@ -15,14 +15,16 @@
|
||||
* `--search-width: 22.5rem` 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-search-scrim] over the page while the view is docked
|
||||
* [data-search-bar] the pill, above the view
|
||||
* [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-results]
|
||||
* [data-search-status] the polite live region that counts the results
|
||||
* [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
|
||||
*/
|
||||
|
||||
@layer components {
|
||||
@@ -204,19 +206,62 @@
|
||||
|
||||
/* 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-view]:has([data-search-results], [data-search-suggestions]) {
|
||||
min-height: 15rem;
|
||||
}
|
||||
|
||||
/* No divider: that belongs to the divided style, which Expressive deprecates in favour of the
|
||||
contained one this file draws. */
|
||||
[data-search-results] {
|
||||
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] {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-block: 0.5rem;
|
||||
}
|
||||
|
||||
/* 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"] {
|
||||
flex: none;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
[data-search-trigger] {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
[data-search-trigger]:hover {
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 8%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
[data-search-trigger]:focus-visible {
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, 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-search][data-trigger="icon"]:not([data-open]) [data-search-bar] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Full screen, on a compact window. */
|
||||
[data-search][data-full-screen] [data-search-bar] {
|
||||
position: fixed;
|
||||
|
||||
+41
-12
@@ -11,7 +11,12 @@
|
||||
* Because the results arrive from the server, nothing in the page tells a screen reader they are
|
||||
* there; M3 asks that it be told. A MutationObserver counts the list items whenever the view's DOM
|
||||
* settles and writes "N results" into the polite live region the view renders, which is the one
|
||||
* announcement M3's search accessibility page names.
|
||||
* announcement M3's search accessibility page names. With a `suggestions` slot, whichever of the
|
||||
* two lists is on screen is the one counted, and the suggestions are named as such.
|
||||
*
|
||||
* `trigger` is M3's entry point: the bar itself, or `icon` — a single search icon button that
|
||||
* expands into the full-screen view wherever the window is wide enough to dock, because an icon
|
||||
* button has nowhere to dock under.
|
||||
*/
|
||||
import { upTo } from './breakpoints.js'
|
||||
|
||||
@@ -29,22 +34,25 @@ const RETURN_GUARD_MS = 250
|
||||
const SETTLE_MS = 120
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialSearch', (docked = false, announce = {}) => ({
|
||||
window.Alpine.data('materialSearch', (docked = false, announce = {}, trigger = 'bar') => ({
|
||||
open: false,
|
||||
compact: false,
|
||||
closedAt: -Infinity,
|
||||
announcement: '',
|
||||
// What is in the field, which is what tells suggestions from results.
|
||||
query: '',
|
||||
observer: null,
|
||||
settle: null,
|
||||
|
||||
init() {
|
||||
const query = upTo('medium')
|
||||
const media = upTo('medium')
|
||||
|
||||
this.compact = query.matches
|
||||
query.addEventListener('change', (event) => (this.compact = event.matches))
|
||||
this.compact = media.matches
|
||||
media.addEventListener('change', (event) => (this.compact = event.matches))
|
||||
|
||||
// Alpine registers x-ref as it walks the children, which is after this runs.
|
||||
this.$nextTick(() => {
|
||||
this.query = this.$refs.input?.value ?? ''
|
||||
this.observer = new MutationObserver(() => this.countLater())
|
||||
this.observer.observe(this.$refs.view, { childList: true, subtree: true, characterData: true })
|
||||
})
|
||||
@@ -69,25 +77,41 @@ document.addEventListener('alpine:init', () => {
|
||||
return
|
||||
}
|
||||
|
||||
const results = this.$refs.view.querySelector('[data-search-results]')
|
||||
const items = results ? results.querySelectorAll('[role="listitem"], li') : []
|
||||
const total = items.length || (results ? this.results().length : 0)
|
||||
// Suggestions and results never show together; count whichever one is on screen.
|
||||
const list = [...this.$refs.view.querySelectorAll('[data-search-results], [data-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'))
|
||||
|
||||
this.announcement = total === 0
|
||||
? (announce.none ?? '')
|
||||
: total === 1
|
||||
? (announce.one ?? '')
|
||||
: (announce.many ?? '').replace(':count', total)
|
||||
? ((suggesting ? announce.suggestionOne : announce.one) ?? '')
|
||||
: ((suggesting ? announce.suggestionMany : announce.many) ?? '').replace(':count', total)
|
||||
},
|
||||
|
||||
get fullScreen() {
|
||||
return this.open && this.compact && !docked
|
||||
// An icon button has nothing to dock under, so M3's icon entry point always expands.
|
||||
return this.open && (trigger === 'icon' || (this.compact && !docked))
|
||||
},
|
||||
|
||||
show() {
|
||||
this.open = true
|
||||
},
|
||||
|
||||
/** M3's search-icon entry point: the button opens the view and hands over focus. */
|
||||
expand() {
|
||||
this.show()
|
||||
this.$nextTick(() => requestAnimationFrame(() => this.$refs.input?.focus()))
|
||||
},
|
||||
|
||||
/** Every keystroke: the view opens, and the query decides suggestions or results. */
|
||||
typed(event) {
|
||||
this.query = event.target.value
|
||||
this.show()
|
||||
},
|
||||
|
||||
focused() {
|
||||
if (performance.now() - this.closedAt > RETURN_GUARD_MS) {
|
||||
this.show()
|
||||
@@ -99,7 +123,11 @@ document.addEventListener('alpine:init', () => {
|
||||
this.closedAt = performance.now()
|
||||
|
||||
if (refocus) {
|
||||
this.$refs.input.focus()
|
||||
// Back to whatever opened the view: the icon button, or the field itself. A tick
|
||||
// later, because the icon button is only on screen again once the view has closed.
|
||||
const back = this.$refs.trigger ?? this.$refs.input
|
||||
|
||||
this.$nextTick(() => back.focus())
|
||||
}
|
||||
},
|
||||
|
||||
@@ -107,6 +135,7 @@ document.addEventListener('alpine:init', () => {
|
||||
const input = this.$refs.input
|
||||
|
||||
input.value = ''
|
||||
this.query = ''
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
input.focus()
|
||||
},
|
||||
|
||||
@@ -13,6 +13,15 @@
|
||||
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
|
||||
@@ -23,22 +32,26 @@
|
||||
'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))"
|
||||
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()"
|
||||
@@ -46,16 +59,34 @@
|
||||
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']) }}
|
||||
>
|
||||
<div data-search-scrim x-cloak x-show="open && ! fullScreen" 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-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">
|
||||
<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()" aria-label="{{ __('Back') }}">
|
||||
<button type="button" data-search-leading data-search-back x-show="fullScreen" x-cloak x-on:click="close(true)" aria-label="{{ __('Back') }}">
|
||||
<x-livewire-material::icon name="arrow_back" />
|
||||
</button>
|
||||
|
||||
@@ -78,7 +109,7 @@
|
||||
aria-label="{{ $label ?? $placeholder }}"
|
||||
x-on:focus="focused()"
|
||||
x-on:click="show()"
|
||||
x-on:input="show()"
|
||||
x-on:input="typed($event)"
|
||||
x-on:keydown.arrow-down.prevent="show(); $nextTick(() => step(1))"
|
||||
data-search-input
|
||||
/>
|
||||
@@ -103,10 +134,14 @@
|
||||
x-on:keydown.arrow-up.prevent="step(-1)"
|
||||
x-on:click="choose($event)"
|
||||
>
|
||||
@isset($suggestions)
|
||||
<div data-search-suggestions role="list" x-show="query === ''">{{ $suggestions }}</div>
|
||||
@endisset
|
||||
|
||||
@if ($slot->hasActualContent())
|
||||
<div data-search-results role="list">{{ $slot }}</div>
|
||||
<div data-search-results role="list" @isset($suggestions) x-cloak x-show="query !== ''" @endisset>{{ $slot }}</div>
|
||||
@elseif (isset($empty))
|
||||
<div data-search-results><p class="px-4 py-3 type-body-md text-on-surface-variant">{{ $empty }}</p></div>
|
||||
<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>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
@@ -169,6 +169,40 @@
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Search entry points and suggestions' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 medium:grid-cols-2">
|
||||
<div x-data="{ query: '' }">
|
||||
<x-search x-model="query" placeholder="Search shares">
|
||||
<x-slot:suggestions>
|
||||
<x-list>
|
||||
<x-list-item title="Recent: holiday photos" icon="history" link="#fields" />
|
||||
<x-list-item title="Recent: contract" icon="history" link="#fields" />
|
||||
<x-list-item title="Shares expiring this week" icon="trending_up" link="#fields" />
|
||||
</x-list>
|
||||
</x-slot:suggestions>
|
||||
|
||||
<x-list>
|
||||
<x-list-item title="holiday-photos.zip" description="248 MB" icon="folder_zip" link="#fields" x-show="'holiday-photos.zip'.includes(query.toLowerCase())" />
|
||||
<x-list-item title="contract.pdf" description="1.2 MB" icon="picture_as_pdf" link="#fields" x-show="'contract.pdf'.includes(query.toLowerCase())" />
|
||||
</x-list>
|
||||
</x-search>
|
||||
</div>
|
||||
|
||||
{{-- M3's search icon button: search as a secondary action, expanding into the full-screen view. --}}
|
||||
<div class="flex items-center gap-2 rounded-corner-full bg-surface-container-high px-2 py-1">
|
||||
<x-button icon="menu" aria-label="Open the menu" />
|
||||
<span class="grow type-title-md text-on-surface">Shares</span>
|
||||
<x-search trigger="icon" label="Search shares">
|
||||
<x-slot:suggestions>
|
||||
<x-list>
|
||||
<x-list-item title="Recent: design review" icon="history" link="#fields" />
|
||||
</x-list>
|
||||
</x-slot:suggestions>
|
||||
<x-slot:empty>No shares match.</x-slot:empty>
|
||||
</x-search>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'A form' => <<<'BLADE'
|
||||
<x-card variant="outlined" class="w-full max-w-xl">
|
||||
<x-form x-on:submit.prevent="materialToast('Share created', { type: 'success' })" separator>
|
||||
|
||||
@@ -47,6 +47,50 @@ it('shows the results as a list, or what to say when there are none', function (
|
||||
->not->toContain('data-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('x-ref="trigger"')
|
||||
->toContain('x-on:click="expand()"')
|
||||
->toContain('aria-label="Search shares"')
|
||||
->toContain('aria-haspopup="dialog"')
|
||||
->toContain('aria-controls="find-view"')
|
||||
->toContain('materialSearch(false,')
|
||||
->toContain("'icon'")
|
||||
// The bar is still there: it becomes the expanded view's header.
|
||||
->toContain('data-search-bar');
|
||||
|
||||
expect((string) $this->blade('<x-search />'))
|
||||
->toContain('data-trigger="bar"')
|
||||
->not->toContain('data-search-trigger');
|
||||
});
|
||||
|
||||
it('shows suggestions until the first keystroke, then the results', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-search>
|
||||
<a href="/shares/1">holiday.zip</a>
|
||||
<x-slot:suggestions><a href="/recent">Recent shares</a></x-slot:suggestions>
|
||||
</x-search>
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('data-search-suggestions role="list"')
|
||||
->toContain('x-show="query === \'\'"')
|
||||
->toContain('Recent shares')
|
||||
->toContain('x-show="query !== \'\'"')
|
||||
->toContain('holiday.zip')
|
||||
->toContain('1 suggestion')
|
||||
->toContain(':count suggestions');
|
||||
|
||||
// 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('x-show="query !== \'\'"');
|
||||
});
|
||||
|
||||
it('names the input, trails the bar, and stays docked on request', function () {
|
||||
$html = (string) $this->blade('<x-search label="Search your shares" icon="travel_explore" docked><x-slot:trailing><button>AR</button></x-slot:trailing></x-search>');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user