Files
livewire-material/tests/Feature/Components/ButtonTest.php
T
Andreas ReinholdandClaude Opus 5 7bc4f740e7 Take the icon off the snackbar and lift the FAB clear of it
M3 says to avoid an icon in a snackbar and gives the supporting text no fourth
colour, so the state glyph and the 80% description are gone: a type now only
picks the announcement role. The 40px action and close buttons take the shared
touch-target, Escape dismisses a snackbar that holds the focus, and the host
publishes --material-snackbar-height on <html> so a `fab` button sits above the
snackbar instead of under it. Plan step 18, actions.md ACT-17, ACT-18, ACT-20,
ACT-34, ACT-35.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:09:47 +02:00

187 lines
9.7 KiB
PHP

<?php
/**
* The classes on the rendered button element.
*/
function buttonClasses(string $blade): string
{
preg_match('/<(?:button|a)\b[^>]*\bclass="([^"]*)"/', (string) test()->blade($blade), $matches);
return $matches[1] ?? '';
}
it('is a text button in the primary colour by default', function () {
expect(buttonClasses('<x-button label="Cancel" />'))
->toContain('text-primary')
->not->toContain('bg-primary');
});
it('draws each M3 variant', function (string $variant, string $classes) {
expect(buttonClasses("<x-button label=\"Go\" variant=\"{$variant}\" />"))->toContain($classes);
})->with([
'filled' => ['filled', 'bg-primary text-on-primary'],
'tonal' => ['tonal', 'bg-secondary-container text-on-secondary-container'],
'outlined' => ['outlined', 'border-outline-variant text-on-surface-variant'],
'elevated' => ['elevated', 'bg-surface-container-low text-primary'],
'text' => ['text', 'text-primary'],
]);
it('draws a variant in another colour role', function () {
expect(buttonClasses('<x-button label="Go" variant="filled" color="tertiary" />'))->toContain('bg-tertiary text-on-tertiary')
->and(buttonClasses('<x-button label="Go" variant="tonal" color="error" />'))->toContain('bg-error-container text-on-error-container')
->and(buttonClasses('<x-button label="Go" variant="outlined" tone="success" />'))->toContain('text-success');
});
it('keeps the maryUI and ReStride shorthands', function () {
expect(buttonClasses('<x-button label="Save" primary />'))->toContain('bg-primary text-on-primary')
->and(buttonClasses('<x-button label="Delete" danger />'))->toContain('bg-error text-on-error')
->and(buttonClasses('<x-button label="Take down" caution />'))->toContain('bg-warning text-on-warning');
});
it('falls back to the defaults for values it does not know', function () {
expect(buttonClasses('<x-button label="Go" variant="glass" color="purple" size="huge" />'))
->toContain('text-primary')
->toContain('h-10 gap-2 px-4 type-label-lg');
});
it('sizes a label button on Expressive\'s scale', function (string $size, string $classes, string $icon) {
$html = (string) $this->blade("<x-button label=\"Upload\" icon=\"upload\" size=\"{$size}\" />");
expect($html)->toContain($classes)->toContain($icon);
})->with([
'xs' => ['xs', 'h-8 gap-2 px-4 type-label-lg', 'size-5'],
'sm' => ['sm', 'h-10 gap-2 px-4 type-label-lg', 'size-5'],
'md' => ['md', 'h-14 gap-2 px-6 type-title-md', 'size-6'],
'lg' => ['lg', 'h-24 gap-3 px-12 type-headline-sm', 'size-8'],
'xl' => ['xl', 'h-34 gap-4 px-16 type-headline-lg', 'size-10'],
]);
it('rounds by default, squares on request, and squares off further while pressed', function () {
expect(buttonClasses('<x-button label="Go" size="md" />'))->toContain('rounded-corner-full')->toContain('active:rounded-corner-md')
->and(buttonClasses('<x-button label="Go" size="md" shape="square" />'))->toContain('rounded-corner-lg')
->and(buttonClasses('<x-button label="Go" size="xl" shape="square" />'))->toContain('rounded-corner-xl')->toContain('active:rounded-corner-lg');
});
it('reaches a 48px touch target below the medium size', function () {
expect(buttonClasses('<x-button label="Go" size="sm" />'))->toContain('touch-target')
->and(buttonClasses('<x-button label="Go" size="md" />'))->not->toContain('touch-target');
});
it('fills a default icon button\'s glyph, and draws a 20px one from the 20px cut', function () {
$geometry = function (string $file): string {
$svg = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/'.$file.'.svg'));
return substr($svg, (int) strpos($svg, '><') + 1);
};
// A default icon button is filled, a toggle still reads outlined unselected, filled selected.
expect((string) $this->blade('<x-button icon="favorite" aria-label="Keep" />'))->toContain($geometry('filled/favorite'))
->and((string) $this->blade('<x-button icon="favorite" aria-label="Keep" :selected="false" />'))->toContain($geometry('outlined/favorite'))
->and((string) $this->blade('<x-button icon="favorite" aria-label="Keep" :selected="true" />'))->toContain($geometry('filled/favorite'));
// 20px glyphs come from the 20px cut; 24px ones stay on the default.
expect((string) $this->blade('<x-button label="Keep" icon="favorite" />'))->toContain($geometry('outlined-20/favorite'))
->and((string) $this->blade('<x-button label="Keep" icon="favorite" size="md" />'))->toContain($geometry('outlined/favorite'));
});
it('is an icon button named by its tooltip when it has no label', function () {
$html = (string) $this->blade('<x-button icon="close" tooltip="Close menu" />');
expect($html)
->toContain('aria-label="Close menu"')
->toContain('size-10')
->toContain('text-on-surface-variant')
->toContain('popover="manual"')
->toContain('aria-hidden="true"');
});
it('sizes an icon button by width', function () {
expect(buttonClasses('<x-button icon="share" label="" width="wide" aria-label="Share" />'))->toContain('h-10 w-13')
->and(buttonClasses('<x-button icon="share" size="xl" width="narrow" aria-label="Share" />'))->toContain('h-34 w-26');
});
it('toggles with aria-pressed and M3\'s selected colours and shape', function () {
expect((string) $this->blade('<x-button label="Bold" variant="filled" :selected="true" />'))->toContain('aria-pressed="true"')
->and(buttonClasses('<x-button label="Bold" variant="filled" :selected="true" />'))->toContain('bg-primary')->toContain('rounded-corner-md')
->and(buttonClasses('<x-button label="Bold" variant="filled" :selected="false" />'))->toContain('bg-surface-container text-on-surface-variant')->toContain('rounded-corner-full')
->and(buttonClasses('<x-button label="Bold" variant="tonal" :selected="true" />'))->toContain('bg-secondary text-on-secondary')
->and(buttonClasses('<x-button label="Bold" variant="outlined" :selected="true" />'))->toContain('bg-inverse-surface text-inverse-on-surface');
});
it('squares a selected round icon button and rounds a selected square one', function () {
expect(buttonClasses('<x-button icon="favorite" aria-label="Like" variant="tonal" :selected="true" />'))->toContain('rounded-corner-md')
->and(buttonClasses('<x-button icon="favorite" aria-label="Like" variant="tonal" shape="square" :selected="true" />'))->toContain('rounded-corner-full')
->and((string) $this->blade('<x-button icon="favorite" aria-label="Like" :selected="true" />'))->toContain('text-primary');
});
it('navigates inside the app, and leaves it for an external link', function () {
$this->blade('<x-button label="Shares" link="/admin/shares" />')
->assertSee('<a ', false)
->assertSee('href="/admin/shares"', false)
->assertSee('wire:navigate', false)
->assertDontSee('type="button"', false);
$this->blade('<x-button label="M3" link="https://m3.material.io" external />')
->assertSee('target="_blank"', false)
->assertSee('rel="noopener"', false)
->assertDontSee('wire:navigate', false);
});
it('disables a button, and a link as far as a link can be', function () {
$this->blade('<x-button label="Save" variant="filled" disabled />')
->assertSee('disabled="disabled"', false)
->assertSee('disabled:bg-on-surface/10', false);
$this->blade('<x-button label="Shares" link="/admin/shares" disabled />')
->assertSee('aria-disabled="true"', false)
->assertSee('tabindex="-1"', false);
});
it('shows the loading indicator while its own action runs, in the button\'s own ink', function () {
$this->blade('<x-button label="Save" wire:click="save" spinner />')
->assertSee('wire:loading.attr="disabled"', false)
->assertSee('wire:target="save"', false)
->assertSee('size-5 text-current', false)
->assertDontSee('text-primary"', false);
$this->blade('<x-button label="Save" wire:click="save" spinner="upload" />')
->assertSee('wire:target="upload"', false);
});
it('hides a responsive label below expanded', function () {
$this->blade('<x-button label="New share" icon="add" responsive />')
->assertSee('<span class="max-expanded:hidden">New share</span>', false);
});
it('anchors its tooltip to itself', function () {
$html = (string) $this->blade('<x-button label="Save" tooltip-bottom="Saves the draft" />');
preg_match('/anchor-name: (--material-button-[a-z0-9]+)/', $html, $anchor);
expect($anchor)->not->toBeEmpty()
->and($html)->toContain("position-anchor: {$anchor[1]}")
->toContain('[position-area:bottom]')
->not->toContain('aria-label="Saves the draft"');
});
it('is a FAB on a compact window and a filled button from medium, in one element', function () {
$html = (string) $this->blade('<x-button label="New share" icon="add" fab />');
expect(substr_count($html, '<button'))->toBe(1)
->and($html)->toContain('max-medium:fixed')->toContain('max-medium:bg-primary-container')->toContain('bg-primary text-on-primary')
// M3 puts a snackbar above a FAB, never over one: the host publishes its height.
->toContain('max-medium:bottom-[calc(var(--material-bottom-bar,0px)+var(--material-snackbar-height,0px)+1rem)]');
});
it('submits a form when asked', function () {
$this->blade('<x-button label="Save" type="submit" />')
->assertSee('type="submit"', false);
});
it('keeps aria-pressed off a selected link, which is not a toggle', function () {
expect((string) $this->blade('<x-button label="Plans" link="/plans" :selected="true" />'))
->not->toContain('aria-pressed')
->and((string) $this->blade('<x-button label="Bold" :selected="true" />'))
->toContain('aria-pressed="true"');
});