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

<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:
Andreas Reinhold / reini
2026-09-13 07:05:52 +02:00
co-authored by Claude Opus 5
parent 648ad8efaf
commit e57063b0f4
7 changed files with 2087 additions and 1 deletions
@@ -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.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>
+1 -1
View File
@@ -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>&lt;x-progress&gt;</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>
+174
View File
@@ -0,0 +1,174 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
/**
* A Livewire component whose value only the server changes, to watch an indicator through a morph.
*/
class ProgressMorphProbe extends Component
{
public int $done = 20;
public function advance(): void
{
$this->done = 80;
}
public function render(): string
{
return <<<'BLADE'
<div class="p-4">
<x-progress :value="$done" label="Server" />
<button type="button" wire:click="advance">Advance</button>
</div>
BLADE;
}
}
/**
* A script run against one indicator in the showcase, scrolled into view first: frames are only
* drawn while an indicator is on screen. `el` is its root; the script's value is the result.
* Pest retries a failing assertion, and gives each attempt a second: keep scripts well inside it.
*/
function onProgress(string $label, string $body): string
{
return <<<JS
(async () => {
const el = document.querySelector('#progress [aria-label="{$label}"]')
el.scrollIntoView({ block: 'center' })
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const active = () => el.querySelector('path[stroke="currentColor"]')
const reach = () => { const box = active().getBBox(); return (box.x + box.width) / el.offsetWidth }
await pause(150)
{$body}
})()
JS;
}
function progressShowcase(array $options = [])
{
return visit('/material', $options)->waitForEvent('networkidle');
}
it('draws a linear indicator to its value', function () {
progressShowcase()
->assertNoJavaScriptErrors()
->assertScript(onProgress('Linear', <<<'JS'
const box = active().getBBox()
return Math.abs(box.x - 2) < 0.5 && Math.abs(box.x + box.width - el.offsetWidth * 0.4) < 1
&& el.querySelectorAll('svg svg').length === 0
JS));
});
it('draws a circular arc to its value, from 12 o\'clock', function () {
progressShowcase()->assertScript(onProgress('Circular', <<<'JS'
const path = active()
const end = path.getPointAtLength(path.getTotalLength())
const start = path.getPointAtLength(0)
const turn = (point) => (Math.atan2(point.y - el.offsetHeight / 2, point.x - el.offsetWidth / 2) * 180 / Math.PI + 450) % 360 / 360
return Math.min(turn(start), 1 - turn(start)) < 0.005 && Math.abs(turn(end) - 0.65) < 0.005
JS));
});
it('draws a circular wave as far as its value', function () {
progressShowcase()->assertScript(onProgress('Circular wavy', <<<'JS'
const path = active()
const [dash] = path.getAttribute('stroke-dasharray').split(' ').map(Number)
return path.getAttribute('visibility') === null && Math.abs(dash / (Number(path.getAttribute('pathLength')) / 2) - 0.65) < 0.001
JS));
});
it('moves to a new bound value instead of jumping', function () {
progressShowcase()
->assertScript(onProgress('Bound', <<<'JS'
const before = reach()
Alpine.$data(el).progress = 80
await pause(80)
const during = reach()
return Math.abs(before - 0.3) < 0.01 && during > before + 0.01 && during < 0.79
JS))
->wait(1)
->assertScript(onProgress('Bound', "return Math.abs(reach() - 0.8) < 0.01 && el.getAttribute('aria-valuenow') === '80'"));
});
it('turns indeterminate when a bound value is null, and back', function () {
progressShowcase()->assertScript(onProgress('Bound', <<<'JS'
Alpine.$data(el).progress = null
await pause(100)
const indeterminate = !el.hasAttribute('aria-valuenow') && !el.hasAttribute('data-value')
const first = active().getAttribute('d')
await pause(200)
const moving = active().getAttribute('d') !== first
Alpine.$data(el).progress = 50
await pause(100)
return indeterminate && moving && el.getAttribute('aria-valuenow') === '50' && Math.abs(reach() - 0.5) < 0.01
JS));
});
it('follows a value the server changes through a Livewire morph', function () {
Livewire::component('progress-morph-probe', ProgressMorphProbe::class);
Route::middleware('web')->get('/progress-morph-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:progress-morph-probe />
@livewireScripts
</body>
</html>
BLADE));
$reach = "(() => { const el = document.querySelector('[aria-label=\"Server\"]'); const box = el.querySelector('path[stroke=\"currentColor\"]').getBBox(); return (box.x + box.width) / el.offsetWidth })()";
$page = visit('/progress-morph-probe')->waitForEvent('networkidle')
->assertNoJavaScriptErrors()
->assertScript("Math.abs({$reach} - 0.2) < 0.01");
$page->click('Advance')
->wait(1.5)
->assertAttribute('[aria-label="Server"]', 'aria-valuenow', '80')
->assertScript("Math.abs({$reach} - 0.8) < 0.01")
->assertScript("document.querySelectorAll('[aria-label=\"Server\"] svg svg').length === 0");
});
it('animates while indeterminate', function () {
progressShowcase()
->assertScript(onProgress('Linear, indeterminate', <<<'JS'
const first = active().getAttribute('d')
await pause(200)
return active().getAttribute('d') !== first
JS))
->assertScript(onProgress('Circular wavy, indeterminate', <<<'JS'
const group = el.querySelector('g')
const first = group.getAttribute('transform') + active().getAttribute('stroke-dasharray')
await pause(200)
return group.getAttribute('transform') + active().getAttribute('stroke-dasharray') !== first
JS));
});
it('holds still under reduced motion', function () {
progressShowcase(['reducedMotion' => 'reduce'])
->assertScript(onProgress('Wavy, indeterminate', <<<'JS'
const first = active().getAttribute('d')
await pause(300)
return active().getAttribute('d') === first
JS))
->assertScript(onProgress('Wavy', <<<'JS'
const first = active().getAttribute('d')
await pause(300)
return active().getAttribute('d') === first
JS))
->assertScript(onProgress('Bound', <<<'JS'
Alpine.$data(el).progress = 90
await pause(50)
return Math.abs(reach() - 0.9) < 0.01
JS));
});
+107
View File
@@ -0,0 +1,107 @@
<?php
it('is a named, determinate progressbar with its value', function () {
expect((string) $this->blade('<x-progress value="42" />'))
->toContain('role="progressbar"')
->toContain('aria-label="Progress"')
->toContain('aria-valuemin="0"')
->toContain('aria-valuemax="100"')
->toContain('aria-valuenow="42"')
->toContain('data-value="42"')
->toContain('x-data="materialProgress"');
});
it('leaves out aria-valuenow while indeterminate', function () {
expect((string) $this->blade('<x-progress label="Uploading" />'))
->toContain('role="progressbar"')
->toContain('aria-label="Uploading"')
->not->toContain('aria-valuenow')
->not->toContain('data-value');
});
it('says nothing where something else already does', function () {
expect((string) $this->blade('<x-progress value="10" :label="false" />'))
->toContain('aria-hidden="true"')
->not->toContain('role="progressbar"')
->not->toContain('aria-label')
->not->toContain('aria-valuenow');
});
it('clamps the value into its range', function (string $template, string $now, string $max) {
expect((string) $this->blade($template))
->toContain("aria-valuenow=\"{$now}\"")
->toContain("aria-valuemax=\"{$max}\"")
->toContain("data-value=\"{$now}\"");
})->with([
'below zero' => ['<x-progress :value="-5" />', '0', '100'],
'above the maximum' => ['<x-progress :value="250" />', '100', '100'],
'a custom maximum' => ['<x-progress :value="3.5" :max="5" />', '3.5', '5'],
'a maximum that cannot be one' => ['<x-progress :value="30" :max="0" />', '30', '100'],
]);
it('draws the first frame on the server, at the value', function () {
expect((string) $this->blade('<x-progress value="42" />'))
->toContain('<svg x="42%" overflow="visible">')
->toContain('x2="42%"')
->toContain('<circle cx="100%"')
->toContain('wire:ignore');
expect((string) $this->blade('<x-progress value="25" circular />'))
->toContain('viewBox="0 0 40 40"')
->toContain('<path d="M20 2A18 18 0 0 1 38 20" stroke="currentColor" />')
->toContain('<path d="M36.579 27.01A18 18 0 1 1 12.99 3.421" />');
});
it('draws the active indicator in the colour and the track in its container', function (string $color, string $ink, string $track) {
expect((string) $this->blade('<x-progress value="50" :color="$color" />', ['color' => $color]))
->toContain($ink)
->toContain($track);
})->with([
'primary' => ['primary', 'text-primary', 'stroke-secondary-container'],
'secondary' => ['secondary', 'text-secondary', 'stroke-secondary-container'],
'tertiary' => ['tertiary', 'text-tertiary', 'stroke-tertiary-container'],
'error' => ['error', 'text-error', 'stroke-error-container'],
'unknown, as primary' => ['purple', 'text-primary', 'stroke-secondary-container'],
]);
it('sizes each shape as M3 does', function (string $template, string $size, array $flags) {
$html = (string) $this->blade($template);
expect($html)->toContain($size);
foreach (['data-circular', 'data-wavy', 'data-thick'] as $flag) {
in_array($flag, $flags, true)
? expect($html)->toContain($flag)
: expect($html)->not->toContain($flag);
}
})->with([
'linear' => ['<x-progress />', 'w-full h-1 ', []],
'linear thick' => ['<x-progress thick />', 'h-2 ', ['data-thick']],
'linear wavy' => ['<x-progress wavy />', 'h-2.5 ', ['data-wavy']],
'linear wavy thick' => ['<x-progress wavy thick />', 'h-3.5 ', ['data-wavy', 'data-thick']],
'circular' => ['<x-progress circular />', 'size-10 ', ['data-circular']],
'circular thick' => ['<x-progress circular thick />', 'size-11 ', ['data-circular', 'data-thick']],
'circular wavy' => ['<x-progress circular wavy />', 'size-12 ', ['data-circular', 'data-wavy']],
'circular wavy thick' => ['<x-progress circular wavy thick />', 'size-13 ', ['data-circular', 'data-wavy', 'data-thick']],
]);
it('takes the caller\'s size instead of its own', function () {
expect((string) $this->blade('<x-progress circular wavy class="size-24" />'))
->toContain('size-24')
->not->toContain('size-12');
expect((string) $this->blade('<x-progress class="w-64" />'))
->toContain('w-64')
->not->toContain('w-full');
});
it('follows a value bound in Alpine', function () {
$html = (string) $this->blade('<x-progress bind="upload.progress" max="1" />');
expect($html)
->toContain('x-bind:data-value="upload.progress"')
->toContain('x-bind:aria-valuenow="((v) =&gt; v === null')
->toContain('Math.min(Math.max(Number(v), 0), 1))(upload.progress)')
->toContain('data-max="1"')
->not->toContain(' data-value=');
});