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
+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()
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user