Files
livewire-material/tests/Feature/Components/ButtonTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 4ad257034e Draw the button without Tailwind
Plan step 36. <x-button> renders data-md-button with its variant, colour,
size, shape, icon-button width, selected state and compact FAB as
data-md-* attributes, and passes the glyph's size to <x-icon>.
button.css draws Button*Tokens' geometry per size, the colour roles
through custom properties that the disabled treatment replaces, the
state layer, focus ring and 48px target, and the compact FAB below
600px. Its corner rules carry no specificity (:where), so a group's
stylesheet reshapes the buttons inside it with any selector; the
split button no longer passes an empty corners prop, which is gone.

data-icon-button becomes data-md-icon-button, also in groups.css,
toolbar.css and theme-toggle's own button (navigation group), and the
AppBarTest assertions follow the new hooks.

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

291 lines
20 KiB
PHP

<?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
/**
* The attributes of the rendered button or link element, by name.
*
* @return array<string, string>
*/
function buttonAttributes(string $blade): array
{
preg_match('/<(?:button|a)\b([^>]*)>/', (string) test()->blade($blade), $tag);
preg_match_all('/([\w:.-]+)(?:="([^"]*)")?/', $tag[1] ?? '', $pairs, PREG_SET_ORDER);
return collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2] ?? ''])->all();
}
function buttonCss(): ComponentStylesheet
{
return ComponentStylesheet::read('button');
}
it('is a text button in the primary colour by default, drawn by its stylesheet', function () {
expect(buttonAttributes('<x-button label="Cancel" />'))->toBe([
'data-md-button' => 'data-md-button',
'data-md-variant' => 'text',
'data-md-color' => 'primary',
'data-md-size' => 'sm',
'data-md-shape' => 'round',
'type' => 'button',
])
->and(buttonCss()->declarations('[data-md-button]'))->toMatchArray([
'--md-button-container' => 'transparent',
'--md-button-label' => 'var(--md-button-color)',
'--md-button-color' => 'var(--md-sys-color-primary)',
'background-color' => 'var(--md-button-container)',
'color' => 'var(--md-button-label)',
]);
});
it('draws each M3 variant', function (string $variant, array $declarations) {
expect(buttonAttributes("<x-button label=\"Go\" variant=\"{$variant}\" />"))->toMatchArray(['data-md-variant' => $variant])
->and(buttonCss()->declarations("[data-md-button][data-md-variant='{$variant}']"))->toMatchArray($declarations);
})->with([
'filled' => ['filled', ['--md-button-container' => 'var(--md-button-color)', '--md-button-label' => 'var(--md-button-on-color)']],
'tonal' => ['tonal', ['--md-button-container' => 'var(--md-button-tone)', '--md-button-label' => 'var(--md-button-on-tone)']],
'outlined' => ['outlined', ['--md-button-label' => 'var(--md-button-quiet)', '--md-button-outline' => 'var(--md-sys-color-outline-variant)', 'border' => 'var(--md-button-outline-width) solid var(--md-button-outline)']],
'elevated' => ['elevated', ['--md-button-container' => 'var(--md-sys-color-surface-container-low)', '--md-button-elevation' => 'var(--md-sys-elevation-1)']],
]);
it('draws primary\'s tonal button in secondary-container and its quiet ink in on-surface-variant', function () {
expect(buttonCss()->declarations('[data-md-button]'))->toMatchArray([
'--md-button-tone' => 'var(--md-sys-color-secondary-container)',
'--md-button-on-tone' => 'var(--md-sys-color-on-secondary-container)',
'--md-button-tone-selected' => 'var(--md-sys-color-secondary)',
'--md-button-quiet' => 'var(--md-sys-color-on-surface-variant)',
])
->and(buttonCss()->declarations("[data-md-button]:not([data-md-color='primary'])"))->toBe([
'--md-button-tone-selected' => 'var(--md-button-color)',
'--md-button-on-tone-selected' => 'var(--md-button-on-color)',
'--md-button-quiet' => 'var(--md-button-color)',
]);
});
it('draws a variant in another colour role', function (string $color) {
expect(buttonCss()->declarations("[data-md-button][data-md-color='{$color}']"))->toBe([
'--md-button-color' => "var(--md-sys-color-{$color})",
'--md-button-on-color' => "var(--md-sys-color-on-{$color})",
'--md-button-tone' => "var(--md-sys-color-{$color}-container)",
'--md-button-on-tone' => "var(--md-sys-color-on-{$color}-container)",
]);
})->with(['secondary', 'tertiary', 'error', 'success', 'warning', 'info']);
it('keeps the maryUI and ReStride shorthands', function () {
expect(buttonAttributes('<x-button label="Save" primary />'))->toMatchArray(['data-md-variant' => 'filled', 'data-md-color' => 'primary'])
->and(buttonAttributes('<x-button label="Delete" danger />'))->toMatchArray(['data-md-variant' => 'filled', 'data-md-color' => 'error'])
->and(buttonAttributes('<x-button label="Take down" caution />'))->toMatchArray(['data-md-variant' => 'filled', 'data-md-color' => 'warning'])
->and(buttonAttributes('<x-button label="Go" variant="outlined" tone="success" />'))->toMatchArray(['data-md-color' => 'success']);
});
it('falls back to the defaults for values it does not know', function () {
expect(buttonAttributes('<x-button label="Go" variant="glass" color="purple" size="huge" shape="blob" />'))
->toMatchArray(['data-md-variant' => 'text', 'data-md-color' => 'primary', 'data-md-size' => 'sm', 'data-md-shape' => 'round']);
});
it('sizes a label button on Expressive\'s scale', function (string $size, array $geometry, string $type, int $icon) {
$html = (string) $this->blade("<x-button label=\"Upload\" icon=\"upload\" size=\"{$size}\" />");
expect($html)->toContain("data-md-size=\"{$size}\"")->toContain("--md-icon-size: {$icon}px")
->and(buttonCss()->declarations("[data-md-button][data-md-size='{$size}']"))->toMatchArray($geometry)
->and(buttonCss()->declarations(in_array($size, ['xs', 'sm'], true)
? "[data-md-button]:not([data-md-icon-button]):is([data-md-size='xs'], [data-md-size='sm'])"
: "[data-md-button]:not([data-md-icon-button])[data-md-size='{$size}']"))->toMatchArray(['font' => "var(--md-sys-typescale-{$type})"]);
})->with([
'xs' => ['xs', ['--md-button-height' => '32px', '--md-button-padding' => 'var(--md-sys-measurement-space200)', '--md-button-gap' => 'var(--md-sys-measurement-space100)', '--md-button-icon' => '20px'], 'label-lg', 20],
'sm' => ['sm', ['--md-button-height' => '40px', '--md-button-padding' => 'var(--md-sys-measurement-space200)', '--md-button-gap' => 'var(--md-sys-measurement-space100)', '--md-button-icon' => '20px'], 'label-lg', 20],
'md' => ['md', ['--md-button-height' => '56px', '--md-button-padding' => 'var(--md-sys-measurement-space300)', '--md-button-gap' => 'var(--md-sys-measurement-space100)', '--md-button-icon' => '24px'], 'title-md', 24],
'lg' => ['lg', ['--md-button-height' => '96px', '--md-button-padding' => 'var(--md-sys-measurement-space600)', '--md-button-gap' => '12px', '--md-button-icon' => '32px'], 'headline-sm', 32],
'xl' => ['xl', ['--md-button-height' => '136px', '--md-button-padding' => 'var(--md-sys-measurement-space800)', '--md-button-gap' => 'var(--md-sys-measurement-space200)', '--md-button-icon' => '40px'], 'headline-lg', 40],
]);
it('rounds by default, squares on request, and squares off further while pressed — without specificity', function () {
expect(buttonAttributes('<x-button label="Go" size="md" shape="square" />'))->toMatchArray(['data-md-shape' => 'square'])
->and(buttonCss()->declarations(':where([data-md-button])'))->toBe(['border-radius' => 'var(--md-sys-shape-corner-full)'])
->and(buttonCss()->declarations(":where([data-md-button]):where([data-md-shape='square'], [data-md-selected='true'])"))->toBe(['border-radius' => 'var(--md-button-square)'])
->and(buttonCss()->declarations(':where([data-md-button]):where(:active)'))->toBe(['border-radius' => 'var(--md-button-pressed)'])
->and(buttonCss()->declarations("[data-md-button][data-md-size='md']"))->toMatchArray(['--md-button-square' => 'var(--md-sys-shape-corner-lg)', '--md-button-pressed' => 'var(--md-sys-shape-corner-md)'])
->and(buttonCss()->declarations("[data-md-button][data-md-size='xl']"))->toMatchArray(['--md-button-square' => 'var(--md-sys-shape-corner-xl)', '--md-button-pressed' => 'var(--md-sys-shape-corner-lg)']);
});
it('reaches a 48px touch target below the medium size', function () {
expect(buttonCss()->declarations("[data-md-button]:is([data-md-size='xs'], [data-md-size='sm'])::after"))->toMatchArray([
'content' => "''",
'min-width' => 'var(--md-sys-measurement-space600)',
'min-height' => 'var(--md-sys-measurement-space600)',
])
->and(buttonCss()->has("[data-md-button]:is([data-md-size='md'])::after"))->toBeFalse();
});
it('draws M3\'s state layer and focus ring on the button itself', function () {
$css = buttonCss();
expect($css->declarations('[data-md-button]::before'))->toMatchArray(['background-color' => 'currentColor', 'opacity' => '0', 'border-radius' => 'inherit'])
->and($css->declarations('[data-md-button]:hover::before', ['@media (hover: hover)']))->toBe(['opacity' => 'var(--md-sys-state-hover-state-layer-opacity)'])
->and($css->declarations('[data-md-button]:focus-visible::before'))->toBe(['opacity' => 'var(--md-sys-state-focus-state-layer-opacity)'])
->and($css->declarations('[data-md-button]:active::before'))->toBe(['opacity' => 'var(--md-sys-state-pressed-state-layer-opacity)'])
->and($css->declarations('[data-md-button]:focus-visible'))->toBe(['outline' => '3px solid var(--md-sys-color-secondary)', 'outline-offset' => '2px']);
});
it('moves shape on the spatial spring and colour on the effects spring', function () {
$declarations = buttonCss()->declarations('[data-md-button]');
expect($declarations['transition-property'])->toBe('border-radius, padding, margin, bottom, background-color, color, box-shadow')
->and($declarations['transition-timing-function'])->toBe('var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast)');
});
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(buttonAttributes('<x-button icon="close" tooltip="Close menu" />'))
->toMatchArray(['data-md-icon-button' => 'data-md-icon-button', 'data-md-width' => 'default', 'aria-label' => 'Close menu'])
->and($html)->toContain('--md-icon-size: 24px')->toContain('popover="manual"')->toContain('aria-hidden="true"')
// M3's standard icon button: on-surface-variant, unless selected.
->and(buttonCss()->declarations("[data-md-button][data-md-variant='text'][data-md-icon-button]:not([data-md-selected='true'])"))->toBe(['--md-button-label' => 'var(--md-button-quiet)']);
});
it('sizes an icon button by width', function () {
expect(buttonAttributes('<x-button icon="share" label="" width="wide" aria-label="Share" />'))->toMatchArray(['data-md-width' => 'wide'])
->and(buttonAttributes('<x-button icon="share" size="xl" width="narrow" aria-label="Share" />'))->toMatchArray(['data-md-width' => 'narrow', 'data-md-size' => 'xl'])
->and(buttonAttributes('<x-button label="Share" width="wide" />'))->not->toHaveKey('data-md-width')
->and(buttonCss()->declarations("[data-md-button][data-md-size='sm']"))->toMatchArray(['--md-button-narrow' => '32px', '--md-button-default' => '40px', '--md-button-wide' => '52px'])
->and(buttonCss()->declarations("[data-md-button][data-md-size='xl']"))->toMatchArray(['--md-button-narrow' => '104px', '--md-button-default' => '136px', '--md-button-wide' => '184px'])
->and(buttonCss()->declarations("[data-md-button][data-md-icon-button][data-md-width='wide']"))->toBe(['--md-button-width' => 'var(--md-button-wide)']);
});
it('toggles with aria-pressed and M3\'s selected colours and shape', function () {
$css = buttonCss();
expect(buttonAttributes('<x-button label="Bold" variant="filled" :selected="true" />'))->toMatchArray(['aria-pressed' => 'true', 'data-md-selected' => 'true'])
->and(buttonAttributes('<x-button label="Bold" variant="filled" :selected="false" />'))->toMatchArray(['aria-pressed' => 'false', 'data-md-selected' => 'false'])
->and(buttonAttributes('<x-button label="Bold" variant="filled" />'))->not->toHaveKey('data-md-selected')
->and($css->declarations("[data-md-button][data-md-variant='filled'][data-md-selected='false']"))->toBe(['--md-button-container' => 'var(--md-sys-color-surface-container)', '--md-button-label' => 'var(--md-sys-color-on-surface-variant)'])
->and($css->declarations("[data-md-button][data-md-variant='tonal'][data-md-selected='true']"))->toBe(['--md-button-container' => 'var(--md-button-tone-selected)', '--md-button-label' => 'var(--md-button-on-tone-selected)'])
->and($css->declarations("[data-md-button][data-md-variant='outlined'][data-md-selected='true']"))->toBe(['--md-button-container' => 'var(--md-sys-color-inverse-surface)', '--md-button-label' => 'var(--md-sys-color-inverse-on-surface)', '--md-button-outline' => 'transparent'])
// Text buttons are not toggles in M3: a selected one takes the tonal container.
->and($css->declarations("[data-md-button][data-md-variant='text']:not([data-md-icon-button])[data-md-selected='true']"))->toBe(['--md-button-container' => 'var(--md-button-tone)', '--md-button-label' => 'var(--md-button-on-tone)']);
});
it('squares a selected round icon button and rounds a selected square one', function () {
expect(buttonCss()->declarations(":where([data-md-button]):where([data-md-icon-button][data-md-shape='square'][data-md-selected='true'])"))->toBe(['border-radius' => 'var(--md-sys-shape-corner-full)'])
->and(buttonAttributes('<x-button icon="favorite" aria-label="Like" variant="tonal" shape="square" :selected="true" />'))
->toMatchArray(['data-md-icon-button' => 'data-md-icon-button', 'data-md-shape' => 'square', 'data-md-selected' => 'true']);
});
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 () {
expect(buttonAttributes('<x-button label="Save" variant="filled" disabled />'))->toMatchArray(['disabled' => 'disabled'])
->and(buttonAttributes('<x-button label="Shares" link="/admin/shares" disabled />'))->toMatchArray(['aria-disabled' => 'true', 'tabindex' => '-1'])
->and(buttonCss()->declarations("[data-md-button]:is(:disabled, [aria-disabled='true'])"))->toBe([
'box-shadow' => 'none',
'color' => 'color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent)',
])
->and(buttonCss()->declarations("[data-md-button]:is([data-md-variant='filled'], [data-md-variant='tonal'], [data-md-variant='elevated'], [data-md-variant='outlined'][data-md-selected='true']):is(:disabled, [aria-disabled='true'])"))
->toBe(['background-color' => 'color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent)'])
->and(buttonCss()->declarations("[data-md-button]:is(:disabled, [aria-disabled='true'])::before"))->toBe(['display' => 'none'])
->and(buttonCss()->declarations("[data-md-button][aria-disabled='true']"))->toBe(['pointer-events' => 'none']);
});
it('shows the loading indicator while its own action runs, in the button\'s own ink', function () {
$html = (string) $this->blade('<x-button label="Save" wire:click="save" spinner />');
expect($html)
->toContain('wire:loading.attr="disabled"')
->toContain('wire:target="save"')
->toMatch('/<span wire:loading.flex wire:target="save" data-md-button-spinner>\s*<span data-md-loading="data-md-loading" aria-hidden="true">/')
->toMatch('/<span data-md-button-icon\s+wire:loading.remove wire:target="save"\s*>|<span data-md-button-label/')
->and(buttonCss()->declarations('[data-md-button-spinner] > [data-md-loading]'))->toBe([
'inline-size' => 'var(--md-button-icon)',
'block-size' => 'var(--md-button-icon)',
'color' => 'currentColor',
]);
$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 data-md-button-label data-md-responsive >New share</span>', false);
expect(buttonCss()->declarations('[data-md-button] > [data-md-responsive]', ['@media (width < 840px)']))->toBe(['display' => 'none']);
});
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('data-md-side="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 />');
$fab = buttonCss()->declarations('[data-md-button][data-md-compact-fab]', ['@media (width < 600px)']);
expect(substr_count($html, '<button'))->toBe(1)
->and(buttonAttributes('<x-button label="New share" icon="add" fab />'))->toMatchArray(['data-md-compact-fab' => 'data-md-compact-fab', 'data-md-variant' => 'filled'])
->and($fab)->toMatchArray([
'position' => 'fixed',
'inset-inline-end' => 'var(--md-sys-measurement-space200)',
'--md-button-height' => '56px',
'border-radius' => 'var(--md-sys-shape-corner-lg)',
'background-color' => 'var(--md-sys-color-primary-container)',
'color' => 'var(--md-sys-color-on-primary-container)',
'box-shadow' => 'var(--md-sys-elevation-3)',
])
// M3 puts a snackbar above a FAB, never over one: the host publishes its height.
->and($fab['bottom'])->toBe('calc(var(--material-bottom-bar, 0px) + var(--material-snackbar-height, 0px) + var(--md-sys-measurement-space200))');
});
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')
->toContain('data-md-selected="true"')
->and((string) $this->blade('<x-button label="Bold" :selected="true" />'))
->toContain('aria-pressed="true"');
});
it('lands a caller\'s class and style on the button untouched', function () {
expect(buttonAttributes('<x-button label="Save" class="w-full" style="margin: 0" tooltip="Save" />'))
->toMatchArray(['class' => 'w-full'])
->and(buttonAttributes('<x-button label="Save" class="w-full" style="margin: 0" tooltip="Save" />')['style'])->toStartWith('anchor-name: --material-button-')->toEndWith('margin: 0;');
});