Files
livewire-material/tests/Feature/Components/ChipTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 0fee2a0952
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
Add chips, choices and search
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
2026-09-13 08:07:59 +02:00

273 lines
13 KiB
PHP

<?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');
});