Add the snackbar, badges, rich tooltips, alerts, stats and empty states
tests / lint (push) Successful in 1m3s
tests / feature (8.4) (push) Failing after 1m5s
tests / feature (8.5) (push) Failing after 1m4s
tests / browser (chrome, chromium) (push) Failing after 1m5s
tests / browser (firefox, firefox) (push) Failing after 1m1s
tests / browser (safari, webkit) (push) Failing after 1m1s

<x-toast> hosts the snackbar queue for the Toasts concern and
window.materialToast(), one at a time, paused on hover and focus, with
an optional action; it listens from the moment its script loads, so a
toast dispatched before Alpine starts is shown rather than lost.
<x-badge> is M3's dot and count, plus a tonal or outlined status label;
<x-rich-tooltip> is transient or persistent; <x-alert>, <x-stat> and
<x-empty-state> are built from M3's parts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 06:59:40 +02:00
co-authored by Claude Opus 5
parent cd64f4f371
commit 648ad8efaf
20 changed files with 813 additions and 1 deletions
@@ -213,6 +213,55 @@ M3 Expressive's loading indicator — a shape morphing through seven Expressive
`<x-button spinner>` shows a decorative one in place of its icon. `<x-button spinner>` shows a decorative one in place of its icon.
### `<x-toast>`
The snackbar host. Once per layout, near the end of `<body>`: `<x-toast />` (`position="bottom-start"` to leave the centre free). It is `@persist`ed across `wire:navigate` and shows, one at a time, every toast from the `Toasts` concern (see Toasts above) or from JavaScript:
```js
materialToast('Share deleted', { type: 'success', description: null, timeout: 4000, action: { label: 'Undo', handler: () => $wire.restore() } })
```
`type` (`success`, `error`, `warning`, `info`) adds the state icon; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer.
### `<x-badge>`
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: `<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
### `<x-alert>`
A notice in the page, in the state's container colour with its icon:
```blade
<x-alert title="Storage almost full" description="3.8 GB of 4 GB used." color="warning" />
<x-alert color="error" dismissible>
The upload failed.
<x-slot:actions><x-button label="Try again" wire:click="retry" /></x-slot:actions>
</x-alert>
```
`color` (alias `tone`): `info` (default), `success`, `warning`, `error`, `primary`, `secondary`, `tertiary`, `neutral`. `icon` overrides the state icon; `:icon="false"` removes it. Errors and warnings are `role="alert"`, the rest `role="status"`.
### `<x-rich-tooltip>`
A few lines of context around a trigger, with an optional `title` and `actions` slot:
```blade
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time.">
<x-button icon="help" aria-label="About expiry" />
</x-rich-tooltip>
```
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). `side`: `bottom` (default), `top`, `left`, `right`.
### `<x-stat>`
`<x-stat title="Shares" value="1,204" icon="link" description="12 this week" />` — a figure on a surface-container panel; the value counts up on first appearance and when it changes. The slot goes under the description (e.g. a quota's `<x-progress>`). Do not pass a `bg-*` class; wrap it.
### `<x-empty-state>`
"Nothing here yet": `icon` on an Expressive `shape` (`cookie-9` by default), `title`, `description` or slot, and an `actions` slot. Use it for an empty collection, not for a filter that matched nothing.
## Testing the design ## Testing the design
```php ```php
+3
View File
@@ -11,3 +11,6 @@ import './theme.js'
import './figure.js' import './figure.js'
import './tooltip.js' import './tooltip.js'
import './menu.js' import './menu.js'
import './snackbar.js'
import './rich-tooltip.js'
import './progress.js'
+53
View File
@@ -0,0 +1,53 @@
/**
* `materialRichTooltip`: shows an `<x-rich-tooltip>`.
*
* Transient (the default): like a plain tooltip — after a short hover on a pointer that can hover,
* at once on keyboard focus, and it stays while the pointer moves onto the bubble to reach its
* actions. Persistent: a press on the trigger opens it as a light-dismiss popover.
*/
const HOVER_DELAY_MS = 500
const LEAVE_GRACE_MS = 200
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialRichTooltip', (persistent = false) => ({
timer: null,
init() {
const bubble = this.$refs.bubble
const wrapper = this.$el
const open = () => bubble.matches(':popover-open')
if (persistent) {
wrapper.addEventListener('click', (event) => {
if (bubble.contains(event.target)) {
return
}
open() ? bubble.hidePopover() : bubble.showPopover()
})
return
}
const show = (delay) => {
clearTimeout(this.timer)
this.timer = setTimeout(() => !open() && bubble.showPopover(), delay)
}
const hide = (delay = 0) => {
clearTimeout(this.timer)
this.timer = setTimeout(() => open() && bubble.hidePopover(), delay)
}
wrapper.addEventListener('pointerenter', (event) => event.pointerType === 'mouse' && show(HOVER_DELAY_MS))
wrapper.addEventListener('pointerleave', () => hide(LEAVE_GRACE_MS))
wrapper.addEventListener('focusin', (event) => event.target.matches(':focus-visible') && show(0))
wrapper.addEventListener('focusout', (event) => !wrapper.contains(event.relatedTarget) && hide())
document.addEventListener('keydown', (event) => event.key === 'Escape' && hide())
},
destroy() {
clearTimeout(this.timer)
},
}))
})
+108
View File
@@ -0,0 +1,108 @@
/**
* `materialSnackbar`: the queue behind `<x-toast>`, and `window.materialToast()`.
*
* One snackbar at a time, as M3 shows them. Each waits its turn, stays for its timeout (paused
* while hovered or focused, so it is never pulled away from someone reading or reaching for its
* action) and is replaced by the next.
*/
const DEFAULT_TIMEOUT_MS = 4000
let sequence = 0
// The listener lives here, not on the Alpine component: a toast dispatched before Alpine has
// started (on page load, straight after a redirect) would otherwise be lost. Until a host
// registers, toasts wait in `pending`.
let host = null
const pending = []
window.addEventListener('toast', (event) => (host ? host.add(event.detail) : pending.push(event.detail)))
window.materialToast = (title, options = {}) => {
window.dispatchEvent(new CustomEvent('toast', { detail: { title, ...options } }))
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialSnackbar', () => ({
queue: [],
current: null,
timer: null,
remaining: 0,
startedAt: 0,
init() {
host = this
pending.splice(0).forEach((detail) => this.add(detail))
},
destroy() {
if (host === this) {
host = null
}
},
add(detail) {
// Livewire dispatches named arguments as the detail object; a positional dispatch
// arrives as an array whose first entry is that object.
const toast = Array.isArray(detail) ? detail[0] : detail
this.queue.push({
id: ++sequence,
type: toast.type ?? null,
title: toast.title ?? '',
description: toast.description ?? null,
timeout: toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS),
action: toast.action ?? null,
})
if (!this.current) {
this.next()
}
},
next() {
clearTimeout(this.timer)
this.current = this.queue.shift() ?? null
if (this.current?.timeout) {
this.remaining = this.current.timeout
this.resume()
}
},
pause() {
if (!this.current?.timeout || !this.timer) {
return
}
clearTimeout(this.timer)
this.timer = null
this.remaining -= performance.now() - this.startedAt
},
resume() {
if (!this.current?.timeout || this.timer) {
return
}
this.startedAt = performance.now()
this.timer = setTimeout(() => {
this.timer = null
this.next()
}, Math.max(this.remaining, 0))
},
dismiss() {
this.timer = null
this.next()
},
act() {
this.current?.action?.handler?.()
this.dismiss()
},
icon(type) {
return ['success', 'error', 'warning', 'info'].includes(type)
},
}))
})
@@ -0,0 +1,65 @@
{{-- A notice in the page: a state that needs saying storage is full, the link has expired.
Not an M3 component (M3's banner is gone); drawn in M3's terms: the state's container colour
(tinted, never filled), a medium corner, the state's icon, a title-small `title`, body-medium
text from `description` or the slot, and an `actions` slot for one or two text buttons.
`color` (alias `tone`): `info` (the default), `success`, `warning`, `error`, `primary`,
`secondary`, `tertiary`, or `neutral` for surface-container-high. `icon` replaces the state's
icon; `:icon="false"` drops it. `dismissible` adds a close button that hides it in the browser.
Errors and warnings are `role="alert"` and announced at once; the rest are `role="status"`. --}}
@props([
'title' => null,
'description' => null,
'color' => null,
'tone' => null,
'icon' => null,
'dismissible' => false,
])
@php
$color = in_array($color ?? $tone, ['info', 'success', 'warning', 'error', 'primary', 'secondary', 'tertiary', 'neutral'], true) ? ($color ?? $tone) : 'info';
$icon = $icon === false ? null : ($icon ?? [
'info' => 'info', 'success' => 'check_circle', 'warning' => 'warning', 'error' => 'error',
'primary' => 'info', 'secondary' => 'info', 'tertiary' => 'info', 'neutral' => 'info',
][$color]);
$colours = [
'info' => 'bg-info-container text-on-info-container', 'success' => 'bg-success-container text-on-success-container',
'warning' => 'bg-warning-container text-on-warning-container', 'error' => 'bg-error-container text-on-error-container',
'primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container',
'tertiary' => 'bg-tertiary-container text-on-tertiary-container', 'neutral' => 'bg-surface-container-high text-on-surface',
][$color];
@endphp
<div
role="{{ in_array($color, ['error', 'warning'], true) ? 'alert' : 'status' }}"
@if ($dismissible) x-data="{ shown: true }" x-show="shown" x-transition.opacity @endif
{{ $attributes->class(['flex items-start gap-3 rounded-corner-md p-4', $colours]) }}
>
@if ($icon)
<x-icon :name="$icon" filled class="size-6" />
@endif
<div class="min-w-0 flex-1 space-y-1 self-center">
@if ($title)
<p class="type-title-sm">{{ $title }}</p>
@endif
@if ($description || $slot->isNotEmpty())
<div class="type-body-md">{{ $description ?? $slot }}</div>
@endif
@isset($actions)
<div class="-ms-3 flex flex-wrap gap-2 pt-1">{{ $actions }}</div>
@endisset
</div>
@if ($dismissible)
<button type="button" class="state-layer focus-ring -m-2 inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="shown = false">
<x-icon name="close" class="size-5" />
</button>
@endif
</div>
@@ -0,0 +1,67 @@
{{-- A badge: M3's count or dot on an icon, or a short status label.
M3's two badges (BadgeTokens, androidx Compose Material 3, Apache-2.0):
- `<x-badge />` the small badge, a 6px dot: something new, no number.
- `<x-badge value="3" />` the large badge, 16px tall, label-small: a count. `max` caps what is
shown ("99+").
Both are `error` by default, as M3 draws them. `floating` pins one to the top-end corner of a
`relative` parent an icon or an icon button:
<span class="relative inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
The status label is not an M3 badge but every app needs one: `tonal` draws the value in the
colour's container ("Expired" in error-container), `outline` in a neutral edge. `color` (alias
`tone`): `error` (the default), `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
A count or dot says nothing to a screen reader on its own: give the icon's control a label
that includes it ("Notifications, 4 new"), or pass `label` here. --}}
@props([
'value' => null,
'max' => null,
'color' => null,
'tone' => null,
'tonal' => false,
'outline' => false,
'floating' => false,
'label' => null,
])
@php
$color = in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) ? ($color ?? $tone) : 'error';
$text = $value ?? ($slot->isNotEmpty() ? trim((string) $slot) : null);
$dot = blank($text);
$status = ($tonal || $outline) && ! $dot;
if (! $dot && $max !== null && is_numeric($text) && (int) $text > (int) $max) {
$text = $max.'+';
}
$filled = [
'primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary',
'error' => 'bg-error text-on-error', 'success' => 'bg-success text-on-success', 'warning' => 'bg-warning text-on-warning', 'info' => 'bg-info text-on-info',
];
$container = [
'primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container',
'error' => 'bg-error-container text-on-error-container', 'success' => 'bg-success-container text-on-success-container', 'warning' => 'bg-warning-container text-on-warning-container', 'info' => 'bg-info-container text-on-info-container',
];
$attributes = $attributes
->class([
'inline-flex shrink-0 items-center justify-center whitespace-nowrap',
'size-1.5 rounded-corner-full' => $dot,
'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums' => ! $dot && ! $status,
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
$filled[$color] => ! $status,
$container[$color] => $tonal && ! $dot,
'border border-outline-variant text-on-surface-variant' => $outline && ! $tonal && ! $dot,
'absolute top-0.5 end-0.5' => $floating && $dot,
'absolute -top-1 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
])
->merge(array_filter([
'aria-label' => $label,
'aria-hidden' => $label === null && ! $status ? 'true' : null,
]));
@endphp
<span {{ $attributes }}>@unless ($dot){{ $text }}@endunless</span>
@@ -0,0 +1,37 @@
{{-- "You have nothing here yet": an Expressive shape with an icon on it, a title, a line of
explanation and the action that fills the space.
<x-empty-state icon="upload_file" title="No shares yet" description="Files you share appear here.">
<x-slot:actions><x-button label="Upload files" variant="filled" link="/upload" /></x-slot:actions>
</x-empty-state>
Not an M3 component; built from M3 Expressive's parts — `shape` (any `<x-shape>` name,
`cookie-9` by default) in secondary-container behind the `icon` in on-secondary-container,
a title-large `title`, body-medium `description` or slot. For "your filter matched nothing"
use a plain line of text instead: a picture there is consolation for a typo. --}}
@props([
'icon' => 'inbox',
'shape' => 'cookie-9',
'title' => null,
'description' => null,
])
<div {{ $attributes->class('flex flex-col items-center gap-4 px-4 py-10 text-center') }}>
<div class="relative grid size-28 place-items-center">
<x-shape :name="$shape" class="absolute inset-0 size-full text-secondary-container" />
<x-icon :name="$icon" class="relative size-12 text-on-secondary-container" />
</div>
@if ($title)
<h3 class="type-title-lg">{{ $title }}</h3>
@endif
@if ($description || $slot->isNotEmpty())
<div class="max-w-md type-body-md text-on-surface-variant">{{ $description ?? $slot }}</div>
@endif
@isset($actions)
<div class="flex flex-wrap justify-center gap-3 pt-2">{{ $actions }}</div>
@endisset
</div>
@@ -0,0 +1,62 @@
{{-- An M3 rich tooltip: a few lines of context for a control, with an optional subhead and actions.
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time. Admins can change the longest allowed.">
<x-button icon="help" aria-label="About expiry" />
<x-slot:actions><x-button label="Learn more" link="/help/expiry" /></x-slot:actions>
</x-rich-tooltip>
It wraps its trigger. By default it shows on hover and keyboard focus like a plain tooltip;
`persistent` makes it open on press instead and stay until a press elsewhere or Escape the
form M3 asks for when it has actions. The bubble is a popover in surface-container with a
medium corner and elevation 2, 312px at most, placed by anchor positioning on `side`.
RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium
text in on-surface-variant, label-large actions in primary. --}}
@props([
'title' => null,
'text' => null,
'side' => 'bottom',
'persistent' => false,
])
@php
$side = in_array($side, ['top', 'bottom', 'left', 'right'], true) ? $side : 'bottom';
$key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
$anchor = "--material-rich-tooltip-{$key}";
@endphp
<span
{{ $attributes->class('inline-flex') }}
style="anchor-name: {{ $anchor }}"
x-data="materialRichTooltip({{ $persistent ? 'true' : 'false' }})"
>
{{ $trigger ?? $slot }}
<span
x-ref="bubble"
id="material-rich-tooltip-{{ $key }}"
popover="{{ $persistent ? 'auto' : 'manual' }}"
role="{{ $persistent ? 'dialog' : 'tooltip' }}"
@if ($title) aria-label="{{ $title }}" @endif
style="position-anchor: {{ $anchor }}"
@class([
'm-0 w-max max-w-78 overflow-visible border-0 rounded-corner-md bg-surface-container px-4 pt-3 pb-2 text-start whitespace-normal shadow-elevation-2 [inset:auto]',
'opacity-0 transition-[opacity,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
'my-1 [position-area:bottom_span-right] [position-try-fallbacks:flip-block,flip-inline]' => $side === 'bottom',
'my-1 [position-area:top_span-right] [position-try-fallbacks:flip-block,flip-inline]' => $side === 'top',
'mx-1 [position-area:left_span-bottom] [position-try-fallbacks:flip-inline,flip-block]' => $side === 'left',
'mx-1 [position-area:right_span-bottom] [position-try-fallbacks:flip-inline,flip-block]' => $side === 'right',
])
>
@if ($title)
<span class="mb-1 block type-title-sm text-on-surface-variant">{{ $title }}</span>
@endif
<span class="block pb-2 type-body-md text-on-surface-variant">{{ $text }}</span>
@isset($actions)
<span class="-ms-3 flex flex-wrap gap-2">{{ $actions }}</span>
@endisset
</span>
</span>
+34
View File
@@ -0,0 +1,34 @@
{{-- A figure and what it counts: "1,204 · Shares", "3.2 GB · Stored".
Not an M3 component; drawn in M3's terms — a panel in surface-container that separates from
the page by tone, a label-large `title`, the `value` as the card's hero in an emphasized
headline with tabular figures, and a body-small `description`. The value counts up once on
first appearance and again when it changes (`x-figure`), and rests under reduced motion.
`icon` sits beside the title. The slot, if any, goes under the description a progress bar
for a quota. --}}
@props([
'title' => null,
'value' => null,
'description' => null,
'icon' => null,
])
<div {{ $attributes->class('flex flex-col gap-1 rounded-corner-lg bg-surface-container p-4') }}>
<div class="flex items-center gap-2 type-label-lg text-on-surface-variant">
@if ($icon)
<x-icon :name="$icon" class="size-5" />
@endif
<span>{{ $title }}</span>
</div>
<p class="type-emphasized-headline-md tabular-nums" x-data x-figure>{{ $value }}</p>
@if ($description)
<p class="type-body-sm text-on-surface-variant">{{ $description }}</p>
@endif
@if ($slot->isNotEmpty())
<div class="pt-2">{{ $slot }}</div>
@endif
</div>
@@ -0,0 +1,74 @@
{{-- The snackbar host: shows every toast, one at a time. Put it once in each layout, near the end
of <body>:
<x-toast />
It shows every `toast` browser event what `NoNameWeb\LivewireMaterial\Concerns\Toasts`
dispatches from a Livewire component and for `window.materialToast(title, options)` from
JavaScript (`{ type, description, timeout, action: { label, handler } }`). Toasts queue and
show in turn, each for its `timeout` (4s by default; M3 asks for 410s), paused while the
pointer or focus is on it. A toast with an action or no timeout gets a close button.
`@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is
still on screen when the next page arrives.
M3's snackbar (SnackbarTokens, androidx Compose Material 3, Apache-2.0): inverse surface,
body-medium text, a label-large action in inverse-primary, extra-small corners, elevation 3,
48px for one line. A type draws its state icon in the inverse state colour. `position`:
`bottom` (centred, the default) or `bottom-start`. It lifts above a bottom bar through
`--material-bottom-bar`. --}}
@props(['position' => 'bottom'])
@persist('material-toast')
<div
x-data="materialSnackbar"
{{ $attributes->class([
'pointer-events-none fixed inset-x-4 z-50 flex bottom-[calc(var(--material-bottom-bar,0px)+1rem)]',
'justify-center' => $position !== 'bottom-start',
'justify-start sm:start-6' => $position === 'bottom-start',
]) }}
>
<template x-if="current">
<div
x-bind:key="current.id"
x-bind:role="current.type === 'error' || current.type === 'warning' ? 'alert' : 'status'"
aria-live="polite"
x-on:mouseenter="pause()"
x-on:mouseleave="resume()"
x-on:focusin="pause()"
x-on:focusout="resume()"
class="pointer-events-auto flex min-h-12 w-full max-w-[min(100%,36rem)] items-center gap-3 rounded-corner-xs bg-inverse-surface py-1.5 ps-4 pe-2 text-inverse-on-surface shadow-elevation-3 transition-[translate,opacity] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast starting:translate-y-4 starting:opacity-0 sm:w-auto sm:min-w-86"
>
<template x-if="icon(current.type)">
<span class="flex shrink-0" x-bind:class="{
'text-inverse-success': current.type === 'success',
'text-inverse-error': current.type === 'error',
'text-inverse-warning': current.type === 'warning',
'text-inverse-info': current.type === 'info',
}">
<span x-show="current.type === 'success'"><x-icon name="check_circle" filled class="size-6" /></span>
<span x-show="current.type === 'error'"><x-icon name="error" filled class="size-6" /></span>
<span x-show="current.type === 'warning'"><x-icon name="warning" filled class="size-6" /></span>
<span x-show="current.type === 'info'"><x-icon name="info" filled class="size-6" /></span>
</span>
</template>
<div class="min-w-0 flex-1 py-1.5">
<p class="type-body-md" x-text="current.title"></p>
<p class="type-body-md opacity-80" x-show="current.description" x-text="current.description"></p>
</div>
<template x-if="current.action">
<button type="button" class="state-layer focus-ring h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
</template>
<template x-if="current.action || ! current.timeout">
<button type="button" class="state-layer focus-ring inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="dismiss()">
<x-icon name="close" class="size-5" />
</button>
</template>
</div>
</template>
</div>
@endpersist
+2
View File
@@ -14,5 +14,7 @@
@include('livewire-material::showcase.sections.icons') @include('livewire-material::showcase.sections.icons')
@include('livewire-material::showcase.sections.buttons') @include('livewire-material::showcase.sections.buttons')
@include('livewire-material::showcase.sections.menus') @include('livewire-material::showcase.sections.menus')
@include('livewire-material::showcase.sections.communication')
@include('livewire-material::showcase.sections.progress')
</main> </main>
@endsection @endsection
+3 -1
View File
@@ -18,7 +18,7 @@
<a href="{{ route('livewire-material.showcase') }}" class="type-title-lg">Livewire Material</a> <a href="{{ route('livewire-material.showcase') }}" class="type-title-lg">Livewire Material</a>
<nav class="flex flex-wrap gap-x-4 gap-y-1 type-label-lg text-on-surface-variant" aria-label="Sections"> <nav class="flex flex-wrap gap-x-4 gap-y-1 type-label-lg text-on-surface-variant" aria-label="Sections">
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus'] as $anchor => $section) @foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication'] 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>
@@ -40,6 +40,8 @@
@yield('content') @yield('content')
<x-toast />
@livewireScripts @livewireScripts
</body> </body>
</html> </html>
@@ -0,0 +1,68 @@
@php
$examples = [
'Badges' => <<<'BLADE'
<span class="relative inline-flex"><x-icon name="notifications" /><x-badge floating /></span>
<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>
<span class="relative inline-flex"><x-icon name="inbox" /><x-badge value="1204" max="999" floating /></span>
<x-badge value="Expired" tonal />
<x-badge value="Active" color="success" tonal />
<x-badge value="Password" color="info" tonal />
<x-badge value="Pro" outline />
BLADE,
'Snackbars' => <<<'BLADE'
<x-button label="Saved" variant="tonal" x-on:click="materialToast('Settings saved', { type: 'success' })" />
<x-button label="With a description" variant="tonal" x-on:click="materialToast('Upload failed', { type: 'error', description: 'The file is larger than 4 GB.' })" />
<x-button label="With an action" variant="tonal" x-on:click="materialToast('Share deleted', { action: { label: 'Undo', handler: () => materialToast('Share restored', { type: 'info' }) } })" />
<x-button label="Until dismissed" variant="tonal" x-on:click="materialToast('Your storage is almost full', { type: 'warning', timeout: 0 })" />
BLADE,
'Rich tooltips' => <<<'BLADE'
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time. Admins can change the longest time allowed.">
<x-button icon="help" aria-label="About expiry" />
</x-rich-tooltip>
<x-rich-tooltip title="Password protection" text="Recipients need the password before they can see the files." persistent>
<x-button label="Press for details" icon="lock" variant="outlined" />
<x-slot:actions>
<x-button label="Learn more" />
</x-slot:actions>
</x-rich-tooltip>
BLADE,
'Alerts' => <<<'BLADE'
<x-alert title="Storage almost full" description="3.8 GB of 4 GB used. Old shares are deleted when they expire." color="warning" class="w-full" />
<x-alert description="Links are shown only once. Copy yours before you leave the page." class="w-full" dismissible />
<x-alert title="Upload failed" color="error" class="w-full">
The connection dropped at 64%.
<x-slot:actions><x-button label="Try again" /></x-slot:actions>
</x-alert>
<x-alert description="Settings saved." color="success" class="w-full" />
BLADE,
'Stats' => <<<'BLADE'
<div class="grid w-full gap-4 sm:grid-cols-2 lg:grid-cols-4">
<x-stat title="Shares" value="1,204" icon="link" description="12 this week" />
<x-stat title="Files" value="8,317" icon="description" />
<x-stat title="Stored" value="3.2 GB" icon="hard_drive" description="of 4 GB" />
<x-stat title="Downloads" value="42,019" icon="download" />
</div>
BLADE,
'Empty state' => <<<'BLADE'
<x-empty-state icon="upload_file" title="No shares yet" description="Files you share appear here, with their links and when they expire." class="w-full">
<x-slot:actions>
<x-button label="Upload files" icon="upload" variant="filled" />
</x-slot:actions>
</x-empty-state>
BLADE,
];
@endphp
<section id="communication" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Communication</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-badge&gt;</code>, <code>&lt;x-toast&gt;</code> (the snackbar host, fed by the <code>Toasts</code> concern or <code>materialToast()</code>),
<code>&lt;x-rich-tooltip&gt;</code>, <code>&lt;x-alert&gt;</code>, <code>&lt;x-stat&gt;</code> and <code>&lt;x-empty-state&gt;</code>.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code />
@endforeach
</section>
+46
View File
@@ -0,0 +1,46 @@
<?php
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')";
it('shows a toast as a snackbar, then the next in turn', function () {
$page = visit('/material')->waitForEvent('networkidle');
$page->script("materialToast('First', { type: 'success', timeout: 600 }); materialToast('Second', { type: 'error' })");
$page->assertScript(SNACKBAR."?.textContent.includes('First')")
->assertScript(SNACKBAR."?.getAttribute('role') === 'status'");
$page->wait(1.2);
$page->assertScript(SNACKBAR."?.textContent.includes('Second')")
->assertScript(SNACKBAR."?.getAttribute('role') === 'alert'");
});
it('shows a toast dispatched as a browser event, as the Toasts concern does', function () {
$page = visit('/material')->waitForEvent('networkidle')
->assertScript("typeof window.Livewire !== 'undefined' && typeof window.Alpine !== 'undefined'");
// Through Livewire, in the page's own realm: an event built inside Playwright's evaluate
// carries a detail object Firefox's sandbox will not let the page read.
$page->script("window.eval(\"Livewire.dispatch('toast', { type: 'info', title: 'Link copied', description: null, timeout: 4000 })\")");
$page->assertScript(SNACKBAR."?.textContent.includes('Link copied')");
});
it('runs a toast\'s action and dismisses it', function () {
$page = visit('/material')->waitForEvent('networkidle');
$page->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })");
$page->click('[x-data="materialSnackbar"] button:has-text("Undo")')
->assertScript('window.__undone === true')
->assertScript(SNACKBAR.' === null');
});
it('opens a persistent rich tooltip on press', function () {
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
visit('/material')->waitForEvent('networkidle')
->click('#communication button:has-text("Press for details")')
->assertScript("{$bubble}.matches(':popover-open')");
});
+34
View File
@@ -0,0 +1,34 @@
<?php
it('states a notice in its state\'s container, with the state\'s icon', function () {
$html = (string) $this->blade('<x-alert title="Storage almost full" description="3.8 of 4 GB" color="warning" />');
expect($html)
->toContain('role="alert"')
->toContain('bg-warning-container text-on-warning-container')
->toContain('Storage almost full')
->toContain('3.8 of 4 GB')
->toContain('<svg');
});
it('is a status unless it is an error or a warning', function () {
expect((string) $this->blade('<x-alert description="Saved." tone="success" />'))->toContain('role="status"')->toContain('bg-success-container')
->and((string) $this->blade('<x-alert description="Heads up." />'))->toContain('role="status"')->toContain('bg-info-container')
->and((string) $this->blade('<x-alert description="Broken." color="error" />'))->toContain('role="alert"');
});
it('takes actions, drops its icon on request, and can be dismissed', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-alert :icon="false" dismissible>
The connection dropped.
<x-slot:actions><x-button label="Try again" /></x-slot:actions>
</x-alert>
BLADE);
expect($html)
->toContain('The connection dropped.')
->toContain('Try again')
->toContain('x-data="{ shown: true }"')
->toContain('aria-label="Dismiss"')
->and(substr_count($html, '<svg'))->toBe(1);
});
+32
View File
@@ -0,0 +1,32 @@
<?php
it('is M3\'s small badge without a value', function () {
expect((string) $this->blade('<x-badge />'))
->toContain('size-1.5 rounded-corner-full')
->toContain('bg-error text-on-error')
->toContain('aria-hidden="true"');
});
it('is M3\'s large badge with a count, capped by max', function () {
expect((string) $this->blade('<x-badge value="4" />'))->toContain('h-4 min-w-4')->toContain('>4</span>')
->and((string) $this->blade('<x-badge value="1204" max="999" />'))->toContain('>999+</span>')
->and((string) $this->blade('<x-badge value="12" max="999" color="primary" />'))->toContain('>12</span>')->toContain('bg-primary text-on-primary');
});
it('pins itself to the corner of an icon when floating', function () {
expect((string) $this->blade('<x-badge floating />'))->toContain('absolute top-0.5 end-0.5')
->and((string) $this->blade('<x-badge value="3" floating label="3 new messages" />'))
->toContain('absolute -top-1')
->toContain('aria-label="3 new messages"')
->not->toContain('aria-hidden');
});
it('draws a status label in a container or an outline', function () {
expect((string) $this->blade('<x-badge value="Active" tone="success" tonal />'))
->toContain('h-6 gap-1 rounded-corner-sm px-2 type-label-md')
->toContain('bg-success-container text-on-success-container')
->not->toContain('aria-hidden')
->and((string) $this->blade('<x-badge value="Pro" outline />'))
->toContain('border border-outline-variant text-on-surface-variant')
->not->toContain('bg-error');
});
@@ -0,0 +1,17 @@
<?php
it('sets an icon on an Expressive shape above its words and action', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-empty-state icon="upload_file" shape="flower" title="No shares yet" description="Files you share appear here.">
<x-slot:actions><x-button label="Upload files" /></x-slot:actions>
</x-empty-state>
BLADE);
expect($html)
->toContain('text-secondary-container')
->toContain('text-on-secondary-container')
->toContain('<h3 class="type-title-lg">No shares yet</h3>')
->toContain('Files you share appear here.')
->toContain('Upload files')
->and(substr_count($html, '<svg'))->toBe(2);
});
@@ -0,0 +1,30 @@
<?php
it('wraps its trigger with a surface bubble anchored to it', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time.">
<button>?</button>
</x-rich-tooltip>
BLADE);
preg_match('/anchor-name: (--material-rich-tooltip-[a-z0-9]+)/', $html, $anchor);
expect($anchor)->not->toBeEmpty()
->and($html)
->toContain('<button>?</button>')
->toContain('x-data="materialRichTooltip(false)"')
->toContain('popover="manual"')
->toContain('role="tooltip"')
->toContain("position-anchor: {$anchor[1]}")
->toContain('rounded-corner-md bg-surface-container')
->toContain('type-title-sm text-on-surface-variant">Expiry')
->toContain('Recipients lose access after this time.');
});
it('opens on press and stays when persistent', function () {
expect((string) $this->blade('<x-rich-tooltip text="Details" persistent><button>i</button><x-slot:actions><button>More</button></x-slot:actions></x-rich-tooltip>'))
->toContain('x-data="materialRichTooltip(true)"')
->toContain('popover="auto"')
->toContain('role="dialog"')
->toContain('<button>More</button>');
});
+14
View File
@@ -0,0 +1,14 @@
<?php
it('shows a figure that counts, with its title and description', function () {
$html = (string) $this->blade('<x-stat title="Shares" value="1,204" icon="link" description="12 this week"><span>quota</span></x-stat>');
expect($html)
->toContain('bg-surface-container')
->toContain('Shares')
->toContain('x-figure>1,204</p>')
->toContain('type-emphasized-headline-md tabular-nums')
->toContain('12 this week')
->toContain('<span>quota</span>')
->toContain('<svg');
});
+15
View File
@@ -0,0 +1,15 @@
<?php
it('hosts the snackbar queue, kept across wire:navigate', function () {
$html = (string) $this->blade('<x-toast />');
expect($html)
->toContain('x-persist="material-toast"')
->toContain('x-data="materialSnackbar"')
->toContain('bg-inverse-surface')
->toContain('justify-center');
});
it('can sit at the start', function () {
expect((string) $this->blade('<x-toast position="bottom-start" />'))->toContain('justify-start');
});