Add chips, choices and search
tests / lint (push) Successful in 1m4s
tests / feature (8.4) (push) Successful in 1m8s
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (chrome, chromium) (push) Successful in 3m6s
tests / browser (firefox, firefox) (push) Successful in 3m45s
tests / browser (safari, webkit) (push) Successful in 5m0s
tests / lint (push) Successful in 1m4s
tests / feature (8.4) (push) Successful in 1m8s
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (chrome, chromium) (push) Successful in 3m6s
tests / browser (firefox, firefox) (push) Successful in 3m45s
tests / browser (safari, webkit) (push) Successful in 5m0s
M3's assist, filter, input and suggestion chips with chip sets; choices as filter chips or a searchable combobox whose list is an anchored popover; and the search bar that opens into a docked or full-screen search view. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
co-authored by
Claude Opus 5
parent
7f92f1cb29
commit
0fee2a0952
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
class ChipProbe extends Component
|
||||
{
|
||||
/** @var list<string> */
|
||||
public array $kinds = ['photos'];
|
||||
|
||||
/** @var list<string> */
|
||||
public array $tags = ['php', 'js', 'css'];
|
||||
|
||||
public function onlyVideo(): void
|
||||
{
|
||||
$this->kinds = ['video'];
|
||||
}
|
||||
|
||||
public function removeTag(string $tag): void
|
||||
{
|
||||
$this->tags = array_values(array_diff($this->tags, [$tag]));
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div class="space-y-4 p-4">
|
||||
<p>kinds: <span id="kinds">{{ implode(',', $kinds) }}</span></p>
|
||||
|
||||
<x-chip-set label="Kinds">
|
||||
@foreach (['photos' => 'Photos', 'documents' => 'Documents', 'video' => 'Video'] as $value => $name)
|
||||
<x-chip type="filter" :label="$name" :value="$value" wire:model.live="kinds" wire:key="kind-{{ $value }}" />
|
||||
@endforeach
|
||||
</x-chip-set>
|
||||
|
||||
<x-button label="Only video" wire:click="onlyVideo" />
|
||||
|
||||
<p>tags: <span id="tags">{{ implode(',', $tags) }}</span></p>
|
||||
|
||||
<x-chip-set label="Tags">
|
||||
@foreach ($tags as $tag)
|
||||
<x-chip type="input" :label="$tag" removable wire:remove="removeTag('{{ $tag }}')" wire:key="tag-{{ $tag }}" />
|
||||
@endforeach
|
||||
</x-chip-set>
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
}
|
||||
|
||||
function chipProbe()
|
||||
{
|
||||
Livewire::component('chip-probe', ChipProbe::class);
|
||||
|
||||
Route::middleware('web')->get('/chip-probe', fn () => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface">
|
||||
<livewire:chip-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
return visit('/chip-probe')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
function chipShowcase()
|
||||
{
|
||||
return visit('/material')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
/**
|
||||
* The script for a showcase filter chip's checkbox, found by its value.
|
||||
*/
|
||||
function filterInput(string $value): string
|
||||
{
|
||||
return "document.querySelector('#chips input[type=\"checkbox\"][value=\"{$value}\"]')";
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
$page = chipShowcase()->assertNoJavaScriptErrors();
|
||||
|
||||
$page->click('#chips label:has-text("Documents")')
|
||||
->assertScript(filterInput('documents').'.checked')
|
||||
->assertScript("Math.round({$check('documents')}) === 18")
|
||||
->assertScript('getComputedStyle('.filterInput('documents').".parentElement).backgroundColor !== 'rgba(0, 0, 0, 0)'")
|
||||
->assertScript("window.eval(\"Alpine.\$data(document.querySelector('#chips input[value=documents]')).kinds.join(',')\") === 'photos,documents'");
|
||||
|
||||
// A key press first, so the browser is in keyboard modality; focused directly, not tabbed to,
|
||||
// because WebKit leaves form controls out of the Tab order unless full keyboard access is on.
|
||||
$page->keys('body', 'Tab');
|
||||
$page->script(filterInput('archives').'.focus()');
|
||||
$page->keys(':focus', 'Space')
|
||||
->assertScript(filterInput('archives').'.checked')
|
||||
->assertScript("Math.round({$check('archives')}) === 18");
|
||||
|
||||
$page->keys(':focus', 'Space')
|
||||
->assertScript('! '.filterInput('archives').'.checked')
|
||||
->assertScript("Math.round({$check('archives')}) === 0");
|
||||
});
|
||||
|
||||
it('binds filter chips to a Livewire array, and a server change reaches them after a morph', function () {
|
||||
$input = fn (string $value): string => "document.querySelector('input[value=\"{$value}\"]')";
|
||||
|
||||
$page = chipProbe()->assertNoJavaScriptErrors();
|
||||
|
||||
$page->assertScript("{$input('photos')}.checked && ! {$input('documents')}.checked");
|
||||
|
||||
$page->click('label:has-text("Documents")')
|
||||
->assertSeeIn('#kinds', 'photos,documents');
|
||||
|
||||
$page->click('button:has-text("Only video")')
|
||||
->assertSeeIn('#kinds', 'video')
|
||||
->assertScript("! {$input('photos')}.checked && ! {$input('documents')}.checked && {$input('video')}.checked")
|
||||
->assertScript("getComputedStyle({$input('video')}.parentElement).backgroundColor !== 'rgba(0, 0, 0, 0)'")
|
||||
->assertScript("getComputedStyle({$input('photos')}.parentElement).backgroundColor === 'rgba(0, 0, 0, 0)'");
|
||||
});
|
||||
|
||||
it('removes a Livewire input chip with its button and with Delete, focusing the chip after it', function () {
|
||||
$page = chipProbe();
|
||||
|
||||
$page->click('button[aria-label="Remove js"]')
|
||||
->assertSeeIn('#tags', 'php,css')
|
||||
->assertScript("document.querySelector('button[aria-label=\"Remove js\"]') === null");
|
||||
|
||||
$page->script("document.querySelector('button[aria-label=\"Remove php\"]').focus()");
|
||||
|
||||
$page->keys(':focus', 'Delete')
|
||||
->assertScript("document.querySelector('#tags').textContent === 'css'")
|
||||
->assertScript("document.querySelector('button[aria-label=\"Remove php\"]') === null")
|
||||
->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove css'");
|
||||
});
|
||||
|
||||
it('removes an Alpine input chip with Backspace, focusing the chip before it', function () {
|
||||
$remove = fn (string $who): string => "document.querySelector('#chips button[aria-label=\"Remove {$who}@example.com\"]')";
|
||||
|
||||
$page = chipShowcase();
|
||||
|
||||
// Named in the browser: the chips come from x-for, so the server never knew their labels.
|
||||
$page->assertScript("{$remove('ben')} !== null");
|
||||
$page->script("{$remove('ben')}.focus()");
|
||||
|
||||
$page->keys(':focus', 'Backspace')
|
||||
->assertScript("{$remove('ben')} === null")
|
||||
->assertScript("{$remove('anna')} !== null && {$remove('chiara')} !== null")
|
||||
->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove anna@example.com'");
|
||||
});
|
||||
|
||||
it('scrolls a chip set sideways, fading the edge it can still scroll towards', function () {
|
||||
$row = "document.querySelector('#chips [data-chip-set][x-data]')";
|
||||
|
||||
$page = chipShowcase();
|
||||
|
||||
$page->assertScript("{$row}.scrollWidth > {$row}.clientWidth")
|
||||
->assertScript("{$row}.hasAttribute('data-scroll-end') && ! {$row}.hasAttribute('data-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'");
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
class PickProbe extends Component
|
||||
{
|
||||
/** @var list<int> */
|
||||
public array $days = [2];
|
||||
|
||||
public ?string $zone = 'Europe/Zurich';
|
||||
|
||||
public string $query = '';
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div class="grid max-w-md gap-6 p-4">
|
||||
<p>days: <span id="days">{{ json_encode($days) }}</span></p>
|
||||
<p>zone: <span id="zone">{{ $zone }}</span></p>
|
||||
<p>query: <span id="query">{{ $query }}</span></p>
|
||||
|
||||
<x-choices label="Days" wire:model.live="days" :options="[['id' => 1, 'name' => 'Mon'], ['id' => 2, 'name' => 'Tue'], ['id' => 3, 'name' => 'Wed']]" />
|
||||
|
||||
<div id="clip" class="h-24 overflow-hidden rounded-corner-md bg-surface-container p-2">
|
||||
<x-choices id="zone-field" label="Time zone" searchable wire:model.live="zone" :options="[['id' => 'Europe/Berlin', 'name' => 'Berlin'], ['id' => 'Europe/Zurich', 'name' => 'Zurich'], ['id' => 'UTC', 'name' => 'UTC', 'disabled' => true]]" />
|
||||
</div>
|
||||
|
||||
<x-search id="find" wire:model.live="query" placeholder="Search files">
|
||||
@foreach (array_filter(['holiday.zip', 'contract.pdf', 'review.mp4'], fn ($file) => $query === '' || str_contains($file, $query)) as $file)
|
||||
<button type="button" wire:key="result-{{ $file }}" class="block w-full px-4 py-3 text-start">{{ $file }}</button>
|
||||
@endforeach
|
||||
<x-slot:empty>No files match.</x-slot:empty>
|
||||
</x-search>
|
||||
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
}
|
||||
|
||||
function pickProbe()
|
||||
{
|
||||
Livewire::component('pick-probe', PickProbe::class);
|
||||
|
||||
Route::middleware('web')->get('/pick-probe', fn () => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface">
|
||||
<livewire:pick-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
return visit('/pick-probe')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
it('keeps the values of filter chips as the property\'s own type', function () {
|
||||
pickProbe()
|
||||
->assertAttribute('button[aria-pressed="true"]', 'aria-pressed', 'true')
|
||||
->click('button:has-text("Mon")')
|
||||
->assertSeeIn('#days', '[2,1]')
|
||||
->click('button:has-text("Tue")')
|
||||
->assertSeeIn('#days', '[1]')
|
||||
->assertScript("[...document.querySelectorAll('[aria-pressed=\"true\"]')].map((chip) => chip.textContent.trim()).join() === 'Mon'");
|
||||
});
|
||||
|
||||
it('filters a searchable choice as it is typed and chooses from the keyboard', function () {
|
||||
$list = "document.querySelector('#zone-field-list')";
|
||||
|
||||
$page = pickProbe()
|
||||
->assertValue('#zone-field', 'Zurich')
|
||||
->click('#zone-field')
|
||||
->assertScript("{$list}.matches(':popover-open')")
|
||||
->assertAttribute('#zone-field', 'aria-expanded', 'true');
|
||||
|
||||
$page->type('#zone-field', 'ber')
|
||||
->assertScript("{$list}.querySelectorAll('[role=\"option\"]').length === 1");
|
||||
|
||||
$page->keys('#zone-field', 'Enter')
|
||||
->assertSeeIn('#zone', 'Europe/Berlin')
|
||||
->assertValue('#zone-field', 'Berlin')
|
||||
->assertScript("! {$list}.matches(':popover-open')");
|
||||
});
|
||||
|
||||
it('puts a searchable choice back on Escape and never chooses a disabled option', function () {
|
||||
$page = pickProbe()->click('#zone-field')->type('#zone-field', 'ber');
|
||||
|
||||
$page->keys('#zone-field', 'Escape')
|
||||
->assertValue('#zone-field', 'Zurich')
|
||||
->assertSeeIn('#zone', 'Europe/Zurich');
|
||||
|
||||
$page->click('#zone-field')->type('#zone-field', 'UTC')->keys('#zone-field', 'Enter')
|
||||
->assertSeeIn('#zone', 'Europe/Zurich');
|
||||
});
|
||||
|
||||
it('opens a searchable choice\'s list above a container that clips', function () {
|
||||
pickProbe()
|
||||
->click('#zone-field')
|
||||
->assertScript(<<<'JS'
|
||||
(() => {
|
||||
const list = document.querySelector('#zone-field-list');
|
||||
const clip = document.querySelector('#clip').getBoundingClientRect();
|
||||
const box = list.getBoundingClientRect();
|
||||
const probe = document.elementFromPoint(box.left + box.width / 2, box.bottom - 8);
|
||||
|
||||
return box.bottom > clip.bottom && list.contains(probe);
|
||||
})()
|
||||
JS);
|
||||
});
|
||||
|
||||
it('opens the search view with the results Livewire renders for the query', function () {
|
||||
$view = "document.querySelector('#find-view')";
|
||||
|
||||
$page = pickProbe()
|
||||
->click('#find')
|
||||
->assertScript("getComputedStyle({$view}).display !== 'none'")
|
||||
->assertAttribute('#find', 'aria-expanded', 'true');
|
||||
|
||||
$page->type('#find', 'con')
|
||||
->assertSeeIn('#query', 'con')
|
||||
->assertScript("[...{$view}.querySelectorAll('button')].map((button) => button.textContent.trim()).join() === 'contract.pdf'");
|
||||
|
||||
$page->keys('#find', 'ArrowDown')
|
||||
->assertScript("document.activeElement.textContent.trim() === 'contract.pdf'");
|
||||
|
||||
$page->keys(':focus', 'Escape')
|
||||
->assertScript("getComputedStyle({$view}).display === 'none'")
|
||||
->assertScript("document.activeElement.id === 'find'")
|
||||
->assertAttribute('#find', 'aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('says so when nothing matches, and closes on a press outside or a chosen result', function () {
|
||||
$view = "document.querySelector('#find-view')";
|
||||
|
||||
$page = pickProbe()
|
||||
->click('#find')
|
||||
->type('#find', 'zzz')
|
||||
->assertSeeIn('#find-view', 'No files match.');
|
||||
|
||||
// The open view covers what is under it, so the press lands above it.
|
||||
$page->click('#days')
|
||||
->assertScript("getComputedStyle({$view}).display === 'none'");
|
||||
|
||||
$page->click('#find')
|
||||
->clear('#find')
|
||||
->assertScript("{$view}.querySelectorAll('button').length === 3")
|
||||
->click('#find-view button:has-text("review.mp4")')
|
||||
->assertScript("getComputedStyle({$view}).display === 'none'");
|
||||
});
|
||||
|
||||
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("(() => { 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')");
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* The opening tag of the first element in the rendered Blade that matches `$tag`.
|
||||
*/
|
||||
function chipTag(string $blade, string $tag = '[a-z]+'): string
|
||||
{
|
||||
preg_match('/<(?:'.$tag.')\b[^>]*>/s', (string) test()->blade($blade), $matches);
|
||||
|
||||
return $matches[0] ?? '';
|
||||
}
|
||||
|
||||
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')
|
||||
->and($html)
|
||||
->toContain('me-2 size-4.5 text-primary')
|
||||
->toContain('ms-2 size-4.5 text-primary')
|
||||
->toContain('Add to calendar');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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"');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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')
|
||||
->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');
|
||||
});
|
||||
|
||||
it('links with wire:navigate, and a disabled link leaves the tab order', function () {
|
||||
expect(chipTag('<x-chip label="Directions" link="/directions" />', 'a'))
|
||||
->toContain('href="/directions"')
|
||||
->toContain('wire:navigate')
|
||||
->not->toContain('type=')
|
||||
->and(chipTag('<x-chip label="Guide" link="https://m3.material.io" external />', 'a'))
|
||||
->toContain('target="_blank"')
|
||||
->toContain('rel="noopener"')
|
||||
->not->toContain('wire:navigate')
|
||||
->and(chipTag('<x-chip label="Directions" link="/directions" disabled />', 'a'))
|
||||
->toContain('aria-disabled="true"')
|
||||
->toContain('tabindex="-1"')
|
||||
->toContain('pointer-events-none');
|
||||
});
|
||||
|
||||
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')
|
||||
->and(chipTag('<x-chip type="suggestion" label="Sounds good" elevated />', 'button'))
|
||||
->toContain('bg-surface-container-low text-on-surface-variant shadow-elevation-1');
|
||||
});
|
||||
|
||||
it('attaches a plain tooltip', function () {
|
||||
$html = (string) $this->blade('<x-chip label="Share" tooltip="Share this file" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('popover="manual"')
|
||||
->toContain('Share this file')
|
||||
->toMatch('/<button[^>]*style="anchor-name: --material-chip-[a-z0-9]{10}"/');
|
||||
});
|
||||
|
||||
it('makes a filter chip without a model a toggle button the caller owns', function () {
|
||||
$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('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');
|
||||
});
|
||||
|
||||
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')
|
||||
->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')
|
||||
->not->toContain('me-4')
|
||||
->not->toContain('checked')
|
||||
->and($html)
|
||||
->toContain('group-has-checked/chip:w-4.5');
|
||||
});
|
||||
|
||||
it('submits a named filter chip, checked when selected, and greys a disabled one', function () {
|
||||
expect(chipTag('<x-chip type="filter" label="Starred" name="starred" :selected="true" />', 'input'))
|
||||
->toContain('name="starred"')
|
||||
->toContain('checked')
|
||||
->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');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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')
|
||||
->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>')
|
||||
->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');
|
||||
});
|
||||
|
||||
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('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');
|
||||
});
|
||||
|
||||
it('gives a removable input chip a named remove button that calls wire:remove', function () {
|
||||
$html = (string) $this->blade('<x-chip type="input" label="anna@example.com" removable wire:remove="removeRecipient(3)" name="to[]" value="3" />');
|
||||
|
||||
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)"')
|
||||
->not->toContain('wire:remove')
|
||||
->and($html)
|
||||
->toContain('data-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');
|
||||
});
|
||||
|
||||
it('runs an Alpine remove expression, or takes the chip off the page with neither', function () {
|
||||
expect((string) $this->blade('<x-chip type="input" label="php" removable remove="tags.splice(0, 1)" />'))
|
||||
->toContain('x-on:click="removeChip($event); tags.splice(0, 1)"')
|
||||
->not->toContain('wire:click')
|
||||
->and((string) $this->blade('<x-chip type="input" label="php" removable />'))
|
||||
->toContain('x-on:click="removeChip($event); $root.remove()"');
|
||||
});
|
||||
|
||||
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"')
|
||||
->not->toContain('aria-label=');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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>');
|
||||
|
||||
preg_match('/aria-labelledby="([^"]+)"/', $html, $labelledBy);
|
||||
preg_match('/aria-describedby="([^"]+)"/', $html, $describedBy);
|
||||
|
||||
expect($html)
|
||||
->toContain('role="group"')
|
||||
->toContain('data-chip-set class="flex flex-wrap gap-2"')
|
||||
->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>")
|
||||
->not->toContain('materialChipSet');
|
||||
});
|
||||
|
||||
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"')
|
||||
->toContain('wire:ignore.self')
|
||||
->toContain('overflow-x-auto')
|
||||
->toContain('data-scroll-start:[--chip-fade-start:1.5rem]')
|
||||
->not->toContain('flex-wrap');
|
||||
});
|
||||
|
||||
it('binds filter chips to a Livewire array and shows the set\'s validation message in place of its hint', function () {
|
||||
$component = new class extends Component
|
||||
{
|
||||
/** @var list<string> */
|
||||
public array $kinds = ['photos'];
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate(['kinds' => 'max:1'], ['kinds.max' => 'Pick one kind.']);
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div>
|
||||
<x-chip-set label="Kinds" hint="Show only these" error-field="kinds">
|
||||
<x-chip type="filter" label="Photos" value="photos" wire:model.live="kinds" />
|
||||
<x-chip type="filter" label="Documents" value="documents" wire:model.live="kinds" />
|
||||
</x-chip-set>
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
};
|
||||
|
||||
Livewire::test($component)
|
||||
->assertSeeHtml('wire:model.live="kinds"')
|
||||
->assertSeeHtml('value="photos" checked')
|
||||
->assertDontSeeHtml('value="documents" checked')
|
||||
->assertSee('Show only these')
|
||||
->set('kinds', ['photos', 'documents'])
|
||||
->assertSeeHtml('value="documents" checked')
|
||||
->call('save')
|
||||
->assertSee('Pick one kind.')
|
||||
->assertDontSee('Show only these');
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
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)
|
||||
->toContain('role="group"')
|
||||
->toContain('Days')
|
||||
->toContain('Pick any')
|
||||
->toContain('x-modelable="selection"')
|
||||
->toContain('x-on:click="toggle(0)"')
|
||||
->toContain('x-bind:aria-pressed="selected(2).toString()"')
|
||||
->and(substr_count($html, 'aria-pressed="true"'))->toBe(1)
|
||||
->and(substr_count($html, 'aria-pressed="false"'))->toBe(2)
|
||||
->and($html)->toContain('disabled');
|
||||
});
|
||||
|
||||
it('entangles the selection with its Livewire property and renders it as the property says', function () {
|
||||
Livewire::component('choices-probe', new class extends Component
|
||||
{
|
||||
/** @var list<int> */
|
||||
public array $days = [1, 3];
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return '<div><x-choices wire:model.live="days" :options="[[\'id\' => 1, \'name\' => \'Mon\'], [\'id\' => 2, \'name\' => \'Tue\'], [\'id\' => 3, \'name\' => \'Wed\']]" /></div>';
|
||||
}
|
||||
});
|
||||
|
||||
$html = Livewire::test('choices-probe')->html();
|
||||
|
||||
expect($html)
|
||||
->toContain(".entangle('days').live")
|
||||
->not->toContain('x-modelable')
|
||||
->and(substr_count($html, 'aria-pressed="true"'))->toBe(2);
|
||||
});
|
||||
|
||||
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)
|
||||
->toContain('role="combobox"')
|
||||
->toContain('aria-controls="zone-list"')
|
||||
->toContain('aria-expanded="false"')
|
||||
->toContain('<ul')
|
||||
->toContain('id="zone-list"')
|
||||
->toContain('role="listbox"')
|
||||
->toContain('popover="manual"')
|
||||
->toMatch('/anchor-name: (--material-choices-[a-z0-9]{10})/')
|
||||
->toContain('x-modelable="value"')
|
||||
->toContain('field-arrow')
|
||||
->toContain('Nothing matches');
|
||||
|
||||
preg_match('/anchor-name: (--material-choices-[a-z0-9]{10})/', $html, $anchor);
|
||||
|
||||
expect($html)->toContain("position-anchor: {$anchor[1]}");
|
||||
});
|
||||
|
||||
it('shows the errors for the property and its items', function () {
|
||||
Livewire::component('choices-errors', new class extends Component
|
||||
{
|
||||
/** @var list<int> */
|
||||
public array $days = [];
|
||||
|
||||
public ?string $zone = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->addError('days.0', 'Mon is not a working day.');
|
||||
$this->addError('zone', 'Choose a time zone.');
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div>
|
||||
<x-choices wire:model="days" :options="[['id' => 1, 'name' => 'Mon']]" />
|
||||
<x-choices wire:model="zone" searchable :options="[['id' => 'UTC', 'name' => 'UTC']]" />
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
});
|
||||
|
||||
expect(Livewire::test('choices-errors')->html())
|
||||
->toContain('Mon is not a working day.')
|
||||
->toContain('Choose a time zone.')
|
||||
->toContain('aria-invalid="true"');
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
it('draws a search bar whose input 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('role="search"')
|
||||
->toContain('type="search"')
|
||||
->toContain('placeholder="Search shares"')
|
||||
->toContain('aria-label="Search shares"')
|
||||
->toContain('aria-controls="find-view"')
|
||||
->toContain('aria-expanded="false"')
|
||||
->toContain('wire:model.live.debounce.300ms="query"')
|
||||
->toContain('id="find-view"')
|
||||
->toContain('data-search-clear')
|
||||
->toContain('aria-label="Back"')
|
||||
->toContain('materialSearch(false)')
|
||||
->not->toContain('data-search-results');
|
||||
});
|
||||
|
||||
it('shows the results, 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')
|
||||
->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.');
|
||||
});
|
||||
|
||||
it('names the input, trails the bar, and stays docked on request', function () {
|
||||
$html = (string) $this->blade('<x-search label="Search your shares" icon="travel_explore" docked><x-slot:trailing><button>AR</button></x-slot:trailing></x-search>');
|
||||
|
||||
expect($html)
|
||||
->toContain('placeholder="Search"')
|
||||
->toContain('aria-label="Search your shares"')
|
||||
->toContain('data-search-trailing')
|
||||
->toContain('<button>AR</button>')
|
||||
->toContain('materialSearch(true)');
|
||||
});
|
||||
Reference in New Issue
Block a user