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
+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 () {
// The scrolling row, inside the component that also holds its scroll buttons.
$row = "document.querySelector('#chips [x-data=\"materialChipSet\"] > [data-chip-set]')";
$row = "document.querySelector('#chips [data-md-chip-set-scroller] > [data-md-chip-set-row]')";
$page = chipShowcase();
$page->assertScript("{$row}.scrollWidth > {$row}.clientWidth")
->assertScript("{$row}.hasAttribute('data-scroll-end') && ! {$row}.hasAttribute('data-scroll-start')")
->assertScript("{$row}.hasAttribute('data-md-scroll-end') && ! {$row}.hasAttribute('data-md-scroll-start')")
->assertScript("getComputedStyle({$row}).flexWrap === 'nowrap'");
$page->script("{$row}.querySelector('input[value=\"unopened\"]').focus()");
$page->assertScript("Math.abs({$row}.scrollLeft) > 0")
->assertScript("{$row}.hasAttribute('data-scroll-start')")
->assertScript("getComputedStyle({$row}).getPropertyValue('--chip-fade-start').trim() === '1.5rem'");
->assertScript("{$row}.hasAttribute('data-md-scroll-start')")
->assertScript("getComputedStyle({$row}).getPropertyValue('--chip-fade-start').trim() === '24px'");
});
it('draws the chip 32px tall and catches presses over 48px, the chip and its remove button alike', function () {
@@ -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.
->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 () {
$html = (string) $this->blade('<x-chip-set label="File types" hint="Show only these"><x-chip label="A" /></x-chip-set>');
$html = (string) $this->blade('<x-chip-set label="File types" hint="Show only these" class="mt-4"><x-chip label="A" /></x-chip-set>');
preg_match('/aria-labelledby="([^"]+)"/', $html, $labelledBy);
preg_match('/aria-describedby="([^"]+)"/', $html, $describedBy);
expect($html)
->toContain('role="group"')
->toContain('data-chip-set x-data="materialChipSet"')
->toContain('flex flex-wrap gap-2')
->toMatch('/<div\s+data-md-chip-set\s+role="group"/')
->toContain('class="mt-4"')
->toContain('<div data-md-chip-set-row x-data="materialChipSet"')
// M3's chip keyboard: the set is one tab stop and the arrows walk it.
->toContain('x-on:keydown="key($event)"')
->toContain('x-on:focusin="rove($event.target)"')
->toContain("id=\"{$labelledBy[1]}\" class=\"mb-2 type-label-lg text-on-surface-variant\">File types</p>")
->toContain("id=\"{$describedBy[1]}\" class=\"mt-1 type-body-sm text-on-surface-variant\">Show only these</p>");
->toContain("id=\"{$labelledBy[1]}\" data-md-chip-set-label>File types</p>")
->toContain("id=\"{$describedBy[1]}\" data-md-chip-set-hint>Show only these</p>")
->not->toContain('data-md-scroll')
->not->toContain('flex-wrap');
});
it('draws the set from its stylesheet: 8px between chips, wrapping unless it scrolls', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/chip-set.css');
expect($css)->toContain("@import './chip.css';")
->toMatch('/\[data-md-chip-set-row\] \{\s*display: flex;\s*flex-wrap: wrap;\s*gap: var\(--md-sys-measurement-space100\);/')
->toContain('[data-md-chip-set-scroller] > [data-md-chip-set-row] {')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/chip-set.css';");
});
it('scrolls a chip set on one line with fading edges', function () {
expect((string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>'))
->toContain('aria-label="Sort"')
->toContain('x-data="materialChipSet"')
->toMatch('/data-md-chip-set\s+data-md-scroll\s/')
->toContain('<div x-data="materialChipSet" data-md-chip-set-scroller>')
->toContain('x-ref="row"')
->toContain('wire:ignore.self')
->toContain('overflow-x-auto')
->toContain('data-scroll-start:[--chip-fade-start:1.5rem]')
->not->toContain('flex-wrap');
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip-set.css'))
->toContain('overflow-x: auto;')
->toContain('--chip-fade-start: var(--md-sys-measurement-space300);');
});
it('puts a scroll button over each fading edge, for a pointer that cannot swipe', function () {
$html = (string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>');
expect($html)
->toContain('data-chip-scroll="start"')
->toContain('data-chip-scroll="end"')
->toContain('data-md-chip-scroll="start"')
->toContain('data-md-chip-scroll="end"')
->toContain('x-on:click="nudge(\'start\')"')
->toContain('x-on:click="nudge(\'end\')"')
// Only while the row can still scroll that way, and only where the pointer is fine.
->toContain('peer-data-scroll-start:pointer-fine:grid')
->toContain('peer-data-scroll-end:pointer-fine:grid')
// A pointer affordance, not a tab stop: the arrows already walk every chip.
->toContain('tabindex="-1"')
->toContain('aria-hidden="true"')
->toContain('data-md-mirror-rtl');
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip-set.css'))
// Only while the row can still scroll that way, and only where the pointer is fine.
->toMatch("/@media \(pointer: fine\) \{\s*\[data-md-chip-set-row\]\[data-md-scroll-start\] ~ \[data-md-chip-scroll='start'\],\s*\[data-md-chip-set-row\]\[data-md-scroll-end\] ~ \[data-md-chip-scroll='end'\] \{\s*display: grid;/")
// A chip the keyboard reaches clears the buttons as well as the fade.
->toContain('pointer-fine:scroll-px-10');
->toContain('scroll-padding-inline: var(--md-sys-measurement-space500);');
// A wrapping set has no edge to scroll towards.
expect((string) $this->blade('<x-chip-set aria-label="Sort"><x-chip label="A" /></x-chip-set>'))
->not->toContain('data-chip-scroll');
->not->toContain('data-md-chip-scroll');
});
it('binds filter chips to a Livewire array and shows the set\'s validation message in place of its hint', function () {