From ed93222d2220f96b3cb624b8681b6cbdc200f478 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Tue, 15 Sep 2026 00:37:22 +0200 Subject: [PATCH] Draw the scaffold without Tailwind Plan step 36 (navigation group, third batch): '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 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 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- UPGRADE.md | 4 +- resources/css/components/navigation-bar.css | 30 +-- resources/css/layout/scaffold.css | 217 ++++++++++++++++-- resources/css/tailwind.css | 47 ---- resources/views/components/scaffold.blade.php | 49 ++-- tests/Browser/BarsTest.php | 4 +- tests/Browser/LayoutTest.php | 52 ++++- tests/Browser/NavigationTest.php | 12 +- .../Feature/Components/NavigationBarTest.php | 41 +--- .../Components/NavigationStylesheetsTest.php | 11 +- tests/Feature/Components/ScaffoldTest.php | 128 +++++++++-- 11 files changed, 410 insertions(+), 185 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index b78aeea2..3009aad9 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -106,7 +106,9 @@ over a colour, use the role directly. - `` is renamed ``, with no alias; it is a column with a nested row and gains `banner` and `fab` slots. ``'s `header` slot takes one `` that morphs (replace the two-FAB `rail-collapsed:` swap); new `divider` - and `fill` props. + and `fill` props. `data-app-shell`, `data-app-shell-bar`, `data-app-shell-actions` and + `data-app-shell-banner` are `data-md-scaffold`, `data-md-scaffold-bar`, `data-md-scaffold-actions` + and `data-md-scaffold-banner`; the skip link is `data-md-skip-link`. - `` selects the optical-size-20 cut; the components pass it for their own small icons, applications pass it for icons drawn at 20px or less. - A text field stops at 40rem wide from `medium:`, as M3 bounds fields on wider windows; a diff --git a/resources/css/components/navigation-bar.css b/resources/css/components/navigation-bar.css index cc92d480..83282b15 100644 --- a/resources/css/components/navigation-bar.css +++ b/resources/css/components/navigation-bar.css @@ -73,19 +73,21 @@ [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-items]:has(> :last-child:nth-child(5)) { --navigation-bar-count: 5; } [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-items]:has(> :last-child:nth-child(6)) { --navigation-bar-count: 6; } } -} -/* - * A bar that has slid off the bottom of the window is no longer there to clear, so the offset - * everything pinned to the bottom reads drops to the bottom safe area and whatever the application - * has docked on the bar — a `fab` button, the snackbar and the page's own bottom padding all follow - * it down and come back up with it. Unlayered on purpose, still: (its own rewrite is a - * later batch) publishes --material-bottom-bar with a Tailwind utility - * (`max-medium:[--material-bottom-bar:…]`), and a rule in any layer loses to an unlayered utility - * regardless of specificity — unlike toolbar.css's and fab.css's overrides, which only have to beat - * another `material.components` rule. Keyed on `data-md-scaffold`, the hook the scaffold already - * renders, not `data-app-shell`, which belongs to that later rewrite. - */ -[data-md-scaffold]:has([data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden]) { - --material-bottom-bar: calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px)); + /* + * A bar that has slid off the bottom of the window is no longer there to clear, so the offset + * everything pinned to the bottom reads drops to the bottom safe area and whatever the + * application has docked on the bar — a `fab` button, the snackbar and the page's own bottom + * padding all follow it down and come back up with it. Layered now, unlike before this + * rewrite: used to publish --material-bottom-bar with a Tailwind utility, which + * no rule in any layer could outrank regardless of specificity; now scaffold.css publishes it + * itself in `material.layout` (layout/scaffold.css), a layer this file's own + * `material.components` always outranks by declaration order alone — the same reason + * toolbar.css's and fab.css's overrides only have to beat another `material.components` rule, + * not chase a Tailwind utility out of the cascade. Keyed on `data-md-scaffold`, the hook the + * scaffold renders on its root, not `data-app-shell`, which that rewrite also retired. + */ + [data-md-scaffold]:has([data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden]) { + --material-bottom-bar: calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px)); + } } diff --git a/resources/css/layout/scaffold.css b/resources/css/layout/scaffold.css index e228b250..320e41c5 100644 --- a/resources/css/layout/scaffold.css +++ b/resources/css/layout/scaffold.css @@ -1,37 +1,214 @@ /* - * : the FAB it places, and the margin its content region already draws. + * : M3's scaffold — bars, then rails, then panes (docs/reference/m3/foundations.md + * § Layout → Scaffold) — as a column: an optional `banner` full width, a row below it holding the + * adaptive navigation rail and the content region, the bottom navigation bar on a compact window, + * and the snackbar host. Plan step 36 (navigation group) moves in everything the view drew with + * Tailwind; `data-md-scaffold-fab` and the content region's `--md-layout-margin` are step 35's, + * unchanged below. * - * The rest of the scaffold — the rail, the navigation bar, the content region — is still drawn by - * its Tailwind classes until its rewrite (plan step 36); this file holds only what step 35 added. + * `--material-margin` is M3's window margin, read by a pane and the canonical layouts wherever + * `--md-layout-margin` has not already zeroed it: 16px below `medium`, 24px from it + * (docs/reference/m3/foundations-supplement.md § Breakpoints), the same two spacing tokens + * layout/pane.css reads for its own margin. * - * `data-md-scaffold-fab` places the page's FAB as Compose's Scaffold places its floating action - * button: fixed at the bottom-end corner (M3's adaptive placement for a FAB, "lower right (bottom - * trailing) corner", docs/reference/m3/components-actions-communication-containment.md § FAB → - * Behaviour), with M3's margin from the window's edges — 16px below medium and 24px from 600px - * (docs/reference/m3/foundations-supplement.md § Breakpoints). `inset-inline-end` is the trailing - * edge, so a right-to-left document puts it on the left, clear of the safe area on that side. + * `[data-md-skip-link]` hides the way `md-visually-hidden` does at rest (text.css) — duplicated + * here rather than shared, because a class cannot be un-applied on `:focus` — and reveals itself + * fixed at the window's leading corner, clear of a safe area an application sets, in M3's pill + * shape and label-large type, with the elevation-3 shadow every temporary surface above the page + * takes. * - * From the bottom it clears, in order: the navigation bar, or the bottom safe area where there is - * none — `max()`, since `--material-bottom-bar` already includes the safe area (the navigation bar - * publishes it on the scaffold below medium); then a snackbar while one shows. sits 16px - * above the bar and publishes its height as `--material-snackbar-height` while it is on screen, so - * the FAB adds that height and the snackbar's own 16px (`min()` of the height and 16px is 16px when - * a snackbar shows and 0px when none does) — M3: "snackbars should appear above FABs — never in - * front of or behind one" (§ Snackbar → Placement). It moves there on the spatial spring, which - * reduced motion makes instant. z-index 30 is the navigation bar's own layer: over the page, under - * a scrim and the snackbar. + * `[data-md-scaffold-row]` is flex from `medium` only (the width the rail joins the layout at + * instead of sliding over it as a modal) but always takes the remaining height, whichever it is; + * `[data-md-scaffold-content]` — the rail's sibling — is always a flex column, so the page's own + * bar, the FAB wrapper and `
` stack regardless of the row's own display. * - * `--md-layout-margin: 0px` on the content region tells a pane or canonical layout inside that the - * region already keeps the window margin (layout/pane.css), so neither draws it a second time. + * `[data-md-scaffold-actions]`'s column layout is `rail-collapsed:flex-col`, the one Tailwind + * variant scaffold.blade.php still used (`resources/css/tailwind.css`, removed with this rule): + * written out branch for branch as the navigation rail's own rewrite did for its internal parts + * (`resources/css/components/navigation-rail.css`) — the three width-independent conditions in one + * `:where()` group, the four width-gated ones each in their own `@media` block, since CSS cannot + * merge different queries. Only the "descendant of a collapsed rail" half of each pair applies: + * the actions row can never be the rail element itself. + * + * `[data-md-scaffold-bar]` pins the navigation bar to the window's bottom edge below `medium`, at + * the navigation bar's own stacking layer (z-index 30: over the page, under a scrim or the + * snackbar — navigation-bar.css and toast.css agree on the number). It also carries + * `--material-bottom-bar`, which everything the bar would otherwise cover reads: the FAB + * (`[data-md-scaffold-fab]`, below), the snackbar host (toast.css) and the content region's own + * bottom padding, all through `max()`/`var()` with a `0px` fallback, so nothing below `medium`, or + * once the bar itself is absent, reads a stale height. It stayed a Tailwind utility + * (`max-medium:[--material-bottom-bar:…]`, unlayered so it could win over `navigation-bar.css`'s + * own override) until this rewrite; now both live in `material.components` + * (`navigation-bar.css`'s own header explains why that layer, not this one, is enough) and this + * declaration only has to beat `main`'s own `0px` fallback inside the very same layer, which normal + * cascade order already does. 64px and 80px are `NavigationBarTokens.ContainerHeight` and + * `TallContainerHeight`, the bar's own dimensions, not the spacing scale — the number + * navigation-bar.css itself draws the bar at, kept literal here for the same reason. + * + * Imports the stylesheets of every component this view renders: the adaptive rail and its item and + * section, the navigation bar and its item, and the snackbar host. * * In `material.layout`. */ @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; +@import './visibility.css'; +@import '../components/navigation-rail.css'; +@import '../components/navigation-rail-item.css'; +@import '../components/navigation-rail-section.css'; +@import '../components/navigation-bar.css'; +@import '../components/navigation-bar-item.css'; +@import '../components/toast.css'; + @layer material.layout { + [data-md-scaffold] { + --material-margin: var(--md-sys-measurement-space200); + + display: flex; + min-block-size: 100dvh; + flex-direction: column; + background-color: var(--md-sys-color-surface); + color: var(--md-sys-color-on-surface); + + @media (width >= 600px) { + --material-margin: var(--md-sys-measurement-space300); + } + } + + [data-md-skip-link] { + position: absolute; + inline-size: 1px; + block-size: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; + + &:focus { + position: fixed; + z-index: 60; + inset-inline-start: var(--md-sys-measurement-space200); + top: calc(var(--material-safe-top, env(safe-area-inset-top)) + var(--md-sys-measurement-space200)); + inline-size: auto; + block-size: auto; + padding: var(--md-sys-measurement-space100) var(--md-sys-measurement-space200); + margin: 0; + overflow: visible; + clip-path: none; + white-space: normal; + border-radius: var(--md-sys-shape-corner-full); + background-color: var(--md-sys-color-inverse-surface); + color: var(--md-sys-color-inverse-on-surface); + font: var(--md-sys-typescale-label-lg); + letter-spacing: var(--md-sys-typescale-label-lg-tracking); + font-variation-settings: normal; + box-shadow: var(--md-sys-elevation-3); + outline: none; + } + } + + [data-md-scaffold-banner] { + flex-shrink: 0; + } + + [data-md-scaffold-row] { + flex: 1 1 0%; + + @media (width >= 600px) { + display: flex; + } + } + + [data-md-scaffold-content] { + display: flex; + min-inline-size: 0; + flex: 1 1 0%; + flex-direction: column; + } + + [data-md-scaffold-actions] { + display: flex; + align-items: center; + gap: var(--md-sys-measurement-space50); + padding-block-start: var(--md-sys-measurement-space100); + padding-inline: 20px; + + &:where( + [data-md-navigation-rail='collapsed'] *, + [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, + [data-md-navigation-rail='modal']:not([data-md-open]) * + ) { + flex-direction: column; + } + + @media (width < 600px) { + &:where([data-md-navigation-rail='collapsible']:not([data-md-open]) *) { + flex-direction: column; + } + } + + @media (width < 840px) { + &:where([data-md-navigation-rail='adaptive']:not([data-md-open]) *) { + flex-direction: column; + } + } + + @media (width >= 840px) { + @media (width < 1200px) { + &:where(:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *) { + flex-direction: column; + } + } + } + + @media (width >= 1200px) { + &:where([data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *) { + flex-direction: column; + } + } + } + [data-md-scaffold] main { --md-layout-margin: 0px; + + min-inline-size: 0; + flex: 1 1 0%; + padding-inline: var(--material-margin); + outline: none; + + @media (width < 840px) { + overflow-x: clip; + } + + @media (width < 600px) { + padding-block-end: var(--material-bottom-bar); + } + } + + [data-md-scaffold-bar] { + position: fixed; + z-index: 30; + inset-inline: 0; + inset-block-end: 0; + + @media (width >= 600px) { + display: none; + } + } + + [data-md-scaffold]:has(> [data-md-scaffold-bar] [data-md-navigation-bar]:not([data-md-tall])) { + @media (width < 600px) { + --material-bottom-bar: calc(64px + var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px)); + } + } + + [data-md-scaffold]:has(> [data-md-scaffold-bar] [data-md-navigation-bar][data-md-tall]) { + @media (width < 600px) { + --material-bottom-bar: calc(80px + var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px)); + } } [data-md-scaffold-fab] { diff --git a/resources/css/tailwind.css b/resources/css/tailwind.css index 5052a092..8d88dea8 100644 --- a/resources/css/tailwind.css +++ b/resources/css/tailwind.css @@ -16,50 +16,3 @@ @import './tokens/theme.css'; @import './tokens/utilities.css'; - -/* - * `rail-collapsed:` — Tailwind's, for the one class left that needs it: - * scaffold.blade.php's `data-app-shell-actions` row (`rail-collapsed:flex-col`), stacking the - * footer's icon buttons once the rail they sit in narrows. The definition used to live in - * navigation.css; that file is gone (plan step 36, navigation group), so this is what is left of - * it — updated to the `data-md-*` hooks navigation-rail.css now renders, otherwise identical to - * every branch that file's own header still documents. Goes with the scaffold's own rewrite, - * whenever `data-app-shell-actions` stops being a Tailwind class list. - */ -@custom-variant rail-collapsed { - &:where([data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *) { - @slot; - } - - &:where([data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *) { - @slot; - } - - &:where([data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) *) { - @slot; - } - - @media (width < 600px) { - &:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) { - @slot; - } - } - - @media (width < 840px) { - &:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) { - @slot; - } - } - - @media (840px <= width < 1200px) { - &:where(:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]), :is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *) { - @slot; - } - } - - @media (width >= 1200px) { - &:where([data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *) { - @slot; - } - } -} diff --git a/resources/views/components/scaffold.blade.php b/resources/views/components/scaffold.blade.php index 4c1a12ec..b91574a2 100644 --- a/resources/views/components/scaffold.blade.php +++ b/resources/views/components/scaffold.blade.php @@ -7,7 +7,7 @@ ['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')], ['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false], ]"> - SealShare + SealShare …the page's app bar… @@ -20,7 +20,7 @@ - **Compact**, below `medium` (600px): a navigation bar with the destinations marked `bar`, pinned to the bottom. Everything else is in the modal rail, which slides in when something calls `$store.rail.show()` — put a menu button in the app bar for it, hidden from `medium`: - ``. + ``. - **Medium** (600–839): the collapsed rail in the layout, 96px, and no bar; its menu button opens it expanded over a scrim, since 256px beside the page would leave the page too little. - **Expanded** (840–1199): a standard rail — in the layout, nothing covered — collapsed until @@ -35,7 +35,7 @@ `--material-margin` is M3's window margin — 16px on a compact window, 24px from `medium` — and the content region is padded with it, so a page inside the scaffold writes no gutters of its own. - Something meant to reach the window's edges opts out with `-mx-(--material-margin)`. + Something meant to reach the window's edges opts out with `margin-inline: calc(var(--material-margin) * -1)`. Panes are ``, and two side by side are M3's canonical layouts from `expanded`, `` and ``; inside the content region they draw no margin of @@ -54,8 +54,11 @@ while it is expanded), `rail-header` (under it: one ``, which the rail morphs between a FAB and an extended FAB as it opens), `rail-footer` (at the foot of the rail: footer destinations, an account), `actions` - (a row of icon buttons at the very foot, stacked when the rail is collapsed: a theme toggle, - sign out), `top` (the page's own bar, above the page and *beside* the rail), `fab` (the + (a row of icon buttons at the very foot, stacked in a column once the rail collapses to its + narrow width — the same selectors `navigation-rail.css` matches "collapsed" with, since + `resources/css/layout/scaffold.css` keys off the rail's own hooks rather than a breakpoint of + its own: a theme toggle, sign out), `top` (the page's own bar, above the page and *beside* the + rail), `fab` (the page's floating action button, see below) and the page itself. The rail is one element at every width, so what is in it is also in the modal rail a phone opens. `label` names both navigation landmarks ("Main"); `rail-width` is the expanded rail's width; `tall-bar` picks M3's 80px navigation bar over the 64px one, and the bottom @@ -92,10 +95,10 @@ before the page in focus order, where M3 puts a FAB (§ FAB → Accessibility: "the FAB should be prioritized in the page's focus order"). - `max-expanded:overflow-x-clip` on the content region is the backstop under every page, and it stays - `clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll - container and break every `position: sticky` inside it (an app bar, a list-detail pane). Below - `expanded` only, so a wide window never clips what overhangs on purpose. + `overflow-x: clip` on the content region, below `expanded` only, is the backstop under every + page: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll + container and break every `position: sticky` inside it (an app bar, a list-detail pane). A wide + window never clips what overhangs on purpose. Nothing application-specific belongs in here: an app's destinations and chrome come in through the props and slots. --}} @@ -134,26 +137,14 @@ $groups = $items->chunkWhile(fn (array $item, int $key, $chunk): bool => $item['section'] === $chunk->last()['section']); @endphp -
$barItems->isNotEmpty() && ! $tallBar, - 'max-medium:[--material-bottom-bar:calc(5rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty() && $tallBar, - ]) -> - {{ __('Skip to content') }} +
+ {{ __('Skip to content') }} @isset($banner) -
{{ $banner }}
+
{{ $banner }}
@endisset -
+
@isset($brand) {{ $brand }} @@ -182,7 +173,7 @@ {{ $railFooter ?? '' }} @isset($actions) -
+
{{ $actions }}
@endisset @@ -190,21 +181,21 @@ @endif -
+
{{ $top ?? '' }} @isset($fab)
{{ $fab }}
@endisset -
+
{{ $slot }}
@if ($barItems->isNotEmpty()) -
+
@foreach ($barItems as $item) diff --git a/tests/Browser/BarsTest.php b/tests/Browser/BarsTest.php index 4b768af0..bb17d193 100644 --- a/tests/Browser/BarsTest.php +++ b/tests/Browser/BarsTest.php @@ -281,9 +281,9 @@ function toolbarScaffoldProbe() it('keeps a docked toolbar clear of the navigation bar below medium', function () { toolbarScaffoldProbe()->resize(599, 800) - ->assertScript("getComputedStyle(document.querySelector('[data-app-shell-bar]')).display !== 'none'") + ->assertScript("getComputedStyle(document.querySelector('[data-md-scaffold-bar]')).display !== 'none'") // A docked toolbar at place="bottom" sits above --material-bottom-bar, never on top of it. - ->assertScript("document.querySelector('#toolbar').getBoundingClientRect().bottom <= document.querySelector('[data-app-shell-bar]').getBoundingClientRect().top") + ->assertScript("document.querySelector('#toolbar').getBoundingClientRect().bottom <= document.querySelector('[data-md-scaffold-bar]').getBoundingClientRect().top") ->assertNoJavaScriptErrors(); }); diff --git a/tests/Browser/LayoutTest.php b/tests/Browser/LayoutTest.php index af83448e..0471fabf 100644 --- a/tests/Browser/LayoutTest.php +++ b/tests/Browser/LayoutTest.php @@ -408,7 +408,7 @@ it('lays a feed out in one column below medium and in columns of its minimum wid ->assertScript(layoutRect('#item-5', 'left').' === '.layoutRect('#item-1', 'left')); }); -function scaffoldFabPage(int $width): mixed +function scaffoldFabPage(int $width, string $dir = 'ltr'): mixed { $body = <<<'BLADE' 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'active' => true], + ['title' => 'Sent', 'icon' => 'send', 'url' => '/sent'], + ]"> + +

The page

+
+ BLADE; + + $page = layoutPage($body, 599, 800); + $shown = (int) $page->script(layoutRect(SCAFFOLD_FAB, 'bottom')); + + $page->script('window.scrollTo(0, 400)') + ->assertScript("document.querySelector('[data-md-navigation-bar]').hasAttribute('data-md-hidden')") + // The bar's own 64px is what --material-bottom-bar drops once the bar has slid off, so the + // FAB moves down by exactly that much (navigation-bar.css, layout/scaffold.css). + ->assertScript(layoutRect(SCAFFOLD_FAB, 'bottom')." === {$shown} + 64") + ->assertNoJavaScriptErrors(); +}); + +it('places the FAB at the trailing edge, the left in a right-to-left document', function () { + scaffoldFabPage(599, 'rtl') + ->assertScript(layoutRect(SCAFFOLD_FAB, 'left').' === 16') + ->assertScript(layoutRect(SCAFFOLD_FAB, 'right').' < window.innerWidth - 16') + ->assertNoJavaScriptErrors(); +}); + +it('clears a safe area an application sets on the FAB\'s inline-end and bottom edges', function () { + $body = <<<'BLADE' + + + + BLADE; + + $page = layoutPage($body, 599, 800); + $right = (int) $page->script('window.innerWidth - '.layoutRect(SCAFFOLD_FAB, 'right')); + $bottom = (int) $page->script('window.innerHeight - '.layoutRect(SCAFFOLD_FAB, 'bottom')); + + $page->script("document.documentElement.style.setProperty('--material-safe-right', '20px')") + ->assertScript('window.innerWidth - '.layoutRect(SCAFFOLD_FAB, 'right')." === {$right} + 20"); + + $page->script("document.documentElement.style.setProperty('--material-safe-bottom', '30px')") + ->assertScript('window.innerHeight - '.layoutRect(SCAFFOLD_FAB, 'bottom')." === {$bottom} + 30") + ->assertNoJavaScriptErrors(); +}); + it('draws no second margin for a pane or a canonical layout inside the scaffold\'s content region', function () { $body = <<<'BLADE' diff --git a/tests/Browser/NavigationTest.php b/tests/Browser/NavigationTest.php index 6e93750b..0684af4e 100644 --- a/tests/Browser/NavigationTest.php +++ b/tests/Browser/NavigationTest.php @@ -5,7 +5,7 @@ use Illuminate\Support\Facades\Route; const RAIL = "document.querySelector('[data-md-navigation-rail]')"; const RAIL_PANEL = "document.querySelector('[data-md-navigation-rail-panel]')"; -const BOTTOM_BAR = "document.querySelector('[data-app-shell-bar]')"; +const BOTTOM_BAR = "document.querySelector('[data-md-scaffold-bar]')"; function navigationReady(mixed $page): mixed { @@ -219,14 +219,14 @@ it('keeps the rail and the theme through wire:navigate and moves aria-current', it('skips to the content', function () { $page = shellPage(); - $page->assertScript("document.querySelector('a[href], button, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])').matches('[data-skip-link]')"); + $page->assertScript("document.querySelector('a[href], button, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])').matches('[data-md-skip-link]')"); // Safari and WebKit leave links out of the Tab order, so focus it directly. - $page->script("document.querySelector('[data-skip-link]').focus()"); - $page->assertScript("getComputedStyle(document.querySelector('[data-skip-link]')).position === 'fixed'") - ->assertVisible('[data-skip-link]'); + $page->script("document.querySelector('[data-md-skip-link]').focus()"); + $page->assertScript("getComputedStyle(document.querySelector('[data-md-skip-link]')).position === 'fixed'") + ->assertVisible('[data-md-skip-link]'); - $page->keys('[data-skip-link]', 'Enter') + $page->keys('[data-md-skip-link]', 'Enter') ->assertScript("location.hash === '#content'") ->assertScript("document.activeElement === document.getElementById('content')"); }); diff --git a/tests/Feature/Components/NavigationBarTest.php b/tests/Feature/Components/NavigationBarTest.php index 2683b297..aa2d1d75 100644 --- a/tests/Feature/Components/NavigationBarTest.php +++ b/tests/Feature/Components/NavigationBarTest.php @@ -1,9 +1,6 @@ blade('')) @@ -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 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 : 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';"); -}); diff --git a/tests/Feature/Components/NavigationStylesheetsTest.php b/tests/Feature/Components/NavigationStylesheetsTest.php index fdf95497..e66d2e4e 100644 --- a/tests/Feature/Components/NavigationStylesheetsTest.php +++ b/tests/Feature/Components/NavigationStylesheetsTest.php @@ -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. `` and `` 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 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 `` + * 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', diff --git a/tests/Feature/Components/ScaffoldTest.php b/tests/Feature/Components/ScaffoldTest.php index c08bdb4d..11073fbf 100644 --- a/tests/Feature/Components/ScaffoldTest.php +++ b/tests/Feature/Components/ScaffoldTest.php @@ -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('The page', ['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('
toContain('max-expanded:overflow-x-clip') ->toContain('The page') ->toContain('x-data="materialSnackbar"') - ->toMatch('/and(strpos($html, 'data-skip-link'))->toBeLessThan(strpos($html, 'data-md-navigation-rail=')); + ->toMatch('/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('', ['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, 'toBe(4) ->and(substr_count($bar, '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('', ['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('', ['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('', ['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('', ['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('', ['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('/(?not->toMatch('/(?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' + + ACTIONS + + BLADE, ['destinations' => scaffoldDestinations()]); + + expect($html)->toMatch('/data-md-scaffold-actions>\s*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.*toMatch('/data-md-navigation-rail-header.*BRAND.*FAB.*data-md-navigation-rail-footer.*FOOTER.*data-md-scaffold-actions.*ACTIONS.*APP BAR.*toMatch('/data-app-shell-banner.*WINDOW BAR.*data-md-navigation-rail=.*
toMatch('/data-md-scaffold-banner.*WINDOW BAR.*data-md-navigation-rail=.*
and((string) $this->blade('', ['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*toMatch('/^\s*/') + ->not->toContain('data-app-shell') ->toMatch('/APP BAR.*