Merge branch 'worktree-agent-a0d44b9ad5ca7814d'
This commit is contained in:
@@ -20,6 +20,69 @@ it('connects buttons 2px apart', function () {
|
||||
->toContain('gap-0.5');
|
||||
});
|
||||
|
||||
it('squares a group, and a choice drawn as one', function () {
|
||||
// M3 lists "Default shape | Round, square" as a button-group configuration; the shape covers
|
||||
// every button in the group, so it need not be written on each one.
|
||||
expect((string) $this->blade('<x-button-group shape="square"><x-button label="Day" /></x-button-group>'))
|
||||
->toContain('data-shape="square"')
|
||||
->and((string) $this->blade('<x-button-group connected shape="square"><x-button label="Day" /></x-button-group>'))
|
||||
->toContain('data-button-group="connected"')
|
||||
->toContain('data-shape="square"')
|
||||
->and((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
|
||||
->toContain('data-shape="round"')
|
||||
->and((string) $this->blade('<x-group shape="square" name="x" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
|
||||
->toContain('data-shape="square"')
|
||||
->and((string) $this->blade('<x-group name="x" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
|
||||
->toContain('data-shape="round"');
|
||||
});
|
||||
|
||||
it('takes over aria-pressed for a group that carries a selection', function () {
|
||||
$html = (string) $this->blade('<x-button-group connected selection="single" required label="View"><x-button label="Day" value="day" :selected="true" /></x-button-group>');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-selection="single"')
|
||||
->toContain('data-selection-required')
|
||||
->toContain('multiple: false')
|
||||
->toContain('required: true')
|
||||
->toContain('value: null')
|
||||
->toContain('x-on:click="press($event)"')
|
||||
// No model: the group takes the state from the buttons' own aria-pressed and goes on.
|
||||
->toContain('x-modelable="value"')
|
||||
->and((string) $this->blade('<x-button-group connected selection="multi"><x-button label="Bold" value="bold" :selected="false" /></x-button-group>'))
|
||||
->toContain('multiple: true')
|
||||
->toContain('required: false')
|
||||
->toContain('value: []')
|
||||
->and((string) $this->blade('<x-button-group connected><x-button label="Day" /></x-button-group>'))
|
||||
->not->toContain('data-selection')
|
||||
->not->toContain('press($event)');
|
||||
});
|
||||
|
||||
it('entangles a selection group with the property it binds, and keeps wire:model off the div', function () {
|
||||
$component = new class extends Component
|
||||
{
|
||||
public string $view = 'week';
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div>
|
||||
<x-button-group connected selection="single" required wire:model.live="view" label="View">
|
||||
<x-button label="Day" value="day" :selected="$view === 'day'" />
|
||||
<x-button label="Week" value="week" :selected="$view === 'week'" />
|
||||
</x-button-group>
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
};
|
||||
|
||||
$html = Livewire::test($component)->assertSet('view', 'week')->html();
|
||||
|
||||
expect($html)
|
||||
->toContain('data-selection="single"')
|
||||
->toMatch('/value: window\.Livewire\.find\(/')
|
||||
->and(substr_count($html, 'wire:model.live="view"'))->toBe(0);
|
||||
});
|
||||
|
||||
it('never wraps a group onto a second line', function () {
|
||||
expect((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
|
||||
->not->toContain('flex-wrap');
|
||||
|
||||
@@ -41,6 +41,23 @@ it('gives the extended FAB M3\'s gaps and its 80px minimum width', function (str
|
||||
'lg' => ['lg', 'h-24 min-w-24 gap-5'],
|
||||
]);
|
||||
|
||||
it('collapses an extended FAB to a FAB on scroll when asked', function () {
|
||||
$html = (string) $this->blade('<x-fab icon="add" label="New share" size="lg" collapse-on-scroll />');
|
||||
|
||||
expect($html)
|
||||
->toContain('x-data="materialFab"')
|
||||
->toContain('x-bind:data-collapsed="collapsed ? '' : null"')
|
||||
->toContain('data-fab-collapsible')
|
||||
->toContain('data-fab-size="lg"')
|
||||
// The label is clipped, never removed, so the collapsed FAB keeps its name.
|
||||
->toContain('<span data-fab-label><span>New share</span></span>');
|
||||
|
||||
// A plain FAB has nothing to collapse, and an extended FAB without a glyph would collapse to nothing.
|
||||
expect((string) $this->blade('<x-fab icon="add" aria-label="New" collapse-on-scroll />'))->not->toContain('materialFab')
|
||||
->and((string) $this->blade('<x-fab label="New share" collapse-on-scroll />'))->not->toContain('materialFab')
|
||||
->and((string) $this->blade('<x-fab icon="add" label="New share" />'))->not->toContain('materialFab')->toContain('<span>New share</span>');
|
||||
});
|
||||
|
||||
it('marks its root for the places that draw a nested FAB their own way', function () {
|
||||
expect((string) $this->blade('<x-fab icon="add" aria-label="New" />'))->toContain('data-fab');
|
||||
});
|
||||
|
||||
@@ -103,6 +103,25 @@ it('separates and labels groups', function () {
|
||||
->assertSee('role="group" aria-label="Sort by"', false);
|
||||
});
|
||||
|
||||
it('separates clusters by a gap instead of a divider when asked', function () {
|
||||
$html = (string) $this->blade('<x-menu-group gap><x-menu-item label="Cut" /></x-menu-group>');
|
||||
|
||||
// M3 Expressive's grouped layout: 2px between the items of a cluster, 8px between clusters —
|
||||
// the same 8px the divider keeps above and below its line.
|
||||
expect($html)
|
||||
->toContain('role="group"')
|
||||
->toContain('not-first:mt-2')
|
||||
->toContain('space-y-0.5')
|
||||
->not->toContain('aria-label')
|
||||
->not->toContain('py-1 first:pt-0')
|
||||
->and((string) $this->blade('<x-menu-group label="Then" gap><x-menu-item label="Cut" /></x-menu-group>'))
|
||||
->toContain('aria-label="Then"')
|
||||
->toContain('space-y-0.5')
|
||||
->and((string) $this->blade('<x-menu-group label="Sort by"><x-menu-item label="Newest" /></x-menu-group>'))
|
||||
->toContain('py-1 first:pt-0 last:pb-0')
|
||||
->not->toContain('space-y-0.5');
|
||||
});
|
||||
|
||||
it('adds icon-class to the leading icon, over its own colour but not over disabled', function () {
|
||||
$leading = fn (string $html): string => preg_match('/<svg[^>]*class="([^"]*)"/', $html, $icon) ? $icon[1] : '';
|
||||
|
||||
@@ -117,3 +136,82 @@ it('adds icon-class to the leading icon, over its own colour but not over disabl
|
||||
->and((string) $this->blade('<x-menu-item label="Next" icon-right="chevron_right" icon-class="text-sport-run" />'))
|
||||
->not->toContain('text-sport-run');
|
||||
});
|
||||
|
||||
it('opens a submenu beside the item that holds it', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-menu-item label="Send to" icon="send" submenu>
|
||||
<x-menu-item label="A person" icon="person" />
|
||||
</x-menu-item>
|
||||
BLADE);
|
||||
|
||||
preg_match('/anchor-name: (--material-submenu-([a-z0-9]+))/', $html, $anchor);
|
||||
|
||||
expect($anchor)->not->toBeEmpty()
|
||||
->and($html)
|
||||
->toContain('x-data="materialSubmenu"')
|
||||
->toContain('x-on:keydown.right.prevent.stop="open(\'first\')"')
|
||||
->toContain('x-on:pointerenter="hover($event)"')
|
||||
->toContain('aria-haspopup="menu"')
|
||||
->toContain('aria-expanded="false"')
|
||||
->toContain("aria-controls=\"material-submenu-{$anchor[2]}\"")
|
||||
->toContain("id=\"material-submenu-{$anchor[2]}\"")
|
||||
->toContain("position-anchor: {$anchor[1]}")
|
||||
->toContain('data-submenu')
|
||||
->toContain('aria-label="Send to"')
|
||||
->toContain('[position-area:inline-end_span-block-end]')
|
||||
->toContain('[position-try-fallbacks:flip-inline]')
|
||||
// Opening a submenu chooses nothing, so the menu around it stays where it was.
|
||||
->toContain('data-keep-open')
|
||||
->toContain('A person');
|
||||
});
|
||||
|
||||
it('marks a submenu item with a chevron instead of a tick', function () {
|
||||
$chevron = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/outlined-20/chevron_right.svg'));
|
||||
$chevron = substr($chevron, (int) strpos($chevron, '><') + 1);
|
||||
|
||||
expect((string) $this->blade('<x-menu-item label="Export as" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
|
||||
->toContain($chevron)
|
||||
->toContain('rtl:-scale-x-100')
|
||||
->and((string) $this->blade('<x-menu-item label="Export as" icon-right="download" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
|
||||
->not->toContain($chevron);
|
||||
});
|
||||
|
||||
it('embeds a text field that filters the list, as a combobox over the menu', function () {
|
||||
$html = (string) $this->blade('<x-menu label="Assign to" filter="Find a person"><x-slot:trigger><button>x</button></x-slot:trigger><x-menu-item label="Ada" /></x-menu>');
|
||||
|
||||
preg_match('/id="(material-menu-[a-z0-9]+)"/', $html, $id);
|
||||
|
||||
expect($id)->not->toBeEmpty()
|
||||
->and($html)
|
||||
->toContain('data-menu-filter')
|
||||
->toContain('x-ref="filter"')
|
||||
->toContain('role="combobox"')
|
||||
->toContain('aria-autocomplete="list"')
|
||||
->toContain("aria-controls=\"{$id[1]}-list\"")
|
||||
->toContain('aria-label="Find a person"')
|
||||
->toContain('placeholder="Find a person"')
|
||||
->toContain('x-on:input="refine()"')
|
||||
->toContain('x-on:keydown.stop="search($event)"')
|
||||
->toContain('Nothing matches')
|
||||
// A text field is not something a `role="menu"` may hold, so the list moves inside it.
|
||||
->toContain("<div id=\"{$id[1]}-list\" role=\"menu\" aria-label=\"Assign to\"")
|
||||
->toMatch('/<div\s[^>]*popover="auto"(?![^>]*role="menu")/');
|
||||
});
|
||||
|
||||
it('names a bare filter field, and leaves a plain menu alone', function () {
|
||||
expect((string) $this->blade('<x-menu filter><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
|
||||
->toContain('aria-label="Filter"')
|
||||
->and((string) $this->blade('<x-menu label="Share"><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
|
||||
->not->toContain('data-menu-filter')
|
||||
->not->toContain('x-ref="filter"')
|
||||
->toMatch('/<div\s[^>]*popover="auto"[^>]*role="menu"/');
|
||||
});
|
||||
|
||||
it('tells a vibrant menu apart, so the submenus inside it take the same container', function () {
|
||||
expect((string) $this->blade('<x-menu vibrant><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
|
||||
->toContain('data-menu')
|
||||
->toContain('data-vibrant')
|
||||
->and((string) $this->blade('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
|
||||
->toContain('data-menu')
|
||||
->not->toContain('data-vibrant');
|
||||
});
|
||||
|
||||
@@ -21,6 +21,18 @@ it('draws no state icon, and reaches 48px from its 40px controls', function () {
|
||||
->toContain('state-layer focus-ring touch-target inline-flex size-10');
|
||||
});
|
||||
|
||||
it('grows to M3\'s two-line height, and wraps the action below on a compact window', function () {
|
||||
$html = (string) $this->blade('<x-toast />');
|
||||
|
||||
// SnackbarTokens: 48dp for one line, 68dp for two — a description is that second line.
|
||||
expect($html)
|
||||
->toContain('x-bind:class="current.description ? \'min-h-17\' : \'min-h-12\'"')
|
||||
->toContain('flex-wrap')
|
||||
// M3's "two lines with longer action": the action leaves the text's line below `medium`.
|
||||
->toContain('x-bind:class="current.description && current.action ? \'max-medium:basis-full\' : \'\'"')
|
||||
->toContain('touch-target ms-auto h-10');
|
||||
});
|
||||
|
||||
it('can sit at the start', function () {
|
||||
expect((string) $this->blade('<x-toast position="bottom-start" />'))->toContain('justify-start');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user