diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 49d6c7e5..088cb690 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -361,6 +361,42 @@ An M3 bottom sheet, bound like ``: modal by default (scrim, inert page, A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). ``: `layout` (`multi-browse` default, `hero`, `uncontained`, `full-screen`), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, 0), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). ``: slot is an `` (fills and crops) or an element sized `size-full`; `label` overlays a line of text. A focusable `region` of `slide` groups named "n of m"; arrow keys move one item while the row has focus, Home/End to the ends. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop. +### `` + +One component for M3's four chips, picked by `type`: + +| `type` | What it is | Element | +|---|---|---| +| `assist` (default) | an action | ` + @endif + + @if (filled($name)) + + @endif + + @if ($tooltip !== null) + + @endif + +@else + <{{ $tag }} {{ $container }}> + @if ($checkbox) + + @endif + + @if ($kind === 'filter') + {{-- The check grows in from nothing; beside an icon it takes the icon's place. --}} + filled($icon), + 'h-4.5 w-0 transition-[width] duration-(--md-sys-motion-effects-default-duration) ease-effects-default group-has-checked/chip:w-4.5 group-has-checked/chip:duration-(--md-sys-motion-spatial-fast-duration) group-has-checked/chip:ease-spatial-fast group-aria-pressed/chip:w-4.5 group-aria-pressed/chip:duration-(--md-sys-motion-spatial-fast-duration) group-aria-pressed/chip:ease-spatial-fast' => blank($icon), + ])> + @if ($icon) + + @endif + + + @elseif ($icon) + + @endif + + {{ $label ?? $slot }} + + @if ($iconRight) + + @endif + + @if ($tooltip !== null) + + @endif + +@endif diff --git a/resources/views/components/choices.blade.php b/resources/views/components/choices.blade.php new file mode 100644 index 00000000..293f9086 --- /dev/null +++ b/resources/views/components/choices.blade.php @@ -0,0 +1,218 @@ +{{-- Choosing from a list: M3's filter chips, or a searchable field with a menu. + + Two shapes for two jobs, both on `options` (`['id' => …, 'name' => …]`, read through + `option-value` and `option-label`), `label`, `hint` and `single`: + + - **Chips**, the default. Every option is on screen and a press turns it on or off — "the days + you are free" is seven filter chips, not a dropdown opened seven times. `single` makes them + choice chips, one on at a time. + - **`searchable`**, for a list too long to lay out — four hundred time zones. A text field + that filters as you type, with the matches in M3's menu under it: the arrow keys move, + Enter chooses, Escape puts the field back. One value only. The list is a popover in the top + layer, placed by CSS anchor positioning, so a card's clipping never cuts it off. `icon`, + `variant` and `placeholder` are the field's. + + The selection lives in Alpine, entangled with the `wire:model` property (live with + `wire:model.live`), rather than in native checkboxes, which would send every value back as a + string: a list of integers stays a list of integers. Without `wire:model` it works on its own + and binds with `x-model`. Errors for the property and its items replace the hint. When the + options change on the server, give the component a `wire:key` that changes with them: the + options are baked into its Alpine state. --}} + +@props([ + 'label' => null, + 'hint' => null, + 'icon' => null, + 'options' => [], + 'optionValue' => 'id', + 'optionLabel' => 'name', + 'single' => false, + 'searchable' => false, + 'variant' => null, + 'placeholder' => null, + 'value' => null, +]) + +@php + $model = $attributes->wire('model')->value() ?: null; + $messages = $model !== null && isset($errors) + ? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')]))) + : []; + $choices = collect($options)->map(fn ($option): array => [ + 'value' => data_get($option, $optionValue), + 'label' => (string) data_get($option, $optionLabel), + 'disabled' => (bool) data_get($option, 'disabled', false), + ])->values()->all(); + $single = $single || $searchable; + + // Rendered as the bound property already says, so nothing flips once Alpine starts. + $current = $value; + if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) { + $current = data_get($component, $model); + } + $isSelected = fn ($candidate): bool => $single + ? $current !== null && (string) $current === (string) $candidate + : in_array((string) $candidate, array_map('strval', array_filter((array) $current, 'is_scalar')), true); + + $id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|choices'), 0, 12); + $anchor = '--material-choices-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)); +@endphp + +@if ($searchable) +
only(['class', 'wire:key', 'x-model']) }} + x-data="{ + @if ($model !== null) value: @entangle($attributes->wire('model')), @else value: @js($current), @endif + options: @js($choices), + query: '', + open: false, + active: 0, + selecting: false, + get selectedLabel() { + return this.options.find((option) => option.value === this.value)?.label ?? ''; + }, + get filtered() { + const query = this.query.trim().toLowerCase(); + + return query === '' || this.query === this.selectedLabel + ? this.options + : this.options.filter((option) => option.label.toLowerCase().includes(query)); + }, + init() { + this.query = this.selectedLabel; + this.$watch('value', () => { if (! this.open) this.query = this.selectedLabel; }); + this.$watch('open', (open) => { + const list = this.$refs.list; + if (open && ! list.matches(':popover-open')) list.showPopover(); + if (! open && list.matches(':popover-open')) list.hidePopover(); + }); + }, + show() { + this.open = true; + this.active = Math.max(0, this.filtered.findIndex((option) => option.value === this.value)); + this.$nextTick(() => this.reveal()); + }, + close() { + this.open = false; + this.query = this.selectedLabel; + }, + move(step) { + if (! this.open) return this.show(); + if (this.filtered.length === 0) return; + this.active = (this.active + step + this.filtered.length) % this.filtered.length; + this.$nextTick(() => this.reveal()); + }, + choose(option) { + if (! option || option.disabled) return; + this.value = option.value; + this.query = option.label; + this.open = false; + }, + reveal() { + this.$refs.list.querySelector('[data-active]')?.scrollIntoView({ block: 'nearest' }); + }, + }" + @if ($model === null) x-modelable="value" @endif + > +
+ + + + + + + +
+ +
    + +
  • {{ __('Nothing matches') }}
  • +
