diff --git a/UPGRADE.md b/UPGRADE.md index d7211793..d5219084 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -74,6 +74,13 @@ `` 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 application put on the switch to hold it can go. +- **A disabled `` 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 diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index e9f5926f..e858674b 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -263,7 +263,7 @@ Label button, icon button, toggle and responsive FAB in one component. | `link`, `external`, `no-wire-navigate` | | renders ``, with `wire:navigate` unless external | | `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 | -| `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 diff --git a/resources/css/components/button.css b/resources/css/components/button.css index ffb49b3b..7b4981b3 100644 --- a/resources/css/components/button.css +++ b/resources/css/components/button.css @@ -44,7 +44,9 @@ * 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 * 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; @@ -397,5 +399,12 @@ inline-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; + } } } diff --git a/resources/views/components/button.blade.php b/resources/views/components/button.blade.php index b4890140..9ca0b74d 100644 --- a/resources/views/components/button.blade.php +++ b/resources/views/components/button.blade.php @@ -36,7 +36,9 @@ 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 (`--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 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-selected' => $selected === null ? null : ($selected ? 'true' : 'false'), '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, 'target' => $isLink && $external ? '_blank' : null, 'rel' => $isLink && $external ? 'noopener' : null, diff --git a/tests/Browser/ActionsTest.php b/tests/Browser/ActionsTest.php index 7abe5f84..4c859757 100644 --- a/tests/Browser/ActionsTest.php +++ b/tests/Browser/ActionsTest.php @@ -509,6 +509,77 @@ it('collapses an extended FAB to a FAB while the page scrolls down, and extends ->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' +
+

created: {{ $created }}

+ + + +
+ 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' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + 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 () { // M3: "XS and S connected button groups have a 48dp target area and a 48dp minimum width". showcase()->assertScript(<<<'JS' diff --git a/tests/Feature/Components/ButtonTest.php b/tests/Feature/Components/ButtonTest.php index 503d473a..4f97fb30 100644 --- a/tests/Feature/Components/ButtonTest.php +++ b/tests/Feature/Components/ButtonTest.php @@ -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))'); }); +it('marks a disabled fab button unavailable, which is not drawn below medium, and nothing else', function () { + expect(buttonAttributes(''))->toMatchArray(['data-md-unavailable' => 'data-md-unavailable', 'disabled' => 'disabled']) + ->and(buttonAttributes(''))->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(''))->not->toHaveKey('data-md-unavailable') + ->and(buttonAttributes(''))->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 () { $this->blade('') ->assertSee('type="submit"', false);