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
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 18:01:18 +02:00
co-authored by Claude Opus 5
parent aa3e789081
commit bfd368e463
6 changed files with 76 additions and 78 deletions
+3 -54
View File
@@ -35,9 +35,9 @@
* Motion: corners, padding, margins and `bottom` on the fast spatial spring, colour and shadow on * 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 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 * The button renders the foundation's `md-state-layer` and `md-focus-ring`
* `md-state-layer`, `md-focus-ring` and `md-touch-target` (foundation/interaction.css), keyed on * (foundation/interaction.css), and, at `xs` and `sm` where it draws under 48px, `md-touch-target`
* the button itself. * too; nothing here copies their rules.
* *
* `data-md-compact-fab`: below `medium` (600px) the button is an extended FAB in the thumb zone — * `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`, * 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-outline: transparent;
--md-button-elevation: none; --md-button-elevation: none;
position: relative;
isolation: isolate;
display: inline-flex; display: inline-flex;
flex-shrink: 0; flex-shrink: 0;
align-items: center; align-items: center;
@@ -82,7 +80,6 @@
cursor: pointer; cursor: pointer;
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
outline: none;
transition-property: border-radius, padding, margin, bottom, background-color, color, box-shadow; transition-property: border-radius, padding, margin, bottom, background-color, color, box-shadow;
transition-duration: transition-duration:
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-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-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); 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. */ /* 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. */ /* Shape, without specificity: a group reshapes the buttons in it. */
:where([data-md-button]) { :where([data-md-button]) {
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
@@ -385,10 +338,6 @@
[data-md-button]:is(:disabled, [aria-disabled='true']) { [data-md-button]:is(:disabled, [aria-disabled='true']) {
box-shadow: none; box-shadow: none;
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent); 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']) { [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']) {
+5 -1
View File
@@ -139,7 +139,11 @@
'wire:loading.attr' => $spinnerTarget ? 'disabled' : null, 'wire:loading.attr' => $spinnerTarget ? 'disabled' : null,
'wire:target' => $spinnerTarget, 'wire:target' => $spinnerTarget,
'style' => $anchor ? "anchor-name: {$anchor}" : null, '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 @endphp
<{{ $tag }} {{ $attributes }}> <{{ $tag }} {{ $attributes }}>
@@ -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([]); expect(array_values(array_diff($rendered, ComponentStylesheet::read($name)->imports())))->toBe([]);
})->with('action components'); })->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"); $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. // `@class()`, `Arr::toCssClasses` maps and Alpine's `:class` binding are never how a class
expect($view)->not->toMatch('/(?<![\w-])class="(?!\{\{ \$)|@class\(|->class\(|toCssClasses|(?:x-bind)?: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('/(?<![\w:-])class=(["\'])(.*?)\1/s', $view, $classAttributes, PREG_SET_ORDER);
foreach ($classAttributes as [, , $content]) {
if (preg_match('/^\{\{\s*\$/', trim($content)) === 1) {
continue;
}
$stripped = (string) preg_replace('/\{\{.*?\}\}|@if\s*\([^()]*\)|@unless\s*\([^()]*\)|@else|@endif|@endunless/s', ' ', $content);
$tokens = array_values(array_filter(preg_split('/\s+/', trim($stripped))));
expect(array_diff($tokens, $allowed))->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'); })->with('action components');
it('takes its values from the tokens and its breakpoints in px', function (string $name) { it('takes its values from the tokens and its breakpoints in px', function (string $name) {
+7 -2
View File
@@ -5,9 +5,14 @@ use Livewire\Livewire;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet; use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('groups buttons with the spacing of their size', function (string $size, string $gap) { it('groups buttons with the spacing of their size', function (string $size, string $gap) {
expect((string) $this->blade("<x-button-group label=\"Views\" size=\"{$size}\"><x-button label=\"Day\" /></x-button-group>")) $html = (string) $this->blade("<x-button-group label=\"Views\" size=\"{$size}\"><x-button label=\"Day\" /></x-button-group>");
preg_match('/<div[^>]*>/', $html, $tag);
expect($html)
->toMatch("/<div\\s+role=\"group\"\\s+aria-label=\"Views\"\\s+data-md-button-group=\"standard\"\\s+data-md-size=\"{$size}\"\\s+data-md-shape=\"round\"/") ->toMatch("/<div\\s+role=\"group\"\\s+aria-label=\"Views\"\\s+data-md-button-group=\"standard\"\\s+data-md-size=\"{$size}\"\\s+data-md-shape=\"round\"/")
->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) ->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'][data-md-size='{$size}']"
: "[data-md-button-group='standard']"))->toBe(['gap' => $gap]); : "[data-md-button-group='standard']"))->toBe(['gap' => $gap]);
+11 -16
View File
@@ -22,6 +22,7 @@ function buttonCss(): ComponentStylesheet
it('is a text button in the primary colour by default, drawn by its stylesheet', function () { it('is a text button in the primary colour by default, drawn by its stylesheet', function () {
expect(buttonAttributes('<x-button label="Cancel" />'))->toBe([ 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-button' => 'data-md-button',
'data-md-variant' => 'text', 'data-md-variant' => 'text',
'data-md-color' => 'primary', '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)']); ->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 () { it('reaches a 48px touch target below the medium size, from the foundation\'s shared class', function () {
expect(buttonCss()->declarations("[data-md-button]:is([data-md-size='xs'], [data-md-size='sm'])::after"))->toMatchArray([ expect(buttonAttributes('<x-button label="Go" size="xs" />')['class'])->toBe('md-state-layer md-focus-ring md-touch-target')
'content' => "''", ->and(buttonAttributes('<x-button label="Go" size="sm" />')['class'])->toBe('md-state-layer md-focus-ring md-touch-target')
'min-width' => 'var(--md-sys-measurement-space600)', ->and(buttonAttributes('<x-button label="Go" size="md" />')['class'])->toBe('md-state-layer md-focus-ring');
'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 () { it('draws M3\'s state layer and focus ring from the foundation\'s shared classes, not its own', function () {
$css = buttonCss(); $css = buttonCss();
expect($css->declarations('[data-md-button]::before'))->toMatchArray(['background-color' => 'currentColor', 'opacity' => '0', 'border-radius' => 'inherit']) expect($css->has('[data-md-button]::before'))->toBeFalse()
->and($css->declarations('[data-md-button]:hover::before', ['@media (hover: hover)']))->toBe(['opacity' => 'var(--md-sys-state-hover-state-layer-opacity)']) ->and($css->has("[data-md-button]:is([data-md-size='xs'], [data-md-size='sm'])::after"))->toBeFalse()
->and($css->declarations('[data-md-button]:focus-visible::before'))->toBe(['opacity' => 'var(--md-sys-state-focus-state-layer-opacity)']) ->and($css->has('[data-md-button]:focus-visible'))->toBeFalse();
->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 () { 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'])")) ->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)']) ->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']); ->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"'); ->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('<x-button label="Save" class="w-full" style="margin: 0" tooltip="Save" />')) expect(buttonAttributes('<x-button label="Save" class="w-full" style="margin: 0" tooltip="Save" />'))
->toMatchArray(['class' => 'w-full']) ->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;'); ->and(buttonAttributes('<x-button label="Save" class="w-full" style="margin: 0" tooltip="Save" />')['style'])->toStartWith('anchor-name: --material-button-')->toEndWith('margin: 0;');
}); });
+2 -2
View File
@@ -11,8 +11,8 @@ it('puts the action on the leading button and the menu on the trailing one', fun
expect($html) expect($html)
->toStartWith('<div data-md-split-button data-md-size="sm" class="mt-4">') ->toStartWith('<div data-md-split-button data-md-size="sm" class="mt-4">')
->toMatch('/<button data-md-button="data-md-button"[^>]*data-md-split="leading" wire:click="downloadZip">/') ->toMatch('/<button class="md-state-layer md-focus-ring md-touch-target" data-md-button="data-md-button"[^>]*data-md-split="leading" wire:click="downloadZip">/')
->toMatch('/<button data-md-button="data-md-button"[^>]*data-md-icon-button="data-md-icon-button"[^>]*aria-label="Download options"[^>]*data-md-split="trailing">/') ->toMatch('/<button class="md-state-layer md-focus-ring md-touch-target" data-md-button="data-md-button"[^>]*data-md-icon-button="data-md-icon-button"[^>]*aria-label="Download options"[^>]*data-md-split="trailing">/')
->toContain('role="menu"') ->toContain('role="menu"')
->toContain('As a ZIP') ->toContain('As a ZIP')
->and(substr_count($html, 'wire:click="downloadZip"'))->toBe(1); ->and(substr_count($html, 'wire:click="downloadZip"'))->toBe(1);