Add M3 error pages and the Markdown mail theme
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (safari, webkit) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m11s
tests / browser (chrome, chromium) (push) Failing after 6m9s
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (safari, webkit) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m11s
tests / browser (chrome, chromium) (push) Failing after 6m9s
Error pages for 401 to 503 in an error-view root appended to view.paths, with an inline fallback when the Vite build is missing, and a mail theme rendered from the application's scheme JSON, with opt-in header and message components. Completes Phase 9. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
co-authored by
Claude Opus 5
parent
ab692b66bb
commit
8f174520eb
@@ -91,6 +91,43 @@ return [
|
|||||||
|
|
||||||
'node' => env('MATERIAL_NODE', 'node'),
|
'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
|
| Showcase
|
||||||
@@ -98,7 +135,8 @@ return [
|
|||||||
|
|
|
|
||||||
| Every component in every variant, rendered in the application's own
|
| Every component in every variant, rendered in the application's own
|
||||||
| scheme. Off unless the application runs locally. 'vite' names the entry
|
| 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.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -557,6 +557,20 @@ worktrees. What changed from the step above:
|
|||||||
`material-scheme.json` (falling back to the default scheme); `html/header` and
|
`material-scheme.json` (falling back to the default scheme); `html/header` and
|
||||||
`html/message` overrides; typescale on bare tags; filled primary button.
|
`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`
|
### Phase 10 — `1.0.0`
|
||||||
|
|
||||||
33. Showcase complete (every component, variant, colour, size and state, both themes); the
|
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.
|
- 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.
|
- 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).
|
- 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
|
@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).
|
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
|
## Components
|
||||||
|
|
||||||
### `<x-icon>`
|
### `<x-icon>`
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -26,5 +26,6 @@
|
|||||||
@include('livewire-material::showcase.sections.bars')
|
@include('livewire-material::showcase.sections.bars')
|
||||||
@include('livewire-material::showcase.sections.navigation')
|
@include('livewire-material::showcase.sections.navigation')
|
||||||
@include('livewire-material::showcase.sections.data')
|
@include('livewire-material::showcase.sections.data')
|
||||||
|
@include('livewire-material::showcase.sections.pages')
|
||||||
</main>
|
</main>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
|
<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">
|
<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', 'navigation' => 'Navigation', '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>
|
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
</nav>
|
</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,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>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcasePageController;
|
||||||
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseSymbolController;
|
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseSymbolController;
|
||||||
|
|
||||||
Route::view('/', 'livewire-material::showcase.index')->name('showcase');
|
Route::view('/', 'livewire-material::showcase.index')->name('showcase');
|
||||||
@@ -11,3 +12,8 @@ Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show
|
|||||||
Route::view('shell/{page?}', 'livewire-material::showcase.shell')
|
Route::view('shell/{page?}', 'livewire-material::showcase.shell')
|
||||||
->whereIn('page', ['inbox', 'starred', 'sent', 'drafts', 'travel', 'receipts'])
|
->whereIn('page', ['inbox', 'starred', 'sent', 'drafts', 'travel', 'receipts'])
|
||||||
->name('shell');
|
->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->mergeConfigFrom(__DIR__.'/../config/livewire-material.php', 'livewire-material');
|
||||||
|
|
||||||
$this->disableBladeIconsComponent();
|
$this->disableBladeIconsComponent();
|
||||||
|
$this->registerErrorViews();
|
||||||
|
$this->registerMailComponents();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
@@ -33,6 +35,71 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
|||||||
$this->publishes([
|
$this->publishes([
|
||||||
__DIR__.'/../config/livewire-material.php' => config_path('livewire-material.php'),
|
__DIR__.'/../config/livewire-material.php' => config_path('livewire-material.php'),
|
||||||
], 'livewire-material-config');
|
], '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,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\Collection;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
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 () {
|
it('keeps blade-icons from registering an <x-icon> that would shadow ours', function () {
|
||||||
expect(Blade::getClassComponentAliases())->not->toHaveKey('icon');
|
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'])
|
->assertJsonFragment(['calendar_month'])
|
||||||
->assertJsonCount(count(SvgFile::symbolNames()));
|
->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