Draw the FAB menu without Tailwind

<x-fab-menu> renders data-md-fab-menu with its trigger
data-md-fab-menu-trigger, data-md-color and data-md-position; a
close icon swaps in on aria-expanded instead of Tailwind's group
variant. fab-menu.css draws FabMenuBaselineTokens' 56px trigger,
its round-to-full morph on the default springs (ACT-19), the 8px
list padding and its calc(100dvh-128px) scroll cap behind the fixed
close button (ACT-32), and the [data-md-fab-menu-popover]:not(:popover-open)
rule that drives fab-menu-item's exit (ACT-33) — actions.css's last
two rules, moved here, empty it, so it and its tailwind.css import
are deleted; fab.js's stale reference to it is corrected to fab.css
(plan step 36, actions — the group is now fully off Tailwind).

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 / reini
2026-09-14 16:20:45 +02:00
co-authored by Claude Opus 5
parent 330b835043
commit c065d940af
8 changed files with 260 additions and 101 deletions
+25 -43
View File
@@ -1,6 +1,6 @@
{{-- An M3 Expressive FAB menu: a FAB that opens into a short list of related actions.
<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">
<div style="position: fixed; inset-inline-end: 16px; bottom: 16px">
<x-fab-menu label="New">
<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" />
@@ -10,18 +10,24 @@
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
list is a `popover="auto"` menu with the menu keyboard of `<x-menu>`. `label` names the FAB
and the menu for screen readers, and defaults to "Toggle menu" — the icons inside the button
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.
and the menu for screen readers, and defaults to "Toggle menu" (ACT-21) — the icons inside
the button 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.
It does not place itself. M3's margin from the edge of the window is 16px, 24px from `large`
(1200px) up — grow it in the caller's own stylesheet:
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
(`state-transition-default`, resources/css/components/actions.css). --}}
@media (width >= 1200px) { .my-fab-menu { inset-inline-end: 24px; bottom: 24px } }
A window too short for the list scrolls it, behind the close button, which stays fixed
(ACT-32); the items rise into place as it opens and sink back as it closes (ACT-33).
FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px close button, full
corner when open, 20px close icon, 8px to the first item, 56px items 4px apart.
Drawn by resources/css/components/fab-menu.css from `data-md-fab-menu`, `data-md-color` and
`data-md-position`. --}}
@props([
'icon' => 'add',
@@ -36,33 +42,20 @@
// fallback wording for the close button.
$label = filled($label) ? $label : __('Toggle menu');
$color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary';
$position = in_array($position, ['top-start', 'top-end', 'bottom-start', 'bottom-end'], true) ? $position : 'top-end';
$key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
$anchor = "--material-fab-menu-{$key}";
$colours = [
'primary' => 'bg-primary-container text-on-primary-container aria-expanded:bg-primary aria-expanded:text-on-primary',
'secondary' => 'bg-secondary-container text-on-secondary-container aria-expanded:bg-secondary aria-expanded:text-on-secondary',
'tertiary' => 'bg-tertiary-container text-on-tertiary-container aria-expanded:bg-tertiary aria-expanded:text-on-tertiary',
][$color];
@endphp
<div x-data="materialMenu" {{ $attributes->class('relative inline-flex') }}>
<span x-ref="trigger" class="inline-flex" style="anchor-name: {{ $anchor }}"
<div x-data="materialMenu" data-md-fab-menu {{ $attributes }}>
<span x-ref="trigger" data-md-fab-menu-anchor style="anchor-name: {{ $anchor }}"
x-on:click="toggle('first')"
x-on:keydown.down.prevent="open('first')"
x-on:keydown.up.prevent="open('last')"
>
<button
type="button"
aria-label="{{ $label }}"
@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',
'state-transition-default aria-expanded:rounded-corner-full',
$colours,
])
>
<x-livewire-material::icon :name="$icon" filled class="size-6 group-aria-expanded/fab:hidden" />
<x-livewire-material::icon name="close" filled optical="20" class="hidden size-5 group-aria-expanded/fab:block" />
<button type="button" aria-label="{{ $label }}" data-md-fab-menu-trigger data-md-color="{{ $color }}">
<x-livewire-material::icon :name="$icon" filled data-md-fab-menu-icon />
<x-livewire-material::icon name="close" filled size="20" data-md-fab-menu-close-icon />
</button>
</span>
@@ -72,24 +65,13 @@
id="material-fab-menu-{{ $key }}"
popover="auto"
role="menu"
data-fab-menu
data-md-fab-menu-popover
data-md-position="{{ $position }}"
aria-label="{{ $label }}"
tabindex="-1"
style="position-anchor: {{ $anchor }}"
x-on:keydown="navigate($event)"
x-on:click="activate($event)"
@class([
'm-0 flex-col gap-1 border-0 bg-transparent p-2 open:flex [inset:auto]',
// A landscape phone has no room for six 56px items: the list scrolls, the FAB below it
// does not. The 8px padding is the gap M3 leaves above the FAB and the room the items'
// elevation needs inside the scroll box; the negative margins put it back where it was.
'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 }}
</div>