Merge branch 'worktree-agent-ab53265ffbd837742'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:21:01 +02:00
23 changed files with 572 additions and 195 deletions
+53 -10
View File
@@ -69,31 +69,70 @@ it('draws floating and docked toolbars', function () {
->not->toContain('data-toolbar-group');
});
it('centres a headline on a three-column row and curves the search container', function () {
$css = file_get_contents(__DIR__.'/../../../resources/css/components/app-bar.css');
expect($css)
// Not a fixed 56px inset, which fits exactly one of M3's two trailing buttons (N-11).
->not->toContain('inset-inline: 3.5rem;')
->toMatch('/\[data-variant="center"\] \[data-app-bar-row\] \{\s+display: grid;\s+grid-template-columns: 1fr auto 1fr;/')
// 312dp, then half of what is left (N-09).
->toContain('max-width: calc(19.5rem + (100% - 19.5rem) / 2);');
});
it('keeps a toolbar at the bottom clear of the navigation bar', function () {
$css = file_get_contents(__DIR__.'/../../../resources/css/components/toolbar.css');
expect($css)
// Never a sum: --material-bottom-bar already swallows the bottom safe area (N-02).
->toContain('bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + 1rem);')
->toContain('bottom: var(--material-bottom-bar, 0px);')
// A vertical toolbar keeps 24dp from the edge where a horizontal one keeps 16 (N-12).
->toContain('inset-inline-end: calc(1.5rem + var(--material-safe-right, env(safe-area-inset-right)));')
// A standard toolbar's standard buttons are primary (N-13).
->toContain('[data-toolbar]:not([data-vibrant]) [data-icon-button]:not([aria-pressed="true"]) {');
});
it('offers M3\'s three contrast levels, marking the one in force', function () {
expect((string) $this->blade('<x-theme-toggle mode="contrast" />'))
->toContain('data-theme-toggle="contrast"')
->toContain('role="radiogroup"')
->toContain('aria-label="Contrast"')
->toContain('data-contrast-option="standard"')
->toContain('data-contrast-option="medium"')
->toContain('data-contrast-option="high"')
->toContain("\$store.theme.setContrast('high')")
// A connected button group over native radios, not the deprecated segmented button (N-04).
->toContain('data-button-group="connected"')
->toContain('name="material-contrast"')
->toContain('value="standard"')
->toContain('value="medium"')
->toContain('value="high"')
->toContain('$store.theme.setContrast(value)')
// The row follows the resolved level, so the operating system's shows under `system`.
->toContain("(\$store.theme.resolvedContrast === 'medium').toString()");
->toContain('return this.$store.theme.resolvedContrast');
});
it('names a theme row for a screen reader and legends it on request', function () {
expect((string) $this->blade('<x-theme-toggle mode="picker" />'))
->toContain('aria-label="Theme"')
->not->toContain('<legend')
->and((string) $this->blade('<x-theme-toggle mode="picker" label="Appearance" />'))
->toContain('aria-label="Appearance"')
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Appearance</legend>');
});
it('switches the theme through the store in three shapes', function () {
expect((string) $this->blade('<x-theme-toggle />'))
->toContain('data-theme-toggle="toggle"')
->toContain('aria-label="Dark theme"')
// 40px drawn, 48px reached: M3's minimum target (N-01).
->toContain('touch-target')
->toContain('$store.theme.toggle()')
->and((string) $this->blade('<x-theme-toggle mode="cycle" />'))
->toContain('data-theme-toggle="cycle"')
->toContain("{ light: 'dark', dark: 'system', system: 'light' }")
->and((string) $this->blade('<x-theme-toggle mode="picker" />'))
->toContain('role="radiogroup"')
->toContain('data-theme-option="system"')
->toContain("\$store.theme.set('dark')");
->toContain('data-theme-toggle="picker"')
->toContain('x-model="chosen"')
->toContain('name="material-theme"')
->toContain('value="system"')
->toContain('$store.theme.set(value)');
});
it('opens an account menu from the initials of a name', function () {
@@ -107,12 +146,16 @@ it('opens an account menu from the initials of a name', function () {
expect($html)
->toContain('aria-label="Account"')
->toContain('data-account-menu')
// The 40px avatar reaches 48px, and nothing clips the pseudo-target (N-01); the trigger
// has a hover and a pressed state like every other trigger in the library (N-15).
->toContain('state-layer touch-target focus-ring')
->not->toContain('overflow-hidden')
->toMatch('/>\s*AM\s*<\/button>/')
->toContain('anna@example.com')
->toContain('Settings')
->toContain('data-account-theme')
->toContain('Sign out')
->and((string) $this->blade('<x-account-menu avatar="/anna.jpg" :theme="false" />'))
->toContain('<img src="/anna.jpg"')
->toContain('<img src="/anna.jpg" alt="" class="relative -z-20 size-full rounded-corner-full object-cover" />')
->not->toContain('data-account-theme');
});
+17 -1
View File
@@ -109,6 +109,7 @@ it('reads the safe area and anything docked on the bar through variables an appl
it('places each slot once', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-app-shell :destinations="$destinations">
<x-slot:banner><header>WINDOW BAR</header></x-slot:banner>
<x-slot:brand><span>BRAND</span></x-slot:brand>
<x-slot:rail-header><span>FAB</span></x-slot:rail-header>
<x-slot:rail-footer><span>FOOTER</span></x-slot:rail-footer>
@@ -118,13 +119,28 @@ it('places each slot once', function () {
</x-app-shell>
BLADE, ['destinations' => shellDestinations()]);
foreach (['BRAND', 'FAB', 'FOOTER', 'ACTIONS', 'APP BAR', 'PAGE'] as $slot) {
foreach (['WINDOW BAR', 'BRAND', 'FAB', 'FOOTER', 'ACTIONS', 'APP BAR', 'PAGE'] as $slot) {
expect(substr_count($html, $slot))->toBe(1);
}
expect($html)->toMatch('/data-navigation-rail-header.*BRAND.*FAB.*data-navigation-rail-footer.*FOOTER.*data-app-shell-actions.*ACTIONS.*APP BAR.*<main.*PAGE/s');
});
it('spans the window with a banner, above the rail, and renders none without the slot', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-app-shell :destinations="$destinations">
<x-slot:banner><header>WINDOW BAR</header></x-slot:banner>
PAGE
</x-app-shell>
BLADE, ['destinations' => shellDestinations()]);
// Bars, then rails, then panes: the banner is outside the row the rail and the page share.
expect($html)
->toMatch('/data-app-shell-banner.*WINDOW BAR.*data-navigation-rail=.*<main/s')
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
->not->toContain('data-app-shell-banner');
});
it('serves the showcase\'s app shell pages', function () {
$html = $this->withoutVite()
->get('/material/shell/starred')
@@ -1,5 +1,7 @@
<?php
use Illuminate\Support\Str;
it('draws a navigation landmark around its items', function () {
expect((string) $this->blade('<x-navigation-bar><x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-bar>'))
->toContain('<nav aria-label="Main" data-navigation-bar')
@@ -46,3 +48,15 @@ it('badges the icon with a dot or a count that screen readers hear', function ()
->and($dot)->toContain('size-1.5')->toContain('<span class="sr-only">, New mail</span>')
->and((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" />'))->not->toContain('sr-only');
});
it('keeps the bar item on the label and state-layer colours M3 tokens', function () {
$css = file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css');
$horizontal = Str::of($css)->after('@container (width >= 37.5rem)')->before('/* ------')->toString();
expect($horizontal)
// NavigationBarTokens names one label font for both icon positions: label-medium (N-08).
->not->toContain('--md-sys-typescale-label-lg')
->and($css)
// The indicator and the pill wash in on-secondary-container, as the rail's do (N-19).
->toContain('[data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill])::before {');
});
@@ -22,6 +22,8 @@ it('gives the collapsible, modal and adaptive rails a menu button and the store'
->toContain('data-navigation-rail-menu')
->toContain('aria-label="Collapse navigation"')
->toContain('aria-expanded="true"')
// The 40px menu button reaches M3's 48px target through the shared utility (N-01).
->toContain('touch-target')
->not->toContain('data-navigation-rail-scrim')
->not->toContain('x-trap')
->and((string) $this->blade('<x-navigation-rail mode="modal" />'))
@@ -49,6 +51,34 @@ it('keeps the header and footer out of the scrolling destinations', function ()
->toMatch('/data-navigation-rail-header.*Brand.*FAB.*data-navigation-rail-destinations.*Inbox.*data-navigation-rail-footer.*Account/s');
});
it('takes M3\'s optional divider and turns the container fill off', function () {
expect((string) $this->blade('<x-navigation-rail mode="expanded" divider :fill="false" />'))
->toContain('data-divider')
->toContain('data-fill="false"')
->and((string) $this->blade('<x-navigation-rail mode="expanded" />'))
->not->toContain('data-divider')
->not->toContain('data-fill');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
// Neither reaches a rail open over a scrim, which is a surface over the page (N-22).
->toContain('[data-navigation-rail][data-divider]:not([data-open]) > [data-navigation-rail-panel] {')
->toContain("[data-navigation-rail][data-fill='false']:not([data-open]) > [data-navigation-rail-panel] {")
// A collapsible rail is held to 96px where M3 asks for a navigation bar instead (N-24).
->toMatch("/\\[data-navigation-rail='collapsible'\\] \\{\\s+width: 6rem;/");
});
it('flattens a FAB nested in the rail header and morphs its label', function () {
// Both rules hang off `data-fab` on <x-fab>'s root, and both are unlayered, because what they
// beat — the FAB's shadow, its gap, an extended FAB's minimum width — are utilities.
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
// A nested FAB rests at elevation 0, not the 3 a standalone one has (N-03).
->toContain('[data-navigation-rail-header] [data-fab],')
->toContain('[data-navigation-rail-header] [data-fab]:hover {')
// One FAB whose label springs shut, not two swapped by display (N-23).
->toContain('[data-navigation-rail-header] [data-fab] > span {')
->toMatch('/@variant rail-collapsed \{\s+min-width: 0;\s+aspect-ratio: 1;\s+gap: 0;/');
});
it('draws a destination in both shapes, with its count on the icon and at the end', function () {
$html = (string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />');
+48 -7
View File
@@ -21,10 +21,27 @@ it('renders the tablist on the server with the first enabled tab chosen', functi
->toContain('data-tab-indicator')
->toContain('--tabs-indicator: share-indicator')
->toContain('x-modelable="selected"')
->toContain('role="tabpanel"')
// The panel's id is the server's, so aria-controls never dangles (N-05).
->toMatch('/role="tabpanel"\s+id="share-people-panel"\s+aria-labelledby="share-people"/')
->toContain('>3</span>');
});
it('renders every panel but the chosen one hidden, before Alpine boots', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-tabs :tabs="[['name' => 'files', 'label' => 'Files'], ['name' => 'people', 'label' => 'People']]" selected="people">
<x-tab name="files">Three files.</x-tab>
<x-tab name="people">Anna and Ben.</x-tab>
</x-tabs>
BLADE);
expect($html)
->toMatch('/id="[^"]+-files-panel"[^>]*style="display: none"/')
->toMatch('/id="[^"]+-people-panel"(?![^>]*style="display: none")/')
// The tab button and its panel agree on the id the slot worked out for itself.
->toMatch('/aria-controls="(tabs-[0-9a-f]{8})-people-panel"/')
->toMatch('/id="(tabs-[0-9a-f]{8})-people-panel"/');
});
it('takes a variant, stacks icons and scrolls on request', function () {
expect((string) $this->blade('<x-tabs variant="secondary" stacked scrollable :tabs="[[\'name\' => \'a\', \'label\' => \'A\']]" />'))
->toContain('data-variant="secondary"')
@@ -42,14 +59,33 @@ it('entangles the chosen tab with Livewire and renders the one the property name
public function render(): string
{
return '<div><x-tabs wire:model.live="tab" :tabs="[[\'name\' => \'files\', \'label\' => \'Files\'], [\'name\' => \'people\', \'label\' => \'People\']]" /></div>';
return '<div><x-tabs wire:model.live="tab" :tabs="[[\'name\' => \'files\', \'label\' => \'Files\'], [\'name\' => \'people\', \'label\' => \'People\']]"><x-tab name="files">Three files.</x-tab><x-tab name="people">Anna and Ben.</x-tab></x-tabs></div>';
}
});
expect(Livewire::test('tabs-probe')->html())
->toContain(".entangle('tab').live")
->not->toContain('x-modelable')
->toMatch('/data-tab="people"\s+aria-controls="[^"]+"\s+aria-selected="true"/');
->toMatch('/data-tab="people"\s+aria-controls="[^"]+"\s+aria-selected="true"/')
// The panel the property names is the one drawn, before Alpine entangles anything (N-05).
->toMatch('/id="[^"]+-files-panel"[^>]*style="display: none"/')
->toMatch('/id="[^"]+-people-panel"(?![^>]*style="display: none")/');
});
it('keeps the tab bar on M3\'s offsets, indicator inset and focus ring', function () {
$css = file_get_contents(__DIR__.'/../../../resources/css/components/tabs.css');
expect($css)
// 52dp before the first of a scrollable set (N-10).
->toContain("[data-tabs-bar][data-scrollable] {\n padding-inline-start: 3.25rem;")
// A primary indicator is inset 2dp each side; a secondary one spans the tab (N-18).
->toMatch('/\[data-tab-indicator\] \{\s+position: absolute;\s+inset-inline: 2px;/')
->toMatch('/\[data-variant="secondary"\] \[data-tab-indicator\] \{\s+inset-inline: 0;/')
// The ring is 2px outside, as everywhere else in the package (N-20).
->toContain('outline-offset: 2px;')
->not->toContain('outline-offset: -3px;')
// A link marked as the page is the chosen tab too (N-21).
->toContain('[data-tab]:is([aria-selected="true"], [aria-current="page"]) {');
});
it('draws section navigation as secondary tabs and a picker', function () {
@@ -64,7 +100,7 @@ it('draws section navigation as secondary tabs and a picker', function () {
->toContain('aria-label="Settings"')
->toContain('data-section-picker')
->toContain('data-variant="secondary"')
->toContain('medium:flex')
->toContain('max-medium:hidden')
->toMatch('/href="\/settings\/security"\s+data-tab\s+aria-current="page"\s+wire:navigate/')
->not->toMatch('/href="\/settings\/profile"\s+data-tab\s+aria-current/')
// The picker is a menu of places: the current one is the page, not a checked choice,
@@ -74,15 +110,20 @@ it('draws section navigation as secondary tabs and a picker', function () {
->toMatch('/data-section-picker.*Security.*>\s*1\s*<.*<nav/s');
});
it('marks the section whose url is the request\'s, and wraps many sections onto a grid', function () {
it('marks the section whose url is the request\'s, and scrolls many sections', function () {
$this->get('/');
$items = collect(range(1, 7))->map(fn (int $n): array => ['title' => "S{$n}", 'url' => $n === 3 ? url('/') : "/s/{$n}"])->all();
expect((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => $items]))
->toContain('medium:grid medium:grid-cols-4 large:flex')
// Five or more sections are M3's scrollable tabs, not a grid of wrapped rows (N-16).
->toMatch('/<ul data-tabs-bar data-variant="secondary"\s+data-scrollable\s*>/')
->not->toContain('grid-cols-')
->toMatch('/data-tab\s+aria-current="page"\s*>\s*<span data-tab-content>\s*<span class="truncate">S3/')
->not->toContain('wire:navigate');
->not->toContain('wire:navigate')
// Four still share the row.
->and((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => array_slice($items, 0, 4)]))
->not->toContain('data-scrollable');
});
it('keeps the page\'s section current while a Livewire component on it updates', function () {