*/
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'
name: {{ $name }}
hours: {{ $hours }}
files: {{ implode(',', $files) }}
audience: {{ $audience }}
public: {{ var_export($public, true) }}
@foreach (['a', 'b', 'c'] as $file)
@endforeach
BLADE;
}
}
function fieldProbe()
{
Livewire::component('field-probe', FieldProbe::class);
Route::middleware('web')->get('/field-probe', fn () => Blade::render(<<<'BLADE'
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
@livewireScripts
BLADE));
return visit('/field-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
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 () {
fieldProbe()
->type('#name-field', 'Holiday')
->assertSeeIn('#name', 'Holiday')
->click('[data-md-field-clear]')
->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 () {
fieldProbe()
->type('#password-field', 'secret')
->click('[data-md-field-reveal]')
->assertScript("document.querySelector('#password-field').type === 'text'")
->assertAttribute('[data-md-field-reveal]', 'aria-label', 'Hide password')
->click('[data-md-field-reveal]')
->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'");
$page->click('#hours-field')
->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'
format: {{ $format }}
@php($formats = [['id' => 'dots', 'name' => 'Day first, dots — 17.08.2026'], ['id' => 'slashes', 'name' => 'Month first, slashes — 08/17/2026']])
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 ``; 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'
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
@livewireScripts
BLADE));
$page = visit('/select-value-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
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 , 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");
$page->click('label[for="file-a"]')
->assertSeeIn('#files', 'a')
->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-md-indeterminate') && ! {$all}.indeterminate");
});
it('moves between radio buttons with the arrow keys', function () {
fieldProbe()
->keys('input[type="radio"][value="anyone"]', 'ArrowDown')
->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()
->keys('#public', 'Space')
->assertSeeIn('#public-state', 'true');
$page->click('label[for="public"]')
->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'
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
@livewireScripts
BLADE);
});
return visit('/field-state-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
}
/** 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) {
$page->click('#select-field')->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')");
$page->click('#invalid-select-field')->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'
@vite(config('livewire-material.showcase.vite'))
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");
fieldWidthProbe(600)
->assertScript("({$width('bounded')}) === 640")
->assertScript("({$width('narrow')}) === 200")
->assertScript("({$width('full')}) === 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'");
$page->keys('#public', 'Space')
->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'
@vite(config('livewire-material.showcase.vite'))
Drop the mark
Takes effect on the next sync: your calendar is rewritten with the new names, and a watch keeps the first characters of each.
Takes effect on the next sync: your calendar is rewritten with the new names, and a watch keeps the first characters of each.
Takes effect on the next sync: your calendar is rewritten with the new names, and a watch keeps the first characters of each.
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();
});