diff --git a/resources/css/components/button.css b/resources/css/components/button.css index 8fdbc7b7..ffb49b3b 100644 --- a/resources/css/components/button.css +++ b/resources/css/components/button.css @@ -35,9 +35,9 @@ * Motion: corners, padding, margins and `bottom` on the fast spatial spring, colour and shadow on * the fast effects spring beside it — a colour must never overshoot (tokens/motion.css). * - * The state layer, focus ring and 48px target (xs and sm) are the declarations of the foundation's - * `md-state-layer`, `md-focus-ring` and `md-touch-target` (foundation/interaction.css), keyed on - * the button itself. + * The button renders the foundation's `md-state-layer` and `md-focus-ring` + * (foundation/interaction.css), and, at `xs` and `sm` where it draws under 48px, `md-touch-target` + * too; nothing here copies their rules. * * `data-md-compact-fab`: below `medium` (600px) the button is an extended FAB in the thumb zone — * fixed 16px from the end, 56px tall with 16px padding, the large corner, `title-md`, @@ -68,8 +68,6 @@ --md-button-outline: transparent; --md-button-elevation: none; - position: relative; - isolation: isolate; display: inline-flex; flex-shrink: 0; align-items: center; @@ -82,7 +80,6 @@ cursor: pointer; -webkit-user-select: none; user-select: none; - outline: none; transition-property: border-radius, padding, margin, bottom, background-color, color, box-shadow; transition-duration: var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration), @@ -92,37 +89,6 @@ 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); - - &::before { - content: ''; - position: absolute; - inset: 0; - z-index: -1; - border-radius: inherit; - background-color: currentColor; - opacity: 0; - pointer-events: none; - transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); - } - - @media (hover: hover) { - &:hover::before { - opacity: var(--md-sys-state-hover-state-layer-opacity); - } - } - - &:focus-visible { - outline: 3px solid var(--md-sys-color-secondary); - outline-offset: 2px; - } - - &:focus-visible::before { - opacity: var(--md-sys-state-focus-state-layer-opacity); - } - - &:active::before { - opacity: var(--md-sys-state-pressed-state-layer-opacity); - } } /* Sizes. */ @@ -238,19 +204,6 @@ } } - /* M3's minimum target, past a button drawn smaller than 48px. */ - [data-md-button]:is([data-md-size='xs'], [data-md-size='sm'])::after { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 100%; - height: 100%; - min-width: var(--md-sys-measurement-space600); - min-height: var(--md-sys-measurement-space600); - transform: translate(-50%, -50%); - } - /* Shape, without specificity: a group reshapes the buttons in it. */ :where([data-md-button]) { border-radius: var(--md-sys-shape-corner-full); @@ -385,10 +338,6 @@ [data-md-button]:is(:disabled, [aria-disabled='true']) { box-shadow: none; color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent); - - &::before { - display: none; - } } [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']) { diff --git a/resources/views/components/button.blade.php b/resources/views/components/button.blade.php index 8a0f8c4a..b4890140 100644 --- a/resources/views/components/button.blade.php +++ b/resources/views/components/button.blade.php @@ -139,7 +139,11 @@ 'wire:loading.attr' => $spinnerTarget ? 'disabled' : null, 'wire:target' => $spinnerTarget, 'style' => $anchor ? "anchor-name: {$anchor}" : null, - ], fn ($value): bool => $value !== null)); + ], fn ($value): bool => $value !== null))->class([ + 'md-state-layer', + 'md-focus-ring', + 'md-touch-target' => in_array($size, ['xs', 'sm'], true), + ]); @endphp <{{ $tag }} {{ $attributes }}> diff --git a/tests/Feature/Components/ActionStylesheetsTest.php b/tests/Feature/Components/ActionStylesheetsTest.php index d17376ae..3d25d5ec 100644 --- a/tests/Feature/Components/ActionStylesheetsTest.php +++ b/tests/Feature/Components/ActionStylesheetsTest.php @@ -61,11 +61,56 @@ it('imports the stylesheet of every component its view renders', function (strin expect(array_values(array_diff($rendered, ComponentStylesheet::read($name)->imports())))->toBe([]); })->with('action components'); -it('writes no class list into the view', function (string $name) { +it('writes no class list into the view but the interaction classes', function (string $name) { $view = File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php"); + $allowed = ['md-state-layer', 'md-focus-ring', 'md-touch-target']; - // A class the caller hands in (`hint-class`, `icon-class`, a slot's attributes) passes through. - expect($view)->not->toMatch('/(?class\(|toCssClasses|(?:x-bind)?:class=/'); + // `@class()`, `Arr::toCssClasses` maps and Alpine's `:class` binding are never how a class + // reaches the page here; the interaction classes and a caller's own class are the only source. + expect($view)->not->toMatch('/@class\(|toCssClasses|(?:x-bind)?:class=/'); + + // A literal `class="…"` may hold only the interaction classes — any subset, any order, with a + // simple `@if (…) … @endif` around one token — or a caller's value forwarded whole (`hint-class`, + // `icon-class`: `class="{{ $…}}"`). + preg_match_all('/(?toBe([], "{$name}.blade.php writes class=\"{$content}\", not only the interaction classes"); + } + + // `$attributes->class([…])` merges the interaction classes with the caller's own, per the + // brief: "merges through $attributes->class() only where the root takes the caller's class". + // Only the array's own items are checked — a condition may nest its own brackets + // (`in_array($size, ['xs', 'sm'], true)`), which are no class list of the view's writing. + preg_match_all('/->class\(\[(.*?)\]\)/s', $view, $classCalls, PREG_SET_ORDER); + + foreach ($classCalls as [, $args]) { + $depth = 0; + + for ($i = 0, $length = strlen($args); $i < $length; $i++) { + $char = $args[$i]; + + if ($char === '[' || $char === '(') { + $depth++; + } elseif ($char === ']' || $char === ')') { + $depth--; + } elseif ($depth === 0 && $char === "'") { + $end = strpos($args, "'", $i + 1); + $item = substr($args, $i + 1, $end - $i - 1); + + expect(in_array($item, $allowed, true))->toBeTrue("{$name}.blade.php: ->class() writes [{$item}], not an interaction class"); + + $i = $end; + } + } + } })->with('action components'); it('takes its values from the tokens and its breakpoints in px', function (string $name) { diff --git a/tests/Feature/Components/ButtonGroupTest.php b/tests/Feature/Components/ButtonGroupTest.php index b27677e9..7c27950d 100644 --- a/tests/Feature/Components/ButtonGroupTest.php +++ b/tests/Feature/Components/ButtonGroupTest.php @@ -5,9 +5,14 @@ use Livewire\Livewire; use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet; it('groups buttons with the spacing of their size', function (string $size, string $gap) { - expect((string) $this->blade("")) + $html = (string) $this->blade(""); + preg_match('/]*>/', $html, $tag); + + expect($html) ->toMatch("/not->toContain('class=') + // The group's own root writes no class; the button nested in the slot carries the + // interaction classes of its own. + ->and($tag[0])->not->toContain('class=') ->and(ComponentStylesheet::read('button-group')->declarations(in_array($size, ['xs', 'sm'], true) ? "[data-md-button-group='standard'][data-md-size='{$size}']" : "[data-md-button-group='standard']"))->toBe(['gap' => $gap]); diff --git a/tests/Feature/Components/ButtonTest.php b/tests/Feature/Components/ButtonTest.php index 8d8fa1db..10786d30 100644 --- a/tests/Feature/Components/ButtonTest.php +++ b/tests/Feature/Components/ButtonTest.php @@ -22,6 +22,7 @@ function buttonCss(): ComponentStylesheet 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', @@ -108,23 +109,18 @@ it('rounds by default, squares on request, and squares off further while pressed ->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('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 on the button itself', function () { +it('draws M3\'s state layer and focus ring from the foundation\'s shared classes, not its own', 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']); + 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 () { @@ -211,7 +207,6 @@ it('disables a button, and a link as far as a link can be', function () { ]) ->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']); }); @@ -283,8 +278,8 @@ it('keeps aria-pressed off a selected link, which is not a toggle', function () ->toContain('aria-pressed="true"'); }); -it('lands a caller\'s class and style on the button untouched', function () { +it('lands a caller\'s class and style on the button untouched, beside the interaction classes', function () { expect(buttonAttributes('')) - ->toMatchArray(['class' => 'w-full']) + ->toMatchArray(['class' => 'md-state-layer md-focus-ring md-touch-target w-full']) ->and(buttonAttributes('')['style'])->toStartWith('anchor-name: --material-button-')->toEndWith('margin: 0;'); }); diff --git a/tests/Feature/Components/SplitButtonTest.php b/tests/Feature/Components/SplitButtonTest.php index e6cb14ea..6d172310 100644 --- a/tests/Feature/Components/SplitButtonTest.php +++ b/tests/Feature/Components/SplitButtonTest.php @@ -11,8 +11,8 @@ it('puts the action on the leading button and the menu on the trailing one', fun expect($html) ->toStartWith('
') - ->toMatch('/