Draw the FAB without Tailwind

Plan step 36. <x-fab> renders data-md-fab with data-md-size,
data-md-color, data-md-variant, data-md-extended and, collapsing on
scroll, data-md-collapse-on-scroll with a bound data-md-collapsed; it
passes its glyph's size to <x-icon>. fab.css draws FabBaseline/Medium/
LargeTokens and ExtendedFab*Tokens per size, the container and filled
colours, elevation 3 and 4 on hover, the state layer and focus ring,
and the collapse morph that leaves actions.css.

data-fab becomes data-md-fab, also in navigation.css (the rail flattens
a nested FAB) and toolbar.css (a docked toolbar's FAB), with their tests.
The actions browser test gains the owed collapse-on-scroll test.

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 15:06:45 +02:00
co-authored by Claude Opus 5
parent 2a4dcead3d
commit 1cf4f16c95
11 changed files with 357 additions and 146 deletions
+30 -41
View File
@@ -6,8 +6,8 @@
drawn in its container, or in the colour itself with `variant="filled"`.
It does not place itself: put it where the layout wants it, e.g.
`<div class="fixed end-4 bottom-4"><x-fab icon="add" tooltip="New share" /></div>`. For a
create action that is a FAB on a phone and a header button above, use `<x-button fab>`.
`<div style="position: fixed; inset-inline-end: 16px; bottom: 16px"><x-fab icon="add" tooltip="New share" /></div>`.
For a create action that is a FAB on a phone and a header button above, use `<x-button fab>`.
The glyph is filled, not outlined: M3 says so twice, in the FAB's anatomy and again in its
guidelines ("icon should be filled (not outlined) and unambiguous").
@@ -17,19 +17,19 @@
than greying it out. A form-submit FAB that must not be pressed twice takes the caller's own
`wire:loading.attr="disabled"`.
`data-fab` marks the root, so a place a FAB sits in can draw it its own way: a navigation
`data-md-fab` marks the root, so a place a FAB sits in can draw it its own way: a navigation
rail flattens a nested FAB to elevation 0.
`collapse-on-scroll` is M3's extended FAB that "can collapse to a FAB on scroll-down and
re-expand to extended on scroll-up": while the window scrolls down it shrinks to the FAB of
its size, and it extends again when the page scrolls back up or reaches the top. The label
closes and fades while the width follows it on the spatial spring (resources/css/components/
actions.css); under reduced motion the two swap outright. The label stays in the page,
clipped rather than removed, so the collapsed FAB keeps its accessible name. It needs an
`icon` — a FAB with no glyph is no FAB — and does nothing on a plain FAB.
closes and fades while the width follows it on the spatial spring; under reduced motion the
two swap outright. The label stays in the page, clipped rather than removed, so the collapsed
FAB keeps its accessible name. It needs an `icon` — a FAB with no glyph is no FAB — and does
nothing on a plain FAB.
Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens
(androidx Compose Material 3, Apache-2.0). --}}
(androidx Compose Material 3, Apache-2.0), drawn by resources/css/components/fab.css. --}}
@props([
'icon' => null,
@@ -47,53 +47,42 @@
@php
$size = in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'sm';
$color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary';
$variant = $variant === 'filled' ? 'filled' : 'container';
$extended = filled($label) || $slot->isNotEmpty();
$collapsing = $collapseOnScroll && $extended && filled($icon);
$isLink = filled($link);
$colours = $variant === 'filled'
? ['primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary'][$color]
: ['primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container'][$color];
$dimensions = $extended
? ['sm' => 'h-14 min-w-20 gap-2 px-4 rounded-corner-lg type-title-md', 'md' => 'h-20 min-w-20 gap-4 px-[26px] rounded-corner-lg-increased type-title-lg', 'lg' => 'h-24 min-w-24 gap-5 px-7 rounded-corner-xl type-headline-sm'][$size]
: ['sm' => 'size-14 rounded-corner-lg', 'md' => 'size-20 rounded-corner-lg-increased', 'lg' => 'size-24 rounded-corner-xl'][$size];
$iconSize = ['sm' => 'size-6', 'md' => 'size-7', 'lg' => 'size-8'][$size];
$iconSize = ['sm' => 24, 'md' => 28, 'lg' => 32][$size];
$anchor = $tooltip !== null ? '--material-fab-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null;
$tag = $isLink ? 'a' : 'button';
$attributes = $attributes
->class([
'state-layer focus-ring inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap',
'shadow-elevation-3 hover:shadow-elevation-4 transition-[box-shadow,background-color,color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast',
$dimensions,
$colours,
])
->merge(array_filter([
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null,
'type' => $isLink ? null : $type,
// The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp.
'data-fab' => true,
'x-data' => $collapsing ? 'materialFab' : null,
'x-bind:data-collapsed' => $collapsing ? "collapsed ? '' : null" : null,
'data-fab-collapsible' => $collapsing ? true : null,
'data-fab-size' => $collapsing ? $size : null,
'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null,
'style' => $anchor ? "anchor-name: {$anchor}" : null,
], fn ($value): bool => $value !== null));
$attributes = $attributes->merge(array_filter([
// The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp.
'data-md-fab' => true,
'data-md-size' => $size,
'data-md-color' => $color,
'data-md-variant' => $variant,
'data-md-extended' => $extended ? true : null,
'data-md-collapse-on-scroll' => $collapsing ? true : null,
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null,
'type' => $isLink ? null : $type,
'x-data' => $collapsing ? 'materialFab' : null,
'x-bind:data-md-collapsed' => $collapsing ? "collapsed ? '' : null" : null,
'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null,
'style' => $anchor ? "anchor-name: {$anchor}" : null,
], fn ($value): bool => $value !== null));
@endphp
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-livewire-material::icon :name="$icon" filled :class="$iconSize" />
<x-livewire-material::icon :name="$icon" filled :size="$iconSize" />
@endif
@if ($collapsing)
<span data-fab-label><span>{{ $label ?? $slot }}</span></span>
<span data-md-fab-label><span>{{ $label ?? $slot }}</span></span>
@elseif ($extended)
<span>{{ $label ?? $slot }}</span>
@endif