+
+@else +
only(['class', 'wire:key', 'x-model']) }} + x-data="{ + @if ($model !== null) selection: @entangle($attributes->wire('model')), @else selection: @js($current ?? ($single ? null : [])), @endif + options: @js(array_column($choices, 'value')), + selected(index) { + const value = this.options[index]; + + return @js($single) ? this.selection === value : (this.selection ?? []).includes(value); + }, + toggle(index) { + const value = this.options[index]; + + if (@js($single)) { + this.selection = value; + + return; + } + + const current = this.selection ?? []; + this.selection = current.includes(value) ? current.filter((each) => each !== value) : [...current, value]; + }, + }" + @if ($model === null) x-modelable="selection" @endif + > + + @foreach ($choices as $index => $choice) + + @endforeach + +
+@endif diff --git a/resources/views/components/search.blade.php b/resources/views/components/search.blade.php new file mode 100644 index 00000000..0dd13fa5 --- /dev/null +++ b/resources/views/components/search.blade.php @@ -0,0 +1,91 @@ +{{-- M3's search: a search bar that opens into a search view with the results. + + Bind the input like any other (`wire:model.live.debounce.300ms="query"`) and render the + results in the slot, from a Livewire property or computed property that follows the query; + `empty` is shown instead when the slot renders nothing (say, "No shares match"). Results are + usually ``s with a `link`, or buttons: choosing one closes the view. + + Docked under the bar from `sm`, full screen below it with a back arrow (resources/css/ + components/search.css); `docked` keeps it docked at every width. `placeholder` ("Search"), + `label` (the input's name when it differs from the placeholder), leading `icon` (`search`), and + a `trailing` slot for an avatar or icon buttons in the bar. Every other attribute reaches the + ``. --}} + +@props([ + 'placeholder' => null, + 'label' => null, + 'icon' => 'search', + 'docked' => false, +]) + +@php + $model = $attributes->wire('model')->value() ?: null; + $placeholder ??= __('Search'); + $id = $attributes->get('id') ?? 'material-search-'.substr(md5($model.'|'.$placeholder), 0, 10); +@endphp + +
only(['class', 'wire:key'])->class(['relative']) }} +> +
+ + + + + + + except(['class', 'wire:key', 'id', 'placeholder', 'type']) }} + x-ref="input" + id="{{ $id }}" + type="search" + autocomplete="off" + enterkeyhint="search" + placeholder="{{ $placeholder }}" + aria-label="{{ $label ?? $placeholder }}" + aria-controls="{{ $id }}-view" + aria-expanded="false" + x-bind:aria-expanded="open.toString()" + x-on:focus="focused()" + x-on:click="show()" + x-on:input="show()" + x-on:keydown.arrow-down.prevent="show(); $nextTick(() => step(1))" + data-search-input + /> + + + + @isset($trailing) + {{ $trailing }} + @endisset +
+ +
+ @if ($slot->hasActualContent()) +
{{ $slot }}
+ @elseif (isset($empty)) +

