Files
livewire-material/tests/Feature/Components/ButtonTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 bfd368e463 Draw the button's state layer, focus ring and target from the classes
Plan step 36 ("Interaction is the shared classes", the user, 2026-09-14).
button.css hand-rolled the same declarations as foundation/interaction.css's
md-state-layer, md-focus-ring and md-touch-target; it now renders those
classes (the touch target only at xs and sm, where it draws under 48px) and
keeps only its own geometry, colour and motion. The stylesheet test's "writes
no class list" check now allows the three interaction classes, in any subset,
and $attributes->class() as the way a root merges them with a caller's own
class; split-button and button-group, which nest a button, are updated to match.

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

286 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([
'class' => 'md-state-layer md-focus-ring md-touch-target',
'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, from the foundation\'s shared class', function () {
expect(buttonAttributes('<x-button label="Go" size="xs" />')['class'])->toBe('md-state-layer md-focus-ring md-touch-target')
->and(buttonAttributes('<x-button label="Go" size="sm" />')['class'])->toBe('md-state-layer md-focus-ring md-touch-target')
->and(buttonAttributes('<x-button label="Go" size="md" />')['class'])->toBe('md-state-layer md-focus-ring');
});
it('draws M3\'s state layer and focus ring from the foundation\'s shared classes, not its own', function () {
$css = buttonCss();
expect($css->has('[data-md-button]::before'))->toBeFalse()
->and($css->has("[data-md-button]:is([data-md-size='xs'], [data-md-size='sm'])::after"))->toBeFalse()
->and($css->has('[data-md-button]:focus-visible'))->toBeFalse();
});
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][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, beside the interaction classes', function () {
expect(buttonAttributes('<x-button label="Save" class="w-full" style="margin: 0" tooltip="Save" />'))
->toMatchArray(['class' => 'md-state-layer md-focus-ring md-touch-target 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;');
});