Keep a switch's track whole beside long text in a row

`[data-md-toggle] { min-width: 0 }` let the switch's root shrink below
its 52px track as a flex item. In `<x-row justify="between">` beside a
paragraph, flex shrinking weighs each item by its width, so the root of
a bare switch gave up a share of its 52px, and a switch with a long
label of its own gave its label a column one word wide and less; the
track, which does not shrink inside the root, ran out of it and past a
card's edge on a phone. `<x-checkbox>`'s root did the same with its box.

Both roots now keep their automatic minimum, the control and, beside a
label, its longest word, so the text beside the control, or the
control's own label, wraps instead. The toggle's docblock and the skill
say so.

A browser test lays a bare switch, a labelled switch and a bare
checkbox beside long text in rows on a 393px window and checks each
control's size and that it stays inside its row, and that the switch's
own label wraps inside its root; it fails without the change in Chrome,
Firefox and Safari.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 06:55:36 +02:00
co-authored by Claude Opus 5
parent 1885c3e54e
commit cf63f11bfb
6 changed files with 69 additions and 3 deletions
+6
View File
@@ -68,6 +68,12 @@
wire:ignore>` holding `<selectedcontent>`, the browser's copy of the chosen option, which
select.css cuts; a browser without the customizable select never renders it. A test that
matched a select's markup up to its first `<option>` finds the button there.
- **A switch beside long text in a row keeps its track.** `<x-toggle>`'s root could shrink to
nothing as a flex item, so beside a paragraph in `<x-row justify="between">`, or with a long
label of its own, the 52px track ran out of the row and past a card's edge on a phone.
`<x-checkbox>` did the same with its box. Both roots now keep their automatic minimum — the
control, and beside a label its longest word — so the text wraps instead. A `flex: none` an
application put on the switch to hold it can go.
## From 2.0.0 to 2.1.0
@@ -682,6 +682,7 @@ M3 selection controls on native inputs; the whole row is the label.
- `<x-checkbox label hint right indeterminate />``indeterminate` for a "select all" whose items are partly ticked (bind it to a server expression; it follows every render). Grouping is yours: from `expanded` (840px) M3 wants a set of related checkboxes gathered into a contained region rather than one long column, so wrap the set in `<x-grid :columns="['compact' => 1, 'expanded' => 2]" gap="space200">` (or a card or side sheet) under a heading that names what the group asks.
- `<x-radio label wire:model :options inline />` — options `['id', 'name', 'hint', 'disabled']` (`option-value`, `option-label`, `option-hint`); `value` checks an option without `wire:model`; `name` names an unbound group. M3 stacks radios and cautions against a row at any width, so reach for `inline` only for two or three short labels; it also wants five options or fewer and one of them chosen when the page loads.
- `<x-toggle label hint right icons />` — M3 switch (`role="switch"`); `icons` puts a check and a cross on the handle, `icons="selected"` only the check. Without `label`, pass `aria-label`.
- Beside text in a row — a setting's words and its switch in `<x-row justify="between">` — a switch keeps its 52px track and a checkbox its box: the text beside it wraps, and so does the control's own label.
```blade
<x-checkbox label="All files" :checked="count($selected) === $files->count()" :indeterminate="$selected && count($selected) < $files->count()" wire:click="toggleAll" />
+3 -1
View File
@@ -22,8 +22,10 @@
@import './icon.css';
@layer material.components {
/* In a flex row the root keeps its automatic minimum, the box and beside a label its longest
word, so the text beside it wraps before the box is pushed out of the row (toggle.css). */
[data-md-checkbox] {
min-width: 0;
min-width: auto;
}
[data-md-checkbox-box] {
+6 -1
View File
@@ -23,8 +23,13 @@
@import './icon.css';
@layer material.components {
/* In a flex row, a setting's text beside its switch, the root keeps its automatic minimum:
the track, and beside a label the track, the gap and the label's longest word. So the text
beside a bare switch, or the switch's own label, wraps before the track gives way; a root
free to shrink below 52px left the track running out of it, and off a card's edge on a
phone. */
[data-md-toggle] {
min-width: 0;
min-width: auto;
}
/* The positions below are the handle's centres less half its size, measured inside the track's
+3 -1
View File
@@ -8,7 +8,9 @@
(resources/css/components/toggle.css).
Without a label the row is only the 52×32 track, so the track carries `md-touch-target` and
catches presses over M3's 48px minimum. `class` and `style` land on the root, `data-md-toggle`. --}}
catches presses over M3's 48px minimum. Beside text in a row (`<x-row justify="between">`, a
setting's words and its switch) the track never shrinks: the text wraps, and so does the
switch's own label. `class` and `style` land on the root, `data-md-toggle`. --}}
@props([
'label' => null,
+50
View File
@@ -505,3 +505,53 @@ it('draws the switch\'s handle at SwitchTokens\' sizes and centres, off and on',
->wait(0.7)
->assertScript("{$handle} === '52,32,24,36,16'");
});
it('keeps a switch\'s track and a checkbox\'s box whole beside long text in a row, wrapping the text instead', function () {
Route::middleware('web')->get('/selection-row-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
</head>
<body style="background-color: var(--md-sys-color-surface);">
<x-surface outlined padding="space200" style="margin: var(--md-sys-measurement-space200);">
<x-row id="bare-switch-row" justify="between" gap="space200">
<x-stack gap="space50">
<div class="md-type-title-sm">Drop the mark</div>
<p class="md-type-body-sm md-ink-variant">Takes effect on the next sync: your calendar is rewritten with the new names, and a watch keeps the first characters of each.</p>
</x-stack>
<x-toggle id="row-bare-switch" aria-label="Drop the mark from exported workouts" />
</x-row>
<x-row id="labelled-switch-row" justify="between" gap="space200">
<p class="md-type-body-sm md-ink-variant">Takes effect on the next sync: your calendar is rewritten with the new names, and a watch keeps the first characters of each.</p>
<x-toggle id="row-labelled-switch" label="Notifications on this device whenever a session changes" />
</x-row>
<x-row id="bare-check-row" justify="between" gap="space200">
<p class="md-type-body-sm md-ink-variant">Takes effect on the next sync: your calendar is rewritten with the new names, and a watch keeps the first characters of each.</p>
<x-checkbox id="row-bare-check" aria-label="Drop the mark" />
</x-row>
</x-surface>
</body>
</html>
BLADE));
// [the control's drawn width, its end inside its row, the root's end inside its row].
$control = fn (string $input, string $drawing, string $root): string => "(() => {
const drawing = document.getElementById('{$input}').closest('{$drawing}').getBoundingClientRect();
const root = document.getElementById('{$input}').closest('{$root}').getBoundingClientRect();
const row = document.getElementById('{$input}').closest('[data-md-row]').getBoundingClientRect();
return [Math.round(drawing.width), drawing.right <= row.right + 0.5, root.right <= row.right + 0.5];
})()";
// A phone's window: the row is 329px across, less than the text beside each control would take.
$page = visit('/selection-row-probe')->resize(393, 800)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete'");
expect($page->script($control('row-bare-switch', '[data-md-switch]', '[data-md-toggle]')))->toBe([52, true, true])
->and($page->script($control('row-labelled-switch', '[data-md-switch]', '[data-md-toggle]')))->toBe([52, true, true])
->and($page->script($control('row-bare-check', '[data-md-checkbox-box]', '[data-md-checkbox]')))->toBe([18, true, true])
// The switch's own label wraps inside the switch's root rather than running past it.
->and($page->script("(() => { const label = document.querySelector('[data-md-toggle]:has(#row-labelled-switch) [data-md-selection-label]').getBoundingClientRect(); const root = document.querySelector('[data-md-toggle]:has(#row-labelled-switch)').getBoundingClientRect(); return label.right <= root.right + 0.5 && label.height > 24; })()"))->toBeTrue();
});