Draw the connected choice group without Tailwind

Plan step 36. <x-group> renders data-md-group with its size, variant,
shape, multiple and inline, a data-md-button-group row and
data-md-group-segment labels over its radios or checkboxes. group.css
draws the segments as M3's buttons with the toggle button's colours
from :has(:checked), the disabled treatment from :has(:disabled), the
focus ring from :has(:focus-visible) and the 48px target at xs and sm.

The connected shapes move into button-group.css (the corner scale,
square ends, the pressed and selected inner corners), which group.css
imports; <x-button-group> adopts them in its own rewrite. The group's
render tests move to GroupTest; AppBarTest (navigation group) follows
the new legend and row hooks of theme-toggle's rows.

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 14:56:59 +02:00
co-authored by Claude Opus 5
parent 4ad257034e
commit b8c6d411db
8 changed files with 504 additions and 137 deletions
@@ -15,6 +15,7 @@ dataset('action components', [
'tooltip',
'badge',
'button',
'group',
]);
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
@@ -48,7 +49,7 @@ it('writes no class list into the view', function (string $name) {
$view = File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php");
// A class the caller hands in (`hint-class`, `icon-class`, a slot's attributes) passes through.
expect($view)->not->toMatch('/\bclass="(?!\{\{ \$)|@class\(|->class\(|toCssClasses|(?:x-bind)?:class=/');
expect($view)->not->toMatch('/(?<![\w-])class="(?!\{\{ \$)|@class\(|->class\(|toCssClasses|(?:x-bind)?:class=/');
})->with('action components');
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
+2 -2
View File
@@ -200,7 +200,7 @@ it('offers M3\'s three contrast levels, marking the one in force', function () {
->toContain('data-theme-toggle="contrast"')
->toContain('aria-label="Contrast"')
// A connected button group over native radios, not the deprecated segmented button (N-04).
->toContain('data-button-group="connected"')
->toContain('data-md-button-group="connected"')
->toContain('name="material-contrast"')
->toContain('value="standard"')
->toContain('value="medium"')
@@ -216,7 +216,7 @@ it('names a theme row for a screen reader and legends it on request', function (
->not->toContain('<legend')
->and((string) $this->blade('<x-theme-toggle mode="picker" label="Appearance" />'))
->toContain('aria-label="Appearance"')
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Appearance</legend>');
->toContain('<legend data-md-group-legend>Appearance</legend>');
});
it('switches the theme through the store in three shapes', function () {
+1 -87
View File
@@ -20,7 +20,7 @@ it('connects buttons 2px apart', function () {
->toContain('gap-0.5');
});
it('squares a group, and a choice drawn as one', function () {
it('squares a group', function () {
// M3 lists "Default shape | Round, square" as a button-group configuration; the shape covers
// every button in the group, so it need not be written on each one.
expect((string) $this->blade('<x-button-group shape="square"><x-button label="Day" /></x-button-group>'))
@@ -29,10 +29,6 @@ it('squares a group, and a choice drawn as one', function () {
->toContain('data-button-group="connected"')
->toContain('data-shape="square"')
->and((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
->toContain('data-shape="round"')
->and((string) $this->blade('<x-group shape="square" name="x" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
->toContain('data-shape="square"')
->and((string) $this->blade('<x-group name="x" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
->toContain('data-shape="round"');
});
@@ -92,85 +88,3 @@ it('marks icon buttons, which keep their width when a group is pressed', functio
expect((string) $this->blade('<x-button icon="format_bold" aria-label="Bold" />'))->toContain('data-md-icon-button')
->and((string) $this->blade('<x-button label="Bold" />'))->not->toContain('data-md-icon-button');
});
it('draws a choice as connected radios', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-group label="Theme" name="theme" x-model="theme" :options="[
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
['id' => 'dark', 'name' => 'Dark', 'disabled' => true],
]" />
BLADE);
expect($html)
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Theme</legend>')
->toContain('data-button-group="connected"')
->toContain('type="radio"')
->toContain('name="theme"')
->toContain('x-model="theme"')
->toContain('value="light"')
->toContain('has-checked:bg-secondary has-checked:text-on-secondary')
->toContain('disabled')
->and(substr_count($html, '<svg'))->toBe(1);
});
it('keeps a 48px target and a 48px minimum width on the smallest connected segments', function () {
$options = [['id' => 'a', 'name' => 'A']];
expect((string) $this->blade('<x-group size="sm" name="x" :$options />', ['options' => $options]))
->toContain('touch-target min-w-12')
->and((string) $this->blade('<x-group size="md" name="x" :$options />', ['options' => $options]))
->not->toContain('touch-target')
->toContain('min-w-0');
});
it('draws several choices as checkboxes', function () {
expect((string) $this->blade('<x-group name="days" multiple variant="outlined" :options="[[\'id\' => \'mon\', \'name\' => \'Mon\']]" />'))
->toContain('type="checkbox"')
->toContain('name="days[]"')
->toContain('has-checked:bg-inverse-surface');
});
it('binds to a Livewire property and shows its validation message', function () {
$component = new class extends Component
{
public string $theme = 'dark';
public function save(): void
{
$this->validate(['theme' => 'in:light'], ['theme.in' => 'Pick light.']);
}
public function render(): string
{
return <<<'BLADE'
<div>
<x-group wire:model="theme" hint="How it looks" :options="[['id' => 'light', 'name' => 'Light'], ['id' => 'dark', 'name' => 'Dark']]" />
</div>
BLADE;
}
};
Livewire::test($component)
->assertSeeHtml('wire:model="theme"')
->assertSee('How it looks')
->set('theme', 'light')
->assertSet('theme', 'light')
->set('theme', 'dark')
->call('save')
->assertSee('Pick light.')
->assertDontSee('How it looks');
});
it('adds hint-class to the hint, which a validation message still replaces', function () {
$options = [['id' => 'flat', 'name' => 'Flat'], ['id' => 'hilly', 'name' => 'Hilly']];
expect((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="text-warning" :$options />', ['options' => $options]))
->toContain('<p class="mt-1 type-body-sm [:where(&amp;)]:text-on-surface-variant text-warning">No elevation data here</p>')
->and((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" :$options />', ['options' => $options]))
->toContain('<p class="mt-1 type-body-sm text-on-surface-variant">No elevation data here</p>');
expect((string) $this->withViewErrors(['terrain' => 'Pick a terrain.'])->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="text-warning" :$options />', ['options' => $options]))
->toContain('<p class="mt-1 type-body-sm text-error">Pick a terrain.</p>')
->not->toContain('No elevation data here')
->not->toContain('text-warning');
});
+129
View File
@@ -0,0 +1,129 @@
<?php
use Livewire\Component;
use Livewire\Livewire;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('draws a choice as connected radios', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-group label="Theme" name="theme" x-model="theme" :options="[
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
['id' => 'dark', 'name' => 'Dark', 'disabled' => true],
]" />
BLADE);
expect($html)
->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>')
->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);
});
it('squares a choice\'s ends, and still rounds the chosen segment', function () {
$options = [['id' => 'a', 'name' => 'A']];
expect((string) $this->blade('<x-group shape="square" name="x" :$options />', ['options' => $options]))->toContain('data-md-button-group="connected" data-md-size="sm" data-md-shape="square"')
->and((string) $this->blade('<x-group name="x" :$options />', ['options' => $options]))->toContain('data-md-shape="round"')
->and(ComponentStylesheet::read('button-group')->declarations("[data-md-button-group][data-md-shape='square']"))->toBe(['--md-button-group-outer' => 'var(--md-button-group-inner)'])
->and(ComponentStylesheet::read('button-group')->declarations("[data-md-button-group='connected'] > :is([aria-pressed='true'], :has(:checked))"))->toBe(['--md-button-group-corner' => 'var(--md-button-group-full)'])
->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 () {
$css = ComponentStylesheet::read('group');
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->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)']);
});
it('sizes its segments as M3\'s buttons', function (string $size, string $height, string $padding, string $type, int $icon) {
$html = (string) $this->blade("<x-group size=\"{$size}\" name=\"x\" :options=\"[['id' => 'a', 'name' => 'A', 'icon' => 'home']]\" />");
expect($html)->toContain("data-md-size=\"{$size}\"")->toContain("--md-icon-size: {$icon}px")
->and(ComponentStylesheet::read('group')->declarations("[data-md-group][data-md-size='{$size}'] [data-md-group-segment]"))->toMatchArray([
'block-size' => $height,
'padding-inline' => $padding,
'font' => "var(--md-sys-typescale-{$type})",
]);
})->with([
'xs' => ['xs', '32px', 'var(--md-sys-measurement-space200)', 'label-lg', 20],
'sm' => ['sm', '40px', 'var(--md-sys-measurement-space200)', 'label-lg', 20],
'md' => ['md', '56px', 'var(--md-sys-measurement-space300)', 'title-md', 24],
'lg' => ['lg', '96px', 'var(--md-sys-measurement-space600)', 'headline-sm', 32],
'xl' => ['xl', '136px', 'var(--md-sys-measurement-space800)', 'headline-lg', 40],
]);
it('draws several choices as checkboxes, in the toggle button\'s colours', function () {
$css = ComponentStylesheet::read('group');
expect((string) $this->blade('<x-group name="days" multiple variant="outlined" :options="[[\'id\' => \'mon\', \'name\' => \'Mon\']]" />'))
->toContain('type="checkbox"')
->toContain('name="days[]"')
->toContain('data-md-variant="outlined"')
->toContain('data-md-multiple')
->and($css->declarations('[data-md-group-segment]:has(:checked)'))->toBe(['--md-group-container' => 'var(--md-sys-color-secondary)', '--md-group-label' => 'var(--md-sys-color-on-secondary)'])
->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(:focus-visible)'))->toBe(['outline' => '3px solid var(--md-sys-color-secondary)', 'outline-offset' => '2px'])
->and((string) $this->blade('<x-group name="x" variant="glass" :options="[]" />'))->toContain('data-md-variant="tonal"');
});
it('binds to a Livewire property and shows its validation message', function () {
$component = new class extends Component
{
public string $theme = 'dark';
public function save(): void
{
$this->validate(['theme' => 'in:light'], ['theme.in' => 'Pick light.']);
}
public function render(): string
{
return <<<'BLADE'
<div>
<x-group wire:model="theme" hint="How it looks" :options="[['id' => 'light', 'name' => 'Light'], ['id' => 'dark', 'name' => 'Dark']]" />
</div>
BLADE;
}
};
Livewire::test($component)
->assertSeeHtml('wire:model="theme"')
->assertSee('How it looks')
->set('theme', 'light')
->assertSet('theme', 'light')
->set('theme', 'dark')
->call('save')
->assertSee('Pick light.')
->assertDontSee('How it looks');
});
it('adds hint-class to the hint, which a validation message still replaces', function () {
$options = [['id' => 'flat', 'name' => 'Flat'], ['id' => 'hilly', 'name' => 'Hilly']];
expect((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="text-warning" :$options />', ['options' => $options]))
->toContain('<p data-md-group-hint class="text-warning" >No elevation data here</p>')
->and((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" :$options />', ['options' => $options]))
->toContain('<p data-md-group-hint >No elevation data here</p>')
->and(ComponentStylesheet::read('group')->declarations('[data-md-group-hint]'))->toBe(['color' => 'var(--md-sys-color-on-surface-variant)']);
expect((string) $this->withViewErrors(['terrain' => 'Pick a terrain.'])->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="text-warning" :$options />', ['options' => $options]))
->toContain('<p data-md-group-error>Pick a terrain.</p>')
->not->toContain('No elevation data here')
->not->toContain('text-warning');
});