Scan for Tailwind breakpoints, and drive the shell by class in the browser
Plan steps 15-17's testing. `tests/Feature/BreakpointsTest.php` reads every file under resources/views, resources/css and resources/js — showcase example heredocs included — and fails on any Tailwind breakpoint prefix and on any media query or `matchMedia` string at 40, 48, 64 or 80rem (or 640/768/1024/ 1280px, or the 39.99rem that stood for "below 640"). Both scans were checked against a deliberately reintroduced `sm:w-auto` and an 80rem query. Every render test that asserted a `sm:`/`lg:`/`xl:` class now asserts the M3 class, the drawer's pane query is 52.5rem, and the theme script keeps `data-rail-auto` through `wire:navigate`. NavigationTest drives the shell at 599/600/839/840/1199/1200/1600 with the expectation each class carries: the bar on a compact window, the 96px rail from the medium class's first pixel, a standard rail collapsed through expanded whose menu button widens it in place with no scrim, and the rail expanded to begin with from large and at extra-large. (Not run here — the Browser suite is out of scope for this stream.) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
afc8e199ae
commit
9fb3126038
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Breakpoints are M3's window size classes and only those: compact below `medium`, then medium 600,
|
||||
* expanded 840, large 1200 and extra-large 1600 (docs/reference/m3/foundations.md § Layout,
|
||||
* foundations-supplement.md § Breakpoints). Tailwind's own screens are cleared in
|
||||
* resources/css/tokens/theme.css — `--breakpoint-*: initial` — so an `sm:` left behind compiles to
|
||||
* nothing at all and the rule it carried silently disappears. These two scans are the guard: one
|
||||
* for the utilities, one for the media queries a stylesheet or a script writes by hand.
|
||||
*
|
||||
* @return list<SplFileInfo>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
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 = '/(?<![\w@-])(?:max-)?(?:sm|md|lg|xl|2xl):[a-z\[(-]/';
|
||||
|
||||
$offenders = collect(breakpointSources())
|
||||
->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 = '/(?<![\d.])(?:40|48|64|80|39\.99)rem|(?<![\d.])(?:640|768|1024|1280)px/';
|
||||
|
||||
$offenders = collect(breakpointSources())
|
||||
->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();
|
||||
});
|
||||
@@ -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('<main id="content" tabindex="-1" wire:transition.navigate')
|
||||
->toContain('max-lg:overflow-x-clip')
|
||||
->toContain('max-expanded:overflow-x-clip')
|
||||
->toContain('The page')
|
||||
->toContain('x-data="materialSnackbar"')
|
||||
->toMatch('/<a\s+href="#content"\s+data-skip-link/')
|
||||
@@ -91,7 +91,7 @@ it('keeps the destination at the page\'s URL current while a Livewire component
|
||||
|
||||
it('lifts the snackbar above the bar only when there is a bar', function () {
|
||||
expect((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['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('<x-app-shell :destinations="$destinations" />', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]]))
|
||||
->not->toContain('data-app-shell-bar')
|
||||
->not->toContain('--material-bottom-bar:');
|
||||
|
||||
@@ -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('<x-button label="New share" icon="add" responsive />')
|
||||
->assertSee('<span class="max-lg:hidden">New share</span>', false);
|
||||
->assertSee('<span class="max-expanded:hidden">New share</span>', 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('<x-button label="New share" icon="add" fab />');
|
||||
|
||||
expect(substr_count($html, '<button'))->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 () {
|
||||
|
||||
@@ -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('<span aria-current="page" class="grid size-10 place-items-center rounded-corner-full type-label-lg tabular-nums bg-secondary-container text-on-secondary-container max-sm:hidden">3</span>')
|
||||
->toContain('<span aria-current="page" class="grid size-10 place-items-center rounded-corner-full type-label-lg tabular-nums bg-secondary-container text-on-secondary-container max-medium:hidden">3</span>')
|
||||
->toContain('href="/shares?page=2" rel="prev"')
|
||||
->toContain('aria-label="Go to page 4"')
|
||||
->not->toContain('text-gray');
|
||||
|
||||
@@ -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('<x-modal title="Enable 2FA" subtitle="Scan the code" fullscreen>Text</x-modal>');
|
||||
|
||||
expect($html)
|
||||
->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-sm:hidden">/')
|
||||
->toContain('<p class="type-body-md text-on-surface-variant mt-4 max-sm:mt-0">Scan the code</p>')
|
||||
->not->toContain('<div class="mb-4 max-sm:hidden">')
|
||||
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('<div class="mb-4 max-sm:hidden">')
|
||||
->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-medium:hidden">/')
|
||||
->toContain('<p class="type-body-md text-on-surface-variant mt-4 max-medium:mt-0">Scan the code</p>')
|
||||
->not->toContain('<div class="mb-4 max-medium:hidden">')
|
||||
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('<div class="mb-4 max-medium:hidden">')
|
||||
->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))->toContain('<p class="type-body-md text-on-surface-variant">Only a subtitle</p>');
|
||||
});
|
||||
|
||||
@@ -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('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
|
||||
->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('<x-drawer side="start">Body</x-drawer>'))->toContain('start-0 sm:rounded-e-corner-lg')
|
||||
->and((string) $this->blade('<x-drawer side="start">Body</x-drawer>'))->toContain('start-0 medium:rounded-e-corner-lg')
|
||||
->and((string) $this->blade('<x-drawer pane pane-width="28rem">Body</x-drawer>'))
|
||||
->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 () {
|
||||
|
||||
@@ -59,7 +59,7 @@ it('names unbound radios after their group and checks the given value', function
|
||||
$html = (string) $this->blade('<x-radio name="theme" value="dark" inline :options="[[\'id\' => \'light\', \'name\' => \'Light\'], [\'id\' => \'dark\', \'name\' => \'Dark\']]" />');
|
||||
|
||||
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/');
|
||||
|
||||
@@ -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('<x-section-nav :items="$items" no-wire-navigate />', ['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*<span data-tab-content>\s*<span class="truncate">S3/')
|
||||
->not->toContain('wire:navigate');
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ it('starts a collapsible rail as the application says, expanded otherwise', func
|
||||
it('puts its attributes back on <html> when wire:navigate swaps the page', function () {
|
||||
$this->blade('<x-theme-script />')
|
||||
->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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user