Draw the progress indicator without Tailwind

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
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 16:46:52 +02:00
co-authored by Claude Opus 5
parent ce8ac4d316
commit 15cd306aba
7 changed files with 211 additions and 99 deletions
+1
View File
@@ -28,6 +28,7 @@
@import './components/fab-menu.css';
@import './components/rich-tooltip.css';
@import './components/toast.css';
@import './components/progress.css';
/* Inputs, selection and data */
@import './components/form.css';
+113
View File
@@ -0,0 +1,113 @@
/*
* <x-progress>: M3 Expressive's progress indicator, drawn on the server for its first frame and
* frame by frame in the browser after.
*
* ProgressIndicatorTokens, LinearProgressIndicatorTokens, CircularProgressIndicatorTokens (androidx
* Compose Material 3, Apache-2.0): 4px stroke (`data-md-thick` 8px), linear 4px tall (8px thick,
* 10px `data-md-wavy`, 14px wavy thick — the container grows by the extra stroke, keeping the
* wave's own height put), circular 40px (44px thick, 48px wavy, 52px wavy thick). `data-md-color`
* is the active indicator's `color` and the track's `stroke`; the track is secondary-container for
* primary and secondary, and the colour's own container otherwise.
*
* Linear mirrors in a right-to-left document unconditionally — M3's own rule for this indicator,
* not the `mirror-rtl` a caller opts an icon into. The sizing here is a default only: an
* application's own class, unlayered or in Tailwind's `utilities` layer, always outranks
* `material.components`, so `w-*`/`size-*` still narrows or grows the indicator exactly as it did
* before this file existed.
*
* resources/js/progress.js reads and writes `data-md-value`, `data-md-max`, `data-md-circular`,
* `data-md-wavy` and `data-md-thick` — ProgressIndicator.kt, WavyProgressIndicator.kt and its
* Linear/CircularWavyProgressModifiers, ported at commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326
* — and draws every frame straight onto the SVG's own attributes; nothing here reads a class or
* runs a transition.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-progress] {
color: var(--md-sys-color-primary);
stroke: var(--md-sys-color-secondary-container);
& > svg {
display: block;
width: 100%;
height: 100%;
}
}
[data-md-progress]:not([data-md-circular]) {
display: block;
width: 100%;
height: 4px;
}
[data-md-progress]:not([data-md-circular]):dir(rtl) {
rotate: 180deg;
}
[data-md-progress]:not([data-md-circular])[data-md-thick] {
height: 8px;
}
[data-md-progress]:not([data-md-circular])[data-md-wavy] {
height: 10px;
}
[data-md-progress]:not([data-md-circular])[data-md-wavy][data-md-thick] {
height: 14px;
}
[data-md-progress][data-md-circular] {
display: inline-block;
flex-shrink: 0;
vertical-align: middle;
width: 40px;
height: 40px;
}
[data-md-progress][data-md-circular][data-md-thick] {
width: 44px;
height: 44px;
}
[data-md-progress][data-md-circular][data-md-wavy] {
width: 48px;
height: 48px;
}
[data-md-progress][data-md-circular][data-md-wavy][data-md-thick] {
width: 52px;
height: 52px;
}
[data-md-progress][data-md-color='secondary'] {
color: var(--md-sys-color-secondary);
stroke: var(--md-sys-color-secondary-container);
}
[data-md-progress][data-md-color='tertiary'] {
color: var(--md-sys-color-tertiary);
stroke: var(--md-sys-color-tertiary-container);
}
[data-md-progress][data-md-color='error'] {
color: var(--md-sys-color-error);
stroke: var(--md-sys-color-error-container);
}
[data-md-progress][data-md-color='success'] {
color: var(--md-sys-color-success);
stroke: var(--md-sys-color-success-container);
}
[data-md-progress][data-md-color='warning'] {
color: var(--md-sys-color-warning);
stroke: var(--md-sys-color-warning-container);
}
[data-md-progress][data-md-color='info'] {
color: var(--md-sys-color-info);
stroke: var(--md-sys-color-info-container);
}
}
+7 -7
View File
@@ -5,7 +5,7 @@
*
* The server renders a first frame (the value, or a still of the indeterminate animation), so
* the indicator is right before this runs. This then owns the SVG, which is `wire:ignore`: a
* Livewire morph only changes the root's `data-value` (so does `x-bind`, for `bind`), and a
* Livewire morph only changes the root's `data-md-value` (so does `x-bind`, for `bind`), and a
* MutationObserver turns that into motion.
*
* - A new value moves on M3 Expressive's effects-slow spring (damping ratio 1, stiffness 800,
@@ -54,7 +54,7 @@
*/
const SVG = 'http://www.w3.org/2000/svg'
const WATCHED = ['data-value', 'data-max', 'data-circular', 'data-wavy', 'data-thick']
const WATCHED = ['data-md-value', 'data-md-max', 'data-md-circular', 'data-md-wavy', 'data-md-thick']
// Tokens: *ProgressIndicatorTokens, WavyProgressIndicatorDefaults, ProgressIndicator.kt ----
@@ -1061,16 +1061,16 @@ class Indicator {
read(initial) {
const element = this.element
const shape = WATCHED.slice(2).map((name) => element.hasAttribute(name)).join()
const maximum = parseFloat(element.getAttribute('data-max')) > 0 ? parseFloat(element.getAttribute('data-max')) : 100
const raw = element.getAttribute('data-value')
const maximum = parseFloat(element.getAttribute('data-md-max')) > 0 ? parseFloat(element.getAttribute('data-md-max')) : 100
const raw = element.getAttribute('data-md-value')
const number = raw === null || raw.trim() === '' ? NaN : Number(raw)
const target = Number.isFinite(number) ? clamp(number / maximum, 0, 1) : null
if (initial || shape !== this.shape) {
this.shape = shape
this.circular = element.hasAttribute('data-circular')
this.wavy = element.hasAttribute('data-wavy')
this.stroke = element.hasAttribute('data-thick') ? 8 : 4
this.circular = element.hasAttribute('data-md-circular')
this.wavy = element.hasAttribute('data-md-wavy')
this.stroke = element.hasAttribute('data-md-thick') ? 8 : 4
this.build()
this.reset(target)
} else if ((target === null) !== (this.target === null)) {
+35 -60
View File
@@ -6,13 +6,15 @@
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.
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.
@@ -25,7 +27,11 @@
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. --}}
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,
@@ -50,23 +56,6 @@
$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.
@@ -147,44 +136,30 @@
$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));
$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 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>
<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
+1 -1
View File
@@ -111,7 +111,7 @@ it('turns indeterminate when a bound value is null, and back', function () {
progressShowcase()->assertScript(onProgress('Bound', <<<'JS'
set(null)
await pause(100)
const indeterminate = !el.hasAttribute('aria-valuenow') && !el.hasAttribute('data-value')
const indeterminate = !el.hasAttribute('aria-valuenow') && !el.hasAttribute('data-md-value')
const first = active().getAttribute('d')
await pause(200)
const moving = active().getAttribute('d') !== first
@@ -27,6 +27,7 @@ dataset('action components', [
'fab-menu',
'rich-tooltip',
'toast',
'progress',
]);
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
+53 -31
View File
@@ -1,13 +1,17 @@
<?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('is a named, determinate progressbar with its value', function () {
expect((string) $this->blade('<x-progress value="42" />'))
->toContain('data-md-progress')
->toContain('role="progressbar"')
->toContain('aria-label="Progress"')
->toContain('aria-valuemin="0"')
->toContain('aria-valuemax="100"')
->toContain('aria-valuenow="42"')
->toContain('data-value="42"')
->toContain('data-md-value="42"')
->toContain('data-md-color="primary"')
->toContain('x-data="materialProgress"');
});
@@ -16,7 +20,7 @@ it('leaves out aria-valuenow while indeterminate', function () {
->toContain('role="progressbar"')
->toContain('aria-label="Uploading"')
->not->toContain('aria-valuenow')
->not->toContain('data-value');
->not->toContain('data-md-value');
});
it('says nothing where something else already does', function () {
@@ -31,7 +35,7 @@ it('clamps the value into its range', function (string $template, string $now, s
expect((string) $this->blade($template))
->toContain("aria-valuenow=\"{$now}\"")
->toContain("aria-valuemax=\"{$max}\"")
->toContain("data-value=\"{$now}\"");
->toContain("data-md-value=\"{$now}\"");
})->with([
'below zero' => ['<x-progress :value="-5" />', '0', '100'],
'above the maximum' => ['<x-progress :value="250" />', '100', '100'],
@@ -52,56 +56,74 @@ it('draws the first frame on the server, at the value', function () {
->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) {
it('draws the active indicator in the colour and the track in its container', function (string $color, array $declarations) {
$css = ComponentStylesheet::read('progress');
expect((string) $this->blade('<x-progress value="50" :color="$color" />', ['color' => $color]))
->toContain($ink)
->toContain($track);
->toContain("data-md-color=\"{$color}\"")
->and($css->declarations("[data-md-progress][data-md-color='{$color}']"))->toBe($declarations);
})->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'],
'secondary' => ['secondary', ['color' => 'var(--md-sys-color-secondary)', 'stroke' => 'var(--md-sys-color-secondary-container)']],
'tertiary' => ['tertiary', ['color' => 'var(--md-sys-color-tertiary)', 'stroke' => 'var(--md-sys-color-tertiary-container)']],
'error' => ['error', ['color' => 'var(--md-sys-color-error)', 'stroke' => 'var(--md-sys-color-error-container)']],
]);
it('sizes each shape as M3 does', function (string $template, string $size, array $flags) {
it('falls back to primary for an unknown colour, secondary-container track included', function () {
$css = ComponentStylesheet::read('progress');
expect((string) $this->blade('<x-progress value="50" color="purple" />'))->toContain('data-md-color="primary"')
->and($css->declarations('[data-md-progress]'))->toMatchArray([
'color' => 'var(--md-sys-color-primary)',
'stroke' => 'var(--md-sys-color-secondary-container)',
]);
});
it('sizes each shape as M3 does', function (string $template, array $flags, string $selector, array $declarations) {
$html = (string) $this->blade($template);
expect($html)->toContain($size);
foreach (['data-circular', 'data-wavy', 'data-thick'] as $flag) {
foreach (['data-md-circular', 'data-md-wavy', 'data-md-thick'] as $flag) {
in_array($flag, $flags, true)
? expect($html)->toContain($flag)
: expect($html)->not->toContain($flag);
}
expect(ComponentStylesheet::read('progress')->declarations($selector))->toMatchArray($declarations);
})->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']],
'linear' => ['<x-progress />', [], '[data-md-progress]:not([data-md-circular])', ['height' => '4px']],
'linear thick' => ['<x-progress thick />', ['data-md-thick'], '[data-md-progress]:not([data-md-circular])[data-md-thick]', ['height' => '8px']],
'linear wavy' => ['<x-progress wavy />', ['data-md-wavy'], '[data-md-progress]:not([data-md-circular])[data-md-wavy]', ['height' => '10px']],
'linear wavy thick' => ['<x-progress wavy thick />', ['data-md-wavy', 'data-md-thick'], '[data-md-progress]:not([data-md-circular])[data-md-wavy][data-md-thick]', ['height' => '14px']],
'circular' => ['<x-progress circular />', ['data-md-circular'], '[data-md-progress][data-md-circular]', ['width' => '40px', 'height' => '40px']],
'circular thick' => ['<x-progress circular thick />', ['data-md-circular', 'data-md-thick'], '[data-md-progress][data-md-circular][data-md-thick]', ['width' => '44px', 'height' => '44px']],
'circular wavy' => ['<x-progress circular wavy />', ['data-md-circular', 'data-md-wavy'], '[data-md-progress][data-md-circular][data-md-wavy]', ['width' => '48px', 'height' => '48px']],
'circular wavy thick' => ['<x-progress circular wavy thick />', ['data-md-circular', 'data-md-wavy', 'data-md-thick'], '[data-md-progress][data-md-circular][data-md-wavy][data-md-thick]', ['width' => '52px', 'height' => '52px']],
]);
it('takes the caller\'s size instead of its own', function () {
it('takes the caller\'s size instead of its own, its class landing on the root untouched', function () {
expect((string) $this->blade('<x-progress circular wavy class="size-24" />'))
->toContain('size-24')
->not->toContain('size-12');
->toContain('class="size-24"')
->toContain('data-md-circular')
->toContain('data-md-wavy');
expect((string) $this->blade('<x-progress class="w-64" />'))
->toContain('w-64')
->not->toContain('w-full');
->toContain('class="w-64"')
->not->toContain('data-md-circular');
});
it('mirrors the linear indicator in a right-to-left document, unconditionally', function () {
$css = ComponentStylesheet::read('progress');
expect($css->declarations('[data-md-progress]:not([data-md-circular]):dir(rtl)'))->toBe(['rotate' => '180deg'])
->and($css->has('[data-md-progress][data-md-circular]:dir(rtl)'))->toBeFalse();
});
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:data-md-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=');
->toContain('data-md-max="1"')
->not->toContain(' data-md-value=');
});