Draw the chip set without Tailwind

<x-chip-set> renders data-md-chip-set (data-md-scroll), its row, label,
hint, errors and scroll buttons as data-md-* attributes, and its row,
fade mask, scroll padding and pointer-fine buttons move into
components/chip-set.css on tokens (plan step 36). chips.js marks the
row data-md-scroll-start and data-md-scroll-end. Browser tests cover the
scroll buttons in LTR and RTL and the arrow keys with a roving tab stop
that scrolls the focused chip clear of the button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 14:54:41 +02:00
co-authored by Claude Opus 5
parent d8072d878a
commit 55cf8ceed7
6 changed files with 259 additions and 43 deletions
+1
View File
@@ -25,6 +25,7 @@
@import './components/radio.css'; @import './components/radio.css';
@import './components/toggle.css'; @import './components/toggle.css';
@import './components/chip.css'; @import './components/chip.css';
@import './components/chip-set.css';
/* Containment */ /* Containment */
+128
View File
@@ -0,0 +1,128 @@
/*
* <x-chip-set>: a set of M3 chips (resources/views/components/chip-set.blade.php), named for screen
* readers as a group.
*
* The chips sit 8px apart (M3's chip spacing), in a row that wraps. Above it the label in
* label-large on-surface-variant, 8px over the row; under it the hint, or the errors in its place,
* body-small, 4px under the row.
*
* `data-md-scroll` keeps the chips on one line that scrolls sideways, as M3 lays chips out on a
* narrow screen. M3's chips accessibility page asks that a row which overflows say so: the edge the
* row can still scroll towards (`data-md-scroll-start`, `data-md-scroll-end`, set by
* resources/js/chips.js) fades over 24px through a mask, black meaning only "opaque" there, and in a
* right-to-left document the fade runs the other way. Where the pointer is fine, and so there is no
* swipe, a 32px round button in surface-container-high at elevation 1 sits over each fading edge;
* the row's scroll padding grows from 24px to 40px there, so a chip the keyboard reaches scrolls
* clear of the button as well as the fade. The row reaches 6px and 8px past its box so a focused
* chip's ring and an elevated chip's shadow are not clipped by the scroller.
*
* [data-md-chip-set] the group; data-md-scroll
* [data-md-chip-set-label]
* [data-md-chip-set-scroller] (scrolling) the row and its buttons
* [data-md-chip-set-row] the chips; data-md-scroll-start, data-md-scroll-end
* [data-md-chip-scroll="start|end"]
* [data-md-chip-set-hint] | [data-md-chip-set-errors]
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './chip.css';
@import './icon.css';
@layer material.components {
[data-md-chip-set] {
min-width: 0;
}
[data-md-chip-set-label] {
margin-bottom: var(--md-sys-measurement-space100);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-chip-set-row] {
display: flex;
flex-wrap: wrap;
gap: var(--md-sys-measurement-space100);
}
[data-md-chip-set-hint],
[data-md-chip-set-errors] > p {
margin-top: var(--md-sys-measurement-space50);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
[data-md-chip-set-errors] > p {
color: var(--md-sys-color-error);
}
/* ---- Scrolling --------------------------------------------------------------------------- */
[data-md-chip-set-scroller] {
position: relative;
}
[data-md-chip-set-scroller] > [data-md-chip-set-row] {
--chip-fade-start: 0px;
--chip-fade-end: 0px;
flex-wrap: nowrap;
margin: calc(-1 * var(--md-sys-measurement-space100)) calc(-1 * var(--md-sys-measurement-space75));
padding: var(--md-sys-measurement-space100) var(--md-sys-measurement-space75);
overflow-x: auto;
scroll-padding-inline: var(--md-sys-measurement-space300);
scrollbar-width: none;
mask-image: linear-gradient(to right, transparent, black var(--chip-fade-start), black calc(100% - var(--chip-fade-end)), transparent);
&:dir(rtl) {
mask-image: linear-gradient(to left, transparent, black var(--chip-fade-start), black calc(100% - var(--chip-fade-end)), transparent);
}
&[data-md-scroll-start] {
--chip-fade-start: var(--md-sys-measurement-space300);
}
&[data-md-scroll-end] {
--chip-fade-end: var(--md-sys-measurement-space300);
}
@media (pointer: fine) {
scroll-padding-inline: var(--md-sys-measurement-space500);
}
}
[data-md-chip-scroll] {
position: absolute;
inset-block: 0;
display: none;
place-items: center;
width: 32px;
height: 32px;
margin-block: auto;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface-variant);
box-shadow: var(--md-sys-elevation-1);
}
[data-md-chip-scroll='start'] {
inset-inline-start: 0;
}
[data-md-chip-scroll='end'] {
inset-inline-end: 0;
}
/* Only while the row can still scroll that way, and only where the pointer cannot swipe. */
@media (pointer: fine) {
[data-md-chip-set-row][data-md-scroll-start] ~ [data-md-chip-scroll='start'],
[data-md-chip-set-row][data-md-scroll-end] ~ [data-md-chip-scroll='end'] {
display: grid;
}
}
}
+5 -5
View File
@@ -6,8 +6,8 @@
* chip taking itself off the page. Before that, focus moves to the chip before it (Backspace) or * chip taking itself off the page. Before that, focus moves to the chip before it (Backspace) or
* after it (Delete), because a focused element that disappears leaves focus on the body. * after it (Delete), because a focused element that disappears leaves focus on the body.
* *
* A scrolling set marks the edges it can still scroll towards (`data-scroll-start`, * A scrolling set marks the edges it can still scroll towards (`data-md-scroll-start`,
* `data-scroll-end`), and the set fades those edges and — where the pointer is fine, so there is no * `data-md-scroll-end`), and the set fades those edges and — where the pointer is fine, so there is no
* swipe — puts a button over each of them, which is the visible affordance M3's chips accessibility * swipe — puts a button over each of them, which is the visible affordance M3's chips accessibility
* page asks a scrolling row for. Its row carries `wire:ignore.self`, so a Livewire morph keeps the * page asks a scrolling row for. Its row carries `wire:ignore.self`, so a Livewire morph keeps the
* marks. Wrapping, the row is the element with `x-data`; scrolling, it is that element's `row` ref, * marks. Wrapping, the row is the element with `x-data`; scrolling, it is that element's `row` ref,
@@ -68,7 +68,7 @@ document.addEventListener('alpine:init', () => {
handOffFocus(backwards) { handOffFocus(backwards) {
const chip = this.$root const chip = this.$root
const set = chip.closest('[data-chip-set]') ?? chip.parentElement const set = chip.closest('[data-md-chip-set-row]') ?? chip.parentElement
const chips = [...(set?.querySelectorAll('[data-md-chip]') ?? [])] const chips = [...(set?.querySelectorAll('[data-md-chip]') ?? [])]
const index = chips.indexOf(chip) const index = chips.indexOf(chip)
const before = chips.slice(0, index).reverse() const before = chips.slice(0, index).reverse()
@@ -140,8 +140,8 @@ document.addEventListener('alpine:init', () => {
const travelled = Math.abs(row.scrollLeft) const travelled = Math.abs(row.scrollLeft)
const room = row.scrollWidth - row.clientWidth const room = row.scrollWidth - row.clientWidth
row.toggleAttribute('data-scroll-start', travelled > 1) row.toggleAttribute('data-md-scroll-start', travelled > 1)
row.toggleAttribute('data-scroll-end', room - travelled > 1) row.toggleAttribute('data-md-scroll-end', room - travelled > 1)
} }
// A filter chip's check changes its width without resizing the row. // A filter chip's check changes its width without resizing the row.
+17 -17
View File
@@ -19,7 +19,11 @@
The set is one tab stop and the arrow keys move between the chips inside it, with Home and End The set is one tab stop and the arrow keys move between the chips inside it, with Home and End
at the ends — M3's chip keyboard table. Backspace and Delete still remove a focused input at the ends — M3's chip keyboard table. Backspace and Delete still remove a focused input
chip. --}} chip.
The root renders `data-md-chip-set` (and `data-md-scroll`), the row `data-md-chip-set-row`, which
resources/js/chips.js marks `data-md-scroll-start` and `data-md-scroll-end`; the drawing is
resources/css/components/chip-set.css. `class` and every other attribute land on the root. --}}
@props([ @props([
'label' => null, 'label' => null,
@@ -37,24 +41,25 @@
@endphp @endphp
<div <div
data-md-chip-set
@if ($scroll) data-md-scroll @endif
role="group" role="group"
@if (filled($label)) aria-labelledby="material-chip-set-{{ $key }}-label" @endif @if (filled($label)) aria-labelledby="material-chip-set-{{ $key }}-label" @endif
@if ($describedBy) aria-describedby="{{ $describedBy }}" @endif @if ($describedBy) aria-describedby="{{ $describedBy }}" @endif
{{ $attributes->class('min-w-0') }} {{ $attributes }}
> >
@if (filled($label)) @if (filled($label))
<p id="material-chip-set-{{ $key }}-label" class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</p> <p id="material-chip-set-{{ $key }}-label" data-md-chip-set-label>{{ $label }}</p>
@endif @endif
@if ($scroll) @if ($scroll)
<div x-data="materialChipSet" class="relative"> <div x-data="materialChipSet" data-md-chip-set-scroller>
<div <div
data-chip-set data-md-chip-set-row
x-ref="row" x-ref="row"
x-on:keydown="key($event)" x-on:keydown="key($event)"
x-on:focusin="rove($event.target)" x-on:focusin="rove($event.target)"
wire:ignore.self wire:ignore.self
class="peer -mx-1.5 -my-2 flex scroll-px-6 gap-2 overflow-x-auto px-1.5 py-2 pointer-fine:scroll-px-10 [scrollbar-width:none] [--chip-fade-end:0px] [--chip-fade-start:0px] data-scroll-end:[--chip-fade-end:1.5rem] data-scroll-start:[--chip-fade-start:1.5rem] [mask-image:linear-gradient(to_right,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)] rtl:[mask-image:linear-gradient(to_left,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)]"
> >
{{ $slot }} {{ $slot }}
</div> </div>
@@ -65,31 +70,26 @@
type="button" type="button"
tabindex="-1" tabindex="-1"
aria-hidden="true" aria-hidden="true"
data-chip-scroll="{{ $edge }}" data-md-chip-scroll="{{ $edge }}"
x-on:click="nudge('{{ $edge }}')" x-on:click="nudge('{{ $edge }}')"
@class([
'absolute inset-y-0 my-auto hidden size-8 place-items-center rounded-corner-full bg-surface-container-high text-on-surface-variant shadow-elevation-1',
'start-0 peer-data-scroll-start:pointer-fine:grid' => $edge === 'start',
'end-0 peer-data-scroll-end:pointer-fine:grid' => $edge === 'end',
])
> >
<x-livewire-material::icon :name="$glyph" class="size-4.5 rtl:-scale-x-100" optical="20" /> <x-livewire-material::icon :name="$glyph" size="18" mirror-rtl />
</button> </button>
@endforeach @endforeach
</div> </div>
@else @else
<div data-chip-set x-data="materialChipSet" x-on:keydown="key($event)" x-on:focusin="rove($event.target)" class="flex flex-wrap gap-2"> <div data-md-chip-set-row x-data="materialChipSet" x-on:keydown="key($event)" x-on:focusin="rove($event.target)">
{{ $slot }} {{ $slot }}
</div> </div>
@endif @endif
@if ($messages !== []) @if ($messages !== [])
<div id="{{ $describedBy }}"> <div id="{{ $describedBy }}" data-md-chip-set-errors>
@foreach ($messages as $message) @foreach ($messages as $message)
<p class="mt-1 type-body-sm text-error">{{ $message }}</p> <p>{{ $message }}</p>
@endforeach @endforeach
</div> </div>
@elseif (filled($hint)) @elseif (filled($hint))
<p id="{{ $describedBy }}" class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p> <p id="{{ $describedBy }}" data-md-chip-set-hint>{{ $hint }}</p>
@endif @endif
</div> </div>
+77 -4
View File
@@ -159,19 +159,19 @@ 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 () { 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. // 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 = chipShowcase();
$page->assertScript("{$row}.scrollWidth > {$row}.clientWidth") $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'"); ->assertScript("getComputedStyle({$row}).flexWrap === 'nowrap'");
$page->script("{$row}.querySelector('input[value=\"unopened\"]').focus()"); $page->script("{$row}.querySelector('input[value=\"unopened\"]').focus()");
$page->assertScript("Math.abs({$row}.scrollLeft) > 0") $page->assertScript("Math.abs({$row}.scrollLeft) > 0")
->assertScript("{$row}.hasAttribute('data-scroll-start')") ->assertScript("{$row}.hasAttribute('data-md-scroll-start')")
->assertScript("getComputedStyle({$row}).getPropertyValue('--chip-fade-start').trim() === '1.5rem'"); ->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 () { it('draws the chip 32px tall and catches presses over 48px, the chip and its remove button alike', function () {
@@ -210,3 +210,76 @@ it('draws a selected filter chip without its outline, and puts an unselected one
// 1px border, 7px padding, the empty check's 8px margin: Compose's 16px before the label. // 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"); ->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)");
});
+31 -17
View File
@@ -221,53 +221,67 @@ it('disables a removable input chip and its remove button', function () {
}); });
it('groups chips in a wrapping row named by its label, with a hint', function () { 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-labelledby="([^"]+)"/', $html, $labelledBy);
preg_match('/aria-describedby="([^"]+)"/', $html, $describedBy); preg_match('/aria-describedby="([^"]+)"/', $html, $describedBy);
expect($html) expect($html)
->toContain('role="group"') ->toMatch('/<div\s+data-md-chip-set\s+role="group"/')
->toContain('data-chip-set x-data="materialChipSet"') ->toContain('class="mt-4"')
->toContain('flex flex-wrap gap-2') ->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. // M3's chip keyboard: the set is one tab stop and the arrows walk it.
->toContain('x-on:keydown="key($event)"') ->toContain('x-on:keydown="key($event)"')
->toContain('x-on:focusin="rove($event.target)"') ->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=\"{$labelledBy[1]}\" data-md-chip-set-label>File types</p>")
->toContain("id=\"{$describedBy[1]}\" class=\"mt-1 type-body-sm text-on-surface-variant\">Show only these</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 () { 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>')) expect((string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>'))
->toContain('aria-label="Sort"') ->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('x-ref="row"')
->toContain('wire:ignore.self') ->toContain('wire:ignore.self')
->toContain('overflow-x-auto') ->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip-set.css'))
->toContain('data-scroll-start:[--chip-fade-start:1.5rem]') ->toContain('overflow-x: auto;')
->not->toContain('flex-wrap'); ->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 () { 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>'); $html = (string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>');
expect($html) expect($html)
->toContain('data-chip-scroll="start"') ->toContain('data-md-chip-scroll="start"')
->toContain('data-chip-scroll="end"') ->toContain('data-md-chip-scroll="end"')
->toContain('x-on:click="nudge(\'start\')"') ->toContain('x-on:click="nudge(\'start\')"')
->toContain('x-on:click="nudge(\'end\')"') ->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. // A pointer affordance, not a tab stop: the arrows already walk every chip.
->toContain('tabindex="-1"') ->toContain('tabindex="-1"')
->toContain('aria-hidden="true"') ->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. // 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. // 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>')) 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 () { it('binds filter chips to a Livewire array and shows the set\'s validation message in place of its hint', function () {