Add M3 Expressive progress indicators
tests / browser (chrome, chromium) (push) Successful in 2m8s
tests / browser (firefox, firefox) (push) Failing after 1m56s
tests / lint (push) Successful in 57s
tests / feature (8.4) (push) Successful in 1m5s
tests / feature (8.5) (push) Successful in 1m1s
tests / browser (safari, webkit) (push) Successful in 2m52s
tests / browser (chrome, chromium) (push) Successful in 2m8s
tests / browser (firefox, firefox) (push) Failing after 1m56s
tests / lint (push) Successful in 57s
tests / feature (8.4) (push) Successful in 1m5s
tests / feature (8.5) (push) Successful in 1m1s
tests / browser (safari, webkit) (push) Successful in 2m52s
<x-progress>: linear or circular, flat or wavy, determinate or indeterminate, 4px or thick, in any colour role, following a server value through Livewire morphs or an Alpine expression in the browser. The first frame is server-rendered SVG; Compose Material 3's drawing and keyframes are ported to an Alpine component that animates only while something moves and the indicator is on screen. The previous commit already imported progress.js and included its showcase section without the files; this adds them. 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
648ad8efaf
commit
e57063b0f4
@@ -223,6 +223,33 @@ materialToast('Share deleted', { type: 'success', description: null, timeout: 40
|
||||
|
||||
`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-progress>`
|
||||
|
||||
M3 Expressive's progress indicator: linear (as wide as its container) or `circular` (40px, 48px wavy, unless a `size-*` class is passed), flat or `wavy`, determinate with a `value` or indeterminate without one.
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
| `value` | `null` | 0 to `max`, clamped; `null` is indeterminate |
|
||||
| `max` | `100` | |
|
||||
| `bind` | `null` | an Alpine expression it follows in the browser; `null`/`undefined` is indeterminate |
|
||||
| `circular` | `false` | circular instead of linear |
|
||||
| `wavy` | `false` | Expressive's wave (flat below 10% and from 95%) |
|
||||
| `thick` | `false` | 8px track and indicator instead of 4px |
|
||||
| `color` | `primary` | `primary`, `secondary`, `tertiary`, `error`, `success`, `warning`, `info`; the track is the colour's container (secondary-container for primary) |
|
||||
| `label` | `"Progress"` | names the `progressbar`; `:label="false"` makes it decorative |
|
||||
|
||||
```blade
|
||||
<x-progress :value="$share->uploaded" :max="$share->size" label="Uploading" />
|
||||
<x-progress circular wavy label="Preparing the download" />
|
||||
|
||||
<div x-data="{ progress: null }" x-on:livewire-upload-progress="progress = $event.detail.progress" x-on:livewire-upload-finish="progress = null">
|
||||
<input type="file" wire:model="file">
|
||||
<div x-show="progress !== null"><x-progress bind="progress" wavy label="Uploading" /></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
A value the server changes animates after a morph (the SVG is `wire:ignore`; only the root's attributes change). Under reduced motion values jump, the wave stands still and an indeterminate indicator holds one frame. A `w-*` class narrows a linear one; never pass a display or position class.
|
||||
|
||||
### `<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").
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
{{-- M3 Expressive's progress indicator: how far along a process is, or that one is running.
|
||||
|
||||
`value` (0 to `max`, which is 100 unless given) makes it determinate; without one it is
|
||||
indeterminate. Values outside the range are clamped. `bind` is an Alpine expression for a
|
||||
value that changes in the browser — a Livewire upload's `progress` in the parent's x-data —
|
||||
which the indicator follows (null or undefined: indeterminate). A value the server changes
|
||||
moves the same way after a Livewire morph.
|
||||
|
||||
Linear by default, as wide as its container (a `w-*` class narrows it). `circular` is 40px,
|
||||
48px wavy, unless a `size-*` class is passed. `wavy` draws Expressive's wave. `thick` makes
|
||||
the track and indicator 8px instead of 4px and keeps the wave as high as it was, which is
|
||||
what Compose's thick samples do: the container grows by the extra stroke (14px linear wavy,
|
||||
44px circular, 52px circular wavy). `color` is the active indicator and the stop: `primary`
|
||||
(the default), `secondary`, `tertiary`, `error`, `success`, `warning`, `info`; the track is
|
||||
secondary-container for primary and secondary, and the colour's own container otherwise.
|
||||
|
||||
A `progressbar` named by `label` ("Progress"), with aria-valuenow while determinate;
|
||||
`:label="false"` makes it decorative where something else already says what is happening.
|
||||
|
||||
The server draws the first frame — the value, or a still of the indeterminate animation,
|
||||
flat even for a wavy one — so the indicator is right before any script runs. From then on
|
||||
resources/js/progress.js draws it frame by frame, ported from androidx Compose Material 3 at
|
||||
commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326 (Apache-2.0): ProgressIndicator.kt,
|
||||
WavyProgressIndicator.kt and its Linear/CircularWavyProgressModifiers, with
|
||||
LinearProgressIndicatorTokens, CircularProgressIndicatorTokens and ProgressIndicatorTokens —
|
||||
a 4px gap between indicator and track, a 4px stop at the track's end, wavelengths of 40px
|
||||
(20px indeterminate) and 15px, active indicator in primary, track in secondary-container.
|
||||
The SVG is `wire:ignore`; only the root's attributes change on a morph. --}}
|
||||
|
||||
@props([
|
||||
'value' => null,
|
||||
'max' => 100,
|
||||
'bind' => null,
|
||||
'circular' => false,
|
||||
'wavy' => false,
|
||||
'thick' => false,
|
||||
'color' => 'primary',
|
||||
'label' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$format = fn (float $number): string => rtrim(rtrim(number_format(round($number, 3) + 0.0, 3, '.', ''), '0'), '.');
|
||||
|
||||
$maximum = is_numeric($max) && (float) $max > 0 ? (float) $max : 100.0;
|
||||
$amount = is_numeric($value) ? min(max((float) $value, 0.0), $maximum) : null;
|
||||
$fraction = $amount === null ? null : $amount / $maximum;
|
||||
$bound = filled($bind) ? (string) $bind : null;
|
||||
|
||||
$color = in_array($color, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) ? $color : 'primary';
|
||||
$decorative = $label === false;
|
||||
$label = $decorative ? null : ($label ?? __('Progress'));
|
||||
|
||||
$classList = (string) $attributes->get('class');
|
||||
$sized = $circular
|
||||
? preg_match('/(^|\s)(size|w|h)-/', $classList) === 1
|
||||
: preg_match('/(^|\s)w-/', $classList) === 1;
|
||||
|
||||
// Every colour class written out whole, so Tailwind compiles it. The active indicator is
|
||||
// drawn in currentColor; the track takes the inherited `stroke`.
|
||||
$ink = [
|
||||
'primary' => 'text-primary', 'secondary' => 'text-secondary', 'tertiary' => 'text-tertiary',
|
||||
'error' => 'text-error', 'success' => 'text-success', 'warning' => 'text-warning', 'info' => 'text-info',
|
||||
];
|
||||
$trackInk = [
|
||||
'primary' => 'stroke-secondary-container', 'secondary' => 'stroke-secondary-container',
|
||||
'tertiary' => 'stroke-tertiary-container', 'error' => 'stroke-error-container',
|
||||
'success' => 'stroke-success-container', 'warning' => 'stroke-warning-container', 'info' => 'stroke-info-container',
|
||||
];
|
||||
|
||||
$stroke = $thick ? 8 : 4;
|
||||
$cap = $stroke / 2;
|
||||
// The space between an active line and the track, caps included: TrackActiveSpace + the stroke.
|
||||
$spacing = 4 + $stroke;
|
||||
|
||||
// The first frame. Linear lines sit at a fraction of the width plus a length in pixels,
|
||||
// which a nested <svg x="…%"> and a translate express without knowing the width.
|
||||
$segments = [];
|
||||
$arcs = [];
|
||||
$stopOffset = null;
|
||||
$box = null;
|
||||
$middle = ($circular || ! $wavy ? $stroke : ($thick ? 14 : 10)) / 2;
|
||||
|
||||
$segment = fn (float $from, float $fromOffset, float $to, float $toOffset, bool $active): array => compact('from', 'fromOffset', 'to', 'toOffset', 'active');
|
||||
|
||||
if (! $circular && $fraction !== null) {
|
||||
if ($fraction <= 0) {
|
||||
$segments[] = $segment(0, $cap, 1, -$cap, false);
|
||||
} else {
|
||||
if ($fraction < 1) {
|
||||
$segments[] = $segment($fraction, $spacing, 1, -$cap, false);
|
||||
}
|
||||
|
||||
$segments[] = $segment(0, $cap, $fraction, $fraction >= 1 ? -$cap : 0, true);
|
||||
}
|
||||
|
||||
// The stop indicator's centre, from the end: half its 4px, plus its offset on a thick track.
|
||||
$stopOffset = 2 + min(($stroke - 4) / 2, 6);
|
||||
} elseif (! $circular) {
|
||||
// progress.js's LINEAR_STILL (875 ms): the first line at 25.74–62.57%, the second at 0–3.98%.
|
||||
$segments[] = $segment(0.6257, $spacing, 1, -$cap, false);
|
||||
$segments[] = $segment(0.2574, 0, 0.6257, 0, true);
|
||||
$segments[] = $segment(0.0398, $spacing, 0.2574, -$spacing, false);
|
||||
$segments[] = $segment(0, $cap, 0.0398, 0, true);
|
||||
} else {
|
||||
$box = ($wavy ? 48 : 40) + ($thick ? 4 : 0);
|
||||
$centre = $box / 2;
|
||||
$radius = ($box - $stroke) / 2;
|
||||
|
||||
$arc = function (float $start, float $sweep) use ($centre, $radius, $format): string {
|
||||
if ($sweep == 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (abs($sweep) >= 360) {
|
||||
return 'M'.$format($centre + $radius).' '.$format($centre).'A'.$format($radius).' '.$format($radius).' 0 1 1 '.$format($centre - $radius).' '.$format($centre)
|
||||
.'A'.$format($radius).' '.$format($radius).' 0 1 1 '.$format($centre + $radius).' '.$format($centre).'Z';
|
||||
}
|
||||
|
||||
[$x0, $y0] = [$centre + $radius * cos(deg2rad($start)), $centre + $radius * sin(deg2rad($start))];
|
||||
[$x1, $y1] = [$centre + $radius * cos(deg2rad($start + $sweep)), $centre + $radius * sin(deg2rad($start + $sweep))];
|
||||
|
||||
if (hypot($x1 - $x0, $y1 - $y0) < 0.01) {
|
||||
return 'M'.$format($x0).' '.$format($y0).'L'.$format($x0 + 0.001).' '.$format($y0);
|
||||
}
|
||||
|
||||
return 'M'.$format($x0).' '.$format($y0).'A'.$format($radius).' '.$format($radius).' 0 '.(abs($sweep) > 180 ? 1 : 0).' '.($sweep > 0 ? 1 : 0).' '.$format($x1).' '.$format($y1);
|
||||
};
|
||||
|
||||
// Determinate from 12 o'clock; indeterminate is progress.js's CIRCULAR_STILL (2000 ms):
|
||||
// turned to 9 o'clock, 61.33% of the circle.
|
||||
$start = $fraction === null ? 180 : 270;
|
||||
$sweep = ($fraction ?? 0.6133) * 360;
|
||||
|
||||
if ($wavy) {
|
||||
$circumference = 2 * M_PI * $radius;
|
||||
$reach = $sweep / 360 * $circumference;
|
||||
$trackGap = (min($reach, $cap) * 2 + min($reach, 4)) / $circumference * 360;
|
||||
|
||||
if (360 - $sweep - $trackGap * 2 > 0) {
|
||||
$arcs[] = ['d' => $arc($start + $sweep + $trackGap, 360 - $sweep - $trackGap * 2), 'active' => false];
|
||||
}
|
||||
} elseif ($fraction !== null) {
|
||||
$trackGap = min($sweep, (4 + $stroke) / (M_PI * $box) * 360);
|
||||
$arcs[] = ['d' => $arc($start + $sweep + $trackGap, 360 - $sweep - $trackGap * 2), 'active' => false];
|
||||
}
|
||||
|
||||
$arcs[] = ['d' => $arc($start, $sweep), 'active' => true];
|
||||
}
|
||||
|
||||
$attributes = $attributes
|
||||
->class([
|
||||
'block rtl:rotate-180' => ! $circular,
|
||||
'w-full' => ! $circular && ! $sized,
|
||||
'h-1' => ! $circular && ! $wavy && ! $thick,
|
||||
'h-2' => ! $circular && ! $wavy && $thick,
|
||||
'h-2.5' => ! $circular && $wavy && ! $thick,
|
||||
'h-3.5' => ! $circular && $wavy && $thick,
|
||||
'inline-block shrink-0 align-middle' => $circular,
|
||||
'size-10' => $circular && ! $sized && ! $wavy && ! $thick,
|
||||
'size-11' => $circular && ! $sized && ! $wavy && $thick,
|
||||
'size-12' => $circular && ! $sized && $wavy && ! $thick,
|
||||
'size-13' => $circular && ! $sized && $wavy && $thick,
|
||||
$ink[$color],
|
||||
$trackInk[$color],
|
||||
])
|
||||
->merge(array_filter([
|
||||
'role' => $decorative ? null : 'progressbar',
|
||||
'aria-label' => $label,
|
||||
'aria-hidden' => $decorative ? 'true' : null,
|
||||
'aria-valuemin' => $decorative ? null : '0',
|
||||
'aria-valuemax' => $decorative ? null : $format($maximum),
|
||||
'aria-valuenow' => $decorative || $amount === null ? null : $format($amount),
|
||||
'x-bind:aria-valuenow' => $decorative || $bound === null
|
||||
? null
|
||||
: "((v) => v === null || v === undefined || v === false || v === '' || isNaN(v) ? null : Math.min(Math.max(Number(v), 0), ".$format($maximum)."))(".$bound.')',
|
||||
'x-data' => 'materialProgress',
|
||||
'data-value' => $amount === null ? null : $format($amount),
|
||||
'x-bind:data-value' => $bound,
|
||||
'data-max' => $format($maximum),
|
||||
'data-circular' => $circular ? true : null,
|
||||
'data-wavy' => $wavy ? true : null,
|
||||
'data-thick' => $thick ? true : null,
|
||||
], fn ($attribute): bool => $attribute !== null));
|
||||
@endphp
|
||||
|
||||
<span {{ $attributes }}>
|
||||
<svg class="block size-full"@if ($box) viewBox="0 0 {{ $box }} {{ $box }}"@endif fill="none" stroke-width="{{ $stroke }}" stroke-linecap="round" aria-hidden="true" focusable="false" wire:ignore>
|
||||
@foreach ($segments as $line)
|
||||
<svg x="{{ $format($line['from'] * 100) }}%" overflow="visible"><line x1="{{ $format($line['fromOffset'] - $line['toOffset']) }}" y1="{{ $format($middle) }}" x2="{{ $format(($line['to'] - $line['from']) * 100) }}%" y2="{{ $format($middle) }}" transform="translate({{ $format($line['toOffset']) }} 0)"@if ($line['active']) stroke="currentColor"@endif /></svg>
|
||||
@endforeach
|
||||
@foreach ($arcs as $path)
|
||||
@if ($path['d'] !== '')
|
||||
<path d="{{ $path['d'] }}"@if ($path['active']) stroke="currentColor"@endif />
|
||||
@endif
|
||||
@endforeach
|
||||
@if ($stopOffset !== null)
|
||||
<circle cx="100%" cy="{{ $format($middle) }}" r="2" transform="translate(-{{ $format($stopOffset) }} 0)" fill="currentColor" stroke="none" />
|
||||
@endif
|
||||
</svg>
|
||||
</span>
|
||||
@@ -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', 'communication' => 'Communication'] as $anchor => $section)
|
||||
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress'] as $anchor => $section)
|
||||
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
@php
|
||||
$examples = [
|
||||
'Linear: determinate and indeterminate' => [<<<'BLADE'
|
||||
<x-progress value="40" label="Linear" />
|
||||
<x-progress label="Linear, indeterminate" />
|
||||
BLADE, true],
|
||||
'Linear wavy' => [<<<'BLADE'
|
||||
<x-progress value="60" wavy label="Wavy" />
|
||||
<x-progress wavy label="Wavy, indeterminate" />
|
||||
BLADE, true],
|
||||
'Thick' => [<<<'BLADE'
|
||||
<x-progress value="70" thick label="Thick" />
|
||||
<x-progress value="55" wavy thick label="Thick wavy" />
|
||||
<x-progress wavy thick label="Thick wavy, indeterminate" />
|
||||
BLADE, true],
|
||||
'Circular' => [<<<'BLADE'
|
||||
<x-progress value="65" circular label="Circular" />
|
||||
<x-progress circular label="Circular, indeterminate" />
|
||||
<x-progress value="65" circular wavy label="Circular wavy" />
|
||||
<x-progress circular wavy label="Circular wavy, indeterminate" />
|
||||
<x-progress value="65" circular thick label="Circular thick" />
|
||||
<x-progress value="65" circular wavy thick label="Circular wavy thick" />
|
||||
<x-progress circular wavy class="size-24" label="Circular wavy, 96px" />
|
||||
BLADE, false],
|
||||
'Colours' => [<<<'BLADE'
|
||||
<x-progress value="35" color="secondary" label="Secondary" />
|
||||
<x-progress value="50" wavy color="tertiary" label="Tertiary" />
|
||||
<x-progress value="65" color="error" label="Error" />
|
||||
<x-progress value="80" wavy color="success" label="Success" />
|
||||
<x-progress value="45" color="warning" label="Warning" />
|
||||
<x-progress value="55" wavy color="info" label="Info" />
|
||||
BLADE, true],
|
||||
'Following a value in the browser (bind)' => [<<<'BLADE'
|
||||
<div x-data="{ progress: 30 }" class="w-full space-y-6">
|
||||
<x-button label="Set a random value" variant="tonal" x-on:click="progress = Math.round(Math.random() * 100)" />
|
||||
<x-progress bind="progress" label="Bound" />
|
||||
<x-progress bind="progress" wavy label="Bound wavy" />
|
||||
<div class="flex gap-6">
|
||||
<x-progress bind="progress" circular label="Bound circular" />
|
||||
<x-progress bind="progress" circular wavy label="Bound circular wavy" />
|
||||
</div>
|
||||
</div>
|
||||
BLADE, false],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section id="progress" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Progress</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><x-progress></code>: M3 Expressive's linear and circular progress indicators, flat or wavy, determinate or indeterminate.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => [$code, $stack])
|
||||
<x-showcase::example :$title :$code :$stack />
|
||||
@endforeach
|
||||
</section>
|
||||
Reference in New Issue
Block a user