diff --git a/tests/Browser/NavigationTest.php b/tests/Browser/NavigationTest.php index fdbfd3e8..48776bf8 100644 --- a/tests/Browser/NavigationTest.php +++ b/tests/Browser/NavigationTest.php @@ -16,9 +16,11 @@ function navigationReady(mixed $page): mixed function shellPage(int $width = 1280, int $height = 900, string $path = '/material/shell'): mixed { // The window is resized after the page starts loading, and an adaptive rail hears of the new - // width from a media query's change event, which can arrive after the first key press. + // width from a media query's change event, which can arrive after the first key press. Both + // of its queries are M3 window size classes: `expanded` (840px) says the rail is standard + // rather than modal, `large` (1200px) that it starts expanded rather than collapsed. return navigationReady(visit($path)->resize($width, $height)) - ->assertScript("window.eval(\"(() => { const rail = document.querySelector('[data-navigation-rail]'); return ! rail || rail.dataset.navigationRail !== 'adaptive' || Alpine.\$data(rail).wide === window.matchMedia('(min-width: 64rem)').matches; })()\")"); + ->assertScript("window.eval(\"(() => { const rail = document.querySelector('[data-navigation-rail]'); if (! rail || rail.dataset.navigationRail !== 'adaptive') return true; const state = Alpine.\$data(rail); return state.wide === window.matchMedia('(width >= 840px)').matches && state.roomy === window.matchMedia('(width >= 1200px)').matches; })()\")"); } /** @@ -61,8 +63,8 @@ function railWidth(int $pixels): string return 'Math.round('.RAIL.".getBoundingClientRect().width) === {$pixels}"; } -it('shows the navigation bar on a phone and marks the current destination', function () { - shellPage(400, 860) +it('shows the navigation bar on a compact window and marks the current destination', function () { + shellPage(599, 860) ->assertScript('getComputedStyle('.BOTTOM_BAR.").display !== 'none'") ->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'") ->assertScript(BOTTOM_BAR.".querySelectorAll('[data-navigation-bar-item]').length === 4") @@ -72,8 +74,13 @@ it('shows the navigation bar on a phone and marks the current destination', func ->assertNoJavaScriptErrors(); }); -it('collapses the rail on a medium window, icon over label', function () { - shellPage(800) +it('collapses the rail across the medium class, icon over label, from its first pixel', function () { + shellPage(600) + ->assertScript(railWidth(96)) + ->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'") + ->assertNoJavaScriptErrors(); + + shellPage(839) ->assertScript(railWidth(96)) ->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'") ->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-item]')).flexDirection === 'column'") @@ -81,10 +88,42 @@ it('collapses the rail on a medium window, icon over label', function () { ->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'false'); }); -it('expands the rail from lg, and keeps it collapsed across a reload from the first paint', function () { +it('starts the rail collapsed across the expanded class and expands it in the layout', function () { + // 840-1199 is M3's expanded class: a standard rail, collapsed until someone says otherwise, + // whose menu button widens it in place — no scrim, no focus trap, the page beside it live. + shellPage(1199) + ->assertScript(railWidth(96)) + ->assertScript("document.documentElement.hasAttribute('data-rail-auto')") + ->assertScript("localStorage.getItem('material-rail') === null") + ->assertNoJavaScriptErrors(); + + $page = shellPage(840) + ->assertScript(railWidth(96)) + ->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'") + ->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'false'); + + $page->click('[data-navigation-rail-menu]') + ->assertScript(railWidth(256)) + ->assertScript('! '.RAIL.".hasAttribute('data-open')") + ->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-scrim]')).display === 'none'") + ->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') === null") + ->assertScript("document.documentElement.getAttribute('data-rail') === 'expanded'") + ->assertScript("! document.documentElement.hasAttribute('data-rail-auto')") + ->assertScript("localStorage.getItem('material-rail') === 'expanded'") + ->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true') + ->assertNoJavaScriptErrors(); + + // The choice outlives the reload, and holds to the top of the class. + navigationReady($page->refresh())->assertScript(railWidth(256)); +}); + +it('expands the rail from large, and keeps it collapsed across a reload from the first paint', function () { firstPaintProbe(); - $page = shellPage(1280, 900, '/rail-probe') + // 1200 is the first pixel of M3's large class, where the rail starts expanded. The first-paint + // probe runs before the resize, so the harness's own window must be in the same class — it is: + // Playwright opens 1280 wide. + $page = shellPage(1200, 900, '/rail-probe') ->assertScript(railWidth(256)) ->assertScript("window.eval('window.firstPaint.width') === '256px'") ->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true'); @@ -100,10 +139,21 @@ it('expands the rail from lg, and keeps it collapsed across a reload from the fi ->assertScript("window.eval('window.firstPaint.rail') === 'collapsed'") ->assertScript("window.eval('window.firstPaint.width') === '96px'") ->assertScript(railWidth(96)); + + // Extra-large is the same band, and the collapse just chosen still holds there. + shellPage(1600, 900)->assertScript(railWidth(96)); }); -it('opens the modal rail from its menu button below lg, holding focus until Escape or the scrim', function () { - $page = shellPage(800); +it('expands the rail by default at extra-large', function () { + shellPage(1600, 900) + ->assertScript(railWidth(256)) + ->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'") + ->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true') + ->assertNoJavaScriptErrors(); +}); + +it('opens the modal rail from its menu button below expanded, holding focus until Escape or the scrim', function () { + $page = shellPage(839); // From the keyboard: WebKit neither focuses a button on click nor returns focus to one that never had it. $page->script("document.querySelector('[data-navigation-rail-menu]').focus()"); @@ -129,8 +179,8 @@ it('opens the modal rail from its menu button below lg, holding focus until Esca ->assertScript("localStorage.getItem('material-rail') === null"); }); -it('slides the modal rail in on a phone from the app bar\'s menu button', function () { - $page = shellPage(400, 860); +it('slides the modal rail in on a compact window from the app bar\'s menu button', function () { + $page = shellPage(599, 860); $page->click('@shell-menu') ->assertScript(RAIL.".hasAttribute('data-open')") @@ -144,7 +194,7 @@ it('slides the modal rail in on a phone from the app bar\'s menu button', functi }); it('keeps the rail and the theme through wire:navigate and moves aria-current', function () { - $page = shellPage(1280); + $page = shellPage(1200); $page->click('[data-navigation-rail-menu]') ->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'"); @@ -193,7 +243,7 @@ it('lifts the snackbar and the page above something docked on the phone\'s bar', $snackbarBottom = "Math.round(parseFloat(getComputedStyle(document.querySelector('[x-data=\"materialSnackbar\"]')).bottom))"; $contentPadding = "Math.round(parseFloat(getComputedStyle(document.getElementById('content')).paddingBottom))"; - $page = shellPage(400, 860); + $page = shellPage(599, 860); $snackbar = (int) $page->script($snackbarBottom); $content = (int) $page->script($contentPadding); diff --git a/tests/Feature/BreakpointsTest.php b/tests/Feature/BreakpointsTest.php new file mode 100644 index 00000000..61df1074 --- /dev/null +++ b/tests/Feature/BreakpointsTest.php @@ -0,0 +1,85 @@ + + */ +function breakpointSources(): array +{ + return collect(['views', 'css', 'js']) + ->flatMap(fn (string $directory): array => File::allFiles(__DIR__.'/../../resources/'.$directory)) + ->filter(fn (SplFileInfo $file): bool => in_array($file->getExtension(), ['php', 'css', 'js'], true)) + ->values() + ->all(); +} + +/** + * Every line of a file that matches, as `path:line: the line`. + * + * @return list + */ +function breakpointOffenders(SplFileInfo $file, string $pattern): array +{ + $lines = preg_split('/\R/', $file->getContents()); + + return collect($lines) + ->filter(fn (string $line): bool => preg_match($pattern, $line) === 1) + ->map(fn (string $line, int $index): string => $file->getRelativePathname().':'.($index + 1).': '.trim($line)) + ->values() + ->all(); +} + +it('writes no Tailwind breakpoint prefix in any view, stylesheet or script', function () { + // A prefix, not a name: `--radius-corner-sm:` and slider.js's `sm: { corner: 8 }` are excluded + // by the character before and the one after, and `@md:` is a container query, a different + // thing — a component sized by the room it has, not by the window. + $pattern = '/(?flatMap(fn (SplFileInfo $file): array => breakpointOffenders($file, $pattern)) + ->values(); + + expect($offenders->all())->toBe([]); +}); + +it('writes no media query at a Tailwind screen width', function () { + // 40rem/640px, 48rem/768px, 64rem/1024px, 80rem/1280px and the 39.99rem that stood for "below + // 640" — none of them is an M3 boundary. The M3 ones are 37.5, 52.5, 75 and 100rem, and a + // script asks resources/js/breakpoints.js for them instead of writing its own string. + $widths = '/(?flatMap(function (SplFileInfo $file) use ($widths): array { + // Only the condition of a query, so `max-width: min(40rem, 70dvh)` — a real width, not + // a breakpoint — and a 640px cap on a bottom sheet are left alone. + preg_match_all('/@(?:media|container)\b[^{]*|matchMedia\(\s*[\'"`][^\'"`]*/', $file->getContents(), $matches); + + return collect($matches[0]) + ->filter(fn (string $condition): bool => preg_match($widths, $condition) === 1) + ->map(fn (string $condition): string => $file->getRelativePathname().': '.trim($condition)) + ->values() + ->all(); + }) + ->values(); + + expect($offenders->all())->toBe([]); +}); + +it('scans the showcase example heredocs too, where the package teaches its own markup', function () { + $sections = collect(breakpointSources()) + ->filter(fn (SplFileInfo $file): bool => str_contains($file->getRelativePathname(), 'showcase'.DIRECTORY_SEPARATOR.'sections')) + ->filter(fn (SplFileInfo $file): bool => str_contains($file->getContents(), "<<<'BLADE'")); + + // The scan reads whole files, so nothing above can be hiding inside a heredoc; this only holds + // the premise up, in case the examples ever move out of them. + expect($sections)->not->toBeEmpty() + ->and($sections->filter(fn (SplFileInfo $file): bool => str_contains($file->getContents(), 'medium:'))->isNotEmpty())->toBeTrue(); +}); diff --git a/tests/Feature/Components/AppShellTest.php b/tests/Feature/Components/AppShellTest.php index b38a0f44..39758d7b 100644 --- a/tests/Feature/Components/AppShellTest.php +++ b/tests/Feature/Components/AppShellTest.php @@ -22,7 +22,7 @@ it('draws the adaptive rail, the bar, the content region and the snackbar host', ->toContain('data-navigation-rail="adaptive"') ->toContain('data-navigation-bar') ->toContain('
toContain('max-lg:overflow-x-clip') + ->toContain('max-expanded:overflow-x-clip') ->toContain('The page') ->toContain('x-data="materialSnackbar"') ->toMatch('/blade('', ['destinations' => shellDestinations()])) - ->toContain('max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]') + ->toContain('max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]') ->and((string) $this->blade('', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]])) ->not->toContain('data-app-shell-bar') ->not->toContain('--material-bottom-bar:'); diff --git a/tests/Feature/Components/ButtonTest.php b/tests/Feature/Components/ButtonTest.php index 15b7c304..4de5bb2b 100644 --- a/tests/Feature/Components/ButtonTest.php +++ b/tests/Feature/Components/ButtonTest.php @@ -129,9 +129,9 @@ it('shows the loading indicator while its own action runs', function () { ->assertSee('wire:target="upload"', false); }); -it('hides a responsive label below lg', function () { +it('hides a responsive label below expanded', function () { $this->blade('') - ->assertSee('New share', false); + ->assertSee('New share', false); }); it('anchors its tooltip to itself', function () { @@ -145,11 +145,11 @@ it('anchors its tooltip to itself', function () { ->not->toContain('aria-label="Saves the draft"'); }); -it('is a FAB below sm and a filled button above it, in one element', function () { +it('is a FAB on a compact window and a filled button from medium, in one element', function () { $html = (string) $this->blade(''); expect(substr_count($html, 'toBe(1) - ->and($html)->toContain('max-sm:fixed')->toContain('max-sm:bg-primary-container')->toContain('bg-primary text-on-primary'); + ->and($html)->toContain('max-medium:fixed')->toContain('max-medium:bg-primary-container')->toContain('bg-primary text-on-primary'); }); it('submits a form when asked', function () { diff --git a/tests/Feature/Components/DataTest.php b/tests/Feature/Components/DataTest.php index 00aeaae1..cca2a077 100644 --- a/tests/Feature/Components/DataTest.php +++ b/tests/Feature/Components/DataTest.php @@ -33,7 +33,7 @@ it('draws Laravel\'s paginators in M3', function () { ->toContain('data-pagination') ->toContain('Page 3 of 10') ->toContain('21–30 of 95') - ->toContain('3') + ->toContain('3') ->toContain('href="/shares?page=2" rel="prev"') ->toContain('aria-label="Go to page 4"') ->not->toContain('text-gray'); diff --git a/tests/Feature/Components/OverlayTest.php b/tests/Feature/Components/OverlayTest.php index 34211e00..6e20420b 100644 --- a/tests/Feature/Components/OverlayTest.php +++ b/tests/Feature/Components/OverlayTest.php @@ -40,7 +40,7 @@ it('uses the surrounding Alpine scope without wire:model, and stays open when pe ->toContain('x-data="{ close() { this.open = false } }"') ->toContain('x-on:cancel.prevent=""') ->not->toContain('x-on:click.self') - ->toContain('max-sm:h-dvh') + ->toContain('max-medium:h-dvh') ->toContain('aria-label="Close"'); }); @@ -48,10 +48,10 @@ it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the $html = (string) $this->blade('Text'); expect($html) - ->toMatch('/

/') - ->toContain('

Scan the code

') - ->not->toContain('
') - ->and((string) $this->blade('Text'))->toContain('
') + ->toMatch('/

/') + ->toContain('

Scan the code

') + ->not->toContain('
') + ->and((string) $this->blade('Text'))->toContain('
') ->and((string) $this->blade('Text'))->toContain('

Only a subtitle

'); }); @@ -64,18 +64,18 @@ it('leaves a pane open on Escape unless it is asked to close then too', function ->not->toContain('keydown.window.escape'); }); -it('slides a side sheet in from either edge, and is a pane from xl when asked', function () { +it('slides a side sheet in from either edge, and is a pane from expanded when asked', function () { expect((string) $this->blade('Body')) ->toContain('x-trap.inert.noscroll="open && ! wide"') - ->toContain('end-0 sm:rounded-s-corner-lg') + ->toContain('end-0 medium:rounded-s-corner-lg') ->toContain('bg-surface-container-low') ->toContain('role="dialog"') ->toContain('aria-label="Close"') - ->and((string) $this->blade('Body'))->toContain('start-0 sm:rounded-e-corner-lg') + ->and((string) $this->blade('Body'))->toContain('start-0 medium:rounded-e-corner-lg') ->and((string) $this->blade('Body')) ->toContain('data-pane') ->toContain('--pane-width: 28rem') - ->toContain("matchMedia('(min-width: 80rem)')"); + ->toContain("matchMedia('(width >= 52.5rem)')"); }); it('draws a modal bottom sheet with a drag handle, or a standard one without a scrim', function () { diff --git a/tests/Feature/Components/SelectionTest.php b/tests/Feature/Components/SelectionTest.php index a93c10eb..7e8b457e 100644 --- a/tests/Feature/Components/SelectionTest.php +++ b/tests/Feature/Components/SelectionTest.php @@ -59,7 +59,7 @@ it('names unbound radios after their group and checks the given value', function $html = (string) $this->blade(''); expect($html) - ->toContain('sm:flex') + ->toContain('medium:flex') ->and(substr_count($html, 'name="theme"'))->toBe(2) ->and($html)->toMatch('/value="dark"\s+checked/') ->not->toMatch('/value="light"\s+checked/'); diff --git a/tests/Feature/Components/TabsTest.php b/tests/Feature/Components/TabsTest.php index 9d1e3b97..960551b0 100644 --- a/tests/Feature/Components/TabsTest.php +++ b/tests/Feature/Components/TabsTest.php @@ -64,7 +64,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('sm:flex') + ->toContain('medium:flex') ->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, @@ -80,7 +80,7 @@ it('marks the section whose url is the request\'s, and wraps many sections onto $items = collect(range(1, 7))->map(fn (int $n): array => ['title' => "S{$n}", 'url' => $n === 3 ? url('/') : "/s/{$n}"])->all(); expect((string) $this->blade('', ['items' => $items])) - ->toContain('sm:grid sm:grid-cols-4 xl:flex') + ->toContain('medium:grid medium:grid-cols-4 large:flex') ->toMatch('/data-tab\s+aria-current="page"\s*>\s*\s*S3/') ->not->toContain('wire:navigate'); }); diff --git a/tests/Feature/Components/ThemeScriptTest.php b/tests/Feature/Components/ThemeScriptTest.php index a982fc31..fc4961e6 100644 --- a/tests/Feature/Components/ThemeScriptTest.php +++ b/tests/Feature/Components/ThemeScriptTest.php @@ -42,7 +42,7 @@ it('starts a collapsible rail as the application says, expanded otherwise', func it('puts its attributes back on when wire:navigate swaps the page', function () { $this->blade('') ->assertSee("document.addEventListener('livewire:navigating'", false) - ->assertSee("['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key']", false) + ->assertSee("['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-auto', 'data-rail-key']", false) ->assertSee('event.detail.onSwap(', false); });