Hide a disabled fab button on a compact window, as M3 removes a FAB

`<x-button fab>` is an extended FAB below `medium` and a filled button
from there, but a disabled one was still drawn as the FAB below
`medium`: the compact FAB's rule set no state of its own, so the button
stood greyed out and fixed over the content. M3 never disables a FAB:
"if its action is unavailable, remove the FAB entirely".

A `fab` given `disabled` (a `<button disabled>`, or a link's
`aria-disabled="true"`) now renders `data-md-unavailable`, and
button.css draws it `display: none` below `medium`, which also takes it
out of the accessibility tree and the Tab order; from `medium` it is
the disabled filled button it was. The mark comes from the prop, not
from `:disabled`, because a `spinner` puts `disabled` on its button
while the action runs, and that FAB is busy rather than unavailable:
hidden, it would vanish instead of showing its loading indicator. The
docblocks and the skill's button table say so.

A browser test renders an enabled fab with a slow spinner action, a
disabled one and a disabled link on a 393px window: the disabled two
are not rendered, the busy one stays on screen with its indicator, and
at 600px both disabled ones are the disabled button again; it fails
without the change in Chrome, Firefox and Safari (and in Chrome with
`:disabled` alone as the condition, on the busy FAB). A feature test
reads the mark and the rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 07:00:40 +02:00
co-authored by Claude Opus 5
parent cf63f11bfb
commit 6d4487feb3
6 changed files with 104 additions and 3 deletions
+7
View File
@@ -74,6 +74,13 @@
`<x-checkbox>` did the same with its box. Both roots now keep their automatic minimum — the `<x-checkbox>` did the same with its box. Both roots now keep their automatic minimum — the
control, and beside a label its longest word — so the text wraps instead. A `flex: none` an control, and beside a label its longest word — so the text wraps instead. A `flex: none` an
application put on the switch to hold it can go. application put on the switch to hold it can go.
- **A disabled `<x-button fab>` is not drawn on a compact window.** Below `medium` it was still
the extended FAB, greyed out and fixed over the content; M3 never shows a disabled FAB and
removes one whose action is unavailable, so a `fab` given `disabled` (on a link too) now renders
`data-md-unavailable` and is `display: none` there, out of the accessibility tree with it. From
`medium` it is the disabled filled button, as before. A `spinner` that disables the button while
its action runs leaves the FAB on screen. An application's own `hide-below` or condition around
a disabled fab button can go.
## From 2.0.0 to 2.1.0 ## From 2.0.0 to 2.1.0
@@ -263,7 +263,7 @@ Label button, icon button, toggle and responsive FAB in one component.
| `link`, `external`, `no-wire-navigate` | | renders `<a>`, with `wire:navigate` unless external | | `link`, `external`, `no-wire-navigate` | | renders `<a>`, with `wire:navigate` unless external |
| `spinner` | | `true` shows the loading indicator while its `wire:click` runs; a string names the action | | `spinner` | | `true` shows the loading indicator while its `wire:click` runs; a string names the action |
| `tooltip`, `tooltip-left`, `tooltip-right`, `tooltip-bottom` | | plain tooltip; also the icon button's accessible name | | `tooltip`, `tooltip-left`, `tooltip-right`, `tooltip-bottom` | | plain tooltip; also the icon button's accessible name |
| `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `expanded`; `fab` is an extended FAB on a compact window (below `medium`), a filled button from there | | `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `expanded`; `fab` is an extended FAB on a compact window (below `medium`), a filled button from there; a `disabled` fab is not drawn below `medium` — M3 removes a FAB whose action is unavailable — and is the disabled button from there (a `spinner` disabling it while its action runs leaves it on screen) |
```blade ```blade
<x-button label="Create link" icon="link" variant="filled" size="md" wire:click="create" spinner /> <x-button label="Create link" icon="link" variant="filled" size="md" wire:click="create" spinner />
+10 -1
View File
@@ -44,7 +44,9 @@
* primary-container at elevation 3 (ExtendedFabPrimaryTokens) lifted clear of a bottom bar and * primary-container at elevation 3 (ExtendedFabPrimaryTokens) lifted clear of a bottom bar and
* of a snackbar on screen (`--material-bottom-bar`, `--material-snackbar-height`), because M3 puts a * of a snackbar on screen (`--material-bottom-bar`, `--material-snackbar-height`), because M3 puts a
* snackbar above a FAB and never over one. From `medium` it is the button it was written as. * snackbar above a FAB and never over one. From `medium` it is the button it was written as.
* `data-md-responsive` on the label hides it below `expanded` (840px). * `data-md-unavailable`, a `fab` given `disabled`, is not drawn below `medium`: M3 never shows a
* disabled FAB, but removes one whose action is unavailable. From `medium` it is the disabled
* button. `data-md-responsive` on the label hides it below `expanded` (840px).
*/ */
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@@ -397,5 +399,12 @@
inline-size: 24px; inline-size: 24px;
block-size: 24px; block-size: 24px;
} }
/* M3 never disables a FAB: "if its action is unavailable, remove the FAB entirely"
(docs/reference/m3/components-actions-communication-containment.md § FAB). Not drawn,
it is out of the accessibility tree and the Tab order too. */
[data-md-button][data-md-compact-fab][data-md-unavailable] {
display: none;
}
} }
} }
+6 -1
View File
@@ -36,7 +36,9 @@
pinned in the thumb zone on a compact window (below `medium`, 600px), a filled button from pinned in the thumb zone on a compact window (below `medium`, 600px), a filled button from
there one element either way. It lifts clear of a bottom bar and of a snackbar on screen there one element either way. It lifts clear of a bottom bar and of a snackbar on screen
(`--material-bottom-bar`, `--material-snackbar-height`), because M3 puts a snackbar above a (`--material-bottom-bar`, `--material-snackbar-height`), because M3 puts a snackbar above a
FAB and never over one. FAB and never over one. A `fab` that is `disabled` is not drawn below `medium`, since M3
removes a FAB whose action is unavailable rather than showing it disabled, and is the disabled
filled button from there; a spinner's own disabling while the action runs leaves it on screen.
The view renders the props as `data-md-*` attributes and resources/css/components/button.css The view renders the props as `data-md-*` attributes and resources/css/components/button.css
draws them, in the package's components layer: a caller's class or unlayered CSS outranks draws them, in the package's components layer: a caller's class or unlayered CSS outranks
@@ -124,6 +126,9 @@
'data-md-width' => $iconOnly ? $width : null, 'data-md-width' => $iconOnly ? $width : null,
'data-md-selected' => $selected === null ? null : ($selected ? 'true' : 'false'), 'data-md-selected' => $selected === null ? null : ($selected ? 'true' : 'false'),
'data-md-compact-fab' => $fab ? true : null, 'data-md-compact-fab' => $fab ? true : null,
// Read from the prop, not from `:disabled`: a spinner disables its button while the action
// runs, and that FAB is busy, not unavailable.
'data-md-unavailable' => $fab && $disabled ? true : null,
'href' => $isLink ? $link : null, 'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null, 'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null, 'rel' => $isLink && $external ? 'noopener' : null,
+71
View File
@@ -509,6 +509,77 @@ it('collapses an extended FAB to a FAB while the page scrolls down, and extends
->assertScript("{$fab}.getBoundingClientRect().width > 56"); ->assertScript("{$fab}.getBoundingClientRect().width > 56");
}); });
class DisabledFabProbe extends Component
{
public int $created = 0;
public function create(): void
{
usleep(800_000);
$this->created++;
}
public function render(): string
{
return <<<'BLADE'
<div>
<p>created: <span id="created">{{ $created }}</span></p>
<x-button id="enabled-fab" fab icon="add" label="New plan" wire:click="create" spinner />
<x-button id="disabled-fab" fab icon="add" label="New route" disabled />
<x-button id="disabled-link-fab" fab icon="add" label="New course" link="/courses/new" disabled />
</div>
BLADE;
}
}
it('hides a disabled fab button below 600px, as M3 removes a FAB whose action is unavailable, and keeps the disabled button from 600px', function () {
Livewire::component('disabled-fab-probe', DisabledFabProbe::class);
Route::middleware('web')->get('/disabled-fab-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<livewire:disabled-fab-probe />
@livewireScripts
</body>
</html>
BLADE));
// [rendered, fixed as a FAB]: `display: none` draws nothing and takes the button out of the
// accessibility tree and the Tab order with it.
$state = fn (string $id): string => "(() => {
const button = document.getElementById('{$id}');
return [getComputedStyle(button).display !== 'none' && button.checkVisibility(), getComputedStyle(button).position === 'fixed'];
})()";
$page = visit('/disabled-fab-probe')->resize(393, 800)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Livewire !== 'undefined'");
expect($page->script($state('enabled-fab')))->toBe([true, true])
->and($page->script($state('disabled-fab')))->toBe([false, true])
->and($page->script($state('disabled-link-fab')))->toBe([false, true]);
// A spinner disables its button while the action runs; that FAB is busy, not unavailable, and
// stays on screen with its loading indicator.
$page->click('#enabled-fab')
->assertScript("(() => { const button = document.getElementById('enabled-fab'); return button.hasAttribute('data-loading') && button.disabled && button.checkVisibility() && button.querySelector('[data-md-button-spinner]').checkVisibility(); })()")
->assertSeeIn('#created', '1');
$page->resize(600, 800)->assertScript("getComputedStyle(document.getElementById('enabled-fab')).position !== 'fixed'");
expect($page->script($state('disabled-fab')))->toBe([true, false])
->and($page->script($state('disabled-link-fab')))->toBe([true, false])
// The disabled filled button it is from medium: on-surface at 10% behind 38% ink.
->and($page->script("getComputedStyle(document.getElementById('disabled-fab')).backgroundColor"))->not->toBe($page->script("getComputedStyle(document.getElementById('enabled-fab')).backgroundColor"));
});
it('takes a press on a small connected segment at its 48px edge, past what it draws', function () { it('takes a press on a small connected segment at its 48px edge, past what it draws', function () {
// M3: "XS and S connected button groups have a 48dp target area and a 48dp minimum width". // M3: "XS and S connected button groups have a 48dp target area and a 48dp minimum width".
showcase()->assertScript(<<<'JS' showcase()->assertScript(<<<'JS'
+9
View File
@@ -265,6 +265,15 @@ it('is a FAB on a compact window and a filled button from medium, in one element
->and($fab['bottom'])->toBe('calc(var(--material-bottom-bar, 0px) + var(--material-snackbar-height, 0px) + var(--md-sys-measurement-space200))'); ->and($fab['bottom'])->toBe('calc(var(--material-bottom-bar, 0px) + var(--material-snackbar-height, 0px) + var(--md-sys-measurement-space200))');
}); });
it('marks a disabled fab button unavailable, which is not drawn below medium, and nothing else', function () {
expect(buttonAttributes('<x-button label="New share" icon="add" fab disabled />'))->toMatchArray(['data-md-unavailable' => 'data-md-unavailable', 'disabled' => 'disabled'])
->and(buttonAttributes('<x-button label="New share" icon="add" link="/shares/new" fab disabled />'))->toMatchArray(['data-md-unavailable' => 'data-md-unavailable', 'aria-disabled' => 'true'])
// A spinner disables the button only while its action runs: busy, not unavailable.
->and(buttonAttributes('<x-button label="New share" icon="add" fab wire:click="create" spinner />'))->not->toHaveKey('data-md-unavailable')
->and(buttonAttributes('<x-button label="Delete" disabled />'))->not->toHaveKey('data-md-unavailable')
->and(buttonCss()->declarations('[data-md-button][data-md-compact-fab][data-md-unavailable]', ['@media (width < 600px)']))->toBe(['display' => 'none']);
});
it('submits a form when asked', function () { it('submits a form when asked', function () {
$this->blade('<x-button label="Save" type="submit" />') $this->blade('<x-button label="Save" type="submit" />')
->assertSee('type="submit"', false); ->assertSee('type="submit"', false);