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:
Andreas Reinhold / reini
2026-09-15 00:37:22 +02:00
co-authored by Claude Sonnet 5
parent 20b062846d
commit ed93222d22
11 changed files with 410 additions and 185 deletions
+3 -1
View File
@@ -106,7 +106,9 @@ over a colour, use the role directly.
- `<x-app-shell>` is renamed `<x-scaffold>`, with no alias; it is a column with a nested row - `<x-app-shell>` is renamed `<x-scaffold>`, with no alias; it is a column with a nested row
and gains `banner` and `fab` slots. `<x-navigation-rail>`'s `header` slot takes one and gains `banner` and `fab` slots. `<x-navigation-rail>`'s `header` slot takes one
`<x-fab label icon>` that morphs (replace the two-FAB `rail-collapsed:` swap); new `divider` `<x-fab label icon>` 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`.
- `<x-icon optical="20">` selects the optical-size-20 cut; the components pass it for their own - `<x-icon optical="20">` 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. 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 - A text field stops at 40rem wide from `medium:`, as M3 bounds fields on wider windows; a
+16 -14
View File
@@ -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(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; } [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 * 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 * everything pinned to the bottom reads drops to the bottom safe area and whatever the
* has docked on the bar — a `fab` button, the snackbar and the page's own bottom padding all follow * application has docked on the bar — a `fab` button, the snackbar and the page's own bottom
* it down and come back up with it. Unlayered on purpose, still: <x-scaffold> (its own rewrite is a * padding all follow it down and come back up with it. Layered now, unlike before this
* later batch) publishes --material-bottom-bar with a Tailwind utility * rewrite: <x-scaffold> used to publish --material-bottom-bar with a Tailwind utility, which
* (`max-medium:[--material-bottom-bar:…]`), and a rule in any layer loses to an unlayered utility * no rule in any layer could outrank regardless of specificity; now scaffold.css publishes it
* regardless of specificity — unlike toolbar.css's and fab.css's overrides, which only have to beat * itself in `material.layout` (layout/scaffold.css), a layer this file's own
* another `material.components` rule. Keyed on `data-md-scaffold`, the hook the scaffold already * `material.components` always outranks by declaration order alone — the same reason
* renders, not `data-app-shell`, which belongs to that later rewrite. * 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
[data-md-scaffold]:has([data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden]) { * scaffold renders on its root, not `data-app-shell`, which that rewrite also retired.
--material-bottom-bar: calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px)); */
[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));
}
} }
+197 -20
View File
@@ -1,37 +1,214 @@
/* /*
* <x-scaffold>: the FAB it places, and the margin its content region already draws. * <x-scaffold>: 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 * `--material-margin` is M3's window margin, read by a pane and the canonical layouts wherever
* its Tailwind classes until its rewrite (plan step 36); this file holds only what step 35 added. * `--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 * `[data-md-skip-link]` hides the way `md-visually-hidden` does at rest (text.css) — duplicated
* button: fixed at the bottom-end corner (M3's adaptive placement for a FAB, "lower right (bottom * here rather than shared, because a class cannot be un-applied on `:focus` — and reveals itself
* trailing) corner", docs/reference/m3/components-actions-communication-containment.md § FAB → * fixed at the window's leading corner, clear of a safe area an application sets, in M3's pill
* Behaviour), with M3's margin from the window's edges — 16px below medium and 24px from 600px * shape and label-large type, with the elevation-3 shadow every temporary surface above the page
* (docs/reference/m3/foundations-supplement.md § Breakpoints). `inset-inline-end` is the trailing * takes.
* edge, so a right-to-left document puts it on the left, clear of the safe area on that side.
* *
* From the bottom it clears, in order: the navigation bar, or the bottom safe area where there is * `[data-md-scaffold-row]` is flex from `medium` only (the width the rail joins the layout at
* none — `max()`, since `--material-bottom-bar` already includes the safe area (the navigation bar * instead of sliding over it as a modal) but always takes the remaining height, whichever it is;
* publishes it on the scaffold below medium); then a snackbar while one shows. <x-toast> sits 16px * `[data-md-scaffold-content]` — the rail's sibling — is always a flex column, so the page's own
* above the bar and publishes its height as `--material-snackbar-height` while it is on screen, so * bar, the FAB wrapper and `<main>` stack regardless of the row's own display.
* 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.
* *
* `--md-layout-margin: 0px` on the content region tells a pane or canonical layout inside that the * `[data-md-scaffold-actions]`'s column layout is `rail-collapsed:flex-col`, the one Tailwind
* region already keeps the window margin (layout/pane.css), so neither draws it a second time. * 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`. * In `material.layout`.
*/ */
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; @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 { @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 { [data-md-scaffold] main {
--md-layout-margin: 0px; --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] { [data-md-scaffold-fab] {
-47
View File
@@ -16,50 +16,3 @@
@import './tokens/theme.css'; @import './tokens/theme.css';
@import './tokens/utilities.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;
}
}
}
+20 -29
View File
@@ -7,7 +7,7 @@
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')], ['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false], ['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
]"> ]">
<x-slot:brand><a href="/" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand> <x-slot:brand><a href="/" wire:navigate class="md-type-title-lg">SealShare</a></x-slot:brand>
<x-slot:top>…the page's app bar…</x-slot:top> <x-slot:top>…the page's app bar…</x-slot:top>
<x-slot:fab><x-fab icon="add" tooltip="New share" /></x-slot:fab> <x-slot:fab><x-fab icon="add" tooltip="New share" /></x-slot:fab>
@@ -20,7 +20,7 @@
- **Compact**, below `medium` (600px): a navigation bar with the destinations marked `bar`, - **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 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`: calls `$store.rail.show()` — put a menu button in the app bar for it, hidden from `medium`:
`<span class="medium:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>`. `<x-stack as="span" hide-from="medium"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></x-stack>`.
- **Medium** (600839): the collapsed rail in the layout, 96px, and no bar; its menu button - **Medium** (600839): 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. opens it expanded over a scrim, since 256px beside the page would leave the page too little.
- **Expanded** (8401199): a standard rail — in the layout, nothing covered — collapsed until - **Expanded** (8401199): 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 `--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. 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 `<x-pane>`, and two side by side are M3's canonical layouts from `expanded`, Panes are `<x-pane>`, and two side by side are M3's canonical layouts from `expanded`,
`<x-list-detail>` and `<x-supporting-pane>`; inside the content region they draw no margin of `<x-list-detail>` and `<x-supporting-pane>`; inside the content region they draw no margin of
@@ -54,8 +54,11 @@
while it is expanded), `rail-header` (under it: one `<x-fab label="…" icon="…">`, which the while it is expanded), `rail-header` (under it: one `<x-fab label="…" icon="…">`, which the
rail morphs between a FAB and an extended FAB as it opens), `rail-footer` (at the foot of 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` rail: footer destinations, an account), `actions`
(a row of icon buttons at the very foot, stacked when the rail is collapsed: a theme toggle, (a row of icon buttons at the very foot, stacked in a column once the rail collapses to its
sign out), `top` (the page's own bar, above the page and *beside* the rail), `fab` (the 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 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 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 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 before the page in focus order, where M3 puts a FAB (§ FAB Accessibility: "the FAB should be
prioritized in the page's focus order"). 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 `overflow-x: clip` on the content region, below `expanded` only, is the backstop under every
`clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll 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). Below container and break every `position: sticky` inside it (an app bar, a list-detail pane). A wide
`expanded` only, so a wide window never clips what overhangs on purpose. window never clips what overhangs on purpose.
Nothing application-specific belongs in here: an app's destinations and chrome come in through Nothing application-specific belongs in here: an app's destinations and chrome come in through
the props and slots. --}} the props and slots. --}}
@@ -134,26 +137,14 @@
$groups = $items->chunkWhile(fn (array $item, int $key, $chunk): bool => $item['section'] === $chunk->last()['section']); $groups = $items->chunkWhile(fn (array $item, int $key, $chunk): bool => $item['section'] === $chunk->last()['section']);
@endphp @endphp
<div <div data-md-scaffold>
data-app-shell <a href="#content" data-md-skip-link>{{ __('Skip to content') }}</a>
data-md-scaffold
@class([
'flex min-h-dvh flex-col bg-surface text-on-surface [--material-margin:1rem] medium:[--material-margin:1.5rem]',
'max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $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,
])
>
<a
href="#content"
data-skip-link
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
>{{ __('Skip to content') }}</a>
@isset($banner) @isset($banner)
<div data-app-shell-banner class="shrink-0">{{ $banner }}</div> <div data-md-scaffold-banner>{{ $banner }}</div>
@endisset @endisset
<div class="flex-1 medium:flex"> <div data-md-scaffold-row>
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth" :hide-when-collapsed="$hideRailWhenCollapsed"> <x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth" :hide-when-collapsed="$hideRailWhenCollapsed">
@isset($brand) @isset($brand)
<x-slot:brand>{{ $brand }}</x-slot:brand> <x-slot:brand>{{ $brand }}</x-slot:brand>
@@ -182,7 +173,7 @@
{{ $railFooter ?? '' }} {{ $railFooter ?? '' }}
@isset($actions) @isset($actions)
<div data-app-shell-actions class="flex items-center gap-1 px-5 pt-2 rail-collapsed:flex-col"> <div data-md-scaffold-actions>
{{ $actions }} {{ $actions }}
</div> </div>
@endisset @endisset
@@ -190,21 +181,21 @@
@endif @endif
</x-livewire-material::navigation-rail> </x-livewire-material::navigation-rail>
<div class="flex min-w-0 flex-1 flex-col"> <div data-md-scaffold-content>
{{ $top ?? '' }} {{ $top ?? '' }}
@isset($fab) @isset($fab)
<div data-md-scaffold-fab>{{ $fab }}</div> <div data-md-scaffold-fab>{{ $fab }}</div>
@endisset @endisset
<main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 px-(--material-margin) outline-none max-expanded:overflow-x-clip max-medium:pb-(--material-bottom-bar)"> <main id="content" tabindex="-1" wire:transition.navigate>
{{ $slot }} {{ $slot }}
</main> </main>
</div> </div>
</div> </div>
@if ($barItems->isNotEmpty()) @if ($barItems->isNotEmpty())
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 medium:hidden"> <div data-md-scaffold-bar>
<x-livewire-material::navigation-bar :label="$label" :tall="$tallBar" :hide-on-scroll="$hideBarOnScroll"> <x-livewire-material::navigation-bar :label="$label" :tall="$tallBar" :hide-on-scroll="$hideBarOnScroll">
@foreach ($barItems as $item) @foreach ($barItems as $item)
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" /> <x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
+2 -2
View File
@@ -281,9 +281,9 @@ function toolbarScaffoldProbe()
it('keeps a docked toolbar clear of the navigation bar below medium', function () { it('keeps a docked toolbar clear of the navigation bar below medium', function () {
toolbarScaffoldProbe()->resize(599, 800) 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. // 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(); ->assertNoJavaScriptErrors();
}); });
+50 -2
View File
@@ -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')); ->assertScript(layoutRect('#item-5', 'left').' === '.layoutRect('#item-1', 'left'));
}); });
function scaffoldFabPage(int $width): mixed function scaffoldFabPage(int $width, string $dir = 'ltr'): mixed
{ {
$body = <<<'BLADE' $body = <<<'BLADE'
<x-scaffold :destinations="[ <x-scaffold :destinations="[
@@ -421,7 +421,7 @@ function scaffoldFabPage(int $width): mixed
</x-scaffold> </x-scaffold>
BLADE; BLADE;
return layoutPage($body, $width, 800); return layoutPage($body, $width, 800, $dir);
} }
const SCAFFOLD_FAB = '[data-md-scaffold-fab]'; const SCAFFOLD_FAB = '[data-md-scaffold-fab]';
@@ -451,6 +451,54 @@ it('places the scaffold\'s FAB at the bottom-end corner, clear of the navigation
->assertNoJavaScriptErrors(); ->assertNoJavaScriptErrors();
}); });
it('drops the bar\'s own height from the FAB\'s offset once hide-bar-on-scroll slides it away', function () {
$body = <<<'BLADE'
<x-scaffold hide-bar-on-scroll :destinations="[
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'active' => true],
['title' => 'Sent', 'icon' => 'send', 'url' => '/sent'],
]">
<x-slot:fab><x-fab icon="edit" tooltip="Compose" /></x-slot:fab>
<p style="block-size: 2000px">The page</p>
</x-scaffold>
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'
<x-scaffold :destinations="[['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'active' => true, 'bar' => false]]">
<x-slot:fab><x-fab icon="edit" tooltip="Compose" /></x-slot:fab>
</x-scaffold>
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 () { it('draws no second margin for a pane or a canonical layout inside the scaffold\'s content region', function () {
$body = <<<'BLADE' $body = <<<'BLADE'
<x-scaffold :destinations="[['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'active' => true]]"> <x-scaffold :destinations="[['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'active' => true]]">
+6 -6
View File
@@ -5,7 +5,7 @@ use Illuminate\Support\Facades\Route;
const RAIL = "document.querySelector('[data-md-navigation-rail]')"; const RAIL = "document.querySelector('[data-md-navigation-rail]')";
const RAIL_PANEL = "document.querySelector('[data-md-navigation-rail-panel]')"; 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 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 () { it('skips to the content', function () {
$page = shellPage(); $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. // Safari and WebKit leave links out of the Tab order, so focus it directly.
$page->script("document.querySelector('[data-skip-link]').focus()"); $page->script("document.querySelector('[data-md-skip-link]').focus()");
$page->assertScript("getComputedStyle(document.querySelector('[data-skip-link]')).position === 'fixed'") $page->assertScript("getComputedStyle(document.querySelector('[data-md-skip-link]')).position === 'fixed'")
->assertVisible('[data-skip-link]'); ->assertVisible('[data-md-skip-link]');
$page->keys('[data-skip-link]', 'Enter') $page->keys('[data-md-skip-link]', 'Enter')
->assertScript("location.hash === '#content'") ->assertScript("location.hash === '#content'")
->assertScript("document.activeElement === document.getElementById('content')"); ->assertScript("document.activeElement === document.getElementById('content')");
}); });
+5 -36
View File
@@ -1,9 +1,6 @@
<?php <?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet; use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
it('draws a navigation landmark around its items', function () { 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>')) 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)']) ->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]')) ->and($css->declarations('[data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden]'))
->toBe(['translate' => '0 100%']) ->toBe(['translate' => '0 100%'])
->and($css->css) // Reads --material-bottom-bar back from <x-scaffold>: layout/scaffold.css publishes it in
// Unlayered, or the Tailwind utility <x-scaffold> still publishes the offset with would win — // `material.layout`, which this rule's own `material.components` always outranks, so both
// its own rewrite has not landed yet, so this one rule stays unlayered until it does. // sit in a layer now (N-02's era of an unlayered Tailwind utility is gone).
->toContain('[data-md-scaffold]:has([data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden]) {') ->and($css->declarations('[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()) ->toBe(['--material-bottom-bar' => 'calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px))']);
->not->toContain('@layer');
expect(file_get_contents(__DIR__.'/../../../resources/js/navigation.js')) 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. // 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\"]')"); ->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 * 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 * 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 * 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 view, one stylesheet, as usual. `navigation-bar` rejoined the dataset with the scaffold's own
* one rule unlayered on purpose (still beating a Tailwind utility <x-scaffold> has not lost yet), * rewrite: its one rule reading `--material-bottom-bar` stayed unlayered only while `<x-scaffold>`
* so its shape does not match every other entry's single `@layer material.components` block; the * published that variable through a Tailwind utility, which no layered rule could outrank; now
* same checks, adjusted for that one rule, are in NavigationBarTest.php instead — the same reason * scaffold.css sets it in `material.layout`, which this file's `material.components` always beats,
* ContainmentStylesheetsTest.php gives for leaving the error layout to ErrorPagesTest.php. * so the whole stylesheet fits one `@layer material.components` block like every other entry here.
*/ */
dataset('navigation components', [ dataset('navigation components', [
'app-bar', 'app-bar',
'toolbar', 'toolbar',
'tabs', 'tabs',
'navigation-bar',
'navigation-bar-item', 'navigation-bar-item',
'navigation-rail', 'navigation-rail',
'navigation-rail-item', 'navigation-rail-item',
+105 -23
View File
@@ -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 () { 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()]); $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-rail="adaptive"')
->toContain('data-md-navigation-bar') ->toContain('data-md-navigation-bar')
->toContain('<main id="content" tabindex="-1" wire:transition.navigate') ->toContain('<main id="content" tabindex="-1" wire:transition.navigate')
->toContain('max-expanded:overflow-x-clip')
->toContain('The page') ->toContain('The page')
->toContain('x-data="materialSnackbar"') ->toContain('x-data="materialSnackbar"')
->toMatch('/<a\s+href="#content"\s+data-skip-link/') ->toMatch('/<a\s+href="#content"\s+data-md-skip-link/')
->and(strpos($html, 'data-skip-link'))->toBeLessThan(strpos($html, 'data-md-navigation-rail=')); ->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 () { 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()]); $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=')); $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-app-shell-bar')); $bar = substr($html, strpos($html, 'data-md-scaffold-bar'));
expect(substr_count($rail, '<a data-md-navigation-rail-item'))->toBe(4) expect(substr_count($rail, '<a data-md-navigation-rail-item'))->toBe(4)
->and(substr_count($bar, '<a data-md-navigation-bar-item'))->toBe(2) ->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"/'); ->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()])) 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]]])) ->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('data-md-scaffold-bar');
->not->toContain('--material-bottom-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 () { 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()])) expect((string) $this->blade('<x-scaffold :destinations="$destinations" tall-bar />', ['destinations' => scaffoldDestinations()]))
->toMatch('/data-md-navigation-bar\s+data-md-tall\s/') ->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()])) ->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
->not->toContain('data-md-tall') ->not->toContain('data-md-tall');
->toContain('max-medium:[--material-bottom-bar:calc(4rem+');
}); });
it('lets the bar leave the window while the page scrolls down', function () { 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)"); ->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()]); $html = (string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]);
expect($html) // No inline value the view computed: every one of these is a variable's own fallback in CSS.
->toContain('+var(--material-bottom-extra,0px))]') expect($html)->not->toMatch('/(?<!,)env\(safe-area-inset/');
->toContain('focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)]')
->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 () { it('places each slot once', function () {
@@ -150,7 +219,7 @@ it('places each slot once', function () {
expect(substr_count($html, $slot))->toBe(1); 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 () { 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. // Bars, then rails, then panes: the banner is outside the row the rail and the page share.
expect($html) 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()])) ->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 () { 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()]); BLADE, ['destinations' => scaffoldDestinations()]);
// M3 puts a FAB early in the focus order: after the bar, before the content. // 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') ->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(substr_count($html, 'NEW'))->toBe(1)
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()])) ->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 () { 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') expect($css)->toContain('@layer material.layout')
->toContain('--md-scaffold-fab-margin: var(--md-sys-measurement-space200);') ->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';"); ->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 () { 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) expect(fn () => $this->blade('<x-app-shell :destinations="[]" />'))->toThrow(Exception::class)
->and(file_exists(__DIR__.'/../../../resources/views/components/app-shell.blade.php'))->toBeFalse(); ->and(file_exists(__DIR__.'/../../../resources/views/components/app-shell.blade.php'))->toBeFalse();