Merge branch 'worktree-agent-a6769a5cd2c4b070e'
This commit is contained in:
@@ -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))');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user