diff --git a/resources/css/components.css b/resources/css/components.css index 868e440a..4216cc33 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -25,6 +25,7 @@ @import './components/radio.css'; @import './components/toggle.css'; @import './components/chip.css'; +@import './components/chip-set.css'; /* Containment */ diff --git a/resources/css/components/chip-set.css b/resources/css/components/chip-set.css new file mode 100644 index 00000000..bbe7a84e --- /dev/null +++ b/resources/css/components/chip-set.css @@ -0,0 +1,128 @@ +/* + * : 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; + } + } +} diff --git a/resources/js/chips.js b/resources/js/chips.js index 818ca6cb..2b8214f2 100644 --- a/resources/js/chips.js +++ b/resources/js/chips.js @@ -6,8 +6,8 @@ * 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. * - * A scrolling set marks the edges it can still scroll towards (`data-scroll-start`, - * `data-scroll-end`), and the set fades those edges and — where the pointer is fine, so there is no + * A scrolling set marks the edges it can still scroll towards (`data-md-scroll-start`, + * `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 * 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, @@ -68,7 +68,7 @@ document.addEventListener('alpine:init', () => { handOffFocus(backwards) { 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 index = chips.indexOf(chip) const before = chips.slice(0, index).reverse() @@ -140,8 +140,8 @@ document.addEventListener('alpine:init', () => { const travelled = Math.abs(row.scrollLeft) const room = row.scrollWidth - row.clientWidth - row.toggleAttribute('data-scroll-start', travelled > 1) - row.toggleAttribute('data-scroll-end', room - travelled > 1) + row.toggleAttribute('data-md-scroll-start', travelled > 1) + row.toggleAttribute('data-md-scroll-end', room - travelled > 1) } // A filter chip's check changes its width without resizing the row. diff --git a/resources/views/components/chip-set.blade.php b/resources/views/components/chip-set.blade.php index 9857bf7f..54f4d82d 100644 --- a/resources/views/components/chip-set.blade.php +++ b/resources/views/components/chip-set.blade.php @@ -19,7 +19,11 @@ 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 - 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([ 'label' => null, @@ -37,24 +41,25 @@ @endphp
class('min-w-0') }} + {{ $attributes }} > @if (filled($label)) -

{{ $label }}

+

{{ $label }}

@endif @if ($scroll) -
+
{{ $slot }}
@@ -65,31 +70,26 @@ type="button" tabindex="-1" aria-hidden="true" - data-chip-scroll="{{ $edge }}" + data-md-chip-scroll="{{ $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', - ]) > - + @endforeach
@else -
+
{{ $slot }}
@endif @if ($messages !== []) -
+
@foreach ($messages as $message) -

{{ $message }}

+

{{ $message }}

@endforeach
@elseif (filled($hint)) -

{{ $hint }}

+

{{ $hint }}

@endif
diff --git a/tests/Browser/ChipsTest.php b/tests/Browser/ChipsTest.php index 54b50106..a55fb734 100644 --- a/tests/Browser/ChipsTest.php +++ b/tests/Browser/ChipsTest.php @@ -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' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + +
+ + @foreach (['Newest', 'Oldest', 'Largest', 'Smallest', 'Shared', 'Starred', 'Unopened', 'Expired'] as $sort) + + @endforeach + +
+ @livewireScripts + + + 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)"); +}); diff --git a/tests/Feature/Components/ChipTest.php b/tests/Feature/Components/ChipTest.php index 595dcdc7..93dedfcb 100644 --- a/tests/Feature/Components/ChipTest.php +++ b/tests/Feature/Components/ChipTest.php @@ -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(''); + $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 x-data="materialChipSet"') - ->toContain('flex flex-wrap gap-2') + ->toMatch('/toContain('class="mt-4"') + ->toContain('
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

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

"); + ->toContain("id=\"{$labelledBy[1]}\" data-md-chip-set-label>File types

") + ->toContain("id=\"{$describedBy[1]}\" data-md-chip-set-hint>Show only these

") + ->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('')) ->toContain('aria-label="Sort"') - ->toContain('x-data="materialChipSet"') + ->toMatch('/data-md-chip-set\s+data-md-scroll\s/') + ->toContain('
') ->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(''); 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('')) - ->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 () {