*/
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(''))->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(""))->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(''))->toMatchArray(['data-md-variant' => 'filled', 'data-md-color' => 'primary'])
->and(buttonAttributes(''))->toMatchArray(['data-md-variant' => 'filled', 'data-md-color' => 'error'])
->and(buttonAttributes(''))->toMatchArray(['data-md-variant' => 'filled', 'data-md-color' => 'warning'])
->and(buttonAttributes(''))->toMatchArray(['data-md-color' => 'success']);
});
it('falls back to the defaults for values it does not know', function () {
expect(buttonAttributes(''))
->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("");
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(''))->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(''))->toContain($geometry('filled/favorite'))
->and((string) $this->blade(''))->toContain($geometry('outlined/favorite'))
->and((string) $this->blade(''))->toContain($geometry('filled/favorite'));
// 20px glyphs come from the 20px cut; 24px ones stay on the default.
expect((string) $this->blade(''))->toContain($geometry('outlined-20/favorite'))
->and((string) $this->blade(''))->toContain($geometry('outlined/favorite'));
});
it('is an icon button named by its tooltip when it has no label', function () {
$html = (string) $this->blade('');
expect(buttonAttributes(''))
->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(''))->toMatchArray(['data-md-width' => 'wide'])
->and(buttonAttributes(''))->toMatchArray(['data-md-width' => 'narrow', 'data-md-size' => 'xl'])
->and(buttonAttributes(''))->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(''))->toMatchArray(['aria-pressed' => 'true', 'data-md-selected' => 'true'])
->and(buttonAttributes(''))->toMatchArray(['aria-pressed' => 'false', 'data-md-selected' => 'false'])
->and(buttonAttributes(''))->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(''))
->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('')
->assertSee('assertSee('href="/admin/shares"', false)
->assertSee('wire:navigate', false)
->assertDontSee('type="button"', false);
$this->blade('')
->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(''))->toMatchArray(['disabled' => 'disabled'])
->and(buttonAttributes(''))->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('');
expect($html)
->toContain('wire:loading.attr="disabled"')
->toContain('wire:target="save"')
->toMatch('/\s*/')
->toMatch('/|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('')
->assertSee('wire:target="upload"', false);
});
it('hides a responsive label below expanded', function () {
$this->blade('')
->assertSee('New share', 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('');
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('');
$fab = buttonCss()->declarations('[data-md-button][data-md-compact-fab]', ['@media (width < 600px)']);
expect(substr_count($html, '