From e57063b0f4593eb17c8b61cf28b7932bc2bc5cfe Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Sun, 13 Sep 2026 07:05:52 +0200 Subject: [PATCH] Add M3 Expressive progress indicators : 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) Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy --- .../livewire-material-development/SKILL.md | 27 + resources/js/progress.js | 1521 +++++++++++++++++ resources/views/components/progress.blade.php | 200 +++ resources/views/showcase/layout.blade.php | 2 +- .../showcase/sections/progress.blade.php | 57 + tests/Browser/ProgressTest.php | 174 ++ tests/Feature/Components/ProgressTest.php | 107 ++ 7 files changed, 2087 insertions(+), 1 deletion(-) create mode 100644 resources/js/progress.js create mode 100644 resources/views/components/progress.blade.php create mode 100644 resources/views/showcase/sections/progress.blade.php create mode 100644 tests/Browser/ProgressTest.php create mode 100644 tests/Feature/Components/ProgressTest.php diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index a9cb6ac3..86152d54 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -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. +### `` + +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 + + + +
+ +
+
+``` + +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. + ### `` - `` — M3's small badge, a dot. `` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: ``. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread"). diff --git a/resources/js/progress.js b/resources/js/progress.js new file mode 100644 index 00000000..a709e7a0 --- /dev/null +++ b/resources/js/progress.js @@ -0,0 +1,1521 @@ +/** + * `materialProgress`: draws ``, M3 Expressive's progress indicator — linear or + * circular, flat or wavy, determinate or indeterminate — the way androidx Compose Material 3 + * draws it, frame by frame, into the component's SVG. + * + * 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 + * MutationObserver turns that into motion. + * + * - A new value moves on M3 Expressive's effects-slow spring (damping ratio 1, stiffness 800, + * motion.css). Compose recommends a spring that does not bounce either + * (ProgressIndicatorDefaults.ProgressAnimationSpec): a progress that overshoots is briefly a lie. + * The duration token scales it, so reduced motion makes it instant. + * - Wavy: full amplitude between 10% and 95%, flat outside (indicatorAmplitude), reached in + * 500 ms (standard easing growing, emphasized-accelerate flattening); the wave travels a + * wavelength a second. The circular wave is a RoundedPolygon star morphing from a circle. + * - Indeterminate: Compose's keyframes — two lines chasing over 1750 ms; an arc that grows and + * shrinks over 6 s while it turns 1080° plus a quarter turn every 1.5 s. + * - Reduced motion: values jump, the wave stands still, and an indeterminate indicator holds + * one frame of its animation. + * + * Frames are drawn only while something moves and the indicator is on screen. + * + * --------------------------------------------------------------------------------------- + * Ported from androidx (https://github.com/androidx/androidx), commit + * 27cf9a7d5788aa0f5f2d8b6699ce279560daf326: + * + * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ProgressIndicator.kt + * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/WavyProgressIndicator.kt + * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/LinearWavyProgressModifiers.kt + * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/CircularWavyProgressModifiers.kt + * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/ShapeUtil.kt + * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/*ProgressIndicatorTokens.kt + * compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/VectorizedAnimationSpec.kt (keyframes) + * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/*.kt (via bin/shapes.mjs + * and bin/loading-indicator.mjs: RoundedPolygon, CornerRounding, Cubic, Morph, FeatureMapping, + * PolygonMeasure) + * + * Copyright 2022-2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * --------------------------------------------------------------------------------------- + */ + +const SVG = 'http://www.w3.org/2000/svg' +const WATCHED = ['data-value', 'data-max', 'data-circular', 'data-wavy', 'data-thick'] + +// Tokens: *ProgressIndicatorTokens, WavyProgressIndicatorDefaults, ProgressIndicator.kt ---- + +const GAP = 4 +const STOP_SIZE = 4 +const STOP_TRAILING_SPACE = 6 +const LINEAR_WAVELENGTH = 40 +const LINEAR_INDETERMINATE_WAVELENGTH = 20 +const CIRCULAR_WAVELENGTH = 15 +const MIN_CIRCULAR_VERTICES = 5 +const WAVE_PERIOD = 1000 +const AMPLITUDE_DURATION = 500 +const LINEAR_CYCLE = 1750 +const CIRCULAR_CYCLE = 6000 + +// effects-slow (motion.css): the spring a new value moves on, and the time it settles in. +const VALUE_STIFFNESS = 800 +const VALUE_SETTLE = 330 + +/** The frames an indeterminate indicator holds under reduced motion; the server draws the same. */ +const LINEAR_STILL = 875 +const CIRCULAR_STILL = 2000 + +// Easing and keyframes ------------------------------------------------------------------ + +const linear = (fraction) => fraction + +/** CubicBezierEasing: the curve's y where its x is `fraction`, x found by bisection. */ +function cubicBezier(x1, y1, x2, y2) { + const axis = (t, p1, p2) => 3 * (1 - t) * (1 - t) * t * p1 + 3 * (1 - t) * t * t * p2 + t * t * t + + return (fraction) => { + if (fraction <= 0 || fraction >= 1) { + return Math.min(Math.max(fraction, 0), 1) + } + + let low = 0 + let high = 1 + + for (let i = 0; i < 24; i++) { + const middle = (low + high) / 2 + + if (axis(middle, x1, x2) < fraction) { + low = middle + } else { + high = middle + } + } + + return axis((low + high) / 2, y1, y2) + } +} + +const STANDARD = cubicBezier(0.2, 0, 0, 1) +const EMPHASIZED_ACCELERATE = cubicBezier(0.3, 0, 0.8, 0.15) +const EMPHASIZED_DECELERATE = cubicBezier(0.05, 0.7, 0.1, 1) + +/** + * An infinitely repeating Compose `keyframes` spec: `[time, value, easing]` entries, where an + * entry's easing shapes the interval that starts at it. As in VectorizedKeyframesSpec, the + * implicit start (at 0 ms, from `initial`) and any entry without an easing are linear, and the + * spec ends on `target` when no entry sits at its end. + */ +function keyframes(duration, initial, target, entries) { + const frames = [...entries] + + if (frames[0][0] !== 0) { + frames.unshift([0, initial]) + } + + if (frames.at(-1)[0] !== duration) { + frames.push([duration, target]) + } + + return (ms) => { + const time = ms % duration + let i = 0 + + while (i < frames.length - 2 && time >= frames[i + 1][0]) { + i++ + } + + const [start, from, easing = linear] = frames[i] + const [end, to] = frames[i + 1] + + return from + (to - from) * easing((time - start) / (end - start)) + } +} + +const line = (delay, duration) => keyframes(LINEAR_CYCLE, 0, 1, [[delay, 0, EMPHASIZED_ACCELERATE], [delay + duration, 1]]) + +/** linearIndeterminate*AnimationSpec, in the order the drawing takes them: [tail, head, tail, head]. */ +const LINEAR_LINES = [line(250, 1000), line(0, 1000), line(900, 850), line(650, 850)] + +const CIRCULAR_TURN = keyframes(CIRCULAR_CYCLE, 0, 360, [ + [300, 90, EMPHASIZED_DECELERATE], + [1500, 90], + [1800, 180], + [3000, 180], + [3300, 270], + [4500, 270], + [4800, 360], +]) + +const CIRCULAR_SWEEP = keyframes(CIRCULAR_CYCLE, 0.1, 0.87, [ + [3000, 0.87, STANDARD], + [6000, 0.1], +]) + +/** The global rotation (1080° in 6 s, linear) plus the additional quarter turns. */ +const circularRotation = (ms) => ((ms % CIRCULAR_CYCLE) / CIRCULAR_CYCLE) * 1080 + CIRCULAR_TURN(ms) + +/** WavyProgressIndicatorDefaults.indicatorAmplitude. */ +const amplitudeFor = (progress) => (progress <= 0.1 || progress >= 0.95 ? 0 : 1) + +const clamp = (value, min, max) => Math.min(Math.max(value, min), max) +const round = (value) => Math.round(value * 1000) / 1000 + +/** A straight stroke; a zero-length one is nudged so every engine still draws its round caps. */ +function straight(x0, y, x1) { + return `M${round(x0)} ${round(y)}L${round(Math.abs(x1 - x0) < 0.001 ? x0 + 0.001 : x1)} ${round(y)}` +} + +/** DrawScope.drawArc: `sweep` degrees clockwise from `start` (0° is 3 o'clock); none for 0. */ +function arc(cx, cy, radius, start, sweep) { + if (sweep === 0) { + return '' + } + + if (Math.abs(sweep) >= 360) { + const [r, left, right] = [round(radius), round(cx - radius), round(cx + radius)] + + return `M${right} ${round(cy)}A${r} ${r} 0 1 1 ${left} ${round(cy)}A${r} ${r} 0 1 1 ${right} ${round(cy)}Z` + } + + const from = (start * Math.PI) / 180 + const to = ((start + sweep) * Math.PI) / 180 + const [x0, y0] = [cx + radius * Math.cos(from), cy + radius * Math.sin(from)] + const [x1, y1] = [cx + radius * Math.cos(to), cy + radius * Math.sin(to)] + + if (Math.hypot(x1 - x0, y1 - y0) < 0.01) { + return `M${round(x0)} ${round(y0)}L${round(x0 + 0.001)} ${round(y0)}` + } + + return `M${round(x0)} ${round(y0)}A${round(radius)} ${round(radius)} 0 ${Math.abs(sweep) > 180 ? 1 : 0} ${sweep > 0 ? 1 : 0} ${round(x1)} ${round(y1)}` +} + +// The linear wave ----------------------------------------------------------------------- + +const waves = new Map() + +/** + * One half-wavelength of LinearProgressDrawingCache's full path: a quadratic from (0, 0) through + * the control point (h / 2, ±height) to (h, 0). Every half-wave has the same length, so a table + * of arc length against the curve parameter locates any distance along the whole path. + */ +function halfWave(halfWavelength, height) { + const key = `${halfWavelength}:${height}` + + if (!waves.has(key)) { + const steps = 64 + const lengths = new Float64Array(steps + 1) + const speed = (t) => Math.hypot(halfWavelength, 2 * height * (1 - 2 * t)) + + for (let i = 1; i <= steps; i++) { + const [a, b] = [(i - 1) / steps, i / steps] + lengths[i] = lengths[i - 1] + ((b - a) / 6) * (speed(a) + 4 * speed((a + b) / 2) + speed(b)) + } + + const length = lengths[steps] + + const parameterAt = (distance) => { + const d = clamp(distance, 0, length) + let low = 0 + let high = steps + + while (high - low > 1) { + const middle = (low + high) >> 1 + + if (lengths[middle] <= d) { + low = middle + } else { + high = middle + } + } + + const span = lengths[high] - lengths[low] + + return (low + (span > 0 ? (d - lengths[low]) / span : 0)) / steps + } + + waves.set(key, { length, parameterAt }) + } + + return waves.get(key) +} + +/** + * PathMeasure.getSegment on the full wave from `from` to `to` (distances), then Compose's + * transform: shifted back by the wave's phase and flattened towards the centre line by + * `amplitude` — as exact quadratic pieces. + */ +function waveSegment(from, to, halfWavelength, height, shift, middle, amplitude) { + const wave = halfWave(halfWavelength, height) + + const locate = (distance) => { + const index = Math.floor(distance / wave.length) + + return [index, wave.parameterAt(distance - index * wave.length)] + } + + const sign = (index) => (index % 2 === 0 ? 1 : -1) + const x = (index, t) => round((index + t) * halfWavelength - shift) + const y = (index, bump) => round(middle + amplitude * sign(index) * height * bump) + + const [first, t0] = locate(from) + const [last, t1] = locate(to) + let d = `M${x(first, t0)} ${y(first, 2 * t0 * (1 - t0))}` + + for (let index = first; index <= last; index++) { + const a = index === first ? t0 : 0 + const b = index === last ? t1 : 1 + + if (b <= a) { + continue + } + + // The quadratic's blossom at (a, b) is the control point of its piece from a to b. + d += `Q${x(index, (a + b) / 2)} ${y(index, a + b - 2 * a * b)} ${x(index, b)} ${y(index, 2 * b * (1 - b))}` + } + + return d +} + +// Shapes: bin/shapes.mjs (RoundedPolygon) -------------------------------------------------- + +const DISTANCE_EPSILON = 1e-4 +const ANGLE_EPSILON = 1e-6 + +const point = (x, y) => ({ x, y }) +const plus = (a, b) => point(a.x + b.x, a.y + b.y) +const minus = (a, b) => point(a.x - b.x, a.y - b.y) +const times = (a, k) => point(a.x * k, a.y * k) +const div = (a, k) => point(a.x / k, a.y / k) +const dot = (a, b) => a.x * b.x + a.y * b.y +const length = (a) => Math.sqrt(a.x * a.x + a.y * a.y) +const rotate90 = (a) => point(-a.y, a.x) +const lerp = (a, b, f) => (1 - f) * a + f * b +const lerpPoint = (a, b, f) => point(lerp(a.x, b.x, f), lerp(a.y, b.y, f)) +const direction = (a) => div(a, length(a)) +const radialToCartesian = (radius, angle) => point(Math.cos(angle) * radius, Math.sin(angle) * radius) +const convex = (previous, current, next) => { + const [a, b] = [minus(current, previous), minus(next, current)] + + return a.x * b.y - a.y * b.x > 0 +} + +/** A cubic is [anchor0X, anchor0Y, control0X, control0Y, control1X, control1Y, anchor1X, anchor1Y]. */ +const cubic = (a0, c0, c1, a1) => [a0.x, a0.y, c0.x, c0.y, c1.x, c1.y, a1.x, a1.y] + +const straightLine = (x0, y0, x1, y1) => [x0, y0, lerp(x0, x1, 1 / 3), lerp(y0, y1, 1 / 3), lerp(x0, x1, 2 / 3), lerp(y0, y1, 2 / 3), x1, y1] + +function circularArc(centerX, centerY, x0, y0, x1, y1) { + const p0d = direction(point(x0 - centerX, y0 - centerY)) + const p1d = direction(point(x1 - centerX, y1 - centerY)) + const rotatedP0 = rotate90(p0d) + const rotatedP1 = rotate90(p1d) + const clockwise = dot(rotatedP0, point(x1 - centerX, y1 - centerY)) >= 0 + const cosa = dot(p0d, p1d) + + if (cosa > 0.999) { + return straightLine(x0, y0, x1, y1) + } + + const k = + (((length(point(x0 - centerX, y0 - centerY)) * 4) / 3) * (Math.sqrt(2 * (1 - cosa)) - Math.sqrt(1 - cosa * cosa))) / + (1 - cosa) * + (clockwise ? 1 : -1) + + return [x0, y0, x0 + rotatedP0.x * k, y0 + rotatedP0.y * k, x1 - rotatedP1.x * k, y1 - rotatedP1.y * k, x1, y1] +} + +function pointOnCurve(c, t) { + const u = 1 - t + + return point( + c[0] * (u * u * u) + c[2] * (3 * t * u * u) + c[4] * (3 * t * t * u) + c[6] * (t * t * t), + c[1] * (u * u * u) + c[3] * (3 * t * u * u) + c[5] * (3 * t * t * u) + c[7] * (t * t * t), + ) +} + +function split(c, t) { + const u = 1 - t + const p = pointOnCurve(c, t) + + return [ + [c[0], c[1], c[0] * u + c[2] * t, c[1] * u + c[3] * t, c[0] * (u * u) + c[2] * (2 * u * t) + c[4] * (t * t), c[1] * (u * u) + c[3] * (2 * u * t) + c[5] * (t * t), p.x, p.y], + [p.x, p.y, c[2] * (u * u) + c[4] * (2 * u * t) + c[6] * (t * t), c[3] * (u * u) + c[5] * (2 * u * t) + c[7] * (t * t), c[4] * u + c[6] * t, c[5] * u + c[7] * t, c[6], c[7]], + ] +} + +const reverse = (c) => [c[6], c[7], c[4], c[5], c[2], c[3], c[0], c[1]] +const zeroLength = (c) => Math.abs(c[0] - c[6]) < DISTANCE_EPSILON && Math.abs(c[1] - c[7]) < DISTANCE_EPSILON + +const rounding = (radius = 0, smoothing = 0) => ({ radius, smoothing }) + +class RoundedCorner { + constructor(p0, p1, p2, cornerRounding) { + this.p0 = p0 + this.p1 = p1 + this.p2 = p2 + + const v01 = minus(p0, p1) + const v21 = minus(p2, p1) + const d01 = length(v01) + const d21 = length(v21) + + if (d01 > 0 && d21 > 0) { + this.d1 = div(v01, d01) + this.d2 = div(v21, d21) + this.cornerRadius = cornerRounding?.radius ?? 0 + this.smoothing = cornerRounding?.smoothing ?? 0 + this.cosAngle = dot(this.d1, this.d2) + this.sinAngle = Math.sqrt(1 - this.cosAngle * this.cosAngle) + this.expectedRoundCut = this.sinAngle > 1e-3 ? (this.cornerRadius * (this.cosAngle + 1)) / this.sinAngle : 0 + } else { + this.d1 = point(0, 0) + this.d2 = point(0, 0) + this.cornerRadius = 0 + this.smoothing = 0 + this.cosAngle = 0 + this.sinAngle = 0 + this.expectedRoundCut = 0 + } + } + + get expectedCut() { + return (1 + this.smoothing) * this.expectedRoundCut + } + + getCubics(allowedCut0, allowedCut1 = allowedCut0) { + const allowedCut = Math.min(allowedCut0, allowedCut1) + + if (this.expectedRoundCut < DISTANCE_EPSILON || allowedCut < DISTANCE_EPSILON || this.cornerRadius < DISTANCE_EPSILON) { + return [straightLine(this.p1.x, this.p1.y, this.p1.x, this.p1.y)] + } + + const actualRoundCut = Math.min(allowedCut, this.expectedRoundCut) + const actualSmoothing0 = this.actualSmoothing(allowedCut0) + const actualSmoothing1 = this.actualSmoothing(allowedCut1) + const actualR = (this.cornerRadius * actualRoundCut) / this.expectedRoundCut + const centerDistance = Math.sqrt(actualR * actualR + actualRoundCut * actualRoundCut) + const center = plus(this.p1, times(direction(div(plus(this.d1, this.d2), 2)), centerDistance)) + const circleIntersection0 = plus(this.p1, times(this.d1, actualRoundCut)) + const circleIntersection2 = plus(this.p1, times(this.d2, actualRoundCut)) + const flanking0 = this.flankingCurve(actualRoundCut, actualSmoothing0, this.p1, this.p0, circleIntersection0, circleIntersection2, center, actualR) + const flanking2 = reverse(this.flankingCurve(actualRoundCut, actualSmoothing1, this.p1, this.p2, circleIntersection2, circleIntersection0, center, actualR)) + + return [flanking0, circularArc(center.x, center.y, flanking0[6], flanking0[7], flanking2[0], flanking2[1]), flanking2] + } + + actualSmoothing(allowedCut) { + if (allowedCut > this.expectedCut) { + return this.smoothing + } + + if (allowedCut > this.expectedRoundCut) { + return (this.smoothing * (allowedCut - this.expectedRoundCut)) / (this.expectedCut - this.expectedRoundCut) + } + + return 0 + } + + flankingCurve(actualRoundCut, smoothing, corner, sideStart, intersection, otherIntersection, circleCenter, actualR) { + const sideDirection = direction(minus(sideStart, corner)) + const curveStart = plus(corner, times(sideDirection, actualRoundCut * (1 + smoothing))) + const p = lerpPoint(intersection, div(plus(intersection, otherIntersection), 2), smoothing) + const curveEnd = plus(circleCenter, times(direction(minus(p, circleCenter)), actualR)) + const circleTangent = rotate90(minus(curveEnd, circleCenter)) + const anchorEnd = lineIntersection(sideStart, sideDirection, curveEnd, circleTangent) ?? intersection + const anchorStart = div(plus(curveStart, times(anchorEnd, 2)), 3) + + return cubic(curveStart, anchorStart, anchorEnd, curveEnd) + } +} + +function lineIntersection(p0, d0, p1, d1) { + const rotatedD1 = rotate90(d1) + const den = dot(d0, rotatedD1) + + if (Math.abs(den) < DISTANCE_EPSILON) { + return null + } + + const num = dot(minus(p1, p0), rotatedD1) + + if (Math.abs(den) < DISTANCE_EPSILON * Math.abs(num)) { + return null + } + + return plus(p0, times(d0, num / den)) +} + +class RoundedPolygon { + constructor(features, center) { + this.features = features + this.center = center + this.cubics = flatten(features, center) + } + + transformed(f) { + const move = (c) => { + const out = [] + + for (let i = 0; i < 8; i += 2) { + const p = f(c[i], c[i + 1]) + out.push(p.x, p.y) + } + + return out + } + + return new RoundedPolygon( + this.features.map((feature) => ({ ...feature, cubics: feature.cubics.map(move) })), + f(this.center.x, this.center.y), + ) + } + + /** RoundedPolygon.normalized: into the unit square, by the approximate (control point) bounds. */ + normalized() { + let [left, top, right, bottom] = [Infinity, Infinity, -Infinity, -Infinity] + + for (const c of this.cubics) { + const count = zeroLength(c) ? 2 : 8 + + for (let i = 0; i < count; i += 2) { + left = Math.min(left, c[i]) + right = Math.max(right, c[i]) + top = Math.min(top, c[i + 1]) + bottom = Math.max(bottom, c[i + 1]) + } + } + + const [width, height] = [right - left, bottom - top] + const side = Math.max(width, height) + const offsetX = (side - width) / 2 - left + const offsetY = (side - height) / 2 - top + + return this.transformed((x, y) => point((x + offsetX) / side, (y + offsetY) / side)) + } +} + +function flatten(features, center) { + const out = [] + let firstCubic = null + let lastCubic = null + let firstFeatureSplitStart = null + let firstFeatureSplitEnd = null + + if (features.length > 0 && features[0].cubics.length === 3) { + const [start, end] = split(features[0].cubics[1], 0.5) + firstFeatureSplitStart = [features[0].cubics[0], start] + firstFeatureSplitEnd = [end, features[0].cubics[2]] + } + + for (let i = 0; i <= features.length; i++) { + let featureCubics + + if (i === 0 && firstFeatureSplitEnd !== null) { + featureCubics = firstFeatureSplitEnd + } else if (i === features.length) { + if (firstFeatureSplitStart === null) { + break + } + + featureCubics = firstFeatureSplitStart + } else { + featureCubics = features[i].cubics + } + + for (const c of featureCubics) { + if (!zeroLength(c)) { + if (lastCubic !== null) { + out.push(lastCubic) + } + + lastCubic = c + firstCubic ??= c + } else if (lastCubic !== null) { + lastCubic = [...lastCubic] + lastCubic[6] = c[0] + lastCubic[7] = c[1] + } + } + } + + if (lastCubic !== null && firstCubic !== null) { + out.push([...lastCubic.slice(0, 6), firstCubic[0], firstCubic[1]]) + } else { + out.push([center.x, center.y, center.x, center.y, center.x, center.y, center.x, center.y]) + } + + return out +} + +/** RoundedPolygon(vertices, rounding, perVertexRounding, centerX, centerY). */ +function polygonFromVertices(vertices, perVertexRounding, center) { + const n = vertices.length + const roundedCorners = vertices.map((vertex, i) => new RoundedCorner(vertices[(i + n - 1) % n], vertex, vertices[(i + 1) % n], perVertexRounding[i])) + + const cutAdjusts = vertices.map((vertex, i) => { + const next = (i + 1) % n + const expectedRoundCut = roundedCorners[i].expectedRoundCut + roundedCorners[next].expectedRoundCut + const expectedCut = roundedCorners[i].expectedCut + roundedCorners[next].expectedCut + const sideSize = length(minus(vertex, vertices[next])) + + if (expectedRoundCut > sideSize) { + return [sideSize / expectedRoundCut, 0] + } + + if (expectedCut > sideSize) { + return [1, (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut)] + } + + return [1, 1] + }) + + const corners = roundedCorners.map((corner, i) => { + const allowedCuts = [0, 1].map((delta) => { + const [roundCutRatio, cutRatio] = cutAdjusts[(i + n - 1 + delta) % n] + + return corner.expectedRoundCut * roundCutRatio + (corner.expectedCut - corner.expectedRoundCut) * cutRatio + }) + + return corner.getCubics(allowedCuts[0], allowedCuts[1]) + }) + + const features = [] + + for (let i = 0; i < n; i++) { + const end = corners[i].at(-1) + const start = corners[(i + 1) % n][0] + + features.push({ type: 'corner', convex: convex(vertices[(i + n - 1) % n], vertices[i], vertices[(i + 1) % n]), cubics: corners[i] }) + features.push({ type: 'edge', cubics: [straightLine(end[6], end[7], start[0], start[1])] }) + } + + return new RoundedPolygon(features, center) +} + +/** RoundedPolygon.circle(numVertices): a regular polygon rounded all the way round. */ +function circlePolygon(vertexCount) { + const radius = 1 / Math.cos(Math.PI / vertexCount) + const vertices = Array.from({ length: vertexCount }, (_, i) => radialToCartesian(radius, ((Math.PI / vertexCount) * 2 * i))) + + return polygonFromVertices(vertices, vertices.map(() => rounding(1)), point(0, 0)) +} + +/** RoundedPolygon.star(numVerticesPerRadius, innerRadius, rounding, innerRounding). */ +function starPolygon(vertexCount, innerRadius, outerRounding, innerRounding) { + const vertices = [] + const roundings = [] + + for (let i = 0; i < vertexCount; i++) { + vertices.push(radialToCartesian(1, (Math.PI / vertexCount) * 2 * i), radialToCartesian(innerRadius, (Math.PI / vertexCount) * (2 * i + 1))) + roundings.push(outerRounding, innerRounding) + } + + return polygonFromVertices(vertices, roundings, point(0, 0)) +} + +// Morph: bin/loading-indicator.mjs (FloatMapping, PolygonMeasure, FeatureMapping, Morph) --- + +const positiveModulo = (num, mod) => ((num % mod) + mod) % mod +const progressInRange = (progress, from, to) => (to >= from ? progress >= from && progress <= to : progress >= from || progress <= to) +const progressDistance = (a, b) => Math.min(Math.abs(a - b), 1 - Math.abs(a - b)) + +function linearMap(xValues, yValues, x) { + const n = xValues.length + const start = xValues.findIndex((_, i) => progressInRange(x, xValues[i], xValues[(i + 1) % n])) + const end = (start + 1) % n + const sizeX = positiveModulo(xValues[end] - xValues[start], 1) + const sizeY = positiveModulo(yValues[end] - yValues[start], 1) + const position = sizeX < 0.001 ? 0.5 : positiveModulo(x - xValues[start], 1) / sizeX + + return positiveModulo(yValues[start] + sizeY * position, 1) +} + +const MEASURE_SEGMENTS = 3 + +function closestProgressTo(c, threshold) { + let total = 0 + let remainder = threshold + let previous = point(c[0], c[1]) + + for (let i = 1; i <= MEASURE_SEGMENTS; i++) { + const progress = i / MEASURE_SEGMENTS + const p = pointOnCurve(c, progress) + const segment = Math.hypot(p.x - previous.x, p.y - previous.y) + + if (segment >= remainder) { + return [progress - (1 - remainder / segment) / MEASURE_SEGMENTS, threshold] + } + + remainder -= segment + total += segment + previous = p + } + + return [1, total] +} + +class MeasuredCubic { + constructor(c, startOutlineProgress, endOutlineProgress) { + this.cubic = c + this.startOutlineProgress = startOutlineProgress + this.endOutlineProgress = endOutlineProgress + this.measuredSize = closestProgressTo(c, Infinity)[1] + } + + cutAtProgress(cutOutlineProgress) { + const bounded = clamp(cutOutlineProgress, this.startOutlineProgress, this.endOutlineProgress) + const relativeProgress = (bounded - this.startOutlineProgress) / (this.endOutlineProgress - this.startOutlineProgress) + const t = closestProgressTo(this.cubic, relativeProgress * this.measuredSize)[0] + const [c1, c2] = split(this.cubic, t) + + return [new MeasuredCubic(c1, this.startOutlineProgress, bounded), new MeasuredCubic(c2, bounded, this.endOutlineProgress)] + } +} + +class MeasuredPolygon { + constructor(features, cubics, outlineProgress) { + this.features = features + this.cubics = [] + + let startOutlineProgress = 0 + + for (let i = 0; i < cubics.length; i++) { + if (outlineProgress[i + 1] - outlineProgress[i] > DISTANCE_EPSILON) { + this.cubics.push(new MeasuredCubic(cubics[i], startOutlineProgress, outlineProgress[i + 1])) + startOutlineProgress = outlineProgress[i + 1] + } + } + + this.cubics.at(-1).endOutlineProgress = 1 + } + + static measure(polygon) { + const cubics = [] + const featureToCubic = [] + + for (const feature of polygon.features) { + feature.cubics.forEach((c, i) => { + if (feature.type === 'corner' && i === Math.floor(feature.cubics.length / 2)) { + featureToCubic.push([feature, cubics.length]) + } + + cubics.push(c) + }) + } + + const measures = [0] + + for (const c of cubics) { + measures.push(measures.at(-1) + closestProgressTo(c, Infinity)[1]) + } + + const outlineProgress = measures.map((measure) => measure / measures.at(-1)) + const features = featureToCubic.map(([feature, ix]) => ({ + progress: positiveModulo((outlineProgress[ix] + outlineProgress[ix + 1]) / 2, 1), + feature, + })) + + return new MeasuredPolygon(features, cubics, outlineProgress) + } + + cutAndShift(cuttingPoint) { + if (cuttingPoint < DISTANCE_EPSILON) { + return this + } + + const n = this.cubics.length + const targetIndex = this.cubics.findIndex((c) => cuttingPoint >= c.startOutlineProgress && cuttingPoint <= c.endOutlineProgress) + const [b1, b2] = this.cubics[targetIndex].cutAtProgress(cuttingPoint) + const cubics = [b2.cubic] + + for (let i = 1; i < n; i++) { + cubics.push(this.cubics[(i + targetIndex) % n].cubic) + } + + cubics.push(b1.cubic) + + const outlineProgress = Array.from({ length: n + 2 }, (_, index) => { + if (index === 0) { + return 0 + } + + if (index === n + 1) { + return 1 + } + + return positiveModulo(this.cubics[(targetIndex + index - 1) % n].endOutlineProgress - cuttingPoint, 1) + }) + + const features = this.features.map(({ progress, feature }) => ({ progress: positiveModulo(progress - cuttingPoint, 1), feature })) + + return new MeasuredPolygon(features, cubics, outlineProgress) + } +} + +function featureDistSquared(f1, f2) { + if (f1.type === 'corner' && f2.type === 'corner' && f1.convex !== f2.convex) { + return Infinity + } + + const representative = (feature) => { + const [first, last] = [feature.cubics[0], feature.cubics.at(-1)] + + return point((first[0] + last[6]) / 2, (first[1] + last[7]) / 2) + } + + const [p1, p2] = [representative(f1), representative(f2)] + + return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2 +} + +function doMapping(features1, features2) { + const distanceVertexList = [] + + for (const f1 of features1) { + for (const f2 of features2) { + const distance = featureDistSquared(f1.feature, f2.feature) + + if (distance !== Infinity) { + distanceVertexList.push({ distance, f1, f2 }) + } + } + } + + distanceVertexList.sort((a, b) => a.distance - b.distance) + + if (distanceVertexList.length === 0) { + return [ + [0, 0], + [0.5, 0.5], + ] + } + + if (distanceVertexList.length === 1) { + const { f1, f2 } = distanceVertexList[0] + + return [ + [f1.progress, f2.progress], + [(f1.progress + 0.5) % 1, (f2.progress + 0.5) % 1], + ] + } + + const mapping = [] + const usedF1 = new Set() + const usedF2 = new Set() + + for (const { f1, f2 } of distanceVertexList) { + if (usedF1.has(f1) || usedF2.has(f2)) { + continue + } + + const insertionIndex = mapping.findIndex((m) => m[0] >= f1.progress) + const index = insertionIndex === -1 ? mapping.length : insertionIndex + const n = mapping.length + + if (n >= 1) { + const [before1, before2] = mapping[(index + n - 1) % n] + const [after1, after2] = mapping[index % n] + + if ( + progressDistance(f1.progress, before1) < DISTANCE_EPSILON || + progressDistance(f1.progress, after1) < DISTANCE_EPSILON || + progressDistance(f2.progress, before2) < DISTANCE_EPSILON || + progressDistance(f2.progress, after2) < DISTANCE_EPSILON + ) { + continue + } + + if (n > 1 && !progressInRange(f2.progress, before2, after2)) { + continue + } + } + + mapping.splice(index, 0, [f1.progress, f2.progress]) + usedF1.add(f1) + usedF2.add(f2) + } + + return mapping +} + +/** Morph.match: both shapes cut into pairs of matching cubics. */ +function match(p1, p2) { + const measuredPolygon1 = MeasuredPolygon.measure(p1) + const measuredPolygon2 = MeasuredPolygon.measure(p2) + const corners = (features) => features.filter(({ feature }) => feature.type === 'corner') + const mappings = doMapping(corners(measuredPolygon1.features), corners(measuredPolygon2.features)) + const [sources, targets] = [mappings.map((m) => m[0]), mappings.map((m) => m[1])] + const map = (x) => linearMap(sources, targets, x) + const mapBack = (x) => linearMap(targets, sources, x) + const polygon2CutPoint = map(0) + const bs1 = measuredPolygon1.cubics + const bs2 = measuredPolygon2.cutAndShift(polygon2CutPoint).cubics + const pairs = [] + + let i1 = 0 + let i2 = 0 + let b1 = bs1[i1++] + let b2 = bs2[i2++] + + while (b1 !== undefined && b2 !== undefined) { + const b1a = i1 === bs1.length ? 1 : b1.endOutlineProgress + const b2a = i2 === bs2.length ? 1 : mapBack(positiveModulo(b2.endOutlineProgress + polygon2CutPoint, 1)) + const minb = Math.min(b1a, b2a) + let seg1 + let seg2 + + if (b1a > minb + ANGLE_EPSILON) { + ;[seg1, b1] = b1.cutAtProgress(minb) + } else { + seg1 = b1 + b1 = bs1[i1++] + } + + if (b2a > minb + ANGLE_EPSILON) { + ;[seg2, b2] = b2.cutAtProgress(positiveModulo(map(minb) - polygon2CutPoint, 1)) + } else { + seg2 = b2 + b2 = bs2[i2++] + } + + pairs.push([seg1.cubic, seg2.cubic]) + } + + return pairs +} + +/** Morph.asCubics: every matched pair interpolated at `progress`, closed on its first anchor. */ +function asCubics(pairs, progress) { + const cubics = pairs.map(([start, end]) => start.map((value, i) => value + (end[i] - value) * progress)) + + cubics.at(-1)[6] = cubics[0][0] + cubics.at(-1)[7] = cubics[0][1] + + return cubics +} + +// The circular wave --------------------------------------------------------------------- + +const circularShapes = new Map() + +/** CircularShapes: the track's circle and the active indicator's star, matched in vertex count. */ +function shapesFor(vertexCount) { + if (!circularShapes.has(vertexCount)) { + const circle = circlePolygon(vertexCount).normalized() + const star = starPolygon(vertexCount, 0.75, rounding(0.35, 0.4), rounding(0.5)).normalized() + let pairs = null + + circularShapes.set(vertexCount, { + circle, + star, + morph: () => (pairs ??= match(circle, star)), + }) + } + + return circularShapes.get(vertexCount) +} + +const GAUSS_LEGENDRE = [ + [0.1834346424956498, 0.362683783378362], + [0.525532409916329, 0.3137066458778873], + [0.7966664774136267, 0.2223810344533745], + [0.9602898564975363, 0.1012285362903763], +] + +function cubicLength(c) { + let sum = 0 + + for (const [node, weight] of GAUSS_LEGENDRE) { + for (const t of [(1 - node) / 2, (1 + node) / 2]) { + const u = 1 - t + const dx = 3 * (u * u * (c[2] - c[0]) + 2 * u * t * (c[4] - c[2]) + t * t * (c[6] - c[4])) + const dy = 3 * (u * u * (c[3] - c[1]) + 2 * u * t * (c[5] - c[3]) + t * t * (c[7] - c[5])) + sum += weight * Math.hypot(dx, dy) + } + } + + return sum / 2 +} + +/** + * pathFromCubics (turned so it starts at 12 o'clock, repeated once when the wave travels) and + * processPath (scaled to the container less the stroke, its control-point bounds centred). + */ +function circularPath(cubics, pivot, repeat, scale, cx, cy) { + const angle = Math.atan2(cubics[0][1] - pivot.y, cubics[0][0] - pivot.x) + const turn = -angle + (270 * Math.PI) / 180 + const [sin, cos] = [Math.sin(turn), Math.cos(turn)] + let [left, top, right, bottom] = [Infinity, Infinity, -Infinity, -Infinity] + + const moved = cubics.map((c) => { + const out = new Array(8) + + for (let i = 0; i < 8; i += 2) { + out[i] = (cos * c[i] - sin * c[i + 1]) * scale + out[i + 1] = (sin * c[i] + cos * c[i + 1]) * scale + left = Math.min(left, out[i]) + right = Math.max(right, out[i]) + top = Math.min(top, out[i + 1]) + bottom = Math.max(bottom, out[i + 1]) + } + + return out + }) + + const [dx, dy] = [cx - (left + right) / 2, cy - (top + bottom) / 2] + const loop = moved.map((c) => `C${round(c[2] + dx)} ${round(c[3] + dy)} ${round(c[4] + dx)} ${round(c[5] + dy)} ${round(c[6] + dx)} ${round(c[7] + dy)}`).join('') + const start = `${round(moved[0][0] + dx)} ${round(moved[0][1] + dy)}` + const loopLength = moved.reduce((sum, c) => sum + cubicLength(c), 0) + + return { + d: repeat ? `M${start}${loop}L${start}${loop}Z` : `M${start}${loop}Z`, + length: repeat ? loopLength * 2 : loopLength, + } +} + +// The component ------------------------------------------------------------------------- + +/** A duration token in milliseconds, whichever unit a minifier left it in; null when unset. */ +function tokenDuration(element, token) { + const value = getComputedStyle(element).getPropertyValue(token).trim() + const number = parseFloat(value) + + if (Number.isNaN(number)) { + return null + } + + return value.endsWith('ms') ? number : number * 1000 +} + +function svgElement(name, attributes) { + const element = document.createElementNS(SVG, name) + + for (const [key, value] of Object.entries(attributes)) { + element.setAttribute(key, value) + } + + return element +} + +class Indicator { + constructor(element) { + this.element = element + this.svg = element.querySelector(':scope > svg') + this.frame = null + this.last = null + this.visible = true + this.width = 0 + this.height = 0 + this.written = new Map() + this.motion = window.matchMedia('(prefers-reduced-motion: reduce)') + this.onMotionChange = () => this.schedule() + this.motion.addEventListener('change', this.onMotionChange) + + this.read(true) + this.measure() + + this.resizes = new ResizeObserver(() => this.measure() && this.schedule()) + this.resizes.observe(element) + + this.intersections = new IntersectionObserver((entries) => { + this.visible = entries.at(-1).isIntersecting + + if (this.visible) { + this.schedule() + } + }) + this.intersections.observe(element) + + this.mutations = new MutationObserver(() => { + this.read(false) + this.schedule() + }) + this.mutations.observe(element, { attributes: true, attributeFilter: WATCHED }) + + this.draw(this.motion.matches) + this.schedule() + } + + destroy() { + cancelAnimationFrame(this.frame) + this.motion.removeEventListener('change', this.onMotionChange) + this.resizes.disconnect() + this.intersections.disconnect() + this.mutations.disconnect() + } + + /** Takes the root's attributes: the shape, and the value to show (null: indeterminate). */ + 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 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.build() + this.reset(target) + } else if ((target === null) !== (this.target === null)) { + // Compose draws determinate and indeterminate indicators as two different composables. + this.reset(target) + } else if (target !== null && target !== this.target) { + const duration = tokenDuration(element, '--md-sys-motion-effects-slow-duration') ?? VALUE_SETTLE + this.timeScale = duration > 0 && !this.motion.matches ? VALUE_SETTLE / duration : 0 + } + + this.target = target + } + + reset(target) { + this.target = target + this.progress = target + this.velocity = 0 + this.timeScale = 0 + this.clock = 0 + this.offset = 0 + this.amplitude = target === null ? 1 : amplitudeFor(target) + this.amplitudeGoal = this.amplitude + this.amplitudeAnimation = null + this.morphed = false + this.vertexCount = MIN_CIRCULAR_VERTICES + this.paths = {} + } + + build() { + const stroke = { fill: 'none', 'stroke-width': this.stroke, 'stroke-linecap': 'round' } + + this.group = svgElement('g', {}) + this.track = svgElement('path', stroke) + this.active = svgElement('path', { ...stroke, stroke: 'currentColor' }) + this.stop = svgElement('circle', { fill: 'currentColor', stroke: 'none', r: 0 }) + this.written.clear() + + this.group.append(this.track, this.active, this.stop) + this.svg.removeAttribute('viewBox') + this.svg.replaceChildren(this.group) + } + + measure() { + const [width, height] = [this.element.offsetWidth, this.element.offsetHeight] + const changed = width !== this.width || height !== this.height + + this.width = width + this.height = height + + return changed + } + + schedule() { + if (this.frame === null) { + this.frame = requestAnimationFrame((now) => this.tick(now)) + } + } + + tick(now) { + this.frame = null + + const reduced = this.motion.matches + const elapsed = this.last === null ? 0 : Math.min(now - this.last, 64) + this.last = now + + if (!reduced) { + this.clock += elapsed + } + + const moving = this.advance(elapsed, reduced) + this.draw(reduced) + + if (moving && this.visible && this.element.isConnected) { + this.schedule() + } else { + this.last = null + } + } + + /** Steps every animation by `elapsed` ms; true while any of them still moves. */ + advance(elapsed, reduced) { + let moving = false + + if (this.target === null) { + moving = !reduced + } else if (this.progress !== this.target || this.velocity !== 0) { + if (this.timeScale === 0 || reduced) { + this.progress = this.target + this.velocity = 0 + } else { + // A critically damped spring, solved exactly, so a new target keeps the velocity. + const omega = Math.sqrt(VALUE_STIFFNESS) + const t = (elapsed / 1000) * this.timeScale + const d0 = this.progress - this.target + const c = this.velocity + omega * d0 + const decay = Math.exp(-omega * t) + + this.progress = this.target + (d0 + c * t) * decay + this.velocity = (c - omega * (d0 + c * t)) * decay + + if (Math.abs(this.progress - this.target) < 1e-4 && Math.abs(this.velocity) < 1e-3) { + this.progress = this.target + this.velocity = 0 + } else { + moving = true + } + } + } + + if (!this.wavy) { + return moving + } + + const animation = this.amplitudeAnimation + + if (animation) { + animation.elapsed += elapsed + const fraction = animation.duration > 0 ? Math.min(animation.elapsed / animation.duration, 1) : 1 + this.amplitude = animation.from + (animation.to - animation.from) * animation.easing(fraction) + + if (fraction >= 1) { + this.amplitudeAnimation = null + } else { + moving = true + } + } + + // As in Compose, a new amplitude animation starts only once the running one has ended. + const goal = this.target === null ? 1 : amplitudeFor(clamp(this.progress, 0, 1)) + + if (!this.amplitudeAnimation && goal !== this.amplitudeGoal) { + const duration = reduced ? 0 : (tokenDuration(this.element, '--md-sys-motion-duration-long') ?? AMPLITUDE_DURATION) + + this.amplitudeGoal = goal + this.morphed = true + + if (duration > 0) { + this.amplitudeAnimation = { + from: this.amplitude, + to: goal, + easing: this.amplitude < goal ? STANDARD : EMPHASIZED_ACCELERATE, + duration, + elapsed: 0, + } + moving = true + } else { + this.amplitude = goal + } + } + + if (this.amplitude > 0 && !reduced) { + const period = this.circular ? WAVE_PERIOD * this.vertexCount : WAVE_PERIOD + this.offset = (this.offset + elapsed / period) % 1 + moving = true + } + + return moving + } + + draw(reduced) { + if (this.width <= 0 || this.height <= 0) { + return + } + + const still = this.circular ? CIRCULAR_STILL : LINEAR_STILL + const time = reduced ? still : this.clock + const offset = reduced ? 0 : this.offset + + if (this.circular) { + this.wavy ? this.drawCircularWavy(time, offset) : this.drawCircular(time) + } else { + this.wavy ? this.drawLinearWavy(time, offset) : this.drawLinear(time) + } + } + + write(element, name, value) { + const key = element === this.track ? `t${name}` : element === this.active ? `a${name}` : element === this.stop ? `s${name}` : `g${name}` + + if (this.written.get(key) === value) { + return + } + + this.written.set(key, value) + + if (value === null) { + element.removeAttribute(name) + } else { + element.setAttribute(name, value) + } + } + + stopAt(cx, cy, radius) { + this.write(this.stop, 'cx', round(cx)) + this.write(this.stop, 'cy', round(cy)) + this.write(this.stop, 'r', round(Math.max(radius, 0))) + } + + /** LinearProgressIndicator. */ + drawLinear(time) { + const [width, height, stroke] = [this.width, this.height, this.stroke] + const cap = height > width ? 0 : stroke / 2 + const gap = (GAP + (height > width ? 0 : stroke)) / width + const middle = height / 2 + let track = '' + let active = '' + + // drawLinearIndicator: a line between two fractions, kept inside for its round caps. + const bar = (start, end) => (Math.abs(end - start) > 0 ? straight(clamp(start * width, cap, width - cap), middle, clamp(end * width, cap, width - cap)) : '') + + if (this.target !== null) { + const progress = clamp(this.progress, 0, 1) + const trackStart = progress + Math.min(progress, gap) + + if (trackStart <= 1) { + track += bar(trackStart, 1) + } + + active += bar(0, progress) + + const size = Math.min(STOP_SIZE, height) + this.stopAt(width - size / 2 - Math.min((height - size) / 2, STOP_TRAILING_SPACE), middle, size / 2) + } else { + const [firstTail, firstHead, secondTail, secondHead] = LINEAR_LINES.map((spec) => spec(time)) + + if (firstHead < 1 - gap) { + track += bar(firstHead > 0 ? firstHead + gap : 0, 1) + } + + if (firstHead - firstTail > 0) { + active += bar(firstHead, firstTail) + } + + if (firstTail > gap) { + track += bar(secondHead > 0 ? secondHead + gap : 0, firstTail < 1 ? firstTail - gap : 1) + } + + if (secondHead - secondTail > 0) { + active += bar(secondHead, secondTail) + } + + if (secondTail > gap) { + track += bar(0, secondTail < 1 ? secondTail - gap : 1) + } + + this.stopAt(0, 0, 0) + } + + this.write(this.track, 'd', track) + this.write(this.active, 'd', active) + } + + /** LinearWavyProgressIndicator: LinearProgressDrawingCache.updateDrawPaths and drawStopIndicator. */ + drawLinearWavy(time, offset) { + const [width, height, stroke] = [this.width, this.height, this.stroke] + const determinate = this.target !== null + const cap = height > width ? 0 : stroke / 2 + const middle = height / 2 + const wavelength = determinate ? LINEAR_WAVELENGTH : LINEAR_INDETERMINATE_WAVELENGTH + const halfWavelength = wavelength / 2 + const waveHeight = height - stroke + const amplitude = this.amplitude + const fractions = determinate ? [0, clamp(this.progress, 0, 1)] : LINEAR_LINES.map((spec) => spec(time)) + + // The full path: a line, or enough half-waves to cover the width plus two wavelengths. + const halfWaves = Math.floor((width + wavelength * 2) / halfWavelength) + const fullLength = amplitude !== 0 ? halfWaves * halfWave(halfWavelength, waveHeight).length : width + const scale = fullLength / ((amplitude !== 0 ? halfWaves * halfWavelength : width) + 0.00000001) + + let trackGap = GAP + let activeVisible = false + let nextTrackEnd = width - cap + let track = `M${round(nextTrackEnd)} ${round(middle)}` + let active = '' + + for (let i = 0; i < fractions.length / 2; i++) { + const [start, end] = [fractions[i * 2], fractions[i * 2 + 1]] + const [tail, head] = [start * width, end * width] + + if (i === 0) { + trackGap = head < cap ? 0 : Math.min(head - cap, GAP) + activeVisible = head >= cap + } + + const adjustedHead = clamp(head, cap, width - cap) + const adjustedTail = clamp(tail, cap, width - cap) + + if (Math.abs(end - start) > 0) { + const shift = amplitude !== 0 ? offset * wavelength : 0 + const from = Math.max((adjustedTail + shift) * scale, 0) + const to = Math.min((adjustedHead + shift) * scale, fullLength) + + // android.graphics.PathMeasure.getSegment draws nothing for an empty segment. + if (from < to) { + active += amplitude !== 0 ? waveSegment(from, to, halfWavelength, waveHeight, shift, middle, amplitude) : straight(from, middle, to) + } + } + + const spacing = activeVisible ? trackGap + cap * 2 : trackGap + + if (nextTrackEnd > adjustedHead + spacing) { + track += `L${round(Math.max(cap, adjustedHead + spacing))} ${round(middle)}` + } + + if (head > tail) { + nextTrackEnd = Math.max(cap, adjustedTail - spacing) + track += `M${round(nextTrackEnd)} ${round(middle)}` + } + } + + if (nextTrackEnd > cap) { + track += `L${round(cap)} ${round(middle)}` + } + + this.write(this.track, 'd', track) + this.write(this.active, 'd', active) + + if (!determinate) { + this.stopAt(0, 0, 0) + + return + } + + let size = Math.min(stroke, STOP_SIZE) + let x = width - size - (size === stroke ? 0 : stroke / 4) + const progressX = width * fractions[1] + cap + + if (x <= progressX) { + size = Math.max(0, size - (progressX - x)) + x = progressX + } + + this.stopAt(x + size / 2, middle, size / 2) + } + + /** CircularProgressIndicator. */ + drawCircular(time) { + const size = Math.min(this.width, this.height) + const [cx, cy, stroke] = [this.width / 2, this.height / 2, this.stroke] + const radius = (size - stroke) / 2 + const gapSweep = ((GAP + stroke) / (Math.PI * size)) * 360 + + if (this.target !== null) { + const sweep = clamp(this.progress, 0, 1) * 360 + const trackGap = Math.min(sweep, gapSweep) + + this.write(this.group, 'transform', null) + this.write(this.track, 'd', arc(cx, cy, radius, 270 + sweep + trackGap, 360 - sweep - trackGap * 2)) + this.write(this.active, 'd', arc(cx, cy, radius, 270, sweep)) + } else { + // circularIndeterminateTrackColor is transparent: no track while it spins. + this.write(this.group, 'transform', `rotate(${round(circularRotation(time))} ${round(cx)} ${round(cy)})`) + this.write(this.track, 'd', '') + this.write(this.active, 'd', arc(cx, cy, radius, 0, CIRCULAR_SWEEP(time) * 360)) + } + } + + /** CircularWavyProgressIndicator: CircularProgressDrawingCache.updateDrawPaths, as dashes. */ + drawCircularWavy(time, offset) { + const size = Math.min(this.width, this.height) + const [cx, cy, stroke] = [this.width / 2, this.height / 2, this.stroke] + const cap = stroke / 2 + const determinate = this.target !== null + const vertexCount = Math.max(MIN_CIRCULAR_VERTICES, Math.round((2 * Math.PI * (size / 2 - stroke / 2)) / CIRCULAR_WAVELENGTH)) + const amplitude = this.amplitude + const motion = determinate || amplitude > 0 + const end = determinate ? clamp(this.progress, 0, 1) : CIRCULAR_SWEEP(time) + const shapes = shapesFor(vertexCount) + + this.vertexCount = vertexCount + + const trackKey = `${vertexCount}|${size}|${stroke}|${cx}|${cy}` + + if (this.paths.trackKey !== trackKey) { + this.paths.trackKey = trackKey + this.paths.track = circularPath(shapes.circle.cubics, shapes.circle.center, false, size - stroke, cx, cy) + } + + // CircularShapes.getProgressPath: the Morph once an amplitude animation has needed one. + const progressKey = `${trackKey}|${amplitude}|${motion}|${this.morphed}` + + if (this.paths.progressKey !== progressKey) { + let [cubics, pivot] = [shapes.circle.cubics, shapes.circle.center] + + if (this.morphed) { + ;[cubics, pivot] = [asCubics(shapes.morph(), amplitude), point(0.5, 0.5)] + } else if (amplitude === 1) { + ;[cubics, pivot] = [shapes.star.cubics, shapes.star.center] + } + + this.paths.progressKey = progressKey + this.paths.progress = circularPath(cubics, pivot, motion, size - stroke, cx, cy) + } + + const { track, progress } = this.paths + const progressLength = motion ? progress.length / 2 : progress.length + const stop = end * progressLength + const spacing = Math.min(stop, cap) * 2 + Math.min(stop, GAP) + const phase = motion ? clamp(offset, 0, 1) : 0 + const shift = phase * progressLength + + this.write(this.group, 'transform', determinate ? null : `rotate(${round(circularRotation(time) + 90)} ${round(cx)} ${round(cy)})`) + this.dash(this.active, progress, shift, stop + shift) + this.write(this.active, 'transform', phase * 360 % 360 !== 0 ? `rotate(${round(-(phase * 360) % 360)} ${round(cx)} ${round(cy)})` : null) + this.dash(this.track, track, end * track.length + spacing, track.length - spacing) + } + + /** PathMeasure.getSegment as a single dash: nothing when the segment is empty. */ + dash(element, path, from, to) { + const [start, stop] = [Math.max(from, 0), Math.min(to, path.length)] + + this.write(element, 'd', path.d) + this.write(element, 'pathLength', round(path.length)) + + if (!(start < stop)) { + this.write(element, 'visibility', 'hidden') + + return + } + + this.write(element, 'visibility', null) + this.write(element, 'stroke-dasharray', `${round(stop - start)} ${round(path.length * 2)}`) + this.write(element, 'stroke-dashoffset', round(-start)) + } +} + +document.addEventListener('alpine:init', () => { + window.Alpine.data('materialProgress', () => { + // Kept out of the returned object: Alpine makes that reactive, and a proxy on every + // per-frame read would cost more than the drawing. + let indicator = null + + return { + init() { + // After this element's own x-bind has written a bound value. + this.$nextTick(() => { + if (this.$el.isConnected && indicator === null) { + indicator = new Indicator(this.$el) + } + }) + }, + + destroy() { + indicator?.destroy() + indicator = null + }, + } + }) +}) diff --git a/resources/views/components/progress.blade.php b/resources/views/components/progress.blade.php new file mode 100644 index 00000000..28ea7ece --- /dev/null +++ b/resources/views/components/progress.blade.php @@ -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 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 + ->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 + + + + diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php index f92c3b6b..6c1469a7 100644 --- a/resources/views/showcase/layout.blade.php +++ b/resources/views/showcase/layout.blade.php @@ -18,7 +18,7 @@ Livewire Material diff --git a/resources/views/showcase/sections/progress.blade.php b/resources/views/showcase/sections/progress.blade.php new file mode 100644 index 00000000..f3cc3eb3 --- /dev/null +++ b/resources/views/showcase/sections/progress.blade.php @@ -0,0 +1,57 @@ +@php + $examples = [ + 'Linear: determinate and indeterminate' => [<<<'BLADE' + + + BLADE, true], + 'Linear wavy' => [<<<'BLADE' + + + BLADE, true], + 'Thick' => [<<<'BLADE' + + + + BLADE, true], + 'Circular' => [<<<'BLADE' + + + + + + + + BLADE, false], + 'Colours' => [<<<'BLADE' + + + + + + + BLADE, true], + 'Following a value in the browser (bind)' => [<<<'BLADE' +
+ + + +
+ + +
+
+ BLADE, false], + ]; +@endphp + +
+

Progress

+ +

+ <x-progress>: M3 Expressive's linear and circular progress indicators, flat or wavy, determinate or indeterminate. +

+ + @foreach ($examples as $title => [$code, $stack]) + + @endforeach +
diff --git a/tests/Browser/ProgressTest.php b/tests/Browser/ProgressTest.php new file mode 100644 index 00000000..44689311 --- /dev/null +++ b/tests/Browser/ProgressTest.php @@ -0,0 +1,174 @@ +done = 80; + } + + public function render(): string + { + return <<<'BLADE' +
+ + +
+ 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 << { + 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' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + 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)); +}); diff --git a/tests/Feature/Components/ProgressTest.php b/tests/Feature/Components/ProgressTest.php new file mode 100644 index 00000000..cae9229f --- /dev/null +++ b/tests/Feature/Components/ProgressTest.php @@ -0,0 +1,107 @@ +blade('')) + ->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('')) + ->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('')) + ->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' => ['', '0', '100'], + 'above the maximum' => ['', '100', '100'], + 'a custom maximum' => ['', '3.5', '5'], + 'a maximum that cannot be one' => ['', '30', '100'], +]); + +it('draws the first frame on the server, at the value', function () { + expect((string) $this->blade('')) + ->toContain('') + ->toContain('x2="42%"') + ->toContain('toContain('wire:ignore'); + + expect((string) $this->blade('')) + ->toContain('viewBox="0 0 40 40"') + ->toContain('') + ->toContain(''); +}); + +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('', ['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' => ['', 'w-full h-1 ', []], + 'linear thick' => ['', 'h-2 ', ['data-thick']], + 'linear wavy' => ['', 'h-2.5 ', ['data-wavy']], + 'linear wavy thick' => ['', 'h-3.5 ', ['data-wavy', 'data-thick']], + 'circular' => ['', 'size-10 ', ['data-circular']], + 'circular thick' => ['', 'size-11 ', ['data-circular', 'data-thick']], + 'circular wavy' => ['', 'size-12 ', ['data-circular', 'data-wavy']], + '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('')) + ->toContain('size-24') + ->not->toContain('size-12'); + + expect((string) $this->blade('')) + ->toContain('w-64') + ->not->toContain('w-full'); +}); + +it('follows a value bound in Alpine', function () { + $html = (string) $this->blade(''); + + expect($html) + ->toContain('x-bind:data-value="upload.progress"') + ->toContain('x-bind:aria-valuenow="((v) => v === null') + ->toContain('Math.min(Math.max(Number(v), 0), 1))(upload.progress)') + ->toContain('data-max="1"') + ->not->toContain(' data-value='); +});