Merge branch 'worktree-agent-a6769a5cd2c4b070e'
This commit is contained in:
@@ -60,7 +60,7 @@ function onCarousel(int $index, string $body, string $scope = '#carousel'): stri
|
||||
JS;
|
||||
}
|
||||
|
||||
const FIRST_SCROLLER = '#carousel [role="region"] >> nth=0';
|
||||
const FIRST_ITEM = '#carousel [data-material-carousel-item] >> nth=0';
|
||||
|
||||
function carouselShowcase(array $options = [])
|
||||
{
|
||||
@@ -110,19 +110,21 @@ it('moves one item with the next and previous buttons', function () {
|
||||
it('moves one item with the arrow keys, and to the ends with Home and End', function () {
|
||||
$page = carouselShowcase();
|
||||
|
||||
$page->keys(FIRST_SCROLLER, 'ArrowRight')
|
||||
->assertScript(onCarousel(0, 'return at(1)'));
|
||||
// M3 puts the tab stop on the item, so the keys are the focused item's and each one moves
|
||||
// focus to the item it scrolls to.
|
||||
$page->keys(FIRST_ITEM, 'ArrowRight')
|
||||
->assertScript(onCarousel(0, 'return at(1) && document.activeElement === items[1]'));
|
||||
|
||||
$page->keys(FIRST_SCROLLER, 'ArrowRight')
|
||||
$page->keys(':focus', 'ArrowRight')
|
||||
->assertScript(onCarousel(0, 'return at(2)'));
|
||||
|
||||
$page->keys(FIRST_SCROLLER, 'ArrowLeft')
|
||||
$page->keys(':focus', 'ArrowLeft')
|
||||
->assertScript(onCarousel(0, 'return at(1)'));
|
||||
|
||||
$page->keys(FIRST_SCROLLER, 'End')
|
||||
$page->keys(':focus', 'End')
|
||||
->assertScript(onCarousel(0, 'return Math.abs(scroller.scrollLeft - (scroller.scrollWidth - scroller.clientWidth)) < 1.5 && inset(items.length - 1) < 0.5 && root.querySelector(\'[aria-label="Next"]\').disabled'));
|
||||
|
||||
$page->keys(FIRST_SCROLLER, 'Home')
|
||||
$page->keys(':focus', 'Home')
|
||||
->assertScript(onCarousel(0, 'return at(0)'));
|
||||
});
|
||||
|
||||
@@ -146,15 +148,14 @@ it('brings a partly hidden item into focus when it is pressed', function () {
|
||||
$page->assertScript(onCarousel(0, 'return inset(4) < 0.5 && scroller.scrollLeft > 0'));
|
||||
});
|
||||
|
||||
it('scrolls instantly and keeps content pinned to its mask under reduced motion', function () {
|
||||
it('scrolls instantly and leaves every item unmasked under reduced motion', function () {
|
||||
carouselShowcase(['reducedMotion' => 'reduce'])
|
||||
->assertScript(onCarousel(0, <<<'JS'
|
||||
root.querySelector('[aria-label="Next"]').click()
|
||||
const arrived = at(1)
|
||||
await pause(50)
|
||||
const last = items.length - 1
|
||||
return arrived && inset(last) > 0.5
|
||||
&& surface(last).style.getPropertyValue('--material-carousel-pin') === surface(last).style.getPropertyValue('--material-carousel-inset')
|
||||
return arrived && items.every((_, i) => inset(i) === 0)
|
||||
&& items.every((item) => Math.abs(item.getBoundingClientRect().width - size) < 1.5)
|
||||
JS));
|
||||
});
|
||||
|
||||
@@ -184,7 +185,7 @@ it('mirrors in a right-to-left page', function () {
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertScript(onCarousel(0, 'return size > 0 && at(0) && inset(0) < 0.5 && inset(items.length - 1) > 0.5', 'body'));
|
||||
|
||||
$page->keys('[role="region"]', 'ArrowLeft')
|
||||
$page->keys('[data-material-carousel-item] >> nth=0', 'ArrowLeft')
|
||||
->assertScript(onCarousel(0, <<<'JS'
|
||||
return scroller.scrollLeft < -1 && at(1) && inset(0) > 0.5 && inset(1) < 0.5
|
||||
&& parseFloat(surface(0).style.getPropertyValue('--material-carousel-shift')) < 0
|
||||
@@ -193,7 +194,7 @@ it('mirrors in a right-to-left page', function () {
|
||||
$page->click('button[aria-label="Next"]')
|
||||
->assertScript(onCarousel(0, 'return at(2)', 'body'));
|
||||
|
||||
$page->keys('[role="region"]', 'ArrowRight')
|
||||
$page->keys('[data-material-carousel-item] >> nth=2', 'ArrowRight')
|
||||
->assertScript(onCarousel(0, 'return at(1)', 'body'));
|
||||
});
|
||||
|
||||
|
||||
@@ -214,12 +214,28 @@ it('dismisses a bottom sheet dragged down past a quarter of its height', functio
|
||||
it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () {
|
||||
$page = containment();
|
||||
|
||||
$page->script("window.__opened = 0; document.querySelector('#containment [data-card][data-list-row] [data-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
|
||||
$row = '#containment [data-card][data-list-row]:not([data-list-actionable])';
|
||||
|
||||
$page->click('#containment [data-card][data-list-row] p')
|
||||
$page->script("window.__opened = 0; document.querySelector('{$row} [data-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
|
||||
|
||||
$page->click("{$row} p")
|
||||
->assertScript('window.__opened === 1');
|
||||
|
||||
$page->click('#containment [data-card][data-list-row] button:has-text("Copy link")')
|
||||
$page->click("{$row} button:has-text(\"Copy link\")")
|
||||
->assertScript('window.__opened === 1');
|
||||
});
|
||||
|
||||
it('makes a directly actionable card the one tab stop, answering Enter', function () {
|
||||
$card = '#containment [data-card][data-list-actionable]';
|
||||
|
||||
$page = containment();
|
||||
|
||||
$page->script("window.__opened = 0; document.querySelector('{$card} [data-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
|
||||
|
||||
$page->script("document.querySelector('{$card}').focus()")
|
||||
->assertScript("document.activeElement.matches('{$card}')");
|
||||
|
||||
$page->keys($card, 'Enter')
|
||||
->assertScript('window.__opened === 1');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<?php
|
||||
|
||||
it('draws M3\'s three cards', function (string $variant, string $classes) {
|
||||
expect((string) $this->blade("<x-card variant=\"{$variant}\">Body</x-card>"))->toContain($classes)->toContain('rounded-corner-md')->toContain('data-card');
|
||||
it('draws M3\'s three cards, each naming its variant for its per-state elevation', function (string $variant, string $classes) {
|
||||
expect((string) $this->blade("<x-card variant=\"{$variant}\">Body</x-card>"))
|
||||
->toContain($classes)
|
||||
->toContain('rounded-corner-md')
|
||||
->toContain("data-card=\"{$variant}\"");
|
||||
})->with([
|
||||
'filled' => ['filled', 'bg-surface-container-highest'],
|
||||
'elevated' => ['elevated', 'bg-surface-container-low shadow-elevation-1'],
|
||||
@@ -9,7 +12,9 @@ it('draws M3\'s three cards', function (string $variant, string $classes) {
|
||||
]);
|
||||
|
||||
it('is filled unless it knows the variant', function () {
|
||||
expect((string) $this->blade('<x-card variant="glass">Body</x-card>'))->toContain('bg-surface-container-highest');
|
||||
expect((string) $this->blade('<x-card variant="glass">Body</x-card>'))
|
||||
->toContain('bg-surface-container-highest')
|
||||
->toContain('data-card="filled"');
|
||||
});
|
||||
|
||||
it('lays out a header, menu, media, body and actions', function () {
|
||||
@@ -38,3 +43,20 @@ it('passes row attributes through', function () {
|
||||
->toContain('data-list-row')
|
||||
->toContain('data-list-open');
|
||||
});
|
||||
|
||||
it('makes a directly actionable card the one tab stop, named by its title', function () {
|
||||
$html = (string) $this->blade('<x-card actionable title="holiday-photos.zip"><a href="/s/1" data-list-open tabindex="-1">Open</a></x-card>');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-list-row')
|
||||
->toContain('data-list-actionable')
|
||||
->toContain('tabindex="0"')
|
||||
->toContain('role="button"')
|
||||
->toContain('aria-label="holiday-photos.zip"')
|
||||
->and((string) $this->blade('<x-card actionable role="link" title="Share">Body</x-card>'))
|
||||
->toContain('role="link"')
|
||||
->and(substr_count((string) $this->blade('<x-card actionable role="link" title="Share">Body</x-card>'), 'role="link"'))->toBe(1)
|
||||
->and((string) $this->blade('<x-card title="Share">Body</x-card>'))
|
||||
->not->toContain('data-list-actionable')
|
||||
->not->toContain('tabindex');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
it('is a focusable carousel region of slides named n of m', function () {
|
||||
it('is a carousel region of focusable slides named n of m', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-carousel label="Recent uploads">
|
||||
<x-carousel-item><img src="/a.jpg" alt="A lake" /></x-carousel-item>
|
||||
@@ -17,14 +17,16 @@ it('is a focusable carousel region of slides named n of m', function () {
|
||||
->toContain('role="region"')
|
||||
->toContain('aria-roledescription="carousel"')
|
||||
->toContain('aria-label="Recent uploads"')
|
||||
->toContain('tabindex="0"')
|
||||
->toContain('aria-roledescription="slide"')
|
||||
->toContain('aria-label="1 of 3"')
|
||||
->toContain('aria-label="2 of 3"')
|
||||
->toContain('aria-label="3 of 3"')
|
||||
->toContain('<img src="/a.jpg" alt="A lake" />')
|
||||
->not->toContain('[material-carousel-position]')
|
||||
->and(substr_count($html, 'role="group"'))->toBe(3);
|
||||
->and(substr_count($html, 'role="group"'))->toBe(3)
|
||||
// M3 puts the tab stop on each item and tells you not to focus the container.
|
||||
->and(substr_count($html, 'tabindex="0"'))->toBe(3)
|
||||
->and($html)->toMatch('/<div\s+x-ref="scroller"(?:(?!tabindex)[^>])*>/');
|
||||
});
|
||||
|
||||
it('counts the slides of a carousel inside a slide on their own', function () {
|
||||
@@ -56,7 +58,9 @@ it('browses many by default: 186px items that snap one at a time, 205px high', f
|
||||
->toContain('aria-label="Carousel"')
|
||||
->toContain('--material-carousel-item-width: 186px; --material-carousel-slot: 186px; --material-carousel-height: 205px')
|
||||
->toContain('x-ref="probe"')
|
||||
->toContain('data-padding="0"')
|
||||
->toContain('data-padding="16"')
|
||||
->toContain('data-padding-end="16"')
|
||||
->toContain('h-[calc(var(--material-carousel-height)+1rem)] gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain py-2')
|
||||
->toContain('snap-x snap-mandatory')
|
||||
->toContain('snap-start snap-always')
|
||||
->toContain('rounded-corner-xl')
|
||||
@@ -65,9 +69,12 @@ it('browses many by default: 186px items that snap one at a time, 205px high', f
|
||||
});
|
||||
|
||||
it('takes the item width in pixels or as a CSS length, and a height and padding', function () {
|
||||
expect((string) $this->blade('<x-carousel item-width="220" height="18rem" padding="16"><x-carousel-item>A</x-carousel-item></x-carousel>'))
|
||||
expect((string) $this->blade('<x-carousel item-width="220" height="18rem" padding="24"><x-carousel-item>A</x-carousel-item></x-carousel>'))
|
||||
->toContain('--material-carousel-item-width: 220px; --material-carousel-slot: 220px; --material-carousel-height: 18rem')
|
||||
->toContain('data-padding="24"')
|
||||
->and((string) $this->blade('<x-carousel layout="uncontained"><x-carousel-item>A</x-carousel-item></x-carousel>'))
|
||||
->toContain('data-padding="16"')
|
||||
->toContain('data-padding-end="0"')
|
||||
->and((string) $this->blade('<x-carousel item-width="40%"><x-carousel-item>A</x-carousel-item></x-carousel>'))
|
||||
->toContain('--material-carousel-item-width: 40%;');
|
||||
});
|
||||
@@ -88,15 +95,23 @@ it('lays out a hero that fills the width unless capped, centred on request', fun
|
||||
->not->toContain('data-centered');
|
||||
});
|
||||
|
||||
it('leaves an uncontained carousel unsnapped, and a full-screen one a page wide', function () {
|
||||
it('leaves an uncontained carousel unsnapped, and scrolls a full-screen one down the page', function () {
|
||||
expect((string) $this->blade('<x-carousel layout="uncontained" item-width="180"><x-carousel-item>A</x-carousel-item></x-carousel>'))
|
||||
->toContain('data-material-carousel="uncontained"')
|
||||
->toContain('--material-carousel-item-width: 180px')
|
||||
->not->toContain('snap-mandatory')
|
||||
->and((string) $this->blade('<x-carousel layout="full-screen" centered><x-carousel-item>A</x-carousel-item></x-carousel>'))
|
||||
->and((string) $this->blade('<x-carousel layout="full-screen" centered controls><x-carousel-item label="Morning">A</x-carousel-item></x-carousel>'))
|
||||
->toContain('data-material-carousel="full-screen"')
|
||||
->toContain('--material-carousel-slot: 100%')
|
||||
->toContain('snap-mandatory')
|
||||
// M3: one edge-to-edge item at a time, scrolled vertically, 16px apart, no end padding,
|
||||
// no corner, no mask, and never wider than the medium window it is meant for.
|
||||
->toContain('mx-auto h-(--material-carousel-height) max-w-210 snap-y snap-mandatory flex-col gap-4 overflow-x-hidden overflow-y-auto overscroll-y-contain')
|
||||
->toContain('data-padding="0" data-padding-end="0"')
|
||||
->toContain('aria-label="Previous"')
|
||||
->not->toContain('--material-carousel-slot')
|
||||
->not->toContain('snap-x')
|
||||
->not->toContain('rounded-corner-xl')
|
||||
->not->toContain('clip-path')
|
||||
->not->toContain('rtl:-scale-x-100')
|
||||
->not->toContain('x-ref="probe"')
|
||||
->not->toContain('data-centered')
|
||||
->and((string) $this->blade('<x-carousel layout="sideways"><x-carousel-item>A</x-carousel-item></x-carousel>'))
|
||||
@@ -136,7 +151,7 @@ it('lays a label over an item on a scrim, and passes attributes to the item', fu
|
||||
->toContain('data-material-carousel-label')
|
||||
->toContain('Lake Constance')
|
||||
->toContain('from-scrim/60')
|
||||
->toContain('type-title-md text-white')
|
||||
->toContain('type-title-md text-inverse-on-surface')
|
||||
->toContain('[&>img]:size-full [&>img]:object-cover')
|
||||
->and((string) $this->blade('<x-carousel-item>A</x-carousel-item>'))
|
||||
->not->toContain('data-material-carousel-label');
|
||||
|
||||
@@ -11,6 +11,8 @@ it('discloses on the native details element, kept open through a morph', functio
|
||||
->toContain('wire:ignore.self')
|
||||
->toContain(' open ')
|
||||
->toContain('rounded-corner-lg bg-surface-container')
|
||||
->toContain('data-collapse')
|
||||
->toContain('[interpolate-size:allow-keywords]')
|
||||
->toContain('How long do links last?')
|
||||
->toContain('Until the expiry.')
|
||||
->toContain('group-open/collapse:rotate-180');
|
||||
@@ -20,12 +22,12 @@ it('has no Alpine on it without a binding', function () {
|
||||
$html = (string) $this->blade('<x-collapse title="Advanced" open>Body</x-collapse>');
|
||||
|
||||
expect($html)
|
||||
->toMatch('/<details\s+wire:ignore.self\s+open\s+class="group\/collapse/')
|
||||
->toMatch('/<details\s+wire:ignore.self\s+data-collapse\s+open\s+class="group\/collapse/')
|
||||
->not->toContain('x-data')
|
||||
->not->toContain('x-modelable')
|
||||
->not->toContain('x-on:toggle')
|
||||
->and((string) $this->blade('<x-collapse title="Advanced">Body</x-collapse>'))
|
||||
->toMatch('/<details\s+wire:ignore.self\s+class="group\/collapse/');
|
||||
->toMatch('/<details\s+wire:ignore.self\s+data-collapse\s+class="group\/collapse/');
|
||||
});
|
||||
|
||||
it('binds its open state through x-model, drawing the open prop until Alpine starts', function () {
|
||||
|
||||
@@ -5,8 +5,11 @@ it('draws a plain list, with dividers on request', function () {
|
||||
->toContain('role="list"')
|
||||
->toContain('aria-label="Files"')
|
||||
->toContain('data-list="plain"')
|
||||
->toContain('divide-y divide-outline-variant')
|
||||
->toContain('role="listitem"');
|
||||
->toContain('data-dividers')
|
||||
->not->toContain('divide-y')
|
||||
->toContain('role="listitem"')
|
||||
->and((string) $this->blade('<x-list><x-list-item title="a.zip" /></x-list>'))->not->toContain('data-dividers')
|
||||
->and((string) $this->blade('<x-list segmented dividers><x-list-item title="a.zip" /></x-list>'))->not->toContain('data-dividers');
|
||||
});
|
||||
|
||||
it('draws M3 Expressive\'s segmented list', function () {
|
||||
@@ -15,10 +18,25 @@ it('draws M3 Expressive\'s segmented list', function () {
|
||||
->toContain('gap-0.5');
|
||||
});
|
||||
|
||||
it('grows an item from one to three lines', function () {
|
||||
expect((string) $this->blade('<x-list-item title="a.zip" />'))->toContain('min-h-14')
|
||||
->and((string) $this->blade('<x-list-item title="a.zip" description="248 MB" />'))->toContain('min-h-18')->toContain('248 MB')
|
||||
->and((string) $this->blade('<x-list-item title="a.zip" overline="Protected" description="248 MB" />'))->toContain('min-h-22')->toContain('Protected');
|
||||
it('grows an item from one to three lines, top-aligning the tallest', function () {
|
||||
expect((string) $this->blade('<x-list-item title="a.zip" />'))->toContain('items-center')->toContain('min-h-14 py-2')
|
||||
->and((string) $this->blade('<x-list-item title="a.zip" description="248 MB" />'))->toContain('items-center')->toContain('min-h-18 py-2')->toContain('248 MB')
|
||||
->and((string) $this->blade('<x-list-item title="a.zip" overline="Protected" description="248 MB" />'))
|
||||
->toContain('items-start')
|
||||
->toContain('min-h-22 py-3')
|
||||
->toContain('Protected')
|
||||
->not->toContain('items-center');
|
||||
});
|
||||
|
||||
it('leaves 16px between the item and what leads or trails it', function () {
|
||||
expect((string) $this->blade('<x-list-item title="a.zip" icon="folder_zip" />'))->toContain('flex gap-4 px-4');
|
||||
});
|
||||
|
||||
it('draws the expressive icon size in a segmented list', function () {
|
||||
expect((string) $this->blade('<x-list segmented><x-list-item title="a" icon="settings" icon-right="chevron_right" /></x-list>'))
|
||||
->toContain('size-5')
|
||||
->not->toContain('size-6')
|
||||
->and((string) $this->blade('<x-list><x-list-item title="a" icon="settings" /></x-list>'))->toContain('size-6');
|
||||
});
|
||||
|
||||
it('leads with an icon, an avatar or an image, and trails with text or an icon', function () {
|
||||
@@ -38,6 +56,37 @@ it('makes a linked item a row that opens from anywhere', function () {
|
||||
->toContain('wire:navigate');
|
||||
});
|
||||
|
||||
it('is a list box of options when it is one a person chooses from', function () {
|
||||
expect((string) $this->blade('<x-list selectable label="Recipients"><x-list-item title="Anna" :selected="true" /><x-list-item title="Ben" /></x-list>'))
|
||||
->toContain('role="listbox"')
|
||||
->toMatch('/role="option"\s+aria-selected="true"/')
|
||||
->toMatch('/role="option"\s+aria-selected="false"/')
|
||||
->not->toContain('aria-multiselectable')
|
||||
->and((string) $this->blade('<x-list selection="multi"><x-list-item title="Anna" /></x-list>'))
|
||||
->toContain('role="listbox"')
|
||||
->toContain('aria-multiselectable="true"')
|
||||
->and((string) $this->blade('<x-list><x-list-item title="Anna" :selected="true" /></x-list>'))
|
||||
->toContain('role="list"')
|
||||
->toMatch('/role="listitem"\s+aria-current="true"/')
|
||||
->not->toContain('aria-selected');
|
||||
});
|
||||
|
||||
it('draws a second cue on a selected option, which icon-right replaces', function () {
|
||||
expect(substr_count((string) $this->blade('<x-list selectable><x-list-item title="Anna" :selected="true" /></x-list>'), '<svg'))->toBe(1)
|
||||
->and(substr_count((string) $this->blade('<x-list selectable><x-list-item title="Anna" :selected="true" icon-right="chevron_right" /></x-list>'), '<svg'))->toBe(1)
|
||||
->and(substr_count((string) $this->blade('<x-list selectable><x-list-item title="Anna" /></x-list>'), '<svg'))->toBe(0)
|
||||
->and(substr_count((string) $this->blade('<x-list><x-list-item title="Anna" :selected="true" /></x-list>'), '<svg'))->toBe(0);
|
||||
});
|
||||
|
||||
it('leaves a disabled item out of the keyboard and announces it disabled', function () {
|
||||
expect((string) $this->blade('<x-list-item title="Settings" link="/settings" icon="settings" disabled />'))
|
||||
->toContain('aria-disabled="true"')
|
||||
->toContain('pointer-events-none text-on-surface/38')
|
||||
->not->toContain('href="/settings"')
|
||||
->not->toContain('data-list-open')
|
||||
->not->toContain('data-list-row');
|
||||
});
|
||||
|
||||
it('marks a selected item and takes controls in its slots', function () {
|
||||
expect((string) $this->blade('<x-list-item title="Anna" :selected="true"><x-slot:leading><input type="checkbox"></x-slot:leading><x-slot:end><button>⋮</button></x-slot:end></x-list-item>'))
|
||||
->toContain('data-selected')
|
||||
|
||||
@@ -49,10 +49,43 @@ it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the
|
||||
|
||||
expect($html)
|
||||
->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-medium:hidden">/')
|
||||
->toContain('<p class="type-body-md text-on-surface-variant mt-4 max-medium:mt-0">Scan the code</p>')
|
||||
->not->toContain('<div class="mb-4 max-medium:hidden">')
|
||||
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('<div class="mb-4 max-medium:hidden">')
|
||||
->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))->toContain('<p class="type-body-md text-on-surface-variant">Only a subtitle</p>');
|
||||
->toMatch('/<p id="[^"]+-subtitle" class="type-body-md text-on-surface-variant mt-4 max-medium:mt-0">Scan the code<\/p>/')
|
||||
->not->toContain('<div class="shrink-0 px-6 pt-6 max-medium:hidden">')
|
||||
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('<div class="shrink-0 px-6 pt-6 max-medium:hidden">')
|
||||
->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))->toMatch('/<p id="[^"]+-subtitle" class="type-body-md text-on-surface-variant">Only a subtitle<\/p>/');
|
||||
});
|
||||
|
||||
it('pins a dialog\'s headline and actions and scrolls only the body between them', function () {
|
||||
$html = (string) $this->blade('<x-modal title="Terms" subtitle="Please read" separator>Body<x-slot:actions><button>Agree</button></x-slot:actions></x-modal>');
|
||||
|
||||
expect($html)
|
||||
->toContain('overflow-hidden rounded-corner-xl bg-surface-container-high shadow-elevation-3')
|
||||
->not->toContain('overflow-y-auto rounded-corner-xl')
|
||||
->toContain('<div class="shrink-0 px-6 pt-6">')
|
||||
->toMatch('/<div id="[^"]+-body" class="min-h-0 flex-1 overflow-y-auto px-6 type-body-md text-on-surface-variant pt-4">/')
|
||||
->toContain('flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pt-6 pb-6')
|
||||
->and(substr_count($html, 'role="separator"'))->toBe(2);
|
||||
});
|
||||
|
||||
it('is an alert dialog when it interrupts, and describes itself by its subtitle', function () {
|
||||
$alert = (string) $this->blade('<x-modal title="Delete this share?" subtitle="Recipients lose access." alert>This cannot be undone.</x-modal>');
|
||||
|
||||
preg_match('/id="(material-dialog-[a-z0-9]+)"/', $alert, $id);
|
||||
|
||||
expect($alert)
|
||||
->toContain('role="alertdialog"')
|
||||
->toContain("aria-describedby=\"{$id[1]}-subtitle {$id[1]}-body\"")
|
||||
->and((string) $this->blade('<x-modal title="Share settings" subtitle="Who can see it">Body</x-modal>'))
|
||||
->not->toContain('role="alertdialog"')
|
||||
->toMatch('/aria-describedby="material-dialog-[a-z0-9]+-subtitle"/')
|
||||
->and((string) $this->blade('<x-modal title="Share settings">Body</x-modal>'))
|
||||
->not->toContain('aria-describedby');
|
||||
});
|
||||
|
||||
it('gives a full-screen dialog M3\'s 56px header and action bar', function () {
|
||||
expect((string) $this->blade('<x-modal title="Share settings" fullscreen>Body<x-slot:actions><button>Save</button></x-slot:actions></x-modal>'))
|
||||
->toContain('flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden')
|
||||
->toContain('max-medium:min-h-14 max-medium:border-t max-medium:border-outline-variant max-medium:pt-2 max-medium:pb-2');
|
||||
});
|
||||
|
||||
it('leaves a pane open on Escape unless it is asked to close then too', function () {
|
||||
@@ -64,6 +97,20 @@ it('leaves a pane open on Escape unless it is asked to close then too', function
|
||||
->not->toContain('keydown.window.escape');
|
||||
});
|
||||
|
||||
it('always offers a way out of a side sheet, as M3 requires', function () {
|
||||
expect((string) $this->blade('<x-drawer title="Details">Body</x-drawer>'))->toContain('aria-label="Close"')
|
||||
->and((string) $this->blade('<x-drawer title="Details" :with-close-button="false">Body</x-drawer>'))->not->toContain('aria-label="Close"')
|
||||
->and((string) $this->blade('<x-drawer title="Details" :with-close-button="false" :close-on-escape="false">Body</x-drawer>'))->toContain('aria-label="Close"')
|
||||
->and((string) $this->blade('<x-drawer title="Details" :with-close-button="false" without-backdrop-close>Body</x-drawer>'))->toContain('aria-label="Close"')
|
||||
->and((string) $this->blade('<x-drawer pane :with-close-button="false">Body</x-drawer>'))->toContain('aria-label="Close"')
|
||||
->and((string) $this->blade('<x-drawer pane pane-close-on-escape :with-close-button="false">Body</x-drawer>'))->not->toContain('aria-label="Close"');
|
||||
});
|
||||
|
||||
it('puts a side sheet\'s actions on the left, in M3\'s 72dp row', function () {
|
||||
expect((string) $this->blade('<x-drawer title="Details">Body<x-slot:actions><button>Delete</button></x-slot:actions></x-drawer>'))
|
||||
->toContain('flex min-h-18 shrink-0 flex-wrap items-center justify-start gap-2 pt-4');
|
||||
});
|
||||
|
||||
it('slides a side sheet in from either edge, and is a pane from expanded when asked', function () {
|
||||
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
|
||||
->toContain('x-trap.inert.noscroll="open && ! wide"')
|
||||
@@ -86,8 +133,13 @@ it('draws a modal bottom sheet with a drag handle, or a standard one without a s
|
||||
->toContain('rounded-t-corner-xl bg-surface-container-low')
|
||||
->toContain('data-drag-handle')
|
||||
->toContain('aria-modal="true"')
|
||||
->toContain('py-5.5')
|
||||
->toContain('touch-target h-1 w-8 rounded-corner-full bg-on-surface-variant ')
|
||||
->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))')
|
||||
->and((string) $this->blade('<x-bottom-sheet standard>Body</x-bottom-sheet>'))
|
||||
->toContain('...materialBottomSheet(true)')
|
||||
->not->toContain('bg-scrim/32')
|
||||
->not->toContain('aria-modal');
|
||||
->not->toContain('aria-modal')
|
||||
->and((string) $this->blade('<x-bottom-sheet height="90dvh">Body</x-bottom-sheet>'))
|
||||
->toContain('--sheet-max-height: min(90dvh, calc(100dvh - 72px))');
|
||||
});
|
||||
|
||||
@@ -118,7 +118,7 @@ it('inlines the application\'s scheme onto the mail', function () {
|
||||
->toContain('border-top: 16px solid #123456')
|
||||
->toContain('color: #fefefe')
|
||||
->toContain('border-radius: 9999px')
|
||||
->and(inlineStyle($html, 'inner-body'))->toContain('background-color: #fdfdfd')
|
||||
->and(inlineStyle($html, 'inner-body'))->toContain('background-color: #fdfdfd')->toContain('border-radius: 28px')
|
||||
->and(inlineStyle($html, 'wrapper'))->toContain('background-color: #eeeeee')
|
||||
->and(inlineStyle($html, 'panel-content'))->toContain('background-color: #eeeeee')
|
||||
->and($html)
|
||||
@@ -190,6 +190,19 @@ it('styles every button colour Laravel and M3 name', function () {
|
||||
expect($css)->not->toContain('@media');
|
||||
});
|
||||
|
||||
it('keeps every corner and weight on M3\'s scales', function () {
|
||||
$css = view('livewire-material::mail.theme')->render();
|
||||
|
||||
preg_match_all('/border-radius: (\d+)px/', $css, $corners);
|
||||
|
||||
// M3's shape scale, plus the `full` corner a button draws as a large pixel value.
|
||||
expect(array_unique($corners[1]))->each->toBeIn(['0', '4', '8', '12', '16', '20', '28', '32', '48', '9999'])
|
||||
->and($css)->toContain(<<<'CSS'
|
||||
.header a {
|
||||
CSS)
|
||||
->toMatch('/\.header a \{[^}]*font-weight: 400/');
|
||||
});
|
||||
|
||||
it('leaves the application\'s mail components alone unless asked', function () {
|
||||
expect(config('mail.markdown.paths'))->not->toContain(LivewireMaterialServiceProvider::mailComponentPath());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user