Compare commits
2
Commits
f4be9848e2
...
8f174520eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f174520eb | ||
|
|
ab692b66bb |
@@ -34,6 +34,22 @@ return [
|
||||
'legacy_keys' => [],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Navigation rail
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Whether a collapsible navigation rail starts 'expanded' or 'collapsed'
|
||||
| until the visitor toggles it. The head script applies the choice before
|
||||
| the first paint, from localStorage under 'storage_key'.
|
||||
|
|
||||
*/
|
||||
|
||||
'rail' => [
|
||||
'default' => 'expanded',
|
||||
'storage_key' => 'material-rail',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fields
|
||||
@@ -75,6 +91,43 @@ return [
|
||||
|
||||
'node' => env('MATERIAL_NODE', 'node'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Scheme data
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The light and dark hexes `php artisan material:scheme` writes beside the
|
||||
| stylesheet. The mail theme reads its colours here, and so does an error
|
||||
| page when the build is missing; without the file both use the package's
|
||||
| default scheme.
|
||||
|
|
||||
*/
|
||||
|
||||
'scheme' => resource_path('css/material-scheme.json'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mail
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Markdown mail takes the theme when `mail.markdown.theme` (MAIL_MARKDOWN_THEME)
|
||||
| is 'livewire-material::mail.theme'. 'components' puts this package's mail
|
||||
| header and message after the application's own mail components — or
|
||||
| publish them with `vendor:publish --tag=livewire-material-mail` instead.
|
||||
| 'logo' replaces the app name in that header with an image: an absolute
|
||||
| 'src', with 'width' and 'height' in pixels, which Outlook sizes it by.
|
||||
|
|
||||
*/
|
||||
|
||||
'mail' => [
|
||||
'components' => (bool) env('MATERIAL_MAIL_COMPONENTS', false),
|
||||
'logo' => [
|
||||
'src' => null,
|
||||
'width' => null,
|
||||
'height' => null,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Showcase
|
||||
@@ -82,7 +135,8 @@ return [
|
||||
|
|
||||
| Every component in every variant, rendered in the application's own
|
||||
| scheme. Off unless the application runs locally. 'vite' names the entry
|
||||
| points that import this package's CSS and JavaScript.
|
||||
| points that import this package's CSS and JavaScript; the error pages
|
||||
| load them too, showcase or not.
|
||||
|
|
||||
*/
|
||||
|
||||
|
||||
@@ -524,6 +524,28 @@ worktrees. What changed from the step above:
|
||||
the theme script), content region with `wire:transition.navigate`, snackbar host. Nothing
|
||||
app-specific inside; apps pass destinations and extra chrome as slots.
|
||||
|
||||
**Phase 8 is done (2026-09-13).** What changed from the steps above:
|
||||
|
||||
- **App bars, toolbars, tabs, section nav, account menu and theme toggle were built in main; the
|
||||
navigation bar, rail and app shell by an agent in its own worktree.**
|
||||
- **A medium or large app bar collapses without script moving anything**: the bar is sticky at a
|
||||
negative top (its measured height less the 64px row, so a wrapped title still fits) and its row
|
||||
is sticky at 0 inside it; script only reports `scrolled` and `collapsed`.
|
||||
- **The tab indicator moves in a view transition** (`view-transition-name` from a per-tablist
|
||||
custom property, `view-transition-class` for the timing); every tab draws its own indicator, so it
|
||||
is right before Alpine starts.
|
||||
- **Livewire 4.4's `wire:navigate` gives `<html>` the next page's attributes and removes the rest**,
|
||||
which dropped `data-theme` on every in-app navigation. The head script saves the theme and rail
|
||||
attributes on `livewire:navigating` and puts them back in `onSwap`, before anything paints.
|
||||
- **The rail's collapsed state is applied before first paint** by the head script
|
||||
(`<html data-rail>`, `rail.default`, `rail.storage_key`) and read by a `rail-collapsed:` variant;
|
||||
`$store.rail` changes it. The shell draws no phone menu button: the app bar in `top` calls
|
||||
`$store.rail.show()`.
|
||||
- **Playwright's locators are strict**: `assertAttribute` on a selector matching two elements fails.
|
||||
- **Tailwind only compiles classes it can see**: a class written only in a test probe (`h-[200vh]`)
|
||||
does not exist in the Workbench build; use inline styles there.
|
||||
- **A Tailwind `@variant` nested under a pseudo-element or a `* +` selector compiles to broken CSS.**
|
||||
|
||||
### Phase 9 — Data, pages, mail
|
||||
|
||||
30. `table` (`.data-table`, descendant selectors, fine-pointer density, `position: relative`),
|
||||
@@ -535,6 +557,20 @@ worktrees. What changed from the step above:
|
||||
`material-scheme.json` (falling back to the default scheme); `html/header` and
|
||||
`html/message` overrides; typescale on bare tags; filled primary button.
|
||||
|
||||
**Steps 30–32 are done (2026-09-13).** Tables, sort headers and pagination were built in main; error
|
||||
pages and the mail theme by an agent in its own worktree. What changed from the steps above:
|
||||
|
||||
- **The paginators are prepended to the `pagination` and `livewire` view namespaces** rather than
|
||||
set as the default view, because Livewire sets its own default on every render; published
|
||||
`vendor/pagination` and `vendor/livewire` views still win (`livewire-material.pagination`).
|
||||
- **The error-view root is `resources/views/error-pages`** (inside `views`, so an application's
|
||||
`@source` line already covers its classes), appended to `view.paths` in `register()`, before the
|
||||
view finder is built. The layout is `errors::minimal`, so the framework's 401 and 402 use it too.
|
||||
Without a Vite build the pages fall back to an inline stylesheet from the scheme JSON. Testbench's
|
||||
skeleton ships its own `errors/503`, which wins in the Workbench.
|
||||
- **Mail is light only**: the CSS inliner strips `@media`. The header and message components are
|
||||
opt-in (`livewire-material.mail.components`) or published, because they change every Markdown mail.
|
||||
|
||||
### Phase 10 — `1.0.0`
|
||||
|
||||
33. Showcase complete (every component, variant, colour, size and state, both themes); the
|
||||
|
||||
@@ -7,4 +7,5 @@ This application uses `nonameweb/livewire-material`: Material 3 Expressive compo
|
||||
- Never write maryUI tags (`<x-mary-*>`) or daisyUI classes (`btn`, `card`, `badge`, `bg-base-200`, `text-base-content`…). They compile to nothing and fail silently.
|
||||
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` — never edit `resources/css/material-scheme.css` by hand.
|
||||
- While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase).
|
||||
- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`.
|
||||
@endverbatim
|
||||
|
||||
@@ -85,6 +85,59 @@ class Settings extends Component
|
||||
|
||||
The methods are protected. They dispatch a `toast` browser event (`assertDispatched('toast', type: 'success', title: 'Settings saved')` in tests).
|
||||
|
||||
## Error pages
|
||||
|
||||
Laravel's HTTP error pages — 403, 404, 419, 429, 500, 503, and the framework's own 401 and 402 — render in M3 without setup. The provider appends the package's error views to `view.paths` after the application's, so a file in `resources/views/errors/` always wins.
|
||||
|
||||
- The pages load `config('livewire-material.showcase.vite')` and `<x-theme-script />`, so they use the app's scheme, font and theme. While the build is missing (a deploy in progress) they fall back to an inline stylesheet coloured from `resources/css/material-scheme.json`.
|
||||
- `abort(403, 'Only the owner can open this share.')` and `abort(503, '…')` show the message as the sentence. Every other string goes through `__()`; translate them in `lang/{locale}.json`.
|
||||
- To change wording or design, run `php artisan vendor:publish --tag=livewire-material-errors`, which copies the layout and pages to `resources/views/errors`. A page extends `errors::minimal` and sets `title`, `code`, `headline`, `message`, `shape` (an `<x-shape>` name) and optionally `actions`:
|
||||
|
||||
```blade
|
||||
@extends('errors::minimal')
|
||||
|
||||
@section('title', __('Payment Required'))
|
||||
@section('code', '402')
|
||||
@section('shape', 'cookie-4')
|
||||
@section('headline', __('Your plan has ended'))
|
||||
@section('message', __('Choose a plan to keep using the app.'))
|
||||
|
||||
@section('actions')
|
||||
<x-button :link="route('billing')" :label="__('Choose a plan')" variant="filled" size="md" no-wire-navigate />
|
||||
@endsection
|
||||
```
|
||||
|
||||
- Maintenance mode: `php artisan down --render="errors::503"`.
|
||||
- The showcase previews each page at `/material/errors/{code}`.
|
||||
|
||||
## Mail
|
||||
|
||||
Markdown mail (notifications and `markdown:` mailables) wears M3 once the application selects the theme:
|
||||
|
||||
```dotenv
|
||||
MAIL_MARKDOWN_THEME=livewire-material::mail.theme
|
||||
```
|
||||
|
||||
or per mail: `(new MailMessage)->theme('livewire-material::mail.theme')`, or `public $theme = 'livewire-material::mail.theme';` on a mailable.
|
||||
|
||||
- Colours are the light scheme from `resources/css/material-scheme.json` (`livewire-material.scheme`), inlined as hexes; regenerate the scheme and mail follows. Without the file, the package's default scheme applies.
|
||||
- Write the body as Markdown; the theme styles the bare tags (`#` headings, prose, lists, tables) with M3's typescale. `<x-mail::button :url="…">` is a filled pill in `primary`; `color` also takes `secondary`, `tertiary`, `error`, `success`, `warning` and `info`. `<x-mail::panel>` is a tinted container.
|
||||
- There is no dark mail. Never put `@media` rules, CSS variables or `color-mix()` in mail CSS: the inliner strips media queries and mail clients resolve no variables.
|
||||
- The package's mail header (the app name, or a logo) and message (with a replaceable footer) are opt-in: set `MATERIAL_MAIL_COMPONENTS=true`, or `php artisan vendor:publish --tag=livewire-material-mail` to copy them into `resources/views/vendor/mail`. For a logo set `livewire-material.mail.logo` to `['src' => 'https://example.com/logo.png', 'width' => 160, 'height' => 40]`: an absolute URL, with the image at twice those dimensions.
|
||||
- With those components, a mail can replace the footer:
|
||||
|
||||
```blade
|
||||
<x-mail::message>
|
||||
Your export is ready.
|
||||
|
||||
<x-slot:footer>
|
||||
© {{ date('Y') }} {{ config('app.name') }} · [Unsubscribe]({{ $unsubscribeUrl }})
|
||||
</x-slot:footer>
|
||||
</x-mail::message>
|
||||
```
|
||||
|
||||
- The showcase renders a sample mail at `/material/mail`.
|
||||
|
||||
## Components
|
||||
|
||||
### `<x-icon>`
|
||||
@@ -535,6 +588,84 @@ M3 search bar that opens into a search view: docked under the bar from `sm`, ful
|
||||
|
||||
The docked view overlaps what is under it; never place a search inside an element with `overflow-hidden` (a card), which clips it.
|
||||
|
||||
### `<x-app-shell>`
|
||||
|
||||
The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a collapsed rail that opens as a modal to `lg`, an expanded rail the visitor can collapse from `lg`, 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>`.
|
||||
|
||||
```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>
|
||||
<span class="rail-collapsed:hidden"><x-fab label="New share" icon="add" link="{{ route('upload') }}" /></span>
|
||||
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="add" tooltip-right="New share" link="{{ route('upload') }}" /></span>
|
||||
</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="sm: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 current one), `badge` (`true` for a dot, or a count), `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: `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 app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
|
||||
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` 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 `sm`).
|
||||
- Below `sm` the shell sets `--material-bottom-bar`, so the snackbar and a `fab` button clear the bar; pad anything else you pin to the bottom with it.
|
||||
- The content region is `max-lg: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):
|
||||
|
||||
```blade
|
||||
<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
|
||||
<x-navigation-bar>
|
||||
<x-navigation-bar-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" :active="request()->routeIs('shares.*')" badge="3" />
|
||||
<x-navigation-bar-item label="Upload" icon="upload" link="{{ route('upload') }}" />
|
||||
</x-navigation-bar>
|
||||
</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"). `<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>`
|
||||
|
||||
M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (a 56px full-width pill, icon beside label, count at the end).
|
||||
|
||||
```blade
|
||||
<div class="flex min-h-dvh">
|
||||
<x-navigation-rail mode="collapsible">
|
||||
<x-slot:brand><span class="type-title-lg">SealShare</span></x-slot:brand>
|
||||
<x-slot:header><x-fab icon="add" tooltip-right="New share" /></x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" active badge="3" />
|
||||
<x-navigation-rail-section label="Admin">
|
||||
<x-navigation-rail-item label="Users" icon="group" link="{{ route('users') }}" />
|
||||
</x-navigation-rail-section>
|
||||
|
||||
<x-slot:footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" />
|
||||
</x-slot:footer>
|
||||
</x-navigation-rail>
|
||||
|
||||
<main class="min-w-0 flex-1">…</main>
|
||||
</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: hidden and opened as a modal below `sm`, collapsed and opened as a modal to `lg`, collapsible from `lg`).
|
||||
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`). Slots: `brand` (beside the menu button, expanded only), `header` (a FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport.
|
||||
- Anything 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.
|
||||
- `<x-navigation-rail-item>`: the same props as `<x-navigation-bar-item>`. `<x-navigation-rail-section label="…">`: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way.
|
||||
- `$store.rail`: `collapsed`, `toggle()`, `collapse()`, `expand()` (the remembered choice), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`). `config/livewire-material.php` → `rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`).
|
||||
|
||||
### `<x-app-bar>`
|
||||
|
||||
M3 Expressive top app bar, sticky by default (`:sticky="false"` to scroll away), turning surface-container once content scrolls under it. `variant`: `small` (default), `center`, `medium` and `large` (a big title that collapses into the row as the page scrolls — CSS sticky, no layout shift), `search` (put an `<x-search>` in the slot). Props: `title`, `subtitle`, `heading` (`h1` default). Slots: `navigation` (leading icon button), `actions` (trailing icon buttons, avatar).
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
/*
|
||||
* M3 Expressive navigation: the flexible navigation bar and the navigation rail — collapsed,
|
||||
* expanded and modal.
|
||||
*
|
||||
* Values from androidx Compose Material 3 (Apache-2.0) at androidx-main
|
||||
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326: tokens/NavigationBarTokens.kt,
|
||||
* NavigationBarVerticalItemTokens.kt, NavigationBarHorizontalItemTokens.kt,
|
||||
* NavigationRailCollapsedTokens.kt, NavigationRailExpandedTokens.kt,
|
||||
* NavigationRailBaselineItemTokens.kt, NavigationRailVerticalItemTokens.kt,
|
||||
* NavigationRailHorizontalItemTokens.kt, NavigationRailColorTokens.kt, and the layout in
|
||||
* ShortNavigationBar.kt, WideNavigationRail.kt and NavigationItem.kt.
|
||||
*
|
||||
* [data-navigation-bar] surface-container, 64px, the bottom safe area under it
|
||||
* [data-navigation-bar-items] equal widths; centred from a 600px-wide bar
|
||||
* [data-navigation-bar-item] data-active
|
||||
* [data-navigation-pill] icon and label; the indicator itself from 600px
|
||||
* [data-navigation-indicator] the 56×32 indicator around the icon below 600px
|
||||
* [data-navigation-label]
|
||||
*
|
||||
* [data-navigation-rail="collapsed|expanded|collapsible|modal|adaptive"] data-open
|
||||
* [data-navigation-rail-scrim] modal and adaptive rails
|
||||
* [data-navigation-rail-panel] the <nav>
|
||||
* [data-navigation-rail-header] menu button, brand, FAB — never scrolls
|
||||
* [data-navigation-rail-destinations] scrolls when the window is too short
|
||||
* [data-navigation-rail-section] a heading (expanded only) and its items
|
||||
* [data-navigation-rail-item] data-active; the full-width pill when expanded
|
||||
* [data-navigation-indicator] the 56×32 indicator when collapsed
|
||||
* [data-navigation-label]
|
||||
* [data-navigation-rail-footer] never scrolls
|
||||
*
|
||||
* `rail-collapsed:` matches a rail, and everything in it, while it is drawn collapsed — whatever
|
||||
* made it so: its mode, the visitor's choice on <html data-rail> (set before the first paint by
|
||||
* <x-theme-script>), or a window under `lg` for the adaptive rail. A rail item is written once
|
||||
* and takes both shapes from it; so can anything an application puts in a rail
|
||||
* (`<span class="rail-collapsed:hidden">`).
|
||||
*/
|
||||
|
||||
@custom-variant rail-collapsed {
|
||||
&:where([data-navigation-rail='collapsed'], [data-navigation-rail='collapsed'] *) {
|
||||
@slot;
|
||||
}
|
||||
|
||||
&:where([data-rail='collapsed'] [data-navigation-rail='collapsible'], [data-rail='collapsed'] [data-navigation-rail='collapsible'] *) {
|
||||
@slot;
|
||||
}
|
||||
|
||||
&:where([data-navigation-rail='modal']:not([data-open]), [data-navigation-rail='modal']:not([data-open]) *) {
|
||||
@slot;
|
||||
}
|
||||
|
||||
@media (width < 64rem) {
|
||||
&:where([data-navigation-rail='adaptive']:not([data-open]), [data-navigation-rail='adaptive']:not([data-open]) *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 64rem) {
|
||||
&:where([data-rail='collapsed'] [data-navigation-rail='adaptive'], [data-rail='collapsed'] [data-navigation-rail='adaptive'] *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ---------------------------------------------------------------- the navigation bar */
|
||||
|
||||
[data-navigation-bar] {
|
||||
container-type: inline-size;
|
||||
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
[data-navigation-bar-items] {
|
||||
display: flex;
|
||||
min-height: 4rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
[data-navigation-bar-item] {
|
||||
--navigation-layer: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-block: 0.375rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
[data-navigation-bar-item] [data-navigation-pill] {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
font: var(--md-sys-typescale-label-md);
|
||||
letter-spacing: var(--md-sys-typescale-label-md-tracking);
|
||||
}
|
||||
|
||||
[data-navigation-bar-item] [data-navigation-label] {
|
||||
max-width: 100%;
|
||||
padding-inline: 0.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-navigation-bar-item][data-active] {
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
/* From 600dp (M3's medium window), icon and label side by side in a 40px indicator, and the
|
||||
items centred with the padding ShortNavigationBar's Centered arrangement computes. */
|
||||
@container (width >= 37.5rem) {
|
||||
[data-navigation-bar-items] {
|
||||
width: calc(10% * (var(--navigation-bar-count, 7) + 3));
|
||||
min-width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-navigation-bar-items]:has(> :last-child:nth-child(1)) { --navigation-bar-count: 1; }
|
||||
[data-navigation-bar-items]:has(> :last-child:nth-child(2)) { --navigation-bar-count: 2; }
|
||||
[data-navigation-bar-items]:has(> :last-child:nth-child(3)) { --navigation-bar-count: 3; }
|
||||
[data-navigation-bar-items]:has(> :last-child:nth-child(4)) { --navigation-bar-count: 4; }
|
||||
[data-navigation-bar-items]:has(> :last-child:nth-child(5)) { --navigation-bar-count: 5; }
|
||||
[data-navigation-bar-items]:has(> :last-child:nth-child(6)) { --navigation-bar-count: 6; }
|
||||
|
||||
[data-navigation-bar-item] {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
[data-navigation-bar-item] [data-navigation-pill] {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
flex-direction: row;
|
||||
height: 2.5rem;
|
||||
padding-inline: 1rem;
|
||||
font: var(--md-sys-typescale-label-lg);
|
||||
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
|
||||
}
|
||||
|
||||
[data-navigation-bar-item] [data-navigation-label] {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
[data-navigation-bar-item][data-active] {
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
[data-navigation-bar-item][data-active] [data-navigation-pill] {
|
||||
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- the navigation rail */
|
||||
|
||||
[data-navigation-rail] {
|
||||
--navigation-rail-expanded-width: clamp(13.75rem, var(--navigation-rail-width, 16rem), 22.5rem);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: var(--navigation-rail-expanded-width);
|
||||
transition: width var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
|
||||
|
||||
@variant rail-collapsed {
|
||||
width: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* A modal rail keeps its collapsed width in the layout while it is open over it, as Compose's
|
||||
ModalWideNavigationRail does; the adaptive rail does below lg, and takes no room below sm. */
|
||||
[data-navigation-rail='modal'] {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
@media (width < 64rem) {
|
||||
[data-navigation-rail='adaptive'] {
|
||||
width: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 40rem) {
|
||||
[data-navigation-rail='adaptive'] {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-panel] {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100dvh;
|
||||
/* Clip, not hide: no scroll container, so the destinations below can still scroll and
|
||||
nothing sticky breaks. What only an expanded rail draws — a label, the brand — is drawn
|
||||
at once when the rail expands, while the width is still growing; the clip keeps it
|
||||
from spilling over the page for those frames. */
|
||||
overflow-x: clip;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
background-color: var(--md-sys-color-surface);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
transition:
|
||||
width var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast),
|
||||
background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
|
||||
}
|
||||
|
||||
/* Open: expanded over a scrim, in surface-container with a large corner at its inner edge. */
|
||||
[data-navigation-rail][data-open] > [data-navigation-rail-panel] {
|
||||
position: fixed;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
z-index: 50;
|
||||
width: var(--navigation-rail-expanded-width);
|
||||
max-width: calc(100vw - 3.5rem);
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
border-start-end-radius: var(--md-sys-shape-corner-lg);
|
||||
border-end-end-radius: var(--md-sys-shape-corner-lg);
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
box-shadow: var(--md-sys-elevation-2);
|
||||
}
|
||||
|
||||
/* Below sm there is no collapsed rail to grow out of: the open rail slides in from the edge,
|
||||
on emphasized decelerate rather than a spring, which would overshoot and open a gap. */
|
||||
@media (width < 40rem) {
|
||||
[data-navigation-rail='adaptive'] > [data-navigation-rail-panel] {
|
||||
position: fixed;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
z-index: 50;
|
||||
display: none;
|
||||
width: var(--navigation-rail-expanded-width);
|
||||
max-width: calc(100vw - 3.5rem);
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
border-start-end-radius: var(--md-sys-shape-corner-lg);
|
||||
border-end-end-radius: var(--md-sys-shape-corner-lg);
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
box-shadow: var(--md-sys-elevation-2);
|
||||
translate: -100% 0;
|
||||
transition:
|
||||
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
|
||||
display var(--md-sys-motion-effects-default-duration) allow-discrete;
|
||||
|
||||
&:dir(rtl) {
|
||||
translate: 100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail='adaptive'][data-open] > [data-navigation-rail-panel] {
|
||||
display: flex;
|
||||
translate: 0 0;
|
||||
transition:
|
||||
translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate),
|
||||
display var(--md-sys-motion-spatial-default-duration) allow-discrete;
|
||||
|
||||
@starting-style {
|
||||
translate: -100% 0;
|
||||
}
|
||||
|
||||
&:dir(rtl) {
|
||||
translate: 0 0;
|
||||
|
||||
@starting-style {
|
||||
translate: 100% 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-scrim] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: none;
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default),
|
||||
display var(--md-sys-motion-effects-default-duration) allow-discrete;
|
||||
}
|
||||
|
||||
[data-navigation-rail][data-open] > [data-navigation-rail-scrim] {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 44px above the header (TopSpace), 40px under it (HeaderSpaceMinimum) — 32 here and 8 as
|
||||
the destinations' own padding, which keeps the first item's focus ring inside the scroller. */
|
||||
[data-navigation-rail-header] {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding-top: calc(env(safe-area-inset-top) + 2.75rem);
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
[data-navigation-rail-panel] > [data-navigation-rail-destinations]:first-child {
|
||||
padding-top: calc(env(safe-area-inset-top) + 2.75rem);
|
||||
}
|
||||
|
||||
[data-navigation-rail-destinations] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-block: 0.5rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
[data-navigation-rail-destinations],
|
||||
[data-navigation-rail-section],
|
||||
[data-navigation-rail-footer] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@variant rail-collapsed {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-footer] {
|
||||
flex-shrink: 0;
|
||||
padding-block: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
[data-navigation-rail-heading] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 3rem;
|
||||
padding-inline: 2.25rem 1.25rem;
|
||||
overflow: hidden;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-title-sm);
|
||||
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@variant rail-collapsed {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-section]:not(:first-child) {
|
||||
@variant rail-collapsed {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Expanded: a 56px full-width pill, icon and label 8px apart, label-large. */
|
||||
[data-navigation-rail-item] {
|
||||
--navigation-layer: 0;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
height: 3.5rem;
|
||||
margin-inline: 1.25rem;
|
||||
padding-inline: 1rem;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-label-lg);
|
||||
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
|
||||
text-align: start;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
/* Collapsed: the icon in its 56×32 indicator over a label-medium label, 64px tall. */
|
||||
@variant rail-collapsed {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
height: auto;
|
||||
min-height: 4rem;
|
||||
margin-inline: 0;
|
||||
padding: 0.375rem 0.25rem;
|
||||
border-radius: 0;
|
||||
font: var(--md-sys-typescale-label-md);
|
||||
letter-spacing: var(--md-sys-typescale-label-md-tracking);
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-item] [data-navigation-label] {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@variant rail-collapsed {
|
||||
flex: none;
|
||||
display: -webkit-box;
|
||||
max-width: 100%;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-item][data-active] {
|
||||
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
|
||||
@variant rail-collapsed {
|
||||
background-image: none;
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-item] [data-navigation-indicator] {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
|
||||
@variant rail-collapsed {
|
||||
width: 3.5rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- both: indicator, states */
|
||||
|
||||
[data-navigation-bar-item] [data-navigation-indicator] {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
}
|
||||
|
||||
[data-navigation-bar-item][data-active] [data-navigation-indicator] {
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
@container (width < 37.5rem) {
|
||||
[data-navigation-bar-item][data-active] [data-navigation-indicator] {
|
||||
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-item][data-active] [data-navigation-indicator] {
|
||||
@variant rail-collapsed {
|
||||
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
}
|
||||
|
||||
/* The active indicator is a secondary-container fill painted as a background image, so it can
|
||||
grow out of its centre without stretching the icon: when a page arrives through
|
||||
wire:navigate, resources/js/navigation.js starts it at zero width for a moment, and it
|
||||
springs open on the default spatial spring, as Compose's indicator does when the selection
|
||||
changes (NavigationItem.kt). A full page load draws it at once; reduced motion zeroes it. */
|
||||
[data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill]),
|
||||
[data-navigation-rail-item],
|
||||
[data-navigation-rail-item] [data-navigation-indicator] {
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
transition: background-size var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
|
||||
}
|
||||
|
||||
/* The state layer covers only the indicator: the pill when the indicator holds the label
|
||||
too, the 56×32 shape when it holds the icon alone. Hover only where a pointer can hover. */
|
||||
@media (hover: hover) {
|
||||
:is([data-navigation-bar-item], [data-navigation-rail-item]):hover {
|
||||
--navigation-layer: 0.08;
|
||||
}
|
||||
}
|
||||
|
||||
:is([data-navigation-bar-item], [data-navigation-rail-item]):is(:focus-visible, :active) {
|
||||
--navigation-layer: 0.1;
|
||||
}
|
||||
|
||||
[data-navigation-bar-item] [data-navigation-indicator]::before,
|
||||
[data-navigation-bar-item] [data-navigation-pill]::before,
|
||||
[data-navigation-rail-item] [data-navigation-indicator]::before,
|
||||
[data-navigation-rail-item]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background-color: var(--md-sys-color-on-surface);
|
||||
opacity: var(--navigation-layer);
|
||||
pointer-events: none;
|
||||
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
@container (width < 37.5rem) {
|
||||
[data-navigation-bar-item] [data-navigation-pill]::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width >= 37.5rem) {
|
||||
[data-navigation-bar-item] [data-navigation-indicator]::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The rail item's layer is on the item while expanded and on its indicator while collapsed. A
|
||||
variant cannot follow a pseudo-element in a selector, so the item hands the layer on through
|
||||
variables; each rule below outweighs its line in the list above. */
|
||||
[data-navigation-rail-item] {
|
||||
--navigation-item-layer: var(--navigation-layer);
|
||||
--navigation-indicator-layer: 0;
|
||||
|
||||
@variant rail-collapsed {
|
||||
--navigation-item-layer: 0;
|
||||
--navigation-indicator-layer: var(--navigation-layer);
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-item][data-navigation-rail-item]::before {
|
||||
background-color: var(--md-sys-color-on-secondary-container);
|
||||
opacity: var(--navigation-item-layer);
|
||||
}
|
||||
|
||||
[data-navigation-rail-item][data-navigation-rail-item] [data-navigation-indicator]::before {
|
||||
background-color: var(--md-sys-color-on-secondary-container);
|
||||
opacity: var(--navigation-indicator-layer);
|
||||
}
|
||||
|
||||
/* M3's focus indicator, 3px of secondary 2px out, around the same shape. */
|
||||
@container (width < 37.5rem) {
|
||||
[data-navigation-bar-item]:focus-visible [data-navigation-indicator] {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width >= 37.5rem) {
|
||||
[data-navigation-bar-item]:focus-visible [data-navigation-pill] {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-item]:focus-visible {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
|
||||
@variant rail-collapsed {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-item]:focus-visible [data-navigation-indicator] {
|
||||
@variant rail-collapsed {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
@import './components/timepicker.css';
|
||||
@import './components/tabs.css';
|
||||
@import './components/app-bar.css';
|
||||
@import './components/navigation.css';
|
||||
@import './components/toolbar.css';
|
||||
@import './components/table.css';
|
||||
|
||||
|
||||
@@ -25,4 +25,5 @@ import './timepicker.js'
|
||||
import './slider.js'
|
||||
import './tabs.js'
|
||||
import './app-bar.js'
|
||||
import './navigation.js'
|
||||
import './toolbar.js'
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Navigation: the rail's state, shared by every rail and menu button on the page.
|
||||
*
|
||||
* `$store.rail.collapsed` is the visitor's choice for a collapsible rail, remembered in
|
||||
* localStorage. <x-theme-script> has already applied it before the first paint as
|
||||
* <html data-rail="expanded|collapsed">, which is what the stylesheet keys on (the
|
||||
* `rail-collapsed:` variant); the store starts from that attribute and writes it back.
|
||||
*
|
||||
* `$store.rail.open` is the modal rail: on a window too narrow for an expanded rail, a menu
|
||||
* button opens it over a scrim (`show()`), and Escape, the scrim or leaving the page closes it
|
||||
* (`hide()`). It is never remembered.
|
||||
*
|
||||
* `materialNavigationRail` is one rail's view of the store for its `mode` — see
|
||||
* resources/views/components/navigation-rail.blade.php.
|
||||
*/
|
||||
const WIDE = '(min-width: 64rem)'
|
||||
|
||||
/*
|
||||
* The active indicator grows out of its centre when a page arrives through wire:navigate. The
|
||||
* new page's indicator is new markup, so the only way to animate it is a starting style — and
|
||||
* only while a navigation swaps the page in, or every full load would animate it too. The sheet
|
||||
* is adopted as the navigation starts and dropped two frames after it ends; the transition itself
|
||||
* is resources/css/components/navigation.css.
|
||||
*/
|
||||
const arriving = new CSSStyleSheet()
|
||||
|
||||
arriving.replaceSync(`@starting-style {
|
||||
:is([data-navigation-bar-item], [data-navigation-rail-item])[data-active],
|
||||
:is([data-navigation-bar-item], [data-navigation-rail-item])[data-active] :is([data-navigation-indicator], [data-navigation-pill]) {
|
||||
background-size: 0% 100%;
|
||||
}
|
||||
}`)
|
||||
|
||||
document.addEventListener('livewire:navigating', () => {
|
||||
if (!document.adoptedStyleSheets.includes(arriving)) {
|
||||
document.adoptedStyleSheets = [...document.adoptedStyleSheets, arriving]
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('livewire:navigated', () => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
document.adoptedStyleSheets = document.adoptedStyleSheets.filter((sheet) => sheet !== arriving)
|
||||
}))
|
||||
})
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
const root = document.documentElement
|
||||
|
||||
window.Alpine.store('rail', {
|
||||
collapsed: root.dataset.rail === 'collapsed',
|
||||
open: false,
|
||||
|
||||
toggle() {
|
||||
this.set(!this.collapsed)
|
||||
},
|
||||
|
||||
collapse() {
|
||||
this.set(true)
|
||||
},
|
||||
|
||||
expand() {
|
||||
this.set(false)
|
||||
},
|
||||
|
||||
set(collapsed) {
|
||||
this.collapsed = collapsed
|
||||
root.dataset.rail = collapsed ? 'collapsed' : 'expanded'
|
||||
|
||||
try {
|
||||
localStorage.setItem(root.dataset.railKey || 'material-rail', root.dataset.rail)
|
||||
} catch {
|
||||
// Blocked storage: the rail still toggles, it just will not remember.
|
||||
}
|
||||
},
|
||||
|
||||
show() {
|
||||
this.open = true
|
||||
},
|
||||
|
||||
hide() {
|
||||
this.open = false
|
||||
},
|
||||
})
|
||||
|
||||
// A destination chosen in the modal rail leaves the page; the next one starts with it shut.
|
||||
document.addEventListener('livewire:navigating', () => window.Alpine.store('rail').hide())
|
||||
|
||||
window.Alpine.data('materialNavigationRail', (mode) => ({
|
||||
wide: mode === 'adaptive' ? window.matchMedia(WIDE).matches : false,
|
||||
query: null,
|
||||
onWidth: null,
|
||||
|
||||
init() {
|
||||
if (mode !== 'adaptive') {
|
||||
return
|
||||
}
|
||||
|
||||
// From lg the adaptive rail is a standard, collapsible rail: a modal left open while
|
||||
// the window widens is shut, or its focus trap would hold a page that has no scrim.
|
||||
this.query = window.matchMedia(WIDE)
|
||||
this.onWidth = (event) => {
|
||||
this.wide = event.matches
|
||||
|
||||
if (event.matches) {
|
||||
this.$store.rail.hide()
|
||||
}
|
||||
}
|
||||
this.query.addEventListener('change', this.onWidth)
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.query?.removeEventListener('change', this.onWidth)
|
||||
},
|
||||
|
||||
/** Whether this rail expands over a scrim rather than in the layout. */
|
||||
get modal() {
|
||||
return mode === 'modal' || (mode === 'adaptive' && !this.wide)
|
||||
},
|
||||
|
||||
get open() {
|
||||
return this.modal && this.$store.rail.open
|
||||
},
|
||||
|
||||
get expanded() {
|
||||
if (this.open || mode === 'expanded') {
|
||||
return true
|
||||
}
|
||||
|
||||
return (mode === 'collapsible' || (mode === 'adaptive' && this.wide)) && !this.$store.rail.collapsed
|
||||
},
|
||||
|
||||
/** The rail's own menu button: open or close the modal, or collapse and expand in place. */
|
||||
menu() {
|
||||
if (this.modal) {
|
||||
this.$store.rail.open ? this.$store.rail.hide() : this.$store.rail.show()
|
||||
} else {
|
||||
this.$store.rail.toggle()
|
||||
}
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
{{-- The adaptive app shell: navigation that changes shape with the window, around the page.
|
||||
|
||||
<x-app-shell :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>
|
||||
|
||||
…the page…
|
||||
</x-app-shell>
|
||||
|
||||
- Below `sm`: a navigation bar with the destinations marked `bar`, pinned to the bottom.
|
||||
Everything else is in the modal rail, which slides in when something calls
|
||||
`$store.rail.show()` — put a menu button in the app bar for it, hidden from `sm`:
|
||||
`<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>`.
|
||||
- `sm` to `lg`: the collapsed rail, whose menu button opens it expanded, as a modal.
|
||||
- From `lg`: the expanded rail, collapsed and expanded again by its menu button; the choice is
|
||||
remembered and applied before the first paint (`$store.rail`, <x-theme-script>).
|
||||
|
||||
`destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally
|
||||
`active` (by default: the URL is the current one), `badge` (`true` for a dot, or a count),
|
||||
`section` (a heading the destination is grouped under in the rail; only an expanded rail shows
|
||||
it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
|
||||
`navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||
|
||||
Slots, each rendered once: `brand` (beside the rail's menu button while it is expanded),
|
||||
`rail-header` (under it: a FAB — see `<x-navigation-rail>` for its two shapes), `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 app
|
||||
bar, above the page at every width) 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.
|
||||
|
||||
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;
|
||||
below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`.
|
||||
|
||||
`max-lg: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
|
||||
`lg` only, so a wide window never clips what overhangs on purpose.
|
||||
|
||||
Nothing application-specific belongs in here: an app's destinations and chrome come in through
|
||||
the props and slots. --}}
|
||||
|
||||
@props([
|
||||
'destinations' => [],
|
||||
'label' => null,
|
||||
'railWidth' => '16rem',
|
||||
])
|
||||
|
||||
@php
|
||||
$label ??= __('Main');
|
||||
$current = request()->url();
|
||||
|
||||
$items = collect($destinations)
|
||||
->filter(fn ($item): bool => is_array($item) && filled($item['title'] ?? null))
|
||||
->map(fn (array $item): array => [
|
||||
'title' => (string) $item['title'],
|
||||
'icon' => $item['icon'] ?? null,
|
||||
'url' => $item['url'] ?? null,
|
||||
'active' => (bool) ($item['active'] ?? (filled($item['url'] ?? null) && rtrim(url($item['url']), '/') === rtrim($current, '/'))),
|
||||
'badge' => $item['badge'] ?? null,
|
||||
'section' => filled($item['section'] ?? null) ? (string) $item['section'] : null,
|
||||
'bar' => ($item['bar'] ?? true) !== false,
|
||||
'navigate' => ($item['navigate'] ?? true) !== false,
|
||||
])
|
||||
->values();
|
||||
|
||||
$barItems = $items->where('bar', true)->values();
|
||||
|
||||
// Consecutive destinations under the same heading form one group, in the order given.
|
||||
$groups = $items->chunkWhile(fn (array $item, int $key, $chunk): bool => $item['section'] === $chunk->last()['section']);
|
||||
@endphp
|
||||
|
||||
<div
|
||||
data-app-shell
|
||||
@class([
|
||||
'min-h-dvh bg-surface text-on-surface sm:flex',
|
||||
'max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]' => $barItems->isNotEmpty(),
|
||||
])
|
||||
>
|
||||
<a
|
||||
href="#content"
|
||||
data-skip-link
|
||||
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(env(safe-area-inset-top)+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
|
||||
>{{ __('Skip to content') }}</a>
|
||||
|
||||
<x-navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
|
||||
@isset($brand)
|
||||
<x-slot:brand>{{ $brand }}</x-slot:brand>
|
||||
@endisset
|
||||
|
||||
@isset($railHeader)
|
||||
<x-slot:header>{{ $railHeader }}</x-slot:header>
|
||||
@endisset
|
||||
|
||||
@foreach ($groups as $group)
|
||||
@if ($group->first()['section'] !== null)
|
||||
<x-navigation-rail-section :label="$group->first()['section']">
|
||||
@foreach ($group as $item)
|
||||
<x-navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
||||
@endforeach
|
||||
</x-navigation-rail-section>
|
||||
@else
|
||||
@foreach ($group as $item)
|
||||
<x-navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if (isset($railFooter) || isset($actions))
|
||||
<x-slot:footer>
|
||||
{{ $railFooter ?? '' }}
|
||||
|
||||
@isset($actions)
|
||||
<div data-app-shell-actions class="flex items-center gap-1 px-5 pt-2 rail-collapsed:flex-col">
|
||||
{{ $actions }}
|
||||
</div>
|
||||
@endisset
|
||||
</x-slot:footer>
|
||||
@endif
|
||||
</x-navigation-rail>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
{{ $top ?? '' }}
|
||||
|
||||
<main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 outline-none max-lg:overflow-x-clip max-sm:pb-(--material-bottom-bar)">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@if ($barItems->isNotEmpty())
|
||||
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
|
||||
<x-navigation-bar :label="$label">
|
||||
@foreach ($barItems as $item)
|
||||
<x-navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
||||
@endforeach
|
||||
</x-navigation-bar>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<x-toast />
|
||||
</div>
|
||||
@@ -0,0 +1,69 @@
|
||||
{{-- One destination in an `<x-navigation-bar>`.
|
||||
|
||||
<x-navigation-bar-item label="Inbox" icon="inbox" link="{{ route('inbox') }}" :active="request()->routeIs('inbox')" badge="12" />
|
||||
|
||||
`link` renders an anchor with `wire:navigate` (unless `external` or `no-wire-navigate`);
|
||||
without one it is a button, for a destination a Livewire action switches to. `active` marks
|
||||
the current destination: `aria-current="page"`, the filled icon in on-secondary-container on
|
||||
the secondary-container indicator, and the label in secondary (on-secondary-container inside
|
||||
the medium bar's pill).
|
||||
|
||||
`badge` puts M3's badge on the icon: `true` for the small dot, a number or a few characters
|
||||
for the large one (999+ at most). Screen readers hear a count after the label (", 12");
|
||||
`badge-label` replaces it with words ("12 unread") and gives a dot something to say.
|
||||
|
||||
Values from NavigationBarTokens.kt and NavigationBarVerticalItemTokens.kt (androidx Compose
|
||||
Material 3, Apache-2.0); see `<x-navigation-bar>`. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'icon' => null,
|
||||
'link' => null,
|
||||
'external' => false,
|
||||
'noWireNavigate' => false,
|
||||
'active' => false,
|
||||
'badge' => null,
|
||||
'badgeLabel' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$isLink = filled($link);
|
||||
$tag = $isLink ? 'a' : 'button';
|
||||
$dot = $badge === true;
|
||||
$count = ! $dot && $badge !== null && $badge !== false && $badge !== '';
|
||||
$spoken = $badgeLabel ?? ($count ? $badge : null);
|
||||
|
||||
$attributes = $attributes->merge(array_filter([
|
||||
'href' => $isLink ? $link : null,
|
||||
'target' => $isLink && $external ? '_blank' : null,
|
||||
'rel' => $isLink && $external ? 'noopener' : null,
|
||||
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
|
||||
'type' => $isLink ? null : 'button',
|
||||
'aria-current' => $active ? 'page' : null,
|
||||
'data-active' => $active ? true : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
@endphp
|
||||
|
||||
<{{ $tag }} data-navigation-bar-item {{ $attributes }}>
|
||||
<span data-navigation-pill>
|
||||
<span data-navigation-indicator>
|
||||
<span class="relative inline-flex">
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" :filled="$active" class="size-6" />
|
||||
@endif
|
||||
|
||||
@if ($dot)
|
||||
<x-badge floating />
|
||||
@elseif ($count)
|
||||
<x-badge :value="$badge" max="999" floating />
|
||||
@endif
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span data-navigation-label>{{ $label ?? $slot }}</span>
|
||||
</span>
|
||||
|
||||
@if ($spoken !== null)
|
||||
<span class="sr-only">, {{ $spoken }}</span>
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
@@ -0,0 +1,40 @@
|
||||
{{-- M3 Expressive's flexible navigation bar: three to five destinations at the bottom of a
|
||||
compact window.
|
||||
|
||||
<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
|
||||
<x-navigation-bar>
|
||||
<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
|
||||
<x-navigation-bar-item label="Starred" icon="star" link="/starred" />
|
||||
<x-navigation-bar-item label="Sent" icon="send" link="/sent" />
|
||||
</x-navigation-bar>
|
||||
</div>
|
||||
|
||||
The short bar, 64px tall in surface-container, with the bottom safe area added under it. On a
|
||||
bar narrower than 600px (M3's compact window) each item is the icon in a 56×32 indicator over a
|
||||
label-medium label, and the items share the width equally. From 600px (medium) icon and label
|
||||
sit side by side in a 40px pill and the items gather in the middle, with the padding Compose's
|
||||
Centered arrangement gives three to six items. Both follow the bar's own width (a container
|
||||
query), so a bar in a narrow column keeps the compact items.
|
||||
|
||||
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
|
||||
`fab` button above it through `--material-bottom-bar`.
|
||||
|
||||
`label` names the landmark ("Main" by default).
|
||||
|
||||
Values from androidx Compose Material 3 (Apache-2.0), androidx-main
|
||||
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationBarTokens.kt,
|
||||
NavigationBarVerticalItemTokens.kt, NavigationBarHorizontalItemTokens.kt and
|
||||
ShortNavigationBar.kt, all under
|
||||
https://github.com/androidx/androidx/tree/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3
|
||||
— the styles are resources/css/components/navigation.css. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
])
|
||||
|
||||
<nav aria-label="{{ $label ?? __('Main') }}" data-navigation-bar {{ $attributes }}>
|
||||
<div data-navigation-bar-items>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,74 @@
|
||||
{{-- One destination in an `<x-navigation-rail>`, drawn in whichever shape the rail has.
|
||||
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="{{ route('inbox') }}" :active="request()->routeIs('inbox')" badge="12" />
|
||||
|
||||
Expanded, it is a 56px full-width pill: the icon, the label-large label beside it and a count
|
||||
at its end. Collapsed, the icon sits in a 56×32 indicator over a label-medium label (two lines
|
||||
at most), with the count on the icon. The current destination (`active`) is
|
||||
`aria-current="page"` with the filled icon on secondary-container, its label in secondary when
|
||||
collapsed.
|
||||
|
||||
`link` renders an anchor with `wire:navigate` (unless `external` or `no-wire-navigate`);
|
||||
without one it is a button. `badge`: `true` for M3's small dot on the icon, a number or a few
|
||||
characters for the large badge (999+ at most). Screen readers hear a count after the label
|
||||
(", 12"); `badge-label` replaces it with words ("12 unread") and gives a dot something to say.
|
||||
|
||||
Values from NavigationRailVerticalItemTokens.kt, NavigationRailHorizontalItemTokens.kt,
|
||||
NavigationRailBaselineItemTokens.kt and NavigationRailColorTokens.kt (androidx Compose
|
||||
Material 3, Apache-2.0); see `<x-navigation-rail>`. Compose's expanded item hugs its label; the
|
||||
package draws the full-width pill, which leaves room for the count at the end. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'icon' => null,
|
||||
'link' => null,
|
||||
'external' => false,
|
||||
'noWireNavigate' => false,
|
||||
'active' => false,
|
||||
'badge' => null,
|
||||
'badgeLabel' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$isLink = filled($link);
|
||||
$tag = $isLink ? 'a' : 'button';
|
||||
$dot = $badge === true;
|
||||
$count = ! $dot && $badge !== null && $badge !== false && $badge !== '';
|
||||
$spoken = $badgeLabel ?? ($count ? $badge : null);
|
||||
|
||||
$attributes = $attributes->merge(array_filter([
|
||||
'href' => $isLink ? $link : null,
|
||||
'target' => $isLink && $external ? '_blank' : null,
|
||||
'rel' => $isLink && $external ? 'noopener' : null,
|
||||
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
|
||||
'type' => $isLink ? null : 'button',
|
||||
'aria-current' => $active ? 'page' : null,
|
||||
'data-active' => $active ? true : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
@endphp
|
||||
|
||||
<{{ $tag }} data-navigation-rail-item {{ $attributes }}>
|
||||
<span data-navigation-indicator>
|
||||
<span class="relative inline-flex">
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" :filled="$active" class="size-6" />
|
||||
@endif
|
||||
|
||||
@if ($dot)
|
||||
<x-badge floating />
|
||||
@elseif ($count)
|
||||
<span class="hidden rail-collapsed:contents"><x-badge :value="$badge" max="999" floating /></span>
|
||||
@endif
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span data-navigation-label>{{ $label ?? $slot }}</span>
|
||||
|
||||
@if ($count)
|
||||
<span class="flex shrink-0 rail-collapsed:hidden"><x-badge :value="$badge" max="999" /></span>
|
||||
@endif
|
||||
|
||||
@if ($spoken !== null)
|
||||
<span class="sr-only">, {{ $spoken }}</span>
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
@@ -0,0 +1,24 @@
|
||||
{{-- A group of destinations in an `<x-navigation-rail>` under a heading.
|
||||
|
||||
<x-navigation-rail-section label="Labels">
|
||||
<x-navigation-rail-item label="Travel" icon="label" link="/labels/travel" />
|
||||
<x-navigation-rail-item label="Receipts" icon="label" link="/labels/receipts" />
|
||||
</x-navigation-rail-section>
|
||||
|
||||
The heading (title-small, on-surface-variant, in line with the icons) shows only while the
|
||||
rail is expanded, as M3 draws section headers; collapsed, a little space sets the group apart.
|
||||
It names the group for screen readers either way. --}}
|
||||
|
||||
@props([
|
||||
'label',
|
||||
])
|
||||
|
||||
@php
|
||||
$headingId = 'navigation-section-'.substr(md5((string) $label), 0, 10);
|
||||
@endphp
|
||||
|
||||
<div role="group" aria-labelledby="{{ $headingId }}" data-navigation-rail-section {{ $attributes }}>
|
||||
<p id="{{ $headingId }}" data-navigation-rail-heading>{{ $label }}</p>
|
||||
|
||||
{{ $slot }}
|
||||
</div>
|
||||
@@ -0,0 +1,135 @@
|
||||
{{-- M3 Expressive's navigation rail: destinations down the start edge of a medium or wider
|
||||
window, collapsed (96px, icon over label) or expanded (icon beside label in a full-width pill).
|
||||
|
||||
<div class="flex min-h-dvh">
|
||||
<x-navigation-rail mode="collapsible">
|
||||
<x-slot:brand><span class="type-title-lg">Mail</span></x-slot:brand>
|
||||
<x-slot:header>
|
||||
<x-fab icon="edit" tooltip-right="Compose" />
|
||||
</x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
|
||||
<x-navigation-rail-section label="Labels">
|
||||
<x-navigation-rail-item label="Travel" icon="label" link="/labels/travel" />
|
||||
</x-navigation-rail-section>
|
||||
|
||||
<x-slot:footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="/settings" />
|
||||
</x-slot:footer>
|
||||
</x-navigation-rail>
|
||||
|
||||
<main class="min-w-0 flex-1">…</main>
|
||||
</div>
|
||||
|
||||
`mode` says what decides its width:
|
||||
- `collapsed` — always collapsed; `expanded` — always expanded.
|
||||
- `collapsible` (the default) — the visitor's choice: expanded until the menu button collapses
|
||||
it. The choice is `$store.rail`, remembered in localStorage and applied by <x-theme-script>
|
||||
before the first paint (<html data-rail>), so the rail never paints wide and snaps shut.
|
||||
- `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: below `sm` nothing until `$store.rail.show()` slides
|
||||
it in as a modal; from `sm` collapsed, opening as a modal; from `lg` collapsible.
|
||||
|
||||
Slots: `brand` beside the menu button, only while expanded; `header` under it — a FAB, drawn
|
||||
as an extended FAB when expanded (`rail-collapsed:` below); the destinations in the default
|
||||
slot, which alone scroll when the window is too short; `footer`, pinned to the foot. Header and
|
||||
footer never scroll, so nothing in them is cut off by the scroller's edge.
|
||||
|
||||
Anything inside can take both shapes with the `rail-collapsed:` variant, which applies while
|
||||
the rail is drawn collapsed for whatever reason:
|
||||
`<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>`
|
||||
`<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>`.
|
||||
Nothing that shows while collapsed may be wider than 96px.
|
||||
|
||||
Props: `label` names the landmark ("Main"); `width` is the expanded width (`16rem`, held
|
||||
between M3's 220 and 360dp); `menu` shows the menu button (by default for `collapsible`,
|
||||
`modal` and `adaptive`). The rail does not scroll with the page: in a flex row it sticks to
|
||||
the top of the viewport, as tall as the viewport at most.
|
||||
|
||||
Values from androidx Compose Material 3 (Apache-2.0), androidx-main
|
||||
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationRailCollapsedTokens.kt,
|
||||
NavigationRailExpandedTokens.kt, NavigationRailBaselineItemTokens.kt and
|
||||
WideNavigationRail.kt under
|
||||
https://github.com/androidx/androidx/tree/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3
|
||||
— surface (surface-container and elevation 2 with a large inner corner when modal), 44px above
|
||||
the header and 40px under it, 4px between collapsed items. The styles are
|
||||
resources/css/components/navigation.css; the behaviour resources/js/navigation.js. --}}
|
||||
|
||||
@props([
|
||||
'mode' => 'collapsible',
|
||||
'label' => null,
|
||||
'width' => '16rem',
|
||||
'menu' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$mode = in_array($mode, ['collapsed', 'expanded', 'collapsible', 'modal', 'adaptive'], true) ? $mode : 'collapsible';
|
||||
$interactive = in_array($mode, ['collapsible', 'modal', 'adaptive'], true);
|
||||
$canOpen = in_array($mode, ['modal', 'adaptive'], true);
|
||||
$menu ??= $interactive;
|
||||
$collapsedAtFirst = in_array($mode, ['collapsed', 'modal'], true);
|
||||
@endphp
|
||||
|
||||
<div
|
||||
data-navigation-rail="{{ $mode }}"
|
||||
@if ($interactive)
|
||||
x-data="materialNavigationRail('{{ $mode }}')"
|
||||
x-bind:data-open="open"
|
||||
@endif
|
||||
{{ $attributes->merge(['style' => "--navigation-rail-width: {$width}"]) }}
|
||||
>
|
||||
@if ($canOpen)
|
||||
<div data-navigation-rail-scrim aria-hidden="true" x-on:click="$store.rail.hide()"></div>
|
||||
@endif
|
||||
|
||||
<nav
|
||||
data-navigation-rail-panel
|
||||
aria-label="{{ $label ?? __('Main') }}"
|
||||
@if ($canOpen)
|
||||
x-trap.inert.noscroll="open"
|
||||
x-on:keydown.escape.window="open && $store.rail.hide()"
|
||||
@endif
|
||||
>
|
||||
@if ($menu || isset($brand) || isset($header))
|
||||
<div data-navigation-rail-header>
|
||||
@if ($menu || isset($brand))
|
||||
<div @class(['flex w-full min-w-0 items-center gap-3 pe-5', 'ps-7' => $menu, 'ps-5' => ! $menu])>
|
||||
@if ($menu)
|
||||
<button
|
||||
type="button"
|
||||
data-navigation-rail-menu
|
||||
aria-label="{{ $collapsedAtFirst ? __('Expand navigation') : __('Collapse navigation') }}"
|
||||
aria-expanded="{{ $collapsedAtFirst ? 'false' : 'true' }}"
|
||||
x-on:click="menu()"
|
||||
x-bind:aria-label="expanded ? @js(__('Collapse navigation')) : @js(__('Expand navigation'))"
|
||||
x-bind:aria-expanded="expanded.toString()"
|
||||
class="state-layer focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant after:absolute after:top-1/2 after:left-1/2 after:size-12 after:-translate-x-1/2 after:-translate-y-1/2"
|
||||
>
|
||||
<span class="contents rail-collapsed:hidden"><x-icon name="menu_open" class="size-6" /></span>
|
||||
<span class="hidden rail-collapsed:contents"><x-icon name="menu" class="size-6" /></span>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
@isset($brand)
|
||||
<div class="min-w-0 flex-1 rail-collapsed:hidden">{{ $brand }}</div>
|
||||
@endisset
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@isset($header)
|
||||
<div {{ $header->attributes->class(['flex w-full flex-col items-start gap-2 px-5']) }}>{{ $header }}</div>
|
||||
@endisset
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div data-navigation-rail-destinations>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@isset($footer)
|
||||
<div data-navigation-rail-footer {{ $footer->attributes }}>{{ $footer }}</div>
|
||||
@endisset
|
||||
</nav>
|
||||
</div>
|
||||
@@ -1,4 +1,5 @@
|
||||
{{-- The theme, decided before the first paint. Include it in <head>, ahead of @vite.
|
||||
{{-- The theme and the navigation rail's width, decided before the first paint. Include it in
|
||||
<head>, ahead of @vite.
|
||||
|
||||
It reads the visitor's choice from localStorage (`livewire-material.theme.storage_key`):
|
||||
`light`, `dark` or `system`, falling back to `theme.default`. `system` follows the
|
||||
@@ -10,15 +11,29 @@
|
||||
JSON-encoded (maryUI's `"dark"`) — is adopted once and removed. Nothing is written for a
|
||||
visitor who never chose, so changing `theme.default` later reaches them too.
|
||||
|
||||
<html> survives wire:navigate, so this only has to run on a full load. --}}
|
||||
The rail rides along for the theme's reason: <html data-rail> is `expanded` or `collapsed`
|
||||
(`livewire-material.rail.storage_key`, falling back to `rail.default`), and a collapsible
|
||||
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.
|
||||
|
||||
wire:navigate swaps the body, merges the head without running this again, and gives <html>
|
||||
the next page's attributes — which the server rendered without any of these, so Livewire
|
||||
removes them. They are put back as the new page is swapped in (`onSwap`, in the same task,
|
||||
before anything paints), so this only has to run on a full load. --}}
|
||||
|
||||
@php
|
||||
$theme = config('livewire-material.theme');
|
||||
$rail = config('livewire-material.rail');
|
||||
|
||||
$settings = [
|
||||
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
|
||||
'key' => $theme['storage_key'] ?? 'material-theme',
|
||||
'legacy' => array_values($theme['legacy_keys'] ?? []),
|
||||
'rail' => [
|
||||
'default' => ($rail['default'] ?? null) === 'collapsed' ? 'collapsed' : 'expanded',
|
||||
'key' => $rail['storage_key'] ?? 'material-rail',
|
||||
],
|
||||
];
|
||||
@endphp
|
||||
|
||||
@@ -28,8 +43,15 @@
|
||||
var media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
var valid = function (value) { return value === 'light' || value === 'dark' || value === 'system'; };
|
||||
var choice = settings.default;
|
||||
var rail = settings.rail.default;
|
||||
|
||||
try {
|
||||
var storedRail = localStorage.getItem(settings.rail.key);
|
||||
|
||||
if (storedRail === 'collapsed' || storedRail === 'expanded') {
|
||||
rail = storedRail;
|
||||
}
|
||||
|
||||
var stored = localStorage.getItem(settings.key);
|
||||
|
||||
if (valid(stored)) {
|
||||
@@ -58,8 +80,24 @@
|
||||
|
||||
root.setAttribute('data-theme-key', settings.key);
|
||||
root.setAttribute('data-theme-choice', choice);
|
||||
root.setAttribute('data-rail-key', settings.rail.key);
|
||||
root.setAttribute('data-rail', rail);
|
||||
apply();
|
||||
|
||||
media.addEventListener('change', apply);
|
||||
|
||||
document.addEventListener('livewire:navigating', function (event) {
|
||||
var kept = ['data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) {
|
||||
return [name, root.getAttribute(name)];
|
||||
});
|
||||
|
||||
event.detail.onSwap(function () {
|
||||
kept.forEach(function (attribute) {
|
||||
if (attribute[1] !== null) {
|
||||
root.setAttribute(attribute[0], attribute[1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
})(@json($settings));
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{{-- Forbidden. A message the app passed along (`abort(403, '…')`, a policy's `Response::deny('…')`)
|
||||
is the sentence, as in the framework's own page; otherwise a general one. --}}
|
||||
|
||||
@extends('errors::minimal')
|
||||
|
||||
@section('title', __('Forbidden'))
|
||||
@section('code', '403')
|
||||
@section('shape', 'gem')
|
||||
@section('headline', __('You don’t have access'))
|
||||
@section('message', isset($exception) && $exception->getMessage() !== '' ? __($exception->getMessage()) : __('Your account isn’t allowed to open this page.'))
|
||||
@@ -0,0 +1,9 @@
|
||||
{{-- Not found. --}}
|
||||
|
||||
@extends('errors::minimal')
|
||||
|
||||
@section('title', __('Not Found'))
|
||||
@section('code', '404')
|
||||
@section('shape', 'cookie-9')
|
||||
@section('headline', __('Page not found'))
|
||||
@section('message', __('The page you’re looking for doesn’t exist or has moved.'))
|
||||
@@ -0,0 +1,16 @@
|
||||
{{-- Page expired: the form's CSRF token outlived the session. Reloading the page the form was on
|
||||
issues a new one, so that is the action — a link to it, never a reload of this response,
|
||||
which would post the stale form again. --}}
|
||||
|
||||
@extends('errors::minimal')
|
||||
|
||||
@section('title', __('Page Expired'))
|
||||
@section('code', '419')
|
||||
@section('shape', 'clover-4')
|
||||
@section('headline', __('This page has expired'))
|
||||
@section('message', __('It was open for a while. Refresh it, then try again.'))
|
||||
|
||||
@section('actions')
|
||||
<x-button :link="url()->previous()" :label="__('Refresh the page')" variant="filled" size="md" no-wire-navigate />
|
||||
<x-button :link="url('/')" :label="__('Go home')" variant="text" size="md" no-wire-navigate />
|
||||
@endsection
|
||||
@@ -0,0 +1,9 @@
|
||||
{{-- Too many requests. --}}
|
||||
|
||||
@extends('errors::minimal')
|
||||
|
||||
@section('title', __('Too Many Requests'))
|
||||
@section('code', '429')
|
||||
@section('shape', 'cookie-12')
|
||||
@section('headline', __('Slow down a little'))
|
||||
@section('message', __('There were too many requests in a short time. Wait a moment, then try again.'))
|
||||
@@ -0,0 +1,9 @@
|
||||
{{-- Server error. Nothing from the exception is shown: its message is for the log, not the visitor. --}}
|
||||
|
||||
@extends('errors::minimal')
|
||||
|
||||
@section('title', __('Server Error'))
|
||||
@section('code', '500')
|
||||
@section('shape', 'soft-burst')
|
||||
@section('headline', __('Something went wrong'))
|
||||
@section('message', __('An error on our side stopped this page from loading. Please try again in a moment.'))
|
||||
@@ -0,0 +1,22 @@
|
||||
{{-- Service unavailable, usually maintenance mode. A message the app passed (`abort(503, '…')`) is
|
||||
the sentence; maintenance mode's own "Service Unavailable" is not a message, so it is not.
|
||||
|
||||
`php artisan down --render="errors::503"` renders this once, in the console, with no
|
||||
`$exception` and no request — so the one action reloads whatever address the visitor is
|
||||
on, in the browser, rather than a URL decided here. --}}
|
||||
|
||||
@extends('errors::minimal')
|
||||
|
||||
@php
|
||||
$reason = isset($exception) ? $exception->getMessage() : '';
|
||||
@endphp
|
||||
|
||||
@section('title', __('Service Unavailable'))
|
||||
@section('code', '503')
|
||||
@section('shape', 'puffy')
|
||||
@section('headline', __('We’ll be right back'))
|
||||
@section('message', $reason !== '' && $reason !== 'Service Unavailable' ? __($reason) : __('We’re making some improvements. Please check back soon.'))
|
||||
|
||||
@section('actions')
|
||||
<x-button :label="__('Try again')" variant="filled" size="md" onclick="location.reload()" />
|
||||
@endsection
|
||||
@@ -0,0 +1,83 @@
|
||||
{{-- The layout every error page extends: this package's 403, 404, 419, 429, 500 and 503, and the
|
||||
framework's own 401 and 402, which extend `errors::minimal` and find this file before the
|
||||
framework's (the provider appends this folder's parent to `view.paths`).
|
||||
|
||||
Sections — the framework layout's `title`, `code` and `message`, plus three of its own, so
|
||||
either layout renders the other's pages:
|
||||
`title` the browser tab, followed by the app name;
|
||||
`code` the status, drawn in display type over the shape;
|
||||
`headline` what happened, in a few words (without it, `message` is the headline);
|
||||
`message` one sentence on what to do about it;
|
||||
`shape` an M3 Expressive shape name (`cookie-7` by default), in primary-container;
|
||||
`actions` the buttons; by default a filled Home and, when the visitor came from a page on
|
||||
the way here, a text Back.
|
||||
|
||||
The app's own Vite entries (`livewire-material.showcase.vite`) bring its scheme, font and
|
||||
utilities. But an error page is also what shows while a deploy has no build yet, so when
|
||||
those tags cannot be made the page brings a small stylesheet of its own: the app's scheme
|
||||
from its scheme data, drawn onto the `data-error-*` hooks. Keep the hooks when changing
|
||||
the markup. The shape turns once a minute, unless the visitor asks for reduced motion. --}}
|
||||
|
||||
@php
|
||||
$assets = \NoNameWeb\LivewireMaterial\Support\ErrorPage::assets();
|
||||
$back = \NoNameWeb\LivewireMaterial\Support\ErrorPage::backUrl();
|
||||
@endphp
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex" />
|
||||
|
||||
<title>@yield('title') · {{ config('app.name') }}</title>
|
||||
|
||||
<x-theme-script />
|
||||
|
||||
@if ($assets !== null)
|
||||
{{ $assets }}
|
||||
@else
|
||||
<style data-error-fallback>{{ \NoNameWeb\LivewireMaterial\Support\ErrorPage::fallbackStyles() }}</style>
|
||||
@endif
|
||||
|
||||
<style>
|
||||
@keyframes material-error-turn { to { transform: rotate(1turn); } }
|
||||
@media (prefers-reduced-motion: no-preference) { [data-error-shape] { animation: material-error-turn 60s linear infinite; } }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-dvh bg-surface font-sans text-on-surface antialiased">
|
||||
<main data-error-page class="mx-auto flex min-h-dvh max-w-xl flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div data-error-art class="relative grid size-48 shrink-0 place-items-center sm:size-60">
|
||||
<div data-error-shape class="absolute inset-0">
|
||||
<x-shape :name="trim($__env->yieldContent('shape', 'cookie-7'))" class="size-full text-primary-container" />
|
||||
</div>
|
||||
|
||||
<p data-error-code class="relative type-emphasized-display-lg text-on-primary-container tabular-nums">@yield('code')</p>
|
||||
</div>
|
||||
|
||||
<h1 data-error-headline class="mt-10 type-headline-md text-balance sm:type-headline-lg">
|
||||
@hasSection('headline')
|
||||
@yield('headline')
|
||||
@else
|
||||
@yield('message')
|
||||
@endif
|
||||
</h1>
|
||||
|
||||
@hasSection('headline')
|
||||
<p data-error-message class="mt-3 max-w-md type-body-lg text-balance text-on-surface-variant">@yield('message')</p>
|
||||
@endif
|
||||
|
||||
<div data-error-actions class="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||
@hasSection('actions')
|
||||
@yield('actions')
|
||||
@else
|
||||
<x-button :link="url('/')" :label="__('Go home')" variant="filled" size="md" no-wire-navigate />
|
||||
|
||||
@if ($back !== null)
|
||||
<x-button :link="$back" :label="__('Go back')" variant="text" size="md" no-wire-navigate />
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{{-- The mail's masthead: the app name in title-lg, or the logo from
|
||||
`livewire-material.mail.logo` (`src`, `width`, `height`).
|
||||
|
||||
Replaces the framework's header, which swaps the name for Laravel's own logo whenever the
|
||||
app is still called "Laravel". A logo keeps the name as its alt text, for readers with
|
||||
images off; its width and height are repeated as attributes because Outlook sizes an image
|
||||
from those and ignores the stylesheet. Serve the file at twice those dimensions to stay
|
||||
crisp, from an absolute URL — a queued mail has no request to resolve a relative one. --}}
|
||||
@props(['url', 'logo' => config('livewire-material.mail.logo')])
|
||||
@php
|
||||
$name = trim(strip_tags((string) $slot));
|
||||
$src = is_array($logo) ? ($logo['src'] ?? null) : null;
|
||||
@endphp
|
||||
<tr>
|
||||
<td class="header">
|
||||
<a href="{{ $url }}" style="display: inline-block;">
|
||||
@if (filled($src))
|
||||
<img src="{{ $src }}" class="logo" alt="{{ $name }}" @if (filled($logo['width'] ?? null)) width="{{ $logo['width'] }}" @endif @if (filled($logo['height'] ?? null)) height="{{ $logo['height'] }}" @endif style="border: 0;">
|
||||
@else
|
||||
{{ $name }}
|
||||
@endif
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -0,0 +1,35 @@
|
||||
{{-- Every Markdown mail passes through here. The framework's message, with the footer open to
|
||||
the mail: a `<x-slot:footer>` inside `<x-mail::message>` replaces the default line — for the
|
||||
sender's address, a privacy link, or an unsubscribe link on mail that is not transactional.
|
||||
Keep the Markdown below unindented: four spaces make a code block. --}}
|
||||
<x-mail::layout>
|
||||
{{-- Header --}}
|
||||
<x-slot:header>
|
||||
<x-mail::header :url="config('app.url')">
|
||||
{{ config('app.name') }}
|
||||
</x-mail::header>
|
||||
</x-slot:header>
|
||||
|
||||
{{-- Body --}}
|
||||
{!! $slot !!}
|
||||
|
||||
{{-- Subcopy --}}
|
||||
@isset($subcopy)
|
||||
<x-slot:subcopy>
|
||||
<x-mail::subcopy>
|
||||
{!! $subcopy !!}
|
||||
</x-mail::subcopy>
|
||||
</x-slot:subcopy>
|
||||
@endisset
|
||||
|
||||
{{-- Footer --}}
|
||||
<x-slot:footer>
|
||||
<x-mail::footer>
|
||||
@isset($footer)
|
||||
{!! $footer !!}
|
||||
@else
|
||||
© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}
|
||||
@endisset
|
||||
</x-mail::footer>
|
||||
</x-slot:footer>
|
||||
</x-mail::layout>
|
||||
@@ -0,0 +1,32 @@
|
||||
{{-- The plain-text twin of html/message.blade.php: the same footer slot, no markup. --}}
|
||||
<x-mail::layout>
|
||||
{{-- Header --}}
|
||||
<x-slot:header>
|
||||
<x-mail::header :url="config('app.url')">
|
||||
{{ config('app.name') }}
|
||||
</x-mail::header>
|
||||
</x-slot:header>
|
||||
|
||||
{{-- Body --}}
|
||||
{{ $slot }}
|
||||
|
||||
{{-- Subcopy --}}
|
||||
@isset($subcopy)
|
||||
<x-slot:subcopy>
|
||||
<x-mail::subcopy>
|
||||
{{ $subcopy }}
|
||||
</x-mail::subcopy>
|
||||
</x-slot:subcopy>
|
||||
@endisset
|
||||
|
||||
{{-- Footer --}}
|
||||
<x-slot:footer>
|
||||
<x-mail::footer>
|
||||
@isset($footer)
|
||||
{{ $footer }}
|
||||
@else
|
||||
© {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.')
|
||||
@endisset
|
||||
</x-mail::footer>
|
||||
</x-slot:footer>
|
||||
</x-mail::layout>
|
||||
@@ -0,0 +1,426 @@
|
||||
{{-- The Markdown mail theme, as CSS rendered from the application's colour scheme.
|
||||
|
||||
Select it with `mail.markdown.theme` = `livewire-material::mail.theme` (MAIL_MARKDOWN_THEME),
|
||||
or `$theme` / `->theme()` on one mailable or MailMessage. Laravel renders a namespaced theme
|
||||
as a view (Illuminate\Mail\Markdown::render) and hands the result to CssToInlineStyles, which
|
||||
writes it onto every element's `style` — so the colours are the app's light scheme, read
|
||||
from `livewire-material.scheme` (Support\Scheme, falling back to the package's default), and
|
||||
a regenerated scheme reaches the next mail without a copy to keep in step.
|
||||
|
||||
What a mail client can take, and so what this is:
|
||||
- Hexes only. No custom properties, no `color-mix()`, no alpha: a client resolves none of
|
||||
them, and Outlook drops an alpha channel.
|
||||
- Light only. `Css\Processor::doCleanup()` strips every `@media` block from the theme before
|
||||
inlining, so a `prefers-color-scheme` rule here would be deleted silently; it could only
|
||||
live in a `<style>` in a published layout, and the framework layout declares
|
||||
`color-scheme: light` in two meta tags besides. Clients that force dark invert the light
|
||||
tones, which are mid-tone enough to survive it.
|
||||
- M3's typescale on the bare tags CommonMark writes (`h1` headline-sm, `p` body-lg, a table
|
||||
head title-sm), in px, with a system font stack: web fonts and
|
||||
`font-variation-settings` reach too few clients to be worth an asset.
|
||||
- No elevation. The card separates from the page by tone, as M3 surfaces do.
|
||||
- The button is M3's filled button at the `md` size (56px, title-md label, full corners),
|
||||
written as borders because padding on an `<a>` is not honoured everywhere. Outlook
|
||||
Classic squares the corners; it is still a button. `x-mail::button`'s `color` takes any
|
||||
role with a filled pair — primary, secondary, tertiary, error, success, warning, info —
|
||||
and the framework's blue, green and red. --}}
|
||||
|
||||
@php
|
||||
$role = \NoNameWeb\LivewireMaterial\Support\Scheme::light();
|
||||
|
||||
$page = $role['surface-container'];
|
||||
$card = $role['surface-container-lowest'];
|
||||
$tint = $role['surface-container'];
|
||||
|
||||
$buttons = [
|
||||
'primary' => ['primary', 'on-primary'],
|
||||
'secondary' => ['secondary', 'on-secondary'],
|
||||
'tertiary' => ['tertiary', 'on-tertiary'],
|
||||
'error' => ['error', 'on-error'],
|
||||
'success' => ['success', 'on-success'],
|
||||
'warning' => ['warning', 'on-warning'],
|
||||
'info' => ['info', 'on-info'],
|
||||
'blue' => ['primary', 'on-primary'],
|
||||
'green' => ['success', 'on-success'],
|
||||
'red' => ['error', 'on-error'],
|
||||
];
|
||||
@endphp
|
||||
|
||||
/* Base */
|
||||
|
||||
body,
|
||||
body *:not(html):not(style):not(br):not(tr):not(code) {
|
||||
box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Roboto, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-text-size-adjust: none;
|
||||
background-color: {{ $page }};
|
||||
color: {{ $role['on-surface-variant'] }};
|
||||
height: 100%;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
line-height: 1.5;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
a {
|
||||
color: {{ $role['primary'] }};
|
||||
}
|
||||
|
||||
a img {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Typography: M3's typescale */
|
||||
|
||||
h1 {
|
||||
color: {{ $role['on-surface'] }};
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
line-height: 32px;
|
||||
margin-top: 0;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: {{ $role['on-surface'] }};
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
line-height: 28px;
|
||||
margin-top: 0;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: {{ $role['on-surface'] }};
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.15px;
|
||||
line-height: 24px;
|
||||
margin-top: 0;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
p {
|
||||
color: {{ $role['on-surface-variant'] }};
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 24px;
|
||||
margin-top: 0;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
color: {{ $role['on-surface-variant'] }};
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 24px;
|
||||
margin: 0 0 16px;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
/* A `->line()` per item makes a loose list, each item wrapped in a paragraph with a
|
||||
paragraph's margin; without this it reads as numbered paragraphs, not one list. */
|
||||
|
||||
li p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Bold stays bold: a 500 would fall to 400 in the many system fonts without a medium weight. */
|
||||
|
||||
strong {
|
||||
color: {{ $role['on-surface'] }};
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid {{ $role['outline-variant'] }};
|
||||
margin: 0 0 16px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: {{ $tint }};
|
||||
border-radius: 4px;
|
||||
color: {{ $role['on-surface'] }};
|
||||
font-size: 14px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid {{ $role['outline-variant'] }};
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
p.sub {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.25px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
|
||||
.wrapper {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 100%;
|
||||
background-color: {{ $page }};
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Header: the app name in title-lg, or the configured logo */
|
||||
|
||||
.header {
|
||||
padding: 32px 0 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header a {
|
||||
color: {{ $role['on-surface'] }};
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
line-height: 28px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
border: 0;
|
||||
display: block;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Body: the card, separated from the page by tone alone */
|
||||
|
||||
.body {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 100%;
|
||||
background-color: {{ $page }};
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inner-body {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 570px;
|
||||
background-color: {{ $card }};
|
||||
border: 0;
|
||||
border-radius: 24px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
width: 570px;
|
||||
}
|
||||
|
||||
/* A long URL breaks where it has to; `break-all`, the framework's value, breaks ordinary words too. */
|
||||
|
||||
.inner-body a {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.content-cell {
|
||||
max-width: 100vw;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* Subcopy */
|
||||
|
||||
.subcopy {
|
||||
border-top: 1px solid {{ $role['outline-variant'] }};
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.subcopy p {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.25px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
/* Footer: body-sm */
|
||||
|
||||
.footer {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 570px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 570px;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: {{ $role['on-surface-variant'] }};
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.4px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: {{ $role['on-surface-variant'] }};
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Tables: a title-sm head over an outline-variant rule, body-md cells, a rule between rows */
|
||||
|
||||
.table table {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 24px auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table th {
|
||||
border-bottom: 1px solid {{ $role['outline-variant'] }};
|
||||
color: {{ $role['on-surface-variant'] }};
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1px;
|
||||
line-height: 20px;
|
||||
margin: 0;
|
||||
padding: 0 12px 12px;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
/* A Markdown table's column alignment arrives as an `align` attribute, which an inline
|
||||
`text-align` would override; the head follows it here as the cells do by themselves. */
|
||||
|
||||
.table th[align="center"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table th[align="right"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.table td {
|
||||
border-bottom: 1px solid {{ $role['outline-variant'] }};
|
||||
color: {{ $role['on-surface'] }};
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.25px;
|
||||
line-height: 20px;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/* Buttons: M3's filled button, md */
|
||||
|
||||
.action {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 100%;
|
||||
float: unset;
|
||||
margin: 32px auto;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button {
|
||||
-webkit-text-size-adjust: none;
|
||||
border-radius: 9999px;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.15px;
|
||||
line-height: 24px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@foreach ($buttons as $name => [$container, $content])
|
||||
.button-{{ $name }} {
|
||||
background-color: {{ $role[$container] }};
|
||||
border-bottom: 16px solid {{ $role[$container] }};
|
||||
border-left: 24px solid {{ $role[$container] }};
|
||||
border-right: 24px solid {{ $role[$container] }};
|
||||
border-top: 16px solid {{ $role[$container] }};
|
||||
color: {{ $role[$content] }};
|
||||
}
|
||||
|
||||
@endforeach
|
||||
/* Panel: a tinted container, no rule down its edge */
|
||||
|
||||
.panel {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 100%;
|
||||
border-collapse: separate;
|
||||
margin: 24px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
background-color: {{ $tint }};
|
||||
border-radius: 16px;
|
||||
color: {{ $role['on-surface'] }};
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.panel-content p {
|
||||
color: {{ $role['on-surface'] }};
|
||||
}
|
||||
|
||||
.panel-item {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.panel-item p:last-of-type {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
|
||||
.break-all {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -24,6 +24,8 @@
|
||||
@include('livewire-material::showcase.sections.pickers')
|
||||
@include('livewire-material::showcase.sections.timepickers')
|
||||
@include('livewire-material::showcase.sections.bars')
|
||||
@include('livewire-material::showcase.sections.navigation')
|
||||
@include('livewire-material::showcase.sections.data')
|
||||
@include('livewire-material::showcase.sections.pages')
|
||||
</main>
|
||||
@endsection
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
|
||||
|
||||
<nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections">
|
||||
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders', 'pickers' => 'Date pickers', 'timepickers' => 'Time pickers', 'bars' => 'Bars', 'data' => 'Data'] as $anchor => $section)
|
||||
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders', 'pickers' => 'Date pickers', 'timepickers' => 'Time pickers', 'bars' => 'Bars', 'navigation' => 'Navigation', 'data' => 'Data', 'pages' => 'Pages'] as $anchor => $section)
|
||||
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{{-- The showcase's sample Markdown mail: every part the theme styles — heading, prose, bold,
|
||||
a list, the button, a panel, a table, subcopy and the footer slot. Markdown, so it stays
|
||||
unindented. --}}
|
||||
<x-mail::message>
|
||||
# Your export is ready
|
||||
|
||||
Hello Anna, the report you asked for on **Monday** has finished. It holds three files and stays available for seven days.
|
||||
|
||||
<x-mail::button :url="url('/')">
|
||||
Download the export
|
||||
</x-mail::button>
|
||||
|
||||
<x-mail::panel>
|
||||
Files are deleted automatically once they expire. Download them before **20 September** to keep a copy.
|
||||
</x-mail::panel>
|
||||
|
||||
<x-mail::table>
|
||||
| File | Rows | Size |
|
||||
|:-----|-----:|-----:|
|
||||
| customers.csv | 1,204 | 184 KB |
|
||||
| orders.csv | 8,930 | 1.2 MB |
|
||||
| summary.pdf | — | 96 KB |
|
||||
</x-mail::table>
|
||||
|
||||
What happens next:
|
||||
|
||||
1. Open the export from the button above.
|
||||
2. Check the summary before you share it.
|
||||
|
||||
Thanks,<br>
|
||||
{{ config('app.name') }}
|
||||
|
||||
<x-slot:subcopy>
|
||||
If the button does not work, paste this address into your browser: [{{ url('/') }}]({{ url('/') }})
|
||||
</x-slot:subcopy>
|
||||
|
||||
<x-slot:footer>
|
||||
© {{ date('Y') }} {{ config('app.name') }} · [Privacy]({{ url('/') }}) · [Unsubscribe]({{ url('/') }})
|
||||
</x-slot:footer>
|
||||
</x-mail::message>
|
||||
@@ -0,0 +1,114 @@
|
||||
@php
|
||||
$examples = [
|
||||
'Navigation bar' => <<<'BLADE'
|
||||
<div class="w-full space-y-6">
|
||||
<div class="max-w-[25rem] space-y-2">
|
||||
<p class="type-label-lg text-on-surface-variant">Compact: icon over label</p>
|
||||
<div class="flex h-56 flex-col overflow-hidden rounded-corner-lg border border-outline-variant bg-surface">
|
||||
<div class="flex-1 p-4 type-body-md text-on-surface-variant">The page</div>
|
||||
<x-navigation-bar>
|
||||
<x-navigation-bar-item label="Shares" icon="folder_shared" link="#navigation" no-wire-navigate active />
|
||||
<x-navigation-bar-item label="Upload" icon="upload" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-bar-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate badge="3" />
|
||||
<x-navigation-bar-item label="Account" icon="account_circle" link="#navigation" no-wire-navigate :badge="true" badge-label="Needs attention" />
|
||||
</x-navigation-bar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-[50rem] space-y-2">
|
||||
<p class="type-label-lg text-on-surface-variant">From 600px wide: icon beside label, centred</p>
|
||||
<div class="overflow-x-auto rounded-corner-lg border border-outline-variant">
|
||||
<div class="flex h-56 min-w-[37.5rem] flex-col bg-surface">
|
||||
<div class="flex-1 p-4 type-body-md text-on-surface-variant">The page</div>
|
||||
<x-navigation-bar>
|
||||
<x-navigation-bar-item label="Shares" icon="folder_shared" link="#navigation" no-wire-navigate active />
|
||||
<x-navigation-bar-item label="Upload" icon="upload" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-bar-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate badge="3" />
|
||||
<x-navigation-bar-item label="Account" icon="account_circle" link="#navigation" no-wire-navigate />
|
||||
</x-navigation-bar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Collapsed and expanded rails' => <<<'BLADE'
|
||||
<div class="flex w-full flex-wrap items-start gap-6">
|
||||
<div class="flex h-[36rem] overflow-hidden rounded-corner-lg border border-outline-variant">
|
||||
<x-navigation-rail mode="collapsed" label="Collapsed example">
|
||||
<x-slot:header>
|
||||
<x-fab icon="edit" tooltip-right="Compose" />
|
||||
</x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
|
||||
<x-navigation-rail-item label="Starred" icon="star" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-rail-item label="Sent" icon="send" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-rail-section label="Labels">
|
||||
<x-navigation-rail-item label="Travel" icon="flight" link="#navigation" no-wire-navigate :badge="true" badge-label="New" />
|
||||
<x-navigation-rail-item label="Receipts" icon="receipt_long" link="#navigation" no-wire-navigate />
|
||||
</x-navigation-rail-section>
|
||||
|
||||
<x-slot:footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="#navigation" no-wire-navigate />
|
||||
</x-slot:footer>
|
||||
</x-navigation-rail>
|
||||
</div>
|
||||
|
||||
<div class="flex h-[36rem] overflow-hidden rounded-corner-lg border border-outline-variant">
|
||||
<x-navigation-rail mode="expanded" label="Expanded example">
|
||||
<x-slot:header>
|
||||
<x-fab label="Compose" icon="edit" />
|
||||
</x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
|
||||
<x-navigation-rail-item label="Starred" icon="star" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-rail-item label="Sent" icon="send" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-rail-section label="Labels">
|
||||
<x-navigation-rail-item label="Travel" icon="flight" link="#navigation" no-wire-navigate :badge="true" badge-label="New" />
|
||||
<x-navigation-rail-item label="Receipts" icon="receipt_long" link="#navigation" no-wire-navigate />
|
||||
</x-navigation-rail-section>
|
||||
|
||||
<x-slot:footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="#navigation" no-wire-navigate />
|
||||
</x-slot:footer>
|
||||
</x-navigation-rail>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Collapsible rail' => <<<'BLADE'
|
||||
<div class="flex h-[30rem] w-full overflow-hidden rounded-corner-lg border border-outline-variant">
|
||||
<x-navigation-rail label="Collapsible example">
|
||||
<x-slot:brand>
|
||||
<span class="block truncate type-title-lg">Mail</span>
|
||||
</x-slot:brand>
|
||||
|
||||
<x-slot:header>
|
||||
<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>
|
||||
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>
|
||||
</x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
|
||||
<x-navigation-rail-item label="Starred" icon="star" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-rail-item label="Sent" icon="send" link="#navigation" no-wire-navigate />
|
||||
</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. The choice is remembered and applied before the next page paints — every collapsible rail follows it, <code><x-app-shell></code>'s from <code>lg</code> too.
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section id="navigation" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Navigation</h2>
|
||||
|
||||
<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: a bar below <code>sm</code>, a collapsed rail that opens as a modal to <code>lg</code>, a collapsible rail from there.
|
||||
<a href="{{ route('livewire-material.shell') }}" class="link text-primary">Open the app shell</a> and change the window's width.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
<x-showcase::example :$title :$code />
|
||||
@endforeach
|
||||
</section>
|
||||
@@ -0,0 +1,16 @@
|
||||
<section id="pages" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Pages</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
The error pages, raised for real so an app's own <code>resources/views/errors</code> still wins, and the Markdown mail theme
|
||||
(<code>livewire-material::mail.theme</code>) on a sample mail, both in this application's scheme.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@foreach (['401', '402', '403', '404', '419', '429', '500', '503'] as $code)
|
||||
<x-button :link="route('livewire-material.error', $code)" :label="$code" variant="tonal" no-wire-navigate />
|
||||
@endforeach
|
||||
|
||||
<x-button :link="route('livewire-material.mail')" label="Sample mail" icon="mail" variant="outlined" no-wire-navigate />
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,87 @@
|
||||
{{-- The app shell, 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
|
||||
$page ??= 'inbox';
|
||||
|
||||
$pages = [
|
||||
'inbox' => ['title' => 'Inbox', 'icon' => 'inbox', 'badge' => 12],
|
||||
'starred' => ['title' => 'Starred', 'icon' => 'star'],
|
||||
'sent' => ['title' => 'Sent', 'icon' => 'send'],
|
||||
'drafts' => ['title' => 'Drafts', 'icon' => 'draft', 'badge' => true],
|
||||
'travel' => ['title' => 'Travel', 'icon' => 'flight', 'section' => 'Labels', 'bar' => false],
|
||||
'receipts' => ['title' => 'Receipts', 'icon' => 'receipt_long', 'section' => 'Labels', 'bar' => false],
|
||||
];
|
||||
|
||||
$destinations = collect($pages)
|
||||
->map(fn (array $destination, string $key): array => $destination + [
|
||||
'url' => route('livewire-material.shell', $key === 'inbox' ? [] : ['page' => $key]),
|
||||
'active' => $key === $page,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$current = $pages[$page];
|
||||
@endphp
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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>
|
||||
|
||||
<x-theme-script />
|
||||
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface font-sans text-on-surface antialiased">
|
||||
<x-app-shell :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>
|
||||
|
||||
<x-slot:rail-header>
|
||||
<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" x-on:click="materialToast('Compose opens here')" /></span>
|
||||
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" x-on:click="materialToast('Compose opens here')" /></span>
|
||||
</x-slot:rail-header>
|
||||
|
||||
<x-slot:rail-footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="#settings" no-wire-navigate />
|
||||
</x-slot:rail-footer>
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button icon="dark_mode" tooltip-right="Switch theme" x-data x-on:click="$store.theme.toggle()" />
|
||||
<x-button icon="help" tooltip-right="Help" x-on:click="materialToast('Help opens here')" />
|
||||
</x-slot:actions>
|
||||
|
||||
<x-slot:top>
|
||||
<header class="sticky top-0 z-20 flex h-16 items-center gap-1 bg-surface px-1 pt-[env(safe-area-inset-top)] sm:px-4">
|
||||
<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="shell-menu" /></span>
|
||||
<h1 class="min-w-0 flex-1 truncate px-3 type-title-lg sm:px-0">{{ $current['title'] }}</h1>
|
||||
<x-button icon="search" tooltip="Search" />
|
||||
</header>
|
||||
</x-slot:top>
|
||||
|
||||
<div class="mx-auto w-full max-w-5xl space-y-4 px-4 pb-6 sm:px-6">
|
||||
<p class="type-body-md text-on-surface-variant" data-test="shell-page">This is the {{ strtolower($current['title']) }} page.</p>
|
||||
|
||||
<x-list segmented>
|
||||
@foreach (range(1, 14) as $index)
|
||||
<x-list-item :title="$current['title'].' item '.$index" description="A line of supporting text for this item" :icon="$current['icon']" trailing="{{ $index }}h" wire:key="item-{{ $index }}" />
|
||||
@endforeach
|
||||
</x-list>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-button label="Show a toast" variant="tonal" x-data x-on:click="materialToast('Moved to archive', { action: { label: 'Undo', handler: () => {} } })" />
|
||||
<x-button label="Back to the showcase" link="{{ route('livewire-material.showcase') }}#navigation" no-wire-navigate />
|
||||
</div>
|
||||
</div>
|
||||
</x-app-shell>
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +1,19 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcasePageController;
|
||||
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseSymbolController;
|
||||
|
||||
Route::view('/', 'livewire-material::showcase.index')->name('showcase');
|
||||
|
||||
Route::get('symbols.json', [ShowcaseSymbolController::class, 'index'])->name('symbols');
|
||||
Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show'])->name('symbol');
|
||||
|
||||
Route::view('shell/{page?}', 'livewire-material::showcase.shell')
|
||||
->whereIn('page', ['inbox', 'starred', 'sent', 'drafts', 'travel', 'receipts'])
|
||||
->name('shell');
|
||||
|
||||
Route::get('errors/{code}', [ShowcasePageController::class, 'error'])
|
||||
->whereIn('code', ['401', '402', '403', '404', '419', '429', '500', '503'])
|
||||
->name('error');
|
||||
Route::get('mail', [ShowcasePageController::class, 'mail'])->name('mail');
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Mail\Markdown;
|
||||
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
||||
|
||||
/**
|
||||
* Previews of the pages the showcase cannot draw inline: an error page, raised for real so the
|
||||
* exception handler picks the view exactly as it would in the application (an app's own
|
||||
* `resources/views/errors` included), and a sample Markdown mail in the package theme.
|
||||
*/
|
||||
class ShowcasePageController
|
||||
{
|
||||
public function error(string $code): never
|
||||
{
|
||||
abort((int) $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sample mail, with the package's header and message after the application's own mail
|
||||
* components — what an application gets with `livewire-material.mail.components` on.
|
||||
*/
|
||||
public function mail(): Response
|
||||
{
|
||||
$markdown = new Markdown(view(), [
|
||||
'theme' => 'livewire-material::mail.theme',
|
||||
'paths' => array_values(array_unique([
|
||||
...(array) config('mail.markdown.paths', []),
|
||||
LivewireMaterialServiceProvider::mailComponentPath(),
|
||||
])),
|
||||
]);
|
||||
|
||||
return response($markdown->render('livewire-material::showcase.mail')->toHtml());
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
$this->mergeConfigFrom(__DIR__.'/../config/livewire-material.php', 'livewire-material');
|
||||
|
||||
$this->disableBladeIconsComponent();
|
||||
$this->registerErrorViews();
|
||||
$this->registerMailComponents();
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
@@ -33,6 +35,71 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
$this->publishes([
|
||||
__DIR__.'/../config/livewire-material.php' => config_path('livewire-material.php'),
|
||||
], 'livewire-material-config');
|
||||
|
||||
$this->publishes([
|
||||
static::errorViewPath().'/errors' => resource_path('views/errors'),
|
||||
], 'livewire-material-errors');
|
||||
|
||||
$this->publishes([
|
||||
static::mailComponentPath().'/html' => resource_path('views/vendor/mail/html'),
|
||||
static::mailComponentPath().'/text' => resource_path('views/vendor/mail/text'),
|
||||
], 'livewire-material-mail');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The error-view root: a folder holding only `errors/`.
|
||||
*/
|
||||
public static function errorViewPath(): string
|
||||
{
|
||||
return dirname(__DIR__).'/resources/views/error-pages';
|
||||
}
|
||||
|
||||
/**
|
||||
* The mail component root, laid out like the framework's: `html/` and `text/`.
|
||||
*/
|
||||
public static function mailComponentPath(): string
|
||||
{
|
||||
return dirname(__DIR__).'/resources/views/mail';
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the error-view root to `view.paths`, after the application's own.
|
||||
*
|
||||
* The exception handler replaces the `errors` namespace each time it renders an HTTP
|
||||
* exception, with every entry of `view.paths` plus `/errors` and the framework's views last
|
||||
* (RegisterErrorViewPaths), so a namespace added with addNamespace() is gone by then; only a
|
||||
* view path survives. That config is read at render time, but also once when the view
|
||||
* finder is built: appended here, in register(), before any boot() resolves the view
|
||||
* factory, the finder and `view:cache` see the same paths the handler does. Appending keeps
|
||||
* `view.paths[0]` — Laravel's viewPath() — the application's, and puts the application's
|
||||
* own `resources/views/errors` first. Idempotent, since a cached config already holds it.
|
||||
*/
|
||||
protected function registerErrorViews(): void
|
||||
{
|
||||
$paths = (array) config('view.paths', []);
|
||||
|
||||
if (! in_array(static::errorViewPath(), $paths, true)) {
|
||||
config(['view.paths' => [...$paths, static::errorViewPath()]]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the package's mail header and message after the application's own mail components,
|
||||
* when `livewire-material.mail.components` asks for it. Off by default: it changes every
|
||||
* Markdown mail the application sends, theme or not, and the framework reads
|
||||
* `mail.markdown.paths` once, when the Markdown renderer is first made — after register().
|
||||
*/
|
||||
protected function registerMailComponents(): void
|
||||
{
|
||||
if (! config('livewire-material.mail.components')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$paths = (array) config('mail.markdown.paths', []);
|
||||
|
||||
if (! in_array(static::mailComponentPath(), $paths, true)) {
|
||||
config(['mail.markdown.paths' => [...$paths, static::mailComponentPath()]]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Support;
|
||||
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Foundation\Vite;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* What the error layout needs from PHP, written so that nothing in it can fail the page: an
|
||||
* error page is shown precisely when something else already went wrong.
|
||||
*/
|
||||
class ErrorPage
|
||||
{
|
||||
/**
|
||||
* The roles the fallback stylesheet draws with.
|
||||
*/
|
||||
protected const array ROLES = [
|
||||
'surface', 'on-surface', 'on-surface-variant', 'primary', 'on-primary',
|
||||
'primary-container', 'on-primary-container', 'secondary',
|
||||
];
|
||||
|
||||
/**
|
||||
* The application's Vite tags, or null when they cannot be made.
|
||||
*
|
||||
* A 500 during a deploy is the moment the manifest is missing, half-written or names files
|
||||
* that are not there yet — a missing manifest throws a ViteException, a truncated one a
|
||||
* PHP warning turned exception — so every failure means "no build", never a second error.
|
||||
*/
|
||||
public static function assets(): ?Htmlable
|
||||
{
|
||||
try {
|
||||
$tags = app(Vite::class)(config('livewire-material.showcase.vite', []));
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tags instanceof Htmlable ? $tags : new HtmlString((string) $tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the Back button goes: the page the visitor came from, when there was one other than
|
||||
* this page and the home page Home already offers. Null when the session behind it cannot
|
||||
* be read.
|
||||
*/
|
||||
public static function backUrl(): ?string
|
||||
{
|
||||
try {
|
||||
$previous = url()->previous();
|
||||
$current = url()->current();
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$isHome = rtrim($previous, '/') === rtrim(url('/'), '/');
|
||||
|
||||
return $previous === $current || $isHome ? null : $previous;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stylesheet for a page without its build: the application's scheme in both themes, a
|
||||
* system font, and the layout's `data-error-*` hooks drawn to match the Tailwind version.
|
||||
*/
|
||||
public static function fallbackStyles(): HtmlString
|
||||
{
|
||||
$scheme = Scheme::load();
|
||||
|
||||
$roles = fn (array $theme): string => implode('', array_map(
|
||||
fn (string $role): string => "--md-sys-color-{$role}: {$theme[$role]}; ",
|
||||
self::ROLES,
|
||||
));
|
||||
|
||||
return new HtmlString(<<<CSS
|
||||
:root, [data-theme='light'] { color-scheme: light; {$roles($scheme['light'])}}
|
||||
[data-theme='dark'] { color-scheme: dark; {$roles($scheme['dark'])}}
|
||||
*, ::before, ::after { box-sizing: border-box; }
|
||||
body { margin: 0; background-color: var(--md-sys-color-surface); color: var(--md-sys-color-on-surface); font: 400 1rem/1.5rem ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
[data-error-page] { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100dvh; max-width: 36rem; margin: 0 auto; padding: 3rem 1.5rem; text-align: center; }
|
||||
[data-error-art] { position: relative; display: grid; place-items: center; width: 12rem; height: 12rem; }
|
||||
[data-error-shape] { position: absolute; inset: 0; color: var(--md-sys-color-primary-container); }
|
||||
[data-error-shape] svg { display: block; width: 100%; height: 100%; }
|
||||
[data-error-code] { position: relative; margin: 0; font-size: 3.5625rem; font-weight: 500; line-height: 4rem; color: var(--md-sys-color-on-primary-container); font-variant-numeric: tabular-nums; }
|
||||
[data-error-headline] { margin: 2.5rem 0 0; font-size: 1.75rem; font-weight: 400; line-height: 2.25rem; text-wrap: balance; }
|
||||
[data-error-message] { margin: 0.75rem 0 0; color: var(--md-sys-color-on-surface-variant); text-wrap: balance; }
|
||||
[data-error-actions] { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 0.75rem; margin-top: 2.5rem; }
|
||||
[data-error-actions] :is(a, button) { display: inline-flex; align-items: center; height: 3.5rem; padding: 0 1.5rem; border: 0; border-radius: 9999px; background: none; color: var(--md-sys-color-primary); font-family: inherit; font-size: 1rem; font-weight: 500; line-height: 1.5rem; text-decoration: none; cursor: pointer; }
|
||||
[data-error-actions] > :first-child { background-color: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); }
|
||||
[data-error-actions] :is(a, button):focus-visible { outline: 3px solid var(--md-sys-color-secondary); outline-offset: 2px; }
|
||||
CSS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Support;
|
||||
|
||||
/**
|
||||
* The colour scheme as data, for the places CSS custom properties cannot reach: a mail client
|
||||
* resolves none, and an error page whose build is missing has no stylesheet to declare them.
|
||||
*
|
||||
* The data is the JSON `php artisan material:scheme` writes beside the stylesheet
|
||||
* (`livewire-material.scheme`, resources/css/material-scheme.json by default). Without that
|
||||
* file — or with one that is not a scheme — the package's own default applies, the same one
|
||||
* tokens/scheme.css carries. A role the application's file lacks (a scheme generated before
|
||||
* the role existed) is taken from the default, and a value that is not a #rrggbb hex is
|
||||
* refused, so nothing but a colour ever reaches a stylesheet.
|
||||
*
|
||||
* Read on every call and never kept: a regenerated scheme applies at once, and a long-running
|
||||
* worker holds nothing.
|
||||
*/
|
||||
class Scheme
|
||||
{
|
||||
/**
|
||||
* @return array{light: array<string, string>, dark: array<string, string>}
|
||||
*/
|
||||
public static function load(?string $path = null): array
|
||||
{
|
||||
$default = static::read(dirname(__DIR__, 2).'/resources/css/tokens/scheme.json');
|
||||
$scheme = static::read($path ?? (string) config('livewire-material.scheme'));
|
||||
|
||||
return [
|
||||
'light' => [...$default['light'], ...$scheme['light']],
|
||||
'dark' => [...$default['dark'], ...$scheme['dark']],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The light roles: what a mail wears.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function light(?string $path = null): array
|
||||
{
|
||||
return static::load($path)['light'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{light: array<string, string>, dark: array<string, string>}
|
||||
*/
|
||||
protected static function read(string $path): array
|
||||
{
|
||||
$data = is_file($path) ? json_decode((string) file_get_contents($path), true) : null;
|
||||
|
||||
return [
|
||||
'light' => static::roles($data['light'] ?? null),
|
||||
'dark' => static::roles($data['dark'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected static function roles(mixed $roles): array
|
||||
{
|
||||
if (! is_array($roles)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_filter(
|
||||
$roles,
|
||||
fn (mixed $hex, mixed $role): bool => is_string($role)
|
||||
&& preg_match('/^[a-z-]+$/', $role) === 1
|
||||
&& is_string($hex)
|
||||
&& preg_match('/^#[0-9a-fA-F]{6}$/', $hex) === 1,
|
||||
ARRAY_FILTER_USE_BOTH,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* The 404 page, reached the way a visitor reaches it: an address nothing answers.
|
||||
*/
|
||||
function missingPage(string $colorScheme = 'light')
|
||||
{
|
||||
$page = visit('/nothing-lives-here');
|
||||
$page = $colorScheme === 'dark' ? $page->inDarkMode() : $page->inLightMode();
|
||||
|
||||
return $page->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && document.querySelector('[data-error-page]') !== null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the element's computed background is the colour a role resolves to on this page.
|
||||
*/
|
||||
function paintedIn(string $selector, string $role): string
|
||||
{
|
||||
return <<<JS
|
||||
(() => {
|
||||
const probe = document.createElement('div');
|
||||
probe.style.backgroundColor = 'var(--md-sys-color-{$role})';
|
||||
document.body.append(probe);
|
||||
const expected = getComputedStyle(probe).backgroundColor;
|
||||
probe.remove();
|
||||
|
||||
return expected !== 'rgba(0, 0, 0, 0)' && getComputedStyle(document.querySelector('{$selector}')).backgroundColor === expected;
|
||||
})()
|
||||
JS;
|
||||
}
|
||||
|
||||
it('draws the 404 page in the scheme, in light and dark', function () {
|
||||
missingPage('light')
|
||||
->assertSee('Page not found')
|
||||
->assertVisible('[data-error-headline]')
|
||||
->assertScript("document.documentElement.getAttribute('data-theme') === 'light'")
|
||||
->assertScript(paintedIn('body', 'surface'))
|
||||
->assertScript("getComputedStyle(document.querySelector('[data-error-shape] svg')).color !== getComputedStyle(document.body).color")
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
missingPage('dark')
|
||||
->assertScript("document.documentElement.getAttribute('data-theme') === 'dark'")
|
||||
->assertScript(paintedIn('body', 'surface'))
|
||||
->assertScript("getComputedStyle(document.body).backgroundColor !== 'rgb(253, 247, 254)'");
|
||||
});
|
||||
|
||||
it('fits a 400px screen without scrolling sideways', function () {
|
||||
missingPage()
|
||||
->resize(400, 800)
|
||||
->assertVisible('[data-error-headline]')
|
||||
->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
|
||||
->assertScript("document.querySelector('[data-error-actions] a').getBoundingClientRect().right <= 400");
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
const RAIL = "document.querySelector('[data-navigation-rail]')";
|
||||
const RAIL_PANEL = "document.querySelector('[data-navigation-rail-panel]')";
|
||||
const BOTTOM_BAR = "document.querySelector('[data-app-shell-bar]')";
|
||||
|
||||
function navigationReady(mixed $page): mixed
|
||||
{
|
||||
return $page->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
function shellPage(int $width = 1280, int $height = 900, string $path = '/material/shell'): mixed
|
||||
{
|
||||
return navigationReady(visit($path)->resize($width, $height));
|
||||
}
|
||||
|
||||
/**
|
||||
* A shell whose page records, while the document is still being parsed — after the rail's
|
||||
* markup and the stylesheet, before Alpine exists — what <html> and the rail looked like.
|
||||
* That is what the first paint shows.
|
||||
*/
|
||||
function firstPaintProbe(): void
|
||||
{
|
||||
Route::middleware('web')->get('/rail-probe', fn () => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body>
|
||||
<x-app-shell :destinations="[
|
||||
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/rail-probe', 'active' => true],
|
||||
['title' => 'Sent', 'icon' => 'send', 'url' => '/rail-probe'],
|
||||
]">
|
||||
<script>
|
||||
window.firstPaint = {
|
||||
rail: document.documentElement.getAttribute('data-rail'),
|
||||
width: getComputedStyle(document.querySelector('[data-navigation-rail]')).width,
|
||||
alpine: typeof window.Alpine,
|
||||
};
|
||||
</script>
|
||||
<p>The page</p>
|
||||
</x-app-shell>
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
}
|
||||
|
||||
function railWidth(int $pixels): string
|
||||
{
|
||||
return 'Math.round('.RAIL.".getBoundingClientRect().width) === {$pixels}";
|
||||
}
|
||||
|
||||
it('shows the navigation bar on a phone and marks the current destination', function () {
|
||||
shellPage(400, 860)
|
||||
->assertScript('getComputedStyle('.BOTTOM_BAR.").display !== 'none'")
|
||||
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'")
|
||||
->assertScript(BOTTOM_BAR.".querySelectorAll('[data-navigation-bar-item]').length === 4")
|
||||
->assertScript(BOTTOM_BAR.".querySelector('[aria-current=\"page\"]').textContent.includes('Inbox')")
|
||||
->assertScript('getComputedStyle('.BOTTOM_BAR.".querySelector('[aria-current=\"page\"] [data-navigation-indicator]')).backgroundImage !== 'none'")
|
||||
->assertScript('getComputedStyle('.BOTTOM_BAR.".querySelector('[data-navigation-bar-item]:not([aria-current]) [data-navigation-indicator]')).backgroundImage === 'none'")
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
it('collapses the rail on a medium window, icon over label', function () {
|
||||
shellPage(800)
|
||||
->assertScript(railWidth(96))
|
||||
->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'")
|
||||
->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-item]')).flexDirection === 'column'")
|
||||
->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-heading]')).display === 'none'")
|
||||
->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('expands the rail from lg, and keeps it collapsed across a reload from the first paint', function () {
|
||||
firstPaintProbe();
|
||||
|
||||
$page = shellPage(1280, 900, '/rail-probe')
|
||||
->assertScript(railWidth(256))
|
||||
->assertScript("window.eval('window.firstPaint.width') === '256px'")
|
||||
->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true');
|
||||
|
||||
$page->click('[data-navigation-rail-menu]')
|
||||
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'")
|
||||
->assertScript("localStorage.getItem('material-rail') === 'collapsed'")
|
||||
->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'false')
|
||||
->assertScript(railWidth(96));
|
||||
|
||||
navigationReady($page->refresh())
|
||||
->assertScript("window.eval('window.firstPaint.alpine') === 'undefined'")
|
||||
->assertScript("window.eval('window.firstPaint.rail') === 'collapsed'")
|
||||
->assertScript("window.eval('window.firstPaint.width') === '96px'")
|
||||
->assertScript(railWidth(96));
|
||||
});
|
||||
|
||||
it('opens the modal rail from its menu button below lg, holding focus until Escape or the scrim', function () {
|
||||
$page = shellPage(800);
|
||||
|
||||
// From the keyboard: WebKit neither focuses a button on click nor returns focus to one that never had it.
|
||||
$page->script("document.querySelector('[data-navigation-rail-menu]').focus()");
|
||||
$page->keys('[data-navigation-rail-menu]', 'Enter')
|
||||
->assertScript(RAIL.".hasAttribute('data-open')")
|
||||
->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().width) === 256')
|
||||
->assertScript(railWidth(96))
|
||||
->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-heading]')).display !== 'none'")
|
||||
->assertScript(RAIL_PANEL.'.contains(document.activeElement)')
|
||||
->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') !== null")
|
||||
->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true');
|
||||
|
||||
$page->keys(':focus', 'Escape')
|
||||
->assertScript('! '.RAIL.".hasAttribute('data-open')")
|
||||
->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') === null")
|
||||
->assertScript("document.activeElement === document.querySelector('[data-navigation-rail-menu]')");
|
||||
|
||||
$page->keys('[data-navigation-rail-menu]', 'Enter')
|
||||
->assertScript(RAIL.".hasAttribute('data-open')");
|
||||
|
||||
$page->click('[data-navigation-rail-scrim]')
|
||||
->assertScript('! '.RAIL.".hasAttribute('data-open')")
|
||||
->assertScript("localStorage.getItem('material-rail') === null");
|
||||
});
|
||||
|
||||
it('slides the modal rail in on a phone from the app bar\'s menu button', function () {
|
||||
$page = shellPage(400, 860);
|
||||
|
||||
$page->click('@shell-menu')
|
||||
->assertScript(RAIL.".hasAttribute('data-open')")
|
||||
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'flex'")
|
||||
->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().left) === 0')
|
||||
->assertScript(RAIL_PANEL.'.contains(document.activeElement)');
|
||||
|
||||
$page->keys(':focus', 'Escape')
|
||||
->assertScript('! '.RAIL.".hasAttribute('data-open')")
|
||||
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'");
|
||||
});
|
||||
|
||||
it('keeps the rail and the theme through wire:navigate and moves aria-current', function () {
|
||||
$page = shellPage(1280);
|
||||
|
||||
$page->click('[data-navigation-rail-menu]')
|
||||
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'");
|
||||
|
||||
$page->script("window.eval(\"Alpine.store('theme').set('dark'); window.samePage = true\")");
|
||||
|
||||
$page->click('[data-navigation-rail-panel] a[href$="/material/shell/starred"]')
|
||||
->assertSeeIn('@shell-page', 'This is the starred page.')
|
||||
->assertScript("window.eval('window.samePage') === true")
|
||||
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'")
|
||||
->assertScript("document.documentElement.getAttribute('data-theme') === 'dark'")
|
||||
->assertScript(railWidth(96))
|
||||
->assertScript("document.querySelector('[data-navigation-rail-panel] [aria-current=\"page\"]').getAttribute('href').endsWith('/material/shell/starred')")
|
||||
->assertScript("document.querySelectorAll('[data-navigation-rail-panel] [aria-current=\"page\"]').length === 1")
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
it('skips to the content', function () {
|
||||
$page = shellPage();
|
||||
|
||||
$page->assertScript("document.querySelector('a[href], button, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])').matches('[data-skip-link]')");
|
||||
|
||||
// Safari and WebKit leave links out of the Tab order, so focus it directly.
|
||||
$page->script("document.querySelector('[data-skip-link]').focus()");
|
||||
$page->assertScript("getComputedStyle(document.querySelector('[data-skip-link]')).position === 'fixed'")
|
||||
->assertVisible('[data-skip-link]');
|
||||
|
||||
$page->keys('[data-skip-link]', 'Enter')
|
||||
->assertScript("location.hash === '#content'")
|
||||
->assertScript("document.activeElement === document.getElementById('content')");
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
function shellDestinations(): array
|
||||
{
|
||||
return [
|
||||
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'badge' => 4],
|
||||
['title' => 'Sent', 'icon' => 'send', 'url' => '/sent', 'active' => true],
|
||||
['title' => 'Travel', 'icon' => 'flight', 'url' => '/travel', 'section' => 'Labels', 'bar' => false],
|
||||
['title' => 'Receipts', 'icon' => 'receipt_long', 'url' => '/receipts', 'section' => 'Labels', 'bar' => false, 'navigate' => false],
|
||||
];
|
||||
}
|
||||
|
||||
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()]);
|
||||
|
||||
expect($html)
|
||||
->toContain('data-navigation-rail="adaptive"')
|
||||
->toContain('data-navigation-bar')
|
||||
->toContain('<main id="content" tabindex="-1" wire:transition.navigate')
|
||||
->toContain('max-lg:overflow-x-clip')
|
||||
->toContain('The page')
|
||||
->toContain('x-data="materialSnackbar"')
|
||||
->toMatch('/<a\s+href="#content"\s+data-skip-link/')
|
||||
->and(strpos($html, 'data-skip-link'))->toBeLessThan(strpos($html, 'data-navigation-rail='));
|
||||
});
|
||||
|
||||
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()]);
|
||||
|
||||
$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'));
|
||||
|
||||
expect(substr_count($rail, '<a data-navigation-rail-item'))->toBe(4)
|
||||
->and(substr_count($bar, '<a data-navigation-bar-item'))->toBe(2)
|
||||
->and($bar)->toContain('Inbox')->toContain('Sent')->not->toContain('Travel')
|
||||
->and($rail)->toContain('data-navigation-rail-heading>Labels</p>')
|
||||
->and(substr_count($rail, 'data-navigation-rail-section'))->toBe(1)
|
||||
->and($rail)->toMatch('/href="\/receipts"(?![^>]*wire:navigate)/')
|
||||
->and(substr_count($html, 'aria-current="page"'))->toBe(2);
|
||||
});
|
||||
|
||||
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' => [
|
||||
['title' => 'Inbox', 'icon' => 'inbox', 'url' => url('/shell-probe/inbox')],
|
||||
['title' => 'Sent', 'icon' => 'send', 'url' => url('/shell-probe/sent')],
|
||||
]]));
|
||||
|
||||
$html = $this->withoutVite()->get('/shell-probe/inbox')->assertOk()->getContent();
|
||||
|
||||
expect($html)->toMatch('/href="[^"]*\/shell-probe\/inbox"[^>]*aria-current="page"/')
|
||||
->and(substr_count($html, 'aria-current="page"'))->toBe(2);
|
||||
});
|
||||
|
||||
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()]))
|
||||
->toContain('max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]')
|
||||
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]]))
|
||||
->not->toContain('data-app-shell-bar')
|
||||
->not->toContain('--material-bottom-bar:');
|
||||
});
|
||||
|
||||
it('places each slot once', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-app-shell :destinations="$destinations">
|
||||
<x-slot:brand><span>BRAND</span></x-slot:brand>
|
||||
<x-slot:rail-header><span>FAB</span></x-slot:rail-header>
|
||||
<x-slot:rail-footer><span>FOOTER</span></x-slot:rail-footer>
|
||||
<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()]);
|
||||
|
||||
foreach (['BRAND', 'FAB', 'FOOTER', 'ACTIONS', 'APP BAR', 'PAGE'] as $slot) {
|
||||
expect(substr_count($html, $slot))->toBe(1);
|
||||
}
|
||||
|
||||
expect($html)->toMatch('/data-navigation-rail-header.*BRAND.*FAB.*data-navigation-rail-footer.*FOOTER.*data-app-shell-actions.*ACTIONS.*APP BAR.*<main.*PAGE/s');
|
||||
});
|
||||
|
||||
it('serves the showcase\'s app shell pages', function () {
|
||||
$html = $this->withoutVite()
|
||||
->get('/material/shell/starred')
|
||||
->assertOk()
|
||||
->assertSee('This is the starred page.')
|
||||
->getContent();
|
||||
|
||||
expect($html)->toMatch('/href="[^"]*\/material\/shell\/starred"[^>]*aria-current="page"/');
|
||||
|
||||
$this->withoutVite()->get('/material/shell')->assertOk()->assertSee('This is the inbox page.');
|
||||
$this->withoutVite()->get('/material/shell/elsewhere')->assertNotFound();
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
it('draws a navigation landmark around its items', function () {
|
||||
expect((string) $this->blade('<x-navigation-bar><x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-bar>'))
|
||||
->toContain('<nav aria-label="Main" data-navigation-bar')
|
||||
->toContain('data-navigation-bar-items')
|
||||
->and((string) $this->blade('<x-navigation-bar label="Sections" />'))->toContain('aria-label="Sections"');
|
||||
});
|
||||
|
||||
it('links a destination through wire:navigate unless told not to', function () {
|
||||
expect((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" />'))
|
||||
->toContain('<a data-navigation-bar-item')
|
||||
->toContain('href="/inbox"')
|
||||
->toContain('wire:navigate')
|
||||
->and((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" no-wire-navigate />'))->not->toContain('wire:navigate')
|
||||
->and((string) $this->blade('<x-navigation-bar-item label="Help" icon="help" link="https://example.com" external />'))
|
||||
->toContain('target="_blank"')
|
||||
->not->toContain('wire:navigate');
|
||||
});
|
||||
|
||||
it('is a button without a link', function () {
|
||||
expect((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" wire:click="show(\'inbox\')" />'))
|
||||
->toContain('<button data-navigation-bar-item')
|
||||
->toContain('type="button"')
|
||||
->not->toContain('href=');
|
||||
});
|
||||
|
||||
it('marks the current destination with aria-current and the filled icon', function () {
|
||||
$active = (string) $this->blade('<x-navigation-bar-item label="Starred" icon="star" link="/starred" active />');
|
||||
$inactive = (string) $this->blade('<x-navigation-bar-item label="Starred" icon="star" link="/starred" />');
|
||||
|
||||
$symbol = fn (string $html): string => preg_match('/<svg.*?<\/svg>/s', $html, $svg) ? $svg[0] : '';
|
||||
|
||||
expect($active)->toContain('aria-current="page"')->toContain('data-active')
|
||||
->and($inactive)->not->toContain('aria-current')->not->toContain('data-active')
|
||||
->and($symbol($active))->toBe(trim((string) $this->blade('<x-icon name="star" filled class="size-6" />')))
|
||||
->and($symbol($inactive))->toBe(trim((string) $this->blade('<x-icon name="star" class="size-6" />')))
|
||||
->and($symbol($active))->not->toBe($symbol($inactive));
|
||||
});
|
||||
|
||||
it('badges the icon with a dot or a count that screen readers hear', function () {
|
||||
$count = (string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" badge="1200" />');
|
||||
$dot = (string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" :badge="true" badge-label="New mail" />');
|
||||
|
||||
expect($count)->toContain('999+')->toContain('<span class="sr-only">, 1200</span>')
|
||||
->and($dot)->toContain('size-1.5')->toContain('<span class="sr-only">, New mail</span>')
|
||||
->and((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" />'))->not->toContain('sr-only');
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
it('draws each mode, falling back to collapsible', function (string $mode, string $expected) {
|
||||
expect((string) $this->blade('<x-navigation-rail :mode="$mode"><x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-rail>', ['mode' => $mode]))
|
||||
->toContain("data-navigation-rail=\"{$expected}\"")
|
||||
->toContain('<nav')
|
||||
->toContain('aria-label="Main"');
|
||||
})->with([
|
||||
['collapsed', 'collapsed'],
|
||||
['expanded', 'expanded'],
|
||||
['collapsible', 'collapsible'],
|
||||
['modal', 'modal'],
|
||||
['adaptive', 'adaptive'],
|
||||
['sideways', 'collapsible'],
|
||||
]);
|
||||
|
||||
it('gives the collapsible, modal and adaptive rails a menu button and the store', function () {
|
||||
$collapsible = (string) $this->blade('<x-navigation-rail />');
|
||||
|
||||
expect($collapsible)
|
||||
->toContain('x-data="materialNavigationRail(\'collapsible\')"')
|
||||
->toContain('data-navigation-rail-menu')
|
||||
->toContain('aria-label="Collapse navigation"')
|
||||
->toContain('aria-expanded="true"')
|
||||
->not->toContain('data-navigation-rail-scrim')
|
||||
->not->toContain('x-trap')
|
||||
->and((string) $this->blade('<x-navigation-rail mode="modal" />'))
|
||||
->toContain('aria-label="Expand navigation"')
|
||||
->toContain('data-navigation-rail-scrim')
|
||||
->toContain('x-trap.inert.noscroll="open"')
|
||||
->and((string) $this->blade('<x-navigation-rail mode="expanded" />'))
|
||||
->not->toContain('data-navigation-rail-menu')
|
||||
->not->toContain('x-data')
|
||||
->and((string) $this->blade('<x-navigation-rail mode="collapsible" :menu="false" />'))->not->toContain('data-navigation-rail-menu');
|
||||
});
|
||||
|
||||
it('keeps the header and footer out of the scrolling destinations', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-navigation-rail mode="expanded" width="20rem">
|
||||
<x-slot:brand><span>Brand</span></x-slot:brand>
|
||||
<x-slot:header><button>FAB</button></x-slot:header>
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" />
|
||||
<x-slot:footer><button>Account</button></x-slot:footer>
|
||||
</x-navigation-rail>
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('--navigation-rail-width: 20rem')
|
||||
->toMatch('/data-navigation-rail-header.*Brand.*FAB.*data-navigation-rail-destinations.*Inbox.*data-navigation-rail-footer.*Account/s');
|
||||
});
|
||||
|
||||
it('draws a destination in both shapes, with its count on the icon and at the end', function () {
|
||||
$html = (string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('<a data-navigation-rail-item')
|
||||
->toContain('wire:navigate')
|
||||
->toContain('aria-current="page"')
|
||||
->toContain('data-navigation-indicator')
|
||||
->toContain('<span data-navigation-label>Inbox</span>')
|
||||
->toContain('hidden rail-collapsed:contents')
|
||||
->toContain('rail-collapsed:hidden')
|
||||
->toContain('<span class="sr-only">, 12</span>')
|
||||
->and(substr_count($html, '>12</span>'))->toBe(2)
|
||||
->and((string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" no-wire-navigate />'))->not->toContain('wire:navigate')
|
||||
->and((string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" />'))->toContain('<button data-navigation-rail-item');
|
||||
});
|
||||
|
||||
it('groups destinations under a heading that names the group', function () {
|
||||
$html = (string) $this->blade('<x-navigation-rail-section label="Labels"><x-navigation-rail-item label="Travel" icon="flight" link="/travel" /></x-navigation-rail-section>');
|
||||
|
||||
preg_match('/aria-labelledby="([^"]+)"/', $html, $labelledBy);
|
||||
|
||||
expect($html)->toContain('role="group"')
|
||||
->and($labelledBy[1] ?? null)->not->toBeNull()
|
||||
->and($html)->toContain("<p id=\"{$labelledBy[1]}\" data-navigation-rail-heading>Labels</p>");
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
it('follows the operating system until the visitor chooses, by default', function () {
|
||||
$this->blade('<x-theme-script />')
|
||||
->assertSee('({"default":"system","key":"material-theme","legacy":[]})', false);
|
||||
->assertSee('({"default":"system","key":"material-theme","legacy":[],"rail":{"default":"expanded","key":"material-rail"}})', false);
|
||||
});
|
||||
|
||||
it('takes the application\'s default, key and legacy keys', function () {
|
||||
@@ -13,7 +13,7 @@ it('takes the application\'s default, key and legacy keys', function () {
|
||||
]]);
|
||||
|
||||
$this->blade('<x-theme-script />')
|
||||
->assertSee('({"default":"dark","key":"sealshare-theme","legacy":["mary-theme"]})', false);
|
||||
->assertSee('"default":"dark","key":"sealshare-theme","legacy":["mary-theme"]', false);
|
||||
});
|
||||
|
||||
it('falls back to the operating system for a default it does not know', function () {
|
||||
@@ -22,3 +22,23 @@ it('falls back to the operating system for a default it does not know', function
|
||||
$this->blade('<x-theme-script />')
|
||||
->assertSee('"default":"system"', false);
|
||||
});
|
||||
|
||||
it('starts a collapsible rail as the application says, expanded otherwise', function () {
|
||||
config(['livewire-material.rail' => ['default' => 'collapsed', 'storage_key' => 'sealshare-rail']]);
|
||||
|
||||
$this->blade('<x-theme-script />')
|
||||
->assertSee('"rail":{"default":"collapsed","key":"sealshare-rail"}', false)
|
||||
->assertSee("root.setAttribute('data-rail', rail);", false);
|
||||
|
||||
config(['livewire-material.rail.default' => 'sideways']);
|
||||
|
||||
$this->blade('<x-theme-script />')
|
||||
->assertSee('"rail":{"default":"expanded","key":"sealshare-rail"}', false);
|
||||
});
|
||||
|
||||
it('puts its attributes back on <html> when wire:navigate swaps the page', function () {
|
||||
$this->blade('<x-theme-script />')
|
||||
->assertSee("document.addEventListener('livewire:navigating'", false)
|
||||
->assertSee("['data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key']", false)
|
||||
->assertSee('event.detail.onSwap(', false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Exceptions\RegisterErrorViewPaths;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->temporary = sys_get_temp_dir().'/livewire-material-errors-'.Str::random(8);
|
||||
File::ensureDirectoryExists($this->temporary);
|
||||
|
||||
Route::middleware('web')->get('/abort/{code}', fn (string $code) => abort((int) $code, request()->query('message', '')));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
File::deleteDirectory($this->temporary);
|
||||
});
|
||||
|
||||
/**
|
||||
* Testbench's skeleton application ships an errors/503.blade.php of its own — an application
|
||||
* view, which rightly wins over the package's. A test of the package's 503 stands in an
|
||||
* application without one.
|
||||
*/
|
||||
function withoutSkeletonErrorViews(): void
|
||||
{
|
||||
$views = test()->temporary.'/views';
|
||||
|
||||
File::ensureDirectoryExists($views);
|
||||
|
||||
config(['view.paths' => [$views, LivewireMaterialServiceProvider::errorViewPath()]]);
|
||||
}
|
||||
|
||||
it('renders the package page for each status', function (int $code, string $headline, string $sentence) {
|
||||
withoutSkeletonErrorViews();
|
||||
|
||||
$this->withoutVite()
|
||||
->get("/abort/{$code}")
|
||||
->assertStatus($code)
|
||||
->assertSee('data-error-page', false)
|
||||
->assertSee("{$code}</p>", false)
|
||||
->assertSee($headline)
|
||||
->assertSee($sentence);
|
||||
})->with([
|
||||
[403, 'You don’t have access', 'Your account isn’t allowed to open this page.'],
|
||||
[404, 'Page not found', 'The page you’re looking for doesn’t exist or has moved.'],
|
||||
[419, 'This page has expired', 'Refresh the page'],
|
||||
[429, 'Slow down a little', 'Wait a moment, then try again.'],
|
||||
[500, 'Something went wrong', 'An error on our side'],
|
||||
[503, 'We’ll be right back', 'We’re making some improvements.'],
|
||||
]);
|
||||
|
||||
it('draws the framework\'s own error pages in the package layout', function () {
|
||||
$this->withoutVite()
|
||||
->get('/abort/401')
|
||||
->assertStatus(401)
|
||||
->assertSee('data-error-page', false)
|
||||
->assertSee('Unauthorized')
|
||||
->assertSee('Go home');
|
||||
});
|
||||
|
||||
it('appends the error-view root to view.paths after the application\'s, before the view finder is built', function () {
|
||||
$root = LivewireMaterialServiceProvider::errorViewPath();
|
||||
|
||||
expect(realpath($root))->toBe(realpath(__DIR__.'/../../resources/views/error-pages'))
|
||||
->and(File::directories($root))->toBe([$root.'/errors'])
|
||||
->and(config('view.paths'))->toBe([resource_path('views'), $root])
|
||||
->and(app()->viewPath())->toBe(resource_path('views'))
|
||||
->and(app('view')->getFinder()->getPaths())->toContain($root);
|
||||
|
||||
$provider = app()->getProvider(LivewireMaterialServiceProvider::class);
|
||||
(fn () => $this->registerErrorViews())->call($provider);
|
||||
|
||||
expect(config('view.paths'))->toBe([resource_path('views'), $root]);
|
||||
});
|
||||
|
||||
it('lets the application\'s own error view win', function () {
|
||||
File::ensureDirectoryExists($this->temporary.'/errors');
|
||||
File::put($this->temporary.'/errors/404.blade.php', 'The application view');
|
||||
|
||||
config(['view.paths' => [$this->temporary, ...config('view.paths')]]);
|
||||
|
||||
$this->withoutVite()
|
||||
->get('/abort/404')
|
||||
->assertNotFound()
|
||||
->assertSee('The application view')
|
||||
->assertDontSee('data-error-page', false);
|
||||
|
||||
$this->get('/abort/403')->assertSee('data-error-page', false);
|
||||
});
|
||||
|
||||
it('loads the application\'s Vite entries', function () {
|
||||
File::ensureDirectoryExists($this->temporary.'/build');
|
||||
File::put($this->temporary.'/build/manifest.json', json_encode([
|
||||
'workbench/resources/css/app.css' => ['file' => 'assets/app-probe.css', 'src' => 'workbench/resources/css/app.css', 'isEntry' => true],
|
||||
'workbench/resources/js/app.js' => ['file' => 'assets/app-probe.js', 'src' => 'workbench/resources/js/app.js', 'isEntry' => true],
|
||||
]));
|
||||
|
||||
app()->usePublicPath($this->temporary);
|
||||
Vite::useHotFile($this->temporary.'/hot');
|
||||
|
||||
$this->get('/abort/404')
|
||||
->assertNotFound()
|
||||
->assertSee('build/assets/app-probe.css', false)
|
||||
->assertSee('build/assets/app-probe.js', false)
|
||||
->assertDontSee('data-error-fallback', false);
|
||||
});
|
||||
|
||||
it('still renders, in the application\'s scheme, when the Vite manifest is missing', function () {
|
||||
File::put($this->temporary.'/material-scheme.json', json_encode([
|
||||
'light' => ['surface' => '#fafaf0', 'primary' => '#123456'],
|
||||
'dark' => ['surface' => '#101010'],
|
||||
]));
|
||||
|
||||
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
|
||||
app()->usePublicPath($this->temporary);
|
||||
Vite::useHotFile($this->temporary.'/hot');
|
||||
|
||||
$this->get('/abort/500')
|
||||
->assertStatus(500)
|
||||
->assertSee('Something went wrong')
|
||||
->assertSee('<style data-error-fallback>', false)
|
||||
->assertSee('--md-sys-color-surface: #fafaf0;', false)
|
||||
->assertSee('--md-sys-color-primary: #123456;', false)
|
||||
->assertSee('--md-sys-color-surface: #101010;', false)
|
||||
->assertSee('--md-sys-color-on-surface: #34313a;', false)
|
||||
->assertDontSee('/build/', false);
|
||||
});
|
||||
|
||||
it('shows the message an application passed for 403 and 503', function (int $code, string $message) {
|
||||
withoutSkeletonErrorViews();
|
||||
|
||||
$this->withoutVite()
|
||||
->get("/abort/{$code}?message=".urlencode($message))
|
||||
->assertStatus($code)
|
||||
->assertSee($message);
|
||||
})->with([
|
||||
[403, 'Only the owner can open this share.'],
|
||||
[503, 'Back at 14:00 after the upgrade.'],
|
||||
]);
|
||||
|
||||
it('does not take maintenance mode\'s "Service Unavailable" for a message', function () {
|
||||
withoutSkeletonErrorViews();
|
||||
|
||||
$this->withoutVite()
|
||||
->get('/abort/503?message=Service+Unavailable')
|
||||
->assertStatus(503)
|
||||
->assertSee('We’re making some improvements.')
|
||||
->assertDontSee('>Service Unavailable<', false);
|
||||
});
|
||||
|
||||
it('prerenders the 503 page for maintenance mode, without an exception', function () {
|
||||
withoutSkeletonErrorViews();
|
||||
$this->withoutVite();
|
||||
|
||||
(new RegisterErrorViewPaths)();
|
||||
|
||||
expect(view('errors::503', ['retryAfter' => 60])->render())
|
||||
->toContain('data-error-page')
|
||||
->toContain('We’ll be right back')
|
||||
->toContain('location.reload()');
|
||||
});
|
||||
|
||||
it('offers Back only when the visitor came from another page', function () {
|
||||
$this->withoutVite()
|
||||
->get('/abort/404')
|
||||
->assertSee('Go home')
|
||||
->assertDontSee('Go back');
|
||||
|
||||
$this->withoutVite()
|
||||
->get('/abort/404', ['Referer' => url('/shares')])
|
||||
->assertSee('Go back')
|
||||
->assertSee('href="'.url('/shares').'"', false);
|
||||
});
|
||||
|
||||
it('sends an expired form back to its page to refresh', function () {
|
||||
$this->withoutVite()
|
||||
->get('/abort/419', ['Referer' => url('/settings')])
|
||||
->assertStatus(419)
|
||||
->assertSee('href="'.url('/settings').'"', false)
|
||||
->assertSee('Refresh the page');
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Markdown;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
||||
|
||||
class ThemedProbeMail extends Mailable
|
||||
{
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(markdown: 'mail-theme-probe');
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->temporary = sys_get_temp_dir().'/livewire-material-mail-'.Str::random(8);
|
||||
File::ensureDirectoryExists($this->temporary);
|
||||
|
||||
File::put($this->temporary.'/mail-theme-probe.blade.php', <<<'BLADE'
|
||||
<x-mail::message>
|
||||
# Your export is ready
|
||||
|
||||
The report has **finished**.
|
||||
|
||||
<x-mail::button :url="'https://example.com/export'">
|
||||
Download
|
||||
</x-mail::button>
|
||||
|
||||
<x-mail::panel>
|
||||
Files expire after seven days.
|
||||
</x-mail::panel>
|
||||
|
||||
<x-mail::table>
|
||||
| File | Size |
|
||||
|:-----|-----:|
|
||||
| orders.csv | 1.2 MB |
|
||||
</x-mail::table>
|
||||
</x-mail::message>
|
||||
BLADE);
|
||||
|
||||
File::put($this->temporary.'/mail-footer-probe.blade.php', <<<'BLADE'
|
||||
<x-mail::message>
|
||||
Hello.
|
||||
|
||||
<x-slot:footer>
|
||||
Sent by the probe · [Unsubscribe](https://example.com/unsubscribe)
|
||||
</x-slot:footer>
|
||||
</x-mail::message>
|
||||
BLADE);
|
||||
|
||||
View::addLocation($this->temporary);
|
||||
|
||||
config(['mail.markdown.theme' => 'livewire-material::mail.theme']);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
File::deleteDirectory($this->temporary);
|
||||
});
|
||||
|
||||
/**
|
||||
* The inline style of the first element carrying the class.
|
||||
*/
|
||||
function inlineStyle(string $html, string $class): string
|
||||
{
|
||||
preg_match('/<[^>]+class="[^"]*\b'.preg_quote($class, '/').'\b[^"]*"[^>]*style="([^"]*)"/', $html, $match);
|
||||
|
||||
return $match[1] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the package's mail components on `mail.markdown.paths`, as the provider does when
|
||||
* `livewire-material.mail.components` is on at boot, and make the renderer again.
|
||||
*/
|
||||
function withPackageMailComponents(): void
|
||||
{
|
||||
config(['livewire-material.mail.components' => true]);
|
||||
|
||||
$provider = app()->getProvider(LivewireMaterialServiceProvider::class);
|
||||
(fn () => $this->registerMailComponents())->call($provider);
|
||||
|
||||
app()->forgetInstance(Markdown::class);
|
||||
}
|
||||
|
||||
it('inlines the application\'s scheme onto the mail', function () {
|
||||
File::put($this->temporary.'/material-scheme.json', json_encode([
|
||||
'light' => ['primary' => '#123456', 'on-primary' => '#fefefe', 'surface-container' => '#eeeeee', 'surface-container-lowest' => '#fdfdfd'],
|
||||
'dark' => ['primary' => '#abcdef'],
|
||||
]));
|
||||
|
||||
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
|
||||
|
||||
$html = (new ThemedProbeMail)->render();
|
||||
|
||||
expect(inlineStyle($html, 'button-primary'))
|
||||
->toContain('background-color: #123456')
|
||||
->toContain('border-top: 16px solid #123456')
|
||||
->toContain('color: #fefefe')
|
||||
->toContain('border-radius: 9999px')
|
||||
->and(inlineStyle($html, 'inner-body'))->toContain('background-color: #fdfdfd')
|
||||
->and(inlineStyle($html, 'wrapper'))->toContain('background-color: #eeeeee')
|
||||
->and(inlineStyle($html, 'panel-content'))->toContain('background-color: #eeeeee')
|
||||
->and($html)
|
||||
->toMatch('/<h1 style="[^"]*font-size: 24px[^"]*line-height: 32px/')
|
||||
->toMatch('/<p style="[^"]*font-size: 16px[^"]*letter-spacing: 0.5px/')
|
||||
->toMatch('/<th [^>]*style="[^"]*font-weight: 500/')
|
||||
->toMatch('/<th align="right" style="[^"]*text-align: right/')
|
||||
->not->toContain('#abcdef')
|
||||
->not->toContain('var(--')
|
||||
->not->toContain('color-mix');
|
||||
});
|
||||
|
||||
it('takes the theme from a mailable too', function () {
|
||||
config(['mail.markdown.theme' => 'default']);
|
||||
|
||||
$mail = new ThemedProbeMail;
|
||||
$mail->theme = 'livewire-material::mail.theme';
|
||||
|
||||
expect(inlineStyle($mail->render(), 'button-primary'))->toContain('border-radius: 9999px');
|
||||
});
|
||||
|
||||
it('falls back to the package\'s default scheme', function (?string $contents) {
|
||||
$path = $this->temporary.'/material-scheme.json';
|
||||
|
||||
if ($contents !== null) {
|
||||
File::put($path, $contents);
|
||||
}
|
||||
|
||||
config(['livewire-material.scheme' => $path]);
|
||||
|
||||
$default = json_decode(File::get(__DIR__.'/../../resources/css/tokens/scheme.json'), true);
|
||||
|
||||
expect(inlineStyle((new ThemedProbeMail)->render(), 'button-primary'))
|
||||
->toContain("background-color: {$default['light']['primary']}");
|
||||
})->with([
|
||||
'no scheme file' => [null],
|
||||
'not JSON' => ['{ nope'],
|
||||
'not a colour' => [json_encode(['light' => ['primary' => 'red; background: url(x)']])],
|
||||
]);
|
||||
|
||||
it('styles every button colour Laravel and M3 name', function () {
|
||||
$css = view('livewire-material::mail.theme')->render();
|
||||
|
||||
foreach (['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info', 'blue', 'green', 'red'] as $color) {
|
||||
expect($css)->toContain(".button-{$color} {");
|
||||
}
|
||||
|
||||
expect($css)->not->toContain('@media');
|
||||
});
|
||||
|
||||
it('leaves the application\'s mail components alone unless asked', function () {
|
||||
expect(config('mail.markdown.paths'))->not->toContain(LivewireMaterialServiceProvider::mailComponentPath());
|
||||
|
||||
withPackageMailComponents();
|
||||
withPackageMailComponents();
|
||||
|
||||
expect(config('mail.markdown.paths'))->toBe([
|
||||
resource_path('views/vendor/mail'),
|
||||
LivewireMaterialServiceProvider::mailComponentPath(),
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders the package header: the app name, never Laravel\'s logo', function () {
|
||||
config(['app.name' => 'Laravel']);
|
||||
|
||||
$framework = (new ThemedProbeMail)->render();
|
||||
|
||||
withPackageMailComponents();
|
||||
|
||||
$html = (new ThemedProbeMail)->render();
|
||||
|
||||
expect($framework)->toContain('notification-logo')
|
||||
->and($html)->not->toContain('notification-logo')
|
||||
->toMatch('/<td class="header"[^>]*>\s*<a [^>]*>\s*Laravel\s*<\/a>/');
|
||||
});
|
||||
|
||||
it('renders a configured logo in the header, sized by attributes', function () {
|
||||
config(['app.name' => 'Probe', 'livewire-material.mail.logo' => ['src' => 'https://example.com/logo.png', 'width' => 160, 'height' => 40]]);
|
||||
|
||||
withPackageMailComponents();
|
||||
|
||||
expect((new ThemedProbeMail)->render())
|
||||
->toMatch('/<img src="https:\/\/example.com\/logo.png" class="logo" alt="Probe" width="160" height="40"/');
|
||||
});
|
||||
|
||||
it('lets a mail replace the footer, in HTML and in text', function () {
|
||||
config(['app.name' => 'Probe']);
|
||||
|
||||
withPackageMailComponents();
|
||||
|
||||
$markdown = app(Markdown::class);
|
||||
|
||||
expect((string) $markdown->render('mail-footer-probe'))
|
||||
->toContain('Sent by the probe')
|
||||
->toContain('href="https://example.com/unsubscribe"')
|
||||
->not->toContain('All rights reserved.')
|
||||
->and((string) $markdown->renderText('mail-footer-probe'))
|
||||
->toContain('Sent by the probe')
|
||||
->not->toContain('All rights reserved.')
|
||||
->and((string) $markdown->render('mail-theme-probe'))
|
||||
->toContain('© '.date('Y').' Probe. All rights reserved.');
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
||||
|
||||
/**
|
||||
@@ -35,3 +37,19 @@ it('registers the components under a configured prefix', function () {
|
||||
it('keeps blade-icons from registering an <x-icon> that would shadow ours', function () {
|
||||
expect(Blade::getClassComponentAliases())->not->toHaveKey('icon');
|
||||
});
|
||||
|
||||
it('publishes the error pages into the application\'s errors folder', function () {
|
||||
expect(ServiceProvider::pathsToPublish(LivewireMaterialServiceProvider::class, 'livewire-material-errors'))->toBe([
|
||||
LivewireMaterialServiceProvider::errorViewPath().'/errors' => resource_path('views/errors'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('publishes the mail components where Laravel looks for them', function () {
|
||||
expect(ServiceProvider::pathsToPublish(LivewireMaterialServiceProvider::class, 'livewire-material-mail'))->toBe([
|
||||
LivewireMaterialServiceProvider::mailComponentPath().'/html' => resource_path('views/vendor/mail/html'),
|
||||
LivewireMaterialServiceProvider::mailComponentPath().'/text' => resource_path('views/vendor/mail/text'),
|
||||
])->and(config('mail.markdown.paths'))->toContain(resource_path('views/vendor/mail'));
|
||||
|
||||
expect(File::files(LivewireMaterialServiceProvider::mailComponentPath().'/html'))->not->toBeEmpty()
|
||||
->and(File::files(LivewireMaterialServiceProvider::mailComponentPath().'/text'))->not->toBeEmpty();
|
||||
});
|
||||
|
||||
@@ -51,3 +51,20 @@ it('lists the symbol names for the icon search', function () {
|
||||
->assertJsonFragment(['calendar_month'])
|
||||
->assertJsonCount(count(SvgFile::symbolNames()));
|
||||
});
|
||||
|
||||
it('previews an error page through the exception handler', function () {
|
||||
$this->withoutVite()
|
||||
->get('/material/errors/404')
|
||||
->assertNotFound()
|
||||
->assertSee('Page not found');
|
||||
|
||||
$this->get('/material/errors/418')->assertNotFound();
|
||||
});
|
||||
|
||||
it('previews a sample mail in the package theme', function () {
|
||||
$this->get('/material/mail')
|
||||
->assertOk()
|
||||
->assertSee('class="button button-primary"', false)
|
||||
->assertSee('border-radius: 9999px', false)
|
||||
->assertSee('Unsubscribe');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user