Merge branch 'worktree-agent-a9c6ef32512bc556d'
This commit is contained in:
@@ -261,7 +261,7 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
|
||||
</x-menu>
|
||||
```
|
||||
|
||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`, `filter`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`, ticked at its end unless it has an `icon-right`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`, `submenu`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a `disabled` item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls.
|
||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`, `filter`, `sheet-at-compact`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`, ticked at its end unless it has an `icon-right`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`, `submenu`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a `disabled` item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls.
|
||||
|
||||
`submenu` makes an item a menu of its own — the slot holds the nested `<x-menu-item>`s instead of a label, and they open beside it, on its end, flipping to its start where the window has no room:
|
||||
|
||||
@@ -276,6 +276,24 @@ The item says so with `aria-haspopup="menu"`, `aria-expanded` and a chevron; Rig
|
||||
|
||||
`filter` puts a text field at the top of the list (M3's menu as a filtering surface) and narrows the items to those whose label holds what has been typed — in the browser, over the items already rendered, so nothing is fetched and every `wire:click` stays where it was. `filter="Find a person"` names the field; bare `filter` calls it "Filter". The field keeps the focus while the arrow keys, Home and End move a highlighted row and Enter chooses it (`aria-activedescendant`, as `<x-choices searchable>`); a query that leaves nothing says "Nothing matches". Reach for it once a menu is long enough to hunt through; for a value bound to a property, `<x-choices searchable>` is the field, not the menu.
|
||||
|
||||
`sheet-at-compact` is M3's adaptive menu ("at compact breakpoints, consider swapping a menu for a bottom sheet"): below `medium` (600px) the trigger opens the same items in a modal `<x-bottom-sheet>`, and from `medium` up it opens the popover. Write the items once — the slot is drawn in both:
|
||||
|
||||
```blade
|
||||
<x-menu label="Photo actions" sheet-at-compact>
|
||||
<x-slot:trigger>
|
||||
<x-button icon="more_vert" tooltip="More" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-menu-item label="Set as wallpaper" icon="wallpaper" description="Home and lock screen" wire:click="wallpaper" />
|
||||
<x-menu-item label="Add to album" icon="photo_album" submenu>
|
||||
<x-menu-item label="Holidays" wire:click="addTo('holidays')" />
|
||||
</x-menu-item>
|
||||
<x-menu-item label="Delete" icon="delete" wire:click="delete" />
|
||||
</x-menu>
|
||||
```
|
||||
|
||||
On a compact window the trigger says `aria-haspopup="dialog"` (and `menu` from `medium`), with `aria-expanded` in both. In the sheet the items keep their `menuitem` roles and the menu keyboard (arrows, Home, End, a letter; Escape or Tab close it and focus returns to the trigger); choosing an item closes it, as do the scrim and a swipe down; a `submenu` opens in place under its item rather than beside it; a `filter` field sits at the top; a `keep-open` item's render keeps the sheet open. Resizing the window across 600px while it is open closes it. The sheet is teleported to the end of `<body>`, so it covers the window from inside a sticky app bar or a toolbar, and is surface-container-low even for a `vibrant` menu. Because the items are rendered twice, do not give them an `id` or nest a Livewire component in a `sheet-at-compact` menu.
|
||||
|
||||
Clusters: `<x-menu-separator />` draws M3's line, `<x-menu-group gap>` M3 Expressive's grouped layout — no line, the cluster set 8px off its neighbours with its items 2px apart and its ends rounded. Reach for the divider first (M3: "on web, use dividers to separate items", and it is the only one a scrolling menu may use); reach for the gap for one or two clusters in a menu short enough not to scroll, and never vary the gap. `<x-menu-group>` takes `label` (optional) and `gap`; a labelled group without `gap` is the plain heading it always was.
|
||||
|
||||
### `<x-button-group>`
|
||||
|
||||
@@ -79,17 +79,76 @@
|
||||
|
||||
/* The filter hides what the query leaves out with the `hidden` attribute; a row's own
|
||||
`display: flex` utility would otherwise beat the user agent's `[hidden] { display: none }`. */
|
||||
[data-menu] [hidden] {
|
||||
:is([data-menu], [data-menu-sheet]) [hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* `data-active` is the row the arrow keys are on while the focus stays in the field — the state
|
||||
layer the roving focus would have drawn, without taking the focus off the field. A chosen or
|
||||
current row keeps its own colour. */
|
||||
[data-menu] [role^="menuitem"][data-active]:not([aria-checked="true"], [aria-current="page"]) {
|
||||
:is([data-menu], [data-menu-sheet]) [role^="menuitem"][data-active]:not([aria-checked="true"], [aria-current="page"]) {
|
||||
background: color-mix(in srgb, var(--material-menu-ink, var(--md-sys-color-on-surface)) 8%, transparent);
|
||||
}
|
||||
|
||||
/*
|
||||
* `<x-menu sheet-at-compact>`: the copy of the list in the bottom sheet a compact window opens
|
||||
* instead of the popover. The sheet is surface-container-low, as the standard menu is, so the
|
||||
* fallbacks above already paint its field; the field reaches both edges of the sheet and holds
|
||||
* its icon where the rows hold theirs, 24px in (the list is 8px in, a row's own padding 16px).
|
||||
*/
|
||||
[data-menu-sheet] [data-menu-filter] {
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* A submenu there opens in place: its list stands under the item, inset by 16px, instead of in a
|
||||
* popover beside it, which a sheet has no room for (M3 calls submenus "best suited to large
|
||||
* screens", docs/reference/m3/components-actions-communication-containment.md § Menus). The
|
||||
* markup is the popover's, never shown as one: the user agent's `[popover]` box is undone, the
|
||||
* list is shown while its item says `aria-expanded="true"`, and it fades in on the effects track.
|
||||
*/
|
||||
[data-menu-sheet] [data-submenu] {
|
||||
position: static;
|
||||
inset: auto;
|
||||
inline-size: auto;
|
||||
max-inline-size: none;
|
||||
block-size: auto;
|
||||
max-block-size: none;
|
||||
margin: 0;
|
||||
padding-block: 0.125rem;
|
||||
padding-inline: 1rem 0;
|
||||
overflow: visible;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
scale: none;
|
||||
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
[data-menu-sheet] [aria-expanded="true"] + [data-submenu] {
|
||||
display: block;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* The chevron turns from the way a popover would open to the way this list did: down, which is a
|
||||
quarter turn the other way on the chevron `rtl:-scale-x-100` mirrors — so the same RTL test. */
|
||||
[data-menu-sheet] [data-submenu-chevron] {
|
||||
transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
|
||||
}
|
||||
|
||||
[data-menu-sheet] [aria-expanded="true"] > [data-submenu-chevron] {
|
||||
rotate: 90deg;
|
||||
|
||||
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
|
||||
rotate: -90deg;
|
||||
}
|
||||
}
|
||||
|
||||
.field-menu {
|
||||
max-block-size: 18rem;
|
||||
overflow-y: auto;
|
||||
|
||||
+331
-41
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* `materialMenu`: the behaviour of `<x-menu>` — WAI-ARIA's menu button pattern on a popover.
|
||||
* `materialSubmenu`: the same pattern one level in, for `<x-menu-item submenu>`.
|
||||
* `materialMenuSheet`: the `open` an `<x-menu sheet-at-compact>` lends its bottom sheet.
|
||||
*
|
||||
* The menu button is the trigger's first button or link. Its ARIA attributes are written by
|
||||
* script, which a Livewire morph removes along with anything else the server did not render,
|
||||
@@ -18,15 +19,27 @@
|
||||
* nested `popover="auto"` light-dismisses only down to its DOM ancestor — and closes the inner one
|
||||
* when the outer goes. Its trigger *is* the item, which the server names itself, so the two pieces
|
||||
* 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
|
||||
* slot) are overridden away. `items()` stops at the list 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.
|
||||
* nothing at all in a menu with no field: `field()` finding one is what turns them on.
|
||||
*
|
||||
* `<x-menu sheet-at-compact>` has a second copy of its list in a modal `<x-bottom-sheet>`, which
|
||||
* the server teleports to <body>, and below `medium` (`upTo('medium')`, 600px) `open()` shows that
|
||||
* instead of the popover (M3 § Menus → Behaviour: "at compact breakpoints, consider swapping a
|
||||
* menu for a bottom sheet"). `shown` remembers which of the two was opened last, and `list()`
|
||||
* is that one's list, so the keyboard, the filter and `activate()` work on whichever the person is
|
||||
* looking at. The sheet's own ways out — its scrim, a swipe, Escape, its handle — only set `open`
|
||||
* false, and `sheetToggled()` follows them with the trigger's `aria-expanded` and its focus. A
|
||||
* submenu in that copy is never shown as a popover: `inline` makes it open in place under its
|
||||
* item, which menu.css draws from the item's `aria-expanded`.
|
||||
*/
|
||||
import { upTo } from './breakpoints.js'
|
||||
|
||||
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
|
||||
|
||||
// A popover="auto" closes on the press that lands on its trigger, and the click that follows
|
||||
@@ -48,6 +61,14 @@ const menu = () => ({
|
||||
focusWasInside: false,
|
||||
listeners: [],
|
||||
|
||||
// `<x-menu sheet-at-compact>` only: the compact window's media query, whether the sheet is
|
||||
// open, which presentation was opened last ('menu' or 'sheet'), and what had the focus as the
|
||||
// sheet opened.
|
||||
compact: null,
|
||||
sheetOpen: false,
|
||||
shown: 'menu',
|
||||
focusedBefore: null,
|
||||
|
||||
init() {
|
||||
const menu = this.$refs.menu
|
||||
|
||||
@@ -103,10 +124,7 @@ 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()
|
||||
}
|
||||
this.clear(menu)
|
||||
})
|
||||
|
||||
// A press outside closes the menu without pulling focus back to the trigger.
|
||||
@@ -115,6 +133,32 @@ const menu = () => ({
|
||||
this.returnFocus = false
|
||||
}
|
||||
})
|
||||
|
||||
if (this.$el.hasAttribute('data-sheet-at-compact')) {
|
||||
this.compact = upTo('medium')
|
||||
|
||||
// A window resized across 600px while the menu is open would leave it in the
|
||||
// presentation the window no longer asks for; it closes instead, and the trigger says
|
||||
// what it opens now.
|
||||
this.listen(this.compact, 'change', () => {
|
||||
this.close()
|
||||
this.label()
|
||||
})
|
||||
|
||||
this.$watch('sheetOpen', (opened) => this.sheetToggled(opened))
|
||||
|
||||
// The sheet is teleported after this runs, so what reads it waits a tick: a morph gives
|
||||
// it a new id with the popover's, and the trigger has to point at the one it opens.
|
||||
this.$nextTick(() => {
|
||||
const dialog = this.sheetDialog()
|
||||
|
||||
if (dialog) {
|
||||
observer.observe(dialog, { attributes: true, attributeFilter: ['id'] })
|
||||
}
|
||||
|
||||
this.label()
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
control() {
|
||||
@@ -154,7 +198,10 @@ const menu = () => ({
|
||||
}
|
||||
},
|
||||
|
||||
/** Writes only what differs: the observer that calls this watches these same attributes. */
|
||||
/**
|
||||
* Writes only what differs: the observer that calls this watches these same attributes. On a
|
||||
* compact window a `sheet-at-compact` trigger opens a dialog, and says so.
|
||||
*/
|
||||
label() {
|
||||
const control = this.control()
|
||||
|
||||
@@ -162,7 +209,15 @@ const menu = () => ({
|
||||
return
|
||||
}
|
||||
|
||||
const attributes = { 'aria-haspopup': 'menu', 'aria-controls': this.$refs.menu.id, 'aria-expanded': String(this.isOpen()) }
|
||||
// Named even on a wide window, so no two sheets on a page share the rendered id.
|
||||
const dialog = this.sheetDialog()
|
||||
const sheet = this.sheeted() ? dialog : null
|
||||
|
||||
const attributes = {
|
||||
'aria-haspopup': sheet ? 'dialog' : 'menu',
|
||||
'aria-controls': sheet ? sheet.id : this.$refs.menu.id,
|
||||
'aria-expanded': String(this.isOpen()),
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries(attributes)) {
|
||||
if (control.getAttribute(name) !== value) {
|
||||
@@ -172,10 +227,51 @@ const menu = () => ({
|
||||
},
|
||||
|
||||
isOpen() {
|
||||
return this.$refs.menu.matches(':popover-open')
|
||||
return this.sheetOpen || this.$refs.menu.matches(':popover-open')
|
||||
},
|
||||
|
||||
/** Whether opening now means the sheet: a `sheet-at-compact` menu on a compact window. */
|
||||
sheeted() {
|
||||
return Boolean(this.compact?.matches && this.$refs.sheetHost)
|
||||
},
|
||||
|
||||
/**
|
||||
* The sheet's `role="dialog"`, which the trigger controls while the window is compact. Every
|
||||
* menu's sheet is rendered with the same id, which a morph matches it by (menu.blade.php), so
|
||||
* this names each one after its popover and keeps the rendered id as its `wire:key`: the key
|
||||
* matches the next render's id, and a render that puts the rendered id back is named again —
|
||||
* the id observer calls here through `label()`.
|
||||
*/
|
||||
sheetDialog() {
|
||||
const dialog = this.$refs.sheetHost?.querySelector('[role="dialog"]') ?? null
|
||||
const id = `${this.$refs.menu.id}-sheet`
|
||||
|
||||
if (dialog && dialog.id !== id) {
|
||||
dialog.setAttribute('wire:key', 'material-menu-sheet')
|
||||
dialog.id = id
|
||||
}
|
||||
|
||||
return dialog
|
||||
},
|
||||
|
||||
/** The list the person is looking at: the popover, or the sheet's copy of it. */
|
||||
list() {
|
||||
return this.shown === 'sheet' ? this.$refs.sheetHost.querySelector('[data-menu-sheet]') : this.$refs.menu
|
||||
},
|
||||
|
||||
/** The filter field at the top of a list, if the menu has one; a submenu never does. */
|
||||
field(root = this.list()) {
|
||||
return root?.querySelector(':scope > [data-menu-filter] input') ?? null
|
||||
},
|
||||
|
||||
open(focus = 'first') {
|
||||
if (this.sheeted()) {
|
||||
this.openSheet(focus)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.shown = 'menu'
|
||||
this.label()
|
||||
this.anchor()
|
||||
|
||||
@@ -188,10 +284,8 @@ const menu = () => ({
|
||||
|
||||
// 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)
|
||||
if (this.field()) {
|
||||
this.lookUp()
|
||||
|
||||
return
|
||||
}
|
||||
@@ -203,8 +297,45 @@ const menu = () => ({
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The sheet shows a frame or two after `open` turns true, once its transition has begun, and
|
||||
* Alpine holds `$nextTick` until then. Its focus trap starts on a timer of its own and keeps a
|
||||
* focus already inside it, so the item (or the field) M3 asks to be focused first wins over
|
||||
* the drag handle the trap would otherwise pick.
|
||||
*/
|
||||
openSheet(focus) {
|
||||
this.focusedBefore = document.activeElement
|
||||
this.shown = 'sheet'
|
||||
this.sheetOpen = true
|
||||
this.control()?.setAttribute('aria-expanded', 'true')
|
||||
this.label()
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.field()) {
|
||||
this.lookUp()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.focusItem(focus)
|
||||
})
|
||||
},
|
||||
|
||||
/** Focus in the field, its text selected, and the first row it leaves highlighted. */
|
||||
lookUp() {
|
||||
const field = this.field()
|
||||
|
||||
field.focus()
|
||||
field.select()
|
||||
this.mark(this.visible()[0] ?? null)
|
||||
},
|
||||
|
||||
close() {
|
||||
if (this.isOpen()) {
|
||||
if (this.sheetOpen) {
|
||||
this.sheetOpen = false
|
||||
}
|
||||
|
||||
if (this.$refs.menu.matches(':popover-open')) {
|
||||
this.$refs.menu.hidePopover()
|
||||
}
|
||||
|
||||
@@ -220,15 +351,56 @@ const menu = () => ({
|
||||
},
|
||||
|
||||
/**
|
||||
* Every item of *this* menu, disabled ones included: M3 keeps a disabled item focusable
|
||||
* Follows the sheet opening or closing, however it was done. Its focus trap hands the focus
|
||||
* back on a timer of its own, started as it lets go, to whatever had it when the trap began:
|
||||
* an item of the sheet now sliding away, or what had it before the sheet opened — in WebKit,
|
||||
* where a press does not focus a button, a region around the trigger. The trigger takes it on
|
||||
* a timer started inside a timer, which runs after the trap's, unless something else (a
|
||||
* dialog an item opened) has it by then.
|
||||
*/
|
||||
sheetToggled(opened) {
|
||||
this.control()?.setAttribute('aria-expanded', String(opened))
|
||||
|
||||
if (opened) {
|
||||
return
|
||||
}
|
||||
|
||||
const list = this.$refs.sheetHost?.querySelector('[data-menu-sheet]')
|
||||
|
||||
if (list) {
|
||||
this.clear(list)
|
||||
|
||||
// A submenu opened in place is closed with the sheet, as a nested popover closes with
|
||||
// its menu.
|
||||
for (const submenu of list.querySelectorAll('[data-submenu]')) {
|
||||
window.Alpine.$data(submenu)?.close?.()
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() =>
|
||||
setTimeout(() => {
|
||||
const active = document.activeElement
|
||||
|
||||
if (active === null || active === document.body || active === this.focusedBefore || this.$refs.sheetHost?.contains(active)) {
|
||||
this.control()?.focus()
|
||||
}
|
||||
|
||||
this.focusedBefore = null
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Every item of *this* list, disabled ones included: M3 keeps a disabled item focusable
|
||||
* ("disabled items can still receive focus, just aren't selectable") so a person reading the
|
||||
* menu with the keyboard learns that it exists. activate() is where the refusal lives.
|
||||
*
|
||||
* An open submenu is a popover of its own nested in this one, and its rows are its: the
|
||||
* nearest popover around a row says which menu the arrow keys should find it in.
|
||||
* An open submenu is a list of its own nested in this one, and its rows are its: the nearest
|
||||
* popover or sheet list around a row says which menu the arrow keys should find it in. A
|
||||
* submenu keeps its `popover` attribute in the sheet too, where it opens in place.
|
||||
*/
|
||||
items() {
|
||||
return [...this.$refs.menu.querySelectorAll(ITEMS)].filter((item) => item.closest('[popover]') === this.$refs.menu)
|
||||
items(root = this.list()) {
|
||||
return [...root.querySelectorAll(ITEMS)].filter((item) => item.closest('[popover], [data-menu-sheet]') === root)
|
||||
},
|
||||
|
||||
/** The menu scrolls when it is too long for the window, so the item taken has to be shown. */
|
||||
@@ -285,58 +457,102 @@ const menu = () => ({
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The sheet's keyboard, heard on its host before anything inside: the sheet's scope, where its
|
||||
* items live, has a `close()` and an `activate()` of its own. The field gets the combobox keys
|
||||
* and the list's own items the menu's; an item of a submenu opened in place is left to that
|
||||
* submenu. Tab leaves a menu (APG) and so the sheet around it, which its focus trap would
|
||||
* otherwise keep circling; Escape reaches the sheet itself, which closes.
|
||||
*/
|
||||
sheetKeydown(event) {
|
||||
const field = this.field()
|
||||
const item = event.target.closest(ITEMS)
|
||||
|
||||
if (event.key === 'Tab' && (item !== null || event.target === field)) {
|
||||
event.preventDefault()
|
||||
this.close()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (field !== null && event.target === field) {
|
||||
this.search(event)
|
||||
} else if (item !== null && this.items().includes(item)) {
|
||||
this.navigate(event)
|
||||
}
|
||||
},
|
||||
|
||||
/** Empties a list's filter field and shows every row again. */
|
||||
clear(root) {
|
||||
const field = this.field(root)
|
||||
|
||||
if (field) {
|
||||
field.value = ''
|
||||
this.refine(root)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* `<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()
|
||||
refine(root = this.list()) {
|
||||
const field = this.field(root)
|
||||
|
||||
for (const item of this.items()) {
|
||||
if (!field) {
|
||||
return
|
||||
}
|
||||
|
||||
const query = field.value.trim().toLowerCase()
|
||||
|
||||
for (const item of this.items(root)) {
|
||||
item.hidden = query !== '' && !(item.textContent ?? '').trim().toLowerCase().includes(query)
|
||||
}
|
||||
|
||||
for (const rule of this.$refs.menu.querySelectorAll('[role="separator"]')) {
|
||||
for (const rule of root.querySelectorAll('[role="separator"]')) {
|
||||
rule.hidden = query !== ''
|
||||
}
|
||||
|
||||
for (const group of this.$refs.menu.querySelectorAll('[role="group"]')) {
|
||||
for (const group of root.querySelectorAll('[role="group"]')) {
|
||||
group.hidden = ![...group.querySelectorAll(ITEMS)].some((item) => !item.hidden)
|
||||
}
|
||||
|
||||
const left = this.visible()
|
||||
const left = this.visible(root)
|
||||
|
||||
this.$refs.empty.hidden = left.length > 0
|
||||
this.mark(left[0] ?? null)
|
||||
root.querySelector('[data-menu-empty]').hidden = left.length > 0
|
||||
this.mark(left[0] ?? null, root)
|
||||
},
|
||||
|
||||
/** The rows a query has left, in the order they are read. */
|
||||
visible() {
|
||||
return this.items().filter((item) => !item.hidden && item.closest('[hidden]') === null)
|
||||
visible(root = this.list()) {
|
||||
return this.items(root).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.
|
||||
* an id for `aria-activedescendant` to name it, and gets one if the caller wrote none — from
|
||||
* the list's own id, which the popover and the sheet do not share.
|
||||
*/
|
||||
mark(item) {
|
||||
for (const each of this.items()) {
|
||||
mark(item, root = this.list()) {
|
||||
const field = this.field(root)
|
||||
|
||||
for (const each of this.items(root)) {
|
||||
if (each !== item) {
|
||||
each.removeAttribute('data-active')
|
||||
}
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
this.$refs.filter.removeAttribute('aria-activedescendant')
|
||||
field?.removeAttribute('aria-activedescendant')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
item.id ||= `${this.$refs.menu.id}-item-${this.items().indexOf(item)}`
|
||||
item.id ||= `${root.id}-item-${this.items(root).indexOf(item)}`
|
||||
item.setAttribute('data-active', '')
|
||||
this.$refs.filter.setAttribute('aria-activedescendant', item.id)
|
||||
field?.setAttribute('aria-activedescendant', item.id)
|
||||
|
||||
if (this.isOpen()) {
|
||||
item.scrollIntoView({ block: 'nearest' })
|
||||
@@ -374,8 +590,8 @@ const menu = () => ({
|
||||
|
||||
return
|
||||
case 'Escape':
|
||||
// The browser's own light dismiss closes the popover; this only says where the
|
||||
// focus goes after it.
|
||||
// The browser's own light dismiss closes the popover, and the sheet's Escape the
|
||||
// sheet; this only says where the focus goes after it.
|
||||
this.returnFocus = true
|
||||
|
||||
return
|
||||
@@ -408,12 +624,37 @@ const menu = () => ({
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialMenu', menu)
|
||||
|
||||
/**
|
||||
* `<x-bottom-sheet>` reads and writes `open` from the scope around it, and in a menu's scope
|
||||
* `open` is a method. This scope sits between the two and passes the sheet's `open` through to
|
||||
* the menu's `sheetOpen`: a getter and setter pair, which Alpine calls with the scope that
|
||||
* asked, so `this` reaches the menu from inside the sheet as well.
|
||||
*/
|
||||
window.Alpine.data('materialMenuSheet', () => ({
|
||||
get open() {
|
||||
return this.sheetOpen
|
||||
},
|
||||
|
||||
set open(value) {
|
||||
this.sheetOpen = Boolean(value)
|
||||
},
|
||||
}))
|
||||
|
||||
window.Alpine.data('materialSubmenu', () => {
|
||||
const base = menu()
|
||||
|
||||
return {
|
||||
...base,
|
||||
hoverTimer: null,
|
||||
inline: false,
|
||||
expanded: false,
|
||||
|
||||
/** In the sheet of a `sheet-at-compact` menu the submenu opens in place, under its item. */
|
||||
init() {
|
||||
this.inline = this.$el.closest('[data-menu-sheet]') !== null
|
||||
|
||||
base.init.call(this)
|
||||
},
|
||||
|
||||
/** The item is the menu button, and the server named it: nothing has to be moved. */
|
||||
control() {
|
||||
@@ -422,11 +663,57 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
anchor() {},
|
||||
|
||||
isOpen() {
|
||||
return this.inline ? this.expanded : base.isOpen.call(this)
|
||||
},
|
||||
|
||||
/**
|
||||
* The sheet's copy of a submenu is rendered with the popover copy's id; it takes one of
|
||||
* its own, and takes it again after a morph puts the rendered one back.
|
||||
*/
|
||||
label() {
|
||||
const list = this.$refs.menu
|
||||
|
||||
if (this.inline && !list.id.endsWith('-sheet')) {
|
||||
list.id = `${list.id}-sheet`
|
||||
}
|
||||
|
||||
base.label.call(this)
|
||||
},
|
||||
|
||||
/** In place, the item's `aria-expanded` is what shows the list (menu.css). */
|
||||
open(focus = 'first') {
|
||||
if (!this.inline) {
|
||||
base.open.call(this, focus)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.expanded = true
|
||||
this.label()
|
||||
|
||||
if (focus !== false) {
|
||||
this.focusItem(focus)
|
||||
}
|
||||
},
|
||||
|
||||
close() {
|
||||
if (!this.inline) {
|
||||
base.close.call(this)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.expanded = false
|
||||
this.control()?.setAttribute('aria-expanded', 'false')
|
||||
},
|
||||
|
||||
navigate(event) {
|
||||
// APG: Left closes a submenu and puts the focus back on the item that opened it.
|
||||
// Escape does the same through the browser's own light dismiss, which the `toggle`
|
||||
// listener follows with the focus.
|
||||
if (event.key === 'ArrowLeft') {
|
||||
// listener follows with the focus — or, opened in place in a sheet, where nothing
|
||||
// light-dismisses it, here, and no further: the sheet stays open.
|
||||
if (event.key === 'ArrowLeft' || (this.inline && event.key === 'Escape')) {
|
||||
event.preventDefault()
|
||||
this.returnFocus = true
|
||||
this.close()
|
||||
@@ -438,9 +725,12 @@ document.addEventListener('alpine:init', () => {
|
||||
base.navigate.call(this, event)
|
||||
},
|
||||
|
||||
/** Hover opens a submenu only where hovering means something, and never on a first tap. */
|
||||
/**
|
||||
* Hover opens a submenu only where hovering means something, and never on a first tap,
|
||||
* nor in a sheet, where the list would jump open under the pointer.
|
||||
*/
|
||||
fine(event) {
|
||||
return (event === undefined || event.pointerType !== 'touch') && window.matchMedia('(hover: hover) and (pointer: fine)').matches
|
||||
return !this.inline && (event === undefined || event.pointerType !== 'touch') && window.matchMedia('(hover: hover) and (pointer: fine)').matches
|
||||
},
|
||||
|
||||
hover(event) {
|
||||
|
||||
@@ -21,6 +21,14 @@
|
||||
pointer resting on the item opens it. Choosing anything inside closes the whole menu, as it
|
||||
would from the outer list.
|
||||
|
||||
In the bottom sheet of an `<x-menu sheet-at-compact>` on a compact window the same markup opens
|
||||
in place instead: the list stands under the item, inset, and the chevron turns to point down
|
||||
at it (menu.css) — a sheet has no room beside an item, and M3 calls submenus "best suited to
|
||||
large screens" (docs/reference/m3/components-actions-communication-containment.md § Menus →
|
||||
Behaviour). It is never shown as a popover there, so menu.js only flips `aria-expanded`, and
|
||||
the keyboard stays the one above: Right, Enter or Space open it, Left or Escape close it and
|
||||
come back to the item, and Escape closes nothing more.
|
||||
|
||||
`icon-class` is for an icon whose colour means something of its own, a sport's glyph in the
|
||||
sport's colour (`icon-class="text-sport-run"`). A colour there paints the icon, a selected
|
||||
item's too: the icon's own colour then carries no specificity, because which of two colour
|
||||
@@ -141,7 +149,7 @@
|
||||
<x-livewire-material::icon :name="$iconRight" optical="20" :class="'size-5 '.$iconInk" />
|
||||
@elseif ($submenu)
|
||||
{{-- M3's submenu marker: it points the way the list opens, and turns over in an RTL page. --}}
|
||||
<x-livewire-material::icon name="chevron_right" optical="20" :class="'size-5 rtl:-scale-x-100 '.$iconInk" />
|
||||
<x-livewire-material::icon name="chevron_right" optical="20" data-submenu-chevron :class="'size-5 rtl:-scale-x-100 '.$iconInk" />
|
||||
@elseif ($selected === true)
|
||||
{{-- The third cue M3 recommends, so a chosen item is not told by colour and shape alone. --}}
|
||||
<x-livewire-material::icon name="check" optical="20" :class="'size-5 '.$iconInk" />
|
||||
|
||||
@@ -50,6 +50,33 @@
|
||||
one `<x-choices searchable>` uses. The list around it stays a `role="menu"` of its own inside
|
||||
the popover, because a text field is not a thing a menu may contain.
|
||||
|
||||
`sheet-at-compact` is M3's adaptive menu: "at compact breakpoints, consider swapping a menu
|
||||
for a bottom sheet (more room for items/longer labels); at medium/expanded breakpoints, menus
|
||||
work well in context" (docs/reference/m3/components-actions-communication-containment.md
|
||||
§ Menus → Behaviour; foundations.md § Layout gives compact the bottom sheet for supplemental
|
||||
selection and actions, and medium up the menu). Below `medium` (600px) the trigger opens the
|
||||
items in a modal `<x-bottom-sheet>` — M3's "alternative to inline menus … on mobile", closed
|
||||
by choosing an item, the scrim, a swipe down or Escape (§ Bottom sheets → Behaviour) — and
|
||||
from `medium` it opens the popover. The trigger says which: `aria-haspopup="dialog"` and the
|
||||
sheet's id while the window is compact, `menu` and the popover's otherwise, `aria-expanded`
|
||||
in both. The slot is written once and drawn twice, in the popover and in the sheet, so a
|
||||
Livewire render patches both copies and a chosen item shows chosen in either. The sheet is
|
||||
teleported to the end of <body>: a menu in a sticky app bar or a toolbar would otherwise cover
|
||||
the window only inside that bar's stacking context, under the navigation bar.
|
||||
|
||||
Inside the sheet the items keep their roles and the keyboard above — arrows, Home, End, a
|
||||
letter; Escape closes the sheet and Tab does too, and either returns focus to the trigger —
|
||||
and choosing one closes the sheet as it closes the popover. A submenu opens in place under
|
||||
its item instead of beside it (M3 calls submenus "best suited to large screens"), and a
|
||||
`filter` field stands at the top of the sheet. A window resized across 600px closes whichever
|
||||
is open rather than leaving the other shown. The sheet is M3's own container
|
||||
(surface-container-low) for a `vibrant` menu too, and is as tall as its items up to the
|
||||
half screen M3 caps a modal sheet's first position at, scrolling inside past that. The
|
||||
sheet's id is fixed and the lists are keyed, so a render keeps an open sheet open with its
|
||||
focus where it was (below). Since the items exist twice, an `id` of the caller's or a nested
|
||||
Livewire component among them would exist twice too: keep those out of a `sheet-at-compact`
|
||||
menu.
|
||||
|
||||
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
|
||||
2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from
|
||||
androidx Compose Material 3 (Apache-2.0). --}}
|
||||
@@ -59,6 +86,7 @@
|
||||
'position' => 'bottom-start',
|
||||
'vibrant' => false,
|
||||
'filter' => false,
|
||||
'sheetAtCompact' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -68,9 +96,13 @@
|
||||
|
||||
$filtering = $filter !== false && $filter !== null && $filter !== '';
|
||||
$filterLabel = is_string($filter) && filled($filter) ? $filter : __('Filter');
|
||||
|
||||
// A dialog needs a name; the menu's own label is the one it has.
|
||||
$sheet = (bool) $sheetAtCompact;
|
||||
$sheetLabel = filled($label) ? $label : __('Menu');
|
||||
@endphp
|
||||
|
||||
<div x-data="materialMenu" {{ $attributes->class('relative inline-flex') }}>
|
||||
<div x-data="materialMenu" @if ($sheet) data-sheet-at-compact @endif {{ $attributes->class('relative inline-flex') }}>
|
||||
<span x-ref="trigger" class="inline-flex" style="anchor-name: {{ $anchor }}"
|
||||
x-on:click="toggle('first')"
|
||||
x-on:keydown.down.prevent="open('first')"
|
||||
@@ -108,7 +140,6 @@
|
||||
<x-livewire-material::icon name="search" optical="20" class="size-5 shrink-0" />
|
||||
|
||||
<input
|
||||
x-ref="filter"
|
||||
type="text"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
@@ -129,10 +160,81 @@
|
||||
], fn ($value): bool => filled($value))) }} class="p-1">
|
||||
{{ $slot }}
|
||||
|
||||
<p x-ref="empty" hidden class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</p>
|
||||
<p data-menu-empty hidden class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</p>
|
||||
</div>
|
||||
@else
|
||||
{{ $slot }}
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($sheet)
|
||||
{{-- The compact presentation. The host is the element menu.js knows the sheet by, and its
|
||||
listeners run in this menu's scope; the one inside it only lends the bottom sheet the
|
||||
`open` it reads, which in this scope is the name of a method. Everything inside the
|
||||
sheet is in the sheet's scope, where `close` and `activate` are the sheet's, so
|
||||
nothing in there calls the menu by name.
|
||||
|
||||
A Livewire morph matches an element by its `wire:key`, or else by its id, and swaps in
|
||||
a fresh copy where the two differ — which would take the focus out of an open sheet.
|
||||
The lists carry keys of their own, as the popover does. The bottom sheet cannot carry
|
||||
one: a `wire:key` given to a Blade component becomes the key of the loop around it,
|
||||
for every Livewire component after it. So the sheet is rendered with one fixed id,
|
||||
the same in every render, and menu.js gives each sheet a unique id of its own while
|
||||
keeping the fixed one as its `wire:key`. --}}
|
||||
<template x-teleport="body">
|
||||
<div
|
||||
x-ref="sheetHost"
|
||||
data-menu-sheet-host
|
||||
x-on:keydown.capture="sheetKeydown($event)"
|
||||
x-on:click="activate($event)"
|
||||
x-on:input="refine()"
|
||||
>
|
||||
<div x-data="materialMenuSheet">
|
||||
<x-livewire-material::bottom-sheet id="material-menu-sheet" aria-label="{{ $sheetLabel }}">
|
||||
@if ($filtering)
|
||||
<div {{ new \Illuminate\View\ComponentAttributeBag([
|
||||
'wire:key' => 'material-menu-sheet-menu',
|
||||
'id' => "material-menu-{$key}-sheet-menu",
|
||||
]) }} data-menu-sheet class="-mx-6">
|
||||
<div data-menu-filter>
|
||||
<x-livewire-material::icon name="search" optical="20" class="size-5 shrink-0" />
|
||||
|
||||
<input
|
||||
type="text"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded="true"
|
||||
aria-controls="material-menu-{{ $key }}-sheet-menu-list"
|
||||
aria-label="{{ $filterLabel }}"
|
||||
placeholder="{{ $filterLabel }}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div {{ new \Illuminate\View\ComponentAttributeBag(array_filter([
|
||||
'wire:key' => 'material-menu-sheet-list',
|
||||
'id' => "material-menu-{$key}-sheet-menu-list",
|
||||
'role' => 'menu',
|
||||
'aria-label' => $label,
|
||||
], fn ($value): bool => filled($value))) }} class="px-2 pt-1">
|
||||
{{ $slot }}
|
||||
|
||||
<p data-menu-empty hidden class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div {{ new \Illuminate\View\ComponentAttributeBag(array_filter([
|
||||
'wire:key' => 'material-menu-sheet-menu',
|
||||
'id' => "material-menu-{$key}-sheet-menu",
|
||||
'role' => 'menu',
|
||||
'aria-label' => $label,
|
||||
], fn ($value): bool => filled($value))) }} data-menu-sheet class="-mx-6 px-2">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
@endif
|
||||
</x-livewire-material::bottom-sheet>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -60,6 +60,23 @@
|
||||
<x-menu-item label="Delete" icon="delete" />
|
||||
</x-menu>
|
||||
BLADE,
|
||||
'A bottom sheet on a phone, a menu from medium' => <<<'BLADE'
|
||||
<x-menu label="Photo actions" sheet-at-compact>
|
||||
<x-slot:trigger>
|
||||
<x-button label="Photo" icon="photo" icon-right="arrow_drop_down" variant="outlined" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-menu-item label="Set as wallpaper" icon="wallpaper" description="Home and lock screen" />
|
||||
<x-menu-item label="Add to album" icon="photo_album" submenu>
|
||||
<x-menu-item label="Holidays" icon="beach_access" />
|
||||
<x-menu-item label="Family" icon="family_restroom" />
|
||||
<x-menu-item label="Favourites" icon="favorite" />
|
||||
</x-menu-item>
|
||||
<x-menu-item label="Print" icon="print" shortcut="⌘P" />
|
||||
<x-menu-separator />
|
||||
<x-menu-item label="Delete" icon="delete" />
|
||||
</x-menu>
|
||||
BLADE,
|
||||
'Clusters: a gap, or a divider' => <<<'BLADE'
|
||||
<x-menu label="Grouped by a gap">
|
||||
<x-slot:trigger>
|
||||
@@ -128,7 +145,8 @@
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><x-menu></code> with <code><x-menu-item></code>, <code><x-menu-group></code> and <code><x-menu-separator></code>.
|
||||
Open one with the keyboard too: arrows, Home, End, a letter, Escape. A <code>submenu</code> item opens a second list beside it
|
||||
— Right to enter it, Left to come back.
|
||||
— Right to enter it, Left to come back. A <code>sheet-at-compact</code> menu opens the same items in a bottom sheet
|
||||
below 600px, where a submenu opens in place: narrow the window to see it.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
|
||||
Reference in New Issue
Block a user