Filter a menu from a field at the top of it
Plan step 22, actions.md § Missing (Menus as a filtering surface): M3's menus page describes a menu that embeds a text field and filters its options as you type, and nothing in the library did that. `<x-menu filter>` renders the field, sticky above the list, and hides the rows the query leaves out — client-side over the items already rendered, so nothing is fetched and a `wire:click` stays where it was. The field keeps the focus and the arrow keys move a highlight it names through `aria-activedescendant`, the APG combobox keyboard `<x-choices searchable>` already uses; Enter chooses the highlighted row, and a query that leaves nothing says so. The list becomes a `role="menu"` inside the popover, because a text field is not something a menu may contain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
3f651c5c08
commit
88c46001fb
@@ -20,6 +20,12 @@
|
||||
* that exist only for a wrapper (moving the anchor name, and finding the button inside the trigger
|
||||
* slot) are overridden away. `items()` stops at the popover it belongs to, so the arrow keys in a
|
||||
* menu never walk into an open submenu's rows, nor a submenu's back out into its parent's.
|
||||
*
|
||||
* `<x-menu filter>` adds a text field at the top of the same list. The field keeps the focus while
|
||||
* the arrow keys move a highlight — APG's combobox, which is what a text field inside a popup
|
||||
* asks for — so `refine()`, `visible()`, `mark()` and `search()` work on `aria-activedescendant`
|
||||
* and the `hidden` attribute rather than on the roving focus the rest of this file uses. They do
|
||||
* nothing at all in a menu with no field: `$refs.filter` is what turns them on.
|
||||
*/
|
||||
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
|
||||
|
||||
@@ -95,6 +101,12 @@ const menu = () => ({
|
||||
}
|
||||
|
||||
this.focusWasInside = false
|
||||
|
||||
// A filtered menu opens on the whole list again: the query belonged to that visit.
|
||||
if (this.$refs.filter) {
|
||||
this.$refs.filter.value = ''
|
||||
this.refine()
|
||||
}
|
||||
})
|
||||
|
||||
// A press outside closes the menu without pulling focus back to the trigger.
|
||||
@@ -174,6 +186,16 @@ const menu = () => ({
|
||||
|
||||
this.control()?.setAttribute('aria-expanded', 'true')
|
||||
|
||||
// A filtering menu hands the focus to its field, not to a row: the field is where the
|
||||
// typing goes, and `aria-activedescendant` says which row the arrows are on meanwhile.
|
||||
if (this.$refs.filter) {
|
||||
this.$refs.filter.focus()
|
||||
this.$refs.filter.select()
|
||||
this.mark(this.visible()[0] ?? null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// `false` opens without taking the focus: a submenu the pointer rested on belongs to the
|
||||
// pointer, and taking the focus out from under the keyboard would be the wrong answer.
|
||||
if (focus !== false) {
|
||||
@@ -263,6 +285,106 @@ const menu = () => ({
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* `<x-menu filter>`: M3's menu as a filtering surface. The rows are already rendered, so this
|
||||
* only hides the ones the query leaves out — with the `hidden` attribute, which menu.css turns
|
||||
* into `display: none` over the row's own `display: flex`. A divider means nothing between two
|
||||
* filtered clusters, and a group whose every row has gone is a heading over nothing.
|
||||
*/
|
||||
refine() {
|
||||
const query = this.$refs.filter.value.trim().toLowerCase()
|
||||
|
||||
for (const item of this.items()) {
|
||||
item.hidden = query !== '' && !(item.textContent ?? '').trim().toLowerCase().includes(query)
|
||||
}
|
||||
|
||||
for (const rule of this.$refs.menu.querySelectorAll('[role="separator"]')) {
|
||||
rule.hidden = query !== ''
|
||||
}
|
||||
|
||||
for (const group of this.$refs.menu.querySelectorAll('[role="group"]')) {
|
||||
group.hidden = ![...group.querySelectorAll(ITEMS)].some((item) => !item.hidden)
|
||||
}
|
||||
|
||||
const left = this.visible()
|
||||
|
||||
this.$refs.empty.hidden = left.length > 0
|
||||
this.mark(left[0] ?? null)
|
||||
},
|
||||
|
||||
/** The rows a query has left, in the order they are read. */
|
||||
visible() {
|
||||
return this.items().filter((item) => !item.hidden && item.closest('[hidden]') === null)
|
||||
},
|
||||
|
||||
/**
|
||||
* Moves the highlight the arrow keys carry while the focus stays in the field. The row needs
|
||||
* an id for `aria-activedescendant` to name it, and gets one if the caller wrote none.
|
||||
*/
|
||||
mark(item) {
|
||||
for (const each of this.items()) {
|
||||
if (each !== item) {
|
||||
each.removeAttribute('data-active')
|
||||
}
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
this.$refs.filter.removeAttribute('aria-activedescendant')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
item.id ||= `${this.$refs.menu.id}-item-${this.items().indexOf(item)}`
|
||||
item.setAttribute('data-active', '')
|
||||
this.$refs.filter.setAttribute('aria-activedescendant', item.id)
|
||||
|
||||
if (this.isOpen()) {
|
||||
item.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
},
|
||||
|
||||
/** The APG combobox keyboard, on the field: the list moves under it and Enter takes a row. */
|
||||
search(event) {
|
||||
const left = this.visible()
|
||||
const current = left.findIndex((item) => item.hasAttribute('data-active'))
|
||||
|
||||
const move = (index) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (left.length > 0) {
|
||||
this.mark(left[(index + left.length) % left.length])
|
||||
}
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
return move(current + 1)
|
||||
case 'ArrowUp':
|
||||
return move(current < 0 ? left.length - 1 : current - 1)
|
||||
case 'Home':
|
||||
return move(0)
|
||||
case 'End':
|
||||
return move(left.length - 1)
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
|
||||
if (left[current] && left[current].getAttribute('aria-disabled') !== 'true') {
|
||||
left[current].click()
|
||||
}
|
||||
|
||||
return
|
||||
case 'Escape':
|
||||
// The browser's own light dismiss closes the popover; this only says where the
|
||||
// focus goes after it.
|
||||
this.returnFocus = true
|
||||
|
||||
return
|
||||
case 'Tab':
|
||||
this.returnFocus = false
|
||||
this.close()
|
||||
}
|
||||
},
|
||||
|
||||
activate(event) {
|
||||
const item = event.target.closest(ITEMS)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user