Merge branch 'worktree-agent-a41c756369d016a29'
This commit is contained in:
+115
-5
@@ -87,7 +87,7 @@ function filterInput(string $value): string
|
||||
}
|
||||
|
||||
it('toggles a filter chip with a click and with Space, and grows its check in', function () {
|
||||
$check = fn (string $value): string => filterInput($value).".parentElement.querySelector('[data-chip-check]').parentElement.getBoundingClientRect().width";
|
||||
$check = fn (string $value): string => filterInput($value).".parentElement.querySelector('[data-md-chip-check]').parentElement.getBoundingClientRect().width";
|
||||
|
||||
$page = chipShowcase()->assertNoJavaScriptErrors();
|
||||
|
||||
@@ -159,17 +159,127 @@ it('removes an Alpine input chip with Backspace, focusing the chip before it', f
|
||||
|
||||
it('scrolls a chip set sideways, fading the edge it can still scroll towards', function () {
|
||||
// The scrolling row, inside the component that also holds its scroll buttons.
|
||||
$row = "document.querySelector('#chips [x-data=\"materialChipSet\"] > [data-chip-set]')";
|
||||
$row = "document.querySelector('#chips [data-md-chip-set-scroller] > [data-md-chip-set-row]')";
|
||||
|
||||
$page = chipShowcase();
|
||||
|
||||
$page->assertScript("{$row}.scrollWidth > {$row}.clientWidth")
|
||||
->assertScript("{$row}.hasAttribute('data-scroll-end') && ! {$row}.hasAttribute('data-scroll-start')")
|
||||
->assertScript("{$row}.hasAttribute('data-md-scroll-end') && ! {$row}.hasAttribute('data-md-scroll-start')")
|
||||
->assertScript("getComputedStyle({$row}).flexWrap === 'nowrap'");
|
||||
|
||||
$page->script("{$row}.querySelector('input[value=\"unopened\"]').focus()");
|
||||
|
||||
$page->assertScript("Math.abs({$row}.scrollLeft) > 0")
|
||||
->assertScript("{$row}.hasAttribute('data-scroll-start')")
|
||||
->assertScript("getComputedStyle({$row}).getPropertyValue('--chip-fade-start').trim() === '1.5rem'");
|
||||
->assertScript("{$row}.hasAttribute('data-md-scroll-start')")
|
||||
->assertScript("getComputedStyle({$row}).getPropertyValue('--chip-fade-start').trim() === '24px'");
|
||||
});
|
||||
|
||||
it('draws the chip 32px tall and catches presses over 48px, the chip and its remove button alike', function () {
|
||||
$page = chipProbe();
|
||||
|
||||
$page->assertScript("document.querySelector('[data-md-chip=\"input\"]').getBoundingClientRect().height === 32")
|
||||
// The remove button is an 18px icon whose target reaches 15px past it on every side.
|
||||
->assertScript("(() => {
|
||||
const button = document.querySelector('button[aria-label=\"Remove css\"]');
|
||||
const box = button.getBoundingClientRect();
|
||||
const after = getComputedStyle(button, '::after');
|
||||
|
||||
return box.width === 18 && box.height === 18 && after.position === 'absolute' && after.top === '-15px' && after.left === '-15px';
|
||||
})()");
|
||||
|
||||
// A press 22px out from the icon's centre, over the chip's 48px strip, still removes the chip.
|
||||
$page->script("(() => {
|
||||
const button = document.querySelector('button[aria-label=\"Remove css\"]');
|
||||
const box = button.getBoundingClientRect();
|
||||
|
||||
document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2 + 22).click();
|
||||
})()");
|
||||
|
||||
$page->assertSeeIn('#tags', 'php,js')
|
||||
->assertScript("document.querySelector('button[aria-label=\"Remove css\"]') === null");
|
||||
});
|
||||
|
||||
it('draws a selected filter chip without its outline, and puts an unselected one\'s label 16px in', function () {
|
||||
$input = fn (string $value): string => "document.querySelector('input[value=\"{$value}\"]')";
|
||||
|
||||
$page = chipProbe();
|
||||
|
||||
$page->assertScript("getComputedStyle({$input('photos')}.parentElement).borderTopColor === 'rgba(0, 0, 0, 0)'")
|
||||
->assertScript("getComputedStyle({$input('documents')}.parentElement).borderTopWidth === '1px'")
|
||||
->assertScript("getComputedStyle({$input('documents')}.parentElement).borderTopColor !== 'rgba(0, 0, 0, 0)'")
|
||||
// 1px border, 7px padding, the empty check's 8px margin: Compose's 16px before the label.
|
||||
->assertScript("Math.round({$input('documents')}.parentElement.querySelector('[data-md-chip-label]').getBoundingClientRect().left - {$input('documents')}.parentElement.getBoundingClientRect().left) === 16");
|
||||
});
|
||||
|
||||
function chipScrollProbe(string $dir)
|
||||
{
|
||||
Route::middleware('web')->get('/chip-scroll-probe', fn () => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html dir="{{ request('dir') }}">
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body>
|
||||
<div style="width: 320px; padding: 24px">
|
||||
<x-chip-set aria-label="Sort" scroll>
|
||||
@foreach (['Newest', 'Oldest', 'Largest', 'Smallest', 'Shared', 'Starred', 'Unopened', 'Expired'] as $sort)
|
||||
<x-chip type="filter" :label="$sort" :value="$sort" name="sort[]" />
|
||||
@endforeach
|
||||
</x-chip-set>
|
||||
</div>
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
return visit("/chip-scroll-probe?dir={$dir}")->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
|
||||
}
|
||||
|
||||
it('shows a scroll button only on the edge the row can still scroll towards, in either direction', function (string $dir) {
|
||||
$row = "document.querySelector('[data-md-chip-set-row]')";
|
||||
$shown = fn (string $edge): string => "getComputedStyle(document.querySelector('[data-md-chip-scroll=\"{$edge}\"]')).display === 'grid'";
|
||||
|
||||
$page = chipScrollProbe($dir)
|
||||
->assertScript("{$row}.scrollWidth > {$row}.clientWidth")
|
||||
->assertScript("! ({$shown('start')}) && ({$shown('end')})");
|
||||
|
||||
$page->click('[data-md-chip-scroll="end"]')
|
||||
->wait(0.8)
|
||||
->assertScript("Math.abs({$row}.scrollLeft) > 0")
|
||||
->assertScript("({$shown('start')})");
|
||||
|
||||
$page->script("{$row}.scrollTo({ left: ({$row}.scrollWidth) * (getComputedStyle({$row}).direction === 'rtl' ? -1 : 1), behavior: 'instant' })");
|
||||
|
||||
$page->wait(0.2)
|
||||
->assertScript("({$shown('start')}) && ! ({$shown('end')})")
|
||||
// The end button sits over the end edge: on the right in LTR, on the left in RTL.
|
||||
->assertScript("(() => { const start = document.querySelector('[data-md-chip-scroll=\"start\"]').getBoundingClientRect(); const box = {$row}.parentElement.getBoundingClientRect(); return '{$dir}' === 'rtl' ? Math.abs(start.right - box.right) < 1 : Math.abs(start.left - box.left) < 1; })()");
|
||||
})->with(['ltr', 'rtl']);
|
||||
|
||||
it('walks a chip set with the arrow keys as one tab stop, and scrolls the focused chip clear of the buttons', function () {
|
||||
$inputs = "[...document.querySelectorAll('[data-md-chip-set-row] input')]";
|
||||
|
||||
$page = chipScrollProbe('ltr')
|
||||
->assertScript("{$inputs}.filter((input) => input.tabIndex === 0).length === 1");
|
||||
|
||||
$page->script("{$inputs}[0].focus()");
|
||||
|
||||
$page->keys(':focus', 'ArrowRight')
|
||||
->assertScript("document.activeElement === {$inputs}[1]")
|
||||
->assertScript("{$inputs}.filter((input) => input.tabIndex === 0).map((input) => input.value).join() === 'Oldest'");
|
||||
|
||||
$page->keys(':focus', 'End')
|
||||
->assertScript("document.activeElement === {$inputs}.at(-1)")
|
||||
->wait(0.8)
|
||||
// The focused chip stands clear of the start button and its fade.
|
||||
->assertScript("(() => { const chip = document.activeElement.closest('[data-md-chip]').getBoundingClientRect(); const button = document.querySelector('[data-md-chip-scroll=\"start\"]').getBoundingClientRect(); return getComputedStyle(document.querySelector('[data-md-chip-scroll=\"start\"]')).display === 'grid' && chip.left >= button.right; })()");
|
||||
|
||||
$page->keys(':focus', 'Home')
|
||||
->assertScript("document.activeElement === {$inputs}[0]");
|
||||
|
||||
$page->keys(':focus', 'ArrowLeft')
|
||||
->assertScript("document.activeElement === {$inputs}.at(-1)");
|
||||
});
|
||||
|
||||
@@ -48,6 +48,13 @@ function dataProbe()
|
||||
</x-table>
|
||||
|
||||
{{ $files->links() }}
|
||||
|
||||
<x-table dense id="dense-table">
|
||||
<tbody>
|
||||
<tr><td>dense-a.zip</td><td>1</td></tr>
|
||||
<tr aria-selected="true"><td>dense-b.zip</td><td>2</td></tr>
|
||||
</tbody>
|
||||
</x-table>
|
||||
</div>
|
||||
BLADE);
|
||||
app('view')->addNamespace('probe', $views);
|
||||
@@ -103,3 +110,19 @@ it('pages through Livewire results and marks the current page', function () {
|
||||
$page->resize(400, 800)
|
||||
->assertSee('Page 2 of 3');
|
||||
});
|
||||
|
||||
it('draws 52px rows, and 36px ones only in a table that asks to be dense', function () {
|
||||
$row = fn (string $table): string => "document.querySelector('{$table} tbody tr').getBoundingClientRect().height";
|
||||
|
||||
dataProbe()
|
||||
->assertScript("Math.abs(({$row('[data-md-table]:not([data-md-dense])')}) - 52) <= 1")
|
||||
->assertScript("Math.abs(({$row('#dense-table')}) - 36) <= 1")
|
||||
->assertScript("getComputedStyle(document.querySelector('#dense-table tr[aria-selected=\"true\"]')).backgroundColor !== 'rgba(0, 0, 0, 0)'")
|
||||
->assertScript("getComputedStyle(document.querySelector('[data-md-table] thead th')).borderBottomWidth === '1px'");
|
||||
});
|
||||
|
||||
it('lets the sort button be pressed anywhere in its 48px target', function () {
|
||||
dataProbe()
|
||||
->assertScript("(() => { const button = document.querySelector('#by-size [data-md-sort-header-button]'); const after = getComputedStyle(button, '::after'); return button.getBoundingClientRect().height < 48 && after.minHeight === '48px' && after.minWidth === '48px'; })()")
|
||||
->assertScript("(() => { const button = document.querySelector('#by-size [data-md-sort-header-button]'); const box = button.getBoundingClientRect(); const probe = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2 - 22); return probe === button; })()");
|
||||
});
|
||||
|
||||
@@ -183,7 +183,7 @@ it('opens the docked picker under its field and walks the grid from the keyboard
|
||||
->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]')
|
||||
->assertScript("{$picker}.matches(':popover-open') && {$picker}.dataset.presentation === 'docked'")
|
||||
->assertAttribute('#expires-field', 'aria-expanded', 'true')
|
||||
->assertScript("(() => { const field = document.querySelector('#expires-field').closest('.field-box').getBoundingClientRect(); const box = {$picker}.getBoundingClientRect(); return Math.abs(box.top - field.bottom - 4) < 2 && Math.abs(box.left - field.left) < 2; })()")
|
||||
->assertScript("(() => { const field = document.querySelector('#expires-field').closest('[data-md-field-box]').getBoundingClientRect(); const box = {$picker}.getBoundingClientRect(); return Math.abs(box.top - field.bottom - 4) < 2 && Math.abs(box.left - field.left) < 2; })()")
|
||||
->assertScript(focusedDay('2026-09-13'));
|
||||
|
||||
$page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-09-14'));
|
||||
@@ -395,15 +395,15 @@ it('opens a docked picker as a modal one on a compact window', function () {
|
||||
|
||||
it('empties a date, or both ends of a range, with its clear button', function () {
|
||||
$page = dateProbe()
|
||||
->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('.field').querySelector('[data-field-clear]')).display !== 'none'");
|
||||
->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('[data-md-field]').querySelector('[data-md-field-clear]')).display !== 'none'");
|
||||
|
||||
$page->click('[data-datepicker]:has(#expires-field) [data-field-clear]')
|
||||
$page->click('[data-datepicker]:has(#expires-field) [data-md-field-clear]')
|
||||
->assertScript("document.querySelector('#expires').textContent === ''")
|
||||
->assertValue('#expires-field', '')
|
||||
->assertScript("document.activeElement.id === 'expires-field'")
|
||||
->assertScript("getComputedStyle(document.querySelector('[data-datepicker]:has(#expires-field) [data-field-clear]')).display === 'none'");
|
||||
->assertScript("getComputedStyle(document.querySelector('[data-datepicker]:has(#expires-field) [data-md-field-clear]')).display === 'none'");
|
||||
|
||||
$page->click('[data-datepicker]:has(#trip-field) [data-field-clear]')
|
||||
$page->click('[data-datepicker]:has(#trip-field) [data-md-field-clear]')
|
||||
->assertSeeIn('#trip', '{"start":null,"end":null}')
|
||||
->assertValue('#trip-field', '');
|
||||
});
|
||||
|
||||
@@ -47,6 +47,15 @@ class FieldProbe extends Component
|
||||
<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>
|
||||
@@ -95,7 +104,7 @@ it('clears a field and tells Livewire', function () {
|
||||
fieldProbe()
|
||||
->type('#name-field', 'Holiday')
|
||||
->assertSeeIn('#name', 'Holiday')
|
||||
->click('[data-field-clear]')
|
||||
->click('[data-md-field-clear]')
|
||||
->assertScript("document.querySelector('#name-field').value === ''")
|
||||
->assertScript("document.querySelector('#name').textContent === ''")
|
||||
->assertScript("document.activeElement.id === 'name-field'");
|
||||
@@ -106,7 +115,7 @@ it('copies a field\'s value and says so', function () {
|
||||
|
||||
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
|
||||
|
||||
$page->click('[data-field-copy]')
|
||||
$page->click('[data-md-field-copy]')
|
||||
->assertScript("window.copied === 'Holiday'")
|
||||
->assertSee('Copied to the clipboard');
|
||||
});
|
||||
@@ -114,10 +123,10 @@ it('copies a field\'s value and says so', function () {
|
||||
it('shows and hides a password', function () {
|
||||
fieldProbe()
|
||||
->type('#password-field', 'secret')
|
||||
->click('[data-field-reveal]')
|
||||
->click('[data-md-field-reveal]')
|
||||
->assertScript("document.querySelector('#password-field').type === 'text'")
|
||||
->assertAttribute('[data-field-reveal]', 'aria-label', 'Hide password')
|
||||
->click('[data-field-reveal]')
|
||||
->assertAttribute('[data-md-field-reveal]', 'aria-label', 'Hide password')
|
||||
->click('[data-md-field-reveal]')
|
||||
->assertScript("document.querySelector('#password-field').type === 'password'");
|
||||
});
|
||||
|
||||
@@ -126,7 +135,7 @@ it('shows the server\'s error in place of the hint', function () {
|
||||
->click('button:has-text("Save")')
|
||||
->assertSee('The name field is required.')
|
||||
->assertAttribute('#name-field', 'aria-invalid', 'true')
|
||||
->assertScript("document.querySelector('#name-field').closest('.field').hasAttribute('data-invalid')");
|
||||
->assertScript("document.querySelector('#name-field').closest('[data-md-field]').hasAttribute('data-md-invalid')");
|
||||
});
|
||||
|
||||
it('binds a select to Livewire', function () {
|
||||
@@ -142,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 () {
|
||||
@@ -183,3 +192,134 @@ it('grows a textarea with its text up to its maximum', function () {
|
||||
->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}')");
|
||||
});
|
||||
|
||||
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>
|
||||
<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 />
|
||||
</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");
|
||||
|
||||
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'");
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ class PickProbe extends Component
|
||||
|
||||
public string $query = '';
|
||||
|
||||
public string $iconQuery = '';
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
@@ -35,6 +37,20 @@ class PickProbe extends Component
|
||||
<x-slot:empty>No files match.</x-slot:empty>
|
||||
</x-search>
|
||||
|
||||
<div id="toolbar" style="display: flex; align-items: center; gap: 8px">
|
||||
<span>Files</span>
|
||||
<x-search id="find-icon" trigger="icon" label="Find files" wire:model.live="iconQuery">
|
||||
@foreach (array_filter(['holiday.zip', 'contract.pdf'], fn ($file) => str_contains($file, $iconQuery)) as $file)
|
||||
<button type="button" wire:key="icon-result-{{ $file }}">{{ $file }}</button>
|
||||
@endforeach
|
||||
<x-slot:suggestions>
|
||||
<button type="button">Recent: holiday.zip</button>
|
||||
<button type="button">Recent: review.mp4</button>
|
||||
<button type="button">Recent: notes.txt</button>
|
||||
</x-slot:suggestions>
|
||||
</x-search>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
@@ -149,7 +165,7 @@ it('says so when nothing matches, and closes on a press outside or a chosen resu
|
||||
|
||||
// The docked view opens over a scrim that covers the rest of the page (inputs.md IN-09), so a
|
||||
// press outside the view lands on the scrim.
|
||||
$page->click('[data-search-scrim]')
|
||||
$page->click('[data-md-search]:has(#find) [data-md-search-scrim]')
|
||||
->assertScript("getComputedStyle({$view}).display === 'none'");
|
||||
|
||||
$page->click('#find')
|
||||
@@ -163,8 +179,72 @@ it('takes the whole screen on a compact window, with a back arrow', function ()
|
||||
$page = pickProbe()->resize(400, 800);
|
||||
|
||||
$page->click('#find')
|
||||
->assertScript("document.querySelector('[data-search]').hasAttribute('data-full-screen')")
|
||||
->assertScript("document.querySelector('[data-md-search]').hasAttribute('data-md-full-screen')")
|
||||
->assertScript("(() => { const box = document.querySelector('#find-view').getBoundingClientRect(); return box.top === 0 && box.width === 400; })()")
|
||||
->click('[data-search-back]')
|
||||
->assertScript("! document.querySelector('[data-search]').hasAttribute('data-open')");
|
||||
->click('[data-md-search]:has(#find) [data-md-search-back]')
|
||||
->assertScript("! document.querySelector('[data-md-search]').hasAttribute('data-md-open')");
|
||||
});
|
||||
|
||||
it('says how many results there are, politely, as they change', function () {
|
||||
$status = "document.querySelector('[data-md-search]:has(#find) [data-md-search-status]')";
|
||||
|
||||
$page = pickProbe()
|
||||
->assertScript("{$status}.getAttribute('aria-live') === 'polite' && {$status}.getAttribute('aria-atomic') === 'true'")
|
||||
->click('#find')
|
||||
->assertScript("{$status}.textContent === '3 results'");
|
||||
|
||||
$page->type('#find', 'con')
|
||||
->assertSeeIn('#query', 'con')
|
||||
->assertScript("{$status}.textContent === '1 result'");
|
||||
|
||||
$page->type('#find', 'zzz')
|
||||
->assertSeeIn('#query', 'zzz')
|
||||
->assertScript("{$status}.textContent === 'No results'");
|
||||
|
||||
// Closed from a result (Escape in the field itself would first clear the query), the region
|
||||
// falls silent.
|
||||
$page->type('#find', 'hol')
|
||||
->assertSeeIn('#query', 'hol')
|
||||
->assertScript("{$status}.textContent === '1 result'");
|
||||
|
||||
$page->keys('#find', 'ArrowDown')
|
||||
->assertScript("document.activeElement.textContent.trim() === 'holiday.zip'");
|
||||
|
||||
$page->keys(':focus', 'Escape')
|
||||
->assertScript("{$status}.textContent === ''");
|
||||
});
|
||||
|
||||
it('expands the search icon into the full-screen view, and hands focus back to the icon on close', function () {
|
||||
$root = "document.querySelector('[data-md-search]:has(#find-icon)')";
|
||||
$trigger = "{$root}.querySelector('[data-md-search-trigger]')";
|
||||
|
||||
$page = pickProbe()
|
||||
->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-bar]')).display === 'none'")
|
||||
->assertScript("{$root}.getBoundingClientRect().width === 48 && {$trigger}.getAttribute('aria-expanded') === 'false'");
|
||||
|
||||
$page->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]')
|
||||
->assertScript("{$root}.hasAttribute('data-md-open') && {$root}.hasAttribute('data-md-full-screen')")
|
||||
->assertScript("document.activeElement.id === 'find-icon'")
|
||||
->assertScript("(() => { const view = {$root}.querySelector('[data-md-search-view]').getBoundingClientRect(); return view.top === 0 && view.left === 0 && view.width === innerWidth; })()")
|
||||
// The toolbar does not shift under the expanded search.
|
||||
->assertScript("{$root}.getBoundingClientRect().width === 48");
|
||||
|
||||
$page->keys('#find-icon', 'Escape')
|
||||
->assertScript("! {$root}.hasAttribute('data-md-open')")
|
||||
->assertScript("document.activeElement === {$trigger}")
|
||||
->assertScript("getComputedStyle({$trigger}).display !== 'none'");
|
||||
});
|
||||
|
||||
it('shows the suggestions until the first key, then the results, and counts whichever is on screen', function () {
|
||||
$root = "document.querySelector('[data-md-search]:has(#find-icon)')";
|
||||
$shown = fn (string $list): string => "{$root}.querySelector('[data-md-search-{$list}]').checkVisibility()";
|
||||
|
||||
$page = pickProbe()
|
||||
->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]')
|
||||
->assertScript("({$shown('suggestions')}) && ! ({$shown('results')})")
|
||||
->assertScript("{$root}.querySelector('[data-md-search-status]').textContent === '3 suggestions'");
|
||||
|
||||
$page->type('#find-icon', 'h')
|
||||
->assertScript("! ({$shown('suggestions')}) && ({$shown('results')})")
|
||||
->assertScript("{$root}.querySelector('[data-md-search-status]').textContent === '1 result'");
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ it('finds a component from anywhere and opens its section', function () {
|
||||
->assertScript("document.activeElement.id === 'showcase-search'");
|
||||
|
||||
$page->type('#showcase-search', 'datepicker')
|
||||
->assertSeeIn('[data-search-view]', '<x-datepicker>');
|
||||
->assertSeeIn('[data-md-search-view]', '<x-datepicker>');
|
||||
|
||||
$page->keys('#showcase-search', 'Enter')
|
||||
->assertScript("location.pathname.endsWith('/material/pickers')")
|
||||
@@ -51,5 +51,5 @@ it('says so when nothing matches', function () {
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
|
||||
->click('#showcase-search')
|
||||
->type('#showcase-search', 'zzzz')
|
||||
->assertSeeIn('[data-search-view]', 'Nothing matches');
|
||||
->assertSeeIn('[data-md-search-view]', 'Nothing matches');
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ function onSlider(string $label, string $body): string
|
||||
{
|
||||
return <<<JS
|
||||
(async () => {
|
||||
const root = [...document.querySelectorAll('[data-slider]')]
|
||||
const root = [...document.querySelectorAll('[data-md-slider-control]')]
|
||||
.find((slider) => slider.parentElement.querySelector(':scope > label, :scope > span')?.textContent.trim() === '{$label}')
|
||||
root.scrollIntoView({ block: 'center' })
|
||||
const inputs = [...root.querySelectorAll('input[type="range"]')]
|
||||
@@ -84,7 +84,7 @@ function onSlider(string $label, string $body): string
|
||||
const near = (a, b, tolerance = 1) => Math.abs(a - b) <= tolerance
|
||||
const left = (selector) => Number.parseFloat(root.querySelector(selector).style.left)
|
||||
const segment = (name) => {
|
||||
const element = root.querySelector(`[data-segment="\${name}"]`)
|
||||
const element = root.querySelector(`[data-md-slider-segment="\${name}"]`)
|
||||
const from = Number.parseFloat(element.style.left)
|
||||
return element.hidden ? null : { from, to: from + Number.parseFloat(element.style.width) }
|
||||
}
|
||||
@@ -106,18 +106,18 @@ it('moves with the keyboard, and the drawing follows', function () {
|
||||
$page->keys(':focus', ['ArrowRight', 'ArrowRight'])
|
||||
->assertScript(onSlider('Volume', <<<'JS'
|
||||
return inputs[0].value === '42'
|
||||
&& near(left('[data-handle="start"]'), width * 0.42)
|
||||
&& near(left('[data-md-slider-handle="start"]'), width * 0.42)
|
||||
&& near(segment('active').to, width * 0.42 - 8)
|
||||
&& near(segment('end').from, width * 0.42 + 8)
|
||||
&& root.querySelector('[data-handle="start"]').hasAttribute('data-focused')
|
||||
&& root.querySelector('[data-value-label]').textContent === '42'
|
||||
&& root.querySelector('[data-md-slider-handle="start"]').hasAttribute('data-md-focused')
|
||||
&& root.querySelector('[data-md-slider-value]').textContent === '42'
|
||||
JS));
|
||||
|
||||
$page->keys(':focus', 'PageUp')
|
||||
->assertScript(onSlider('Volume', "return inputs[0].value === '52' && near(left('[data-handle=\"start\"]'), width * 0.52)"));
|
||||
->assertScript(onSlider('Volume', "return inputs[0].value === '52' && near(left('[data-md-slider-handle=\"start\"]'), width * 0.52)"));
|
||||
|
||||
$page->keys(':focus', 'End')
|
||||
->assertScript(onSlider('Volume', "return inputs[0].value === '100' && segment('end') === null && ! root.querySelector('[data-stop=\"end\"]').checkVisibility()"));
|
||||
->assertScript(onSlider('Volume', "return inputs[0].value === '100' && segment('end') === null && ! root.querySelector('[data-md-slider-stop=\"end\"]').checkVisibility()"));
|
||||
|
||||
$page->keys(':focus', 'Home')
|
||||
->assertScript(onSlider('Volume', "return inputs[0].value === '0' && segment('active') === null && near(segment('end').from, 8)"));
|
||||
@@ -130,14 +130,14 @@ it('follows a pointer drag with a narrowed handle and its value label', function
|
||||
|
||||
$page->assertScript(onSlider('Volume', <<<'JS'
|
||||
await pause(450)
|
||||
const thumb = root.querySelector('[data-handle="start"]')
|
||||
const label = thumb.querySelector('[data-value-label]')
|
||||
const thumb = root.querySelector('[data-md-slider-handle="start"]')
|
||||
const label = thumb.querySelector('[data-md-slider-value]')
|
||||
return inputs[0].value === '75'
|
||||
&& label.textContent === '75'
|
||||
&& getComputedStyle(label).opacity === '1'
|
||||
&& thumb.hasAttribute('data-pressed')
|
||||
&& thumb.hasAttribute('data-md-pressed')
|
||||
&& thumb.firstElementChild.offsetWidth === 2
|
||||
&& near(left('[data-handle="start"]'), width * 0.75)
|
||||
&& near(left('[data-md-slider-handle="start"]'), width * 0.75)
|
||||
&& document.activeElement === inputs[0]
|
||||
JS));
|
||||
|
||||
@@ -145,13 +145,13 @@ it('follows a pointer drag with a narrowed handle and its value label', function
|
||||
|
||||
$page->assertScript(onSlider('Volume', <<<'JS'
|
||||
await pause(450)
|
||||
const thumb = root.querySelector('[data-handle="start"]')
|
||||
const thumb = root.querySelector('[data-md-slider-handle="start"]')
|
||||
return inputs[0].value === '75'
|
||||
&& window.__changed === 1
|
||||
&& ! thumb.hasAttribute('data-pressed')
|
||||
&& ! thumb.hasAttribute('data-focused')
|
||||
&& ! thumb.hasAttribute('data-md-pressed')
|
||||
&& ! thumb.hasAttribute('data-md-focused')
|
||||
&& thumb.firstElementChild.offsetWidth === 4
|
||||
&& getComputedStyle(thumb.querySelector('[data-value-label]')).opacity === '0'
|
||||
&& getComputedStyle(thumb.querySelector('[data-md-slider-value]')).opacity === '0'
|
||||
JS));
|
||||
});
|
||||
|
||||
@@ -163,7 +163,7 @@ it('keeps a range\'s handles from crossing, by pointer and by keyboard', functio
|
||||
|
||||
$page->assertScript(onSlider('Price', <<<'JS'
|
||||
return inputs[0].value === '60' && inputs[1].value === '60'
|
||||
&& near(left('[data-handle="start"]'), left('[data-handle="end"]'))
|
||||
&& near(left('[data-md-slider-handle="start"]'), left('[data-md-slider-handle="end"]'))
|
||||
&& segment('active') === null
|
||||
JS));
|
||||
|
||||
@@ -183,7 +183,7 @@ it('fills a centred slider from the middle', function () {
|
||||
$page = sliderShowcase();
|
||||
|
||||
$page->assertScript(onSlider('Balance', <<<'JS'
|
||||
const handle = left('[data-handle="start"]')
|
||||
const handle = left('[data-md-slider-handle="start"]')
|
||||
return inputs[0].value === '15'
|
||||
&& near(handle, width * 0.65)
|
||||
&& near(segment('start').to, width / 2 - 6)
|
||||
@@ -209,7 +209,7 @@ it('follows a value x-model sets from outside', function () {
|
||||
|
||||
$page->script("[...document.querySelectorAll('#sliders button')].find((button) => button.textContent.trim() === 'Set 80').click()");
|
||||
|
||||
$page->assertScript(onSlider('Bound volume', "return inputs[0].value === '80' && near(left('[data-handle=\"start\"]'), width * 0.8)"));
|
||||
$page->assertScript(onSlider('Bound volume', "return inputs[0].value === '80' && near(left('[data-md-slider-handle=\"start\"]'), width * 0.8)"));
|
||||
});
|
||||
|
||||
it('sends live values to Livewire, is not moved back by a render mid-drag, and moves when the server sets a value', function () {
|
||||
@@ -237,7 +237,7 @@ it('sends live values to Livewire, is not moved back by a render mid-drag, and m
|
||||
|
||||
// Both round trips back: the handle is where it was dragged, and so is the property.
|
||||
$page->assertScript(onSlider('Volume', <<<'JS'
|
||||
return window.__renders >= 2 && inputs[0].value === '70' && near(left('[data-handle="start"]'), width * 0.7)
|
||||
return window.__renders >= 2 && inputs[0].value === '70' && near(left('[data-md-slider-handle="start"]'), width * 0.7)
|
||||
JS))
|
||||
->assertSeeIn('#volume', '70');
|
||||
|
||||
@@ -245,10 +245,89 @@ it('sends live values to Livewire, is not moved back by a render mid-drag, and m
|
||||
|
||||
$page->click('button:has-text("Maximise")')
|
||||
->assertSeeIn('#volume', '90')
|
||||
->assertScript(onSlider('Volume', "return inputs[0].value === '90' && near(left('[data-handle=\"start\"]'), width * 0.9) && root.querySelector('[data-value-label]').textContent === '90'"));
|
||||
->assertScript(onSlider('Volume', "return inputs[0].value === '90' && near(left('[data-md-slider-handle=\"start\"]'), width * 0.9) && root.querySelector('[data-md-slider-value]').textContent === '90'"));
|
||||
|
||||
$page->script(onSlider('Price', 'inputs[1].focus()'));
|
||||
|
||||
$page->keys(':focus', 'ArrowLeft')
|
||||
->assertSeeIn('#price', '20,79');
|
||||
});
|
||||
|
||||
it('centres the handle on the track at every size', function () {
|
||||
$page = sliderShowcase();
|
||||
|
||||
foreach (['Volume', 'Small (24px)', 'Medium (40px)', 'Extra large (96px)'] as $label) {
|
||||
$page->assertScript(onSlider($label, <<<'JS'
|
||||
const thumb = root.querySelector('[data-md-slider-thumb]').getBoundingClientRect()
|
||||
const track = root.querySelector('[data-md-slider-track]').getBoundingClientRect()
|
||||
const box = root.getBoundingClientRect()
|
||||
|
||||
return near(thumb.top + thumb.height / 2, track.top + track.height / 2)
|
||||
&& near(track.top + track.height / 2, box.top + box.height / 2)
|
||||
&& thumb.height === box.height - (box.height === 48 ? 4 : 0)
|
||||
JS));
|
||||
}
|
||||
});
|
||||
|
||||
it('moves by the large interval with an arrow while Space is held', function () {
|
||||
$page = sliderShowcase();
|
||||
|
||||
$page->script(onSlider('Volume', "inputs[0].focus(); inputs[0].dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true, cancelable: true }))"));
|
||||
|
||||
$page->keys(':focus', 'ArrowRight')
|
||||
->assertScript(onSlider('Volume', "return inputs[0].value === '50' && near(left('[data-md-slider-handle=\"start\"]'), width * 0.5)"));
|
||||
|
||||
$page->script(onSlider('Volume', "inputs[0].dispatchEvent(new KeyboardEvent('keyup', { key: ' ', code: 'Space', bubbles: true }))"));
|
||||
|
||||
$page->keys(':focus', 'ArrowRight')
|
||||
->assertScript(onSlider('Volume', "return inputs[0].value === '51'"));
|
||||
});
|
||||
|
||||
it('stands a slider up: dragged along Y, moved by Up, Down, Home and End, its label beside the handle', function () {
|
||||
$page = sliderShowcase();
|
||||
|
||||
// Where the handle sits along a vertical slider, measured up from its bottom edge.
|
||||
$vertical = <<<'JS'
|
||||
const controls = [...document.querySelectorAll('[data-md-slider][data-md-orientation="vertical"] [data-md-slider-control]')]
|
||||
const slider = controls.find((control) => control.parentElement.querySelector(':scope > label')?.textContent.trim() === 'Warmth')
|
||||
slider.scrollIntoView({ block: 'center' })
|
||||
const input = slider.querySelector('input[type="range"]')
|
||||
const length = slider.clientHeight - 4
|
||||
const box = () => slider.getBoundingClientRect()
|
||||
const thumb = () => slider.querySelector('[data-md-slider-thumb]').getBoundingClientRect()
|
||||
const up = () => box().bottom - 2 - (thumb().top + thumb().height / 2)
|
||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const pointer = (type, fraction) => (type === 'pointerdown' ? slider : window).dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, clientX: box().left + box().width / 2, clientY: box().bottom - 2 - length * fraction, button: 0, pointerId: 1, pointerType: 'mouse' }))
|
||||
JS;
|
||||
|
||||
$page->assertScript("(async () => { {$vertical}; return input.value === '6' && input.getAttribute('aria-orientation') === 'vertical' && box().height > box().width })()");
|
||||
|
||||
// Warmth has ten steps on ticks, so a drag to 30% of the way up lands on 3.
|
||||
$page->script("(async () => { {$vertical}; pointer('pointerdown', 0.9); pointer('pointermove', 0.3) })()");
|
||||
|
||||
$page->assertScript("(async () => { {$vertical}; await pause(400);
|
||||
const label = slider.querySelector('[data-md-slider-value]').getBoundingClientRect()
|
||||
const handle = thumb()
|
||||
|
||||
return input.value === '3'
|
||||
&& slider.querySelector('[data-md-slider-value]').textContent === '3'
|
||||
// Beside the handle, not above it: level with it, and off to one side.
|
||||
&& Math.abs((label.top + label.height / 2) - (handle.top + handle.height / 2)) < 2
|
||||
&& (label.right <= handle.left || label.left >= handle.right)
|
||||
})()");
|
||||
|
||||
$page->script("(async () => { {$vertical}; pointer('pointerup', 0.3); input.focus() })()");
|
||||
|
||||
$page->keys(':focus', 'ArrowUp')
|
||||
->assertScript("(async () => { {$vertical}; await pause(400); return input.value === '4' })()");
|
||||
|
||||
$page->keys(':focus', 'ArrowDown')
|
||||
->keys(':focus', 'ArrowDown')
|
||||
->assertScript("(async () => { {$vertical}; return input.value === '2' })()");
|
||||
|
||||
$page->keys(':focus', 'End')
|
||||
->assertScript("(async () => { {$vertical}; await pause(400); return input.value === '10' && up() > length - 2 })()");
|
||||
|
||||
$page->keys(':focus', 'Home')
|
||||
->assertScript("(async () => { {$vertical}; await pause(400); return input.value === '0' && up() < 2 })()");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user