From 0013e0154da2eccaf4c95e5f875ee50bc81e23ed Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 14:51:48 +0200 Subject: [PATCH] Rename the app shell to the scaffold and give it a FAB slot Plan step 35: becomes , M3's and Compose's name for the structure of bars, rails and panes, with no alias; every package view, the showcase, the tests, the skills, README and UPGRADE follow. Its markup and Tailwind classes stay for the navigation rewrite. The new fab slot places the FAB as Compose's Scaffold does - bottom-end, 16px from the edges below medium and 24px from it, clear of the navigation bar and of a snackbar on screen (resources/css/layout/scaffold.css) - and the content region tells a pane inside that the margin is already drawn. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- README.md | 4 +- UPGRADE.md | 7 +- .../boost/guidelines/material-3.blade.php | 2 +- .../livewire-material-development/SKILL.md | 100 +++++++++--------- .../boost/skills/material-3-design/SKILL.md | 2 +- resources/css/components/navigation.css | 6 +- resources/css/components/toolbar.css | 2 +- resources/css/layout.css | 1 + resources/css/layout/scaffold.css | 65 ++++++++++++ .../views/components/navigation-bar.blade.php | 4 +- .../components/navigation-rail.blade.php | 2 +- ...app-shell.blade.php => scaffold.blade.php} | 45 +++++--- .../views/components/theme-script.blade.php | 2 +- resources/views/components/toolbar.blade.php | 2 +- resources/views/showcase/layout.blade.php | 6 +- .../views/showcase/sections/layout.blade.php | 3 +- .../showcase/sections/navigation.blade.php | 6 +- resources/views/showcase/shell.blade.php | 8 +- src/Showcase/Sections.php | 4 +- tests/Browser/LayoutTest.php | 56 ++++++++++ tests/Browser/NavigationTest.php | 4 +- .../Feature/Components/NavigationBarTest.php | 2 +- .../{AppShellTest.php => ScaffoldTest.php} | 86 ++++++++++----- tests/Feature/ShowcaseTest.php | 2 +- 24 files changed, 301 insertions(+), 120 deletions(-) create mode 100644 resources/css/layout/scaffold.css rename resources/views/components/{app-shell.blade.php => scaffold.blade.php} (82%) rename tests/Feature/Components/{AppShellTest.php => ScaffoldTest.php} (59%) diff --git a/README.md b/README.md index f06ce01a..28e1ca7a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Material 3 Expressive components for Laravel and Livewire, built on Tailwind CSS 4. -- Anonymous Blade components for the current M3 Expressive catalogue: buttons and FABs, menus, chips, text fields, selection controls, sliders, pickers, dialogs and sheets, lists, cards, carousels, progress and loading indicators, snackbars, tabs, app bars, toolbars, navigation bars and rails, an adaptive app shell, data tables and pagination. +- Anonymous Blade components for the current M3 Expressive catalogue: buttons and FABs, menus, chips, text fields, selection controls, sliders, pickers, dialogs and sheets, lists, cards, carousels, progress and loading indicators, snackbars, tabs, app bars, toolbars, navigation bars and rails, an adaptive scaffold, the layout components (panes, list-detail, supporting pane, feed), data tables and pagination. - A colour scheme generated from one seed colour with Google's colour science (`php artisan material:scheme`), light and dark, and a theme that is chosen before the first paint. - The full Material Symbols Rounded set and the M3 Expressive shapes, drawn inline without an icon package. - Error pages and a Markdown mail theme in the same scheme. @@ -136,7 +136,7 @@ Every component, prop and slot is documented in the Boost skill (`resources/boos ## Showcase -While the application runs locally (or with `MATERIAL_SHOWCASE=true`), `/material` shows every token and component, in every variant, in the application's own scheme and theme: an overview, and a page per section behind a navigation rail (the package's own app shell), with a search over every section, example and component (press `/`). +While the application runs locally (or with `MATERIAL_SHOWCASE=true`), `/material` shows every token and component, in every variant, in the application's own scheme and theme: an overview, and a page per section behind a navigation rail (the package's own scaffold), with a search over every section, example and component (press `/`). ## Testing the design diff --git a/UPGRADE.md b/UPGRADE.md index 21cd4d82..2e7437f0 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -101,9 +101,10 @@ over a colour, use the role directly. `--material-bottom-bar`; a standard toolbar's icon buttons are `primary`. - The centred app bar headline is a grid column; the `search` variant bounds its own width, so drop hand-written `max-w-*` wrappers. -- `` is a column with a nested row and gains a `banner` slot; ``'s - `header` slot takes one `` that morphs (replace the two-FAB - `rail-collapsed:` swap); new `divider` and `fill` props. +- `` is renamed ``, with no alias; it is a column with a nested row + and gains `banner` and `fab` slots. ``'s `header` slot takes one + `` that morphs (replace the two-FAB `rail-collapsed:` swap); new `divider` + and `fill` props. - `` selects the optical-size-20 cut; the components pass it for their own small icons, applications pass it for icons drawn at 20px or less. - A text field stops at 40rem wide from `medium:`, as M3 bounds fields on wider windows; a diff --git a/resources/boost/guidelines/material-3.blade.php b/resources/boost/guidelines/material-3.blade.php index b2863777..d5b52a09 100644 --- a/resources/boost/guidelines/material-3.blade.php +++ b/resources/boost/guidelines/material-3.blade.php @@ -41,7 +41,7 @@ Every view in this application is Material 3 Expressive (m3.material.io), throug ### Layout and breakpoints - Widths are M3's window size classes, the only variants that compile: compact below 600px (the default), `medium:` 600, `expanded:` 840, `large:` 1200, `extra-large:` 1600, and `max-medium:` … for "below". In scripts, `from()` and `upTo()` from `resources/js/breakpoints.js`. -- What changes per class: compact — navigation bar, one pane, full-screen dialogs, a bottom sheet for choices; medium — collapsed rail, one pane; expanded — rail (collapsible), two panes, menus and basic dialogs; large and extra-large — the rail expanded, two panes, a third only at extra-large as a side sheet. `` does this; content lives in panes (``), never beside the rail by hand. +- What changes per class: compact — navigation bar, one pane, full-screen dialogs, a bottom sheet for choices; medium — collapsed rail, one pane; expanded — rail (collapsible), two panes, menus and basic dialogs; large and extra-large — the rail expanded, two panes, a third only at extra-large as a side sheet. `` does this; content lives in panes (``), never beside the rail by hand. - Margins are 16px below `medium` and 24px from it; spacing sits on the 4px grid, as padding and gaps on the parent, with margins only between layout regions. A fixed pane is 360px (expanded) or 412px (large); a side sheet at most 400px. - Write logical properties (`ps-*`, `me-*`, `start-*`, `text-start`); directional icons mirror in RTL; charts and media controls stay LTR. Keep controls inside the safe area (`--material-safe-*`). diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index f96a3cea..aed083e3 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -113,9 +113,9 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex Every component that meets the edge of the screen (app bar, navigation bar and rail, docked and placed toolbars, full-screen search, dialog and side sheet, bottom sheet, the skip link) keeps clear of a notch or home indicator through `var(--material-safe-top|bottom|left|right, env(safe-area-inset-…))`. The layout needs `viewport-fit=cover` in its viewport meta for the insets to be non-zero. Set a variable to replace the device's inset, on `` or any ancestor: a browser test fakes a notch with `document.documentElement.style.setProperty('--material-safe-top', '47px')`, and an app that draws its own status strip adds its height. -`--material-snackbar-height` is the height of the snackbar on screen, written on `` by `` while one shows and removed when it goes. `` reads it, so the FAB sits above the snackbar rather than under it, as M3 requires; a placed `` does the same by wrapping it in `
`. +`--material-snackbar-height` is the height of the snackbar on screen, written on `` by `` while one shows and removed when it goes. `` reads it, so the FAB sits above the snackbar rather than under it, as M3 requires; so does an `` in ``'s `fab` slot, which places it. -`--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `` (an offline banner): the shell adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button and the page's bottom padding clear it too. Set it while the docked element shows, and remove it when it goes; place the docked element itself directly above the bar, at `bottom: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`, on a compact window (below `medium`) only. +`--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `` (an offline banner): the scaffold adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button, the scaffold's FAB and the page's bottom padding clear it too. Set it while the docked element shows, and remove it when it goes; place the docked element itself directly above the bar, at `bottom: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`, on a compact window (below `medium`) only. ## Toasts @@ -339,7 +339,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d ### `` -`` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. The glyph is filled, as M3 requires of a FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`
` — M3's 16dp margin, 24dp from `large`). `link`, `external`, `type`. There is no `disabled`: M3 says to remove a FAB whose action is unavailable, so hide it instead. `data-fab` on the root lets a place restyle a nested FAB (a rail flattens it to elevation 0). +`` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. The glyph is filled, as M3 requires of a FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself: put the page's FAB in ``'s `fab` slot, which places it at the bottom-end corner with M3's margin and clear of the navigation bar and a snackbar. `link`, `external`, `type`. There is no `disabled`: M3 says to remove a FAB whose action is unavailable, so hide it instead. `data-fab` on the root lets a place restyle a nested FAB (a rail flattens it to elevation 0). `collapse-on-scroll` on an extended FAB with an `icon` (``) is M3's scroll behaviour: it shrinks to the FAB of its size while the window scrolls down and extends again on scroll-up or at the top of the page. The width morphs on the spatial spring and the label fades; under reduced motion it swaps outright. The label stays in the page, clipped, so the collapsed FAB keeps its accessible name. It watches the window, so it is for a FAB pinned over a scrolling page, not one inside a scrolling pane. @@ -783,6 +783,50 @@ Breakpoints are M3's five, in px: compact below 600, `medium` 600, `expanded` 84 M3's margin — 16px on a compact window, 24px from `medium` — is drawn once: by the scaffold's content region, or by the outermost pane or canonical layout when there is no scaffold. A pane inside one of those draws none, and one on an ``, a new edge, draws it again. +#### `` + +The adaptive scaffold, a whole layout's body: one navigation per M3 breakpoint, the page as `
` behind a skip link, the page's FAB, and the snackbar host (do not add another ``). It needs `` in ``. It was `` before 2.0.0; that name is gone. + +| Breakpoint | Width | Navigation | Margin | +| --- | --- | --- | --- | +| Compact | below `medium` (600px) | navigation bar, pinned to the bottom; the rest in the modal rail, opened by `$store.rail.show()` | 16px | +| Medium | `medium` 600–839 | collapsed rail (96px) in the layout, no bar; its menu button opens it expanded over a scrim | 24px | +| Expanded | `expanded` 840–1199 | standard rail in the layout, collapsed; the menu button expands it in place, no scrim | 24px | +| Large, extra-large | `large` from 1200 | the same standard rail, expanded to begin with | 24px | + +A visitor who has pressed the menu button keeps that choice in both standard bands (`$store.rail`, remembered and applied before the first paint). + +```blade + + SealShare + + + + + {{-- the page's app bar; its menu button opens the modal rail on a phone --}} + + + + + + + {{ $slot }} + +``` + +- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the page's, also during a Livewire update request), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for the badge: "3 unread"), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`). +- Slots, each rendered once: `banner` (a bar across the whole window, above the rail and the page), `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB that lives in the rail), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the page's own bar, above the page and beside the rail), `fab` (the page's FAB) and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`); `tall-bar` picks M3's 80px navigation bar over the 64px one; `hide-bar-on-scroll` lets the bar leave the window while the page scrolls down; `hide-rail-when-collapsed` takes the rail out of the layout from `expanded` when its menu button collapses it, instead of narrowing it to 96px — the only way back is `$store.rail.show()`, so the app bar then needs a menu button at every width. +- `fab` places an `` as Compose's Scaffold does: fixed at the bottom-end corner, 16px from the window's edges on a compact window and 24px from `medium`, above the navigation bar and the bottom safe area, and lifted above a snackbar while one shows (M3: a snackbar appears above a FAB, never in front of or behind it). In focus order it comes after the page's bar and before the page. Use it or a FAB in `rail-header`, never both: M3 allows one FAB on a screen. +- `banner` or `top`: M3's scaffold is bars, then rails, then panes. An application-wide bar — one search, one account menu, the same on every page — goes in `banner` and the rail starts under it; a bar that titles the page goes in `top`, beside the rail. Never both. A banner that pins itself to the top of the window says how tall it is (`style="--material-banner: 4rem"` on ``), so the rail sticks under it instead of behind it. +- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. On a compact window nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `medium`). +- `--material-margin` is M3's window margin (16px compact, 24px from `medium`) and the content region already carries it, so a page inside the scaffold writes no gutters of its own, and a pane or canonical layout inside draws none; something that must reach the window's edges opts out with `-mx-(--material-margin)`. +- On a compact window the scaffold sets `--material-bottom-bar` (the bar, the bottom safe area and `--material-bottom-extra`), so the snackbar, the FAB and the page's bottom padding clear the bar; pad anything else you pin to the bottom with it. See Safe areas. +- The content region is `max-expanded:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside. + #### `` A content region: M3 puts all content in panes, and each may carry its own top app bar. @@ -898,53 +942,9 @@ Children in columns inside a pane. - `min-item` fills each row with as many columns as fit at that width — following the grid's own width, the choice inside a pane narrower than the window. With `columns` too, the counts are a ceiling. - Children keep their source order. M3 publishes no column table for the web; choose counts by the content. -### `` - -The adaptive app shell, a whole layout's body: one navigation per M3 window size class, the page as `
` behind a skip link, and the snackbar host (do not add another ``). It needs `` in ``. - -| Window size class | Width | Navigation | Margin | -| --- | --- | --- | --- | -| Compact | below `medium` (600px) | navigation bar, pinned to the bottom; the rest in the modal rail, opened by `$store.rail.show()` | 16px | -| Medium | `medium` 600–839 | collapsed rail (96px) in the layout, no bar; its menu button opens it expanded over a scrim | 24px | -| Expanded | `expanded` 840–1199 | standard rail in the layout, collapsed; the menu button expands it in place, no scrim | 24px | -| Large, extra-large | `large` from 1200 | the same standard rail, expanded to begin with | 24px | - -A visitor who has pressed the menu button keeps that choice in both standard bands (`$store.rail`, remembered and applied before the first paint). - -```blade - - SealShare - - - - - - - - {{-- the page's app bar; its menu button opens the modal rail on a phone --}} - - - - {{ $slot }} - -``` - -- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the page's, also during a Livewire update request), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for the badge: "3 unread"), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`). -- Slots, each rendered once: `banner` (a bar across the whole window, above the rail and the page), `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the page's own bar, above the page and beside the rail), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`); `tall-bar` picks M3's 80px navigation bar over the 64px one; `hide-bar-on-scroll` lets the bar leave the window while the page scrolls down; `hide-rail-when-collapsed` takes the rail out of the layout from `expanded` when its menu button collapses it, instead of narrowing it to 96px — the only way back is `$store.rail.show()`, so the app bar then needs a menu button at every width. -- `banner` or `top`: M3's scaffold is bars, then rails, then panes. An application-wide bar — one search, one account menu, the same on every page — goes in `banner` and the rail starts under it; a bar that titles the page goes in `top`, beside the rail. Never both. A banner that pins itself to the top of the window says how tall it is (`style="--material-banner: 4rem"` on ``), so the rail sticks under it instead of behind it. -- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. On a compact window nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `medium`). -- `--material-margin` is M3's window margin (16px compact, 24px from `medium`) and the content region already carries it, so a page inside the shell writes no gutters of its own; something that must reach the window's edges opts out with `-mx-(--material-margin)`. -- Two panes side by side are M3's from `expanded`: `` is the second one, in an `expanded:flex expanded:items-start expanded:gap-6` row inside the page. -- On a compact window the shell sets `--material-bottom-bar` (the bar, the bottom safe area and `--material-bottom-extra`), so the snackbar, a `fab` button and the page's bottom padding clear the bar; pad anything else you pin to the bottom with it. See Safe areas. -- The content region is `max-expanded:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside. - ### ``, `` -M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`` does): +M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`` does): ```blade
@@ -955,7 +955,7 @@ M3 Expressive's flexible navigation bar, for three to five destinations. It does
``` -64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. ``: `label` ("Main"), `tall` (M3's 80px container, which keeps the icon over the label at every width — `` picks it, and the bottom offset grows with it), `hide-on-scroll` (M3's scrolling behaviour: the bar slides out on a scroll down and springs back on a scroll up, never before the first screenful and never while a snackbar, bottom sheet or drawer is on screen; focus reaching it brings it back. `` picks it, and `--material-bottom-bar` goes down and comes back with the bar, so a `fab` button and the snackbar keep their distance from it). ``: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3"). +64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. ``: `label` ("Main"), `tall` (M3's 80px container, which keeps the icon over the label at every width — `` picks it, and the bottom offset grows with it), `hide-on-scroll` (M3's scrolling behaviour: the bar slides out on a scroll down and springs back on a scroll up, never before the first screenful and never while a snackbar, bottom sheet or drawer is on screen; focus reaching it brings it back. `` picks it, and `--material-bottom-bar` goes down and comes back with the bar, so a `fab` button and the snackbar keep their distance from it). ``: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3"). ### ``, ``, `` @@ -981,7 +981,7 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (
``` -- `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (``'s, one rail per window size class: hidden and opened as a modal on a compact window, collapsed and opened as a modal at `medium`, a standard rail from `expanded` — collapsed there, expanded from `large`). +- `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (``'s, one rail per window size class: hidden and opened as a modal on a compact window, collapsed and opened as a modal at `medium`, a standard rail from `expanded` — collapsed there, expanded from `large`). - Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px — or the word `narrow` for M3's other *collapsed* width, 80px against the default 96, where the items are their icons alone; the labels stay in the accessibility tree and a narrow rail still expands to 16rem), `align` (`top` default, or `center` for M3's centred destinations — preferred on a tablet; the menu button, brand and FAB stay at the top and the footer at the foot), `hide-when-collapsed` (M3's immersive expanded behaviour, `collapsible` and `adaptive` only: collapsing the rail takes it out of the layout instead of narrowing it, and `$store.rail.show()` brings it back expanded over a scrim — so put a menu button in the app bar; the rail's own button then docks it again. Not below `medium` for a collapsible rail nor at `medium` for an adaptive one, where the window rather than the visitor collapses it and M3's collapsed rail may never hide), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`), `divider` (M3's optional vertical divider on the page's side — use it when the page scrolls under a fixed rail), `fill` (`false` for a transparent container, which M3 allows while the items keep 3:1 contrast). Slots: `brand` (beside the menu button, expanded only), `header` (one ``, which the rail morphs into an extended FAB and back as it expands — it also rests at elevation 0, as M3 asks of a nested FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport. - Anything else inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `…expanded only…`, ``. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px. - A `collapsible` rail is held to the collapsed 96px below `medium` (600px), where M3 says to use a navigation bar rather than a standard rail. `collapsed` and `expanded` are fixed-width by design: wrap one in a `medium:` element if it must not show on a phone. diff --git a/resources/boost/skills/material-3-design/SKILL.md b/resources/boost/skills/material-3-design/SKILL.md index e583ee72..f73fa234 100644 --- a/resources/boost/skills/material-3-design/SKILL.md +++ b/resources/boost/skills/material-3-design/SKILL.md @@ -215,7 +215,7 @@ Layout keys on the width of the window, in M3's five window size classes; they a | Large | 1200–1599px | `large:` | rail expanded | 2; a fixed pane 412px | basic dialog; menu | 24px | | Extra-large | 1600px and up | `extra-large:` | rail expanded | 2, or 3 with a standard side sheet (at most 400px) | basic dialog; menu | 24px | -- `` implements the navigation column; `` is the second pane of a list-detail layout from `expanded:`, a supporting pane (360px, beside the focus pane) from `expanded:` and below it before that. Moving up a class, ask what to reveal, divide into panes, resize, reposition or swap — never swap a component for one that does not do the same job. +- `` implements the navigation column; `` is the second pane of a list-detail layout from `expanded:`, a supporting pane (360px, beside the focus pane) from `expanded:` and below it before that. Moving up a class, ask what to reveal, divide into panes, resize, reposition or swap — never swap a component for one that does not do the same job. - Scaffold: bars (app bar at the top, navigation bar at the bottom: 3–5 destinations), rails (the navigation rail, toolbars, the FAB, on the leading edge), panes (all content), around a safety region that stays clear of the device's own chrome (`--material-safe-top|bottom|left|right`). - Canonical layouts: feed (a grid of cards that gains columns per class), list-detail (one pane on compact, two from expanded; a back button only in single-pane mode, a selected row only in two-pane mode), supporting pane (two thirds focus, one third support). - Bidirectionality: write logical properties (`ps-4`, `me-2`, `start-0`, `text-start`, `border-s`); leading and trailing icons swap, directional icons (back, send) mirror, the rail moves to the right; charts, media controls, clocks and Hebrew progress bars stay left-to-right. diff --git a/resources/css/components/navigation.css b/resources/css/components/navigation.css index 99059700..8b9b5287 100644 --- a/resources/css/components/navigation.css +++ b/resources/css/components/navigation.css @@ -44,7 +44,7 @@ * the same 37.5rem, so a bar in a narrow column lays out by its own width. * * Everything here is in `@layer components` except the blocks after it, which reach past the - * component into what an application wrote — the bottom offset publishes, a FAB put + * component into what an application wrote — the bottom offset publishes, a FAB put * in the rail: those have to beat a utility, and a layer never does. */ @@ -296,7 +296,7 @@ } /* `--material-banner` is the height of a bar pinned across the top of the window — what - 's `banner` slot holds, if the application made it sticky. The rail sticks + 's `banner` slot holds, if the application made it sticky. The rail sticks under it rather than behind it; 0 without one. */ [data-navigation-rail-panel] { position: sticky; @@ -795,7 +795,7 @@ /* A bar that has slid off the bottom of the window is no longer there to clear, so the offset * everything pinned to the bottom reads drops to the bottom safe area and whatever the application * has docked on the bar — a `fab` button, the snackbar and the page's own bottom padding all follow - * it down and come back up with the bar. Unlayered on purpose: publishes the variable + * it down and come back up with the bar. Unlayered on purpose: publishes the variable * with a utility, and a rule in any layer loses to one. */ [data-app-shell]:has([data-navigation-bar][data-hide-on-scroll][data-hidden]) { --material-bottom-bar: calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px)); diff --git a/resources/css/components/toolbar.css b/resources/css/components/toolbar.css index 14e95ab2..5f3dab5f 100644 --- a/resources/css/components/toolbar.css +++ b/resources/css/components/toolbar.css @@ -124,7 +124,7 @@ /* Placed over the page: centred above the bottom edge, or centred against the end edge. * * A toolbar at the bottom shares the screen region with a navigation bar, so it clears the one - * publishes as --material-bottom-bar, exactly as the FAB and the snackbar do — + * publishes as --material-bottom-bar, exactly as the FAB and the snackbar do — * `max()`, not a sum, because that height already swallows the bottom safe area; without a bar * the safe area alone applies. A *docked* toolbar and a navigation bar must never be on screen * together at all (docs/reference/m3/components-navigation-selection-inputs.md § Toolbars); the diff --git a/resources/css/layout.css b/resources/css/layout.css index bb6802f0..5a4fa93d 100644 --- a/resources/css/layout.css +++ b/resources/css/layout.css @@ -14,3 +14,4 @@ @import './layout/list-detail.css'; @import './layout/supporting-pane.css'; @import './layout/feed.css'; +@import './layout/scaffold.css'; diff --git a/resources/css/layout/scaffold.css b/resources/css/layout/scaffold.css new file mode 100644 index 00000000..e228b250 --- /dev/null +++ b/resources/css/layout/scaffold.css @@ -0,0 +1,65 @@ +/* + * : the FAB it places, and the margin its content region already draws. + * + * The rest of the scaffold — the rail, the navigation bar, the content region — is still drawn by + * its Tailwind classes until its rewrite (plan step 36); this file holds only what step 35 added. + * + * `data-md-scaffold-fab` places the page's FAB as Compose's Scaffold places its floating action + * button: fixed at the bottom-end corner (M3's adaptive placement for a FAB, "lower right (bottom + * trailing) corner", docs/reference/m3/components-actions-communication-containment.md § FAB → + * Behaviour), with M3's margin from the window's edges — 16px below medium and 24px from 600px + * (docs/reference/m3/foundations-supplement.md § Breakpoints). `inset-inline-end` is the trailing + * edge, so a right-to-left document puts it on the left, clear of the safe area on that side. + * + * From the bottom it clears, in order: the navigation bar, or the bottom safe area where there is + * none — `max()`, since `--material-bottom-bar` already includes the safe area (the navigation bar + * publishes it on the scaffold below medium); then a snackbar while one shows. sits 16px + * above the bar and publishes its height as `--material-snackbar-height` while it is on screen, so + * the FAB adds that height and the snackbar's own 16px (`min()` of the height and 16px is 16px when + * a snackbar shows and 0px when none does) — M3: "snackbars should appear above FABs — never in + * front of or behind one" (§ Snackbar → Placement). It moves there on the spatial spring, which + * reduced motion makes instant. z-index 30 is the navigation bar's own layer: over the page, under + * a scrim and the snackbar. + * + * `--md-layout-margin: 0px` on the content region tells a pane or canonical layout inside that the + * region already keeps the window margin (layout/pane.css), so neither draws it a second time. + * + * In `material.layout`. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@layer material.layout { + [data-md-scaffold] main { + --md-layout-margin: 0px; + } + + [data-md-scaffold-fab] { + --md-scaffold-fab-margin: var(--md-sys-measurement-space200); + + position: fixed; + z-index: 30; + inset-inline-end: max( + var(--md-scaffold-fab-margin), + var(--material-safe-right, env(safe-area-inset-right)) + ); + inset-block-end: calc( + max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + + var(--md-scaffold-fab-margin) + + var(--material-snackbar-height, 0px) + + min(var(--material-snackbar-height, 0px), var(--md-sys-measurement-space200)) + ); + transition: inset-block-end var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default); + + @media (width >= 600px) { + --md-scaffold-fab-margin: var(--md-sys-measurement-space300); + } + } + + [data-md-scaffold-fab]:dir(rtl) { + inset-inline-end: max( + var(--md-scaffold-fab-margin), + var(--material-safe-left, env(safe-area-inset-left)) + ); + } +} diff --git a/resources/views/components/navigation-bar.blade.php b/resources/views/components/navigation-bar.blade.php index 6814444f..7b10d433 100644 --- a/resources/views/components/navigation-bar.blade.php +++ b/resources/views/components/navigation-bar.blade.php @@ -24,12 +24,12 @@ down and springs back on a scroll up, never before the first screenful has gone by, and never while a snackbar, a bottom sheet or a drawer is on screen — those are anchored to the bar's edge and would slide with it. Focus reaching the bar brings it back, which is as close as the - web gets to M3's "never hide it while a screen reader is active". Inside `` + web gets to M3's "never hide it while a screen reader is active". Inside `` `--material-bottom-bar` follows the bar down and up, so a `fab` button, the snackbar and the page's bottom padding keep their distance from it rather than from where it was. It does not position itself: wrap it in the element that pins it (`fixed inset-x-0 bottom-0`) - and hides it where a rail takes over. `` does both, and lifts the snackbar and a + and hides it where a rail takes over. `` does both, and lifts the snackbar and a `fab` button above it through `--material-bottom-bar`. `label` names the landmark ("Main" by default); `tall` picks the 80px container; diff --git a/resources/views/components/navigation-rail.blade.php b/resources/views/components/navigation-rail.blade.php index c097a3f2..64002b4d 100644 --- a/resources/views/components/navigation-rail.blade.php +++ b/resources/views/components/navigation-rail.blade.php @@ -29,7 +29,7 @@ - `modal` — collapsed in the layout; the menu button (or `$store.rail.show()` from anywhere) opens it expanded over a scrim, holding focus until Escape, the scrim, the menu button or leaving the page closes it (Compose's ModalWideNavigationRail). - - `adaptive` — what `` uses, one rail per M3 window size class: on a compact + - `adaptive` — what `` uses, one rail per M3 window size class: on a compact window (below `medium`, 600px) nothing until `$store.rail.show()` slides it in as a modal; at `medium` (600–839) collapsed in the layout, opening as a modal; at `expanded` (840–1199) a standard rail, collapsed until its menu button expands it in place; from `large` (1200) diff --git a/resources/views/components/app-shell.blade.php b/resources/views/components/scaffold.blade.php similarity index 82% rename from resources/views/components/app-shell.blade.php rename to resources/views/components/scaffold.blade.php index d286e3cb..4c1a12ec 100644 --- a/resources/views/components/app-shell.blade.php +++ b/resources/views/components/scaffold.blade.php @@ -1,17 +1,20 @@ -{{-- The adaptive app shell: navigation that changes shape with the window, around the page. +{{-- The scaffold: M3's structure of bars, rails and panes around the page, and the FAB, with + navigation that changes shape with the window. M3 and Compose call it the scaffold + (docs/reference/m3/foundations.md § Layout → Scaffold); before 2.0.0 it was ``. - 'Shares', 'icon' => 'folder_shared', 'url' => route('shares'), 'active' => request()->routeIs('shares*'), 'badge' => 3], ['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')], ['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false], ]"> SealShare …the page's app bar… + …the page… - + - The navigation is M3's per window size class (docs/reference/m3/foundations.md § Layout and + The navigation is M3's per breakpoint (docs/reference/m3/foundations.md § Layout and foundations-supplement.md § Breakpoints), and only those four numbers: - **Compact**, below `medium` (600px): a navigation bar with the destinations marked `bar`, @@ -28,14 +31,15 @@ From `expanded` the choice the menu button makes is remembered and applied before the first paint (`$store.rail`, ), so the rail never paints one width and snaps to the other. A visitor who has chosen keeps that choice in both bands; one who never has gets the - class's own default. + breakpoint's own default. `--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 shell 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)`. - Two panes side by side are M3's from `expanded`: `` is the second one, 360dp - wide, in an `expanded:flex expanded:items-start expanded:gap-6` row inside the page. + Panes are ``, and two side by side are M3's canonical layouts from `expanded`, + `` and ``; inside the content region they draw no margin of + their own (resources/css/layout/scaffold.css sets `--md-layout-margin` there). `destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally `active` (by default: the URL is the page's; during a Livewire update request, the page the @@ -51,8 +55,8 @@ rail morphs between a FAB and an extended FAB as it opens), `rail-footer` (at the foot of the rail: footer destinations, an account), `actions` (a row of icon buttons at the very foot, stacked when the rail is collapsed: a theme toggle, - sign out), `top` (the page's own bar, above the page and *beside* the rail) and the page - itself. The rail is one element at every width, so what is in it is also in the modal rail a + sign out), `top` (the page's own bar, above the page and *beside* the rail), `fab` (the + page's floating action button, see below) and the page itself. The rail is one element at every width, so what is in it is also in the modal rail a phone opens. `label` names both navigation landmarks ("Main"); `rail-width` is the expanded rail's width; `tall-bar` picks M3's 80px navigation bar over the 64px one, and the bottom offset every pinned thing reads follows it; `hide-bar-on-scroll` lets the bar leave the window @@ -66,16 +70,28 @@ search, one account menu, the same on every page — spans the window and the rail starts under it; a bar that titles the page belongs to the page, beside the rail. Put an app bar in one or the other, never both. A banner that pins itself to the top of the window says how tall it is - — `style="--material-banner: 4rem"` on `` — so the rail sticks under it instead + — `style="--material-banner: 4rem"` on `` — so the rail sticks under it instead of behind it. The page is `
` with `wire:transition.navigate`, behind a skip link that is - the first thing a keyboard reaches. The snackbar host (``) is part of the shell; + the first thing a keyboard reaches. The snackbar host (``) is part of the scaffold; on a compact window it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`: the bar's 64px, the bottom safe area (`--material-safe-bottom`, else the device's inset) and `--material-bottom-extra` (0px unless the application docks something, an offline banner, on top of the bar). + `fab` places an `` as Compose's Scaffold places its floating action button: fixed at the + bottom-end corner, 16px from the window's edges on a compact window and 24px from `medium` + (M3's margins, foundations-supplement.md § Breakpoints; the FAB's own adaptive placement is + the lower trailing corner, components-actions-communication-containment.md § FAB), above the + navigation bar and the bottom safe area, and lifted over a snackbar while one shows — + M3: a snackbar appears above a FAB, never in front of or behind one. Compose raises the + snackbar over the FAB instead; here the snackbar host is one fixed element every page shares, + so the FAB is what moves (resources/css/layout/scaffold.css). Use it or `rail-header`'s FAB, + not both: M3 says not to show more than one FAB on a screen. It comes after the page's bar and + before the page in focus order, where M3 puts a FAB (§ FAB → Accessibility: "the FAB should be + prioritized in the page's focus order"). + `max-expanded:overflow-x-clip` on the content region is the backstop under every page, and it stays `clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll container and break every `position: sticky` inside it (an app bar, a list-detail pane). Below @@ -120,6 +136,7 @@
$barItems->isNotEmpty() && ! $tallBar, @@ -176,6 +193,10 @@
{{ $top ?? '' }} + @isset($fab) +
{{ $fab }}
+ @endisset +
{{ $slot }}
diff --git a/resources/views/components/theme-script.blade.php b/resources/views/components/theme-script.blade.php index 8ada6106..4aa35998 100644 --- a/resources/views/components/theme-script.blade.php +++ b/resources/views/components/theme-script.blade.php @@ -31,7 +31,7 @@ rail's width is CSS keyed on it (the `rail-collapsed:` variant). Set any later, a collapsed rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js) changes it. rides with it and says nothing was stored — the value is only - `rail.default`, not a choice — so ``'s adaptive rail can start collapsed in the + `rail.default`, not a choice — so ``'s adaptive rail can start collapsed in the expanded class (840–1199) and expanded from large, as M3 asks, while still obeying a visitor who has chosen. `$store.rail` drops it the first time they do. diff --git a/resources/views/components/toolbar.blade.php b/resources/views/components/toolbar.blade.php index 8afb5592..87cffeb2 100644 --- a/resources/views/components/toolbar.blade.php +++ b/resources/views/components/toolbar.blade.php @@ -25,7 +25,7 @@ A docked toolbar and a navigation bar occupy the same region of the screen and M3 says never to show both at once: the bar belongs on a primary page, the toolbar on a secondary or contextual one. Either way a toolbar placed at `bottom` clears `--material-bottom-bar`, so it is never - buried under ``'s bar. + buried under ``'s bar. Put `` controls in the slot (`:selected` for toggles). It is a `role="toolbar"`: the arrow keys move between its controls (resources/js/toolbar.js, diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php index c9f00244..cfbd8981 100644 --- a/resources/views/showcase/layout.blade.php +++ b/resources/views/showcase/layout.blade.php @@ -1,4 +1,4 @@ -{{-- The showcase's frame, and the package's own app shell at work: the rail groups every section, +{{-- The showcase's frame, and the package's own scaffold at work: the rail groups every section, collapses and expands from `lg`, and opens as a modal from the app bar's menu button on a phone. Pages move with wire:navigate, so the rail keeps its place and the theme stays. --}} @@ -42,7 +42,7 @@ @livewireStyles - + Livewire Material @@ -152,7 +152,7 @@ @yield('content') - + @livewireScripts diff --git a/resources/views/showcase/sections/layout.blade.php b/resources/views/showcase/sections/layout.blade.php index 9eda5824..2dac0eb3 100644 --- a/resources/views/showcase/sections/layout.blade.php +++ b/resources/views/showcase/sections/layout.blade.php @@ -98,7 +98,7 @@

