Files
livewire-material/tests/Browser/FieldsTest.php
T
surtic86andClaude Opus 5 ebdc2ef2e1
tests / feature (8.4) (push) Successful in 1m50s
tests / feature (8.5) (push) Successful in 1m54s
tests / browser (chrome, chromium) (push) Successful in 7m52s
tests / browser (firefox, firefox) (push) Successful in 11m56s
tests / browser (safari, webkit) (push) Successful in 12m35s
Press once whatever a second press would open over or undo
The browser plugin runs every call on a page again when its first
attempt takes over a second, and on the runner a press can. For most
presses that costs nothing. For two kinds it breaks the test: a press
that opens a dialog, a sheet, a full-screen view or a modal rail over
its own trigger, whose second press can never land, and a press that
changes state a second one would change again — a menu trigger, a
toggle, a chip, a range picker's day, a paging key, a Save. The bottom
sheet with preset heights timed out on WebKit that way after the date
picker had on Firefox.

Every such press in the browser suite now goes through pressOnce(), 253
of them across sixteen files, not only the ones the runner happened to
catch; focus moves inside an open view, Escape, links and plain "set"
actions keep the retry, which is harmless for them.

Two samples that were still racing the machine:

- The standard side sheet's exit is caught half-way with its motion
  stretched, as every other mid-exit sample is, and fullSpeed() takes
  the stretch off again before the test times a reopen against the real
  exit. Under load on Linux WebKit it had failed two runs in five; it
  passes ten in ten.
- The switch-and-checkbox row measures its widths once, so it now waits
  for the resize to land and the brand face to load before it does.

Browser 300 passed on Firefox and WebKitGTK in a Linux container held to
two busy cores, and on Chrome, Firefox and WebKit on macOS. Feature 1159
passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:15:25 +02:00

605 lines
29 KiB
PHP

