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:
co-authored by
Claude Opus 5
parent
d8072d878a
commit
55cf8ceed7
@@ -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)");
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user