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
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:
co-authored by
Claude Opus 5
parent
cd64f4f371
commit
648ad8efaf
@@ -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-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
|
||||
|
||||
```php
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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 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')
|
||||
<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
|
||||
@@ -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')
|
||||
</main>
|
||||
@endsection
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<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">
|
||||
@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>
|
||||
@endforeach
|
||||
</nav>
|
||||
@@ -40,6 +40,8 @@
|
||||
|
||||
@yield('content')
|
||||
|
||||
<x-toast />
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
</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><x-badge></code>, <code><x-toast></code> (the snackbar host, fed by the <code>Toasts</code> concern or <code>materialToast()</code>),
|
||||
<code><x-rich-tooltip></code>, <code><x-alert></code>, <code><x-stat></code> and <code><x-empty-state></code>.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
<x-showcase::example :$title :$code />
|
||||
@endforeach
|
||||
</section>
|
||||
@@ -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')");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user