Plan step 36 (actions). <x-progress> renders data-md-progress with data-md-color and data-md-circular/-wavy/-thick, and data-md-value/-max in place of data-value/data-max; no class list. progress.css draws LinearProgressIndicatorTokens and CircularProgressIndicatorTokens' stroke, sizes and colours, and the linear indicator's unconditional right-to-left mirror. Its sizing is a default only, same as loading.css: a caller's own class, unlayered or in Tailwind's utilities layer, still outranks it, so the width/ size detection regex the view carried is gone with it. resources/js/progress.js's WATCHED array and its reads follow the renamed hooks. ProgressTest (Feature and Browser) is rewritten on the hooks and ComponentStylesheet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
176 lines
9.2 KiB
PHP
176 lines
9.2 KiB
PHP
{{-- 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, unless the caller's own class narrows it — an
|
||
application's unlayered CSS always outranks the package's default, `w-*` Tailwind utilities
|
||
included. `circular` is 40px, 48px wavy, the same way. `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.
|
||
|
||
Drawn by resources/css/components/progress.css from `data-md-progress`, `data-md-color` and
|
||
the `data-md-circular`/`data-md-wavy`/`data-md-thick`/`data-md-value`/`data-md-max` progress.js
|
||
also reads and writes; no class list. --}}
|
||
|
||
@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'));
|
||
|
||
$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->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-md-progress' => true,
|
||
'data-md-color' => $color,
|
||
'data-md-value' => $amount === null ? null : $format($amount),
|
||
'x-bind:data-md-value' => $bound,
|
||
'data-md-max' => $format($maximum),
|
||
'data-md-circular' => $circular ? true : null,
|
||
'data-md-wavy' => $wavy ? true : null,
|
||
'data-md-thick' => $thick ? true : null,
|
||
], fn ($attribute): bool => $attribute !== null));
|
||
@endphp
|
||
|
||
<span {{ $attributes }}>
|
||
<svg @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>
|