Draw the group segment's state layer from the foundation class
Plan step 36 ("Interaction is the shared classes", the user, 2026-09-14).
A segment's hover and press opacities were group.css's own copy of
md-state-layer's declarations; the label now renders md-state-layer
(and, below medium's two smallest sizes, md-touch-target) and the
stylesheet keeps only what the class cannot draw: focus and disabled read
off the real radio or checkbox inside with :has(), not the label itself,
so the focus ring's layer tint and the disabled hide are kept as a
refinement (the latter a real gap the old rule left open). button-group.css,
which only shapes corners and spacing, needed no change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
ea12f9d75b
commit
7803298fe7
@@ -13,8 +13,14 @@
|
||||
* secondary), `filled` (surface-container behind on-surface-variant, chosen in primary) or
|
||||
* `outlined` (an outline-variant edge, chosen in inverse-surface). A checked input picks the
|
||||
* chosen colours (`:has(:checked)`), a disabled one the disabled treatment — on-surface at 10%
|
||||
* behind 38% text — and keyboard focus on the input draws M3's focus ring around its segment.
|
||||
* Corners move on the fast spatial spring and colours on the fast effects spring beside it.
|
||||
* behind 38% text. Corners move on the fast spatial spring and colours on the fast effects spring
|
||||
* beside it.
|
||||
*
|
||||
* A segment renders the foundation's `md-state-layer` (foundation/interaction.css) on the label
|
||||
* itself, which reaches the same `:hover`/`:active` as the class expects; below `medium`'s smallest
|
||||
* two sizes it renders `md-touch-target` too. The real control is the `<input>` inside, not the
|
||||
* label, so keyboard focus and the disabled state are read off it with `:has()` — the one
|
||||
* difference the class cannot draw, kept here as a refinement.
|
||||
*
|
||||
* The legend is label-large and the hint body-small, both in on-surface-variant; a validation
|
||||
* message takes the hint's place in error. `hint-class` lands on the hint, where a caller's class
|
||||
@@ -53,8 +59,6 @@
|
||||
--md-group-container: var(--md-sys-color-secondary-container);
|
||||
--md-group-label: var(--md-sys-color-on-secondary-container);
|
||||
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
flex: 1 1 0%;
|
||||
align-items: center;
|
||||
@@ -76,31 +80,21 @@
|
||||
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 lands on the input inside, not the label, so it is read with :has(); the state
|
||||
layer's own opacity joins the ring, as the foundation class draws for a focused control. */
|
||||
&:has(:focus-visible) {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
|
||||
&::before {
|
||||
opacity: var(--md-sys-state-focus-state-layer-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
&:active::before {
|
||||
opacity: var(--md-sys-state-pressed-state-layer-opacity);
|
||||
/* Disabled is read off the input too: the class's own :disabled/aria-disabled rule never
|
||||
fires on the label it never reaches. */
|
||||
&:has(:disabled)::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The radio or checkbox stays in the page, for the form, the keyboard and a screen reader. */
|
||||
@@ -123,18 +117,6 @@
|
||||
|
||||
[data-md-group]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-group-segment] {
|
||||
min-inline-size: var(--md-sys-measurement-space600);
|
||||
|
||||
&::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%);
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-group][data-md-size='xs'] [data-md-group-segment] {
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
|
||||
// A 20px glyph comes from M3's 20px cut, which the icon component picks from the size.
|
||||
$iconSize = ['xs' => 20, 'sm' => 20, 'md' => 24, 'lg' => 32, 'xl' => 40][$size];
|
||||
// Below `medium` a segment draws under 48px, so it needs the foundation's touch target too.
|
||||
$segmentNeedsTouchTarget = in_array($size, ['xs', 'sm'], true);
|
||||
|
||||
$root = $attributes->only(['class', 'wire:key'])->merge(array_filter([
|
||||
'data-md-group' => true,
|
||||
@@ -78,7 +80,7 @@
|
||||
|
||||
<div data-md-button-group="connected" data-md-size="{{ $size }}" data-md-shape="{{ $shape }}">
|
||||
@foreach ($options as $option)
|
||||
<label data-md-group-segment>
|
||||
<label data-md-group-segment class="md-state-layer @if ($segmentNeedsTouchTarget) md-touch-target @endif">
|
||||
<input
|
||||
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
|
||||
type="{{ $multiple ? 'checkbox' : 'radio' }}"
|
||||
|
||||
@@ -16,14 +16,13 @@ it('draws a choice as connected radios', function () {
|
||||
->toMatch('/<fieldset data-md-group="data-md-group" data-md-size="sm" data-md-variant="tonal" data-md-shape="round">/')
|
||||
->toContain('<legend data-md-group-legend>Theme</legend>')
|
||||
->toContain('<div data-md-button-group="connected" data-md-size="sm" data-md-shape="round">')
|
||||
->toContain('<label data-md-group-segment>')
|
||||
->toMatch('/<label data-md-group-segment class="md-state-layer\s+md-touch-target\s*">/')
|
||||
->toContain('type="radio"')
|
||||
->toContain('name="theme"')
|
||||
->toContain('x-model="theme"')
|
||||
->toContain('value="light"')
|
||||
->toContain('disabled')
|
||||
->toContain('<span data-md-group-label>Light</span>')
|
||||
->not->toContain('class=')
|
||||
->and(substr_count($html, '<svg'))->toBe(1);
|
||||
});
|
||||
|
||||
@@ -37,16 +36,19 @@ it('squares a choice\'s ends, and still rounds the chosen segment', function ()
|
||||
->and(ComponentStylesheet::read('group')->imports())->toBe(['./icon.css', './button-group.css']);
|
||||
});
|
||||
|
||||
it('keeps a 48px target and a 48px minimum width on the smallest connected segments', function () {
|
||||
it('keeps a 48px target and a 48px minimum width on the smallest connected segments, from the foundation\'s class', function () {
|
||||
$css = ComponentStylesheet::read('group');
|
||||
$options = [['id' => 'a', 'name' => 'A']];
|
||||
|
||||
expect($css->declarations("[data-md-group]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-group-segment]"))->toBe(['min-inline-size' => 'var(--md-sys-measurement-space600)'])
|
||||
->and($css->declarations("[data-md-group]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-group-segment]::after"))->toMatchArray([
|
||||
'min-width' => 'var(--md-sys-measurement-space600)',
|
||||
'min-height' => 'var(--md-sys-measurement-space600)',
|
||||
])
|
||||
->and($css->has("[data-md-group]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-group-segment]::after"))->toBeFalse()
|
||||
->and($css->declarations('[data-md-group-segment]'))->toMatchArray(['min-inline-size' => '0'])
|
||||
->and(ComponentStylesheet::read('button-group')->declarations("[data-md-button-group='connected']:is([data-md-size='xs'], [data-md-size='sm']) > *"))->toBe(['min-inline-size' => 'var(--md-sys-measurement-space600)']);
|
||||
->and(ComponentStylesheet::read('button-group')->declarations("[data-md-button-group='connected']:is([data-md-size='xs'], [data-md-size='sm']) > *"))->toBe(['min-inline-size' => 'var(--md-sys-measurement-space600)'])
|
||||
->and((string) $this->blade('<x-group size="xs" name="x" :$options />', ['options' => $options]))
|
||||
->toMatch('/class="md-state-layer\s+md-touch-target\s*"/')
|
||||
->and((string) $this->blade('<x-group size="md" name="x" :$options />', ['options' => $options]))
|
||||
->toMatch('/class="md-state-layer\s*"/')
|
||||
->not->toContain('md-touch-target');
|
||||
});
|
||||
|
||||
it('sizes its segments as M3\'s buttons', function (string $size, string $height, string $padding, string $type, int $icon) {
|
||||
@@ -78,7 +80,10 @@ it('draws several choices as checkboxes, in the toggle button\'s colours', funct
|
||||
->and($css->declarations("[data-md-group][data-md-variant='filled'] [data-md-group-segment]:has(:checked)"))->toBe(['--md-group-container' => 'var(--md-sys-color-primary)', '--md-group-label' => 'var(--md-sys-color-on-primary)'])
|
||||
->and($css->declarations("[data-md-group][data-md-variant='outlined'] [data-md-group-segment]:has(:checked)"))->toBe(['--md-group-container' => 'var(--md-sys-color-inverse-surface)', '--md-group-label' => 'var(--md-sys-color-inverse-on-surface)', 'border-color' => 'transparent'])
|
||||
->and($css->declarations('[data-md-group-segment]:has(:disabled)'))->toMatchArray(['cursor' => 'not-allowed'])
|
||||
->and($css->declarations('[data-md-group-segment]:has(:disabled)::before'))->toBe(['display' => 'none'])
|
||||
->and($css->declarations('[data-md-group-segment]:has(:focus-visible)'))->toBe(['outline' => '3px solid var(--md-sys-color-secondary)', 'outline-offset' => '2px'])
|
||||
->and($css->declarations('[data-md-group-segment]:has(:focus-visible)::before'))->toBe(['opacity' => 'var(--md-sys-state-focus-state-layer-opacity)'])
|
||||
->and($css->has('[data-md-group-segment]::before'))->toBeFalse()
|
||||
->and((string) $this->blade('<x-group name="x" variant="glass" :options="[]" />'))->toContain('data-md-variant="tonal"');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user