Name the FAB menu, scroll it, and let its items leave

Its trigger holds two aria-hidden icons and wrote aria-label only when a caller
passed `label`, so an unnamed button was the default; `label` now falls back to
"Toggle menu" and is always written. The list had no ceiling, so six items ran
off a landscape phone — it now scrolls under the FAB, which stays put — and its
items rose into place but vanished on close, because the popover took its
display away before they could move. Plan step 18, actions.md ACT-21, ACT-32,
ACT-33.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold
2026-09-14 06:07:22 +02:00
co-authored by Claude Opus 5
parent deb7ff8ab5
commit eac7f6a1de
4 changed files with 49 additions and 12 deletions
@@ -304,7 +304,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
</div> </div>
``` ```
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as `<x-menu>`. Two to six items open above the FAB, which turns into a close button, rising into place as it opens and sinking back as it closes; a window too short for them scrolls the list while the FAB stays put. `<x-fab-menu>`: `icon` (`add`), `label` (defaults to "Toggle menu" — the trigger has no other accessible name), `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as `<x-menu>`. The wrapper keeps M3's margin from the window edge: 16dp, 24dp from `large`.
### `<x-loading>` ### `<x-loading>`
+13
View File
@@ -68,3 +68,16 @@
} }
} }
} }
/*
* A FAB menu's items rise into place as the list opens and sink back as it closes. The entry is
* `@starting-style` on the item itself; the exit needs the state to be readable while the popover
* is on its way out, which is what `:not(:popover-open)` is — the popover keeps `display: flex`
* for the length of its own discrete transition, so the items have somewhere to animate to.
*
* Unlayered (a plain rule, not an `@utility`), so it beats the item's own utilities.
*/
[data-fab-menu]:not(:popover-open) > * {
translate: 0 0.5rem;
opacity: 0;
}
+26 -10
View File
@@ -1,6 +1,6 @@
{{-- An M3 Expressive FAB menu: a FAB that opens into a short list of related actions. {{-- An M3 Expressive FAB menu: a FAB that opens into a short list of related actions.
<div class="fixed end-4 bottom-4"> <div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">
<x-fab-menu label="New"> <x-fab-menu label="New">
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" /> <x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
<x-fab-menu-item label="Upload a folder" icon="drive_folder_upload" wire:click="uploadFolder" /> <x-fab-menu-item label="Upload a folder" icon="drive_folder_upload" wire:click="uploadFolder" />
@@ -10,8 +10,14 @@
Two to six items. The FAB (`icon`, `add` by default, in `color`'s container) turns into a Two to six items. The FAB (`icon`, `add` by default, in `color`'s container) turns into a
round close button in the colour itself while the list is open above it, end-aligned; the round close button in the colour itself while the list is open above it, end-aligned; the
list is a `popover="auto"` menu with the menu keyboard of `<x-menu>`. `label` names the FAB list is a `popover="auto"` menu with the menu keyboard of `<x-menu>`. `label` names the FAB
for screen readers. Give the items the same `color`. Like `<x-menu>`'s, the list is keyed for and the menu for screen readers, and defaults to "Toggle menu" — the icons inside the button
Livewire, so it stays open through a render of the component around it. are aria-hidden, so without it the trigger has no name at all. Give the items the same
`color`. Like `<x-menu>`'s, the list is keyed for Livewire, so it stays open through a render
of the component around it.
It does not place itself. M3's margin from the edge of the window is 16dp, and 24dp from the
`large` class up: `fixed end-4 bottom-4 large:end-6 large:bottom-6`. A window too short for
the list scrolls it; the items rise into place as it opens and sink back as it closes.
FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px
above the close button. The FAB's corner and colours morph on the two default springs at once above the close button. The FAB's corner and colours morph on the two default springs at once
@@ -25,6 +31,10 @@
]) ])
@php @php
// The trigger's only content is two icons, which are aria-hidden, so without a name it is an
// unnamed button. M3 asks the FAB's label to say what menu it opens; "Toggle menu" is its own
// fallback wording for the close button.
$label = filled($label) ? $label : __('Toggle menu');
$color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary'; $color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary';
$key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)); $key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
$anchor = "--material-fab-menu-{$key}"; $anchor = "--material-fab-menu-{$key}";
@@ -44,7 +54,7 @@
> >
<button <button
type="button" type="button"
@if ($label) aria-label="{{ $label }}" @endif aria-label="{{ $label }}"
@class([ @class([
'group/fab state-layer focus-ring inline-flex size-14 shrink-0 cursor-pointer items-center justify-center rounded-corner-lg shadow-elevation-3 hover:shadow-elevation-4', 'group/fab state-layer focus-ring inline-flex size-14 shrink-0 cursor-pointer items-center justify-center rounded-corner-lg shadow-elevation-3 hover:shadow-elevation-4',
'state-transition-default aria-expanded:rounded-corner-full', 'state-transition-default aria-expanded:rounded-corner-full',
@@ -62,17 +72,23 @@
id="material-fab-menu-{{ $key }}" id="material-fab-menu-{{ $key }}"
popover="auto" popover="auto"
role="menu" role="menu"
@if ($label) aria-label="{{ $label }}" @endif data-fab-menu
aria-label="{{ $label }}"
tabindex="-1" tabindex="-1"
style="position-anchor: {{ $anchor }}" style="position-anchor: {{ $anchor }}"
x-on:keydown="navigate($event)" x-on:keydown="navigate($event)"
x-on:click="activate($event)" x-on:click="activate($event)"
@class([ @class([
'm-0 flex-col gap-1 overflow-visible border-0 bg-transparent p-0 open:flex [inset:auto]', 'm-0 flex-col gap-1 border-0 bg-transparent p-2 open:flex [inset:auto]',
'mb-2 items-end [position-area:top_span-left]' => $position === 'top-end', // A landscape phone has no room for six 56px items: the list scrolls, the FAB below it
'mb-2 items-start [position-area:top_span-right]' => $position === 'top-start', // does not. The 8px padding is the gap M3 leaves above the FAB and the room the items'
'mt-2 items-end [position-area:bottom_span-left]' => $position === 'bottom-end', // elevation needs inside the scroll box; the negative margins put it back where it was.
'mt-2 items-start [position-area:bottom_span-right]' => $position === 'bottom-start', 'max-h-[calc(100dvh-8rem)] overflow-y-auto',
'transition-[display,overlay] transition-discrete duration-(--md-sys-motion-spatial-fast-duration)',
'-mx-2 -mt-2 items-end [position-area:top_span-left]' => $position === 'top-end',
'-mx-2 -mt-2 items-start [position-area:top_span-right]' => $position === 'top-start',
'-mx-2 -mb-2 items-end [position-area:bottom_span-left]' => $position === 'bottom-end',
'-mx-2 -mb-2 items-start [position-area:bottom_span-right]' => $position === 'bottom-start',
]) ])
> >
{{ $slot }} {{ $slot }}
+9 -1
View File
@@ -70,5 +70,13 @@ it('opens a FAB menu of end-aligned actions above it', function () {
->toContain('role="menuitem"') ->toContain('role="menuitem"')
->toContain('wire:click="upload"') ->toContain('wire:click="upload"')
->toContain('h-14') ->toContain('h-14')
->toContain('Upload files'); ->toContain('Upload files')
->toContain('max-h-[calc(100dvh-8rem)] overflow-y-auto')
->toContain('data-fab-menu');
});
it('names the FAB menu even when the caller forgets to', function () {
expect((string) $this->blade('<x-fab-menu />'))
->toContain('aria-label="Toggle menu"')
->and(substr_count((string) $this->blade('<x-fab-menu />'), 'aria-label="Toggle menu"'))->toBe(2);
}); });