Take disabled off the FAB, and fix its extended metrics

M3 is explicit twice over — never disable a FAB, remove it instead — and the
prop painted the full disabled treatment with nothing warning against it. It is
gone; hiding the FAB is the documented answer. The extended FAB's icon-to-label
gap goes to M3's 16dp at md and 20dp at lg, the small one takes the baseline's
80dp minimum width, and the root gains a `data-fab` hook for a place that draws
a nested FAB its own way. Plan step 18, actions.md ACT-08, ACT-16, ACT-31.

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:05:04 +02:00
co-authored by Claude Opus 5
parent 9828c9258a
commit deb7ff8ab5
3 changed files with 33 additions and 6 deletions
@@ -291,7 +291,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
### `<x-fab>` ### `<x-fab>`
`<x-fab icon="add" tooltip="New share" />``size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. The glyph is filled, as M3 requires of a FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">` — M3's 16dp margin, 24dp from `large`). `link`, `external`, `disabled`, `type`. `<x-fab icon="add" tooltip="New share" />``size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. The glyph is filled, as M3 requires of a FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">` — M3's 16dp margin, 24dp from `large`). `link`, `external`, `type`. There is no `disabled`: M3 says to remove a FAB whose action is unavailable, so hide it instead. `data-fab` on the root lets a place restyle a nested FAB (a rail flattens it to elevation 0).
### `<x-fab-menu>`, `<x-fab-menu-item>` ### `<x-fab-menu>`, `<x-fab-menu-item>`
+11 -4
View File
@@ -12,6 +12,14 @@
The glyph is filled, not outlined: M3 says so twice, in the FAB's anatomy and again in its 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"). guidelines ("icon should be filled (not outlined) and unambiguous").
There is no `disabled`: M3 says never to disable a FAB — "if its action is unavailable,
remove the FAB entirely instead" — so hide it (`@if`), or leave it out of the render, rather
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
rail flattens a nested FAB to elevation 0.
Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens 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). --}}
@@ -24,7 +32,6 @@
'link' => null, 'link' => null,
'external' => false, 'external' => false,
'tooltip' => null, 'tooltip' => null,
'disabled' => false,
'type' => 'button', 'type' => 'button',
]) ])
@@ -39,7 +46,7 @@
: ['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]; : ['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 $dimensions = $extended
? ['sm' => 'h-14 min-w-14 gap-2 px-4 rounded-corner-lg type-title-md', 'md' => 'h-20 min-w-20 gap-3 px-[26px] rounded-corner-lg-increased type-title-lg', 'lg' => 'h-24 min-w-24 gap-4 px-7 rounded-corner-xl type-headline-sm'][$size] ? ['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]; : ['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' => 'size-6', 'md' => 'size-7', 'lg' => 'size-8'][$size];
@@ -52,7 +59,6 @@
'shadow-elevation-3 hover:shadow-elevation-4 transition-[box-shadow,background-color,color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast', 'shadow-elevation-3 hover:shadow-elevation-4 transition-[box-shadow,background-color,color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast',
$dimensions, $dimensions,
$colours, $colours,
'disabled:cursor-not-allowed disabled:bg-on-surface/10 disabled:text-on-surface/38 disabled:shadow-none',
]) ])
->merge(array_filter([ ->merge(array_filter([
'href' => $isLink ? $link : null, 'href' => $isLink ? $link : null,
@@ -60,7 +66,8 @@
'rel' => $isLink && $external ? 'noopener' : null, 'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null, 'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null,
'type' => $isLink ? null : $type, 'type' => $isLink ? null : $type,
'disabled' => ! $isLink && $disabled ? true : null, // The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp.
'data-fab' => true,
'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null, 'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null,
'style' => $anchor ? "anchor-name: {$anchor}" : null, 'style' => $anchor ? "anchor-name: {$anchor}" : null,
], fn ($value): bool => $value !== null)); ], fn ($value): bool => $value !== null));
+21 -1
View File
@@ -27,12 +27,32 @@ it('fills the FAB\'s glyph, as M3 asks twice over', function () {
it('extends with a label', function () { it('extends with a label', function () {
expect((string) $this->blade('<x-fab icon="upload" label="Upload" size="md" color="tertiary" variant="filled" />')) expect((string) $this->blade('<x-fab icon="upload" label="Upload" size="md" color="tertiary" variant="filled" />'))
->toContain('h-20 min-w-20 gap-3 px-[26px] rounded-corner-lg-increased type-title-lg') ->toContain('h-20 min-w-20 gap-4 px-[26px] rounded-corner-lg-increased type-title-lg')
->toContain('bg-tertiary text-on-tertiary') ->toContain('bg-tertiary text-on-tertiary')
->toContain('<span>Upload</span>') ->toContain('<span>Upload</span>')
->not->toContain('aria-label'); ->not->toContain('aria-label');
}); });
it('gives the extended FAB M3\'s gaps and its 80px minimum width', function (string $size, string $classes) {
expect((string) $this->blade("<x-fab icon=\"add\" label=\"New\" size=\"{$size}\" />"))->toContain($classes);
})->with([
'sm' => ['sm', 'h-14 min-w-20 gap-2'],
'md' => ['md', 'h-20 min-w-20 gap-4'],
'lg' => ['lg', 'h-24 min-w-24 gap-5'],
]);
it('marks its root for the places that draw a nested FAB their own way', function () {
expect((string) $this->blade('<x-fab icon="add" aria-label="New" />'))->toContain('data-fab');
});
it('cannot be disabled, because M3 says to remove a FAB instead', function () {
$html = (string) $this->blade('<x-fab icon="add" aria-label="New" disabled />');
// `disabled` is no longer a prop, so it falls through as a plain attribute rather than
// painting M3's disabled treatment.
expect($html)->not->toContain('disabled:bg-on-surface/10')->not->toContain('disabled:shadow-none');
});
it('opens a FAB menu of end-aligned actions above it', function () { it('opens a FAB menu of end-aligned actions above it', function () {
$html = (string) $this->blade(<<<'BLADE' $html = (string) $this->blade(<<<'BLADE'
<x-fab-menu label="New" color="secondary"> <x-fab-menu label="New" color="secondary">