Plan step 22, actions.md § Missing (Selection-required / multi-select semantics): M3 lists single-select, multi-select and selection-required as button-group configurations, and `<x-button-group connected>` left selection entirely to the caller's own `aria-pressed`. `selection="single|multi"`, with `required`, takes `aria-pressed` over: pressing a button writes its `value` to `wire:model` or `x-model`, deselects the others in `single`, and refuses the press that would leave nothing selected. With no model it reads the buttons' own `aria-pressed` once and goes on from there. `<x-group>` and this do not absorb one another, and the header and SKILL.md say why: `<x-group>` is for a choice whose options are data — it renders real inputs, posts in a plain form and paints its own segments — and stays the first thing to reach for; a selection group governs buttons the caller writes, manages state and shape, and leaves each button to draw its own colours from its own `:selected`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
177 lines
8.0 KiB
PHP
177 lines
8.0 KiB
PHP
<?php
|
|
|
|
use Livewire\Component;
|
|
use Livewire\Livewire;
|
|
|
|
it('groups buttons with the spacing of their size', function () {
|
|
expect((string) $this->blade('<x-button-group label="Views" size="md"><x-button label="Day" /></x-button-group>'))
|
|
->toContain('role="group"')
|
|
->toContain('aria-label="Views"')
|
|
->toContain('data-button-group="standard"')
|
|
->toContain('data-size="md"')
|
|
->toContain('gap-2')
|
|
->and((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
|
|
->toContain('gap-3');
|
|
});
|
|
|
|
it('connects buttons 2px apart', function () {
|
|
expect((string) $this->blade('<x-button-group connected><x-button label="Day" /></x-button-group>'))
|
|
->toContain('data-button-group="connected"')
|
|
->toContain('gap-0.5');
|
|
});
|
|
|
|
it('squares a group, and a choice drawn as one', 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>'))
|
|
->toContain('data-shape="square"')
|
|
->and((string) $this->blade('<x-button-group connected shape="square"><x-button label="Day" /></x-button-group>'))
|
|
->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"');
|
|
});
|
|
|
|
it('takes over aria-pressed for a group that carries a selection', function () {
|
|
$html = (string) $this->blade('<x-button-group connected selection="single" required label="View"><x-button label="Day" value="day" :selected="true" /></x-button-group>');
|
|
|
|
expect($html)
|
|
->toContain('data-selection="single"')
|
|
->toContain('data-selection-required')
|
|
->toContain('multiple: false')
|
|
->toContain('required: true')
|
|
->toContain('value: null')
|
|
->toContain('x-on:click="press($event)"')
|
|
// No model: the group takes the state from the buttons' own aria-pressed and goes on.
|
|
->toContain('x-modelable="value"')
|
|
->and((string) $this->blade('<x-button-group connected selection="multi"><x-button label="Bold" value="bold" :selected="false" /></x-button-group>'))
|
|
->toContain('multiple: true')
|
|
->toContain('required: false')
|
|
->toContain('value: []')
|
|
->and((string) $this->blade('<x-button-group connected><x-button label="Day" /></x-button-group>'))
|
|
->not->toContain('data-selection')
|
|
->not->toContain('press($event)');
|
|
});
|
|
|
|
it('entangles a selection group with the property it binds, and keeps wire:model off the div', function () {
|
|
$component = new class extends Component
|
|
{
|
|
public string $view = 'week';
|
|
|
|
public function render(): string
|
|
{
|
|
return <<<'BLADE'
|
|
<div>
|
|
<x-button-group connected selection="single" required wire:model.live="view" label="View">
|
|
<x-button label="Day" value="day" :selected="$view === 'day'" />
|
|
<x-button label="Week" value="week" :selected="$view === 'week'" />
|
|
</x-button-group>
|
|
</div>
|
|
BLADE;
|
|
}
|
|
};
|
|
|
|
$html = Livewire::test($component)->assertSet('view', 'week')->html();
|
|
|
|
expect($html)
|
|
->toContain('data-selection="single"')
|
|
->toMatch('/value: window\.Livewire\.find\(/')
|
|
->and(substr_count($html, 'wire:model.live="view"'))->toBe(0);
|
|
});
|
|
|
|
it('never wraps a group onto a second line', function () {
|
|
expect((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
|
|
->not->toContain('flex-wrap');
|
|
});
|
|
|
|
it('marks icon buttons, which keep their width when a group is pressed', function () {
|
|
expect((string) $this->blade('<x-button icon="format_bold" aria-label="Bold" />'))->toContain('data-icon-button')
|
|
->and((string) $this->blade('<x-button label="Bold" />'))->not->toContain('data-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(&)]: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');
|
|
});
|