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
@@ -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 */
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)");
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 () {
|
||||||
|
|||||||
Reference in New Issue
Block a user