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("