M3 asks a FAB's icon and a default (non-toggle) icon button's icon to be filled, never outlined; the toggle half of that rule was already right, so only the `$selected === null` case changed. Every glyph these components draw at 20px now passes `optical="20"` for the cut M3 draws at that size — button xs/sm, group, menu item, the alert, snackbar and FAB-menu close buttons, the stat's icon. Plan step 18, actions.md ACT-14, ACT-15. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
185 lines
9.5 KiB
PHP
185 lines
9.5 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');
|
|
});
|
|
|
|
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"');
|
|
});
|