{{ $empty }}

+ @endif +
+
diff --git a/resources/views/showcase/index.blade.php b/resources/views/showcase/index.blade.php index a5208c45..c3e18434 100644 --- a/resources/views/showcase/index.blade.php +++ b/resources/views/showcase/index.blade.php @@ -19,5 +19,6 @@ @include('livewire-material::showcase.sections.containment') @include('livewire-material::showcase.sections.carousel') @include('livewire-material::showcase.sections.fields') + @include('livewire-material::showcase.sections.chips') @endsection diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php index d5477b6b..28f67f42 100644 --- a/resources/views/showcase/layout.blade.php +++ b/resources/views/showcase/layout.blade.php @@ -18,7 +18,7 @@
Livewire Material diff --git a/resources/views/showcase/sections/chips.blade.php b/resources/views/showcase/sections/chips.blade.php new file mode 100644 index 00000000..42de4e12 --- /dev/null +++ b/resources/views/showcase/sections/chips.blade.php @@ -0,0 +1,79 @@ +@php + $examples = [ + 'Assist chips' => <<<'BLADE' +
+ + + + + + +
+ BLADE, + 'Filter chips' => <<<'BLADE' +
+ + + + + + + + + + + + + +
+ BLADE, + 'Input chips' => <<<'BLADE' +
+ + + + + + + + + + +
+ BLADE, + 'Suggestion chips' => <<<'BLADE' + + + + + + + BLADE, + 'A set that scrolls' => <<<'BLADE' +
+ + + + + + + + +
+ BLADE, + ]; +@endphp + +
+

Chips

+ +

+ <x-chip> — assist, filter, input and suggestion — and <x-chip-set>. +

+ + @foreach ($examples as $title => $code) + + @endforeach +
diff --git a/resources/views/showcase/sections/fields.blade.php b/resources/views/showcase/sections/fields.blade.php index 7a4defd9..56a6ad39 100644 --- a/resources/views/showcase/sections/fields.blade.php +++ b/resources/views/showcase/sections/fields.blade.php @@ -98,6 +98,39 @@ BLADE, + 'Choices' => <<<'BLADE' +
+
+ + +
+ +
+ + +
+
+ BLADE, + 'Search' => <<<'BLADE' +
+
+ + + + + + + + AR + + +
+ + + Type to search your settings. + +
+ BLADE, 'A form' => <<<'BLADE' @@ -121,7 +154,7 @@

<x-input>, <x-password>, <x-textarea>, <x-select>, <x-file> - (all on <x-field>, outlined or filled), <x-checkbox>, <x-radio>, <x-toggle> and <x-form>. + (all on <x-field>, outlined or filled), <x-checkbox>, <x-radio>, <x-toggle>, <x-choices>, <x-search> and <x-form>.

