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:
co-authored by
Claude Opus 5
parent
deb7ff8ab5
commit
eac7f6a1de
@@ -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>`
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user