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
@@ -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
|
||||
Reference in New Issue
Block a user