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
|
* 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
|
* `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%
|
* 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.
|
* behind 38% text. Corners move on the fast spatial spring and colours on the fast effects spring
|
||||||
* Corners move on the fast spatial spring and colours on the fast effects spring beside it.
|
* 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
|
* 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
|
* 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-container: var(--md-sys-color-secondary-container);
|
||||||
--md-group-label: var(--md-sys-color-on-secondary-container);
|
--md-group-label: var(--md-sys-color-on-secondary-container);
|
||||||
|
|
||||||
position: relative;
|
|
||||||
isolation: isolate;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 1 0%;
|
flex: 1 1 0%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -76,31 +80,21 @@
|
|||||||
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 {
|
/* Focus lands on the input inside, not the label, so it is read with :has(); the state
|
||||||
content: '';
|
layer's own opacity joins the ring, as the foundation class draws for a focused control. */
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:has(:focus-visible) {
|
&:has(:focus-visible) {
|
||||||
outline: 3px solid var(--md-sys-color-secondary);
|
outline: 3px solid var(--md-sys-color-secondary);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
opacity: var(--md-sys-state-focus-state-layer-opacity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active::before {
|
/* Disabled is read off the input too: the class's own :disabled/aria-disabled rule never
|
||||||
opacity: var(--md-sys-state-pressed-state-layer-opacity);
|
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. */
|
/* 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] {
|
[data-md-group]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-group-segment] {
|
||||||
min-inline-size: var(--md-sys-measurement-space600);
|
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] {
|
[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.
|
// 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];
|
$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([
|
$root = $attributes->only(['class', 'wire:key'])->merge(array_filter([
|
||||||
'data-md-group' => true,
|
'data-md-group' => true,
|
||||||
@@ -78,7 +80,7 @@
|
|||||||
|
|
||||||
<div data-md-button-group="connected" data-md-size="{{ $size }}" data-md-shape="{{ $shape }}">
|
<div data-md-button-group="connected" data-md-size="{{ $size }}" data-md-shape="{{ $shape }}">
|
||||||
@foreach ($options as $option)
|
@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
|
<input
|
||||||
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
|
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
|
||||||
type="{{ $multiple ? 'checkbox' : 'radio' }}"
|
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">/')
|
->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('<legend data-md-group-legend>Theme</legend>')
|
||||||
->toContain('<div data-md-button-group="connected" data-md-size="sm" data-md-shape="round">')
|
->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('type="radio"')
|
||||||
->toContain('name="theme"')
|
->toContain('name="theme"')
|
||||||
->toContain('x-model="theme"')
|
->toContain('x-model="theme"')
|
||||||
->toContain('value="light"')
|
->toContain('value="light"')
|
||||||
->toContain('disabled')
|
->toContain('disabled')
|
||||||
->toContain('<span data-md-group-label>Light</span>')
|
->toContain('<span data-md-group-label>Light</span>')
|
||||||
->not->toContain('class=')
|
|
||||||
->and(substr_count($html, '<svg'))->toBe(1);
|
->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']);
|
->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');
|
$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)'])
|
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([
|
->and($css->has("[data-md-group]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-group-segment]::after"))->toBeFalse()
|
||||||
'min-width' => 'var(--md-sys-measurement-space600)',
|
|
||||||
'min-height' => 'var(--md-sys-measurement-space600)',
|
|
||||||
])
|
|
||||||
->and($css->declarations('[data-md-group-segment]'))->toMatchArray(['min-inline-size' => '0'])
|
->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) {
|
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='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][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)'))->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)'))->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"');
|
->and((string) $this->blade('<x-group name="x" variant="glass" :options="[]" />'))->toContain('data-md-variant="tonal"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user