Files
livewire-material/resources/views/components/progress.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 e57063b0f4
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
Add M3 Expressive progress indicators
<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
2026-09-13 07:05:52 +02:00

201 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{-- 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.7462.57%, the second at 03.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>