Draw the scaffold without Tailwind
Plan step 36 (navigation group, third batch): <x-scaffold>'s own styling moves into resources/css/layout/scaffold.css (the content region, the bar and rail row, the banner, the actions row and its rail-collapsed column layout, --material-bottom-bar and --material-margin publishing, the skip link) alongside step 35's FAB and content-margin rules already there. Every data-app-shell* hook becomes data-md-scaffold-* (data-app-shell-bar, -actions, -banner); the skip link is data-md-skip-link; data-app-shell itself is dropped, data-md-scaffold already named the root. The actions row's rail-collapsed:flex-col is written out branch for branch as the navigation rail's own rewrite did for its internal parts: the three width-independent conditions in one :where() group, the four width-gated ones each in their own @media block. With that gone, resources/css/tailwind.css's rail-collapsed custom-variant shim (its last use) is removed; tailwind.css now carries only tokens/theme.css and tokens/utilities.css, which the showcase still needs until step 38. navigation-bar.css's hide-on-scroll rule reading --material-bottom-bar stayed unlayered only because <x-scaffold> published that variable through a Tailwind utility, which no layered rule could outrank; now scaffold.css sets it itself in material.layout, a layer navigation-bar.css's own material.components always beats, so the rule moves into the layer and the file fits one @layer material.components block like every other navigation stylesheet. navigation-bar rejoins NavigationStylesheetsTest.php's dataset and NavigationBarTest.php's own duplicate shape test is retired in favour of it. Browser tests added (docs/plans/material-3-browser-tests.md): the scaffold's FAB dropping the bar's own height once hide-bar-on-scroll slides it away, at the trailing edge in a right-to-left document, and clearing a safe area an application sets on its inline-end and bottom edges. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
20b062846d
commit
ed93222d22
@@ -1,9 +1,6 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
|
||||
|
||||
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>'))
|
||||
@@ -104,41 +101,13 @@ it('hides on a scroll down and springs back on a scroll up', function () {
|
||||
->toBe(['transition' => 'translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default)'])
|
||||
->and($css->declarations('[data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden]'))
|
||||
->toBe(['translate' => '0 100%'])
|
||||
->and($css->css)
|
||||
// Unlayered, or the Tailwind utility <x-scaffold> still publishes the offset with would win —
|
||||
// its own rewrite has not landed yet, so this one rule stays unlayered until it does.
|
||||
->toContain('[data-md-scaffold]:has([data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden]) {')
|
||||
->and(Str::of($css->css)->after('[data-md-scaffold]:has(')->toString())
|
||||
->not->toContain('@layer');
|
||||
// Reads --material-bottom-bar back from <x-scaffold>: layout/scaffold.css publishes it in
|
||||
// `material.layout`, which this rule's own `material.components` always outranks, so both
|
||||
// sit in a layer now (N-02's era of an unlayered Tailwind utility is gone).
|
||||
->and($css->declarations('[data-md-scaffold]:has([data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden])'))
|
||||
->toBe(['--material-bottom-bar' => 'calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px))']);
|
||||
|
||||
expect(file_get_contents(__DIR__.'/../../../resources/js/navigation.js'))
|
||||
// Never out from under a snackbar, a bottom sheet or a drawer resting on its edge.
|
||||
->toContain("document.querySelectorAll('[data-md-toast-snackbar], [role=\"dialog\"]')");
|
||||
});
|
||||
|
||||
/**
|
||||
* navigation-bar.css is not in NavigationStylesheetsTest.php's dataset: it keeps one rule
|
||||
* unlayered on purpose, so its shape does not match every other stylesheet's single
|
||||
* `@layer material.components` block. These are the same checks, minus that one assumption — the
|
||||
* same reason ErrorPagesTest.php carries error-page.css's.
|
||||
*/
|
||||
it('draws navigation-bar.css shaped like every package stylesheet but its one unlayered rule', function () {
|
||||
$css = ComponentStylesheet::read('navigation-bar');
|
||||
|
||||
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())->toContain('@layer material.components')
|
||||
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
|
||||
|
||||
expect(ViewClasses::violations(File::get(__DIR__.'/../../../resources/views/components/navigation-bar.blade.php')))->toBe([]);
|
||||
|
||||
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
|
||||
|
||||
expect($source)->not->toMatch('/(?:^|[,{};]\s*)(?:html|body|dialog|:root)(?![\w-])(?!\[data-md-|:has\(> \[data-md-)/m');
|
||||
|
||||
$components = File::get(__DIR__.'/../../../resources/css/components.css');
|
||||
$block = substr($components, (int) strpos($components, '/* Navigation */'));
|
||||
|
||||
expect($block)->toContain("@import './components/navigation-bar.css';");
|
||||
});
|
||||
|
||||
@@ -15,16 +15,17 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
|
||||
* 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. `navigation-bar` is not in this dataset: its stylesheet keeps
|
||||
* one rule unlayered on purpose (still beating a Tailwind utility <x-scaffold> has not lost yet),
|
||||
* so its shape does not match every other entry's single `@layer material.components` block; the
|
||||
* same checks, adjusted for that one rule, are in NavigationBarTest.php instead — the same reason
|
||||
* ContainmentStylesheetsTest.php gives for leaving the error layout to ErrorPagesTest.php.
|
||||
* one view, one stylesheet, as usual. `navigation-bar` rejoined the dataset with the scaffold's own
|
||||
* rewrite: its one rule reading `--material-bottom-bar` stayed unlayered only while `<x-scaffold>`
|
||||
* published that variable through a Tailwind utility, which no layered rule could outrank; now
|
||||
* scaffold.css sets it in `material.layout`, which this file's `material.components` always beats,
|
||||
* so the whole stylesheet fits one `@layer material.components` block like every other entry here.
|
||||
*/
|
||||
dataset('navigation components', [
|
||||
'app-bar',
|
||||
'toolbar',
|
||||
'tabs',
|
||||
'navigation-bar',
|
||||
'navigation-bar-item',
|
||||
'navigation-rail',
|
||||
'navigation-rail-item',
|
||||
|
||||
@@ -15,6 +15,11 @@ function scaffoldDestinations(): array
|
||||
];
|
||||
}
|
||||
|
||||
function scaffoldCss(): string
|
||||
{
|
||||
return (string) file_get_contents(__DIR__.'/../../../resources/css/layout/scaffold.css');
|
||||
}
|
||||
|
||||
it('draws the adaptive rail, the bar, the content region and the snackbar host', function () {
|
||||
$html = (string) $this->blade('<x-scaffold :destinations="$destinations">The page</x-scaffold>', ['destinations' => scaffoldDestinations()]);
|
||||
|
||||
@@ -22,18 +27,17 @@ it('draws the adaptive rail, the bar, the content region and the snackbar host',
|
||||
->toContain('data-md-navigation-rail="adaptive"')
|
||||
->toContain('data-md-navigation-bar')
|
||||
->toContain('<main id="content" tabindex="-1" wire:transition.navigate')
|
||||
->toContain('max-expanded:overflow-x-clip')
|
||||
->toContain('The page')
|
||||
->toContain('x-data="materialSnackbar"')
|
||||
->toMatch('/<a\s+href="#content"\s+data-skip-link/')
|
||||
->and(strpos($html, 'data-skip-link'))->toBeLessThan(strpos($html, 'data-md-navigation-rail='));
|
||||
->toMatch('/<a\s+href="#content"\s+data-md-skip-link/')
|
||||
->and(strpos($html, 'data-md-skip-link'))->toBeLessThan(strpos($html, 'data-md-navigation-rail='));
|
||||
});
|
||||
|
||||
it('puts every destination in the rail and only those marked for the bar in the bar', function () {
|
||||
$html = (string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]);
|
||||
|
||||
$rail = substr($html, strpos($html, 'data-md-navigation-rail='), strpos($html, 'data-app-shell-bar') - strpos($html, 'data-md-navigation-rail='));
|
||||
$bar = substr($html, strpos($html, 'data-app-shell-bar'));
|
||||
$rail = substr($html, strpos($html, 'data-md-navigation-rail='), strpos($html, 'data-md-scaffold-bar') - strpos($html, 'data-md-navigation-rail='));
|
||||
$bar = substr($html, strpos($html, 'data-md-scaffold-bar'));
|
||||
|
||||
expect(substr_count($rail, '<a data-md-navigation-rail-item'))->toBe(4)
|
||||
->and(substr_count($bar, '<a data-md-navigation-bar-item'))->toBe(2)
|
||||
@@ -89,22 +93,26 @@ it('keeps the destination at the page\'s URL current while a Livewire component
|
||||
->and($probe->html())->toMatch('/href="[^"]*\/livewire-unit-test-endpoint\/[^"]*"[^>]*aria-current="page"/');
|
||||
});
|
||||
|
||||
it('lifts the snackbar above the bar only when there is a bar', function () {
|
||||
it('shows a bar only when a destination asks for one, and none otherwise', function () {
|
||||
expect((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
|
||||
->toContain('max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]')
|
||||
->toContain('data-md-scaffold-bar')
|
||||
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]]))
|
||||
->not->toContain('data-app-shell-bar')
|
||||
->not->toContain('--material-bottom-bar:');
|
||||
->not->toContain('data-md-scaffold-bar');
|
||||
});
|
||||
|
||||
it('sets the bottom offset from the bar\'s own height, short or tall, only below medium', function () {
|
||||
$css = scaffoldCss();
|
||||
|
||||
expect($css)
|
||||
->toContain("[data-md-scaffold]:has(> [data-md-scaffold-bar] [data-md-navigation-bar]:not([data-md-tall])) {\n @media (width < 600px) {\n --material-bottom-bar: calc(64px + var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px));")
|
||||
->toContain("[data-md-scaffold]:has(> [data-md-scaffold-bar] [data-md-navigation-bar][data-md-tall]) {\n @media (width < 600px) {\n --material-bottom-bar: calc(80px + var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px));");
|
||||
});
|
||||
|
||||
it('passes M3\'s tall bar through, and the bottom offset grows with it', function () {
|
||||
expect((string) $this->blade('<x-scaffold :destinations="$destinations" tall-bar />', ['destinations' => scaffoldDestinations()]))
|
||||
->toMatch('/data-md-navigation-bar\s+data-md-tall\s/')
|
||||
->toContain('max-medium:[--material-bottom-bar:calc(5rem+')
|
||||
->not->toContain('[--material-bottom-bar:calc(4rem+')
|
||||
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
|
||||
->not->toContain('data-md-tall')
|
||||
->toContain('max-medium:[--material-bottom-bar:calc(4rem+');
|
||||
->not->toContain('data-md-tall');
|
||||
});
|
||||
|
||||
it('lets the bar leave the window while the page scrolls down', function () {
|
||||
@@ -124,13 +132,74 @@ it('exposes the rail that hides when collapsed', function () {
|
||||
->toContain("materialNavigationRail('adaptive', false)");
|
||||
});
|
||||
|
||||
it('reads the safe area and anything docked on the bar through variables an application can set', function () {
|
||||
it('reads the window margin and the safe areas through variables an application can set', function () {
|
||||
$html = (string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]);
|
||||
|
||||
expect($html)
|
||||
->toContain('+var(--material-bottom-extra,0px))]')
|
||||
->toContain('focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)]')
|
||||
->not->toMatch('/(?<!,)env\(safe-area-inset/');
|
||||
// No inline value the view computed: every one of these is a variable's own fallback in CSS.
|
||||
expect($html)->not->toMatch('/(?<!,)env\(safe-area-inset/');
|
||||
|
||||
$css = scaffoldCss();
|
||||
|
||||
expect($css)
|
||||
->toContain("--material-margin: var(--md-sys-measurement-space200);\n\n display: flex;")
|
||||
->toContain("@media (width >= 600px) {\n --material-margin: var(--md-sys-measurement-space300);")
|
||||
->toContain('top: calc(var(--material-safe-top, env(safe-area-inset-top)) + var(--md-sys-measurement-space200));')
|
||||
->toContain('+ var(--material-bottom-extra, 0px))');
|
||||
});
|
||||
|
||||
it('hides itself at rest and reveals a pill in the window\'s corner on focus', function () {
|
||||
$css = scaffoldCss();
|
||||
|
||||
expect($css)
|
||||
->toContain("[data-md-skip-link] {\n position: absolute;\n inline-size: 1px;\n block-size: 1px;")
|
||||
->toContain('clip-path: inset(50%);')
|
||||
->toContain("&:focus {\n position: fixed;\n z-index: 60;")
|
||||
->toContain('border-radius: var(--md-sys-shape-corner-full);')
|
||||
->toContain('box-shadow: var(--md-sys-elevation-3);');
|
||||
});
|
||||
|
||||
it('is a column below medium and a row of the rail and the content region from it', function () {
|
||||
$css = scaffoldCss();
|
||||
|
||||
expect($css)
|
||||
->toContain("[data-md-scaffold-row] {\n flex: 1 1 0%;\n\n @media (width >= 600px) {\n display: flex;\n }\n }")
|
||||
->toContain("[data-md-scaffold-content] {\n display: flex;\n min-inline-size: 0;\n flex: 1 1 0%;\n flex-direction: column;\n }");
|
||||
});
|
||||
|
||||
it('clips the content region below expanded and clears the bar below medium, never above', function () {
|
||||
$css = scaffoldCss();
|
||||
|
||||
expect($css)
|
||||
->toContain("@media (width < 840px) {\n overflow-x: clip;\n }")
|
||||
->toContain("@media (width < 600px) {\n padding-block-end: var(--material-bottom-bar);\n }");
|
||||
});
|
||||
|
||||
it('pins the bar to the window\'s bottom edge, hidden from medium', function () {
|
||||
$css = scaffoldCss();
|
||||
|
||||
expect($css)->toContain("[data-md-scaffold-bar] {\n position: fixed;\n z-index: 30;\n inset-inline: 0;\n inset-block-end: 0;\n\n @media (width >= 600px) {\n display: none;\n }\n }");
|
||||
});
|
||||
|
||||
it('stacks the actions row into a column through every branch a collapsed rail can take', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-scaffold :destinations="$destinations">
|
||||
<x-slot:actions><span>ACTIONS</span></x-slot:actions>
|
||||
</x-scaffold>
|
||||
BLADE, ['destinations' => scaffoldDestinations()]);
|
||||
|
||||
expect($html)->toMatch('/data-md-scaffold-actions>\s*<span>ACTIONS<\/span>/');
|
||||
|
||||
$css = scaffoldCss();
|
||||
|
||||
// The three width-independent branches share one :where() group; the four width-gated ones
|
||||
// each keep their own @media block, since CSS cannot merge different queries — the same shape
|
||||
// navigation-rail.css's own rewrite gave its internal parts.
|
||||
expect($css)
|
||||
->toContain("&:where(\n [data-md-navigation-rail='collapsed'] *,\n [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,\n [data-md-navigation-rail='modal']:not([data-md-open]) *\n ) {\n flex-direction: column;\n }")
|
||||
->toContain("@media (width < 600px) {\n &:where([data-md-navigation-rail='collapsible']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }")
|
||||
->toContain("@media (width < 840px) {\n &:where([data-md-navigation-rail='adaptive']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }")
|
||||
->toContain("@media (width >= 840px) {\n @media (width < 1200px) {\n &:where(:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }\n }")
|
||||
->toContain("@media (width >= 1200px) {\n &:where([data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }");
|
||||
});
|
||||
|
||||
it('places each slot once', function () {
|
||||
@@ -150,7 +219,7 @@ it('places each slot once', function () {
|
||||
expect(substr_count($html, $slot))->toBe(1);
|
||||
}
|
||||
|
||||
expect($html)->toMatch('/data-md-navigation-rail-header.*BRAND.*FAB.*data-md-navigation-rail-footer.*FOOTER.*data-app-shell-actions.*ACTIONS.*APP BAR.*<main.*PAGE/s');
|
||||
expect($html)->toMatch('/data-md-navigation-rail-header.*BRAND.*FAB.*data-md-navigation-rail-footer.*FOOTER.*data-md-scaffold-actions.*ACTIONS.*APP BAR.*<main.*PAGE/s');
|
||||
});
|
||||
|
||||
it('spans the window with a banner, above the rail, and renders none without the slot', function () {
|
||||
@@ -163,9 +232,9 @@ it('spans the window with a banner, above the rail, and renders none without the
|
||||
|
||||
// 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-md-navigation-rail=.*<main/s')
|
||||
->toMatch('/data-md-scaffold-banner.*WINDOW BAR.*data-md-navigation-rail=.*<main/s')
|
||||
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
|
||||
->not->toContain('data-app-shell-banner');
|
||||
->not->toContain('data-md-scaffold-banner');
|
||||
});
|
||||
|
||||
it('marks the scaffold, and draws the FAB after the page\'s bar and before the page', function () {
|
||||
@@ -178,7 +247,8 @@ it('marks the scaffold, and draws the FAB after the page\'s bar and before the p
|
||||
BLADE, ['destinations' => scaffoldDestinations()]);
|
||||
|
||||
// M3 puts a FAB early in the focus order: after the bar, before the content.
|
||||
expect($html)->toMatch('/^\s*<div\s+data-app-shell\s+data-md-scaffold\b/')
|
||||
expect($html)->toMatch('/^\s*<div\s+data-md-scaffold>/')
|
||||
->not->toContain('data-app-shell')
|
||||
->toMatch('/APP BAR.*<div data-md-scaffold-fab><button>NEW<\/button><\/div>\s*<main id="content".*PAGE/s')
|
||||
->and(substr_count($html, 'NEW'))->toBe(1)
|
||||
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
|
||||
@@ -186,7 +256,7 @@ it('marks the scaffold, and draws the FAB after the page\'s bar and before the p
|
||||
});
|
||||
|
||||
it('places the FAB at the bottom-end corner, clear of the bar and of a snackbar, in the layout layer', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/scaffold.css');
|
||||
$css = scaffoldCss();
|
||||
|
||||
expect($css)->toContain('@layer material.layout')
|
||||
->toContain('--md-scaffold-fab-margin: var(--md-sys-measurement-space200);')
|
||||
@@ -199,6 +269,18 @@ it('places the FAB at the bottom-end corner, clear of the bar and of a snackbar,
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/layout.css'))->toContain("@import './layout/scaffold.css';");
|
||||
});
|
||||
|
||||
it('imports the stylesheets of the navigation rail, the navigation bar and the snackbar host', function () {
|
||||
$css = scaffoldCss();
|
||||
|
||||
expect($css)
|
||||
->toContain("@import '../components/navigation-rail.css';")
|
||||
->toContain("@import '../components/navigation-rail-item.css';")
|
||||
->toContain("@import '../components/navigation-rail-section.css';")
|
||||
->toContain("@import '../components/navigation-bar.css';")
|
||||
->toContain("@import '../components/navigation-bar-item.css';")
|
||||
->toContain("@import '../components/toast.css';");
|
||||
});
|
||||
|
||||
it('is the only name for it: there is no app shell to fall back on', function () {
|
||||
expect(fn () => $this->blade('<x-app-shell :destinations="[]" />'))->toThrow(Exception::class)
|
||||
->and(file_exists(__DIR__.'/../../../resources/views/components/app-shell.blade.php'))->toBeFalse();
|
||||
|
||||
Reference in New Issue
Block a user