Draw the scaffold without Tailwind
Plan step 36 (navigation group, third batch): <x-scaffold>'s own styling moves into resources/css/layout/scaffold.css (the content region, the bar and rail row, the banner, the actions row and its rail-collapsed column layout, --material-bottom-bar and --material-margin publishing, the skip link) alongside step 35's FAB and content-margin rules already there. Every data-app-shell* hook becomes data-md-scaffold-* (data-app-shell-bar, -actions, -banner); the skip link is data-md-skip-link; data-app-shell itself is dropped, data-md-scaffold already named the root. The actions row's rail-collapsed:flex-col is written out branch for branch as the navigation rail's own rewrite did for its internal parts: the three width-independent conditions in one :where() group, the four width-gated ones each in their own @media block. With that gone, resources/css/tailwind.css's rail-collapsed custom-variant shim (its last use) is removed; tailwind.css now carries only tokens/theme.css and tokens/utilities.css, which the showcase still needs until step 38. navigation-bar.css's hide-on-scroll rule reading --material-bottom-bar stayed unlayered only because <x-scaffold> published that variable through a Tailwind utility, which no layered rule could outrank; now scaffold.css sets it itself in material.layout, a layer navigation-bar.css's own material.components always beats, so the rule moves into the layer and the file fits one @layer material.components block like every other navigation stylesheet. navigation-bar rejoins NavigationStylesheetsTest.php's dataset and NavigationBarTest.php's own duplicate shape test is retired in favour of it. Browser tests added (docs/plans/material-3-browser-tests.md): the scaffold's FAB dropping the bar's own height once hide-bar-on-scroll slides it away, at the trailing edge in a right-to-left document, and clearing a safe area an application sets on its inline-end and bottom edges. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
20b062846d
commit
ed93222d22
+3
-1
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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] {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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** (600–839): the collapsed rail in the layout, 96px, and no bar; its menu button
|
- **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.
|
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
|
- **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
|
`--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']" />
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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]]">
|
||||||
|
|||||||
@@ -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')");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user