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 @@ -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 + +
+

Communication

+ +

+ <x-badge>, <x-toast> (the snackbar host, fed by the Toasts concern or materialToast()), + <x-rich-tooltip>, <x-alert>, <x-stat> and <x-empty-state>. +

+ + @foreach ($examples as $title => $code) + + @endforeach +
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('')) + ->toContain('x-data="materialRichTooltip(true)"') + ->toContain('popover="auto"') + ->toContain('role="dialog"') + ->toContain(''); +}); 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'); +});