*/ 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([ '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(""))->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, from the foundation\'s shared class', function () { expect(buttonAttributes('')['class'])->toBe('md-state-layer md-focus-ring md-touch-target') ->and(buttonAttributes('')['class'])->toBe('md-state-layer md-focus-ring md-touch-target') ->and(buttonAttributes('')['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(''))->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][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*