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:
Andreas Reinhold / reini
2026-09-14 06:40:48 +02:00
co-authored by Claude Fable 5.1
parent 2662040c24
commit 549d96b0dc
6 changed files with 230 additions and 27 deletions
+41 -12
View File
@@ -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()
},