@foreach ($examples as $title => $code) diff --git a/tests/Browser/ChipsTest.php b/tests/Browser/ChipsTest.php new file mode 100644 index 00000000..dd1613d2 --- /dev/null +++ b/tests/Browser/ChipsTest.php @@ -0,0 +1,174 @@ + */ + public array $kinds = ['photos']; + + /** @var list */ + 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' +
+

kinds: {{ implode(',', $kinds) }}

+ + + @foreach (['photos' => 'Photos', 'documents' => 'Documents', 'video' => 'Video'] as $value => $name) + + @endforeach + + + + +

tags: {{ implode(',', $tags) }}

+ + + @foreach ($tags as $tag) + + @endforeach + +
+ BLADE; + } +} + +function chipProbe() +{ + Livewire::component('chip-probe', ChipProbe::class); + + Route::middleware('web')->get('/chip-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + 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'"); +}); diff --git a/tests/Browser/PickingTest.php b/tests/Browser/PickingTest.php new file mode 100644 index 00000000..bfd3abff --- /dev/null +++ b/tests/Browser/PickingTest.php @@ -0,0 +1,168 @@ + */ + public array $days = [2]; + + public ?string $zone = 'Europe/Zurich'; + + public string $query = ''; + + public function render(): string + { + return <<<'BLADE' +
+

days: {{ json_encode($days) }}

+

zone: {{ $zone }}

+

query: {{ $query }}

+ + + +
+ +
+ + + @foreach (array_filter(['holiday.zip', 'contract.pdf', 'review.mp4'], fn ($file) => $query === '' || str_contains($file, $query)) as $file) + + @endforeach + No files match. + + +
+ BLADE; + } +} + +function pickProbe() +{ + Livewire::component('pick-probe', PickProbe::class); + + Route::middleware('web')->get('/pick-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + 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')"); +}); diff --git a/tests/Feature/Components/ChipTest.php b/tests/Feature/Components/ChipTest.php new file mode 100644 index 00000000..02bce831 --- /dev/null +++ b/tests/Feature/Components/ChipTest.php @@ -0,0 +1,272 @@ +]*>/s', (string) test()->blade($blade), $matches); + + return $matches[0] ?? ''; +} + +it('is an outlined assist chip, a button, by default', function () { + $html = (string) $this->blade(''); + + expect(chipTag('', '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('', '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('', 'button'))->toContain('data-chip="assist"'); +}); + +it('draws an elevated chip on surface-container-low at elevation 1', function () { + expect(chipTag('', '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('', 'button')) + ->toContain('disabled') + ->toContain('border-on-surface/12 text-on-surface/38') + ->and(chipTag('', 'button')) + ->toContain('bg-on-surface/12 text-on-surface/38') + ->and((string) $this->blade('')) + ->not->toContain('text-primary'); +}); + +it('links with wire:navigate, and a disabled link leaves the tab order', function () { + expect(chipTag('', 'a')) + ->toContain('href="/directions"') + ->toContain('wire:navigate') + ->not->toContain('type=') + ->and(chipTag('', 'a')) + ->toContain('target="_blank"') + ->toContain('rel="noopener"') + ->not->toContain('wire:navigate') + ->and(chipTag('', 'a')) + ->toContain('aria-disabled="true"') + ->toContain('tabindex="-1"') + ->toContain('pointer-events-none'); +}); + +it('draws a suggestion chip in on-surface-variant', function () { + expect(chipTag('', 'button')) + ->toContain('data-chip="suggestion"') + ->toContain('border-outline-variant text-on-surface-variant') + ->and(chipTag('', 'button')) + ->toContain('bg-surface-container-low text-on-surface-variant shadow-elevation-1'); +}); + +it('attaches a plain tooltip', function () { + $html = (string) $this->blade(''); + + expect($html) + ->toContain('popover="manual"') + ->toContain('Share this file') + ->toMatch('/]*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(''); + + expect(chipTag('', '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('', '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(''); + + expect(chipTag('', '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('', '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('', 'input')) + ->toContain('name="starred"') + ->toContain('checked') + ->and(chipTag('', 'input')) + ->toContain('disabled') + ->and(chipTag('', '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(''); + + 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, 'toBe(2); +}); + +it('draws an input chip with an avatar, an icon or neither', function () { + expect(chipTag('', 'span')) + ->toContain('data-chip="input"') + ->toContain('border-outline-variant text-on-surface-variant') + ->not->toContain('x-data') + ->and((string) $this->blade('')) + ->toContain('ps-0.75 pe-2.75') + ->toContain('size-6 shrink-0 place-items-center rounded-corner-full bg-primary-container') + ->toContain('>AM') + ->not->toContain('and((string) $this->blade('')) + ->toContain('and((string) $this->blade('')) + ->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(''); + + expect(chipTag('', 'button')) + ->toContain('data-chip-action') + ->toContain('aria-pressed="true"') + ->toContain('wire:click="open(1)"') + ->and(chipTag('', '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(''); + + expect(chipTag('', '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('') + ->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('')) + ->toContain('x-on:click="removeChip($event); tags.splice(0, 1)"') + ->not->toContain('wire:click') + ->and((string) $this->blade('')) + ->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('')) + ->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(''); + + expect($html) + ->toContain('border-on-surface/12 text-on-surface/38') + ->toMatch('/]*data-chip-remove[^>]*disabled/s'); +}); + +it('groups chips in a wrapping row named by its label, with a hint', function () { + $html = (string) $this->blade(''); + + 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

") + ->toContain("id=\"{$describedBy[1]}\" class=\"mt-1 type-body-sm text-on-surface-variant\">Show only these

") + ->not->toContain('materialChipSet'); +}); + +it('scrolls a chip set on one line with fading edges', function () { + expect((string) $this->blade('')) + ->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 */ + public array $kinds = ['photos']; + + public function save(): void + { + $this->validate(['kinds' => 'max:1'], ['kinds.max' => 'Pick one kind.']); + } + + public function render(): string + { + return <<<'BLADE' +
+ + + + +
+ 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'); +}); diff --git a/tests/Feature/Components/ChoicesTest.php b/tests/Feature/Components/ChoicesTest.php new file mode 100644 index 00000000..9447b5c7 --- /dev/null +++ b/tests/Feature/Components/ChoicesTest.php @@ -0,0 +1,91 @@ +blade(''); + + 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 */ + public array $days = [1, 3]; + + public function render(): string + { + return '
'; + } + }); + + $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(''); + + expect($html) + ->toContain('role="combobox"') + ->toContain('aria-controls="zone-list"') + ->toContain('aria-expanded="false"') + ->toContain('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 */ + 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' +
+ + +
+ BLADE; + } + }); + + expect(Livewire::test('choices-errors')->html()) + ->toContain('Mon is not a working day.') + ->toContain('Choose a time zone.') + ->toContain('aria-invalid="true"'); +}); diff --git a/tests/Feature/Components/SearchTest.php b/tests/Feature/Components/SearchTest.php new file mode 100644 index 00000000..b46d4eab --- /dev/null +++ b/tests/Feature/Components/SearchTest.php @@ -0,0 +1,39 @@ +blade(''); + + 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('holiday.zip')) + ->toContain('data-search-results') + ->toContain('holiday.zip') + ->and((string) $this->blade('No shares match.')) + ->toContain('No shares match.'); +}); + +it('names the input, trails the bar, and stays docked on request', function () { + $html = (string) $this->blade(''); + + expect($html) + ->toContain('placeholder="Search"') + ->toContain('aria-label="Search your shares"') + ->toContain('data-search-trailing') + ->toContain('') + ->toContain('materialSearch(true)'); +});