An over-engineering audit of the whole tree, applied in five reviewed batches. Behaviour stays the same except where UPGRADE.md says otherwise. PHP: the showcase and error-page stylesheets are prebuilt into resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import (first occurrence kept, the order an application's build gives), instead of Stylesheets::bundle() inlining imports on every request; only the import walk DesignGuard needs stays. SchemeStylesheet::withProfiles() replaces three copies of the scheme-plus-profiles loop, material:scheme leaves spec and contrast checks to the node script that already made them, and the error page's scheme cache, the hashed view namespace, the translations path with no lang/ folder and DesignGuard's 1.x-name hints are gone. JS: the androidx shape port progress.js and both bin scripts each carried lives once in resources/js/shapes.js (the generated SVGs are unchanged); util.js holds ringIndex(), ms(), reopenGuard() and remember(), which were written out several times; listeners are released through AbortController; tooltip.js's hoverPopover() serves the rich tooltip too. CSS: every rule for an element inside the navigation rail queries `--md-navigation-rail-value` instead of repeating the seven collapsed conditions under five media branches; badge, alert, progress, slider and button read one non-inheriting colour-role table (components/color.css); the dialog chrome, the submenu's popover chrome, the chip's state layer and touch target, and the visually-hidden inputs use the shared rules they copied; foundation/tokens.css is folded into foundation.css. Views: Support\Field and Support\Link replace the error-key, bound-value and link-attribute blocks copied into the fields and link components; the timepicker period group, the menu filter and the showcase head are partials; the datepicker's steppers and entry fields are loops; component docblocks no longer restate SKILL.md. Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces four per-group files, DesignGuardTest and the layout-component tests use datasets, browser tests share one ready() helper, CSS parsing lives in ComponentStylesheet alone. docs/audits and the finding IDs citing it are removed, as are pestphp/pest-plugin-laravel, the unused composer scripts and check:font; the lint job runs in the feature job, which now installs node packages so the prebuilt-stylesheet staleness test runs in CI. Feature suite 1177 passed, Chrome browser suite 299 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
176 lines
9.1 KiB
PHP
176 lines
9.1 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. `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([
|
||
'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,
|
||
]);
|
||
@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>
|