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 })()");
|
||||
});
|
||||
|
||||
@@ -16,42 +16,56 @@ function chipTag(string $blade, string $tag = '[a-z]+'): string
|
||||
it('is an outlined assist chip, a button, by default', function () {
|
||||
$html = (string) $this->blade('<x-chip label="Add to calendar" icon="event" icon-right="arrow_drop_down" />');
|
||||
|
||||
expect(chipTag('<x-chip label="Add to calendar" icon="event" />', 'button'))
|
||||
->toContain('data-chip="assist"')
|
||||
->toContain('type="button"')
|
||||
->toContain('h-8')
|
||||
->toContain('rounded-corner-sm')
|
||||
->toContain('type-label-lg')
|
||||
->toContain('border-outline-variant text-on-surface')
|
||||
->toContain('ps-1.75 pe-3.75')
|
||||
expect(chipTag('<x-chip label="Add to calendar" icon="event" class="me-2" />', 'button'))
|
||||
->toBe('<button data-md-chip="assist" data-md-icon-start="" type="button" class="me-2">')
|
||||
->and(chipTag('<x-chip label="Add to calendar" icon-right="arrow_drop_down" />', 'button'))
|
||||
->toContain('data-md-icon-end=""')
|
||||
->not->toContain('data-md-icon-start')
|
||||
->and($html)
|
||||
->toContain('me-2 size-4.5 text-primary')
|
||||
->toContain('ms-2 size-4.5 text-primary')
|
||||
->toContain('Add to calendar');
|
||||
->toMatch('/<svg(?=[^>]*--md-icon-size: 18px)(?=[^>]*data-md-chip-icon)[^>]*>/')
|
||||
->toMatch('/<svg(?=[^>]*--md-icon-size: 18px)(?=[^>]*data-md-chip-trailing)[^>]*>/')
|
||||
->toContain('<span data-md-chip-label>Add to calendar</span>')
|
||||
->not->toContain('class=');
|
||||
});
|
||||
|
||||
it('draws a chip at Compose\'s size, corner, type and padding from its stylesheet', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css');
|
||||
|
||||
expect($css)->toContain('@layer material.components')
|
||||
->toContain("@import './icon.css';")
|
||||
->toMatch('/\[data-md-chip\] \{[^}]*height: 32px;[^}]*border-radius: var\(--md-sys-shape-corner-sm\);[^}]*font: var\(--md-sys-typescale-label-lg\);/')
|
||||
// 16px beside a label and 8px beside an icon, each 1px short for the border.
|
||||
->toContain('padding-inline: 15px;')
|
||||
->toContain('padding-inline-start: 7px;')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/chip.css';");
|
||||
});
|
||||
|
||||
it('pads a chip without icons by 16px on both sides', function () {
|
||||
expect(chipTag('<x-chip label="Go" />', 'button'))->toContain('ps-3.75 pe-3.75');
|
||||
expect(chipTag('<x-chip label="Go" />', 'button'))
|
||||
->not->toContain('data-md-icon-start')
|
||||
->not->toContain('data-md-icon-end');
|
||||
});
|
||||
|
||||
it('falls back to an assist chip for a type it does not know', function () {
|
||||
expect(chipTag('<x-chip type="choice" label="Go" />', 'button'))->toContain('data-chip="assist"');
|
||||
expect(chipTag('<x-chip type="choice" label="Go" />', 'button'))->toContain('data-md-chip="assist"');
|
||||
});
|
||||
|
||||
it('draws an elevated chip on surface-container-low at elevation 1', function () {
|
||||
expect(chipTag('<x-chip label="Share" elevated />', 'button'))
|
||||
->toContain('border-transparent bg-surface-container-low text-on-surface shadow-elevation-1 hover:shadow-elevation-2')
|
||||
->not->toContain('border-outline-variant');
|
||||
expect(chipTag('<x-chip label="Share" elevated />', 'button'))->toContain('data-md-elevated=""')
|
||||
->and(chipTag('<x-chip label="Share" />', 'button'))->not->toContain('data-md-elevated')
|
||||
// Input chips are flat only.
|
||||
->and(chipTag('<x-chip type="input" label="Anna" elevated />', 'span'))->not->toContain('data-md-elevated')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css'))
|
||||
->toMatch('/\[data-md-chip\]\[data-md-elevated\] \{[^}]*background-color: var\(--md-sys-color-surface-container-low\);[^}]*box-shadow: var\(--md-sys-elevation-1\);/');
|
||||
});
|
||||
|
||||
it('greys a disabled chip, flat or elevated', function () {
|
||||
expect(chipTag('<x-chip label="Share" icon="share" disabled />', 'button'))
|
||||
->toContain('disabled')
|
||||
->toContain('border-on-surface/12 text-on-surface/38')
|
||||
->toContain('data-md-disabled=""')
|
||||
->toMatch('/\sdisabled="disabled"/')
|
||||
->and(chipTag('<x-chip label="Share" elevated disabled />', 'button'))
|
||||
->toContain('bg-on-surface/12 text-on-surface/38')
|
||||
->and((string) $this->blade('<x-chip label="Share" icon="share" disabled />'))
|
||||
->not->toContain('text-primary');
|
||||
->toContain('data-md-elevated=""')
|
||||
->toContain('data-md-disabled=""');
|
||||
});
|
||||
|
||||
it('links with wire:navigate, and a disabled link leaves the tab order', function () {
|
||||
@@ -66,15 +80,14 @@ it('links with wire:navigate, and a disabled link leaves the tab order', functio
|
||||
->and(chipTag('<x-chip label="Directions" link="/directions" disabled />', 'a'))
|
||||
->toContain('aria-disabled="true"')
|
||||
->toContain('tabindex="-1"')
|
||||
->toContain('pointer-events-none');
|
||||
->toContain('data-md-disabled=""');
|
||||
});
|
||||
|
||||
it('draws a suggestion chip in on-surface-variant', function () {
|
||||
expect(chipTag('<x-chip type="suggestion" label="Sounds good" />', 'button'))
|
||||
->toContain('data-chip="suggestion"')
|
||||
->toContain('border-outline-variant text-on-surface-variant')
|
||||
->toContain('data-md-chip="suggestion"')
|
||||
->and(chipTag('<x-chip type="suggestion" label="Sounds good" elevated />', 'button'))
|
||||
->toContain('bg-surface-container-low text-on-surface-variant shadow-elevation-1');
|
||||
->toContain('data-md-elevated=""');
|
||||
});
|
||||
|
||||
it('attaches a plain tooltip', function () {
|
||||
@@ -90,37 +103,31 @@ it('makes a filter chip without a model a toggle button the caller owns', functi
|
||||
$html = (string) $this->blade('<x-chip type="filter" label="Starred" :selected="true" wire:click="toggleStarred" />');
|
||||
|
||||
expect(chipTag('<x-chip type="filter" label="Starred" :selected="true" wire:click="toggleStarred" />', 'button'))
|
||||
->toContain('data-chip="filter"')
|
||||
->toContain('data-md-chip="filter"')
|
||||
->toContain('aria-pressed="true"')
|
||||
->toContain('wire:click="toggleStarred"')
|
||||
->toContain('aria-pressed:bg-secondary-container aria-pressed:text-on-secondary-container')
|
||||
->and(chipTag('<x-chip type="filter" label="Starred" />', 'button'))
|
||||
->toContain('aria-pressed="false"')
|
||||
->and($html)
|
||||
->not->toContain('type="checkbox"')
|
||||
->toContain('group-aria-pressed/chip:w-4.5')
|
||||
->toContain('data-chip-check');
|
||||
->toContain('<span data-md-chip-check-slot>')
|
||||
->toContain('data-md-chip-check');
|
||||
});
|
||||
|
||||
it('makes a bound filter chip a native checkbox under the chip', function () {
|
||||
$html = (string) $this->blade('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" wire:key="photos" />');
|
||||
|
||||
expect(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" wire:key="photos" />', 'label'))
|
||||
->toContain('data-chip="filter"')
|
||||
->toContain('me-4')
|
||||
->toContain('wire:key="photos"')
|
||||
->toContain('has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container')
|
||||
->toContain('has-focus-visible:outline-3')
|
||||
->not->toContain('x-model')
|
||||
->toBe('<label data-md-chip="filter" class="me-4" wire:key="photos">')
|
||||
->and(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" />', 'input'))
|
||||
->toContain('type="checkbox"')
|
||||
->toContain('value="photos"')
|
||||
->toContain('x-model="kinds"')
|
||||
->toContain('sr-only')
|
||||
->toContain('class="md-visually-hidden"')
|
||||
->not->toContain('me-4')
|
||||
->not->toContain('checked')
|
||||
->and($html)
|
||||
->toContain('group-has-checked/chip:w-4.5');
|
||||
->toContain('data-md-chip-check');
|
||||
});
|
||||
|
||||
it('submits a named filter chip, checked when selected, and greys a disabled one', function () {
|
||||
@@ -130,48 +137,47 @@ it('submits a named filter chip, checked when selected, and greys a disabled one
|
||||
->and(chipTag('<x-chip type="filter" label="Starred" name="starred" disabled />', 'input'))
|
||||
->toContain('disabled')
|
||||
->and(chipTag('<x-chip type="filter" label="Starred" name="starred" disabled />', 'label'))
|
||||
->toContain('border-on-surface/12 text-on-surface/38 has-checked:border-transparent has-checked:bg-on-surface/12')
|
||||
->toContain('cursor-not-allowed');
|
||||
->toContain('data-md-disabled=""');
|
||||
});
|
||||
|
||||
it('swaps a filter chip\'s icon for the check when selected', function () {
|
||||
$html = (string) $this->blade('<x-chip type="filter" label="Starred" icon="star" name="starred" elevated />');
|
||||
|
||||
expect($html)
|
||||
->toContain('group-has-checked/chip:opacity-0')
|
||||
->toContain('text-primary')
|
||||
->toContain('bg-surface-container-low text-on-surface-variant shadow-elevation-1')
|
||||
->not->toContain('w-0')
|
||||
->and(substr_count($html, '<svg'))->toBe(2);
|
||||
->toMatch('/<span data-md-chip-check-slot>\s*<svg[^>]*data-md-chip-icon[^>]*>.*?<\/svg>\s*<svg[^>]*data-md-chip-check/s')
|
||||
->toContain('data-md-elevated=""')
|
||||
->and(substr_count($html, '<svg'))->toBe(2)
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css'))
|
||||
->toContain("[data-md-chip]:is(:has(:checked), [aria-pressed='true']) [data-md-chip-check-slot] [data-md-chip-icon] {");
|
||||
});
|
||||
|
||||
it('draws an input chip with an avatar, an icon or neither', function () {
|
||||
expect(chipTag('<x-chip type="input" label="Anna" avatar="AM" />', 'span'))
|
||||
->toContain('data-chip="input"')
|
||||
->toContain('border-outline-variant text-on-surface-variant')
|
||||
->toContain('data-md-chip="input"')
|
||||
->toContain('data-md-avatar=""')
|
||||
->not->toContain('x-data')
|
||||
->and((string) $this->blade('<x-chip type="input" label="Anna" avatar="AM" />'))
|
||||
->toContain('ps-0.75 pe-2.75')
|
||||
->toContain('size-6 shrink-0 place-items-center rounded-corner-full bg-primary-container')
|
||||
->toContain('>AM</span>')
|
||||
->toContain('<span aria-hidden="true" data-md-chip-avatar>AM</span>')
|
||||
->not->toContain('<button')
|
||||
->and((string) $this->blade('<x-chip type="input" label="Anna" avatar="/avatars/anna.jpg" />'))
|
||||
->toContain('<img src="/avatars/anna.jpg" alt=""')
|
||||
->and((string) $this->blade('<x-chip type="input" label="report.pdf" icon="picture_as_pdf" />'))
|
||||
->toContain('ps-1.75 pe-2.75');
|
||||
->toContain('<img src="/avatars/anna.jpg" alt="" data-md-chip-avatar />')
|
||||
->and(chipTag('<x-chip type="input" label="report.pdf" icon="picture_as_pdf" />', 'span'))
|
||||
->toContain('data-md-icon-start=""')
|
||||
->not->toContain('data-md-avatar');
|
||||
});
|
||||
|
||||
it('makes an input chip a button when it has its own action, and selects it', function () {
|
||||
$html = (string) $this->blade('<x-chip type="input" label="Anna" icon="person" :selected="true" wire:click="open(1)" />');
|
||||
|
||||
expect(chipTag('<x-chip type="input" label="Anna" icon="person" :selected="true" wire:click="open(1)" />', 'button'))
|
||||
->toContain('data-chip-action')
|
||||
->toContain('data-md-chip-action')
|
||||
->toContain('aria-pressed="true"')
|
||||
->toContain('wire:click="open(1)"')
|
||||
->and(chipTag('<x-chip type="input" label="Anna" :selected="true" />', 'span'))
|
||||
->toContain('border-transparent bg-secondary-container text-on-secondary-container')
|
||||
->and($html)
|
||||
->toContain('me-2 size-4.5 text-primary');
|
||||
->toContain('data-md-selected=""')
|
||||
// An action of its own lights the leading icon on hover, focus and press.
|
||||
->and(chipTag('<x-chip type="input" label="Anna" icon="person" wire:click="open(1)" />', 'span'))
|
||||
->toContain('data-md-actionable=""')
|
||||
->and(chipTag('<x-chip type="input" label="Anna" icon="person" />', 'span'))
|
||||
->not->toContain('data-md-actionable');
|
||||
});
|
||||
|
||||
it('gives a removable input chip a named remove button that calls wire:remove', function () {
|
||||
@@ -180,15 +186,16 @@ it('gives a removable input chip a named remove button that calls wire:remove',
|
||||
expect(chipTag('<x-chip type="input" label="anna@example.com" removable wire:remove="removeRecipient(3)" />', 'span'))
|
||||
->toContain('x-data="materialChip"')
|
||||
->toContain('x-on:keydown="removeOnKey($event)"')
|
||||
->toContain('data-md-removable=""')
|
||||
->not->toContain('wire:remove')
|
||||
->and($html)
|
||||
->toContain('data-chip-remove aria-label="Remove anna@example.com"')
|
||||
->toContain('data-md-chip-remove aria-label="Remove anna@example.com"')
|
||||
->toContain('wire:click="removeRecipient(3)"')
|
||||
->toContain('x-on:click="removeChip($event)"')
|
||||
->toContain('<input type="hidden" name="to[]" value="3" />')
|
||||
->toContain('ps-2.75 pe-2')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css'))
|
||||
// 18px icon, 15px out on every side: M3's 48dp minimum for the close icon's target.
|
||||
->toContain('after:absolute after:-inset-3.75');
|
||||
->toMatch('/\[data-md-chip-remove\] \{.*?&::after \{\s*content: \'\';\s*position: absolute;\s*inset: -15px;/s');
|
||||
});
|
||||
|
||||
it('runs an Alpine remove expression, or takes the chip off the page with neither', function () {
|
||||
@@ -201,7 +208,7 @@ it('runs an Alpine remove expression, or takes the chip off the page with neithe
|
||||
|
||||
it('leaves a client-rendered chip\'s remove button to be named in the browser', function () {
|
||||
expect((string) $this->blade('<x-chip type="input" removable remove="tags.pop()"><span x-text="tag"></span></x-chip>'))
|
||||
->toContain('data-chip-remove="Remove :label"')
|
||||
->toContain('data-md-chip-remove="Remove :label"')
|
||||
->not->toContain('aria-label=');
|
||||
});
|
||||
|
||||
@@ -209,58 +216,72 @@ it('disables a removable input chip and its remove button', function () {
|
||||
$html = (string) $this->blade('<x-chip type="input" label="php" removable disabled />');
|
||||
|
||||
expect($html)
|
||||
->toContain('border-on-surface/12 text-on-surface/38')
|
||||
->toMatch('/<button[^>]*data-chip-remove[^>]*disabled/s');
|
||||
->toContain('data-md-disabled=""')
|
||||
->toMatch('/<button[^>]*data-md-chip-remove[^>]*disabled/s');
|
||||
});
|
||||
|
||||
it('groups chips in a wrapping row named by its label, with a hint', function () {
|
||||
$html = (string) $this->blade('<x-chip-set label="File types" hint="Show only these"><x-chip label="A" /></x-chip-set>');
|
||||
$html = (string) $this->blade('<x-chip-set label="File types" hint="Show only these" class="mt-4"><x-chip label="A" /></x-chip-set>');
|
||||
|
||||
preg_match('/aria-labelledby="([^"]+)"/', $html, $labelledBy);
|
||||
preg_match('/aria-describedby="([^"]+)"/', $html, $describedBy);
|
||||
|
||||
expect($html)
|
||||
->toContain('role="group"')
|
||||
->toContain('data-chip-set x-data="materialChipSet"')
|
||||
->toContain('flex flex-wrap gap-2')
|
||||
->toMatch('/<div\s+data-md-chip-set\s+role="group"/')
|
||||
->toContain('class="mt-4"')
|
||||
->toContain('<div data-md-chip-set-row x-data="materialChipSet"')
|
||||
// M3's chip keyboard: the set is one tab stop and the arrows walk it.
|
||||
->toContain('x-on:keydown="key($event)"')
|
||||
->toContain('x-on:focusin="rove($event.target)"')
|
||||
->toContain("id=\"{$labelledBy[1]}\" class=\"mb-2 type-label-lg text-on-surface-variant\">File types</p>")
|
||||
->toContain("id=\"{$describedBy[1]}\" class=\"mt-1 type-body-sm text-on-surface-variant\">Show only these</p>");
|
||||
->toContain("id=\"{$labelledBy[1]}\" data-md-chip-set-label>File types</p>")
|
||||
->toContain("id=\"{$describedBy[1]}\" data-md-chip-set-hint>Show only these</p>")
|
||||
->not->toContain('data-md-scroll')
|
||||
->not->toContain('flex-wrap');
|
||||
});
|
||||
|
||||
it('draws the set from its stylesheet: 8px between chips, wrapping unless it scrolls', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/chip-set.css');
|
||||
|
||||
expect($css)->toContain("@import './chip.css';")
|
||||
->toMatch('/\[data-md-chip-set-row\] \{\s*display: flex;\s*flex-wrap: wrap;\s*gap: var\(--md-sys-measurement-space100\);/')
|
||||
->toContain('[data-md-chip-set-scroller] > [data-md-chip-set-row] {')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/chip-set.css';");
|
||||
});
|
||||
|
||||
it('scrolls a chip set on one line with fading edges', function () {
|
||||
expect((string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>'))
|
||||
->toContain('aria-label="Sort"')
|
||||
->toContain('x-data="materialChipSet"')
|
||||
->toMatch('/data-md-chip-set\s+data-md-scroll\s/')
|
||||
->toContain('<div x-data="materialChipSet" data-md-chip-set-scroller>')
|
||||
->toContain('x-ref="row"')
|
||||
->toContain('wire:ignore.self')
|
||||
->toContain('overflow-x-auto')
|
||||
->toContain('data-scroll-start:[--chip-fade-start:1.5rem]')
|
||||
->not->toContain('flex-wrap');
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip-set.css'))
|
||||
->toContain('overflow-x: auto;')
|
||||
->toContain('--chip-fade-start: var(--md-sys-measurement-space300);');
|
||||
});
|
||||
|
||||
it('puts a scroll button over each fading edge, for a pointer that cannot swipe', function () {
|
||||
$html = (string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-chip-scroll="start"')
|
||||
->toContain('data-chip-scroll="end"')
|
||||
->toContain('data-md-chip-scroll="start"')
|
||||
->toContain('data-md-chip-scroll="end"')
|
||||
->toContain('x-on:click="nudge(\'start\')"')
|
||||
->toContain('x-on:click="nudge(\'end\')"')
|
||||
// Only while the row can still scroll that way, and only where the pointer is fine.
|
||||
->toContain('peer-data-scroll-start:pointer-fine:grid')
|
||||
->toContain('peer-data-scroll-end:pointer-fine:grid')
|
||||
// A pointer affordance, not a tab stop: the arrows already walk every chip.
|
||||
->toContain('tabindex="-1"')
|
||||
->toContain('aria-hidden="true"')
|
||||
->toContain('data-md-mirror-rtl');
|
||||
|
||||
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip-set.css'))
|
||||
// Only while the row can still scroll that way, and only where the pointer is fine.
|
||||
->toMatch("/@media \(pointer: fine\) \{\s*\[data-md-chip-set-row\]\[data-md-scroll-start\] ~ \[data-md-chip-scroll='start'\],\s*\[data-md-chip-set-row\]\[data-md-scroll-end\] ~ \[data-md-chip-scroll='end'\] \{\s*display: grid;/")
|
||||
// A chip the keyboard reaches clears the buttons as well as the fade.
|
||||
->toContain('pointer-fine:scroll-px-10');
|
||||
->toContain('scroll-padding-inline: var(--md-sys-measurement-space500);');
|
||||
|
||||
// A wrapping set has no edge to scroll towards.
|
||||
expect((string) $this->blade('<x-chip-set aria-label="Sort"><x-chip label="A" /></x-chip-set>'))
|
||||
->not->toContain('data-chip-scroll');
|
||||
->not->toContain('data-md-chip-scroll');
|
||||
});
|
||||
|
||||
it('binds filter chips to a Livewire array and shows the set\'s validation message in place of its hint', function () {
|
||||
|
||||
@@ -7,6 +7,8 @@ it('lays every option out as a filter chip', function () {
|
||||
$html = (string) $this->blade('<x-choices label="Days" hint="Pick any" :value="[2]" :options="[[\'id\' => 1, \'name\' => \'Mon\'], [\'id\' => 2, \'name\' => \'Tue\'], [\'id\' => 3, \'name\' => \'Wed\', \'disabled\' => true]]" />');
|
||||
|
||||
expect($html)
|
||||
->toMatch('/<div\\s+data-md-choices\\s/')
|
||||
->not->toContain('data-md-searchable')
|
||||
->toContain('role="group"')
|
||||
->toContain('Days')
|
||||
->toContain('Pick any')
|
||||
@@ -42,6 +44,7 @@ it('draws a searchable combobox with a popover list', function () {
|
||||
$html = (string) $this->blade('<x-choices id="zone" label="Time zone" searchable icon="public" value="Europe/Zurich" :options="[[\'id\' => \'Europe/Zurich\', \'name\' => \'Zurich\']]" />');
|
||||
|
||||
expect($html)
|
||||
->toMatch('/<div\\s+data-md-choices\\s+data-md-searchable\\s/')
|
||||
->toContain('role="combobox"')
|
||||
->toContain('aria-controls="zone-list"')
|
||||
->toContain('aria-expanded="false"')
|
||||
@@ -51,11 +54,14 @@ it('draws a searchable combobox with a popover list', function () {
|
||||
->toContain('popover="manual"')
|
||||
->toMatch('/anchor-name: (--material-choices-[a-z0-9]{10})/')
|
||||
->toContain('x-modelable="value"')
|
||||
->toContain('field-arrow')
|
||||
->toContain('data-md-field-arrow')
|
||||
// WAI-ARIA's combobox keyboard, Home and End included.
|
||||
->toContain('x-on:keydown.home="jump($event, false)"')
|
||||
->toContain('x-on:keydown.end="jump($event, true)"')
|
||||
->toContain('Nothing matches');
|
||||
->toContain('<li x-show="filtered.length === 0" data-md-choices-empty>Nothing matches</li>')
|
||||
->toContain('data-md-field-menu')
|
||||
->toContain('data-md-field-option')
|
||||
->not->toContain('class=');
|
||||
|
||||
preg_match('/anchor-name: (--material-choices-[a-z0-9]{10})/', $html, $anchor);
|
||||
|
||||
@@ -92,3 +98,13 @@ it('shows the errors for the property and its items', function () {
|
||||
->toContain('Choose a time zone.')
|
||||
->toContain('aria-invalid="true"');
|
||||
});
|
||||
|
||||
it('brings the stylesheets of what it renders, and draws its empty row', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/choices.css');
|
||||
|
||||
expect($css)->toContain("@import './field.css';")
|
||||
->toContain("@import './chip-set.css';")
|
||||
->toContain("@import './chip.css';")
|
||||
->toContain('[data-md-choices-empty] {')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/choices.css';");
|
||||
});
|
||||
|
||||
@@ -8,14 +8,25 @@ use Livewire\WithPagination;
|
||||
|
||||
it('marks a table for its styles and takes a size', function () {
|
||||
expect((string) $this->blade('<x-table class="min-w-160"><tbody><tr><td>a</td></tr></tbody></x-table>'))
|
||||
->toContain('<table data-table data-size="sm" class="min-w-160">')
|
||||
->and((string) $this->blade('<x-table size="xs" />'))->toContain('data-size="xs"')
|
||||
->and((string) $this->blade('<x-table size="huge" />'))->toContain('data-size="sm"');
|
||||
->toContain('<table data-md-table data-md-size="sm" class="min-w-160">')
|
||||
->and((string) $this->blade('<x-table size="xs" />'))->toContain('data-md-size="xs"')
|
||||
->and((string) $this->blade('<x-table size="huge" />'))->toContain('data-md-size="sm"');
|
||||
});
|
||||
|
||||
it('tightens a table only when the caller asks for it', function () {
|
||||
expect((string) $this->blade('<x-table />'))->not->toContain('data-dense')
|
||||
->and((string) $this->blade('<x-table dense />'))->toContain('data-dense');
|
||||
expect((string) $this->blade('<x-table />'))->not->toContain('data-md-dense')
|
||||
->and((string) $this->blade('<x-table dense />'))->toContain('data-md-dense');
|
||||
});
|
||||
|
||||
it('draws 52px rows, 36px dense ones, from its stylesheet in the components layer', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/table.css');
|
||||
|
||||
expect($css)->toContain('@layer material.components')
|
||||
->toMatch('/\\[data-md-table\\] \\{\\s*--cell-x: 12px;\\s*--cell-y: var\\(--md-sys-measurement-space200\\);/')
|
||||
->toMatch('/\\[data-md-table\\]\\[data-md-dense\\] \\{\\s*--cell-y: var\\(--md-sys-measurement-space100\\);/')
|
||||
->toContain('[data-md-table] :where(tbody tr[aria-selected=\'true\'])')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/table.css';")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('table.css');
|
||||
});
|
||||
|
||||
it('sorts by its column, ascending first and then flipping', function () {
|
||||
@@ -24,7 +35,7 @@ it('sorts by its column, ascending first and then flipping', function () {
|
||||
->toContain('wire:click="$set(\'sortBy\', {"column":"size","direction":"asc"})"')
|
||||
->and((string) $this->blade('<x-sort-header column="size" model="order" :sort-by="[\'column\' => \'size\', \'direction\' => \'asc\']" class="text-end">Size</x-sort-header>'))
|
||||
->toContain('aria-sort="ascending"')
|
||||
->toContain('class="text-end"')
|
||||
->toMatch('/<th data-md-sort-header\\s+data-md-active aria-sort="ascending"\\s+class="text-end">/')
|
||||
->toContain('$set(\'order\', {"column":"size","direction":"desc"})')
|
||||
->and((string) $this->blade('<x-sort-header column="size" :sort-by="[\'column\' => \'size\', \'direction\' => \'desc\']">Size</x-sort-header>'))
|
||||
->toContain('aria-sort="descending"')
|
||||
@@ -35,7 +46,9 @@ it('gives the sort button and every page control a 48px target', function () {
|
||||
$pages = new LengthAwarePaginator(range(1, 10), 95, 10, 3, ['path' => '/shares']);
|
||||
|
||||
expect((string) $this->blade('<x-sort-header column="size" :sort-by="[]">Size</x-sort-header>'))
|
||||
->toContain('touch-target')
|
||||
->toMatch('/<button(?=[^>]*data-md-sort-header-button)(?=[^>]*class="md-focus-ring md-touch-target")[^>]*>/')
|
||||
->toContain('--md-icon-size: 16px')
|
||||
->not->toContain('data-md-active')
|
||||
->and((string) $pages->links())
|
||||
->toContain('state-layer focus-ring touch-target');
|
||||
});
|
||||
@@ -80,3 +93,10 @@ it('draws Livewire\'s paginators in M3, wired to its page actions', function ()
|
||||
->toContain('wire:click="nextPage(\'page\')"')
|
||||
->not->toContain('text-gray');
|
||||
});
|
||||
|
||||
it('draws the sort header from its stylesheet, its arrow hidden until the column sorts or is hovered', function () {
|
||||
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/sort-header.css'))
|
||||
->toContain('@layer material.components')
|
||||
->toMatch('/\\[data-md-sort-header\\]:not\\(\\[data-md-active\\]\\) \\[data-md-sort-header-arrow\\] \\{\\s*opacity: 0;/')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/sort-header.css';");
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ it('draws a docked date field whose combobox controls a popover picker', functio
|
||||
|
||||
expect($html)
|
||||
->toContain('data-datepicker')
|
||||
->toContain('<label for="expires" class="field-label">Expires on</label>')
|
||||
->toContain('<label for="expires" data-md-field-label>Expires on</label>')
|
||||
->toContain('role="combobox"')
|
||||
->toContain('aria-haspopup="dialog"')
|
||||
->toContain('aria-controls="expires-picker"')
|
||||
@@ -155,8 +155,8 @@ it('puts class on the root and every other attribute on the text field', functio
|
||||
|
||||
expect($html)
|
||||
->toContain('class="w-60"')
|
||||
->toContain('data-variant="filled"')
|
||||
->toContain('data-size="sm"')
|
||||
->toContain('data-md-variant="filled"')
|
||||
->toContain('data-md-size="sm"')
|
||||
->toMatch('/<input\s[^>]*required[^>]*id="due"/s')
|
||||
->toMatch('/<input\s[^>]*disabled[^>]*id="due"/s')
|
||||
->and(datepickerConfig($html)['disabled'])->toBeTrue();
|
||||
@@ -225,14 +225,14 @@ it('replaces the hint with the errors for the property and its start and end', f
|
||||
->not->toContain('Pick a day')
|
||||
->toContain('aria-invalid="true"')
|
||||
->toContain('data-datepicker-error')
|
||||
->and(substr_count($html, 'data-invalid=""'))->toBe(2);
|
||||
->and(substr_count($html, 'data-md-invalid=""'))->toBe(2);
|
||||
});
|
||||
|
||||
it('offers a clear button on request', function () {
|
||||
expect((string) $this->blade('<x-datepicker label="Expires" clearable />'))
|
||||
->toContain('data-field-clear')
|
||||
->toContain('data-md-field-clear')
|
||||
->toContain('x-on:click="clear()"')
|
||||
->toContain('aria-label="Clear"')
|
||||
->and((string) $this->blade('<x-datepicker label="Expires" />'))
|
||||
->not->toContain('data-field-clear');
|
||||
->not->toContain('data-md-field-clear');
|
||||
});
|
||||
|
||||
@@ -4,31 +4,34 @@ it('draws an outlined field with a notched label unless told otherwise', functio
|
||||
$html = (string) $this->blade('<x-input label="Share name" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-variant="outlined"')
|
||||
->toContain('data-md-field')
|
||||
->toContain('data-md-input')
|
||||
->toContain('data-md-variant="outlined"')
|
||||
->toContain('<legend><span>Share name</span></legend>')
|
||||
->toMatch('/<label for="(field-[0-9a-f]{12})" class="field-label">Share name<\/label>/')
|
||||
->toMatch('/<label for="(field-[0-9a-f]{12})" data-md-field-label>Share name<\/label>/')
|
||||
->toContain('placeholder=" "')
|
||||
->toContain('class="field-control"');
|
||||
->toContain('data-md-field-control')
|
||||
->not->toContain('class=');
|
||||
});
|
||||
|
||||
it('draws a filled field on request, or by the application\'s default', function () {
|
||||
expect((string) $this->blade('<x-input label="Name" variant="filled" />'))->toContain('data-variant="filled"');
|
||||
expect((string) $this->blade('<x-input label="Name" variant="filled" />'))->toContain('data-md-variant="filled"');
|
||||
|
||||
config(['livewire-material.fields.variant' => 'filled']);
|
||||
|
||||
expect((string) $this->blade('<x-input label="Name" />'))->toContain('data-variant="filled"')
|
||||
->and((string) $this->blade('<x-input label="Name" variant="outlined" />'))->toContain('data-variant="outlined"')
|
||||
->and((string) $this->blade('<x-input label="Name" variant="glass" />'))->toContain('data-variant="filled"');
|
||||
expect((string) $this->blade('<x-input label="Name" />'))->toContain('data-md-variant="filled"')
|
||||
->and((string) $this->blade('<x-input label="Name" variant="outlined" />'))->toContain('data-md-variant="outlined"')
|
||||
->and((string) $this->blade('<x-input label="Name" variant="glass" />'))->toContain('data-md-variant="filled"');
|
||||
});
|
||||
|
||||
it('ties the label, the hint and the control together', function () {
|
||||
$html = (string) $this->blade('<x-input id="share-name" label="Share name" hint="Recipients see this name" wire:model="name" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('<label for="share-name" class="field-label">')
|
||||
->toContain('<label for="share-name" data-md-field-label>')
|
||||
->toContain('id="share-name"')
|
||||
->toContain('aria-describedby="share-name-support"')
|
||||
->toContain('<p id="share-name-support" class="field-support">Recipients see this name</p>')
|
||||
->toMatch('/<p id="share-name-support" data-md-field-support\s*>Recipients see this name<\/p>/')
|
||||
->toContain('wire:model="name"')
|
||||
->not->toContain('aria-invalid');
|
||||
});
|
||||
@@ -38,8 +41,9 @@ it('replaces the hint with the errors for its model', function () {
|
||||
->blade('<x-input id="email" label="Email" hint="We never share it" wire:model.live="email" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-invalid')
|
||||
->toContain('data-md-invalid')
|
||||
->toContain('aria-invalid="true"')
|
||||
->toContain('role="alert"')
|
||||
->toContain('aria-describedby="email-support"')
|
||||
->toContain('Enter a valid email address.')
|
||||
->not->toContain('We never share it');
|
||||
@@ -49,28 +53,26 @@ it('pairs the error colours with a trailing error icon', function () {
|
||||
$errors = ['email' => ['Enter a valid email address.']];
|
||||
|
||||
expect((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" wire:model="email" />'))
|
||||
->toContain('field-trailing field-error')
|
||||
->toContain('aria-label="Error"')
|
||||
->toMatch('/<svg(?=[^>]*data-md-field-trailing)(?=[^>]*data-md-field-error)(?=[^>]*aria-label="Error")(?=[^>]*role="img")[^>]*>/')
|
||||
->and((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" size="xs" wire:model="email" />'))
|
||||
->not->toContain('field-error')
|
||||
->not->toContain('data-md-field-error')
|
||||
->and((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" icon-right="info" wire:model="email" />'))
|
||||
->not->toContain('field-error')
|
||||
->not->toContain('data-md-field-error')
|
||||
->and((string) $this->blade('<x-input id="email" label="Email" />'))
|
||||
->not->toContain('field-error');
|
||||
->not->toContain('data-md-field-error');
|
||||
});
|
||||
|
||||
it('puts icons, affixes and the size on the field, and every other attribute on the input', function () {
|
||||
$html = (string) $this->blade('<x-input label="Price" icon="payments" icon-right="info" prefix="CHF" suffix="per month" size="sm" type="number" min="0" class="w-40" mono readonly />');
|
||||
|
||||
expect($html)
|
||||
->toContain('class="field w-40"')
|
||||
->toContain('data-size="sm"')
|
||||
->toContain('data-mono')
|
||||
->toContain('data-readonly')
|
||||
->toContain('<span class="field-affix">CHF</span>')
|
||||
->toContain('<span class="field-affix">per month</span>')
|
||||
->toMatch('/<div\s+data-md-field\s+data-md-variant="outlined"\s+data-md-size="sm"\s+data-md-mono\s+class="w-40" data-md-readonly="" data-md-input="data-md-input"\s*>/')
|
||||
->toContain('<span data-md-field-affix>CHF</span>')
|
||||
->toContain('<span data-md-field-affix>per month</span>')
|
||||
->toContain('type="number"')
|
||||
->toContain('min="0"')
|
||||
// An sm field's icons are 20px, drawn from the 20 cut.
|
||||
->and(substr_count($html, '--md-icon-size: 20px'))->toBe(2)
|
||||
->and(substr_count($html, '<svg'))->toBe(2);
|
||||
});
|
||||
|
||||
@@ -78,9 +80,12 @@ it('offers to clear and to copy the value', function () {
|
||||
$html = (string) $this->blade('<x-input label="Link" clearable copyable />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-field-clear')
|
||||
->toContain('data-md-field-clear')
|
||||
->toContain('aria-label="Clear"')
|
||||
->toContain('data-field-copy')
|
||||
->toContain('data-md-field-copy')
|
||||
->toContain("\$el.closest('[data-md-field]').querySelector('[data-md-field-control]')")
|
||||
// A labelled field is md: its icons are 24px.
|
||||
->toContain('--md-icon-size: 24px')
|
||||
->toContain('navigator.clipboard.writeText')
|
||||
->toContain('aria-label="Copy"');
|
||||
});
|
||||
@@ -92,7 +97,8 @@ it('lets a password be shown and hidden again', function () {
|
||||
->toContain('type="password"')
|
||||
->not->toContain('type="text"')
|
||||
->toContain('x-bind:type="shown ? \'text\' : \'password\'"')
|
||||
->toContain('data-field-reveal')
|
||||
->toContain('data-md-password')
|
||||
->toMatch('/<button(?=[^>]*data-md-field-button)(?=[^>]*data-md-field-reveal)[^>]*>/')
|
||||
->toContain('aria-label="Show password"')
|
||||
// The flipping label already says the state; aria-pressed would say it again, inverted.
|
||||
->not->toContain('aria-pressed')
|
||||
@@ -102,12 +108,13 @@ it('lets a password be shown and hidden again', function () {
|
||||
it('grows a textarea from its rows up to its maximum, or leaves it to be resized', function () {
|
||||
expect((string) $this->blade('<x-textarea label="Message" rows="2" max-rows="6" />'))
|
||||
->toContain('rows="2"')
|
||||
->toContain('data-autogrow')
|
||||
->toContain('data-md-textarea')
|
||||
->toContain('data-md-autogrow')
|
||||
->toContain('--field-rows: 2;')
|
||||
->toContain('--field-max-rows: 6;')
|
||||
->and((string) $this->blade('<x-textarea label="Message" :autogrow="false" />'))
|
||||
->toContain('rows="3"')
|
||||
->not->toContain('data-autogrow');
|
||||
->not->toContain('data-md-autogrow');
|
||||
});
|
||||
|
||||
it('draws a native select whose label always floats', function () {
|
||||
@@ -119,13 +126,15 @@ it('draws a native select whose label always floats', function () {
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('data-floated')
|
||||
->toContain('data-md-floated')
|
||||
->toContain('data-md-select')
|
||||
->toMatch('/<select(?=[^>]*data-md-field-control)[^>]*>/')
|
||||
->toContain('<select')
|
||||
->toContain('wire:model="hours"')
|
||||
->toContain('<option value="">Choose…</option>')
|
||||
->toContain('<option value="1" >1 hour</option>')
|
||||
->toContain('<option value="720" disabled>30 days</option>')
|
||||
->toContain('field-arrow');
|
||||
->toContain('data-md-field-arrow');
|
||||
});
|
||||
|
||||
it('takes a select\'s options from its slot as well', function () {
|
||||
@@ -142,7 +151,8 @@ it('collects a file field\'s own errors and each file\'s', function () {
|
||||
expect($html)
|
||||
->toContain('type="file"')
|
||||
->toContain('multiple')
|
||||
->toContain('data-floated')
|
||||
->toContain('data-md-file')
|
||||
->toContain('data-md-floated')
|
||||
->toContain('Choose at most 10 photos.')
|
||||
->toContain('photo.jpg is larger than 2 GB.');
|
||||
});
|
||||
@@ -151,8 +161,10 @@ it('counts the characters of a field that has a maximum', function () {
|
||||
$html = (string) $this->blade('<x-input id="bio" label="Bio" hint="Shown on your profile" maxlength="60" counter />');
|
||||
|
||||
expect($html)
|
||||
->toContain('class="field-support-row"')
|
||||
->toContain('class="field-counter"')
|
||||
->toContain('data-md-field-support-row')
|
||||
->toContain('data-md-field-counter')
|
||||
->toContain('x-bind:data-md-over="used > max"')
|
||||
->toContain('class="md-visually-hidden"')
|
||||
->toContain('maxlength="60"')
|
||||
->toContain('max: 60,')
|
||||
->toContain('>0/60</span>')
|
||||
@@ -160,24 +172,24 @@ it('counts the characters of a field that has a maximum', function () {
|
||||
->toContain('Shown on your profile');
|
||||
|
||||
expect((string) $this->blade('<x-textarea id="note" label="Note" maxlength="140" counter />'))
|
||||
->toContain('class="field-counter"')
|
||||
->toContain('data-md-field-counter')
|
||||
->toContain('>0/140</span>');
|
||||
});
|
||||
|
||||
it('shows no counter without `counter` or without a maximum to count against', function () {
|
||||
expect((string) $this->blade('<x-input label="Bio" maxlength="60" />'))->not->toContain('field-counter')
|
||||
->and((string) $this->blade('<x-input label="Bio" counter />'))->not->toContain('field-counter')
|
||||
->and((string) $this->blade('<x-textarea label="Note" counter />'))->not->toContain('field-counter');
|
||||
expect((string) $this->blade('<x-input label="Bio" maxlength="60" />'))->not->toContain('data-md-field-counter')
|
||||
->and((string) $this->blade('<x-input label="Bio" counter />'))->not->toContain('data-md-field-counter')
|
||||
->and((string) $this->blade('<x-textarea label="Note" counter />'))->not->toContain('data-md-field-counter');
|
||||
});
|
||||
|
||||
it('bounds a field\'s width from medium unless it is told to fill its pane', function () {
|
||||
expect(file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))
|
||||
->toContain('@media (width >= 37.5rem)')
|
||||
->toMatch('/\.field:not\(\[data-full\]\)\s*\{\s*max-width: 40rem;/');
|
||||
->toContain('@media (width >= 600px)')
|
||||
->toMatch('/\[data-md-field\]:not\(\[data-md-full\]\)\s*\{\s*max-width: 40rem;/');
|
||||
|
||||
expect((string) $this->blade('<x-input label="Share name" full />'))->toContain('data-full')
|
||||
->and((string) $this->blade('<x-textarea label="Message" full />'))->toContain('data-full')
|
||||
->and((string) $this->blade('<x-input label="Share name" />'))->not->toContain('data-full');
|
||||
expect((string) $this->blade('<x-input label="Share name" full />'))->toContain('data-md-full')
|
||||
->and((string) $this->blade('<x-textarea label="Message" full />'))->toContain('data-md-full')
|
||||
->and((string) $this->blade('<x-input label="Share name" />'))->not->toContain('data-md-full');
|
||||
});
|
||||
|
||||
it('lays out a form with its actions under an optional divider', function () {
|
||||
@@ -189,10 +201,91 @@ it('lays out a form with its actions under an optional divider', function () {
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('<form class="grid grid-cols-1 auto-rows-min gap-4" wire:submit="save">')
|
||||
->toContain('<form data-md-form wire:submit="save">')
|
||||
->toContain('role="separator"')
|
||||
->toContain('justify-between')
|
||||
->toContain('<div data-md-form-actions class="justify-between">')
|
||||
->not->toContain('grid-cols-1')
|
||||
->toContain('<button>Save</button>')
|
||||
->and((string) $this->blade('<x-form><x-slot:actions><button>Save</button></x-slot:actions></x-form>'))
|
||||
->not->toContain('role="separator"');
|
||||
});
|
||||
|
||||
it('draws a form\'s column and its actions from its stylesheet', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/form.css');
|
||||
|
||||
expect($css)->toContain('@layer material.components')
|
||||
->toContain('grid-template-columns: minmax(0, 1fr);')
|
||||
->toContain('gap: var(--md-sys-measurement-space200);')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/form.css';");
|
||||
});
|
||||
|
||||
it('keeps the field\'s chrome in its own stylesheet in the components layer', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css');
|
||||
|
||||
expect($css)->toContain('@layer material.components')
|
||||
->toContain("@import './icon.css';")
|
||||
->toContain('[data-md-field-box]')
|
||||
->not->toMatch('/(?<![\\w-])\\.field\\b/')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/field.css';")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('field.css');
|
||||
});
|
||||
|
||||
it('takes a custom control marked as the field\'s control, and a hint class', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-field id="expiry" label="Expiry" hint="In the future" hint-class="md-ink-primary">
|
||||
<input id="expiry" data-md-field-control value="2030-01-01" />
|
||||
</x-field>
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('<input id="expiry" data-md-field-control value="2030-01-01" />')
|
||||
->toMatch('/<p id="expiry-support" data-md-field-support\s+class="md-ink-primary"\s*>In the future<\/p>/')
|
||||
->toContain('<fieldset aria-hidden="true" data-md-field-outline>');
|
||||
});
|
||||
|
||||
it('gives the one-line field a stylesheet that brings the field and its icons', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/input.css');
|
||||
|
||||
expect($css)->toStartWith('/*')
|
||||
->toContain("@import './field.css';")
|
||||
->toContain("@import './icon.css';")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/input.css';");
|
||||
});
|
||||
|
||||
it('gives the password field a stylesheet that brings the field and its icons', function () {
|
||||
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/password.css'))
|
||||
->toContain("@import './field.css';")
|
||||
->toContain("@import './icon.css';")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/password.css';");
|
||||
});
|
||||
|
||||
it('draws what a textarea changes in the field from its own stylesheet', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/textarea.css');
|
||||
|
||||
expect($css)->toContain("@import './field.css';")
|
||||
->toContain('@layer material.components')
|
||||
->toContain('textarea[data-md-field-control][data-md-autogrow]')
|
||||
->toContain('field-sizing: content;')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('textarea[')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/textarea.css';");
|
||||
});
|
||||
|
||||
it('draws what a select changes in the field from its own stylesheet', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/select.css');
|
||||
|
||||
expect($css)->toContain("@import './field.css';")
|
||||
->toContain('@supports (appearance: base-select)')
|
||||
->toContain('select[data-md-field-control]:open')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('select[')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/select.css';");
|
||||
});
|
||||
|
||||
it('draws the file picker\'s button from its own stylesheet', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/file.css');
|
||||
|
||||
expect($css)->toContain("@import './field.css';")
|
||||
->toContain("input[type='file'][data-md-field-control]::file-selector-button")
|
||||
->toContain('background-color: var(--md-sys-color-secondary-container);')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain("[type='file']")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/file.css';");
|
||||
});
|
||||
|
||||
@@ -4,7 +4,10 @@ it('draws a search bar whose combobox controls the view', function () {
|
||||
$html = (string) $this->blade('<x-search id="find" placeholder="Search shares" wire:model.live.debounce.300ms="query" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-search')
|
||||
->toContain('data-md-search')
|
||||
->toContain('data-md-trigger="bar"')
|
||||
->toContain('x-bind:data-md-open="open ? \'\' : null"')
|
||||
->toContain('x-bind:data-md-full-screen="fullScreen ? \'\' : null"')
|
||||
->toContain('role="search"')
|
||||
->toContain('type="search"')
|
||||
->toContain('placeholder="Search shares"')
|
||||
@@ -15,14 +18,15 @@ it('draws a search bar whose combobox controls the view', function () {
|
||||
->toContain('aria-expanded="false"')
|
||||
->toContain('wire:model.live.debounce.300ms="query"')
|
||||
->toContain('id="find-view"')
|
||||
->toContain('data-search-clear')
|
||||
->toContain('data-search-scrim')
|
||||
->toContain('data-md-search-clear')
|
||||
->toContain('data-md-search-scrim')
|
||||
->toContain('aria-label="Back"')
|
||||
->toContain('materialSearch(false,')
|
||||
->not->toContain('data-search-results');
|
||||
->not->toContain('data-md-search-results')
|
||||
->not->toContain('class="relative"');
|
||||
|
||||
// The state belongs to the combobox, never to the bare textbox ARIA gives it to.
|
||||
expect($html)->toMatch('/<span\s+data-search-field[^>]*aria-expanded/s');
|
||||
expect($html)->toMatch('/<span\s+data-md-search-field[^>]*aria-expanded/s');
|
||||
expect($html)->not->toMatch('/<input[^>]*aria-expanded/s');
|
||||
});
|
||||
|
||||
@@ -30,7 +34,7 @@ it('announces how many results there are, politely', function () {
|
||||
$html = (string) $this->blade('<x-search />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-search-status')
|
||||
->toContain('data-md-search-status class="md-visually-hidden"')
|
||||
->toContain('aria-live="polite"')
|
||||
->toContain('aria-atomic="true"')
|
||||
->toContain('No results')
|
||||
@@ -40,19 +44,19 @@ it('announces how many results there are, politely', function () {
|
||||
|
||||
it('shows the results as a list, or what to say when there are none', function () {
|
||||
expect((string) $this->blade('<x-search><a href="/shares/1">holiday.zip</a></x-search>'))
|
||||
->toContain('data-search-results role="list"')
|
||||
->toContain('data-md-search-results role="list"')
|
||||
->toContain('<a href="/shares/1">holiday.zip</a>')
|
||||
->and((string) $this->blade('<x-search><x-slot:empty>No shares match.</x-slot:empty></x-search>'))
|
||||
->toContain('No shares match.')
|
||||
->not->toContain('data-search-results role="list"');
|
||||
->toContain('<p data-md-search-empty>No shares match.</p>')
|
||||
->not->toContain('data-md-search-results role="list"');
|
||||
});
|
||||
|
||||
it('offers M3\'s search-icon entry point', function () {
|
||||
$html = (string) $this->blade('<x-search id="find" trigger="icon" label="Search shares" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-trigger="icon"')
|
||||
->toContain('data-search-trigger')
|
||||
->toContain('data-md-trigger="icon"')
|
||||
->toContain('data-md-search-trigger')
|
||||
->toContain('x-ref="trigger"')
|
||||
->toContain('x-on:click="expand()"')
|
||||
->toContain('aria-label="Search shares"')
|
||||
@@ -61,11 +65,11 @@ it('offers M3\'s search-icon entry point', function () {
|
||||
->toContain('materialSearch(false,')
|
||||
->toContain("'icon'")
|
||||
// The bar is still there: it becomes the expanded view's header.
|
||||
->toContain('data-search-bar');
|
||||
->toContain('data-md-search-bar');
|
||||
|
||||
expect((string) $this->blade('<x-search />'))
|
||||
->toContain('data-trigger="bar"')
|
||||
->not->toContain('data-search-trigger');
|
||||
->toContain('data-md-trigger="bar"')
|
||||
->not->toContain('data-md-search-trigger');
|
||||
});
|
||||
|
||||
it('shows suggestions until the first keystroke, then the results', function () {
|
||||
@@ -77,7 +81,7 @@ it('shows suggestions until the first keystroke, then the results', function ()
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('data-search-suggestions role="list"')
|
||||
->toContain('data-md-search-suggestions role="list"')
|
||||
->toContain('x-show="query === \'\'"')
|
||||
->toContain('Recent shares')
|
||||
->toContain('x-show="query !== \'\'"')
|
||||
@@ -87,7 +91,7 @@ it('shows suggestions until the first keystroke, then the results', function ()
|
||||
|
||||
// Without the slot the results stand alone and are never hidden.
|
||||
expect((string) $this->blade('<x-search><a href="/shares/1">holiday.zip</a></x-search>'))
|
||||
->not->toContain('data-search-suggestions')
|
||||
->not->toContain('data-md-search-suggestions')
|
||||
->not->toContain('x-show="query !== \'\'"');
|
||||
});
|
||||
|
||||
@@ -97,7 +101,19 @@ it('names the input, trails the bar, and stays docked on request', function () {
|
||||
expect($html)
|
||||
->toContain('placeholder="Search"')
|
||||
->toContain('aria-label="Search your shares"')
|
||||
->toContain('data-search-trailing')
|
||||
->toContain('data-md-search-trailing')
|
||||
->toContain('<button>AR</button>')
|
||||
->toContain('materialSearch(true,');
|
||||
});
|
||||
|
||||
it('draws the bar and the view from its stylesheet, at M3\'s widths', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/search.css');
|
||||
|
||||
expect($css)->toContain('@layer material.components')
|
||||
->toMatch('/\\[data-md-search\\] \\{\\s*--search-height: 56px;[^}]*--search-open-width: 720px;/')
|
||||
->toContain('[data-md-search][data-md-full-screen] [data-md-search-view] {')
|
||||
->toContain("[data-md-search][data-md-trigger='icon']:not([data-md-open]) [data-md-search-bar] {")
|
||||
->not->toMatch('/\\d(?:\\.\\d+)?rem/')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/search.css';")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('search.css');
|
||||
});
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
<?php
|
||||
|
||||
it('draws a checkbox whose row is its label', function () {
|
||||
$html = (string) $this->blade('<x-checkbox id="notify" label="Notify me" hint="On every download" wire:model="notify" class="mt-2" />');
|
||||
$html = (string) $this->blade('<x-checkbox id="notify" label="Notify me" hint="On every download" wire:model="notify" class="mt-2" style="order: 2" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('<div class="min-w-0 mt-2">')
|
||||
->toContain('<label for="notify" data-selection')
|
||||
->toContain('data-checkbox')
|
||||
->toContain('<div data-md-checkbox class="mt-2" style="order: 2">')
|
||||
->toMatch('/<label for="notify" data-md-selection-row\s*>/')
|
||||
->toContain('<span data-md-checkbox-box >')
|
||||
->toContain('type="checkbox"')
|
||||
->toContain('wire:model="notify"')
|
||||
->toContain('data-check')
|
||||
->toContain('data-mixed')
|
||||
->toContain('Notify me')
|
||||
->toContain('On every download')
|
||||
->not->toContain('data-indeterminate');
|
||||
->toContain('data-md-checkbox-check')
|
||||
->toContain('data-md-checkbox-mixed')
|
||||
->toContain('<span data-md-selection-label>Notify me</span>')
|
||||
->toContain('<span data-md-selection-hint>On every download</span>')
|
||||
->not->toContain('data-md-indeterminate')
|
||||
->not->toContain('min-w-0');
|
||||
});
|
||||
|
||||
it('fills the box with an 18px tick from the 20 optical cut', function () {
|
||||
expect((string) $this->blade('<x-checkbox label="Notify me" />'))
|
||||
->toContain((string) $this->blade('<x-icon name="check" class="size-4.5" optical="20" data-check />'))
|
||||
->toContain((string) $this->blade('<x-icon name="remove" class="size-4.5" optical="20" data-mixed />'));
|
||||
->toContain((string) $this->blade('<x-icon name="check" size="18" data-md-checkbox-check />'))
|
||||
->toContain((string) $this->blade('<x-icon name="remove" size="18" data-md-checkbox-mixed />'))
|
||||
->toContain('--md-icon-size: 18px');
|
||||
});
|
||||
|
||||
it('marks a checkbox that is partly ticked', function () {
|
||||
expect((string) $this->blade('<x-checkbox label="Select all" indeterminate />'))->toContain('data-indeterminate');
|
||||
expect((string) $this->blade('<x-checkbox label="Select all" indeterminate />'))->toContain('data-md-indeterminate');
|
||||
});
|
||||
|
||||
it('gives a selection control without a label a 48px target', function () {
|
||||
expect((string) $this->blade('<x-checkbox aria-label="Select all" />'))->toContain('touch-target')
|
||||
->and((string) $this->blade('<x-toggle aria-label="Public link" />'))->toContain('touch-target')
|
||||
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'\']]" />'))->toContain('touch-target');
|
||||
expect((string) $this->blade('<x-checkbox aria-label="Select all" />'))->toMatch('/<span data-md-checkbox-box\s+class="md-touch-target"\s*>/')
|
||||
->and((string) $this->blade('<x-toggle aria-label="Public link" />'))->toMatch('/<span data-md-switch\\s+class="md-touch-target"\\s*>/')
|
||||
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'\']]" />'))->toMatch('/<span data-md-radio-button\\s+class="md-touch-target"\\s*>/');
|
||||
|
||||
// A labelled control is pressed anywhere along its row, so it needs no extra target.
|
||||
expect((string) $this->blade('<x-checkbox label="Notify me" />'))->not->toContain('touch-target')
|
||||
->and((string) $this->blade('<x-toggle label="Public link" />'))->not->toContain('touch-target')
|
||||
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))->not->toContain('touch-target');
|
||||
expect((string) $this->blade('<x-checkbox label="Notify me" />'))->not->toContain('md-touch-target')
|
||||
->and((string) $this->blade('<x-toggle label="Public link" />'))->not->toContain('md-touch-target')
|
||||
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))->not->toContain('md-touch-target');
|
||||
});
|
||||
|
||||
it('leaves a checkbox group to the caller and says how M3 wants it laid out', function () {
|
||||
@@ -52,7 +54,7 @@ it('leaves a checkbox group to the caller and says how M3 wants it laid out', fu
|
||||
->toContain('class="grid gap-4 expanded:grid-cols-2"')
|
||||
->toContain('Downloads')
|
||||
->toContain('Comments')
|
||||
->and(substr_count($html, 'data-checkbox'))->toBe(2);
|
||||
->and(substr_count($html, 'data-md-checkbox-box'))->toBe(2);
|
||||
|
||||
// The rule is guidance, not markup, so the component's header has to carry it.
|
||||
expect(file_get_contents(__DIR__.'/../../../resources/views/components/checkbox.blade.php'))
|
||||
@@ -61,7 +63,8 @@ it('leaves a checkbox group to the caller and says how M3 wants it laid out', fu
|
||||
});
|
||||
|
||||
it('puts a checkbox at the end of its row on request', function () {
|
||||
expect((string) $this->blade('<x-checkbox label="Show" right />'))->toContain('flex-row-reverse justify-between');
|
||||
expect((string) $this->blade('<x-checkbox label="Show" right />'))->toMatch('/<label for="check-[0-9a-f]{12}" data-md-selection-row\s+data-md-right\s*>/')
|
||||
->and((string) $this->blade('<x-checkbox label="Show" />'))->not->toContain('data-md-right');
|
||||
});
|
||||
|
||||
it('shows a checkbox\'s errors', function () {
|
||||
@@ -69,7 +72,8 @@ it('shows a checkbox\'s errors', function () {
|
||||
->blade('<x-checkbox id="terms" label="I accept the terms" wire:model="terms" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-invalid')
|
||||
->toContain('data-md-invalid')
|
||||
->toContain('<div id="terms-support" data-md-selection-support role="alert">')
|
||||
->toContain('aria-invalid="true"')
|
||||
->toContain('aria-describedby="terms-support"')
|
||||
->toContain('Accept the terms to continue.');
|
||||
@@ -85,8 +89,11 @@ it('draws radio buttons in a fieldset named by its question', function () {
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('<fieldset')
|
||||
->toContain('Who can open it</legend>')
|
||||
->toMatch('/<fieldset\\s+data-md-radio\\s/')
|
||||
->toContain('<legend data-md-radio-legend>Who can open it</legend>')
|
||||
->toContain('<div data-md-radio-options>')
|
||||
->toContain('<span data-md-selection-hint>Share it separately</span>')
|
||||
->not->toContain('class=')
|
||||
->and(substr_count($html, 'type="radio"'))->toBe(3)
|
||||
->and(substr_count($html, 'name="audience"'))->toBe(3)
|
||||
->and(substr_count($html, 'wire:model.live="audience"'))->toBe(3)
|
||||
@@ -99,7 +106,7 @@ it('names unbound radios after their group and checks the given value', function
|
||||
$html = (string) $this->blade('<x-radio name="theme" value="dark" inline :options="[[\'id\' => \'light\', \'name\' => \'Light\'], [\'id\' => \'dark\', \'name\' => \'Dark\']]" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('medium:flex')
|
||||
->toMatch('/<fieldset\\s+data-md-radio\\s+data-md-inline\\s/')
|
||||
->and(substr_count($html, 'name="theme"'))->toBe(2)
|
||||
->and($html)->toMatch('/value="dark"\s+checked/')
|
||||
->not->toMatch('/value="light"\s+checked/');
|
||||
@@ -108,7 +115,8 @@ it('names unbound radios after their group and checks the given value', function
|
||||
it('shows the errors of a radio group', function () {
|
||||
expect((string) $this->withViewErrors(['audience' => ['Choose who can open it.']])
|
||||
->blade('<x-radio wire:model="audience" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
|
||||
->toContain('data-invalid')
|
||||
->toContain('data-md-invalid')
|
||||
->toMatch('/<div id="radio-[0-9a-f]{12}-support" data-md-selection-support role="alert">/')
|
||||
->toContain('Choose who can open it.');
|
||||
});
|
||||
|
||||
@@ -116,17 +124,50 @@ it('draws a switch on a checkbox with the switch role', function () {
|
||||
$html = (string) $this->blade('<x-toggle id="public" label="Public link" wire:model.live="public" right />');
|
||||
|
||||
expect($html)
|
||||
->toContain('<label for="public" data-selection')
|
||||
->toContain('data-switch')
|
||||
->toMatch('/<div data-md-toggle\s*>/')
|
||||
->toMatch('/<label for="public" data-md-selection-row\\s+data-md-right\\s*>/')
|
||||
->toContain('data-md-switch')
|
||||
->toContain('role="switch"')
|
||||
->toContain('type="checkbox"')
|
||||
->toContain('wire:model.live="public"')
|
||||
->toContain('data-handle')
|
||||
->toContain('flex-row-reverse')
|
||||
->not->toContain('data-icons');
|
||||
->toContain('data-md-switch-handle')
|
||||
->not->toContain('class=')
|
||||
->not->toContain('data-md-icons');
|
||||
});
|
||||
|
||||
it('puts icons on a switch\'s handle', function () {
|
||||
expect((string) $this->blade('<x-toggle label="Wi-Fi" icons />'))->toContain('data-icons="both"')->toContain('data-on')->toContain('data-off')
|
||||
->and((string) $this->blade('<x-toggle label="Wi-Fi" icons="selected" />'))->toContain('data-icons="selected"');
|
||||
expect((string) $this->blade('<x-toggle label="Wi-Fi" icons />'))->toContain('data-md-icons="both"')->toContain('data-md-switch-on')->toContain('data-md-switch-off')
|
||||
->and((string) $this->blade('<x-toggle label="Wi-Fi" icons />'))->toContain('--md-icon-size: 16px')
|
||||
->and((string) $this->blade('<x-toggle label="Wi-Fi" icons="selected" />'))->toContain('data-md-icons="selected"');
|
||||
});
|
||||
|
||||
it('draws the checkbox from its stylesheet, on the row the selection controls share', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/checkbox.css');
|
||||
|
||||
expect($css)->toContain("@import './selection.css';")
|
||||
->toContain("@import './icon.css';")
|
||||
->toContain('@layer material.components')
|
||||
->toMatch('/\\[data-md-checkbox-box\\] \\{[^}]*width: 18px;[^}]*height: 18px;/')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/selection.css'))
|
||||
->toContain('[data-md-selection-row]')
|
||||
->toContain('@layer material.components')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/checkbox.css';")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('selection.css');
|
||||
});
|
||||
|
||||
it('lays the radio group out from its stylesheet, inline only from 600px', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/radio.css');
|
||||
|
||||
expect($css)->toContain("@import './selection.css';")
|
||||
->toMatch('/@media \\(width >= 600px\\) \\{\\s*\\[data-md-radio\\]\\[data-md-inline\\] \\[data-md-radio-options\\] \\{\\s*display: flex;/')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/radio.css';")
|
||||
->and((string) $this->blade('<x-radio name="size" class="mt-4" style="order: 1" :options="[[\'id\' => \'s\', \'name\' => \'S\']]" />'))
|
||||
->toMatch('/data-md-radio\\s+class="mt-4" style="order: 1"/');
|
||||
});
|
||||
|
||||
it('draws the switch from its stylesheet', function () {
|
||||
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/toggle.css'))
|
||||
->toContain("@import './selection.css';")
|
||||
->toMatch('/\\[data-md-switch\\] \\{[^}]*width: 52px;[^}]*height: 32px;/')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/toggle.css';");
|
||||
});
|
||||
|
||||
@@ -19,8 +19,10 @@ it('is a labelled native range input under a drawing only the script touches', f
|
||||
->toContain('value="40"')
|
||||
->toContain('x-data="materialSlider"')
|
||||
->toContain('x-on:pointerdown="press($event)"')
|
||||
->toContain('data-slider-drawing wire:ignore aria-hidden="true"')
|
||||
->toContain('noscript:appearance-auto')
|
||||
->toContain('data-md-slider-control')
|
||||
->toContain('<div data-md-slider-drawing wire:ignore aria-hidden="true">')
|
||||
->toContain('data-md-slider-input="start"')
|
||||
->not->toContain('class=')
|
||||
->not->toContain('role="group"');
|
||||
});
|
||||
|
||||
@@ -28,19 +30,24 @@ it('draws the first frame on the server: the track split around the handle, and
|
||||
$html = (string) $this->blade('<x-slider value="40" />');
|
||||
|
||||
expect($html)
|
||||
->toMatch('/data-segment="active"\s+class="[^"]*bg-primary[^"]*"\s+style="left: calc\(0% \+ 0px\); width: calc\(40% - 8px\); border-radius: 8px 2px 2px 8px"/')
|
||||
->toMatch('/data-segment="end"\s+class="[^"]*bg-secondary-container[^"]*"\s+style="left: calc\(40% \+ 8px\); width: calc\(60% - 8px\); border-radius: 2px 8px 8px 2px"/')
|
||||
->toMatch('/data-segment="start"\s+hidden/')
|
||||
->toMatch('/data-md-slider-segment="active"\s+style="left: calc\(0% \+ 0px\); width: calc\(40% - 8px\); border-radius: 8px 2px 2px 8px"/')
|
||||
->toMatch('/data-md-slider-segment="end"\s+style="left: calc\(40% \+ 8px\); width: calc\(60% - 8px\); border-radius: 2px 8px 8px 2px"/')
|
||||
->toMatch('/data-md-slider-segment="start"\s+hidden/')
|
||||
->toContain('style="left: calc(100% - 8px)"')
|
||||
->toContain('<span data-handle="start" class="group/thumb absolute inset-y-0 w-0" style="left: calc(40% + 0px)">')
|
||||
->toMatch('/data-value-label\s+class="[^"]*opacity-0[^"]*"\s*>40<\/span>/');
|
||||
->toContain('<span data-md-slider-handle="start" style="left: calc(40% + 0px)">')
|
||||
->toContain('<span data-md-slider-value>40</span>')
|
||||
->toMatch('/<div\s+data-md-slider\s+data-md-size="xs"\s+data-md-color="primary"\s+data-md-value-label="drag"/');
|
||||
});
|
||||
|
||||
it('draws M3\'s 44x48 value indicator and puts the stop on the inactive track', function () {
|
||||
expect((string) $this->blade('<x-slider value="40" />'))
|
||||
->toContain('flex h-11 min-w-12 items-center')
|
||||
->toMatch('/data-value-label[^>]*class="[^"]*origin-bottom/')
|
||||
->toMatch('/data-stop="end"[^>]*class="[^"]*bg-on-secondary-container/');
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
|
||||
|
||||
expect((string) $this->blade('<x-slider value="40" />'))->toMatch('/data-md-slider-stop="end"\s+style="left: calc\(100% - 8px\)"/')
|
||||
->and($css)
|
||||
->toMatch('/\[data-md-slider-value\] \{[^}]*min-width: var\(--md-sys-measurement-space600\);[^}]*height: 44px;[^}]*background-color: var\(--md-sys-color-inverse-surface\);[^}]*transform-origin: bottom;/')
|
||||
->toMatch('/:is\(\[data-md-slider-tick\], \[data-md-slider-stop\]\) \{[^}]*background-color: var\(--slider-on-inactive\);/')
|
||||
->toContain("@import './icon.css';")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/slider.css';");
|
||||
});
|
||||
|
||||
it('snaps and clamps the value to the step grid', function (string $template, string $value) {
|
||||
@@ -77,7 +84,8 @@ it('gives a range two inputs bound to the ends of an array, in a named group', f
|
||||
->toContain('aria-label="Range start"')
|
||||
->toContain('aria-label="Range end"')
|
||||
->toContain('id="price-end"')
|
||||
->toContain('data-handle="end"')
|
||||
->toContain('data-md-slider-handle="end"')
|
||||
->toContain('data-md-slider-input="end"')
|
||||
->not->toContain('<label for=');
|
||||
|
||||
expect(substr_count($html, 'name="price[]"'))->toBe(2)
|
||||
@@ -123,7 +131,7 @@ it('replaces the hint with the validation message and marks the input invalid',
|
||||
|
||||
expect((string) $this->blade('<x-slider name="volume" hint="Quietly" id="volume" />'))
|
||||
->toContain('Too loud.')
|
||||
->toContain('text-error')
|
||||
->toContain('<div id="volume-hint" data-md-slider-errors>')
|
||||
->toContain('aria-invalid="true"')
|
||||
->toContain('aria-describedby="volume-hint"')
|
||||
->not->toContain('Quietly');
|
||||
@@ -133,105 +141,131 @@ it('replaces the hint with the validation message and marks the input invalid',
|
||||
->not->toContain('Per night');
|
||||
|
||||
expect((string) $this->blade('<x-slider name="balance" hint="Centre is even" />'))
|
||||
->toContain('Centre is even')
|
||||
->toMatch('/data-md-slider-hint>Centre is even<\/p>/')
|
||||
->not->toContain('aria-invalid');
|
||||
});
|
||||
|
||||
it('sizes the track and the handle by Expressive\'s tokens', function (string $size, string $track, string $handle, string $body) {
|
||||
it('sizes the track and the handle by Expressive\'s tokens', function (string $size, string $track, string $handle, string $across) {
|
||||
expect((string) $this->blade('<x-slider :size="$size" />', ['size' => $size]))
|
||||
->toContain("data-size=\"{$size}\"")
|
||||
->toContain("-translate-y-1/2 {$track}\"")
|
||||
->toContain($handle)
|
||||
->toMatch("/group\\/slider [^\"]* {$body} /");
|
||||
->toContain("data-md-size=\"{$size}\"");
|
||||
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
|
||||
$block = $size === 'xs'
|
||||
? '/\[data-md-slider\] \{[^}]*/'
|
||||
: '/\[data-md-slider\]\[data-md-size=\''.$size.'\'\] \{[^}]*/';
|
||||
|
||||
preg_match($block, $css, $rule);
|
||||
|
||||
foreach (['--slider-across' => $across, '--slider-track' => $track, '--slider-handle' => $handle] as $property => $value) {
|
||||
if ($size === 'sm' && $property !== '--slider-track') {
|
||||
// sm keeps xs's handle and height.
|
||||
continue;
|
||||
}
|
||||
|
||||
expect($rule[0])->toContain("{$property}: {$value};");
|
||||
}
|
||||
|
||||
// A focused handle is 6px shorter, so its ring stays clear of the label.
|
||||
expect($css)->toContain('height: calc(var(--slider-handle) - 6px);');
|
||||
})->with([
|
||||
'xs' => ['xs', 'h-4', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'],
|
||||
'sm' => ['sm', 'h-6', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'],
|
||||
'md' => ['md', 'h-10', 'h-13 group-data-focused/thumb:h-11.5', 'h-13'],
|
||||
'lg' => ['lg', 'h-14', 'h-17 group-data-focused/thumb:h-15.5', 'h-17'],
|
||||
'xl' => ['xl', 'h-24', 'h-27 group-data-focused/thumb:h-25.5', 'h-27'],
|
||||
'xs' => ['xs', '16px', '44px', '48px'],
|
||||
'sm' => ['sm', '24px', '44px', '48px'],
|
||||
'md' => ['md', '40px', '52px', '52px'],
|
||||
'lg' => ['lg', '56px', '68px', '68px'],
|
||||
'xl' => ['xl', '96px', '108px', '108px'],
|
||||
]);
|
||||
|
||||
it('insets an icon in the track from md, but not on a small, range or centred slider', function () {
|
||||
expect((string) $this->blade('<x-slider size="md" icon="volume_up" value="60" />'))
|
||||
->toContain('data-track-icon')
|
||||
->toMatch('/data-track-icon\s+data-active/')
|
||||
->toContain('text-on-secondary-container data-active:text-on-primary');
|
||||
->toMatch('/data-md-slider-icon\s+data-md-active/')
|
||||
->toContain('--md-icon-size: 24px');
|
||||
|
||||
expect((string) $this->blade('<x-slider size="xl" icon="volume_up" value="2" />'))
|
||||
->toMatch('/data-track-icon\s+class/')
|
||||
->toContain('size-8');
|
||||
->toMatch('/data-md-slider-icon\s+style=/')
|
||||
->toContain('--md-icon-size: 32px');
|
||||
|
||||
foreach (['<x-slider size="sm" icon="volume_up" />', '<x-slider size="lg" icon="volume_up" range />', '<x-slider size="lg" icon="volume_up" centered />'] as $template) {
|
||||
expect((string) $this->blade($template))->not->toContain('data-track-icon');
|
||||
expect((string) $this->blade($template))->not->toContain('data-md-slider-icon');
|
||||
}
|
||||
});
|
||||
|
||||
it('fills a centred slider from the middle', function () {
|
||||
expect((string) $this->blade('<x-slider :min="-50" :max="50" value="25" centered />'))
|
||||
->toContain('data-centered')
|
||||
->toMatch('/data-segment="start"\s+class="[^"]*"\s+style="left: calc\(0% \+ 0px\); width: calc\(50% - 6px\)/')
|
||||
->toMatch('/data-segment="active"\s+class="[^"]*"\s+style="left: calc\(50% \+ 0px\); width: calc\(25% - 8px\)/')
|
||||
->toMatch('/data-segment="end"\s+class="[^"]*"\s+style="left: calc\(75% \+ 8px\)/');
|
||||
->toContain('data-md-centered')
|
||||
->toMatch('/data-md-slider-segment="start"\s+style="left: calc\(0% \+ 0px\); width: calc\(50% - 6px\)/')
|
||||
->toMatch('/data-md-slider-segment="active"\s+style="left: calc\(50% \+ 0px\); width: calc\(25% - 8px\)/')
|
||||
->toMatch('/data-md-slider-segment="end"\s+style="left: calc\(75% \+ 8px\)/');
|
||||
});
|
||||
|
||||
it('marks every step when asked, and no more than 200 of them', function () {
|
||||
$html = (string) $this->blade('<x-slider ticks :max="10" value="5" />');
|
||||
|
||||
expect(substr_count($html, 'data-tick="'))->toBe(11)
|
||||
->and($html)->toContain('data-tick="0.5"')
|
||||
->and($html)->toContain('bg-on-secondary-container data-active:bg-on-primary');
|
||||
expect(substr_count($html, 'data-md-slider-tick="'))->toBe(11)
|
||||
->and($html)->toContain('data-md-slider-tick="0.5"')
|
||||
->and($html)->toMatch('/data-md-slider-tick="0.2"\s+data-md-active/')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css'))
|
||||
->toMatch('/\[data-md-slider-tick\]\[data-md-active\] \{\s*background-color: var\(--slider-on-active\);/');
|
||||
|
||||
expect((string) $this->blade('<x-slider ticks :max="1000" />'))->not->toContain('data-tick=')
|
||||
->and((string) $this->blade('<x-slider ticks step="any" />'))->not->toContain('data-tick=')
|
||||
->and((string) $this->blade('<x-slider :max="10" />'))->not->toContain('data-tick=');
|
||||
expect((string) $this->blade('<x-slider ticks :max="1000" />'))->not->toContain('data-md-slider-tick=')
|
||||
->and((string) $this->blade('<x-slider ticks step="any" />'))->not->toContain('data-md-slider-tick=')
|
||||
->and((string) $this->blade('<x-slider :max="10" />'))->not->toContain('data-md-slider-tick=');
|
||||
});
|
||||
|
||||
it('shows the value label on drag, always, or never', function () {
|
||||
expect((string) $this->blade('<x-slider />'))->toContain('opacity-0 scale-75 group-data-pressed/thumb:opacity-100');
|
||||
expect((string) $this->blade('<x-slider />'))->toContain('data-md-value-label="drag"')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css'))
|
||||
->toMatch("/\\[data-md-slider\\]\\[data-md-value-label='drag'\\] \\[data-md-slider-value\\] \\{\\s*opacity: 0;\\s*scale: 0.75;/")
|
||||
->toMatch("/\\[data-md-slider\\]\\[data-md-value-label='always'\\] \\[data-md-slider-control\\] \\{\\s*margin-top: var\\(--md-sys-measurement-space600\\);/");
|
||||
|
||||
expect((string) $this->blade('<x-slider value-label="always" value="30" />'))
|
||||
->toContain('data-value-label')
|
||||
->toContain('mt-12')
|
||||
->not->toContain('opacity-0 scale-75');
|
||||
->toContain('data-md-value-label="always"')
|
||||
->toContain('<span data-md-slider-value>30</span>');
|
||||
|
||||
expect((string) $this->blade('<x-slider value-label="never" />'))->not->toContain('data-value-label');
|
||||
expect((string) $this->blade('<x-slider value-label="never" />'))->not->toContain('data-md-slider-value');
|
||||
});
|
||||
|
||||
it('stands a slider up on request, turning the drawing a quarter', function () {
|
||||
$html = (string) $this->blade('<x-slider label="Volume" orientation="vertical" value="40" class="h-64" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-orientation="vertical"')
|
||||
->toContain('data-md-orientation="vertical"')
|
||||
->toContain('aria-orientation="vertical"')
|
||||
// The slider is as wide as a horizontal one is tall, and as long as its wrapper.
|
||||
->toContain('w-12')
|
||||
->toContain('touch-pan-x h-48 min-h-0 flex-auto [container-type:size]')
|
||||
->toContain('h-[100cqw] w-[calc(100cqh-0.25rem)] -translate-1/2 -rotate-90')
|
||||
// The value label is turned back, so it lands beside the handle and reads across.
|
||||
->toMatch('/data-value-label[^>]*class="[^"]*rotate-90/')
|
||||
->not->toContain('origin-bottom')
|
||||
->not->toContain('rtl:-scale-x-100');
|
||||
->toContain('class="h-64"');
|
||||
|
||||
// The sizes are the horizontal ones, across instead of along.
|
||||
expect((string) $this->blade('<x-slider orientation="vertical" size="xl" />'))->toContain('w-27')
|
||||
->and((string) $this->blade('<x-slider orientation="vertical" value-label="always" />'))->toContain('ms-12');
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
|
||||
|
||||
expect($css)
|
||||
// The slider is as wide as a horizontal one is tall, and as long as its wrapper.
|
||||
->toMatch("/\\[data-md-slider\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-control\\] \\{[^}]*width: var\\(--slider-across\\);[^}]*height: 192px;[^}]*container-type: size;/")
|
||||
->toMatch("/\\[data-md-slider\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-drawing\\] \\{[^}]*width: calc\\(100cqh - var\\(--md-sys-measurement-space50\\)\\);[^}]*height: 100cqw;[^}]*rotate: -90deg;/")
|
||||
// The value label is turned back, so it lands beside the handle and reads across.
|
||||
->toMatch("/\\[data-md-slider\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-value\\] \\{[^}]*rotate: 90deg;/")
|
||||
->toMatch("/\\[data-md-slider\\]\\[data-md-value-label='always'\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-control\\] \\{[^}]*margin-inline-start: var\\(--md-sys-measurement-space600\\);/");
|
||||
});
|
||||
|
||||
it('keeps a range slider horizontal, as M3 asks', function () {
|
||||
expect((string) $this->blade('<x-slider label="Price" range orientation="vertical" />'))
|
||||
->not->toContain('data-orientation')
|
||||
->not->toContain('aria-orientation')
|
||||
->toContain('h-12');
|
||||
->not->toContain('data-md-orientation')
|
||||
->not->toContain('aria-orientation');
|
||||
});
|
||||
|
||||
it('draws in the colour and its container, and greys out when disabled', function (string $color, string $active, string $inactive) {
|
||||
expect((string) $this->blade('<x-slider :color="$color" value="50" disabled />', ['color' => $color]))
|
||||
->toContain("absolute inset-y-0 {$active} group-has-disabled/slider:bg-on-surface/38")
|
||||
->toContain("absolute inset-y-0 {$inactive} group-has-disabled/slider:bg-on-surface/12")
|
||||
->toContain('data-md-color="'.($color === 'pink' ? 'primary' : $color).'"')
|
||||
->toMatch('/<input[^>]*\sdisabled[\s>]/');
|
||||
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
|
||||
|
||||
if ($color === 'primary' || $color === 'pink') {
|
||||
expect($css)->toMatch("/\\[data-md-slider\\] \\{[^}]*--slider-active: var\\(--md-sys-color-{$active}\\);[^}]*--slider-inactive: var\\(--md-sys-color-{$inactive}\\);/");
|
||||
} else {
|
||||
expect($css)->toMatch("/\\[data-md-slider\\]\\[data-md-color='{$color}'\\] \\{[^}]*--slider-active: var\\(--md-sys-color-{$active}\\);[^}]*--slider-inactive: var\\(--md-sys-color-{$inactive}\\);/");
|
||||
}
|
||||
|
||||
expect($css)->toMatch('/\[data-md-slider\]:has\(\[data-md-slider-input\]:disabled\) \{[^}]*--slider-active: var\(--slider-disabled-active\);[^}]*--slider-inactive: var\(--slider-disabled-inactive\);/');
|
||||
})->with([
|
||||
'primary' => ['primary', 'bg-primary', 'bg-secondary-container'],
|
||||
'tertiary' => ['tertiary', 'bg-tertiary', 'bg-tertiary-container'],
|
||||
'error' => ['error', 'bg-error', 'bg-error-container'],
|
||||
'unknown' => ['pink', 'bg-primary', 'bg-secondary-container'],
|
||||
'primary' => ['primary', 'primary', 'secondary-container'],
|
||||
'tertiary' => ['tertiary', 'tertiary', 'tertiary-container'],
|
||||
'error' => ['error', 'error', 'error-container'],
|
||||
'unknown' => ['pink', 'primary', 'secondary-container'],
|
||||
]);
|
||||
|
||||
@@ -19,14 +19,14 @@ it('draws a read-only field that opens the picker in a labelled dialog', functio
|
||||
$html = (string) $this->blade('<x-timepicker id="starts" label="Starts at" hint="In your time zone" value="14:30" format="24" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('<label for="starts" class="field-label">Starts at</label>')
|
||||
->toContain('<label for="starts" data-md-field-label>Starts at</label>')
|
||||
->toMatch('/<input[^>]*id="starts"[^>]*readonly/s')
|
||||
->toContain('value="14:30"')
|
||||
->toContain('aria-haspopup="dialog"')
|
||||
->toContain('aria-controls="starts-dialog"')
|
||||
->toContain('aria-expanded="false"')
|
||||
->toContain('aria-describedby="starts-support"')
|
||||
->toContain('<p id="starts-support" class="field-support">In your time zone</p>')
|
||||
->toMatch('/<p id="starts-support" data-md-field-support\s*>In your time zone<\/p>/')
|
||||
->toContain('x-on:keydown.enter.prevent="show()"')
|
||||
->toContain('data-timepicker-open')
|
||||
->toContain('aria-label="Choose time"')
|
||||
@@ -38,7 +38,7 @@ it('draws a read-only field that opens the picker in a labelled dialog', functio
|
||||
->toContain('x-modelable="value"')
|
||||
->toContain("materialTimepicker( '14:30' ,")
|
||||
->not->toContain('aria-invalid="true"')
|
||||
->not->toContain('data-invalid');
|
||||
->not->toContain('data-md-invalid');
|
||||
});
|
||||
|
||||
it('draws the dial as a slider, with twelve numbers per ring and M3\'s 24-hour inner ring', function () {
|
||||
@@ -153,7 +153,7 @@ it('shows the errors for its property in place of the hint', function () {
|
||||
});
|
||||
|
||||
expect(Livewire::test('timepicker-errors')->html())
|
||||
->toContain('data-invalid')
|
||||
->toContain('data-md-invalid')
|
||||
->toContain('aria-invalid="true"')
|
||||
->toContain('aria-describedby="starts-support"')
|
||||
->toContain('Choose a time during opening hours.')
|
||||
@@ -165,13 +165,13 @@ it('puts the field props on the field, its attributes on the input, and posts a
|
||||
|
||||
expect($html)
|
||||
->toMatch('/^<div\s+class="w-60"/')
|
||||
->toContain('data-variant="filled"')
|
||||
->toContain('data-size="sm"')
|
||||
->toContain('data-md-variant="filled"')
|
||||
->toContain('data-md-size="sm"')
|
||||
->toContain('data-timepicker-field')
|
||||
->toContain('required')
|
||||
->toContain('placeholder="hh:mm"')
|
||||
->toContain('<input type="hidden" name="alarm" value="06:30" x-bind:value="value ?? \'\'" />')
|
||||
->toContain('data-field-clear')
|
||||
->toContain('data-md-field-clear')
|
||||
->toMatch('/<button[^>]*data-timepicker-open[^>]*disabled/s')
|
||||
->and(substr_count($html, 'class="field w-60"'))->toBe(0);
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ it('renders components that use others under a prefix', function () {
|
||||
expect(Blade::render('<x-m::drawer title="Share" with-close-button separator><x-m::input label="Name" clearable /></x-m::drawer>'))
|
||||
->toContain('aria-label="Close"')
|
||||
->toContain('role="separator"')
|
||||
->toContain('data-field-clear');
|
||||
->toContain('data-md-field-clear');
|
||||
});
|
||||
|
||||
it('is not shadowed by an application component of the same name', function () {
|
||||
|
||||
@@ -555,9 +555,13 @@ it('draws the interaction classes as the utilities the Tailwind components still
|
||||
->and(stylesheetBlock($interaction, '.md-touch-target'))->toContain('min-width: var(--md-sys-measurement-space600);');
|
||||
});
|
||||
|
||||
it('declares the material layers in the Workbench before Tailwind\'s', function () {
|
||||
it('declares the material layers in the Workbench between Tailwind\'s preflight and its utilities', function () {
|
||||
$app = stylesheetWithoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
|
||||
|
||||
// The first statement orders the layers: above preflight, whose `* { padding: 0 }` would
|
||||
// otherwise beat a rewritten component's padding, and below the utilities.
|
||||
expect(trim($app))->toStartWith('@layer theme, base, material, components, utilities;');
|
||||
|
||||
$foundation = strpos($app, "@import '../../../resources/css/foundation.css';");
|
||||
$tailwind = strpos($app, "@import 'tailwindcss'");
|
||||
$components = strpos($app, "@import '../../../resources/css/tailwind.css';");
|
||||
|
||||
Reference in New Issue
Block a user