Draw the checkbox and the selection controls' row without Tailwind

<x-checkbox> renders data-md-checkbox, its box, tick and dash, and its
row, text and errors as data-md-* attributes; the 18px tick takes a size
prop and a label-less box md-touch-target (plan step 36). selection.css
moves into material.components as the row, text and state layer the
three controls share, and each control's drawing goes to its own file:
checkbox.css, radio.css, toggle.css, on the state tokens. The radio and
the switch take the renamed shared hooks now and lose their layout
classes in their own commits. field.js follows data-md-indeterminate.

Renaming the switch's data-handle also stops selection.css matching the
slider's handles, which it drew 24px above the track.

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:34:00 +02:00
co-authored by Claude Opus 5
parent 3083fe28b2
commit f86afc0542
12 changed files with 510 additions and 365 deletions
+25 -2
View File
@@ -51,6 +51,11 @@ class FieldProbe extends Component
<x-input id="off-field" label="Off" value="Not editable" disabled />
<x-input id="plain-name-field" label="Name, plain" wire:model="name" />
<x-file id="upload-field" label="Upload" />
<div style="display: flex; gap: 48px; padding: 24px">
<x-checkbox id="bare-check" aria-label="Select every row" />
<x-toggle id="bare-switch" aria-label="Bare switch" />
<x-radio name="bare-radio" :options="[['id' => 'only', 'name' => '']]" />
</div>
<x-button label="Save" wire:click="save" />
</div>
@@ -146,12 +151,12 @@ it('keeps a partly ticked checkbox in step with the server', function () {
$page->click('label[for="file-a"]')
->assertSeeIn('#files', 'a')
->assertScript("{$all}.hasAttribute('data-indeterminate') && {$all}.indeterminate");
->assertScript("{$all}.hasAttribute('data-md-indeterminate') && {$all}.indeterminate");
$page->click('label[for="file-b"]')
->click('label[for="file-c"]')
->assertSeeIn('#files', 'a,b,c')
->assertScript("! {$all}.hasAttribute('data-indeterminate') && ! {$all}.indeterminate");
->assertScript("! {$all}.hasAttribute('data-md-indeterminate') && ! {$all}.indeterminate");
});
it('moves between radio buttons with the arrow keys', function () {
@@ -289,3 +294,21 @@ it('draws the field at M3\'s geometry and the file picker\'s button as a tonal p
->assertScript("getComputedStyle(document.querySelector('#upload-field'), '::file-selector-button').borderTopLeftRadius !== '0px'")
->assertScript("getComputedStyle(document.querySelector('#upload-field')).color === 'rgba(0, 0, 0, 0)'");
});
it('lets a selection control without a label be pressed at the edge of its 48px target', function () {
// A press 22px from the centre, past the drawn box but inside M3's 48px, lands on the control.
$pressAtEdge = fn (string $input, string $dx, string $dy): string => "(() => {
const input = document.querySelector('{$input}');
input.scrollIntoView({ block: 'center' });
const box = input.parentElement.getBoundingClientRect();
const target = document.elementFromPoint(box.left + box.width / 2 + {$dx}, box.top + box.height / 2 + {$dy});
target.click();
return input.checked;
})()";
fieldProbe()
->assertScript($pressAtEdge('#bare-check', '22', '0'))
->assertScript($pressAtEdge('#bare-switch', '0', '22'))
->assertScript($pressAtEdge('input[name=\"bare-radio\"]', '0', '-22'));
});
+42 -24
View File
@@ -1,38 +1,40 @@
<?php
it('draws a checkbox whose row is its label', function () {
$html = (string) $this->blade('<x-checkbox id="notify" label="Notify me" hint="On every download" wire:model="notify" class="mt-2" />');
$html = (string) $this->blade('<x-checkbox id="notify" label="Notify me" hint="On every download" wire:model="notify" class="mt-2" style="order: 2" />');
expect($html)
->toContain('<div class="min-w-0 mt-2">')
->toContain('<label for="notify" data-selection')
->toContain('data-checkbox')
->toContain('<div data-md-checkbox class="mt-2" style="order: 2">')
->toMatch('/<label for="notify" data-md-selection-row\s*>/')
->toContain('<span data-md-checkbox-box >')
->toContain('type="checkbox"')
->toContain('wire:model="notify"')
->toContain('data-check')
->toContain('data-mixed')
->toContain('Notify me')
->toContain('On every download')
->not->toContain('data-indeterminate');
->toContain('data-md-checkbox-check')
->toContain('data-md-checkbox-mixed')
->toContain('<span data-md-selection-label>Notify me</span>')
->toContain('<span data-md-selection-hint>On every download</span>')
->not->toContain('data-md-indeterminate')
->not->toContain('min-w-0');
});
it('fills the box with an 18px tick from the 20 optical cut', function () {
expect((string) $this->blade('<x-checkbox label="Notify me" />'))
->toContain((string) $this->blade('<x-icon name="check" class="size-4.5" optical="20" data-check />'))
->toContain((string) $this->blade('<x-icon name="remove" class="size-4.5" optical="20" data-mixed />'));
->toContain((string) $this->blade('<x-icon name="check" size="18" data-md-checkbox-check />'))
->toContain((string) $this->blade('<x-icon name="remove" size="18" data-md-checkbox-mixed />'))
->toContain('--md-icon-size: 18px');
});
it('marks a checkbox that is partly ticked', function () {
expect((string) $this->blade('<x-checkbox label="Select all" indeterminate />'))->toContain('data-indeterminate');
expect((string) $this->blade('<x-checkbox label="Select all" indeterminate />'))->toContain('data-md-indeterminate');
});
it('gives a selection control without a label a 48px target', function () {
expect((string) $this->blade('<x-checkbox aria-label="Select all" />'))->toContain('touch-target')
expect((string) $this->blade('<x-checkbox aria-label="Select all" />'))->toMatch('/<span data-md-checkbox-box\s+class="md-touch-target"\s*>/')
->and((string) $this->blade('<x-toggle aria-label="Public link" />'))->toContain('touch-target')
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'\']]" />'))->toContain('touch-target');
// A labelled control is pressed anywhere along its row, so it needs no extra target.
expect((string) $this->blade('<x-checkbox label="Notify me" />'))->not->toContain('touch-target')
expect((string) $this->blade('<x-checkbox label="Notify me" />'))->not->toContain('md-touch-target')
->and((string) $this->blade('<x-toggle label="Public link" />'))->not->toContain('touch-target')
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))->not->toContain('touch-target');
});
@@ -52,7 +54,7 @@ it('leaves a checkbox group to the caller and says how M3 wants it laid out', fu
->toContain('class="grid gap-4 expanded:grid-cols-2"')
->toContain('Downloads')
->toContain('Comments')
->and(substr_count($html, 'data-checkbox'))->toBe(2);
->and(substr_count($html, 'data-md-checkbox-box'))->toBe(2);
// The rule is guidance, not markup, so the component's header has to carry it.
expect(file_get_contents(__DIR__.'/../../../resources/views/components/checkbox.blade.php'))
@@ -61,7 +63,8 @@ it('leaves a checkbox group to the caller and says how M3 wants it laid out', fu
});
it('puts a checkbox at the end of its row on request', function () {
expect((string) $this->blade('<x-checkbox label="Show" right />'))->toContain('flex-row-reverse justify-between');
expect((string) $this->blade('<x-checkbox label="Show" right />'))->toMatch('/<label for="check-[0-9a-f]{12}" data-md-selection-row\s+data-md-right\s*>/')
->and((string) $this->blade('<x-checkbox label="Show" />'))->not->toContain('data-md-right');
});
it('shows a checkbox\'s errors', function () {
@@ -69,7 +72,8 @@ it('shows a checkbox\'s errors', function () {
->blade('<x-checkbox id="terms" label="I accept the terms" wire:model="terms" />');
expect($html)
->toContain('data-invalid')
->toContain('data-md-invalid')
->toContain('<div id="terms-support" data-md-selection-support role="alert">')
->toContain('aria-invalid="true"')
->toContain('aria-describedby="terms-support"')
->toContain('Accept the terms to continue.');
@@ -108,7 +112,7 @@ it('names unbound radios after their group and checks the given value', function
it('shows the errors of a radio group', function () {
expect((string) $this->withViewErrors(['audience' => ['Choose who can open it.']])
->blade('<x-radio wire:model="audience" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
->toContain('data-invalid')
->toContain('data-md-invalid')
->toContain('Choose who can open it.');
});
@@ -116,17 +120,31 @@ it('draws a switch on a checkbox with the switch role', function () {
$html = (string) $this->blade('<x-toggle id="public" label="Public link" wire:model.live="public" right />');
expect($html)
->toContain('<label for="public" data-selection')
->toContain('data-switch')
->toContain('<label for="public" data-md-selection-row')
->toContain('data-md-switch')
->toContain('role="switch"')
->toContain('type="checkbox"')
->toContain('wire:model.live="public"')
->toContain('data-handle')
->toContain('data-md-switch-handle')
->toContain('flex-row-reverse')
->not->toContain('data-icons');
->not->toContain('data-md-icons');
});
it('puts icons on a switch\'s handle', function () {
expect((string) $this->blade('<x-toggle label="Wi-Fi" icons />'))->toContain('data-icons="both"')->toContain('data-on')->toContain('data-off')
->and((string) $this->blade('<x-toggle label="Wi-Fi" icons="selected" />'))->toContain('data-icons="selected"');
expect((string) $this->blade('<x-toggle label="Wi-Fi" icons />'))->toContain('data-md-icons="both"')->toContain('data-md-switch-on')->toContain('data-md-switch-off')
->and((string) $this->blade('<x-toggle label="Wi-Fi" icons="selected" />'))->toContain('data-md-icons="selected"');
});
it('draws the checkbox from its stylesheet, on the row the selection controls share', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/checkbox.css');
expect($css)->toContain("@import './selection.css';")
->toContain("@import './icon.css';")
->toContain('@layer material.components')
->toMatch('/\\[data-md-checkbox-box\\] \\{[^}]*width: 18px;[^}]*height: 18px;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/selection.css'))
->toContain('[data-md-selection-row]')
->toContain('@layer material.components')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/checkbox.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('selection.css');
});