Rename the app shell to the scaffold and give it a FAB slot
Plan step 35: <x-app-shell> becomes <x-scaffold>, 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
acd6ba21ae
commit
0013e0154d
@@ -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
|
||||
|
||||
|
||||
+4
-3
@@ -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.
|
||||
- `<x-app-shell>` is a column with a nested row and gains a `banner` slot; `<x-navigation-rail>`'s
|
||||
`header` slot takes one `<x-fab label icon>` that morphs (replace the two-FAB
|
||||
`rail-collapsed:` swap); new `divider` and `fill` props.
|
||||
- `<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
|
||||
`<x-fab label icon>` that morphs (replace the two-FAB `rail-collapsed:` swap); new `divider`
|
||||
and `fill` props.
|
||||
- `<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.
|
||||
- A text field stops at 40rem wide from `medium:`, as M3 bounds fields on wider windows; a
|
||||
|
||||
@@ -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. `<x-app-shell>` does this; content lives in panes (`<x-drawer pane>`), 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. `<x-scaffold>` does this; content lives in panes (`<x-drawer pane>`), 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-*`).
|
||||
|
||||
|
||||
@@ -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 `<html>` 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 `<html>` by `<x-toast>` while one shows and removed when it goes. `<x-button fab>` reads it, so the FAB sits above the snackbar rather than under it, as M3 requires; a placed `<x-fab>` does the same by wrapping it in `<div class="fixed end-4 bottom-[calc(1rem+var(--material-snackbar-height,0px))] large:end-6">`.
|
||||
`--material-snackbar-height` is the height of the snackbar on screen, written on `<html>` by `<x-toast>` while one shows and removed when it goes. `<x-button fab>` reads it, so the FAB sits above the snackbar rather than under it, as M3 requires; so does an `<x-fab>` in `<x-scaffold>`'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 `<x-app-shell>` (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 `<x-scaffold>` (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
|
||||
|
||||
### `<x-fab>`
|
||||
|
||||
`<x-fab icon="add" tooltip="New share" />` — `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 (`<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">` — 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).
|
||||
`<x-fab icon="add" tooltip="New share" />` — `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 `<x-scaffold>`'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` (`<x-fab icon="edit" label="Compose" collapse-on-scroll />`) 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 `<x-surface>`, a new edge, draws it again.
|
||||
|
||||
#### `<x-scaffold>`
|
||||
|
||||
The adaptive scaffold, a whole layout's body: one navigation per M3 breakpoint, the page as `<main id="content" wire:transition.navigate>` behind a skip link, the page's FAB, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`. It was `<x-app-shell>` 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
|
||||
<x-scaffold :destinations="[
|
||||
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares.index'), 'active' => request()->routeIs('shares.*'), 'badge' => $expiringCount],
|
||||
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
|
||||
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
|
||||
]">
|
||||
<x-slot:brand><a href="{{ route('home') }}" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
|
||||
<x-slot:rail-footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
|
||||
</x-slot:rail-footer>
|
||||
<x-slot:top>
|
||||
{{-- the page's app bar; its menu button opens the modal rail on a phone --}}
|
||||
<span class="medium:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>
|
||||
</x-slot:top>
|
||||
<x-slot:fab>
|
||||
<x-fab icon="add" tooltip="New share" link="{{ route('upload') }}" />
|
||||
</x-slot:fab>
|
||||
|
||||
{{ $slot }}
|
||||
</x-scaffold>
|
||||
```
|
||||
|
||||
- `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 `<x-fab>` 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 `<x-scaffold>`), 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.
|
||||
|
||||
#### `<x-pane>`
|
||||
|
||||
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.
|
||||
|
||||
### `<x-app-shell>`
|
||||
|
||||
The adaptive app shell, a whole layout's body: one navigation per M3 window size class, the page as `<main id="content" wire:transition.navigate>` behind a skip link, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`.
|
||||
|
||||
| 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
|
||||
<x-app-shell :destinations="[
|
||||
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares.index'), 'active' => request()->routeIs('shares.*'), 'badge' => $expiringCount],
|
||||
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
|
||||
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
|
||||
]">
|
||||
<x-slot:brand><a href="{{ route('home') }}" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
|
||||
<x-slot:rail-header>
|
||||
<x-fab label="New share" icon="add" link="{{ route('upload') }}" />
|
||||
</x-slot:rail-header>
|
||||
<x-slot:rail-footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
|
||||
</x-slot:rail-footer>
|
||||
<x-slot:top>
|
||||
{{-- the page's app bar; its menu button opens the modal rail on a phone --}}
|
||||
<span class="medium:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>
|
||||
</x-slot:top>
|
||||
|
||||
{{ $slot }}
|
||||
</x-app-shell>
|
||||
```
|
||||
|
||||
- `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 `<x-app-shell>`), 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`: `<x-drawer pane>` 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.
|
||||
|
||||
### `<x-navigation-bar>`, `<x-navigation-bar-item>`
|
||||
|
||||
M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`<x-app-shell>` does):
|
||||
M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`<x-scaffold>` does):
|
||||
|
||||
```blade
|
||||
<div class="fixed inset-x-0 bottom-0 z-30 medium:hidden">
|
||||
@@ -955,7 +955,7 @@ M3 Expressive's flexible navigation bar, for three to five destinations. It does
|
||||
</div>
|
||||
```
|
||||
|
||||
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. `<x-navigation-bar>`: `label` ("Main"), `tall` (M3's 80px container, which keeps the icon over the label at every width — `<x-app-shell tall-bar>` 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. `<x-app-shell hide-bar-on-scroll>` 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). `<x-navigation-bar-item>`: `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. `<x-navigation-bar>`: `label` ("Main"), `tall` (M3's 80px container, which keeps the icon over the label at every width — `<x-scaffold tall-bar>` 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. `<x-scaffold hide-bar-on-scroll>` 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). `<x-navigation-bar-item>`: `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").
|
||||
|
||||
### `<x-navigation-rail>`, `<x-navigation-rail-item>`, `<x-navigation-rail-section>`
|
||||
|
||||
@@ -981,7 +981,7 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (
|
||||
</div>
|
||||
```
|
||||
|
||||
- `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` (`<x-app-shell>`'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` (`<x-scaffold>`'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 `<x-fab label icon>`, 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: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. 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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
- `<x-app-shell>` implements the navigation column; `<x-drawer pane>` 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.
|
||||
- `<x-scaffold>` implements the navigation column; `<x-drawer pane>` 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.
|
||||
|
||||
@@ -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 <x-app-shell> publishes, a FAB put
|
||||
* component into what an application wrote — the bottom offset <x-scaffold> 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
|
||||
<x-app-shell>'s `banner` slot holds, if the application made it sticky. The rail sticks
|
||||
<x-scaffold>'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: <x-app-shell> publishes the variable
|
||||
* it down and come back up with the bar. Unlayered on purpose: <x-scaffold> 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));
|
||||
|
||||
@@ -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
|
||||
* <x-app-shell> publishes as --material-bottom-bar, exactly as the FAB and the snackbar do —
|
||||
* <x-scaffold> 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
|
||||
|
||||
@@ -14,3 +14,4 @@
|
||||
@import './layout/list-detail.css';
|
||||
@import './layout/supporting-pane.css';
|
||||
@import './layout/feed.css';
|
||||
@import './layout/scaffold.css';
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* <x-scaffold>: 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. <x-toast> 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 `<x-app-shell>`
|
||||
web gets to M3's "never hide it while a screen reader is active". Inside `<x-scaffold>`
|
||||
`--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. `<x-app-shell>` does both, and lifts the snackbar and a
|
||||
and hides it where a rail takes over. `<x-scaffold>` 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;
|
||||
|
||||
@@ -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 `<x-app-shell>` uses, one rail per M3 window size class: on a compact
|
||||
- `adaptive` — what `<x-scaffold>` 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)
|
||||
|
||||
+33
-12
@@ -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 `<x-app-shell>`.
|
||||
|
||||
<x-app-shell :destinations="[
|
||||
<x-scaffold :destinations="[
|
||||
['title' => '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],
|
||||
]">
|
||||
<x-slot:brand><a href="/" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
|
||||
<x-slot:top>…the page's app bar…</x-slot:top>
|
||||
<x-slot:fab><x-fab icon="add" tooltip="New share" /></x-slot:fab>
|
||||
|
||||
…the page…
|
||||
</x-app-shell>
|
||||
</x-scaffold>
|
||||
|
||||
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`, <x-theme-script>), 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`: `<x-drawer pane>` is the second one, 360dp
|
||||
wide, in an `expanded:flex expanded:items-start expanded:gap-6` row inside the page.
|
||||
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
|
||||
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 `<x-app-shell>` — so the rail sticks under it instead
|
||||
— `style="--material-banner: 4rem"` on `<x-scaffold>` — so the rail sticks under it instead
|
||||
of behind it.
|
||||
|
||||
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
|
||||
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
|
||||
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) 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 `<x-fab>` 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 @@
|
||||
|
||||
<div
|
||||
data-app-shell
|
||||
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,
|
||||
@@ -176,6 +193,10 @@
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
{{ $top ?? '' }}
|
||||
|
||||
@isset($fab)
|
||||
<div data-md-scaffold-fab>{{ $fab }}</div>
|
||||
@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)">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
@@ -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. <html data-rail-auto> rides with it and says nothing was stored — the value is only
|
||||
`rail.default`, not a choice — so `<x-app-shell>`'s adaptive rail can start collapsed in the
|
||||
`rail.default`, not a choice — so `<x-scaffold>`'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.
|
||||
|
||||
|
||||
@@ -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 `<x-app-shell>`'s bar.
|
||||
buried under `<x-scaffold>`'s bar.
|
||||
|
||||
Put `<x-button icon="…" tooltip="…" />` controls in the slot (`:selected` for toggles). It is a
|
||||
`role="toolbar"`: the arrow keys move between its controls (resources/js/toolbar.js,
|
||||
|
||||
@@ -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
|
||||
</head>
|
||||
<body class="bg-surface font-sans text-on-surface antialiased">
|
||||
<x-livewire-material::app-shell :destinations="$destinations" label="Showcase" rail-width="17rem">
|
||||
<x-livewire-material::scaffold :destinations="$destinations" label="Showcase" rail-width="17rem">
|
||||
<x-slot:brand>
|
||||
<a href="{{ route('livewire-material.showcase') }}" wire:navigate class="block truncate rounded-corner-xs type-title-lg focus-ring">Livewire Material</a>
|
||||
</x-slot:brand>
|
||||
@@ -152,7 +152,7 @@
|
||||
</x-slot:top>
|
||||
|
||||
@yield('content')
|
||||
</x-livewire-material::app-shell>
|
||||
</x-livewire-material::scaffold>
|
||||
|
||||
@livewireScripts
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
</div>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><x-app-shell></code> does all of this: resize this showcase and watch the bar, the rail and the
|
||||
<code><x-scaffold></code> does all of this: resize this showcase and watch the bar, the rail and the
|
||||
content margin (<code>--material-margin</code>) change at 600, 840 and 1200px. A visitor who has chosen a rail
|
||||
width keeps it; until then the rail follows the class.
|
||||
</p>
|
||||
@@ -113,6 +113,7 @@
|
||||
</p>
|
||||
|
||||
<x-livewire-material::stack as="ul" gap="space100" class="md-type-body-md">
|
||||
<li><code><x-scaffold></code> — the bars, the rail and the FAB around the page (this showcase is one).</li>
|
||||
<li><code><x-pane></code> — a content region with M3's margins and its own app bar.</li>
|
||||
<li><code><x-list-detail></code> — a list and the detail of its selection: one pane below 840px, two from it.</li>
|
||||
<li><code><x-supporting-pane></code> — a focus pane and a pane beside it from 840px, below it or a bottom sheet before.</li>
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
</x-navigation-rail>
|
||||
|
||||
<div class="min-w-0 flex-1 bg-surface-container-low p-6 type-body-md text-on-surface-variant">
|
||||
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, <code><x-app-shell></code>'s from <code>expanded</code> (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, <code><x-scaffold></code>'s from <code>expanded</code> (840px) too.
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
@@ -164,8 +164,8 @@
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><x-navigation-bar></code> and <code><x-navigation-bar-item></code>, <code><x-navigation-rail></code>, <code><x-navigation-rail-item></code> and <code><x-navigation-rail-section></code>,
|
||||
and <code><x-app-shell></code>, which puts them together on M3's window size classes: a bar below <code>medium</code> (600px), a collapsed rail that opens as a modal through <code>medium</code>, a standard rail from <code>expanded</code> (840px) and an expanded one from <code>large</code> (1200px).
|
||||
<a href="{{ route('livewire-material.shell') }}" class="link text-primary">Open the app shell</a> and change the window's width.
|
||||
and <code><x-scaffold></code>, which puts them together on M3's window size classes: a bar below <code>medium</code> (600px), a collapsed rail that opens as a modal through <code>medium</code>, a standard rail from <code>expanded</code> (840px) and an expanded one from <code>large</code> (1200px).
|
||||
<a href="{{ route('livewire-material.shell') }}" class="link text-primary">Open the scaffold</a> and change the window's width.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
|
||||
@@ -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 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="robots" content="noindex" />
|
||||
|
||||
<title>{{ $current['title'] }} · App shell · Livewire Material</title>
|
||||
<title>{{ $current['title'] }} · Scaffold · Livewire Material</title>
|
||||
|
||||
<x-livewire-material::theme-script />
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface font-sans text-on-surface antialiased">
|
||||
<x-livewire-material::app-shell :destinations="$destinations">
|
||||
<x-livewire-material::scaffold :destinations="$destinations">
|
||||
<x-slot:brand>
|
||||
<a href="{{ route('livewire-material.showcase') }}" class="block truncate rounded-corner-xs type-title-lg focus-ring">Livewire Material</a>
|
||||
</x-slot:brand>
|
||||
@@ -79,7 +79,7 @@
|
||||
<x-livewire-material::button label="Back to the showcase" link="{{ route('livewire-material.section', 'navigation') }}" no-wire-navigate />
|
||||
</div>
|
||||
</div>
|
||||
</x-livewire-material::app-shell>
|
||||
</x-livewire-material::scaffold>
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
|
||||
@@ -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.'],
|
||||
];
|
||||
|
||||
@@ -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'
|
||||
<x-scaffold :destinations="[
|
||||
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'active' => true],
|
||||
['title' => 'Sent', 'icon' => 'send', 'url' => '/sent'],
|
||||
['title' => 'Drafts', 'icon' => 'draft', 'url' => '/drafts'],
|
||||
]">
|
||||
<x-slot:fab><x-fab icon="edit" tooltip="Compose" /></x-slot:fab>
|
||||
<p style="block-size: 2000px">The page</p>
|
||||
</x-scaffold>
|
||||
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'
|
||||
<x-scaffold :destinations="[['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'active' => true]]">
|
||||
<x-pane id="pane"><p id="text">Body</p></x-pane>
|
||||
</x-scaffold>
|
||||
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'");
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ function firstPaintProbe(): void
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body>
|
||||
<x-app-shell :destinations="[
|
||||
<x-scaffold :destinations="[
|
||||
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/rail-probe', 'active' => true],
|
||||
['title' => 'Sent', 'icon' => 'send', 'url' => '/rail-probe'],
|
||||
]">
|
||||
@@ -51,7 +51,7 @@ function firstPaintProbe(): void
|
||||
};
|
||||
</script>
|
||||
<p>The page</p>
|
||||
</x-app-shell>
|
||||
</x-scaffold>
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 <x-app-shell> publishes the offset with would win.
|
||||
// Unlayered, or the utility <x-scaffold> 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');
|
||||
|
||||
+61
-25
@@ -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('<x-app-shell :destinations="$destinations">The page</x-app-shell>', ['destinations' => shellDestinations()]);
|
||||
$html = (string) $this->blade('<x-scaffold :destinations="$destinations">The page</x-scaffold>', ['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('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]);
|
||||
$html = (string) $this->blade('<x-scaffold :destinations="$destinations" />', ['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('<x-app-shell :destinations="$destinations" />', ['destinations' => [
|
||||
$html = (string) $this->blade('<x-scaffold :destinations="$destinations" />', ['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('<x-app-shell :destinations="$destinations" />', ['destinations' => [
|
||||
Route::get('/shell-probe/inbox', fn () => Blade::render('<x-scaffold :destinations="$destinations" />', ['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 '<div><x-app-shell :destinations="[[\'title\' => \'Inbox\', \'icon\' => \'inbox\', \'url\' => $page], [\'title\' => \'Sent\', \'icon\' => \'send\', \'url\' => \'/sent\']]" /></div>';
|
||||
return '<div><x-scaffold :destinations="[[\'title\' => \'Inbox\', \'icon\' => \'inbox\', \'url\' => $page], [\'title\' => \'Sent\', \'icon\' => \'send\', \'url\' => \'/sent\']]" /></div>';
|
||||
}
|
||||
});
|
||||
|
||||
$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('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
|
||||
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))]')
|
||||
->and((string) $this->blade('<x-app-shell :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('--material-bottom-bar:');
|
||||
});
|
||||
|
||||
it('passes M3\'s tall bar through, and the bottom offset grows with it', function () {
|
||||
expect((string) $this->blade('<x-app-shell :destinations="$destinations" tall-bar />', ['destinations' => shellDestinations()]))
|
||||
expect((string) $this->blade('<x-scaffold :destinations="$destinations" tall-bar />', ['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('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
|
||||
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['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('<x-app-shell :destinations="$destinations" hide-bar-on-scroll />', ['destinations' => shellDestinations()]))
|
||||
expect((string) $this->blade('<x-scaffold :destinations="$destinations" hide-bar-on-scroll />', ['destinations' => scaffoldDestinations()]))
|
||||
->toContain('data-hide-on-scroll')
|
||||
->toContain('x-data="materialNavigationBar"')
|
||||
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
|
||||
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
|
||||
->not->toContain('data-hide-on-scroll');
|
||||
});
|
||||
|
||||
it('exposes the rail that hides when collapsed', function () {
|
||||
expect((string) $this->blade('<x-app-shell :destinations="$destinations" hide-rail-when-collapsed />', ['destinations' => shellDestinations()]))
|
||||
expect((string) $this->blade('<x-scaffold :destinations="$destinations" hide-rail-when-collapsed />', ['destinations' => scaffoldDestinations()]))
|
||||
->toContain('data-hide-when-collapsed')
|
||||
->toContain("materialNavigationRail('adaptive', true)")
|
||||
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
|
||||
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['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('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]);
|
||||
$html = (string) $this->blade('<x-scaffold :destinations="$destinations" />', ['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'
|
||||
<x-app-shell :destinations="$destinations">
|
||||
<x-scaffold :destinations="$destinations">
|
||||
<x-slot:banner><header>WINDOW BAR</header></x-slot:banner>
|
||||
<x-slot:brand><span>BRAND</span></x-slot:brand>
|
||||
<x-slot:rail-header><span>FAB</span></x-slot:rail-header>
|
||||
@@ -143,8 +143,8 @@ it('places each slot once', function () {
|
||||
<x-slot:actions><span>ACTIONS</span></x-slot:actions>
|
||||
<x-slot:top><header>APP BAR</header></x-slot:top>
|
||||
PAGE
|
||||
</x-app-shell>
|
||||
BLADE, ['destinations' => shellDestinations()]);
|
||||
</x-scaffold>
|
||||
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'
|
||||
<x-app-shell :destinations="$destinations">
|
||||
<x-scaffold :destinations="$destinations">
|
||||
<x-slot:banner><header>WINDOW BAR</header></x-slot:banner>
|
||||
PAGE
|
||||
</x-app-shell>
|
||||
BLADE, ['destinations' => shellDestinations()]);
|
||||
</x-scaffold>
|
||||
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=.*<main/s')
|
||||
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
|
||||
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['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'
|
||||
<x-scaffold :destinations="$destinations">
|
||||
<x-slot:top><header>APP BAR</header></x-slot:top>
|
||||
<x-slot:fab><button>NEW</button></x-slot:fab>
|
||||
PAGE
|
||||
</x-scaffold>
|
||||
BLADE, ['destinations' => scaffoldDestinations()]);
|
||||
|
||||
// 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/')
|
||||
->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((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
|
||||
->not->toContain('data-md-scaffold-fab');
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
expect($css)->toContain('@layer material.layout')
|
||||
->toContain('--md-scaffold-fab-margin: var(--md-sys-measurement-space200);')
|
||||
->toContain("@media (width >= 600px) {\n --md-scaffold-fab-margin: var(--md-sys-measurement-space300);")
|
||||
->toContain('max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom)))')
|
||||
->toContain('+ var(--material-snackbar-height, 0px)')
|
||||
->toContain('+ min(var(--material-snackbar-height, 0px), var(--md-sys-measurement-space200))')
|
||||
// The content region already has the window margin, so a pane inside draws none.
|
||||
->toContain("[data-md-scaffold] main {\n --md-layout-margin: 0px;")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/layout.css'))->toContain("@import './layout/scaffold.css';");
|
||||
});
|
||||
|
||||
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)
|
||||
->and(file_exists(__DIR__.'/../../../resources/views/components/app-shell.blade.php'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('serves the showcase\'s scaffold pages', function () {
|
||||
$html = $this->withoutVite()
|
||||
->get('/material/shell/starred')
|
||||
->assertOk()
|
||||
@@ -51,7 +51,7 @@ it('indexes every section, example and component for the search, each linking to
|
||||
expect($index->where('kind', 'Section')->pluck('title')->all())
|
||||
->toBe(collect(Sections::all())->pluck('title')->all())
|
||||
->and($index->where('kind', 'Component')->pluck('title')->all())
|
||||
->toContain('<x-datepicker>', '<x-button>', '<x-app-shell>')
|
||||
->toContain('<x-datepicker>', '<x-button>', '<x-scaffold>')
|
||||
->and($index->firstWhere('title', '<x-datepicker>')['url'])->toBe('/material/pickers');
|
||||
|
||||
$index->where('kind', 'Example')
|
||||
|
||||
Reference in New Issue
Block a user