From 648ad8efafafd35d58ee54146c5eb9687e3e4823 Mon Sep 17 00:00:00 2001
From: Andreas Reinhold / reini
Date: Sun, 13 Sep 2026 06:59:40 +0200
Subject: [PATCH] Add the snackbar, badges, rich tooltips, alerts, stats and
empty states
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.
is M3's dot and count, plus a tonal or outlined status label;
is transient or persistent; , and
are built from M3's parts.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
---
.../livewire-material-development/SKILL.md | 49 ++++++++
resources/js/material.js | 3 +
resources/js/rich-tooltip.js | 53 +++++++++
resources/js/snackbar.js | 108 ++++++++++++++++++
resources/views/components/alert.blade.php | 65 +++++++++++
resources/views/components/badge.blade.php | 67 +++++++++++
.../views/components/empty-state.blade.php | 37 ++++++
.../views/components/rich-tooltip.blade.php | 62 ++++++++++
resources/views/components/stat.blade.php | 34 ++++++
resources/views/components/toast.blade.php | 74 ++++++++++++
resources/views/showcase/index.blade.php | 2 +
resources/views/showcase/layout.blade.php | 4 +-
.../showcase/sections/communication.blade.php | 68 +++++++++++
tests/Browser/CommunicationTest.php | 46 ++++++++
tests/Feature/Components/AlertTest.php | 34 ++++++
tests/Feature/Components/BadgeTest.php | 32 ++++++
tests/Feature/Components/EmptyStateTest.php | 17 +++
tests/Feature/Components/RichTooltipTest.php | 30 +++++
tests/Feature/Components/StatTest.php | 14 +++
tests/Feature/Components/ToastTest.php | 15 +++
20 files changed, 813 insertions(+), 1 deletion(-)
create mode 100644 resources/js/rich-tooltip.js
create mode 100644 resources/js/snackbar.js
create mode 100644 resources/views/components/alert.blade.php
create mode 100644 resources/views/components/badge.blade.php
create mode 100644 resources/views/components/empty-state.blade.php
create mode 100644 resources/views/components/rich-tooltip.blade.php
create mode 100644 resources/views/components/stat.blade.php
create mode 100644 resources/views/components/toast.blade.php
create mode 100644 resources/views/showcase/sections/communication.blade.php
create mode 100644 tests/Browser/CommunicationTest.php
create mode 100644 tests/Feature/Components/AlertTest.php
create mode 100644 tests/Feature/Components/BadgeTest.php
create mode 100644 tests/Feature/Components/EmptyStateTest.php
create mode 100644 tests/Feature/Components/RichTooltipTest.php
create mode 100644 tests/Feature/Components/StatTest.php
create mode 100644 tests/Feature/Components/ToastTest.php
diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md
index 11d16fcd..a9cb6ac3 100644
--- a/resources/boost/skills/livewire-material-development/SKILL.md
+++ b/resources/boost/skills/livewire-material-development/SKILL.md
@@ -213,6 +213,55 @@ M3 Expressive's loading indicator — a shape morphing through seven Expressive
`` shows a decorative one in place of its icon.
+### ``
+
+The snackbar host. Once per layout, near the end of ``: ` ` (`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.
+
+### ``
+
+- ` ` — M3's small badge, a dot. ` ` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: ` `. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
+- ` `, ` `, ` ` — 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`.
+
+### ``
+
+A notice in the page, in the state's container colour with its icon:
+
+```blade
+
+
+ The upload failed.
+
+
+```
+
+`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"`.
+
+### ``
+
+A few lines of context around a trigger, with an optional `title` and `actions` slot:
+
+```blade
+
+
+
+```
+
+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`.
+
+### ``
+
+` ` — 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 ``). Do not pass a `bg-*` class; wrap it.
+
+### ``
+
+"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
```php
diff --git a/resources/js/material.js b/resources/js/material.js
index 7d4b5e23..a2120ac0 100644
--- a/resources/js/material.js
+++ b/resources/js/material.js
@@ -11,3 +11,6 @@ import './theme.js'
import './figure.js'
import './tooltip.js'
import './menu.js'
+import './snackbar.js'
+import './rich-tooltip.js'
+import './progress.js'
diff --git a/resources/js/rich-tooltip.js b/resources/js/rich-tooltip.js
new file mode 100644
index 00000000..9d0cc473
--- /dev/null
+++ b/resources/js/rich-tooltip.js
@@ -0,0 +1,53 @@
+/**
+ * `materialRichTooltip`: shows an ``.
+ *
+ * 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)
+ },
+ }))
+})
diff --git a/resources/js/snackbar.js b/resources/js/snackbar.js
new file mode 100644
index 00000000..8bd59349
--- /dev/null
+++ b/resources/js/snackbar.js
@@ -0,0 +1,108 @@
+/**
+ * `materialSnackbar`: the queue behind ``, 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)
+ },
+ }))
+})
diff --git a/resources/views/components/alert.blade.php b/resources/views/components/alert.blade.php
new file mode 100644
index 00000000..fd234346
--- /dev/null
+++ b/resources/views/components/alert.blade.php
@@ -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
+
+class(['flex items-start gap-3 rounded-corner-md p-4', $colours]) }}
+>
+ @if ($icon)
+
+ @endif
+
+
+ @if ($title)
+
{{ $title }}
+ @endif
+
+ @if ($description || $slot->isNotEmpty())
+
{{ $description ?? $slot }}
+ @endif
+
+ @isset($actions)
+
{{ $actions }}
+ @endisset
+
+
+ @if ($dismissible)
+
+
+
+ @endif
+
diff --git a/resources/views/components/badge.blade.php b/resources/views/components/badge.blade.php
new file mode 100644
index 00000000..f7c4b04f
--- /dev/null
+++ b/resources/views/components/badge.blade.php
@@ -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):
+ - ` ` — the small badge, a 6px dot: something new, no number.
+ - ` ` — 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:
+
+
+
+ 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
+
+@unless ($dot){{ $text }}@endunless
diff --git a/resources/views/components/empty-state.blade.php b/resources/views/components/empty-state.blade.php
new file mode 100644
index 00000000..4ca67b91
--- /dev/null
+++ b/resources/views/components/empty-state.blade.php
@@ -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.
+
+
+
+
+
+ Not an M3 component; built from M3 Expressive's parts — `shape` (any `` 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,
+])
+
+class('flex flex-col items-center gap-4 px-4 py-10 text-center') }}>
+
+
+
+
+
+ @if ($title)
+
{{ $title }}
+ @endif
+
+ @if ($description || $slot->isNotEmpty())
+
{{ $description ?? $slot }}
+ @endif
+
+ @isset($actions)
+
{{ $actions }}
+ @endisset
+
diff --git a/resources/views/components/rich-tooltip.blade.php b/resources/views/components/rich-tooltip.blade.php
new file mode 100644
index 00000000..3e6267e2
--- /dev/null
+++ b/resources/views/components/rich-tooltip.blade.php
@@ -0,0 +1,62 @@
+{{-- An M3 rich tooltip: a few lines of context for a control, with an optional subhead and actions.
+
+
+
+
+
+
+ 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
+
+class('inline-flex') }}
+ style="anchor-name: {{ $anchor }}"
+ x-data="materialRichTooltip({{ $persistent ? 'true' : 'false' }})"
+>
+ {{ $trigger ?? $slot }}
+
+ $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)
+ {{ $title }}
+ @endif
+
+ {{ $text }}
+
+ @isset($actions)
+ {{ $actions }}
+ @endisset
+
+
diff --git a/resources/views/components/stat.blade.php b/resources/views/components/stat.blade.php
new file mode 100644
index 00000000..7808118b
--- /dev/null
+++ b/resources/views/components/stat.blade.php
@@ -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,
+])
+
+class('flex flex-col gap-1 rounded-corner-lg bg-surface-container p-4') }}>
+
+ @if ($icon)
+
+ @endif
+ {{ $title }}
+
+
+
{{ $value }}
+
+ @if ($description)
+
{{ $description }}
+ @endif
+
+ @if ($slot->isNotEmpty())
+
{{ $slot }}
+ @endif
+
diff --git a/resources/views/components/toast.blade.php b/resources/views/components/toast.blade.php
new file mode 100644
index 00000000..92c1adf8
--- /dev/null
+++ b/resources/views/components/toast.blade.php
@@ -0,0 +1,74 @@
+{{-- The snackbar host: shows every toast, one at a time. Put it once in each layout, near the end
+ of :
+
+
+
+ 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 4–10s), 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')
+ 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',
+ ]) }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@endpersist
diff --git a/resources/views/showcase/index.blade.php b/resources/views/showcase/index.blade.php
index cec169c2..272ed194 100644
--- a/resources/views/showcase/index.blade.php
+++ b/resources/views/showcase/index.blade.php
@@ -14,5 +14,7 @@
@include('livewire-material::showcase.sections.icons')
@include('livewire-material::showcase.sections.buttons')
@include('livewire-material::showcase.sections.menus')
+ @include('livewire-material::showcase.sections.communication')
+ @include('livewire-material::showcase.sections.progress')
@endsection
diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php
index 799102a4..f92c3b6b 100644
--- a/resources/views/showcase/layout.blade.php
+++ b/resources/views/showcase/layout.blade.php
@@ -18,7 +18,7 @@
Livewire Material
- @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)
{{ $section }}
@endforeach
@@ -40,6 +40,8 @@
@yield('content')
+
+
@livewireScripts
diff --git a/resources/views/showcase/sections/communication.blade.php b/resources/views/showcase/sections/communication.blade.php
new file mode 100644
index 00000000..513b2828
--- /dev/null
+++ b/resources/views/showcase/sections/communication.blade.php
@@ -0,0 +1,68 @@
+@php
+ $examples = [
+ 'Badges' => <<<'BLADE'
+
+
+
+
+
+
+
+ BLADE,
+ 'Snackbars' => <<<'BLADE'
+
+
+
+
+ BLADE,
+ 'Rich tooltips' => <<<'BLADE'
+
+
+
+
+
+
+
+
+
+
+ BLADE,
+ 'Alerts' => <<<'BLADE'
+
+
+
+ The connection dropped at 64%.
+
+
+
+ BLADE,
+ 'Stats' => <<<'BLADE'
+
+
+
+
+
+
+ BLADE,
+ 'Empty state' => <<<'BLADE'
+
+
+
+
+
+ BLADE,
+ ];
+@endphp
+
+
diff --git a/tests/Browser/CommunicationTest.php b/tests/Browser/CommunicationTest.php
new file mode 100644
index 00000000..413a82b8
--- /dev/null
+++ b/tests/Browser/CommunicationTest.php
@@ -0,0 +1,46 @@
+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')");
+});
diff --git a/tests/Feature/Components/AlertTest.php b/tests/Feature/Components/AlertTest.php
new file mode 100644
index 00000000..409efecc
--- /dev/null
+++ b/tests/Feature/Components/AlertTest.php
@@ -0,0 +1,34 @@
+blade(' ');
+
+ expect($html)
+ ->toContain('role="alert"')
+ ->toContain('bg-warning-container text-on-warning-container')
+ ->toContain('Storage almost full')
+ ->toContain('3.8 of 4 GB')
+ ->toContain('blade(' '))->toContain('role="status"')->toContain('bg-success-container')
+ ->and((string) $this->blade(' '))->toContain('role="status"')->toContain('bg-info-container')
+ ->and((string) $this->blade(' '))->toContain('role="alert"');
+});
+
+it('takes actions, drops its icon on request, and can be dismissed', function () {
+ $html = (string) $this->blade(<<<'BLADE'
+
+ The connection dropped.
+
+
+ BLADE);
+
+ expect($html)
+ ->toContain('The connection dropped.')
+ ->toContain('Try again')
+ ->toContain('x-data="{ shown: true }"')
+ ->toContain('aria-label="Dismiss"')
+ ->and(substr_count($html, 'toBe(1);
+});
diff --git a/tests/Feature/Components/BadgeTest.php b/tests/Feature/Components/BadgeTest.php
new file mode 100644
index 00000000..9442f42b
--- /dev/null
+++ b/tests/Feature/Components/BadgeTest.php
@@ -0,0 +1,32 @@
+blade(' '))
+ ->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(' '))->toContain('h-4 min-w-4')->toContain('>4')
+ ->and((string) $this->blade(' '))->toContain('>999+')
+ ->and((string) $this->blade(' '))->toContain('>12')->toContain('bg-primary text-on-primary');
+});
+
+it('pins itself to the corner of an icon when floating', function () {
+ expect((string) $this->blade(' '))->toContain('absolute top-0.5 end-0.5')
+ ->and((string) $this->blade(' '))
+ ->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(' '))
+ ->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(' '))
+ ->toContain('border border-outline-variant text-on-surface-variant')
+ ->not->toContain('bg-error');
+});
diff --git a/tests/Feature/Components/EmptyStateTest.php b/tests/Feature/Components/EmptyStateTest.php
new file mode 100644
index 00000000..0de9e60a
--- /dev/null
+++ b/tests/Feature/Components/EmptyStateTest.php
@@ -0,0 +1,17 @@
+blade(<<<'BLADE'
+
+
+
+ BLADE);
+
+ expect($html)
+ ->toContain('text-secondary-container')
+ ->toContain('text-on-secondary-container')
+ ->toContain('No shares yet ')
+ ->toContain('Files you share appear here.')
+ ->toContain('Upload files')
+ ->and(substr_count($html, 'toBe(2);
+});
diff --git a/tests/Feature/Components/RichTooltipTest.php b/tests/Feature/Components/RichTooltipTest.php
new file mode 100644
index 00000000..03d869fc
--- /dev/null
+++ b/tests/Feature/Components/RichTooltipTest.php
@@ -0,0 +1,30 @@
+blade(<<<'BLADE'
+
+ ?
+
+ BLADE);
+
+ preg_match('/anchor-name: (--material-rich-tooltip-[a-z0-9]+)/', $html, $anchor);
+
+ expect($anchor)->not->toBeEmpty()
+ ->and($html)
+ ->toContain('? ')
+ ->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('i More '))
+ ->toContain('x-data="materialRichTooltip(true)"')
+ ->toContain('popover="auto"')
+ ->toContain('role="dialog"')
+ ->toContain('More ');
+});
diff --git a/tests/Feature/Components/StatTest.php b/tests/Feature/Components/StatTest.php
new file mode 100644
index 00000000..42e8b0b4
--- /dev/null
+++ b/tests/Feature/Components/StatTest.php
@@ -0,0 +1,14 @@
+blade('quota ');
+
+ expect($html)
+ ->toContain('bg-surface-container')
+ ->toContain('Shares')
+ ->toContain('x-figure>1,204
')
+ ->toContain('type-emphasized-headline-md tabular-nums')
+ ->toContain('12 this week')
+ ->toContain('quota ')
+ ->toContain('blade(' ');
+
+ 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(' '))->toContain('justify-start');
+});