- <x-app-shell> does all of this: resize this showcase and watch the bar, the rail and the + <x-scaffold> does all of this: resize this showcase and watch the bar, the rail and the content margin (--material-margin) change at 600, 840 and 1200px. A visitor who has chosen a rail width keeps it; until then the rail follows the class.

@@ -113,6 +113,7 @@

+
  • <x-scaffold> — the bars, the rail and the FAB around the page (this showcase is one).
  • <x-pane> — a content region with M3's margins and its own app bar.
  • <x-list-detail> — a list and the detail of its selection: one pane below 840px, two from it.
  • <x-supporting-pane> — a focus pane and a pane beside it from 840px, below it or a bottom sheet before.
  • diff --git a/resources/views/showcase/sections/navigation.blade.php b/resources/views/showcase/sections/navigation.blade.php index db1e2e46..4143aff3 100644 --- a/resources/views/showcase/sections/navigation.blade.php +++ b/resources/views/showcase/sections/navigation.blade.php @@ -134,7 +134,7 @@
    - The menu button collapses and expands the rail, and the FAB morphs into an extended FAB with it. The choice is remembered and applied before the next page paints — every collapsible rail follows it, <x-app-shell>'s from expanded (840px) too. + The menu button collapses and expands the rail, and the FAB morphs into an extended FAB with it. The choice is remembered and applied before the next page paints — every collapsible rail follows it, <x-scaffold>'s from expanded (840px) too.
    BLADE, @@ -164,8 +164,8 @@

    <x-navigation-bar> and <x-navigation-bar-item>, <x-navigation-rail>, <x-navigation-rail-item> and <x-navigation-rail-section>, - and <x-app-shell>, which puts them together on M3's window size classes: a bar below medium (600px), a collapsed rail that opens as a modal through medium, a standard rail from expanded (840px) and an expanded one from large (1200px). - Open the app shell and change the window's width. + and <x-scaffold>, which puts them together on M3's window size classes: a bar below medium (600px), a collapsed rail that opens as a modal through medium, a standard rail from expanded (840px) and an expanded one from large (1200px). + Open the scaffold and change the window's width.

    @foreach ($examples as $title => $code) diff --git a/resources/views/showcase/shell.blade.php b/resources/views/showcase/shell.blade.php index 09190a86..5c9c59f0 100644 --- a/resources/views/showcase/shell.blade.php +++ b/resources/views/showcase/shell.blade.php @@ -1,4 +1,4 @@ -{{-- The app shell, as a whole page: /material/shell and its sibling pages, so the rail, the bar, the +{{-- The scaffold, as a whole page: /material/shell and its sibling pages, so the rail, the bar, the modal rail and wire:navigate between pages can be tried at any window width. --}} @php @@ -31,7 +31,7 @@ - {{ $current['title'] }} · App shell · Livewire Material + {{ $current['title'] }} · Scaffold · Livewire Material @@ -39,7 +39,7 @@ @livewireStyles - + Livewire Material @@ -79,7 +79,7 @@
    - + @livewireScripts diff --git a/src/Showcase/Sections.php b/src/Showcase/Sections.php index ece59f34..d1404b8f 100644 --- a/src/Showcase/Sections.php +++ b/src/Showcase/Sections.php @@ -87,7 +87,7 @@ class Sections 'shape' => ['title' => 'Shape', 'icon' => 'interests', 'group' => 'Foundations', 'description' => 'Corner radii and the 35 M3 Expressive shapes.'], 'elevation' => ['title' => 'Elevation', 'icon' => 'layers', 'group' => 'Foundations', 'description' => 'Shadows for what floats over content.'], 'motion' => ['title' => 'Motion', 'icon' => 'animation', 'group' => 'Foundations', 'description' => 'Spatial springs and effects easings.'], - 'layout' => ['title' => 'Layout', 'icon' => 'devices', 'group' => 'Foundations', 'description' => 'M3\'s window size classes, what changes at each, and the app shell\'s response.'], + 'layout' => ['title' => 'Layout', 'icon' => 'devices', 'group' => 'Foundations', 'description' => 'M3\'s window size classes, what changes at each, and the scaffold\'s response.'], 'icons' => ['title' => 'Icons', 'icon' => 'emoji_symbols', 'group' => 'Foundations', 'description' => 'Every Material Symbol, searchable, outlined and filled.'], 'buttons' => ['title' => 'Buttons', 'icon' => 'smart_button', 'group' => 'Actions', 'description' => 'Buttons, icon buttons, groups, split buttons and FABs.'], 'menus' => ['title' => 'Menus', 'icon' => 'menu_open', 'group' => 'Actions', 'description' => 'Menus with items, groups, choices and shortcuts.'], @@ -101,7 +101,7 @@ class Sections 'pickers' => ['title' => 'Date pickers', 'icon' => 'calendar_month', 'group' => 'Inputs', 'description' => 'Docked, modal and input date pickers, single and range.'], 'timepickers' => ['title' => 'Time pickers', 'icon' => 'schedule', 'group' => 'Inputs', 'description' => 'The dial and input time picker.'], 'bars' => ['title' => 'App bars and tabs', 'icon' => 'toolbar', 'group' => 'Navigation', 'description' => 'Top app bars, toolbars, tabs, section navigation and the account menu.'], - 'navigation' => ['title' => 'Navigation', 'icon' => 'explore', 'group' => 'Navigation', 'description' => 'The navigation bar, the navigation rail and the app shell.'], + 'navigation' => ['title' => 'Navigation', 'icon' => 'explore', 'group' => 'Navigation', 'description' => 'The navigation bar, the navigation rail and the scaffold.'], 'data' => ['title' => 'Data', 'icon' => 'table_chart', 'group' => 'Data and pages', 'description' => 'Data tables, sort headers and pagination.'], 'pages' => ['title' => 'Error pages and mail', 'icon' => 'page_info', 'group' => 'Data and pages', 'description' => 'The HTTP error pages and the Markdown mail theme.'], ]; diff --git a/tests/Browser/LayoutTest.php b/tests/Browser/LayoutTest.php index 43d51e2c..29d7ddda 100644 --- a/tests/Browser/LayoutTest.php +++ b/tests/Browser/LayoutTest.php @@ -407,3 +407,59 @@ it('lays a feed out in one column below medium and in columns of its minimum wid ->assertScript(layoutColumns('#feed').' === 4') ->assertScript(layoutRect('#item-5', 'left').' === '.layoutRect('#item-1', 'left')); }); + +function scaffoldFabPage(int $width): mixed +{ + $body = <<<'BLADE' + + +

    The page

    +
    + BLADE; + + return layoutPage($body, $width, 800); +} + +const SCAFFOLD_FAB = '[data-md-scaffold-fab]'; + +/** The navigation bar the scaffold pins to the bottom: the one navigation landmark at the window's foot. */ +const SCAFFOLD_BAR = "[...document.querySelectorAll('[data-md-scaffold] nav')].find((nav) => nav.checkVisibility() && Math.round(nav.getBoundingClientRect().bottom) >= window.innerHeight - 1)"; + +it('places the scaffold\'s FAB at the bottom-end corner, clear of the navigation bar and of a snackbar', function () { + $page = scaffoldFabPage(599) + ->assertScript(layoutStyle(SCAFFOLD_FAB, 'position')." === 'fixed'") + ->assertScript(SCAFFOLD_BAR.' !== undefined') + ->assertScript('window.innerWidth - '.layoutRect(SCAFFOLD_FAB, 'right').' === 16') + // 16px above the bar's top edge. + ->assertScript('Math.round('.SCAFFOLD_BAR.'.getBoundingClientRect().top) - '.layoutRect(SCAFFOLD_FAB, 'bottom').' === 16'); + + $page->script("window.eval(\"materialToast('Draft saved', { sticky: true })\")"); + + $page->wait(1) + ->assertScript("document.documentElement.style.getPropertyValue('--material-snackbar-height') !== ''") + // Lifted above the snackbar, which sits 16px above the bar, by M3's 16px margin. + ->assertScript("Math.round(document.querySelector('[x-data=\"materialSnackbar\"] > div').getBoundingClientRect().top) - ".layoutRect(SCAFFOLD_FAB, 'bottom').' === 16') + ->assertScript(layoutRect(SCAFFOLD_FAB, 'bottom')." <= Math.round(document.querySelector('[x-data=\"materialSnackbar\"] > div').getBoundingClientRect().top)"); + + scaffoldFabPage(600) + ->assertScript('window.innerWidth - '.layoutRect(SCAFFOLD_FAB, 'right').' === 24') + ->assertScript('window.innerHeight - '.layoutRect(SCAFFOLD_FAB, 'bottom').' === 24') + ->assertNoJavaScriptErrors(); +}); + +it('draws no second margin for a pane or a canonical layout inside the scaffold\'s content region', function () { + $body = <<<'BLADE' + +

    Body

    +
    + BLADE; + + // The content region's own 24px beside the 96px collapsed rail, and nothing more. + layoutPage($body, 700) + ->assertScript(layoutRect('#text', 'left').' - '.layoutRect('main', 'left').' === 24') + ->assertScript(layoutStyle('#pane [data-md-pane-body]', 'paddingLeft')." === '0px'"); +}); diff --git a/tests/Browser/NavigationTest.php b/tests/Browser/NavigationTest.php index a732e103..417d8b54 100644 --- a/tests/Browser/NavigationTest.php +++ b/tests/Browser/NavigationTest.php @@ -39,7 +39,7 @@ function firstPaintProbe(): void @livewireStyles - 'Inbox', 'icon' => 'inbox', 'url' => '/rail-probe', 'active' => true], ['title' => 'Sent', 'icon' => 'send', 'url' => '/rail-probe'], ]"> @@ -51,7 +51,7 @@ function firstPaintProbe(): void };

    The page

    -
    +
    @livewireScripts diff --git a/tests/Feature/Components/NavigationBarTest.php b/tests/Feature/Components/NavigationBarTest.php index 43003011..29632ac8 100644 --- a/tests/Feature/Components/NavigationBarTest.php +++ b/tests/Feature/Components/NavigationBarTest.php @@ -94,7 +94,7 @@ it('hides on a scroll down and springs back on a scroll up', function () { // The spatial spring, which reduced motion zeroes along with every other duration token. ->toMatch('/\[data-navigation-bar\]\[data-hide-on-scroll\] \{\s+transition: translate var\(--md-sys-motion-spatial-default-duration\) var\(--md-sys-motion-spatial-default\);/') ->toMatch('/\[data-navigation-bar\]\[data-hide-on-scroll\]\[data-hidden\] \{\s+translate: 0 100%;/') - // Unlayered, or the utility publishes the offset with would win. + // Unlayered, or the utility publishes the offset with would win. ->toContain('[data-app-shell]:has([data-navigation-bar][data-hide-on-scroll][data-hidden]) {') ->and(Str::of(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))->after('[data-app-shell]:has(')->toString()) ->not->toContain('@layer'); diff --git a/tests/Feature/Components/AppShellTest.php b/tests/Feature/Components/ScaffoldTest.php similarity index 59% rename from tests/Feature/Components/AppShellTest.php rename to tests/Feature/Components/ScaffoldTest.php index ea309bfc..745c6fbb 100644 --- a/tests/Feature/Components/AppShellTest.php +++ b/tests/Feature/Components/ScaffoldTest.php @@ -5,7 +5,7 @@ use Illuminate\Support\Facades\Route; use Livewire\Component; use Livewire\Livewire; -function shellDestinations(): array +function scaffoldDestinations(): array { return [ ['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'badge' => 4], @@ -16,7 +16,7 @@ function shellDestinations(): array } it('draws the adaptive rail, the bar, the content region and the snackbar host', function () { - $html = (string) $this->blade('The page', ['destinations' => shellDestinations()]); + $html = (string) $this->blade('The page', ['destinations' => scaffoldDestinations()]); expect($html) ->toContain('data-navigation-rail="adaptive"') @@ -30,7 +30,7 @@ it('draws the adaptive rail, the bar, the content region and the snackbar host', }); it('puts every destination in the rail and only those marked for the bar in the bar', function () { - $html = (string) $this->blade('', ['destinations' => shellDestinations()]); + $html = (string) $this->blade('', ['destinations' => scaffoldDestinations()]); $rail = substr($html, strpos($html, 'data-navigation-rail='), strpos($html, 'data-app-shell-bar') - strpos($html, 'data-navigation-rail=')); $bar = substr($html, strpos($html, 'data-app-shell-bar')); @@ -45,7 +45,7 @@ it('puts every destination in the rail and only those marked for the bar in the }); it('speaks a destination\'s badge in its own words when it has them', function () { - $html = (string) $this->blade('', ['destinations' => [ + $html = (string) $this->blade('', ['destinations' => [ ['title' => 'Get started', 'icon' => 'rocket_launch', 'url' => '/start', 'badge' => '0/3', 'badgeLabel' => '0 of 3 done'], ['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'badge' => 4], ]]); @@ -55,7 +55,7 @@ it('speaks a destination\'s badge in its own words when it has them', function ( }); it('marks the destination at the current URL when none says it is active', function () { - Route::get('/shell-probe/inbox', fn () => Blade::render('', ['destinations' => [ + Route::get('/shell-probe/inbox', fn () => Blade::render('', ['destinations' => [ ['title' => 'Inbox', 'icon' => 'inbox', 'url' => url('/shell-probe/inbox')], ['title' => 'Sent', 'icon' => 'send', 'url' => url('/shell-probe/sent')], ]])); @@ -67,7 +67,7 @@ it('marks the destination at the current URL when none says it is active', funct }); it('keeps the destination at the page\'s URL current while a Livewire component on it updates', function () { - Livewire::component('app-shell-probe', new class extends Component + Livewire::component('scaffold-probe', new class extends Component { public string $page = ''; @@ -78,11 +78,11 @@ it('keeps the destination at the page\'s URL current while a Livewire component public function render(): string { - return '
    '; + return '
    '; } }); - $probe = Livewire::test('app-shell-probe'); + $probe = Livewire::test('scaffold-probe'); expect(substr_count($probe->html(), 'aria-current="page"'))->toBe(2) ->and(substr_count($probe->call('$refresh')->html(), 'aria-current="page"'))->toBe(2) @@ -90,42 +90,42 @@ it('keeps the destination at the page\'s URL current while a Livewire component }); it('lifts the snackbar above the bar only when there is a bar', function () { - expect((string) $this->blade('', ['destinations' => shellDestinations()])) + expect((string) $this->blade('', ['destinations' => scaffoldDestinations()])) ->toContain('max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]') - ->and((string) $this->blade('', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]])) + ->and((string) $this->blade('', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]])) ->not->toContain('data-app-shell-bar') ->not->toContain('--material-bottom-bar:'); }); it('passes M3\'s tall bar through, and the bottom offset grows with it', function () { - expect((string) $this->blade('', ['destinations' => shellDestinations()])) + expect((string) $this->blade('', ['destinations' => scaffoldDestinations()])) ->toMatch('/data-navigation-bar\s+data-tall\s/') ->toContain('max-medium:[--material-bottom-bar:calc(5rem+') ->not->toContain('[--material-bottom-bar:calc(4rem+') - ->and((string) $this->blade('', ['destinations' => shellDestinations()])) + ->and((string) $this->blade('', ['destinations' => scaffoldDestinations()])) ->not->toContain('data-tall') ->toContain('max-medium:[--material-bottom-bar:calc(4rem+'); }); it('lets the bar leave the window while the page scrolls down', function () { - expect((string) $this->blade('', ['destinations' => shellDestinations()])) + expect((string) $this->blade('', ['destinations' => scaffoldDestinations()])) ->toContain('data-hide-on-scroll') ->toContain('x-data="materialNavigationBar"') - ->and((string) $this->blade('', ['destinations' => shellDestinations()])) + ->and((string) $this->blade('', ['destinations' => scaffoldDestinations()])) ->not->toContain('data-hide-on-scroll'); }); it('exposes the rail that hides when collapsed', function () { - expect((string) $this->blade('', ['destinations' => shellDestinations()])) + expect((string) $this->blade('', ['destinations' => scaffoldDestinations()])) ->toContain('data-hide-when-collapsed') ->toContain("materialNavigationRail('adaptive', true)") - ->and((string) $this->blade('', ['destinations' => shellDestinations()])) + ->and((string) $this->blade('', ['destinations' => scaffoldDestinations()])) ->not->toContain('data-hide-when-collapsed') ->toContain("materialNavigationRail('adaptive', false)"); }); it('reads the safe area and anything docked on the bar through variables an application can set', function () { - $html = (string) $this->blade('', ['destinations' => shellDestinations()]); + $html = (string) $this->blade('', ['destinations' => scaffoldDestinations()]); expect($html) ->toContain('+var(--material-bottom-extra,0px))]') @@ -135,7 +135,7 @@ it('reads the safe area and anything docked on the bar through variables an appl it('places each slot once', function () { $html = (string) $this->blade(<<<'BLADE' - +
    WINDOW BAR
    BRAND FAB @@ -143,8 +143,8 @@ it('places each slot once', function () { ACTIONS
    APP BAR
    PAGE -
    - BLADE, ['destinations' => shellDestinations()]); +
    + BLADE, ['destinations' => scaffoldDestinations()]); foreach (['WINDOW BAR', 'BRAND', 'FAB', 'FOOTER', 'ACTIONS', 'APP BAR', 'PAGE'] as $slot) { expect(substr_count($html, $slot))->toBe(1); @@ -155,20 +155,56 @@ it('places each slot once', function () { it('spans the window with a banner, above the rail, and renders none without the slot', function () { $html = (string) $this->blade(<<<'BLADE' - +
    WINDOW BAR
    PAGE -
    - BLADE, ['destinations' => shellDestinations()]); +
    + BLADE, ['destinations' => scaffoldDestinations()]); // Bars, then rails, then panes: the banner is outside the row the rail and the page share. expect($html) ->toMatch('/data-app-shell-banner.*WINDOW BAR.*data-navigation-rail=.*
    and((string) $this->blade('', ['destinations' => shellDestinations()])) + ->and((string) $this->blade('', ['destinations' => scaffoldDestinations()])) ->not->toContain('data-app-shell-banner'); }); -it('serves the showcase\'s app shell pages', function () { +it('marks the scaffold, and draws the FAB after the page\'s bar and before the page', function () { + $html = (string) $this->blade(<<<'BLADE' + +
    APP BAR
    + + PAGE +
    + BLADE, ['destinations' => scaffoldDestinations()]); + + // M3 puts a FAB early in the focus order: after the bar, before the content. + expect($html)->toMatch('/^\s*toMatch('/APP BAR.*