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:
co-authored by
Claude Opus 5
parent
cf63f11bfb
commit
6d4487feb3
@@ -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 />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user