Open a submenu from a menu item
Plan step 22, actions.md § Missing (Submenus): M3's Expressive vertical menu specifies submenus and the Left/Right keys that open and close them, and the library had neither. `<x-menu-item submenu>` puts its slot in a second popover on the item's end, flipping to the start where there is no room. `materialSubmenu` reuses the menu button pattern one level in: the item is its own trigger, so the anchor move and the button lookup are overridden away; Right, Enter and Space open it, Left and Escape close it and return the focus, and a fine pointer resting on the item opens it after a moment. `items()` now stops at the popover it belongs to, so the arrows never walk between a menu and an open submenu. The menu publishes its container as custom properties so a vibrant menu's submenus are vibrant too. 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
d74fa250ee
commit
25091ebf21
@@ -261,7 +261,18 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
|
|||||||
</x-menu>
|
</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`. `<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`. 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`. `<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:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-menu-item label="Export as" icon="download" submenu>
|
||||||
|
<x-menu-item label="ZIP" icon="folder_zip" wire:click="exportZip" />
|
||||||
|
<x-menu-item label="PDF" icon="picture_as_pdf" wire:click="exportPdf" />
|
||||||
|
</x-menu-item>
|
||||||
|
```
|
||||||
|
|
||||||
|
The item says so with `aria-haspopup="menu"`, `aria-expanded` and a chevron; Right, Enter or Space open it on its first item, Left or Escape close it and come back, and on a fine pointer resting on the item opens it. Choosing anything inside closes the whole menu. Arrows stay inside the list they are in. M3 calls submenus a large-screen pattern — on a phone give the menu `sheet-at-compact`, or keep the list flat.
|
||||||
|
|
||||||
### `<x-button-group>`
|
### `<x-button-group>`
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* The exposed dropdown menu — the list a field drops open.
|
* The menus: the exposed dropdown a field drops open, and what `<x-menu>` cannot say in utilities.
|
||||||
*
|
*
|
||||||
* Two lists wear it, so a form reads as one family:
|
* Two lists wear the dropdown, so a form reads as one family:
|
||||||
*
|
*
|
||||||
* - `<x-select>`'s: the native <select>, opted into the browser's customizable select
|
* - `<x-select>`'s: the native <select>, opted into the browser's customizable select
|
||||||
* (`appearance: base-select`). `::picker(select)` is the menu and each <option> a row, while the
|
* (`appearance: base-select`). `::picker(select)` is the menu and each <option> a row, while the
|
||||||
@@ -20,6 +20,29 @@
|
|||||||
* paints option colours into its native list would otherwise show a half-painted one.
|
* paints option colours into its native list would otherwise show a half-painted one.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* `<x-menu>` and the submenus inside it.
|
||||||
|
*
|
||||||
|
* A submenu is a popover of its own nested in the menu's, so it cannot take the container colour
|
||||||
|
* from a class the item knows nothing about: the menu declares the pair as custom properties,
|
||||||
|
* which a nested popover inherits down the DOM whatever the top layer does with its painting. The
|
||||||
|
* fallbacks are the standard menu's, for a submenu in some other list (a FAB menu's).
|
||||||
|
*/
|
||||||
|
[data-menu] {
|
||||||
|
--material-menu-surface: var(--md-sys-color-surface-container-low);
|
||||||
|
--material-menu-ink: var(--md-sys-color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-menu][data-vibrant] {
|
||||||
|
--material-menu-surface: var(--md-sys-color-tertiary-container);
|
||||||
|
--material-menu-ink: var(--md-sys-color-on-tertiary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-submenu] {
|
||||||
|
background: var(--material-menu-surface, var(--md-sys-color-surface-container-low));
|
||||||
|
color: var(--material-menu-ink, var(--md-sys-color-on-surface));
|
||||||
|
}
|
||||||
|
|
||||||
.field-menu {
|
.field-menu {
|
||||||
max-block-size: 18rem;
|
max-block-size: 18rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
+103
-7
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* `materialMenu`: the behaviour of `<x-menu>` — WAI-ARIA's menu button pattern on a popover.
|
* `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>`.
|
||||||
*
|
*
|
||||||
* The menu button is the trigger's first button or link. Its ARIA attributes are written by
|
* 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,
|
* script, which a Livewire morph removes along with anything else the server did not render,
|
||||||
@@ -12,6 +13,13 @@
|
|||||||
* so the menu opened there. Script moves the name onto the menu button, beside any name the button
|
* so the menu opened there. Script moves the name onto the menu button, beside any name the button
|
||||||
* carries itself (a button's tooltip anchors on it too), and moves it again after every morph,
|
* carries itself (a button's tooltip anchors on it too), and moves it again after every morph,
|
||||||
* which puts the server's attributes, and a fresh name, back.
|
* which puts the server's attributes, and a fresh name, back.
|
||||||
|
*
|
||||||
|
* A submenu's popover sits inside its parent's, so the browser keeps the two open together — a
|
||||||
|
* 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
|
||||||
|
* menu never walk into an open submenu's rows, nor a submenu's back out into its parent's.
|
||||||
*/
|
*/
|
||||||
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
|
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
|
||||||
|
|
||||||
@@ -21,8 +29,13 @@ const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradi
|
|||||||
// click.
|
// click.
|
||||||
const REOPEN_GUARD_MS = 250
|
const REOPEN_GUARD_MS = 250
|
||||||
|
|
||||||
document.addEventListener('alpine:init', () => {
|
// A submenu opens after the pointer has rested on its item for a moment, and closes a moment after
|
||||||
window.Alpine.data('materialMenu', () => ({
|
// it leaves the pair — long enough to cross the gap between them. APG's menu pattern asks for both
|
||||||
|
// delays. A coarse pointer has no hover to speak of, so there it opens on the press instead.
|
||||||
|
const HOVER_OPEN_MS = 180
|
||||||
|
const HOVER_CLOSE_MS = 320
|
||||||
|
|
||||||
|
const menu = () => ({
|
||||||
closedAt: -Infinity,
|
closedAt: -Infinity,
|
||||||
anchored: null,
|
anchored: null,
|
||||||
returnFocus: true,
|
returnFocus: true,
|
||||||
@@ -160,7 +173,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.control()?.setAttribute('aria-expanded', 'true')
|
this.control()?.setAttribute('aria-expanded', 'true')
|
||||||
|
|
||||||
|
// `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) {
|
||||||
this.focusItem(focus)
|
this.focusItem(focus)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
@@ -180,12 +198,15 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every item, disabled ones included: M3 keeps a disabled item focusable ("disabled items
|
* Every item of *this* menu, disabled ones included: M3 keeps a disabled item focusable
|
||||||
* can still receive focus, just aren't selectable") so a person reading the menu with the
|
* ("disabled items can still receive focus, just aren't selectable") so a person reading the
|
||||||
* keyboard learns that it exists. activate() is where the refusal lives.
|
* 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.
|
||||||
*/
|
*/
|
||||||
items() {
|
items() {
|
||||||
return [...this.$refs.menu.querySelectorAll(ITEMS)]
|
return [...this.$refs.menu.querySelectorAll(ITEMS)].filter((item) => item.closest('[popover]') === this.$refs.menu)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** The menu scrolls when it is too long for the window, so the item taken has to be shown. */
|
/** The menu scrolls when it is too long for the window, so the item taken has to be shown. */
|
||||||
@@ -260,5 +281,80 @@ document.addEventListener('alpine:init', () => {
|
|||||||
destroy() {
|
destroy() {
|
||||||
this.listeners.forEach((remove) => remove())
|
this.listeners.forEach((remove) => remove())
|
||||||
},
|
},
|
||||||
}))
|
})
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
window.Alpine.data('materialMenu', menu)
|
||||||
|
|
||||||
|
window.Alpine.data('materialSubmenu', () => {
|
||||||
|
const base = menu()
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
hoverTimer: null,
|
||||||
|
|
||||||
|
/** The item is the menu button, and the server named it: nothing has to be moved. */
|
||||||
|
control() {
|
||||||
|
return this.$refs.trigger
|
||||||
|
},
|
||||||
|
|
||||||
|
anchor() {},
|
||||||
|
|
||||||
|
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') {
|
||||||
|
event.preventDefault()
|
||||||
|
this.returnFocus = true
|
||||||
|
this.close()
|
||||||
|
this.control()?.focus()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
base.navigate.call(this, event)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Hover opens a submenu only where hovering means something, and never on a first tap. */
|
||||||
|
fine(event) {
|
||||||
|
return (event === undefined || event.pointerType !== 'touch') && window.matchMedia('(hover: hover) and (pointer: fine)').matches
|
||||||
|
},
|
||||||
|
|
||||||
|
hover(event) {
|
||||||
|
if (!this.fine(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(this.hoverTimer)
|
||||||
|
this.hoverTimer = setTimeout(() => this.open(false), HOVER_OPEN_MS)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The submenu is a DOM child of the item's wrapper, so crossing into it is not a leave;
|
||||||
|
* a pointer that really left closes it, unless the keyboard has since taken it over.
|
||||||
|
*/
|
||||||
|
unhover() {
|
||||||
|
clearTimeout(this.hoverTimer)
|
||||||
|
|
||||||
|
if (!this.fine()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hoverTimer = setTimeout(() => {
|
||||||
|
if (this.$el.contains(document.activeElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.returnFocus = false
|
||||||
|
this.close()
|
||||||
|
}, HOVER_CLOSE_MS)
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
clearTimeout(this.hoverTimer)
|
||||||
|
base.destroy.call(this)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,14 @@
|
|||||||
is there. `keep-open` leaves the menu open when it is activated — for a choice the person may
|
is there. `keep-open` leaves the menu open when it is activated — for a choice the person may
|
||||||
want to change twice.
|
want to change twice.
|
||||||
|
|
||||||
|
`submenu` turns the item into a menu of its own: the slot holds `<x-menu-item>`s instead of a
|
||||||
|
label, and they open in a second popover beside this one, on the item's end, flipping to its
|
||||||
|
start where the window has no room. The item says so — `aria-haspopup="menu"`,
|
||||||
|
`aria-expanded`, and a chevron at its end — and keeps the APG menu keyboard: Right, Enter or
|
||||||
|
Space open it on its first item, Left or Escape close it and come back here, and on a fine
|
||||||
|
pointer resting on the item opens it. Choosing anything inside closes the whole menu, as it
|
||||||
|
would from the outer list.
|
||||||
|
|
||||||
`icon-class` is for an icon whose colour means something of its own, a sport's glyph in the
|
`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
|
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
|
item's too: the icon's own colour then carries no specificity, because which of two colour
|
||||||
@@ -41,12 +49,18 @@
|
|||||||
'badge' => null,
|
'badge' => null,
|
||||||
'disabled' => false,
|
'disabled' => false,
|
||||||
'keepOpen' => false,
|
'keepOpen' => false,
|
||||||
|
'submenu' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$isLink = filled($link);
|
$isLink = filled($link);
|
||||||
$tag = $isLink ? 'a' : 'button';
|
$tag = $isLink ? 'a' : 'button';
|
||||||
|
|
||||||
|
// A submenu's own popover, named like the menu's: a new id and a new anchor name with every
|
||||||
|
// render, matched through a morph by the key rather than by either of them.
|
||||||
|
$key = $submenu ? \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null;
|
||||||
|
$anchor = $submenu ? "--material-submenu-{$key}" : null;
|
||||||
|
|
||||||
$attributes = $attributes
|
$attributes = $attributes
|
||||||
->class([
|
->class([
|
||||||
'group/item state-layer flex w-full min-h-12 cursor-pointer items-center gap-3 px-4 text-start outline-none',
|
'group/item state-layer flex w-full min-h-12 cursor-pointer items-center gap-3 px-4 text-start outline-none',
|
||||||
@@ -64,12 +78,19 @@
|
|||||||
'aria-current' => $current ? 'page' : null,
|
'aria-current' => $current ? 'page' : null,
|
||||||
'aria-disabled' => $disabled ? 'true' : null,
|
'aria-disabled' => $disabled ? 'true' : null,
|
||||||
'tabindex' => '-1',
|
'tabindex' => '-1',
|
||||||
|
'x-ref' => $submenu ? 'trigger' : null,
|
||||||
|
'style' => $submenu ? "anchor-name: {$anchor}" : null,
|
||||||
|
'aria-haspopup' => $submenu ? 'menu' : null,
|
||||||
|
'aria-expanded' => $submenu ? 'false' : null,
|
||||||
|
'aria-controls' => $submenu ? "material-submenu-{$key}" : null,
|
||||||
|
'x-on:click' => $submenu ? "toggle('first')" : null,
|
||||||
'type' => $isLink ? null : 'button',
|
'type' => $isLink ? null : 'button',
|
||||||
'href' => $isLink ? $link : null,
|
'href' => $isLink ? $link : null,
|
||||||
'target' => $isLink && $external ? '_blank' : null,
|
'target' => $isLink && $external ? '_blank' : null,
|
||||||
'rel' => $isLink && $external ? 'noopener' : null,
|
'rel' => $isLink && $external ? 'noopener' : null,
|
||||||
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
|
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
|
||||||
'data-keep-open' => $keepOpen ? true : null,
|
// Opening a submenu is not choosing anything: the outer menu stays where it was.
|
||||||
|
'data-keep-open' => $keepOpen || $submenu ? true : null,
|
||||||
], fn ($value): bool => $value !== null));
|
], fn ($value): bool => $value !== null));
|
||||||
|
|
||||||
$iconInk = match (true) {
|
$iconInk = match (true) {
|
||||||
@@ -88,13 +109,21 @@
|
|||||||
};
|
};
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
|
@if ($submenu)
|
||||||
|
<div x-data="materialSubmenu"
|
||||||
|
x-on:keydown.right.prevent.stop="open('first')"
|
||||||
|
x-on:pointerenter="hover($event)"
|
||||||
|
x-on:pointerleave="unhover()"
|
||||||
|
>
|
||||||
|
@endif
|
||||||
|
|
||||||
<{{ $tag }} {{ $attributes }}>
|
<{{ $tag }} {{ $attributes }}>
|
||||||
@if ($icon)
|
@if ($icon)
|
||||||
<x-livewire-material::icon :name="$icon" optical="20" :filled="$selected === true || $current" :class="$leadingIcon" />
|
<x-livewire-material::icon :name="$icon" optical="20" :filled="$selected === true || $current" :class="$leadingIcon" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<span class="min-w-0 flex-1">
|
<span class="min-w-0 flex-1">
|
||||||
<span class="block truncate type-body-lg">{{ $label ?? $slot }}</span>
|
<span class="block truncate type-body-lg">{{ $submenu ? $label : ($label ?? $slot) }}</span>
|
||||||
|
|
||||||
@if ($description)
|
@if ($description)
|
||||||
<span @class(['block type-body-md', $iconInk])>{{ $description }}</span>
|
<span @class(['block type-body-md', $iconInk])>{{ $description }}</span>
|
||||||
@@ -110,8 +139,31 @@
|
|||||||
|
|
||||||
@if ($iconRight)
|
@if ($iconRight)
|
||||||
<x-livewire-material::icon :name="$iconRight" optical="20" :class="'size-5 '.$iconInk" />
|
<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" />
|
||||||
@elseif ($selected === true)
|
@elseif ($selected === true)
|
||||||
{{-- The third cue M3 recommends, so a chosen item is not told by colour and shape alone. --}}
|
{{-- 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" />
|
<x-livewire-material::icon name="check" optical="20" :class="'size-5 '.$iconInk" />
|
||||||
@endif
|
@endif
|
||||||
</{{ $tag }}>
|
</{{ $tag }}>
|
||||||
|
|
||||||
|
@if ($submenu)
|
||||||
|
<div
|
||||||
|
x-ref="menu"
|
||||||
|
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-submenu-'.substr(md5((string) $label), 0, 10)]) }}
|
||||||
|
id="material-submenu-{{ $key }}"
|
||||||
|
popover="auto"
|
||||||
|
role="menu"
|
||||||
|
data-submenu
|
||||||
|
aria-label="{{ $label }}"
|
||||||
|
tabindex="-1"
|
||||||
|
style="position-anchor: {{ $anchor }}"
|
||||||
|
x-on:keydown.stop="navigate($event)"
|
||||||
|
x-on:click="activate($event)"
|
||||||
|
class="m-0 mx-1 min-w-28 max-w-70 max-h-[min(18rem,calc(100dvh-2rem))] origin-top overflow-y-auto border-0 p-1 rounded-corner-lg shadow-elevation-2 popover-transition [inset:auto] [position-area:inline-end_span-block-end] [position-try-fallbacks:flip-inline]"
|
||||||
|
>
|
||||||
|
{{ $slot }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|||||||
@@ -69,6 +69,8 @@
|
|||||||
id="material-menu-{{ $key }}"
|
id="material-menu-{{ $key }}"
|
||||||
popover="auto"
|
popover="auto"
|
||||||
role="menu"
|
role="menu"
|
||||||
|
data-menu
|
||||||
|
@if ($vibrant) data-vibrant @endif
|
||||||
@if ($label) aria-label="{{ $label }}" @endif
|
@if ($label) aria-label="{{ $label }}" @endif
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
style="position-anchor: {{ $anchor }}"
|
style="position-anchor: {{ $anchor }}"
|
||||||
|
|||||||
@@ -36,6 +36,30 @@
|
|||||||
<x-menu-item label="Upload a folder" icon="drive_folder_upload" />
|
<x-menu-item label="Upload a folder" icon="drive_folder_upload" />
|
||||||
</x-menu>
|
</x-menu>
|
||||||
BLADE,
|
BLADE,
|
||||||
|
'Submenus' => <<<'BLADE'
|
||||||
|
<x-menu label="Share actions">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<x-button label="Share" icon="share" variant="tonal" />
|
||||||
|
</x-slot:trigger>
|
||||||
|
|
||||||
|
<x-menu-item label="Copy link" icon="content_copy" shortcut="⌘C" />
|
||||||
|
<x-menu-item label="Send to" icon="send" submenu>
|
||||||
|
<x-menu-item label="A person" icon="person" />
|
||||||
|
<x-menu-item label="A team" icon="group" />
|
||||||
|
<x-menu-item label="Somewhere else" icon="more_horiz" submenu>
|
||||||
|
<x-menu-item label="Slack" icon="chat" />
|
||||||
|
<x-menu-item label="Email" icon="mail" />
|
||||||
|
</x-menu-item>
|
||||||
|
</x-menu-item>
|
||||||
|
<x-menu-item label="Export as" icon="download" submenu>
|
||||||
|
<x-menu-item label="ZIP" icon="folder_zip" />
|
||||||
|
<x-menu-item label="PDF" icon="picture_as_pdf" />
|
||||||
|
<x-menu-item label="CSV" icon="table" disabled />
|
||||||
|
</x-menu-item>
|
||||||
|
<x-menu-separator />
|
||||||
|
<x-menu-item label="Delete" icon="delete" />
|
||||||
|
</x-menu>
|
||||||
|
BLADE,
|
||||||
'Icons in their own colour' => <<<'BLADE'
|
'Icons in their own colour' => <<<'BLADE'
|
||||||
<x-menu label="New plan">
|
<x-menu label="New plan">
|
||||||
<x-slot:trigger>
|
<x-slot:trigger>
|
||||||
@@ -56,7 +80,8 @@
|
|||||||
|
|
||||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
<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>.
|
<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.
|
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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@foreach ($examples as $title => $code)
|
@foreach ($examples as $title => $code)
|
||||||
|
|||||||
@@ -117,3 +117,51 @@ it('adds icon-class to the leading icon, over its own colour but not over disabl
|
|||||||
->and((string) $this->blade('<x-menu-item label="Next" icon-right="chevron_right" icon-class="text-sport-run" />'))
|
->and((string) $this->blade('<x-menu-item label="Next" icon-right="chevron_right" icon-class="text-sport-run" />'))
|
||||||
->not->toContain('text-sport-run');
|
->not->toContain('text-sport-run');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens a submenu beside the item that holds it', function () {
|
||||||
|
$html = (string) $this->blade(<<<'BLADE'
|
||||||
|
<x-menu-item label="Send to" icon="send" submenu>
|
||||||
|
<x-menu-item label="A person" icon="person" />
|
||||||
|
</x-menu-item>
|
||||||
|
BLADE);
|
||||||
|
|
||||||
|
preg_match('/anchor-name: (--material-submenu-([a-z0-9]+))/', $html, $anchor);
|
||||||
|
|
||||||
|
expect($anchor)->not->toBeEmpty()
|
||||||
|
->and($html)
|
||||||
|
->toContain('x-data="materialSubmenu"')
|
||||||
|
->toContain('x-on:keydown.right.prevent.stop="open(\'first\')"')
|
||||||
|
->toContain('x-on:pointerenter="hover($event)"')
|
||||||
|
->toContain('aria-haspopup="menu"')
|
||||||
|
->toContain('aria-expanded="false"')
|
||||||
|
->toContain("aria-controls=\"material-submenu-{$anchor[2]}\"")
|
||||||
|
->toContain("id=\"material-submenu-{$anchor[2]}\"")
|
||||||
|
->toContain("position-anchor: {$anchor[1]}")
|
||||||
|
->toContain('data-submenu')
|
||||||
|
->toContain('aria-label="Send to"')
|
||||||
|
->toContain('[position-area:inline-end_span-block-end]')
|
||||||
|
->toContain('[position-try-fallbacks:flip-inline]')
|
||||||
|
// Opening a submenu chooses nothing, so the menu around it stays where it was.
|
||||||
|
->toContain('data-keep-open')
|
||||||
|
->toContain('A person');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a submenu item with a chevron instead of a tick', function () {
|
||||||
|
$chevron = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/outlined-20/chevron_right.svg'));
|
||||||
|
$chevron = substr($chevron, (int) strpos($chevron, '><') + 1);
|
||||||
|
|
||||||
|
expect((string) $this->blade('<x-menu-item label="Export as" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
|
||||||
|
->toContain($chevron)
|
||||||
|
->toContain('rtl:-scale-x-100')
|
||||||
|
->and((string) $this->blade('<x-menu-item label="Export as" icon-right="download" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
|
||||||
|
->not->toContain($chevron);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells a vibrant menu apart, so the submenus inside it take the same container', function () {
|
||||||
|
expect((string) $this->blade('<x-menu vibrant><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
|
||||||
|
->toContain('data-menu')
|
||||||
|
->toContain('data-vibrant')
|
||||||
|
->and((string) $this->blade('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
|
||||||
|
->toContain('data-menu')
|
||||||
|
->not->toContain('data-vibrant');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user