<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\MessageBag;
use Illuminate\Support\ViewErrorBag;
use Livewire\Component;
use Livewire\Livewire;
class FieldProbe extends Component
{
public string $name = '';
public string $hours = '24';
/** @var list<string> */
public array $files = [];
public string $audience = 'anyone';
public bool $public = false;
public string $message = '';
public function save(): void
{
$this->validate(['name' => 'required']);
}
public function render(): string
{
return <<<'BLADE'
<div style="display: grid; max-width: 448px; gap: var(--md-sys-measurement-space200); padding: var(--md-sys-measurement-space200);">
<p>name: <span id="name">{{ $name }}</span></p>
<p>hours: <span id="hours">{{ $hours }}</span></p>
<p>files: <span id="files">{{ implode(',', $files) }}</span></p>
<p>audience: <span id="audience">{{ $audience }}</span></p>
<p>public: <span id="public-state">{{ var_export($public, true) }}</span></p>
<x-input id="name-field" label="Share name" wire:model.live="name" clearable copyable />
<x-password id="password-field" label="Password" />
<x-select id="hours-field" label="Expires" wire:model.live="hours" :options="[['id' => '1', 'name' => '1 hour'], ['id' => '24', 'name' => '1 day']]" />
<x-checkbox id="all" label="All files" :indeterminate="count($files) > 0 && count($files) < 3" />
@foreach (['a', 'b', 'c'] as $file)
<x-checkbox id="file-{{ $file }}" :label="'File '.$file" value="{{ $file }}" wire:model.live="files" wire:key="file-{{ $file }}" />
@endforeach
<x-radio label="Audience" wire:model.live="audience" :options="[['id' => 'anyone', 'name' => 'Anyone'], ['id' => 'team', 'name' => 'Team']]" />
<x-toggle id="public" label="Public" wire:model.live="public" />
<x-textarea id="message" label="Message" rows="2" max-rows="4" wire:model="message" />
<x-input id="bio-field" label="Bio" maxlength="10" counter />
<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>
BLADE;
}
}
function fieldProbe()
{
Livewire::component('field-probe', FieldProbe::class);
Route::middleware('web')->get('/field-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<livewire:field-probe />
<x-toast />
@livewireScripts
</body>
</html>
BLADE));
return ready(visit('/field-probe'));
}
it('floats the label while the field has focus or holds a value', function () {
$label = "getComputedStyle(document.querySelector('label[for=\"name-field\"]')).fontSize";
$page = fieldProbe()->assertScript("{$label} === '16px'");
$page->click('#name-field')->assertScript("{$label} === '12px'");
$page->type('#name-field', 'Holiday')
->assertSeeIn('#name', 'Holiday');
$page->click('#password-field')->assertScript("{$label} === '12px'");
});
it('clears a field and tells Livewire', function () {
$page = fieldProbe()
->type('#name-field', 'Holiday')
->assertSeeIn('#name', 'Holiday');
// The clear button vanishes once its field is empty: pressOnce(), tests/Pest.php.
pressOnce($page)->click('[data-md-field-clear]');
$page->assertScript("document.querySelector('#name-field').value === ''")
->assertScript("document.querySelector('#name').textContent === ''")
->assertScript("document.activeElement.id === 'name-field'");
});
it('copies a field\'s value and says so', function () {
$page = fieldProbe()->type('#name-field', 'Holiday');
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
$page->click('[data-md-field-copy]')
->assertScript("window.copied === 'Holiday'")
->assertSee('Copied to the clipboard');
});
it('shows and hides a password', function () {
$page = fieldProbe()
->type('#password-field', 'secret');
// The reveal button toggles on every press: pressOnce(), tests/Pest.php.
pressOnce($page)->click('[data-md-field-reveal]');
$page->assertScript("document.querySelector('#password-field').type === 'text'")
->assertAttribute('[data-md-field-reveal]', 'aria-label', 'Hide password');
pressOnce($page)->click('[data-md-field-reveal]');
$page->assertScript("document.querySelector('#password-field').type === 'password'");
});
it('shows the server\'s error in place of the hint', function () {
fieldProbe()
->click('button:has-text("Save")')
->assertSee('The name field is required.')
->assertAttribute('#name-field', 'aria-invalid', 'true')
->assertScript("document.querySelector('#name-field').closest('[data-md-field]').hasAttribute('data-md-invalid')");
});
it('binds a select to Livewire', function () {
fieldProbe()
->select('#hours-field', '1')
->assertSeeIn('#hours', '1');
});
it('opens the customizable select picker as M3\'s menu, where the browser supports it', function () {
$select = "document.querySelector('#hours-field')";
$option = "{$select}.querySelector('option')";
$page = fieldProbe();
// menu.css only styles the picker inside @supports (appearance: base-select). A browser without
// it (Firefox) keeps its own native picker, which is the fallback, and there is no M3 menu to open.
if ($page->script("CSS.supports('appearance', 'base-select')") !== true) {
$page->assertScript("getComputedStyle({$select}).appearance !== 'base-select'");
return;
}
$page->assertScript("getComputedStyle({$select}).appearance === 'base-select'");
// Opens the customizable select's list over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#hours-field');
$page->assertScript("{$select}.matches(':open')")
// M3's menu row height (menu-item.css's 48px), not the browser's own tiny option row.
->assertScript("parseFloat(getComputedStyle({$option}).minBlockSize) === 48");
});
class SelectValueProbe extends Component
{
public string $format = 'dots';
public function render(): string
{
return <<<'BLADE'
<div style="display: grid; width: 240px; gap: var(--md-sys-measurement-space300); padding: var(--md-sys-measurement-space200);">
<p>format: <span id="format">{{ $format }}</span></p>
@php($formats = [['id' => 'dots', 'name' => 'Day first, dots — 17.08.2026'], ['id' => 'slashes', 'name' => 'Month first, slashes — 08/17/2026']])
<x-select id="format-md" label="Date format" wire:model.live="format" :options="$formats" />
<x-select id="format-sm" size="sm" aria-label="Date format" :options="$formats" />
<x-select id="format-xs" size="xs" aria-label="Date format" :options="$formats" />
<x-select id="format-filled" variant="filled" label="Date format" :options="$formats" />
<x-select id="format-icon" icon="calendar_today" label="Date format" :options="$formats" />
</div>
BLADE;
}
}
/**
* Where a select's value ends against its arrow, and how it is cut: `[ends before the arrow,
* text-overflow, white-space, overflow, the value's text]`. The customizable select draws the value
* in its `<selectedcontent>`; the native one in the select's own box, less its end padding.
*/
function selectValue(string $id): string
{
return "(() => {
const select = document.getElementById('{$id}');
const arrow = select.closest('[data-md-field]').querySelector('[data-md-field-arrow]').getBoundingClientRect();
const value = CSS.supports('appearance', 'base-select') ? select.querySelector('selectedcontent') : select;
if (! value) {
return 'nothing draws the value';
}
const style = getComputedStyle(value);
const end = value.getBoundingClientRect().right - parseFloat(style.paddingRight) - parseFloat(style.borderRightWidth);
return [end <= arrow.left, style.textOverflow, style.whiteSpace, style.overflowX, (value === select ? select.selectedOptions[0] : value).textContent.trim()];
})()";
}
it('ends a select\'s value before its arrow with an ellipsis, at every size and after a render', function () {
Livewire::component('select-value-probe', SelectValueProbe::class);
Route::middleware('web')->get('/select-value-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<livewire:select-value-probe />
@livewireScripts
</body>
</html>
BLADE));
$page = ready(visit('/select-value-probe'));
foreach (['format-md', 'format-sm', 'format-xs', 'format-filled', 'format-icon'] as $id) {
expect($page->script(selectValue($id)))->toBe([true, 'ellipsis', 'nowrap', 'hidden', 'Day first, dots — 17.08.2026']);
}
// The customizable select copies the chosen option into its <selectedcontent>, which a
// Livewire render must not empty again.
$page->select('#format-md', 'slashes')->assertSeeIn('#format', 'slashes');
expect($page->script(selectValue('format-md')))->toBe([true, 'ellipsis', 'nowrap', 'hidden', 'Month first, slashes — 08/17/2026']);
});
it('keeps a partly ticked checkbox in step with the server', function () {
$all = "document.querySelector('#all')";
$page = fieldProbe()->assertScript("! {$all}.indeterminate");
// A checkbox toggles on every press: pressOnce(), tests/Pest.php.
pressOnce($page)->click('label[for="file-a"]');
$page->assertSeeIn('#files', 'a')
->assertScript("{$all}.hasAttribute('data-md-indeterminate') && {$all}.indeterminate");
pressOnce($page)->click('label[for="file-b"]')
->click('label[for="file-c"]');
$page->assertSeeIn('#files', 'a,b,c')
->assertScript("! {$all}.hasAttribute('data-md-indeterminate') && ! {$all}.indeterminate");
});
it('moves between radio buttons with the arrow keys', function () {
$page = fieldProbe();
// A two-option radio group: a second press of the same arrow cycles back: pressOnce(),
// tests/Pest.php.
pressOnce($page)->keys('input[type="radio"][value="anyone"]', 'ArrowDown');
$page->assertSeeIn('#audience', 'team')
->assertScript("document.querySelector('input[type=\"radio\"][value=\"team\"]').checked");
});
it('turns a switch on from the keyboard and from its label', function () {
$page = fieldProbe();
// Space toggles a switch on every press: pressOnce(), tests/Pest.php.
pressOnce($page)->keys('#public', 'Space');
$page->assertSeeIn('#public-state', 'true');
// A switch's label toggles it, same as the switch itself: pressOnce(), tests/Pest.php.
pressOnce($page)->click('label[for="public"]');
$page->assertSeeIn('#public-state', 'false')
->assertAttribute('#public', 'role', 'switch');
});
it('grows a textarea with its text up to its maximum', function () {
$height = "document.querySelector('#message').getBoundingClientRect().height";
$page = fieldProbe();
$rows = $page->script("({$height})");
$page->type('#message', "one\ntwo\nthree")
->assertScript("({$height}) > {$rows}");
$three = $page->script("({$height})");
$page->type('#message', "one\ntwo\nthree\nfour\nfive\nsix\nseven")
->assertScript("Math.abs(({$height}) - ({$three} + 24)) < 2")
->assertScript("document.querySelector('#message').scrollHeight > document.querySelector('#message').clientHeight");
});
it('pairs the server\'s error with a trailing error icon in the error colour', function () {
$icon = "document.querySelector('[data-md-field]:has(#plain-name-field) [data-md-field-error]')";
fieldProbe()
->assertScript("{$icon} === null")
->click('button:has-text("Save")')
->assertSee('The name field is required.')
// A field that trails with buttons of its own gives the icon's place to them.
->assertScript("document.querySelector('[data-md-field]:has(#name-field)').hasAttribute('data-md-invalid') && document.querySelector('[data-md-field]:has(#name-field) [data-md-field-error]') === null")
->assertScript("{$icon}.getAttribute('aria-label') === 'Error' && {$icon}.getAttribute('role') === 'img'")
->assertScript("{$icon}.getBoundingClientRect().width === 24")
->assertScript("getComputedStyle({$icon}).color === getComputedStyle(document.querySelector('[data-md-field]:has(#plain-name-field) [data-md-field-support]')).color");
});
it('lights an enabled field\'s outline on hover, and never a disabled one\'s', function () {
$edge = fn (string $id): string => "getComputedStyle(document.querySelector('[data-md-field]:has(#{$id}) [data-md-field-outline]')).borderTopColor";
$page = fieldProbe();
$resting = $page->script("({$edge('name-field')})");
$disabled = $page->script("({$edge('off-field')})");
$page->hover('[data-md-field]:has(#name-field) [data-md-field-box]')
->assertScript("({$edge('name-field')}) !== '{$resting}'");
$page->hover('[data-md-field]:has(#off-field) [data-md-field-box]')
->assertScript("({$edge('off-field')}) === '{$disabled}'")
// The disabled edge is on-surface at 12%, which is translucent.
->assertScript("/rgba?\\(.*,\\s*0?\\.\\d+\\)|color\\(srgb .* \\/ 0?\\.\\d+\\)/.test('{$disabled}')");
});
/**
* An outlined, a filled and an invalid field and a select, beside the three roles their edges take,
* with nothing that moves them after the first paint.
*/
function fieldStateProbe(): mixed
{
Route::middleware('web')->get('/field-state-probe', function () {
view()->share('errors', (new ViewErrorBag)->put('default', new MessageBag(['broken' => ['Say what it is.'], 'broken-choice' => ['Choose one.']])));
return Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<div style="display: grid; max-width: 448px; gap: var(--md-sys-measurement-space300); padding: var(--md-sys-measurement-space200);">
<p id="primary" style="color: var(--md-sys-color-primary);">primary</p>
<p id="on-surface" style="color: var(--md-sys-color-on-surface);">on-surface</p>
<p id="error" style="color: var(--md-sys-color-error);">error</p>
<x-input id="outlined-field" label="Outlined" />
<x-input id="filled-field" label="Filled" variant="filled" />
<x-input id="invalid-field" label="Invalid" name="broken" />
<x-select id="select-field" label="Expires" :options="[['id' => '1', 'name' => '1 hour'], ['id' => '24', 'name' => '1 day']]" />
<x-select id="invalid-select-field" label="Invalid expiry" name="broken-choice" :options="[['id' => '1', 'name' => '1 hour'], ['id' => '24', 'name' => '1 day']]" />
</div>
@livewireScripts
</body>
</html>
BLADE);
});
return ready(visit('/field-state-probe'), livewire: false);
}
/** A field's edge, "width colour", once its colour transition has run: `Top` outlined, `Bottom` filled. */
function settledEdge(string $id, string $side = 'Top'): string
{
return "(async () => { const outline = document.querySelector('[data-md-field]:has(#{$id}) [data-md-field-outline]'); await Promise.allSettled(outline.getAnimations().map((animation) => animation.finished)); const style = getComputedStyle(outline); return style.border{$side}Width + ' ' + style.border{$side}Color })()";
}
it('keeps a focused field\'s focus edge under the pointer, as M3 layers focus over hover', function () {
$page = fieldStateProbe();
$role = fn (string $id): string => $page->script("getComputedStyle(document.getElementById('{$id}')).color");
// Hovered alone, the edge still answers the pointer.
$page->hover('[data-md-field]:has(#outlined-field) [data-md-field-box]');
expect($page->script(settledEdge('outlined-field')))->toBe('1px '.$role('on-surface'));
// A click focuses the field and leaves the pointer over it.
$page->click('#outlined-field');
expect($page->script(settledEdge('outlined-field')))->toBe('2px '.$role('primary'));
$page->click('#filled-field');
expect($page->script(settledEdge('filled-field', 'Bottom')))->toBe('2px '.$role('primary'));
$page->click('#invalid-field');
expect($page->script(settledEdge('invalid-field')))->toBe('2px '.$role('error'));
// A customizable select reads as focused while its menu is open, though focus is then on an
// option in the top layer: in primary under the pointer, and in error when the field is.
if ($page->script("CSS.supports('appearance', 'base-select')") === true) {
// Opens the customizable select's list over the field that triggers it: pressOnce(),
// tests/Pest.php.
pressOnce($page)->click('#select-field');
$page->assertScript("document.getElementById('select-field').matches(':open')");
expect($page->script(settledEdge('select-field')))->toBe('2px '.$role('primary'));
$page->keys('#select-field', 'Escape')->assertScript("! document.getElementById('select-field').matches(':open')");
pressOnce($page)->click('#invalid-select-field');
$page->assertScript("document.getElementById('invalid-select-field').matches(':open')");
expect($page->script(settledEdge('invalid-select-field')))->toBe('2px '.$role('error'));
}
});
it('counts the characters as they are typed, marks a count past the maximum and says it once typing stops', function () {
$counter = "document.querySelector('[data-md-field]:has(#bio-field) [data-md-field-counter]')";
$page = fieldProbe()
->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '0/10'")
->type('#bio-field', 'Hello')
->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '5/10'")
->assertScript("! {$counter}.hasAttribute('data-md-over')");
$within = $page->script("getComputedStyle({$counter}).color");
// maxlength stops typing at the maximum, but a value put in from elsewhere can pass it.
$page->script("(() => { const input = document.querySelector('#bio-field'); input.value = 'Hello there!'; input.dispatchEvent(new Event('input', { bubbles: true })); })()");
$page->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '12/10'")
->assertScript("{$counter}.hasAttribute('data-md-over')")
->assertScript("getComputedStyle({$counter}).color !== '{$within}'")
->assertScript("{$counter}.querySelector('[aria-live]').textContent === ''")
->wait(1.3)
->assertScript("{$counter}.querySelector('[aria-live=\\'polite\\']').textContent === 'Character count, 12/10'");
});
function fieldWidthProbe(int $width)
{
Route::middleware('web')->get('/field-width-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
<style>.probe-narrow { max-width: 200px; }</style>
</head>
<body>
<div style="width: 1400px">
<x-input id="bounded" label="Bounded" />
<x-input id="narrow" label="Narrow" class="probe-narrow" />
<x-input id="full" label="Full" full />
<x-choices id="choices-bounded" label="Time zone" searchable :options="[['id' => 'Europe/Zurich', 'name' => 'Zurich']]" />
<x-choices id="choices-full" label="Time zone" searchable full :options="[['id' => 'Europe/Zurich', 'name' => 'Zurich']]" />
<x-datepicker id="date-bounded" label="Starts" />
<x-datepicker id="date-full" label="Starts" full />
<x-timepicker id="time-bounded" label="At" />
<x-timepicker id="time-full" label="At" full />
</div>
</body>
</html>
BLADE));
return visit('/field-width-probe')->resize($width, 800)->waitForEvent('networkidle');
}
it('bounds a field to 40rem from 600px, unless the caller\'s width rule or full says otherwise', function () {
$width = fn (string $id): string => "document.querySelector('[data-md-field]:has(#{$id})').getBoundingClientRect().width";
fieldWidthProbe(599)
->assertScript("({$width('bounded')}) === 1400")
->assertScript("({$width('narrow')}) === 200")
->assertScript("({$width('full')}) === 1400");
$page = fieldWidthProbe(600)
->assertScript("({$width('bounded')}) === 640")
->assertScript("({$width('narrow')}) === 200")
->assertScript("({$width('full')}) === 1400");
// The fields the searchable choice and the pickers draw take `full` as the others do.
foreach (['choices', 'date', 'time'] as $field) {
expect($page->script($width("{$field}-bounded")))->toBe(640)
->and($page->script($width("{$field}-full")))->toBe(1400);
}
});
it('draws the field at M3\'s geometry and the file picker\'s button as a tonal pill', function () {
$box = fn (string $id): string => "document.querySelector('[data-md-field]:has(#{$id}) [data-md-field-box]').getBoundingClientRect()";
fieldProbe()
->assertScript("({$box('name-field')}).height === 56")
// The control starts after the box's 16px padding.
->assertScript("document.querySelector('#name-field').getBoundingClientRect().left - ({$box('name-field')}).left === 16")
->assertScript("getComputedStyle(document.querySelector('#upload-field'), '::file-selector-button').height === '32px'")
->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'));
});
it('draws the switch\'s handle at SwitchTokens\' sizes and centres, off and on', function () {
$handle = "(() => { const track = document.querySelector('[data-md-switch]:has(#public)').getBoundingClientRect(); const handle = document.querySelector('[data-md-switch]:has(#public) [data-md-switch-handle]').getBoundingClientRect(); return [track.width, track.height, handle.width, Math.round(handle.left + handle.width / 2 - track.left), Math.round(handle.top + handle.height / 2 - track.top)].join(); })()";
$page = fieldProbe()->assertScript("{$handle} === '52,32,16,16,16'");
// Space toggles a switch on every press: pressOnce(), tests/Pest.php.
pressOnce($page)->keys('#public', 'Space');
$page->assertSeeIn('#public-state', 'true')
->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>
<link rel="icon" href="data:,">
<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 = ready(visit('/selection-row-probe')->resize(393, 800), alpine: false, livewire: false)
// Measured once, so measured only when the layout is final: the resize has landed and the
// brand face has replaced its fallback, either of which moves every width below.
->assertScript("window.innerWidth === 393 && document.fonts.status === 'loaded'")
->assertScript(settled('document.documentElement', subtree: true));
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();
});