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='); +});