Draw the app bar without Tailwind

Plan step 36 (navigation group, first stream): <x-app-bar>'s class
lists move into resources/css/components/app-bar.css, keyed on
data-md-app-bar (with data-md-variant/-sticky/-subtitled/-scrolled/
-collapsed) and its parts data-md-app-bar-row/-leading/-headline/
-title/-subtitle/-search/-trailing/-action/-overflow/-expanded. The
overflow menu (two icon buttons below 600px, four from it, N-09's
312px search cap and N-11's three-column centred row) already matched
the audit; only its hooks, units and layer needed to change. Every
length is px, spacing through the measurement tokens where one
matches (M3's own "4dp leading/trailing space" and "16dp" headline
inset had no exact token, so those stay literal). Imports button.css,
menu.css and menu-item.css for the overflow menu the view renders.

Hooks renamed: every unprefixed data-app-bar-* attribute and
data-scrolled/data-collapsed to data-md-*, updated in
tests/Feature/Components/{AppBarTest,PaneTest}.php and
tests/Browser/{BarsTest,NavigationTest}.php. app-bar.js needed no
change: it reads the bar through $root, not by hook name.

tests/Feature/Components/NavigationStylesheetsTest.php is the
navigation twin of ContainmentStylesheetsTest.php, on the same
tests/Support/ViewClasses.php rule; its dataset starts at 'app-bar'
and a 'tabs' entry (once that commit lands) will read both
tabs.blade.php and tab.blade.php, since they share one stylesheet.

Added the owed browser tests (docs/plans/material-3-browser-tests.md):
the overflow at 599/600px, keyboard in its menu, a menu closing once
its width no longer shows it, and a wire:click action from both the
icon button and the menu item form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 23:21:54 +02:00
co-authored by Claude Sonnet 5
parent b7f120af98
commit b7641a35c2
9 changed files with 437 additions and 155 deletions
+54 -38
View File
@@ -1,5 +1,7 @@
<?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('draws a small, sticky app bar with a heading, navigation and actions', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-app-bar title="Shares" subtitle="12 active">
@@ -11,37 +13,37 @@ it('draws a small, sticky app bar with a heading, navigation and actions', funct
expect($html)
->toContain('<header')
->toContain('x-data="materialAppBar"')
->toContain('data-variant="small"')
->toContain('data-sticky')
->toContain('data-subtitled')
->toContain('<h1 data-app-bar-title>Shares</h1>')
->toContain('<p data-app-bar-subtitle>12 active</p>')
->toContain('<div data-app-bar-leading><button>menu</button></div>')
->toContain('<div data-app-bar-trailing><button>search</button></div>');
->toContain('data-md-variant="small"')
->toContain('data-md-sticky')
->toContain('data-md-subtitled')
->toContain('<h1 data-md-app-bar-title>Shares</h1>')
->toContain('<p data-md-app-bar-subtitle>12 active</p>')
->toContain('<div data-md-app-bar-leading><button>menu</button></div>')
->toContain('<div data-md-app-bar-trailing><button>search</button></div>');
});
it('gives a medium or large bar a collapsed title in its row and the heading under it', function () {
$html = (string) $this->blade('<x-app-bar variant="large" title="Settings" heading="h2" :sticky="false" />');
expect($html)
->toContain('data-variant="large"')
->not->toContain('data-sticky')
->toContain('<div data-app-bar-headline aria-hidden="true" >')
->toContain('<span data-app-bar-title>Settings</span>')
->toContain('<div data-app-bar-expanded>')
->toContain('<h2 data-app-bar-title>Settings</h2>');
->toContain('data-md-variant="large"')
->not->toContain('data-md-sticky')
->toContain('<div data-md-app-bar-headline aria-hidden="true" >')
->toContain('<span data-md-app-bar-title>Settings</span>')
->toContain('<div data-md-app-bar-expanded>')
->toContain('<h2 data-md-app-bar-title>Settings</h2>');
});
it('puts a search bar in the row of a search app bar', function () {
expect((string) $this->blade('<x-app-bar variant="search"><x-search placeholder="Search shares" /></x-app-bar>'))
->toContain('data-app-bar-search')
->toContain('data-md-app-bar-search')
->toContain('placeholder="Search shares"');
});
it('falls back to a small bar and an h1 for unknown values', function () {
expect((string) $this->blade('<x-app-bar variant="huge" heading="section" title="Shares" />'))
->toContain('data-variant="small"')
->toContain('<h1 data-app-bar-title>');
->toContain('data-md-variant="small"')
->toContain('<h1 data-md-app-bar-title>');
});
it('keeps two trailing icon buttons below medium and four from it, the rest in an overflow menu', function () {
@@ -55,17 +57,17 @@ it('keeps two trailing icon buttons below medium and four from it, the rest in a
]" />
BLADE);
preg_match('/data-app-bar-overflow="compact">(.*?)data-app-bar-overflow="medium">(.*)$/s', $html, $menus);
preg_match('/data-md-app-bar-overflow="compact">(.*?)data-md-app-bar-overflow="medium">(.*)$/s', $html, $menus);
[, $compact, $medium] = $menus + [null, '', ''];
expect($html)
// M3's "up to four trailing icons" on a larger screen, the overflow button one of them: the
// first three are icon buttons, and the first stays one on a compact window too, beside the
// overflow button — M3's "up to two icon buttons" (navigation reference § Top app bar).
->and(substr_count($html, '<span data-app-bar-action'))->toBe(3)
->and(substr_count($html, 'data-overflows-compact'))->toBe(2)
->and($html)->toMatch('/<span data-app-bar-action\s*>\s*<button[^>]*aria-label="Search"/')
->toMatch('/<span data-app-bar-action\s+data-overflows-compact\s*>\s*<a [^>]*href="\/shares\/1\/share"[^>]*aria-label="Share"/')
->and(substr_count($html, '<span data-md-app-bar-action'))->toBe(3)
->and(substr_count($html, 'data-md-overflows-compact'))->toBe(2)
->and($html)->toMatch('/<span data-md-app-bar-action\s*>\s*<button[^>]*aria-label="Search"/')
->toMatch('/<span data-md-app-bar-action\s+data-md-overflows-compact\s*>\s*<a [^>]*href="\/shares\/1\/share"[^>]*aria-label="Share"/')
->not->toMatch('/<(?:button|a)[^>]*aria-label="(?:Archive|Delete)"/')
// Every icon button is named, tooltipped and reaches 48px.
->toMatch('/<button[^>]*data-md-size="sm"[^>]*aria-label="Search"/')
@@ -103,26 +105,26 @@ it('adds an overflow menu only at the widths something overflows', function () {
$four = (string) $this->blade('<x-app-bar title="Shares" :actions="$actions" />', ['actions' => $actions(4)]);
expect($two)
->not->toContain('data-app-bar-overflow')
->not->toContain('data-overflows-compact')
->not->toContain('data-md-app-bar-overflow')
->not->toContain('data-md-overflows-compact')
->not->toContain('More options')
->and(substr_count($two, '<span data-app-bar-action'))->toBe(2)
->and(substr_count($two, '<span data-md-app-bar-action'))->toBe(2)
->and($four)
->toContain('data-app-bar-overflow="compact"')
->not->toContain('data-app-bar-overflow="medium"')
->and(substr_count($four, '<span data-app-bar-action'))->toBe(4)
->and(substr_count($four, 'data-overflows-compact'))->toBe(3)
->toContain('data-md-app-bar-overflow="compact"')
->not->toContain('data-md-app-bar-overflow="medium"')
->and(substr_count($four, '<span data-md-app-bar-action'))->toBe(4)
->and(substr_count($four, 'data-md-overflows-compact'))->toBe(3)
// An empty list draws no trailing area; markup in the slot is drawn as written, never
// overflowing.
->and((string) $this->blade('<x-app-bar title="Shares" :actions="[]" />'))
->not->toContain('data-app-bar-trailing')
->not->toContain('data-md-app-bar-trailing')
->and((string) $this->blade('<x-app-bar title="Shares"><x-slot:actions><button>a</button><button>b</button><button>c</button></x-slot:actions></x-app-bar>'))
->toContain('<div data-app-bar-trailing><button>a</button><button>b</button><button>c</button></div>')
->not->toContain('data-app-bar-overflow');
->toContain('<div data-md-app-bar-trailing><button>a</button><button>b</button><button>c</button></div>')
->not->toContain('data-md-app-bar-overflow');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/app-bar.css'))
->toMatch('/@media \(width < 37\.5rem\) \{\s+\[data-app-bar-action\]\[data-overflows-compact\],\s+\[data-app-bar-overflow="medium"\] \{\s+display: none;/')
->toMatch('/@media \(width >= 37\.5rem\) \{\s+\[data-app-bar-overflow="compact"\] \{\s+display: none;/');
->toMatch('/@media \(width < 600px\) \{\s+\[data-md-app-bar-action\]\[data-md-overflows-compact\],\s+\[data-md-app-bar-overflow=\'medium\'\] \{\s+display: none;/')
->toMatch('/@media \(width >= 600px\) \{\s+\[data-md-app-bar-overflow=\'compact\'\] \{\s+display: none;/');
});
it('draws floating and docked toolbars', function () {
@@ -172,14 +174,28 @@ it('sets a FAB at the end of a docked toolbar, resting flat on it', function ()
});
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');
$css = ComponentStylesheet::read('app-bar');
expect($css)
expect(file_get_contents(__DIR__.'/../../../resources/css/components/app-bar.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;/')
->not->toContain('inset-inline: 56px;')
->and($css->declarations('[data-md-app-bar][data-md-variant=\'center\'] [data-md-app-bar-row]'))
->toBe(['display' => 'grid', '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);');
->and($css->declarations('[data-md-app-bar-search]'))
->toHaveKey('max-width', 'calc(312px + (100% - 312px) / 2)');
});
it('collapses a medium or large bar into its row at the breakpoint its title collapses to', function () {
$css = ComponentStylesheet::read('app-bar');
// Sticks at a negative top (the bar minus its measured height) so the row alone remains.
expect($css->declarations('[data-md-app-bar][data-md-sticky]:is([data-md-variant=\'medium\'], [data-md-variant=\'large\'])'))
->toBe(['top' => 'calc(var(--app-bar-row) - var(--app-bar-measured, var(--app-bar-height)))'])
->and($css->declarations('[data-md-app-bar-action][data-md-overflows-compact], [data-md-app-bar-overflow=\'medium\']', ['@media (width < 600px)']))
->toBe(['display' => 'none'])
->and($css->declarations('[data-md-app-bar-overflow=\'compact\']', ['@media (width >= 600px)']))
->toBe(['display' => 'none']);
});
it('keeps a toolbar at the bottom clear of the navigation bar', function () {
@@ -0,0 +1,134 @@
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
/**
* The navigation components, rewritten without Tailwind (plan step 36, the last audit group): each
* view renders `data-md-*` attributes and no class list of its own beyond the interaction and text
* classes (tests/Support/ViewClasses.php), and each has a stylesheet in `material.components` that
* imports the stylesheets of the components its view renders, writes its values from the tokens
* and its breakpoints as px range queries, and is imported from the "Navigation" block of
* components.css.
*
* The dataset grows by one name per component commit, the same rule ContainmentStylesheetsTest.php
* and the earlier groups follow. `<x-tabs>` and `<x-tab>` share one stylesheet (tabs.css) and one
* commit, so `navigationViews()` maps the 'tabs' entry onto both blade views; every other entry is
* one view, one stylesheet, as usual.
*/
dataset('navigation components', [
'app-bar',
]);
/**
* The blade view(s) a dataset name's checks read. One file for every name but 'tabs', whose panel
* lives in a second view, tab.blade.php, sharing tabs.css with tabs.blade.php.
*
* @return list<string>
*/
function navigationViews(string $name): array
{
return $name === 'tabs' ? ['tabs', 'tab'] : [$name];
}
/**
* Fails unless every media query in the stylesheet writes its lengths in px, at one of M3's four
* breakpoints. `rem` and `em` would grow with the reader's text size, which a breakpoint must not.
*/
function assertNavigationBreakpointsInPx(ComponentStylesheet $css, string $label): void
{
foreach ($css->mediaQueries() as $query) {
preg_match_all('/\(([^()]*)\)/', $query, $features);
foreach ($features[1] as $feature) {
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', "{$label}: {$query}");
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$label}: {$query} is not at an M3 breakpoint");
}
}
}
}
/**
* Colours are roles, shadows are elevation levels or a ring, type is a type style, and breakpoints
* are px, at one of M3's four.
*/
function assertNavigationTokensAndPxBreakpoints(ComponentStylesheet $css, string $label): void
{
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
// A box-shadow is `none`, an elevation level, or a ring: `[inset] 0 0 0 <n>px` in a colour role.
$colour = 'var\(--md-sys-color-[a-z-]+\)|color-mix\(in srgb, var\(--md-sys-color-[a-z-]+\) [^;]+?, transparent\)';
preg_match_all('/\bbox-shadow:\s*([^;]+);/', $source, $shadows, PREG_SET_ORDER);
foreach ($shadows as [, $value]) {
expect(trim($value))->toMatch("/^(?:none|var\\(--md-sys-elevation-[0-5]\\)|(?:inset )?0 0 0 \\d+px (?:{$colour}))$/", "{$label}: box-shadow: {$value} is not none, an elevation or a ring in a colour role");
}
assertNavigationBreakpointsInPx($css, $label);
}
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
$css = ComponentStylesheet::read($name);
expect($css->css)->toStartWith('/*')
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
->and($css->blocks())->each->toBe('@layer material.components')
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
foreach ($css->imports() as $import) {
expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist");
}
})->with('navigation components');
it('imports the stylesheet of every component its view renders', function (string $name) {
$rendered = collect(navigationViews($name))
->flatMap(function (string $view): array {
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/components/{$view}.blade.php"), $tags);
return $tags[1];
})
->unique()
// A component still written for Tailwind has no stylesheet of its own to import yet; its
// old stylesheet, if it has one, lives in tailwind.css without the layer statement.
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components'))
->map(fn (string $tag): string => "./{$tag}.css")
->values()
->all();
expect(array_values(array_diff($rendered, ComponentStylesheet::read($name)->imports())))->toBe([]);
})->with('navigation components');
it('writes no class list into the view but the interaction and text classes', function (string $name) {
foreach (navigationViews($name) as $view) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$view}.blade.php")))->toBe([]);
}
})->with('navigation components');
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
assertNavigationTokensAndPxBreakpoints(ComponentStylesheet::read($name), "{$name}.css");
})->with('navigation components');
it('is imported from the navigation block of components.css', function (string $name) {
$components = File::get(__DIR__.'/../../../resources/css/components.css');
$block = substr($components, (int) strpos($components, '/* Navigation */'));
expect($block)->toContain("@import './components/{$name}.css';");
})->with('navigation components');
it('scopes every element-wide selector to a data-md hook', function (string $name) {
// `all.css` bundles every component stylesheet into every page, so a bare `html`, `body`,
// `dialog` or `:root` rule would restyle the application's own pages.
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read($name)->css);
expect($source)->not->toMatch('/(?:^|[,{};]\s*)(?:html|body|dialog|:root)(?![\w-])(?!\[data-md-|:has\(> \[data-md-)/m');
})->with('navigation components');
+11 -11
View File
@@ -6,7 +6,7 @@ it('draws a content region with a body and no app bar when it has no title', fun
expect(layoutRoot($html))->toMatchArray(['<' => 'div', 'data-md-pane' => 'data-md-pane'])
->not->toHaveKeys(['data-md-width', 'data-md-hide-below', 'data-md-hide-from'])
->and($html)->toMatch('/<div data-md-pane-body>The page<\/div>/')
->not->toContain('data-app-bar')
->not->toContain('data-md-app-bar')
->not->toContain('data-md-pane-navigation');
});
@@ -19,18 +19,18 @@ it('gives the pane its own app bar, a direct child spanning it, with the title,
BLADE);
expect($html)
->toMatch('/^\s*<div data-md-pane[^>]*>\s*<header\b[^>]*data-app-bar[^>]*data-md-pane-bar/s')
->toContain('<h2 data-app-bar-title>Settings</h2>')
->toContain('<p data-app-bar-subtitle>Your account</p>')
->toMatch('/<div data-app-bar-trailing><span>ACTIONS<\/span><\/div>/')
->toContain('data-sticky')
->toMatch('/^\s*<div data-md-pane[^>]*>\s*<header\b[^>]*data-md-app-bar[^>]*data-md-pane-bar/s')
->toContain('<h2 data-md-app-bar-title>Settings</h2>')
->toContain('<p data-md-app-bar-subtitle>Your account</p>')
->toMatch('/<div data-md-app-bar-trailing><span>ACTIONS<\/span><\/div>/')
->toContain('data-md-sticky')
->toMatch('/<\/header>\s*<div data-md-pane-body>\s*BODY\s*<\/div>/s')
->and((string) $this->blade('<x-pane title="Settings" :sticky="false" />'))->not->toContain('data-sticky');
->and((string) $this->blade('<x-pane title="Settings" :sticky="false" />'))->not->toContain('data-md-sticky');
});
it('draws a bar for actions alone, and names the heading h1 by default', function () {
expect((string) $this->blade('<x-pane><x-slot:actions><span>ACTIONS</span></x-slot:actions></x-pane>'))->toContain('data-md-pane-bar')
->and((string) $this->blade('<x-pane title="Inbox" />'))->toContain('<h1 data-app-bar-title>Inbox</h1>');
->and((string) $this->blade('<x-pane title="Inbox" />'))->toContain('<h1 data-md-app-bar-title>Inbox</h1>');
});
it('puts the section navigation under the bar and above the body', function () {
@@ -43,7 +43,7 @@ it('puts the section navigation under the bar and above the body', function () {
expect($html)->toMatch('/<\/header>\s*<div data-md-pane-navigation><nav>SECTIONS<\/nav><\/div>\s*<div data-md-pane-body>\s*BODY/s')
// It is the pane's section navigation, not the bar's leading button.
->not->toContain('data-app-bar-leading');
->not->toContain('data-md-app-bar-leading');
});
it('leads the bar with a back link, a back action for a list-detail, or the leading slot', function () {
@@ -51,12 +51,12 @@ it('leads the bar with a back link, a back action for a list-detail, or the lead
$action = (string) $this->blade('<x-pane title="Message" back />');
$leading = (string) $this->blade('<x-pane title="Inbox" back><x-slot:leading><button>MENU</button></x-slot:leading></x-pane>');
expect($link)->toMatch('/data-app-bar-leading><span data-md-pane-back\s*>/')
expect($link)->toMatch('/data-md-app-bar-leading><span data-md-pane-back\s*>/')
->toMatch('/<a[^>]*href="\/shares"[^>]*aria-label="Back"/s')
->not->toContain('data-md-list-detail-back')
->and($action)->toMatch('/<span data-md-pane-back\s+data-md-list-detail-back\s*>/')
->toMatch('/<button[^>]*aria-label="Back"[^>]*x-on:click="back\(\)"/s')
->and($leading)->toContain('<div data-app-bar-leading><button>MENU</button></div>')
->and($leading)->toContain('<div data-md-app-bar-leading><button>MENU</button></div>')
->not->toContain('data-md-pane-back');
});