From dcb11bf0b2352fbf8fdf671aa4890dfe4a92f9ea Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Tue, 23 Jun 2026 17:37:18 -0700 Subject: [PATCH 01/13] feat: implement Material Design 3 Expressive Loading Indicator --- all.js | 2 + demo/index.html | 10 + loading-indicator/loading-indicator.js | 419 +++++++++++++++++++++++++ 3 files changed, 431 insertions(+) create mode 100644 loading-indicator/loading-indicator.js diff --git a/all.js b/all.js index bc4175c..d69aa86 100644 --- a/all.js +++ b/all.js @@ -33,6 +33,7 @@ import './menu/menu.js' import './menu/menu-item.js' import './menu/sub-menu.js' import './progress/progress.js' +import './loading-indicator/loading-indicator.js' import './radio/radio.js' import './internal/ripple/ripple.js' import './select/select.js' @@ -65,6 +66,7 @@ export * from './menu/menu.js' export * from './menu/menu-item.js' export * from './menu/sub-menu.js' export * from './progress/progress.js' +export * from './loading-indicator/loading-indicator.js' export * from './radio/radio.js' export * from './internal/ripple/ripple.js' export * from './select/select.js' diff --git a/demo/index.html b/demo/index.html index bb1a109..301a9ff 100644 --- a/demo/index.html +++ b/demo/index.html @@ -58,6 +58,7 @@ import 'material/snackbar/snackbar.js' import 'material/app/bar.js' import 'material/progress/progress.js' + import 'material/loading-indicator/loading-indicator.js' import './components/expressive-component.js' @@ -182,6 +183,15 @@

Progress

+

Loading Indicator (M3 Expressive)

+
+ + + + + +
+ diff --git a/loading-indicator/loading-indicator.js b/loading-indicator/loading-indicator.js new file mode 100644 index 0000000..74d416c --- /dev/null +++ b/loading-indicator/loading-indicator.js @@ -0,0 +1,419 @@ +import { html, css, LitElement } from 'lit' + +// --- Spring Physics Engine --- +class Spring { + constructor(stiffness, dampingRatio) { + this.k = stiffness + this.c = dampingRatio * 2 * Math.sqrt(stiffness) + this.pos = 0 + this.vel = 0 + this.target = 0 + } + step(dt) { + const SUB_STEPS = 12 + const sub = dt / SUB_STEPS + for (let i = 0; i < SUB_STEPS; i++) { + const accel = -this.k * (this.pos - this.target) - this.c * this.vel + this.vel += accel * sub + this.pos += this.vel * sub + } + } + reset() { + this.pos = 0 + this.vel = 0 + this.target = 0 + } +} + +// --- Shape Definitions and Geometry Math --- +const SHAPE_COUNT = 7 +const SAMPLES_PER_CURVE = 14 +const POINTS_PER_SHAPE = 180 +const SVG_DEFS = [ + // 0: SOFT_BURST (144×144) + { + viewBox: 144, + d: "M65.3162 3.5567C68.3922 -1.18556 75.6078 -1.18557 78.6837 3.55669L86.0569 14.9241C88.0791 18.0418 92.1588 19.3094 95.7112 17.9237L108.664 12.8715C114.067 10.7638 119.905 14.8195 119.478 20.3849L118.456 33.7255C118.175 37.3845 120.697 40.7029 124.422 41.5786L138.007 44.7714C143.674 46.1033 145.903 52.6655 142.137 56.9283L133.11 67.1465C130.634 69.9491 130.634 74.0509 133.11 76.8535L142.137 87.0716C145.903 91.3345 143.674 97.8967 138.007 99.2286L124.422 102.421C120.697 103.297 118.175 106.616 118.456 110.274L119.478 123.615C119.905 129.181 114.067 133.236 108.664 131.129L95.7112 126.076C92.1588 124.691 88.0791 125.958 86.0569 129.076L78.6838 140.443C75.6078 145.186 68.3922 145.186 65.3162 140.443L57.9431 129.076C55.9208 125.958 51.8412 124.691 48.2888 126.076L35.3365 131.129C29.933 133.236 24.0954 129.181 24.5219 123.615L25.5442 110.274C25.8246 106.616 23.3033 103.297 19.5775 102.421L5.99339 99.2286C0.326335 97.8967 -1.90342 91.3345 1.86259 87.0717L10.8899 76.8535C13.3658 74.0509 13.3658 69.9491 10.8899 67.1465L1.8626 56.9283C-1.90341 52.6655 0.326326 46.1033 5.99338 44.7714L19.5775 41.5786C23.3033 40.7029 25.8246 37.3845 25.5442 33.7255L24.5219 20.3849C24.0954 14.8195 29.933 10.7638 35.3364 12.8715L48.2888 17.9237C51.8412 19.3094 55.9208 18.0418 57.9431 14.9241L65.3162 3.5567Z", + }, + // 1: COOKIE_9 (144×144) + { + viewBox: 144, + d: "M56.3679 6.02002C57.1442 5.3783 57.5324 5.05744 57.8867 4.78656C66.2354 -1.59552 77.7646 -1.59552 86.1133 4.78656C86.4676 5.05744 86.8558 5.3783 87.6321 6.02002C87.9786 6.30648 88.1519 6.44971 88.3233 6.58606C92.2522 9.71203 97.0693 11.4835 102.068 11.6405C102.286 11.6473 102.51 11.6501 102.957 11.6558C103.96 11.6684 104.462 11.6747 104.906 11.6973C115.361 12.2303 124.193 19.7179 126.528 30.0289C126.627 30.4666 126.721 30.9644 126.907 31.9602C126.99 32.4047 127.032 32.627 127.076 32.8427C128.097 37.789 130.661 42.2744 134.39 45.6409C134.552 45.7878 134.722 45.9353 135.062 46.2304C135.822 46.8914 136.202 47.2219 136.528 47.5275C144.198 54.7262 146.2 66.1979 141.429 75.6132C141.227 76.0128 140.981 76.4547 140.49 77.3386C140.271 77.7332 140.162 77.9304 140.059 78.1246C137.694 82.5768 136.804 87.6775 137.519 92.6782C137.55 92.8964 137.586 93.1196 137.658 93.5661C137.82 94.5662 137.901 95.0663 137.956 95.5117C139.252 106.008 133.488 116.096 123.843 120.21C123.434 120.385 122.965 120.564 122.026 120.922C121.608 121.082 121.398 121.162 121.196 121.244C116.552 123.119 112.625 126.448 109.991 130.743C109.876 130.93 109.762 131.125 109.533 131.514C109.021 132.385 108.765 132.821 108.523 133.198C102.839 142.08 92.0048 146.064 81.9992 142.952C81.5745 142.82 81.1011 142.652 80.1544 142.318C79.7318 142.168 79.5205 142.094 79.3133 142.025C74.5631 140.445 69.4369 140.445 64.6867 142.025C64.4795 142.094 64.2682 142.168 63.8456 142.318C62.8989 142.652 62.4255 142.82 62.0008 142.952C51.9952 146.064 41.1613 142.08 35.4766 133.198C35.2353 132.821 34.9791 132.385 34.4669 131.514C34.2382 131.125 34.1239 130.93 34.009 130.743C31.3752 126.448 27.4482 123.119 22.8044 121.244C22.6018 121.162 22.3924 121.082 21.9736 120.922C21.0354 120.564 20.5663 120.385 20.1569 120.21C10.5122 116.096 4.74763 106.008 6.04367 95.5117C6.09868 95.0663 6.17963 94.5662 6.34151 93.5661C6.41377 93.1196 6.4499 92.8964 6.48109 92.6783C7.19603 87.6775 6.30587 82.5768 3.94121 78.1246C3.83807 77.9304 3.72855 77.7332 3.50951 77.3386C3.01883 76.4547 2.77349 76.0128 2.57099 75.6132C-2.19995 66.1979 -0.197931 54.7262 7.47248 47.5275C7.79804 47.2219 8.17819 46.8914 8.93848 46.2304C9.27787 45.9353 9.44757 45.7878 9.61023 45.6409C13.3394 42.2744 15.9025 37.789 16.9235 32.8427C16.9681 32.627 17.0097 32.4047 17.0929 31.9602C17.2793 30.9644 17.3725 30.4666 17.4717 30.0289C19.8069 19.7179 28.6387 12.2303 39.0944 11.6973C39.5382 11.6747 40.0397 11.6684 41.0426 11.6558C41.4903 11.6501 41.7142 11.6473 41.9322 11.6405C46.9307 11.4835 51.7478 9.71203 55.6767 6.58606C55.8481 6.44971 56.0214 6.30648 56.3679 6.02002Z", + }, + // 2: PENTAGON (144×144) + { + viewBox: 144, + d: "M49.3332 10.9681C56.3577 5.53061 59.8699 2.81189 63.6224 1.46315C69.0501 -0.487717 74.9499 -0.487717 80.3776 1.46315C84.1301 2.81189 87.6423 5.53062 94.6668 10.9681L110.03 22.8606L125.386 34.0038C132.67 39.2902 136.313 41.9334 138.747 45.2576C142.27 50.0661 144.119 55.9761 143.994 62.0207C143.907 66.1996 142.46 70.5678 139.564 79.3044L133.535 97.4958L127.969 116.136C125.358 124.884 124.052 129.258 121.769 132.66C118.466 137.581 113.667 141.201 108.144 142.936C104.327 144.135 99.9259 144.065 91.1241 143.926L72 143.623L52.8759 143.926C44.0741 144.065 39.6732 144.135 35.8555 142.936C30.3334 141.201 25.5338 137.581 22.2314 132.66C19.9483 129.258 18.6425 124.884 16.0307 116.136L10.4655 97.4958L4.43609 79.3044C1.54044 70.5678 0.0926215 66.1996 0.00597479 62.0207C-0.119358 55.9761 1.73035 50.0661 5.2525 45.2576C7.68747 41.9334 11.3298 39.2902 18.6143 34.0038L33.9696 22.8606L49.3332 10.9681Z", + }, + // 3: PILL (144×144) — rotated rounded diamond + { + viewBox: 144, + d: "M40.4355 21.4968C63.0979 -1.16559 99.8408 -1.16559 122.503 21.4968C145.166 44.1592 145.166 80.9021 122.503 103.565L103.565 122.503C80.9021 145.166 44.1592 145.166 21.4968 122.503C-1.16559 99.8408 -1.1656 63.0979 21.4968 40.4355L40.4355 21.4968Z", + }, + // 4: SUNNY (153×153) + { + viewBox: 153, + d: "M117.835 18.5569C122.594 18.8803 124.973 19.0419 126.896 19.883C129.679 21.0999 131.9 23.3213 133.117 26.1039C133.958 28.027 134.12 30.4062 134.443 35.1647L135.181 46.0237C135.312 47.9482 135.377 48.9105 135.586 49.8297C135.889 51.1579 136.414 52.4254 137.139 53.5784C137.641 54.3763 138.275 55.1029 139.544 56.5563L146.7 64.7565C149.837 68.3499 151.405 70.1466 152.17 72.1011C153.277 74.9292 153.277 78.0708 152.17 80.8989C151.405 82.8534 149.837 84.6501 146.7 88.2435L139.544 96.4437C138.275 97.8971 137.641 98.6237 137.139 99.4217C136.414 100.575 135.889 101.842 135.586 103.17C135.377 104.09 135.312 105.052 135.181 106.976L134.443 117.835C134.12 122.594 133.958 124.973 133.117 126.896C131.9 129.679 129.679 131.9 126.896 133.117C124.973 133.958 122.594 134.12 117.835 134.443L106.976 135.181C105.052 135.312 104.09 135.377 103.17 135.586C101.842 135.889 100.575 136.414 99.4217 137.139C98.6237 137.641 97.8971 138.275 96.4437 139.544L88.2435 146.7C84.6501 149.837 82.8534 151.405 80.8989 152.17C78.0708 153.277 74.9292 153.277 72.1011 152.17C70.1466 151.405 68.3499 149.837 64.7565 146.7L56.5563 139.544C55.1029 138.275 54.3763 137.641 53.5784 137.139C52.4254 136.414 51.1579 135.889 49.8297 135.586C48.9105 135.377 47.9482 135.312 46.0237 135.181L35.1647 134.443C30.4062 134.12 28.027 133.958 26.1039 133.117C23.3213 131.9 21.0999 129.679 19.883 126.896C19.0419 124.973 18.8803 122.594 18.5569 117.835L17.819 106.976C17.6882 105.052 17.6228 104.09 17.4136 103.17C17.1113 101.842 16.5863 100.575 15.8608 99.4217C15.3588 98.6237 14.7246 97.8971 13.4562 96.4437L6.29956 88.2435C3.16348 84.6501 1.59544 82.8534 0.830322 80.8989C-0.276774 78.0708 -0.276774 74.9292 0.830323 72.1011C1.59544 70.1466 3.16348 68.3499 6.29956 64.7565L13.4562 56.5563C14.7246 55.1029 15.3588 54.3763 15.8608 53.5784C16.5863 52.4254 17.1113 51.1579 17.4136 49.8297C17.6228 48.9105 17.6882 47.9482 17.819 46.0237L18.5569 35.1647C18.8803 30.4062 19.0419 28.027 19.883 26.1039C21.0999 23.3213 23.3213 21.0999 26.1039 19.883C28.027 19.0419 30.4062 18.8803 35.1647 18.5569L46.0237 17.819C47.9482 17.6882 48.9105 17.6228 49.8297 17.4136C51.1579 17.1113 52.4254 16.5863 53.5784 15.8608C54.3763 15.3588 55.1029 14.7246 56.5563 13.4562L64.7565 6.29957C68.3499 3.16348 70.1466 1.59544 72.1011 0.830323C74.9292 -0.276774 78.0708 -0.276774 80.8989 0.830323C82.8534 1.59544 84.6501 3.16348 88.2435 6.29957L96.4437 13.4562C97.8971 14.7246 98.6237 15.3588 99.4216 15.8608C100.575 16.5863 101.842 17.1113 103.17 17.4136C104.09 17.6228 105.052 17.6882 106.976 17.819L117.835 18.5569Z", + }, + // 5: COOKIE_4 (144×144) + { + viewBox: 144, + d: "M89.4282 11.7931C116.492 0.0387955 143.961 27.5077 132.207 54.5718L130.263 59.0465C126.675 67.3094 126.675 76.6907 130.263 84.9535L132.207 89.4282C143.961 116.492 116.492 143.961 89.4282 132.207L84.9535 130.263C76.6907 126.675 67.3093 126.675 59.0465 130.263L54.5718 132.207C27.5077 143.961 0.0387983 116.492 11.7931 89.4282L13.7366 84.9535C17.3253 76.6907 17.3252 67.3093 13.7366 59.0465L11.7931 54.5718C0.0387955 27.5077 27.5077 0.0387993 54.5718 11.7931L59.0465 13.7366C67.3094 17.3252 76.6907 17.3252 84.9535 13.7366L89.4282 11.7931Z", + }, + // 6: OVAL (generated dynamically) + null, +] + +function parseSVGPath(d) { + const cmds = [] + const re = /([MCLZmclz])([^MCLZmclz]*)/g + let m + while ((m = re.exec(d)) !== null) { + const nums = m[2].trim().match(/-?\d+\.?\d*(?:e[+-]?\d+)?/g) + cmds.push({ cmd: m[1], args: nums ? nums.map(Number) : [] }) + } + return cmds +} + +function sampleCubic(p0, p1, p2, p3, steps) { + const out = [] + for (let i = 1; i <= steps; i++) { + const t = i / steps + const u = 1 - t + out.push([ + u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0], + u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1], + ]) + } + return out +} + +function svgPathToPoints(d) { + const cmds = parseSVGPath(d) + const pts = [] + let cx = 0, cy = 0 + for (const { cmd, args } of cmds) { + if (cmd === "M") { + cx = args[0] + cy = args[1] + pts.push([cx, cy]) + } else if (cmd === "L") { + for (let i = 0; i < args.length; i += 2) { + cx = args[i] + cy = args[i + 1] + pts.push([cx, cy]) + } + } else if (cmd === "C") { + for (let i = 0; i < args.length; i += 6) { + const p0 = [cx, cy] + const p1 = [args[i], args[i + 1]] + const p2 = [args[i + 2], args[i + 3]] + const p3 = [args[i + 4], args[i + 5]] + pts.push(...sampleCubic(p0, p1, p2, p3, SAMPLES_PER_CURVE)) + cx = p3[0] + cy = p3[1] + } + } + } + return pts +} + +function normalize(pts, viewBox) { + const h = viewBox / 2 + const c = pts.map(([x, y]) => [(x - h) / h, (y - h) / h]) + let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity + for (const [x, y] of c) { + if (x < x0) x0 = x + if (x > x1) x1 = x + if (y < y0) y0 = y + if (y > y1) y1 = y + } + const s = Math.min(2 / (x1 - x0), 2 / (y1 - y0)) + const ox = (x0 + x1) / 2, oy = (y0 + y1) / 2 + return c.map(([x, y]) => [(x - ox) * s, (y - oy) * s]) +} + +function resample(pts, n) { + const all = [...pts, pts[0]] + const arc = [0] + for (let i = 1; i < all.length; i++) { + const dx = all[i][0] - all[i - 1][0] + const dy = all[i][1] - all[i - 1][1] + arc.push(arc[i - 1] + Math.sqrt(dx * dx + dy * dy)) + } + const total = arc[arc.length - 1] + const out = [] + for (let i = 0; i < n; i++) { + const target = i / n * total + let j = 1 + while (j < arc.length - 1 && arc[j] < target) j++ + const t = arc[j] > arc[j - 1] ? (target - arc[j - 1]) / (arc[j] - arc[j - 1]) : 0 + out.push([ + all[j - 1][0] + t * (all[j][0] - all[j - 1][0]), + all[j - 1][1] + t * (all[j][1] - all[j - 1][1]), + ]) + } + return out +} + +function generateOval(n) { + const pts = [] + for (let i = 0; i < n; i++) { + const theta = i / n * 2 * Math.PI + pts.push([Math.cos(theta), 0.74 * Math.sin(theta)]) + } + return pts +} + +let _shapes = null +function getShapes() { + if (_shapes) return _shapes + _shapes = SVG_DEFS.map((def) => { + if (def === null) return generateOval(POINTS_PER_SHAPE) + const raw = svgPathToPoints(def.d) + const norm = normalize(raw, def.viewBox) + return resample(norm, POINTS_PER_SHAPE) + }) + return _shapes +} + +function lerpShapes(a, b, t) { + const out = new Array(a.length) + for (let i = 0; i < a.length; i++) { + out[i] = [ + a[i][0] + (b[i][0] - a[i][0]) * t, + a[i][1] + (b[i][1] - a[i][1]) * t, + ] + } + return out +} + +function getMorphedShape(morphFraction) { + const shapes = getShapes() + const idx = Math.floor(morphFraction) + const from = (idx % SHAPE_COUNT + SHAPE_COUNT) % SHAPE_COUNT + const to = (from + 1) % SHAPE_COUNT + const t = Math.max(0, Math.min(1, morphFraction - idx)) + return lerpShapes(shapes[from], shapes[to], t) +} + +// --- Animator State Machine --- +const DURATION_PER_SHAPE_MS = 650 +const CONSTANT_ROTATION_DEG = 50 +const EXTRA_ROTATION_DEG = 90 +const DEFAULT_SPRING_STIFFNESS = 200 +const DEFAULT_SPRING_DAMPING = 0.6 + +class M3Animator { + constructor(stiffness = DEFAULT_SPRING_STIFFNESS, damping = DEFAULT_SPRING_DAMPING) { + this.morphTarget = 1 + this.fraction = 0 + this.elapsed = 0 + this.lastTs = 0 + this.prevCycle = 0 + this.speed = 1 + this.paused = false + this.rotation = 0 + this.morph = 0 + this.spring = new Spring(stiffness, damping) + this.spring.target = 1 + } + update(ts) { + if (this.paused) { + this.lastTs = ts + return + } + if (this.lastTs === 0) this.lastTs = ts + const rawDt = Math.min((ts - this.lastTs) / 1e3, 0.1) + const dt = rawDt * this.speed + this.lastTs = ts + if (dt <= 0) return + this.elapsed += dt * 1e3 + const cycle = Math.floor(this.elapsed / DURATION_PER_SHAPE_MS) + if (cycle > this.prevCycle) { + this.morphTarget += cycle - this.prevCycle + this.spring.target = this.morphTarget + this.prevCycle = cycle + } + this.fraction = (this.elapsed % DURATION_PER_SHAPE_MS) / DURATION_PER_SHAPE_MS + this.spring.step(dt) + const base = this.morphTarget - 1 + const perShape = this.spring.pos - base + this.rotation = ((CONSTANT_ROTATION_DEG + EXTRA_ROTATION_DEG) * base + CONSTANT_ROTATION_DEG * this.fraction + EXTRA_ROTATION_DEG * perShape) % 360 + this.morph = this.spring.pos + } + reset() { + this.morphTarget = 1 + this.fraction = 0 + this.elapsed = 0 + this.lastTs = 0 + this.prevCycle = 0 + this.spring.reset() + this.spring.target = 1 + this.rotation = 0 + this.morph = 0 + } +} + +// --- Canvas Drawing Utilities --- +function drawIndicator(ctx, cssSize, points, rotation, options) { + const ratio = options.sizeRatio ?? 0.79 + const indicatorSize = cssSize * ratio + const cx = cssSize / 2 + const cy = cssSize / 2 + const scale = indicatorSize / 2 + ctx.clearRect(0, 0, cssSize, cssSize) + if (options.contained) { + ctx.save() + ctx.beginPath() + ctx.arc(cx, cy, cssSize / 2, 0, Math.PI * 2) + ctx.fillStyle = options.containerColor ?? "rgba(0,0,0,0.08)" + ctx.fill() + ctx.restore() + } + ctx.save() + ctx.translate(cx, cy) + ctx.rotate((rotation * Math.PI) / 180) + ctx.beginPath() + for (let i = 0; i <= points.length; i++) { + const [px, py] = points[i % points.length] + if (i === 0) ctx.moveTo(px * scale, py * scale) + else ctx.lineTo(px * scale, py * scale) + } + ctx.closePath() + ctx.fillStyle = options.color + ctx.fill() + ctx.restore() +} + +function setupCanvas(canvas, cssSize) { + const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1 + const px = Math.round(cssSize * dpr) + canvas.width = px + canvas.height = px + canvas.style.width = `${cssSize}px` + canvas.style.height = `${cssSize}px` + const ctx = canvas.getContext("2d") + ctx.scale(dpr, dpr) + return ctx +} + +// --- Web Component Implementation --- +export class LoadingIndicator extends LitElement { + static styles = css` + :host { + display: inline-flex; + vertical-align: middle; + position: relative; + align-items: center; + justify-content: center; + contain: strict; + content-visibility: auto; + } + canvas { + display: block; + } + ` + + static properties = { + size: { type: Number }, + color: { type: String }, + contained: { type: Boolean }, + containerColor: { type: String, attribute: 'container-color' }, + speed: { type: Number }, + paused: { type: Boolean }, + sizeRatio: { type: Number, attribute: 'size-ratio' }, + } + + constructor() { + super() + this.size = 48 + this.color = 'var(--md-sys-color-primary, #6750a4)' + this.contained = false + this.containerColor = 'var(--md-sys-color-surface-container-high, rgba(0, 0, 0, 0.08))' + this.speed = 1 + this.paused = false + this.sizeRatio = 0.79 + + this.style.width = '48px' + this.style.height = '48px' + + this.rafId = 0 + this.ctx = null + this.animator = new M3Animator() + } + + render() { + return html` + + ` + } + + firstUpdated() { + const canvas = this.renderRoot.querySelector('#canvas') + if (!canvas) return + this.ctx = setupCanvas(canvas, this.size) + + const loop = (ts) => { + this.animator.speed = this.speed + this.animator.paused = this.paused + this.animator.update(ts) + const shape = getMorphedShape(this.animator.morph) + drawIndicator(this.ctx, this.size, shape, this.animator.rotation, { + color: this._resolveColor(this.color), + sizeRatio: this.sizeRatio, + contained: this.contained, + containerColor: this._resolveColor(this.containerColor) + }) + this.rafId = requestAnimationFrame(loop) + } + this.rafId = requestAnimationFrame(loop) + } + + updated(changedProperties) { + if (changedProperties.has('size')) { + this.style.width = `${this.size}px` + this.style.height = `${this.size}px` + const canvas = this.renderRoot.querySelector('#canvas') + if (canvas) { + this.ctx = setupCanvas(canvas, this.size) + } + } + } + + disconnectedCallback() { + super.disconnectedCallback() + if (this.rafId) { + cancelAnimationFrame(this.rafId) + } + } + + _resolveColor(colorString) { + if (!colorString) return '#6750a4' + const trimmed = colorString.trim() + if (trimmed === 'currentColor') { + return window.getComputedStyle(this).color || '#6750a4' + } + if (trimmed.startsWith('var(') && trimmed.endsWith(')')) { + const content = trimmed.substring(4, trimmed.length - 1).trim() + const commaIdx = content.indexOf(',') + if (commaIdx === -1) { + const varName = content.trim() + const val = window.getComputedStyle(this).getPropertyValue(varName).trim() + return val ? this._resolveColor(val) : '#6750a4' + } else { + const varName = content.substring(0, commaIdx).trim() + const fallback = content.substring(commaIdx + 1).trim() + const val = window.getComputedStyle(this).getPropertyValue(varName).trim() + return val ? this._resolveColor(val) : this._resolveColor(fallback) + } + } + if (trimmed.startsWith('--')) { + const val = window.getComputedStyle(this).getPropertyValue(trimmed).trim() + return val ? this._resolveColor(val) : '#6750a4' + } + return trimmed + } +} + +customElements.define('md-loading-indicator', LoadingIndicator) From b6c6e5830ec570caef973f0204b1053cd126aa10 Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Tue, 23 Jun 2026 18:15:16 -0700 Subject: [PATCH 02/13] feat: Add wavy shape attribute for linear and circular progress indicators --- demo/index.html | 15 ++++- progress/progress.js | 144 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 147 insertions(+), 12 deletions(-) diff --git a/demo/index.html b/demo/index.html index 301a9ff..0825fda 100644 --- a/demo/index.html +++ b/demo/index.html @@ -180,8 +180,19 @@

Inputs

Progress

- - +
+ + + +
+ +

Wavy Progress Indicators

+
+ + + + +

Loading Indicator (M3 Expressive)

diff --git a/progress/progress.js b/progress/progress.js index f1b16b5..e7138a4 100644 --- a/progress/progress.js +++ b/progress/progress.js @@ -1,4 +1,4 @@ -import { html, LitElement, nothing, css } from 'lit' +import { html, LitElement, nothing, css, svg } from 'lit' import { classMap } from 'lit/directives/class-map.js' import { requestUpdateOnAriaChange } from '../internal/aria/delegate.js' import { styleMap } from 'lit/directives/style-map.js' @@ -13,6 +13,7 @@ export class Progress extends LitElement { indeterminate: { type: Boolean }, fourColor: { type: Boolean, attribute: 'four-color' }, buffer: { type: Number }, + wavy: { type: Boolean, reflect: true }, } constructor() { super(...arguments) @@ -39,6 +40,56 @@ export class Progress extends LitElement { * If the value is 0 or negative, the buffer is not displayed. */ this.buffer = 0 + this.wavy = false + } + + firstUpdated() { + this.updateWavyAnimationState() + } + + updated(changedProperties) { + if (changedProperties.has('wavy') || changedProperties.has('type')) { + this.updateWavyAnimationState() + } + } + + disconnectedCallback() { + super.disconnectedCallback() + this.stopWavyAnimation() + } + + updateWavyAnimationState() { + if (this.wavy && this.type === 'circular') { + this.startWavyAnimation() + } else { + this.stopWavyAnimation() + } + } + + startWavyAnimation() { + if (this.wavyAnimationActive) return + this.wavyAnimationActive = true + this.wavyPhase = 0 + const loop = (ts) => { + if (!this.wavyAnimationActive) return + this.wavyPhase += 0.08 + const path = this.renderRoot.querySelector('.active-track.wavy-circle') + if (path) { + const isIndeterminateWavy = this.indeterminate && this.wavy + const progress = isIndeterminateWavy ? 0.25 : (this.value / this.max) + path.setAttribute('d', getWavyCirclePath(2400, 2400, 2160, 120, 12, this.wavyPhase, progress)) + } + this.wavyRafId = requestAnimationFrame(loop) + } + this.wavyRafId = requestAnimationFrame(loop) + } + + stopWavyAnimation() { + this.wavyAnimationActive = false + if (this.wavyRafId) { + cancelAnimationFrame(this.wavyRafId) + this.wavyRafId = 0 + } } render() { // Needed for closure conformance @@ -61,6 +112,7 @@ export class Progress extends LitElement { 'four-color': this.fourColor, circular: this.type === 'circular', linear: this.type === 'linear', + wavy: this.wavy, } } renderIndicator() { @@ -71,7 +123,7 @@ export class Progress extends LitElement { } } renderCircularIndicator() { - if (this.indeterminate) { + if (this.indeterminate && !this.wavy) { return this.renderIndeterminateContainer() } return this.renderDeterminateContainer() @@ -79,13 +131,33 @@ export class Progress extends LitElement { // Determinate mode is rendered with an svg so the progress arc can be // easily animated via stroke-dashoffset. renderDeterminateContainer() { + const isIndeterminateWavy = this.indeterminate && this.wavy + const progress = isIndeterminateWavy ? 0.25 : (this.value / this.max) const dashOffset = (1 - this.value / this.max) * 100 // note, dash-array/offset are relative to Setting `pathLength` but // Chrome seems to render this inaccurately and using a large viewbox helps. return html` - - - + + + ${this.wavy + ? svg`` + : svg`` + } ` } @@ -188,11 +260,6 @@ export class Progress extends LitElement { transform: rotate(-90deg); } circle { - cx: 50%; - cy: 50%; - r: calc(50% * (1 - var(--_active-indicator-width) / 100)); - stroke-width: calc(var(--_active-indicator-width) * 1%); - stroke-dasharray: 100; fill: rgba(0, 0, 0, 0); } .active-track { @@ -561,6 +628,47 @@ export class Progress extends LitElement { background-color: CanvasText; } } + /* --- Wavy Progress Indicator Styling --- */ + :host([wavy][type='linear']) { + --_track-height: 12px; + } + :host([wavy][type='linear']) .bar-inner { + background: var(--_active-indicator-color); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='12' viewBox='0 0 24 12'%3E%3Cpath d='M 0 6 Q 6 1 12 6 T 24 6' fill='none' stroke='black' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='12' viewBox='0 0 24 12'%3E%3Cpath d='M 0 6 Q 6 1 12 6 T 24 6' fill='none' stroke='black' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E"); + -webkit-mask-repeat: repeat-x; + mask-repeat: repeat-x; + animation: wavy-linear-move 1s linear infinite; + } + :host([wavy][type='linear'].indeterminate) .primary-bar > .bar-inner { + animation: primary-indeterminate-scale 2s linear infinite, wavy-linear-move 1s linear infinite; + } + :host([wavy][type='linear'].indeterminate.four-color) .primary-bar > .bar-inner { + animation: primary-indeterminate-scale 2s linear infinite, four-color 4s linear infinite, wavy-linear-move 1s linear infinite; + } + :host([wavy][type='linear'].indeterminate) .secondary-bar > .bar-inner { + animation: secondary-indeterminate-scale 2s linear infinite, wavy-linear-move 1s linear infinite; + } + :host([wavy][type='linear'].indeterminate.four-color) .secondary-bar > .bar-inner { + animation: secondary-indeterminate-scale 2s linear infinite, four-color 4s linear infinite, wavy-linear-move 1s linear infinite; + } + @keyframes wavy-linear-move { + from { + -webkit-mask-position-x: 0px; + mask-position-x: 0px; + } + to { + -webkit-mask-position-x: 24px; + mask-position-x: 24px; + } + } + .active-track.wavy-circle { + stroke-linecap: round; + stroke-linejoin: round; + stroke: var(--_active-indicator-color); + stroke-width: calc(var(--_active-indicator-width) * 48px); + fill: rgba(0, 0, 0, 0); + } `, ] } @@ -569,3 +677,19 @@ export class Progress extends LitElement { })() customElements.define('md-progress', Progress) + +function getWavyCirclePath(cx, cy, r, amplitude, frequency, phase, progress = 1.0) { + const points = [] + const steps = Math.max(10, Math.round(180 * progress)) + for (let i = 0; i <= steps; i++) { + const theta = (i / steps) * progress * 2 * Math.PI + const perturbedRadius = r + amplitude * Math.sin(frequency * theta + phase) + const x = cx + perturbedRadius * Math.cos(theta) + const y = cy + perturbedRadius * Math.sin(theta) + points.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`) + } + if (progress >= 1.0) { + points.push('Z') + } + return points.join(' ') +} From 7daa0f64ac374ac6cf1f4c9eba60e20ff3f142f3 Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Tue, 23 Jun 2026 20:13:51 -0700 Subject: [PATCH 03/13] fix: Address PR review feedback for loading indicator component --- all.js | 4 +- loading-indicator/loading-indicator.js | 66 +++++++++++++++++++------- 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/all.js b/all.js index d69aa86..d08bd0a 100644 --- a/all.js +++ b/all.js @@ -29,11 +29,11 @@ import './buttons/icon-button.js' import './buttons/outlined-icon-button.js' import './list/list.js' import './list/list-item.js' +import './loading-indicator/loading-indicator.js' import './menu/menu.js' import './menu/menu-item.js' import './menu/sub-menu.js' import './progress/progress.js' -import './loading-indicator/loading-indicator.js' import './radio/radio.js' import './internal/ripple/ripple.js' import './select/select.js' @@ -62,11 +62,11 @@ export * from './icon/icon.js' export * from './buttons/icon-button.js' export * from './list/list.js' export * from './list/list-item.js' +export * from './loading-indicator/loading-indicator.js' export * from './menu/menu.js' export * from './menu/menu-item.js' export * from './menu/sub-menu.js' export * from './progress/progress.js' -export * from './loading-indicator/loading-indicator.js' export * from './radio/radio.js' export * from './internal/ripple/ripple.js' export * from './select/select.js' diff --git a/loading-indicator/loading-indicator.js b/loading-indicator/loading-indicator.js index 74d416c..8d51fd8 100644 --- a/loading-indicator/loading-indicator.js +++ b/loading-indicator/loading-indicator.js @@ -309,6 +309,8 @@ export class LoadingIndicator extends LitElement { justify-content: center; contain: strict; content-visibility: auto; + width: var(--md-loading-indicator-size, 48px); + height: var(--md-loading-indicator-size, 48px); } canvas { display: block; @@ -335,12 +337,12 @@ export class LoadingIndicator extends LitElement { this.paused = false this.sizeRatio = 0.79 - this.style.width = '48px' - this.style.height = '48px' - this.rafId = 0 this.ctx = null this.animator = new M3Animator() + this.resolvedColor = '#6750a4' + this.resolvedContainerColor = 'rgba(0, 0, 0, 0.08)' + this.frameCount = 0 } render() { @@ -349,42 +351,74 @@ export class LoadingIndicator extends LitElement { ` } - firstUpdated() { - const canvas = this.renderRoot.querySelector('#canvas') + updateResolvedColors() { + this.resolvedColor = this._resolveColor(this.color) + this.resolvedContainerColor = this._resolveColor(this.containerColor) + } + + startAnimation() { + if (this.rafId) return + const canvas = this.renderRoot?.querySelector('#canvas') if (!canvas) return - this.ctx = setupCanvas(canvas, this.size) + if (!this.ctx) { + this.ctx = setupCanvas(canvas, this.size) + } + this.animator.lastTs = 0 const loop = (ts) => { this.animator.speed = this.speed this.animator.paused = this.paused this.animator.update(ts) const shape = getMorphedShape(this.animator.morph) + + this.frameCount++ + if (this.frameCount % 60 === 0) { + this.updateResolvedColors() + } + drawIndicator(this.ctx, this.size, shape, this.animator.rotation, { - color: this._resolveColor(this.color), + color: this.resolvedColor, sizeRatio: this.sizeRatio, contained: this.contained, - containerColor: this._resolveColor(this.containerColor) + containerColor: this.resolvedContainerColor }) this.rafId = requestAnimationFrame(loop) } this.rafId = requestAnimationFrame(loop) } + stopAnimation() { + if (this.rafId) { + cancelAnimationFrame(this.rafId) + this.rafId = 0 + } + } + + connectedCallback() { + super.connectedCallback() + this.startAnimation() + } + + disconnectedCallback() { + super.disconnectedCallback() + this.stopAnimation() + } + + firstUpdated() { + this.updateResolvedColors() + this.startAnimation() + } + updated(changedProperties) { if (changedProperties.has('size')) { - this.style.width = `${this.size}px` - this.style.height = `${this.size}px` + this.style.setProperty('--md-loading-indicator-size', `${this.size}px`) const canvas = this.renderRoot.querySelector('#canvas') if (canvas) { this.ctx = setupCanvas(canvas, this.size) } } - } - - disconnectedCallback() { - super.disconnectedCallback() - if (this.rafId) { - cancelAnimationFrame(this.rafId) + if (changedProperties.has('color') || changedProperties.has('containerColor') || changedProperties.has('contained')) { + this.updateResolvedColors() } } From ce5db56733595eaa15fe422fbcc6a669a8e59bcb Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Tue, 23 Jun 2026 20:15:20 -0700 Subject: [PATCH 04/13] fix: Prevent vertical clipping of wavy linear progress indicators --- progress/progress.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/progress/progress.js b/progress/progress.js index e7138a4..8db80fd 100644 --- a/progress/progress.js +++ b/progress/progress.js @@ -631,11 +631,12 @@ export class Progress extends LitElement { /* --- Wavy Progress Indicator Styling --- */ :host([wavy][type='linear']) { --_track-height: 12px; + --_active-indicator-height: 12px; } :host([wavy][type='linear']) .bar-inner { background: var(--_active-indicator-color); - -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='12' viewBox='0 0 24 12'%3E%3Cpath d='M 0 6 Q 6 1 12 6 T 24 6' fill='none' stroke='black' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E"); - mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='12' viewBox='0 0 24 12'%3E%3Cpath d='M 0 6 Q 6 1 12 6 T 24 6' fill='none' stroke='black' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E"); + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='12' viewBox='0 0 24 12'%3E%3Cpath d='M 0 6 Q 6 -2 12 6 T 24 6' fill='none' stroke='black' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='12' viewBox='0 0 24 12'%3E%3Cpath d='M 0 6 Q 6 -2 12 6 T 24 6' fill='none' stroke='black' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E"); -webkit-mask-repeat: repeat-x; mask-repeat: repeat-x; animation: wavy-linear-move 1s linear infinite; From 015751dcd36ae64ed4d9801773613b2e1a38a5d4 Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Tue, 23 Jun 2026 20:17:47 -0700 Subject: [PATCH 05/13] fix: Reduce linear wavy progress background track height to 4px and center it --- progress/progress.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/progress/progress.js b/progress/progress.js index 8db80fd..959c9c4 100644 --- a/progress/progress.js +++ b/progress/progress.js @@ -633,6 +633,11 @@ export class Progress extends LitElement { --_track-height: 12px; --_active-indicator-height: 12px; } + :host([wavy][type='linear']) .inactive-track, + :host([wavy][type='linear']) .dots { + height: 4px; + top: 4px; + } :host([wavy][type='linear']) .bar-inner { background: var(--_active-indicator-color); -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='12' viewBox='0 0 24 12'%3E%3Cpath d='M 0 6 Q 6 -2 12 6 T 24 6' fill='none' stroke='black' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E"); From 45de646ee7c492f6f553e71a577db8883ccd3b72 Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Tue, 23 Jun 2026 20:23:58 -0700 Subject: [PATCH 06/13] feat: Add stop indicator dot and rounded track corners to linear progress --- progress/progress.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/progress/progress.js b/progress/progress.js index 959c9c4..2de523d 100644 --- a/progress/progress.js +++ b/progress/progress.js @@ -196,6 +196,7 @@ export class Progress extends LitElement { return html`
+
@@ -471,6 +472,15 @@ export class Progress extends LitElement { transition: transform 250ms cubic-bezier(0.4, 0, 0.6, 1); transform-origin: left center; } + .stop-indicator { + position: absolute; + right: 0; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--_active-indicator-color); + top: calc(50% - 2px); + } .dots { inset: 0; animation: linear infinite 250ms; @@ -632,6 +642,7 @@ export class Progress extends LitElement { :host([wavy][type='linear']) { --_track-height: 12px; --_active-indicator-height: 12px; + --_track-shape: 6px; } :host([wavy][type='linear']) .inactive-track, :host([wavy][type='linear']) .dots { From 248e93f04e5021d9af2d194029bba3a5dcf1dcdf Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Tue, 23 Jun 2026 20:26:36 -0700 Subject: [PATCH 07/13] refactor: Move progress and loading indicator components to indicators directory and update README --- all.js | 8 +- common.js | 4 +- demo/index.html | 4 +- indicators/README.md | 108 ++++++++++++++++++ .../loading-indicator.js | 0 {progress => indicators}/progress.js | 0 progress/README.md | 10 -- 7 files changed, 116 insertions(+), 18 deletions(-) create mode 100644 indicators/README.md rename {loading-indicator => indicators}/loading-indicator.js (100%) rename {progress => indicators}/progress.js (100%) delete mode 100644 progress/README.md diff --git a/all.js b/all.js index d08bd0a..b9f782d 100644 --- a/all.js +++ b/all.js @@ -27,13 +27,13 @@ import './buttons/filled-icon-button.js' import './buttons/filled-tonal-icon-button.js' import './buttons/icon-button.js' import './buttons/outlined-icon-button.js' +import './indicators/loading-indicator.js' +import './indicators/progress.js' import './list/list.js' import './list/list-item.js' -import './loading-indicator/loading-indicator.js' import './menu/menu.js' import './menu/menu-item.js' import './menu/sub-menu.js' -import './progress/progress.js' import './radio/radio.js' import './internal/ripple/ripple.js' import './select/select.js' @@ -60,13 +60,13 @@ export * from './internal/field/field.js' export * from './internal/focus/focus-ring.js' export * from './icon/icon.js' export * from './buttons/icon-button.js' +export * from './indicators/loading-indicator.js' +export * from './indicators/progress.js' export * from './list/list.js' export * from './list/list-item.js' -export * from './loading-indicator/loading-indicator.js' export * from './menu/menu.js' export * from './menu/menu-item.js' export * from './menu/sub-menu.js' -export * from './progress/progress.js' export * from './radio/radio.js' export * from './internal/ripple/ripple.js' export * from './select/select.js' diff --git a/common.js b/common.js index d063462..5758a8f 100644 --- a/common.js +++ b/common.js @@ -13,12 +13,12 @@ import './chips/chip-set.js' import './dialog/dialog.js' import './icon/icon.js' import './buttons/icon-button.js' +import './indicators/progress.js' import './list/list.js' import './list/list-item.js' import './menu/menu.js' import './menu/menu-item.js' import './menu/sub-menu.js' -import './progress/progress.js' import './radio/radio.js' import './select/select.js' import './select/select-option.js' @@ -33,12 +33,12 @@ export * from './chips/chip-set.js' export * from './dialog/dialog.js' export * from './icon/icon.js' export * from './buttons/icon-button.js' +export * from './indicators/progress.js' export * from './list/list.js' export * from './list/list-item.js' export * from './menu/menu.js' export * from './menu/menu-item.js' export * from './menu/sub-menu.js' -export * from './progress/progress.js' export * from './radio/radio.js' export * from './select/select.js' export * from './select/select-option.js' diff --git a/demo/index.html b/demo/index.html index 0825fda..c64fcbf 100644 --- a/demo/index.html +++ b/demo/index.html @@ -57,8 +57,8 @@ import 'material/menu/menu-item.js' import 'material/snackbar/snackbar.js' import 'material/app/bar.js' - import 'material/progress/progress.js' - import 'material/loading-indicator/loading-indicator.js' + import 'material/indicators/progress.js' + import 'material/indicators/loading-indicator.js' import './components/expressive-component.js' diff --git a/indicators/README.md b/indicators/README.md new file mode 100644 index 0000000..c31bf75 --- /dev/null +++ b/indicators/README.md @@ -0,0 +1,108 @@ +# Indicators + +This directory contains progress and loading indicator components implementing the Material Design 3 specifications, including the M3 Expressive shape and physics extensions. + +- [Progress Indicators (`md-progress`)](#progress-indicators-md-progress) +- [Loading Indicator (`md-loading-indicator`)](#loading-indicator-md-loading-indicator) + +--- + +## Progress Indicators (`md-progress`) + +The `md-progress` component displays activity or loading status. It supports linear and circular shapes, determinate and indeterminate behaviors, and the M3 Expressive wavy track extension. + +### Import + +```javascript +import 'material/indicators/progress.js' +``` + +### Usage + +#### Circular Progress + +```html + + + + + +``` + +#### Linear Progress + +```html + + + + + +``` + +#### Wavy M3 Expressive Extension + +Add the `wavy` attribute to enable the M3 Expressive waveform style: + +* **Wavy Circular**: Animates the active track as a circular wave with smooth phase propagation. +* **Wavy Linear**: Replaces the active bar with a smooth sine wave. Features a `12px` expanded track height, a centered `4px` straight inactive track, and a stop indicator dot at the end (for determinate mode). + +```html + + + + + + + + + + + +``` + +--- + +## Loading Indicator (`md-loading-indicator`) + +The `md-loading-indicator` is an expressive M3 component that uses a spring physics engine to morph between 7 different geometric shapes (soft burst, cookie, pentagon, rounded diamond/pill, sunny, oval, etc.) with a rotating canvas animation. + +### Import + +```javascript +import 'material/indicators/loading-indicator.js' +``` + +### Usage + +```html + + + + + + + + +``` + +### Properties and Attributes + +| Property | Attribute | Type | Default | Description | +| :--- | :--- | :--- | :--- | :--- | +| `size` | `size` | `number` | `48` | The bounding box size of the indicator in pixels. | +| `color` | `color` | `string` | `'var(--md-sys-color-primary, #6750a4)'` | The color of the morphing indicator shape. | +| `contained` | `contained` | `boolean` | `false` | If true, renders a circular background container behind the indicator. | +| `containerColor` | `container-color`| `string` | `'var(--md-sys-color-surface-container-high, rgba(0, 0, 0, 0.08))'` | The color of the background container (when `contained` is true). | +| `speed` | `speed` | `number` | `1` | Animation speed multiplier. | +| `paused` | `paused` | `boolean` | `false` | If true, pauses the physics engine and animation loop. | +| `sizeRatio` | `size-ratio` | `number` | `0.79` | The ratio of the indicator shape size relative to the host container `size`. | + +### Styling + +You can customize the size of the loading indicator using the CSS custom property: + +```css +md-loading-indicator { + --md-loading-indicator-size: 64px; +} +``` diff --git a/loading-indicator/loading-indicator.js b/indicators/loading-indicator.js similarity index 100% rename from loading-indicator/loading-indicator.js rename to indicators/loading-indicator.js diff --git a/progress/progress.js b/indicators/progress.js similarity index 100% rename from progress/progress.js rename to indicators/progress.js diff --git a/progress/README.md b/progress/README.md deleted file mode 100644 index b5288d8..0000000 --- a/progress/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Progress indicators - -```html - - - - -``` From 37e1e8463fc1a64211fc1d7d4b19cdf89ab45dda Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Tue, 23 Jun 2026 20:28:34 -0700 Subject: [PATCH 08/13] refactor: Rename loading-indicator component to md-loading and file to loading.js --- all.js | 4 ++-- demo/index.html | 12 +++++----- indicators/README.md | 24 +++++++++---------- .../{loading-indicator.js => loading.js} | 10 ++++---- 4 files changed, 25 insertions(+), 25 deletions(-) rename indicators/{loading-indicator.js => loading.js} (98%) diff --git a/all.js b/all.js index b9f782d..ac1b046 100644 --- a/all.js +++ b/all.js @@ -27,7 +27,7 @@ import './buttons/filled-icon-button.js' import './buttons/filled-tonal-icon-button.js' import './buttons/icon-button.js' import './buttons/outlined-icon-button.js' -import './indicators/loading-indicator.js' +import './indicators/loading.js' import './indicators/progress.js' import './list/list.js' import './list/list-item.js' @@ -60,7 +60,7 @@ export * from './internal/field/field.js' export * from './internal/focus/focus-ring.js' export * from './icon/icon.js' export * from './buttons/icon-button.js' -export * from './indicators/loading-indicator.js' +export * from './indicators/loading.js' export * from './indicators/progress.js' export * from './list/list.js' export * from './list/list-item.js' diff --git a/demo/index.html b/demo/index.html index c64fcbf..2d12bf3 100644 --- a/demo/index.html +++ b/demo/index.html @@ -58,7 +58,7 @@ import 'material/snackbar/snackbar.js' import 'material/app/bar.js' import 'material/indicators/progress.js' - import 'material/indicators/loading-indicator.js' + import 'material/indicators/loading.js' import './components/expressive-component.js' @@ -196,11 +196,11 @@

Wavy Progress Indicators

Loading Indicator (M3 Expressive)

- - - - - + + + + +
diff --git a/indicators/README.md b/indicators/README.md index c31bf75..7f3b3fd 100644 --- a/indicators/README.md +++ b/indicators/README.md @@ -3,7 +3,7 @@ This directory contains progress and loading indicator components implementing the Material Design 3 specifications, including the M3 Expressive shape and physics extensions. - [Progress Indicators (`md-progress`)](#progress-indicators-md-progress) -- [Loading Indicator (`md-loading-indicator`)](#loading-indicator-md-loading-indicator) +- [Loading Component (`md-loading`)](#loading-component-md-loading) --- @@ -62,27 +62,27 @@ Add the `wavy` attribute to enable the M3 Expressive waveform style: --- -## Loading Indicator (`md-loading-indicator`) +## Loading Component (`md-loading`) -The `md-loading-indicator` is an expressive M3 component that uses a spring physics engine to morph between 7 different geometric shapes (soft burst, cookie, pentagon, rounded diamond/pill, sunny, oval, etc.) with a rotating canvas animation. +The `md-loading` is an expressive M3 component that uses a spring physics engine to morph between 7 different geometric shapes (soft burst, cookie, pentagon, rounded diamond/pill, sunny, oval, etc.) with a rotating canvas animation. ### Import ```javascript -import 'material/indicators/loading-indicator.js' +import 'material/indicators/loading.js' ``` ### Usage ```html - - + + - - + + - - + + ``` ### Properties and Attributes @@ -102,7 +102,7 @@ import 'material/indicators/loading-indicator.js' You can customize the size of the loading indicator using the CSS custom property: ```css -md-loading-indicator { - --md-loading-indicator-size: 64px; +md-loading { + --md-loading-size: 64px; } ``` diff --git a/indicators/loading-indicator.js b/indicators/loading.js similarity index 98% rename from indicators/loading-indicator.js rename to indicators/loading.js index 8d51fd8..88c4930 100644 --- a/indicators/loading-indicator.js +++ b/indicators/loading.js @@ -299,7 +299,7 @@ function setupCanvas(canvas, cssSize) { } // --- Web Component Implementation --- -export class LoadingIndicator extends LitElement { +export class Loading extends LitElement { static styles = css` :host { display: inline-flex; @@ -309,8 +309,8 @@ export class LoadingIndicator extends LitElement { justify-content: center; contain: strict; content-visibility: auto; - width: var(--md-loading-indicator-size, 48px); - height: var(--md-loading-indicator-size, 48px); + width: var(--md-loading-size, 48px); + height: var(--md-loading-size, 48px); } canvas { display: block; @@ -411,7 +411,7 @@ export class LoadingIndicator extends LitElement { updated(changedProperties) { if (changedProperties.has('size')) { - this.style.setProperty('--md-loading-indicator-size', `${this.size}px`) + this.style.setProperty('--md-loading-size', `${this.size}px`) const canvas = this.renderRoot.querySelector('#canvas') if (canvas) { this.ctx = setupCanvas(canvas, this.size) @@ -450,4 +450,4 @@ export class LoadingIndicator extends LitElement { } } -customElements.define('md-loading-indicator', LoadingIndicator) +customElements.define('md-loading', Loading) From 8f9bbe8b098629e3ba13dd43ba10ddc7b1b632ab Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Tue, 23 Jun 2026 23:18:27 -0700 Subject: [PATCH 09/13] 2.12.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 35c702c..36bc5b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "material", - "version": "2.10.1", + "version": "2.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "material", - "version": "2.10.1", + "version": "2.12.0", "license": "Apache-2.0", "workspaces": [ "catalog" diff --git a/package.json b/package.json index f0f9b5d..c753dae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "material", - "version": "2.11.0", + "version": "2.12.0", "publishConfig": { "access": "public" }, From 6000a8e3443bdc1f12d6a5fb36c92c924f95ac1a Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Wed, 24 Jun 2026 07:49:53 -0700 Subject: [PATCH 10/13] refactor: replace wavy boolean attribute with shape='flat|wavy' on md-progress --- demo/index.html | 10 +++++----- indicators/README.md | 10 +++++----- indicators/progress.js | 34 +++++++++++++++++----------------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/demo/index.html b/demo/index.html index 1eb75bc..5c52451 100644 --- a/demo/index.html +++ b/demo/index.html @@ -185,17 +185,17 @@

Inputs

Progress

- +

Wavy Progress Indicators

- - - - + + + +

Loading Indicator (M3 Expressive)

diff --git a/indicators/README.md b/indicators/README.md index 7f3b3fd..aa7ca7d 100644 --- a/indicators/README.md +++ b/indicators/README.md @@ -41,23 +41,23 @@ import 'material/indicators/progress.js' #### Wavy M3 Expressive Extension -Add the `wavy` attribute to enable the M3 Expressive waveform style: +Add the `shape="wavy"` attribute to enable the M3 Expressive waveform style (defaults to `flat`): * **Wavy Circular**: Animates the active track as a circular wave with smooth phase propagation. * **Wavy Linear**: Replaces the active bar with a smooth sine wave. Features a `12px` expanded track height, a centered `4px` straight inactive track, and a stop indicator dot at the end (for determinate mode). ```html - + - + - + - + ``` --- diff --git a/indicators/progress.js b/indicators/progress.js index 2de523d..ff698ee 100644 --- a/indicators/progress.js +++ b/indicators/progress.js @@ -13,7 +13,7 @@ export class Progress extends LitElement { indeterminate: { type: Boolean }, fourColor: { type: Boolean, attribute: 'four-color' }, buffer: { type: Number }, - wavy: { type: Boolean, reflect: true }, + shape: { type: String, reflect: true }, } constructor() { super(...arguments) @@ -40,7 +40,7 @@ export class Progress extends LitElement { * If the value is 0 or negative, the buffer is not displayed. */ this.buffer = 0 - this.wavy = false + this.shape = 'flat' } firstUpdated() { @@ -48,7 +48,7 @@ export class Progress extends LitElement { } updated(changedProperties) { - if (changedProperties.has('wavy') || changedProperties.has('type')) { + if (changedProperties.has('shape') || changedProperties.has('type')) { this.updateWavyAnimationState() } } @@ -59,7 +59,7 @@ export class Progress extends LitElement { } updateWavyAnimationState() { - if (this.wavy && this.type === 'circular') { + if (this.shape === 'wavy' && this.type === 'circular') { this.startWavyAnimation() } else { this.stopWavyAnimation() @@ -75,7 +75,7 @@ export class Progress extends LitElement { this.wavyPhase += 0.08 const path = this.renderRoot.querySelector('.active-track.wavy-circle') if (path) { - const isIndeterminateWavy = this.indeterminate && this.wavy + const isIndeterminateWavy = this.indeterminate && this.shape === 'wavy' const progress = isIndeterminateWavy ? 0.25 : (this.value / this.max) path.setAttribute('d', getWavyCirclePath(2400, 2400, 2160, 120, 12, this.wavyPhase, progress)) } @@ -112,7 +112,7 @@ export class Progress extends LitElement { 'four-color': this.fourColor, circular: this.type === 'circular', linear: this.type === 'linear', - wavy: this.wavy, + wavy: this.shape === 'wavy', } } renderIndicator() { @@ -123,7 +123,7 @@ export class Progress extends LitElement { } } renderCircularIndicator() { - if (this.indeterminate && !this.wavy) { + if (this.indeterminate && this.shape !== 'wavy') { return this.renderIndeterminateContainer() } return this.renderDeterminateContainer() @@ -131,7 +131,7 @@ export class Progress extends LitElement { // Determinate mode is rendered with an svg so the progress arc can be // easily animated via stroke-dashoffset. renderDeterminateContainer() { - const isIndeterminateWavy = this.indeterminate && this.wavy + const isIndeterminateWavy = this.indeterminate && this.shape === 'wavy' const progress = isIndeterminateWavy ? 0.25 : (this.value / this.max) const dashOffset = (1 - this.value / this.max) * 100 // note, dash-array/offset are relative to Setting `pathLength` but @@ -139,7 +139,7 @@ export class Progress extends LitElement { return html` - ${this.wavy + ${this.shape === 'wavy' ? svg` .bar-inner { + :host([shape='wavy'][type='linear'].indeterminate) .primary-bar > .bar-inner { animation: primary-indeterminate-scale 2s linear infinite, wavy-linear-move 1s linear infinite; } - :host([wavy][type='linear'].indeterminate.four-color) .primary-bar > .bar-inner { + :host([shape='wavy'][type='linear'].indeterminate.four-color) .primary-bar > .bar-inner { animation: primary-indeterminate-scale 2s linear infinite, four-color 4s linear infinite, wavy-linear-move 1s linear infinite; } - :host([wavy][type='linear'].indeterminate) .secondary-bar > .bar-inner { + :host([shape='wavy'][type='linear'].indeterminate) .secondary-bar > .bar-inner { animation: secondary-indeterminate-scale 2s linear infinite, wavy-linear-move 1s linear infinite; } - :host([wavy][type='linear'].indeterminate.four-color) .secondary-bar > .bar-inner { + :host([shape='wavy'][type='linear'].indeterminate.four-color) .secondary-bar > .bar-inner { animation: secondary-indeterminate-scale 2s linear infinite, four-color 4s linear infinite, wavy-linear-move 1s linear infinite; } @keyframes wavy-linear-move { From bb1fa1841aeccabe26cb4b7d78a391591e9329bd Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Thu, 25 Jun 2026 08:58:50 -0700 Subject: [PATCH 11/13] feat: implement Material 3 typography tokens and classes --- typography/md-typescale-styles.js | 105 +------- typography/md-typescale.js | 389 ++++++++++++++++++++++++++++++ 2 files changed, 391 insertions(+), 103 deletions(-) create mode 100644 typography/md-typescale.js diff --git a/typography/md-typescale-styles.js b/typography/md-typescale-styles.js index a809307..42cf500 100644 --- a/typography/md-typescale-styles.js +++ b/typography/md-typescale-styles.js @@ -1,103 +1,2 @@ -import { css } from 'lit' -export const styles = css` - @layer { - .md-typescale-display-small, - .md-typescale-display-small-prominent { - font: var(--md-sys-typescale-display-small-weight, var(--md-ref-typeface-weight-regular, 400)) - var(--md-sys-typescale-display-small-size, 2.25rem) / var(--md-sys-typescale-display-small-line-height, 2.75rem) - var(--md-sys-typescale-display-small-font, var(--md-ref-typeface-brand, Roboto)); - } - .md-typescale-display-medium, - .md-typescale-display-medium-prominent { - font: var(--md-sys-typescale-display-medium-weight, var(--md-ref-typeface-weight-regular, 400)) - var(--md-sys-typescale-display-medium-size, 2.8125rem) / - var(--md-sys-typescale-display-medium-line-height, 3.25rem) - var(--md-sys-typescale-display-medium-font, var(--md-ref-typeface-brand, Roboto)); - } - .md-typescale-display-large, - .md-typescale-display-large-prominent { - font: var(--md-sys-typescale-display-large-weight, var(--md-ref-typeface-weight-regular, 400)) - var(--md-sys-typescale-display-large-size, 3.5625rem) / var(--md-sys-typescale-display-large-line-height, 4rem) - var(--md-sys-typescale-display-large-font, var(--md-ref-typeface-brand, Roboto)); - } - .md-typescale-headline-small, - .md-typescale-headline-small-prominent { - font: var(--md-sys-typescale-headline-small-weight, var(--md-ref-typeface-weight-regular, 400)) - var(--md-sys-typescale-headline-small-size, 1.5rem) / var(--md-sys-typescale-headline-small-line-height, 2rem) - var(--md-sys-typescale-headline-small-font, var(--md-ref-typeface-brand, Roboto)); - } - .md-typescale-headline-medium, - .md-typescale-headline-medium-prominent { - font: var(--md-sys-typescale-headline-medium-weight, var(--md-ref-typeface-weight-regular, 400)) - var(--md-sys-typescale-headline-medium-size, 1.75rem) / - var(--md-sys-typescale-headline-medium-line-height, 2.25rem) - var(--md-sys-typescale-headline-medium-font, var(--md-ref-typeface-brand, Roboto)); - } - .md-typescale-headline-large, - .md-typescale-headline-large-prominent { - font: var(--md-sys-typescale-headline-large-weight, var(--md-ref-typeface-weight-regular, 400)) - var(--md-sys-typescale-headline-large-size, 2rem) / var(--md-sys-typescale-headline-large-line-height, 2.5rem) - var(--md-sys-typescale-headline-large-font, var(--md-ref-typeface-brand, Roboto)); - } - .md-typescale-title-small, - .md-typescale-title-small-prominent { - font: var(--md-sys-typescale-title-small-weight, var(--md-ref-typeface-weight-medium, 500)) - var(--md-sys-typescale-title-small-size, 0.875rem) / var(--md-sys-typescale-title-small-line-height, 1.25rem) - var(--md-sys-typescale-title-small-font, var(--md-ref-typeface-plain, Roboto)); - } - .md-typescale-title-medium, - .md-typescale-title-medium-prominent { - font: var(--md-sys-typescale-title-medium-weight, var(--md-ref-typeface-weight-medium, 500)) - var(--md-sys-typescale-title-medium-size, 1rem) / var(--md-sys-typescale-title-medium-line-height, 1.5rem) - var(--md-sys-typescale-title-medium-font, var(--md-ref-typeface-plain, Roboto)); - } - .md-typescale-title-large, - .md-typescale-title-large-prominent { - font: var(--md-sys-typescale-title-large-weight, var(--md-ref-typeface-weight-regular, 400)) - var(--md-sys-typescale-title-large-size, 1.375rem) / var(--md-sys-typescale-title-large-line-height, 1.75rem) - var(--md-sys-typescale-title-large-font, var(--md-ref-typeface-brand, Roboto)); - } - .md-typescale-body-small, - .md-typescale-body-small-prominent { - font: var(--md-sys-typescale-body-small-weight, var(--md-ref-typeface-weight-regular, 400)) - var(--md-sys-typescale-body-small-size, 0.75rem) / var(--md-sys-typescale-body-small-line-height, 1rem) - var(--md-sys-typescale-body-small-font, var(--md-ref-typeface-plain, Roboto)); - } - .md-typescale-body-medium, - .md-typescale-body-medium-prominent { - font: var(--md-sys-typescale-body-medium-weight, var(--md-ref-typeface-weight-regular, 400)) - var(--md-sys-typescale-body-medium-size, 0.875rem) / var(--md-sys-typescale-body-medium-line-height, 1.25rem) - var(--md-sys-typescale-body-medium-font, var(--md-ref-typeface-plain, Roboto)); - } - .md-typescale-body-large, - .md-typescale-body-large-prominent { - font: var(--md-sys-typescale-body-large-weight, var(--md-ref-typeface-weight-regular, 400)) - var(--md-sys-typescale-body-large-size, 1rem) / var(--md-sys-typescale-body-large-line-height, 1.5rem) - var(--md-sys-typescale-body-large-font, var(--md-ref-typeface-plain, Roboto)); - } - .md-typescale-label-small, - .md-typescale-label-small-prominent { - font: var(--md-sys-typescale-label-small-weight, var(--md-ref-typeface-weight-medium, 500)) - var(--md-sys-typescale-label-small-size, 0.6875rem) / var(--md-sys-typescale-label-small-line-height, 1rem) - var(--md-sys-typescale-label-small-font, var(--md-ref-typeface-plain, Roboto)); - } - .md-typescale-label-medium, - .md-typescale-label-medium-prominent { - font: var(--md-sys-typescale-label-medium-weight, var(--md-ref-typeface-weight-medium, 500)) - var(--md-sys-typescale-label-medium-size, 0.75rem) / var(--md-sys-typescale-label-medium-line-height, 1rem) - var(--md-sys-typescale-label-medium-font, var(--md-ref-typeface-plain, Roboto)); - } - .md-typescale-label-medium-prominent { - font-weight: var(--md-sys-typescale-label-medium-weight-prominent, var(--md-ref-typeface-weight-bold, 700)); - } - .md-typescale-label-large, - .md-typescale-label-large-prominent { - font: var(--md-sys-typescale-label-large-weight, var(--md-ref-typeface-weight-medium, 500)) - var(--md-sys-typescale-label-large-size, 0.875rem) / var(--md-sys-typescale-label-large-line-height, 1.25rem) - var(--md-sys-typescale-label-large-font, var(--md-ref-typeface-plain, Roboto)); - } - .md-typescale-label-large-prominent { - font-weight: var(--md-sys-typescale-label-large-weight-prominent, var(--md-ref-typeface-weight-bold, 700)); - } - } -` +export { styles } from './md-typescale.js' + diff --git a/typography/md-typescale.js b/typography/md-typescale.js new file mode 100644 index 0000000..5051ae8 --- /dev/null +++ b/typography/md-typescale.js @@ -0,0 +1,389 @@ +import { css, unsafeCSS } from 'lit' + +/** + * Material Design 3 Typography System Tokens. + * These map CSS custom properties to their M3 baseline defaults. + */ +export const typescaleTokens = { + // Brand Typeface Reference + '--md-ref-typeface-brand': 'Roboto', + '--md-ref-typeface-plain': 'Roboto', + '--md-ref-typeface-weight-regular': '400', + '--md-ref-typeface-weight-medium': '500', + '--md-ref-typeface-weight-bold': '700', + + // Display Large + '--md-sys-typescale-display-large-font': 'var(--md-ref-typeface-brand, Roboto)', + '--md-sys-typescale-display-large-weight': 'var(--md-ref-typeface-weight-regular, 400)', + '--md-sys-typescale-display-large-weight-prominent': 'var(--md-ref-typeface-weight-bold, 700)', + '--md-sys-typescale-display-large-size': '3.5625rem', // 57px + '--md-sys-typescale-display-large-line-height': '4rem', // 64px + '--md-sys-typescale-display-large-tracking': '-0.015625rem', // -0.25px + + // Display Medium + '--md-sys-typescale-display-medium-font': 'var(--md-ref-typeface-brand, Roboto)', + '--md-sys-typescale-display-medium-weight': 'var(--md-ref-typeface-weight-regular, 400)', + '--md-sys-typescale-display-medium-weight-prominent': 'var(--md-ref-typeface-weight-bold, 700)', + '--md-sys-typescale-display-medium-size': '2.8125rem', // 45px + '--md-sys-typescale-display-medium-line-height': '3.25rem', // 52px + '--md-sys-typescale-display-medium-tracking': '0rem', + + // Display Small + '--md-sys-typescale-display-small-font': 'var(--md-ref-typeface-brand, Roboto)', + '--md-sys-typescale-display-small-weight': 'var(--md-ref-typeface-weight-regular, 400)', + '--md-sys-typescale-display-small-weight-prominent': 'var(--md-ref-typeface-weight-bold, 700)', + '--md-sys-typescale-display-small-size': '2.25rem', // 36px + '--md-sys-typescale-display-small-line-height': '2.75rem', // 44px + '--md-sys-typescale-display-small-tracking': '0rem', + + // Headline Large + '--md-sys-typescale-headline-large-font': 'var(--md-ref-typeface-brand, Roboto)', + '--md-sys-typescale-headline-large-weight': 'var(--md-ref-typeface-weight-regular, 400)', + '--md-sys-typescale-headline-large-weight-prominent': 'var(--md-ref-typeface-weight-bold, 700)', + '--md-sys-typescale-headline-large-size': '2rem', // 32px + '--md-sys-typescale-headline-large-line-height': '2.5rem', // 40px + '--md-sys-typescale-headline-large-tracking': '0rem', + + // Headline Medium + '--md-sys-typescale-headline-medium-font': 'var(--md-ref-typeface-brand, Roboto)', + '--md-sys-typescale-headline-medium-weight': 'var(--md-ref-typeface-weight-regular, 400)', + '--md-sys-typescale-headline-medium-weight-prominent': 'var(--md-ref-typeface-weight-bold, 700)', + '--md-sys-typescale-headline-medium-size': '1.75rem', // 28px + '--md-sys-typescale-headline-medium-line-height': '2.25rem', // 36px + '--md-sys-typescale-headline-medium-tracking': '0rem', + + // Headline Small + '--md-sys-typescale-headline-small-font': 'var(--md-ref-typeface-brand, Roboto)', + '--md-sys-typescale-headline-small-weight': 'var(--md-ref-typeface-weight-regular, 400)', + '--md-sys-typescale-headline-small-weight-prominent': 'var(--md-ref-typeface-weight-bold, 700)', + '--md-sys-typescale-headline-small-size': '1.5rem', // 24px + '--md-sys-typescale-headline-small-line-height': '2rem', // 32px + '--md-sys-typescale-headline-small-tracking': '0rem', + + // Title Large + '--md-sys-typescale-title-large-font': 'var(--md-ref-typeface-brand, Roboto)', + '--md-sys-typescale-title-large-weight': 'var(--md-ref-typeface-weight-regular, 400)', + '--md-sys-typescale-title-large-weight-prominent': 'var(--md-ref-typeface-weight-bold, 700)', + '--md-sys-typescale-title-large-size': '1.375rem', // 22px + '--md-sys-typescale-title-large-line-height': '1.75rem', // 28px + '--md-sys-typescale-title-large-tracking': '0rem', + + // Title Medium + '--md-sys-typescale-title-medium-font': 'var(--md-ref-typeface-plain, Roboto)', + '--md-sys-typescale-title-medium-weight': 'var(--md-ref-typeface-weight-medium, 500)', + '--md-sys-typescale-title-medium-weight-prominent': 'var(--md-ref-typeface-weight-bold, 700)', + '--md-sys-typescale-title-medium-size': '1rem', // 16px + '--md-sys-typescale-title-medium-line-height': '1.5rem', // 24px + '--md-sys-typescale-title-medium-tracking': '0.009375rem', // 0.15px + + // Title Small + '--md-sys-typescale-title-small-font': 'var(--md-ref-typeface-plain, Roboto)', + '--md-sys-typescale-title-small-weight': 'var(--md-ref-typeface-weight-medium, 500)', + '--md-sys-typescale-title-small-weight-prominent': 'var(--md-ref-typeface-weight-bold, 700)', + '--md-sys-typescale-title-small-size': '0.875rem', // 14px + '--md-sys-typescale-title-small-line-height': '1.25rem', // 20px + '--md-sys-typescale-title-small-tracking': '0.00625rem', // 0.1px + + // Body Large + '--md-sys-typescale-body-large-font': 'var(--md-ref-typeface-plain, Roboto)', + '--md-sys-typescale-body-large-weight': 'var(--md-ref-typeface-weight-regular, 400)', + '--md-sys-typescale-body-large-weight-prominent': 'var(--md-ref-typeface-weight-bold, 700)', + '--md-sys-typescale-body-large-size': '1rem', // 16px + '--md-sys-typescale-body-large-line-height': '1.5rem', // 24px + '--md-sys-typescale-body-large-tracking': '0.03125rem', // 0.5px + + // Body Medium + '--md-sys-typescale-body-medium-font': 'var(--md-ref-typeface-plain, Roboto)', + '--md-sys-typescale-body-medium-weight': 'var(--md-ref-typeface-weight-regular, 400)', + '--md-sys-typescale-body-medium-weight-prominent': 'var(--md-ref-typeface-weight-bold, 700)', + '--md-sys-typescale-body-medium-size': '0.875rem', // 14px + '--md-sys-typescale-body-medium-line-height': '1.25rem', // 20px + '--md-sys-typescale-body-medium-tracking': '0.015625rem', // 0.25px + + // Body Small + '--md-sys-typescale-body-small-font': 'var(--md-ref-typeface-plain, Roboto)', + '--md-sys-typescale-body-small-weight': 'var(--md-ref-typeface-weight-regular, 400)', + '--md-sys-typescale-body-small-weight-prominent': 'var(--md-ref-typeface-weight-bold, 700)', + '--md-sys-typescale-body-small-size': '0.75rem', // 12px + '--md-sys-typescale-body-small-line-height': '1rem', // 16px + '--md-sys-typescale-body-small-tracking': '0.025rem', // 0.4px + + // Label Large + '--md-sys-typescale-label-large-font': 'var(--md-ref-typeface-plain, Roboto)', + '--md-sys-typescale-label-large-weight': 'var(--md-ref-typeface-weight-medium, 500)', + '--md-sys-typescale-label-large-weight-prominent': 'var(--md-ref-typeface-weight-bold, 700)', + '--md-sys-typescale-label-large-size': '0.875rem', // 14px + '--md-sys-typescale-label-large-line-height': '1.25rem', // 20px + '--md-sys-typescale-label-large-tracking': '0.00625rem', // 0.1px + + // Label Medium + '--md-sys-typescale-label-medium-font': 'var(--md-ref-typeface-plain, Roboto)', + '--md-sys-typescale-label-medium-weight': 'var(--md-ref-typeface-weight-medium, 500)', + '--md-sys-typescale-label-medium-weight-prominent': 'var(--md-ref-typeface-weight-bold, 700)', + '--md-sys-typescale-label-medium-size': '0.75rem', // 12px + '--md-sys-typescale-label-medium-line-height': '1rem', // 16px + '--md-sys-typescale-label-medium-tracking': '0.03125rem', // 0.5px + + // Label Small + '--md-sys-typescale-label-small-font': 'var(--md-ref-typeface-plain, Roboto)', + '--md-sys-typescale-label-small-weight': 'var(--md-ref-typeface-weight-medium, 500)', + '--md-sys-typescale-label-small-weight-prominent': 'var(--md-ref-typeface-weight-bold, 700)', + '--md-sys-typescale-label-small-size': '0.6875rem', // 11px + '--md-sys-typescale-label-small-line-height': '1rem', // 16px + '--md-sys-typescale-label-small-tracking': '0.03125rem', // 0.5px +} + +/** + * Converts typescaleTokens object to raw CSS rules for custom properties. + */ +export const cssVariablesString = Object.entries(typescaleTokens) + .map(([prop, val]) => ` ${prop}: ${val};`) + .join('\n') + +/** + * Raw vanilla CSS string for class rules. + */ +export const classesCssString = ` +@layer { + /* Display Styles */ + .md-typescale-display-large { + font: var(--md-sys-typescale-display-large-weight, var(--md-ref-typeface-weight-regular, 400)) + var(--md-sys-typescale-display-large-size, 3.5625rem) / var(--md-sys-typescale-display-large-line-height, 4rem) + var(--md-sys-typescale-display-large-font, var(--md-ref-typeface-brand, Roboto)); + letter-spacing: var(--md-sys-typescale-display-large-tracking, -0.015625rem); + } + .md-typescale-display-large-prominent { + font: var(--md-sys-typescale-display-large-weight-prominent, var(--md-ref-typeface-weight-bold, 700)) + var(--md-sys-typescale-display-large-size, 3.5625rem) / var(--md-sys-typescale-display-large-line-height, 4rem) + var(--md-sys-typescale-display-large-font, var(--md-ref-typeface-brand, Roboto)); + letter-spacing: var(--md-sys-typescale-display-large-tracking, -0.015625rem); + } + + .md-typescale-display-medium { + font: var(--md-sys-typescale-display-medium-weight, var(--md-ref-typeface-weight-regular, 400)) + var(--md-sys-typescale-display-medium-size, 2.8125rem) / var(--md-sys-typescale-display-medium-line-height, 3.25rem) + var(--md-sys-typescale-display-medium-font, var(--md-ref-typeface-brand, Roboto)); + letter-spacing: var(--md-sys-typescale-display-medium-tracking, 0rem); + } + .md-typescale-display-medium-prominent { + font: var(--md-sys-typescale-display-medium-weight-prominent, var(--md-ref-typeface-weight-bold, 700)) + var(--md-sys-typescale-display-medium-size, 2.8125rem) / var(--md-sys-typescale-display-medium-line-height, 3.25rem) + var(--md-sys-typescale-display-medium-font, var(--md-ref-typeface-brand, Roboto)); + letter-spacing: var(--md-sys-typescale-display-medium-tracking, 0rem); + } + + .md-typescale-display-small { + font: var(--md-sys-typescale-display-small-weight, var(--md-ref-typeface-weight-regular, 400)) + var(--md-sys-typescale-display-small-size, 2.25rem) / var(--md-sys-typescale-display-small-line-height, 2.75rem) + var(--md-sys-typescale-display-small-font, var(--md-ref-typeface-brand, Roboto)); + letter-spacing: var(--md-sys-typescale-display-small-tracking, 0rem); + } + .md-typescale-display-small-prominent { + font: var(--md-sys-typescale-display-small-weight-prominent, var(--md-ref-typeface-weight-bold, 700)) + var(--md-sys-typescale-display-small-size, 2.25rem) / var(--md-sys-typescale-display-small-line-height, 2.75rem) + var(--md-sys-typescale-display-small-font, var(--md-ref-typeface-brand, Roboto)); + letter-spacing: var(--md-sys-typescale-display-small-tracking, 0rem); + } + + /* Headline Styles */ + .md-typescale-headline-large { + font: var(--md-sys-typescale-headline-large-weight, var(--md-ref-typeface-weight-regular, 400)) + var(--md-sys-typescale-headline-large-size, 2rem) / var(--md-sys-typescale-headline-large-line-height, 2.5rem) + var(--md-sys-typescale-headline-large-font, var(--md-ref-typeface-brand, Roboto)); + letter-spacing: var(--md-sys-typescale-headline-large-tracking, 0rem); + } + .md-typescale-headline-large-prominent { + font: var(--md-sys-typescale-headline-large-weight-prominent, var(--md-ref-typeface-weight-bold, 700)) + var(--md-sys-typescale-headline-large-size, 2rem) / var(--md-sys-typescale-headline-large-line-height, 2.5rem) + var(--md-sys-typescale-headline-large-font, var(--md-ref-typeface-brand, Roboto)); + letter-spacing: var(--md-sys-typescale-headline-large-tracking, 0rem); + } + + .md-typescale-headline-medium { + font: var(--md-sys-typescale-headline-medium-weight, var(--md-ref-typeface-weight-regular, 400)) + var(--md-sys-typescale-headline-medium-size, 1.75rem) / var(--md-sys-typescale-headline-medium-line-height, 2.25rem) + var(--md-sys-typescale-headline-medium-font, var(--md-ref-typeface-brand, Roboto)); + letter-spacing: var(--md-sys-typescale-headline-medium-tracking, 0rem); + } + .md-typescale-headline-medium-prominent { + font: var(--md-sys-typescale-headline-medium-weight-prominent, var(--md-ref-typeface-weight-bold, 700)) + var(--md-sys-typescale-headline-medium-size, 1.75rem) / var(--md-sys-typescale-headline-medium-line-height, 2.25rem) + var(--md-sys-typescale-headline-medium-font, var(--md-ref-typeface-brand, Roboto)); + letter-spacing: var(--md-sys-typescale-headline-medium-tracking, 0rem); + } + + .md-typescale-headline-small { + font: var(--md-sys-typescale-headline-small-weight, var(--md-ref-typeface-weight-regular, 400)) + var(--md-sys-typescale-headline-small-size, 1.5rem) / var(--md-sys-typescale-headline-small-line-height, 2rem) + var(--md-sys-typescale-headline-small-font, var(--md-ref-typeface-brand, Roboto)); + letter-spacing: var(--md-sys-typescale-headline-small-tracking, 0rem); + } + .md-typescale-headline-small-prominent { + font: var(--md-sys-typescale-headline-small-weight-prominent, var(--md-ref-typeface-weight-bold, 700)) + var(--md-sys-typescale-headline-small-size, 1.5rem) / var(--md-sys-typescale-headline-small-line-height, 2rem) + var(--md-sys-typescale-headline-small-font, var(--md-ref-typeface-brand, Roboto)); + letter-spacing: var(--md-sys-typescale-headline-small-tracking, 0rem); + } + + /* Title Styles */ + .md-typescale-title-large { + font: var(--md-sys-typescale-title-large-weight, var(--md-ref-typeface-weight-regular, 400)) + var(--md-sys-typescale-title-large-size, 1.375rem) / var(--md-sys-typescale-title-large-line-height, 1.75rem) + var(--md-sys-typescale-title-large-font, var(--md-ref-typeface-brand, Roboto)); + letter-spacing: var(--md-sys-typescale-title-large-tracking, 0rem); + } + .md-typescale-title-large-prominent { + font: var(--md-sys-typescale-title-large-weight-prominent, var(--md-ref-typeface-weight-bold, 700)) + var(--md-sys-typescale-title-large-size, 1.375rem) / var(--md-sys-typescale-title-large-line-height, 1.75rem) + var(--md-sys-typescale-title-large-font, var(--md-ref-typeface-brand, Roboto)); + letter-spacing: var(--md-sys-typescale-title-large-tracking, 0rem); + } + + .md-typescale-title-medium { + font: var(--md-sys-typescale-title-medium-weight, var(--md-ref-typeface-weight-medium, 500)) + var(--md-sys-typescale-title-medium-size, 1rem) / var(--md-sys-typescale-title-medium-line-height, 1.5rem) + var(--md-sys-typescale-title-medium-font, var(--md-ref-typeface-plain, Roboto)); + letter-spacing: var(--md-sys-typescale-title-medium-tracking, 0.009375rem); + } + .md-typescale-title-medium-prominent { + font: var(--md-sys-typescale-title-medium-weight-prominent, var(--md-ref-typeface-weight-bold, 700)) + var(--md-sys-typescale-title-medium-size, 1rem) / var(--md-sys-typescale-title-medium-line-height, 1.5rem) + var(--md-sys-typescale-title-medium-font, var(--md-ref-typeface-plain, Roboto)); + letter-spacing: var(--md-sys-typescale-title-medium-tracking, 0.009375rem); + } + + .md-typescale-title-small { + font: var(--md-sys-typescale-title-small-weight, var(--md-ref-typeface-weight-medium, 500)) + var(--md-sys-typescale-title-small-size, 0.875rem) / var(--md-sys-typescale-title-small-line-height, 1.25rem) + var(--md-sys-typescale-title-small-font, var(--md-ref-typeface-plain, Roboto)); + letter-spacing: var(--md-sys-typescale-title-small-tracking, 0.00625rem); + } + .md-typescale-title-small-prominent { + font: var(--md-sys-typescale-title-small-weight-prominent, var(--md-ref-typeface-weight-bold, 700)) + var(--md-sys-typescale-title-small-size, 0.875rem) / var(--md-sys-typescale-title-small-line-height, 1.25rem) + var(--md-sys-typescale-title-small-font, var(--md-ref-typeface-plain, Roboto)); + letter-spacing: var(--md-sys-typescale-title-small-tracking, 0.00625rem); + } + + /* Body Styles */ + .md-typescale-body-large { + font: var(--md-sys-typescale-body-large-weight, var(--md-ref-typeface-weight-regular, 400)) + var(--md-sys-typescale-body-large-size, 1rem) / var(--md-sys-typescale-body-large-line-height, 1.5rem) + var(--md-sys-typescale-body-large-font, var(--md-ref-typeface-plain, Roboto)); + letter-spacing: var(--md-sys-typescale-body-large-tracking, 0.03125rem); + } + .md-typescale-body-large-prominent { + font: var(--md-sys-typescale-body-large-weight-prominent, var(--md-ref-typeface-weight-bold, 700)) + var(--md-sys-typescale-body-large-size, 1rem) / var(--md-sys-typescale-body-large-line-height, 1.5rem) + var(--md-sys-typescale-body-large-font, var(--md-ref-typeface-plain, Roboto)); + letter-spacing: var(--md-sys-typescale-body-large-tracking, 0.03125rem); + } + + .md-typescale-body-medium { + font: var(--md-sys-typescale-body-medium-weight, var(--md-ref-typeface-weight-regular, 400)) + var(--md-sys-typescale-body-medium-size, 0.875rem) / var(--md-sys-typescale-body-medium-line-height, 1.25rem) + var(--md-sys-typescale-body-medium-font, var(--md-ref-typeface-plain, Roboto)); + letter-spacing: var(--md-sys-typescale-body-medium-tracking, 0.015625rem); + } + .md-typescale-body-medium-prominent { + font: var(--md-sys-typescale-body-medium-weight-prominent, var(--md-ref-typeface-weight-bold, 700)) + var(--md-sys-typescale-body-medium-size, 0.875rem) / var(--md-sys-typescale-body-medium-line-height, 1.25rem) + var(--md-sys-typescale-body-medium-font, var(--md-ref-typeface-plain, Roboto)); + letter-spacing: var(--md-sys-typescale-body-medium-tracking, 0.015625rem); + } + + .md-typescale-body-small { + font: var(--md-sys-typescale-body-small-weight, var(--md-ref-typeface-weight-regular, 400)) + var(--md-sys-typescale-body-small-size, 0.75rem) / var(--md-sys-typescale-body-small-line-height, 1rem) + var(--md-sys-typescale-body-small-font, var(--md-ref-typeface-plain, Roboto)); + letter-spacing: var(--md-sys-typescale-body-small-tracking, 0.025rem); + } + .md-typescale-body-small-prominent { + font: var(--md-sys-typescale-body-small-weight-prominent, var(--md-ref-typeface-weight-bold, 700)) + var(--md-sys-typescale-body-small-size, 0.75rem) / var(--md-sys-typescale-body-small-line-height, 1rem) + var(--md-sys-typescale-body-small-font, var(--md-ref-typeface-plain, Roboto)); + letter-spacing: var(--md-sys-typescale-body-small-tracking, 0.025rem); + } + + /* Label Styles */ + .md-typescale-label-large { + font: var(--md-sys-typescale-label-large-weight, var(--md-ref-typeface-weight-medium, 500)) + var(--md-sys-typescale-label-large-size, 0.875rem) / var(--md-sys-typescale-label-large-line-height, 1.25rem) + var(--md-sys-typescale-label-large-font, var(--md-ref-typeface-plain, Roboto)); + letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.00625rem); + } + .md-typescale-label-large-prominent { + font: var(--md-sys-typescale-label-large-weight-prominent, var(--md-ref-typeface-weight-bold, 700)) + var(--md-sys-typescale-label-large-size, 0.875rem) / var(--md-sys-typescale-label-large-line-height, 1.25rem) + var(--md-sys-typescale-label-large-font, var(--md-ref-typeface-plain, Roboto)); + letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.00625rem); + } + + .md-typescale-label-medium { + font: var(--md-sys-typescale-label-medium-weight, var(--md-ref-typeface-weight-medium, 500)) + var(--md-sys-typescale-label-medium-size, 0.75rem) / var(--md-sys-typescale-label-medium-line-height, 1rem) + var(--md-sys-typescale-label-medium-font, var(--md-ref-typeface-plain, Roboto)); + letter-spacing: var(--md-sys-typescale-label-medium-tracking, 0.03125rem); + } + .md-typescale-label-medium-prominent { + font: var(--md-sys-typescale-label-medium-weight-prominent, var(--md-ref-typeface-weight-bold, 700)) + var(--md-sys-typescale-label-medium-size, 0.75rem) / var(--md-sys-typescale-label-medium-line-height, 1rem) + var(--md-sys-typescale-label-medium-font, var(--md-ref-typeface-plain, Roboto)); + letter-spacing: var(--md-sys-typescale-label-medium-tracking, 0.03125rem); + } + + .md-typescale-label-small { + font: var(--md-sys-typescale-label-small-weight, var(--md-ref-typeface-weight-medium, 500)) + var(--md-sys-typescale-label-small-size, 0.6875rem) / var(--md-sys-typescale-label-small-line-height, 1rem) + var(--md-sys-typescale-label-small-font, var(--md-ref-typeface-plain, Roboto)); + letter-spacing: var(--md-sys-typescale-label-small-tracking, 0.03125rem); + } + .md-typescale-label-small-prominent { + font: var(--md-sys-typescale-label-small-weight-prominent, var(--md-ref-typeface-weight-bold, 700)) + var(--md-sys-typescale-label-small-size, 0.6875rem) / var(--md-sys-typescale-label-small-line-height, 1rem) + var(--md-sys-typescale-label-small-font, var(--md-ref-typeface-plain, Roboto)); + letter-spacing: var(--md-sys-typescale-label-small-tracking, 0.03125rem); + } +} +` + +/** + * Complete raw CSS stylesheet as a string, with variables defined on :root. + */ +export const typescaleCss = ` +:root { +${cssVariablesString} +} +${classesCssString} +` + +export const typescaleStyles = css` + :host { + --md-ref-typeface-brand: Roboto; + --md-ref-typeface-plain: Roboto; + --md-ref-typeface-weight-regular: 400; + --md-ref-typeface-weight-medium: 500; + --md-ref-typeface-weight-bold: 700; + } + ${unsafeCSS(classesCssString)} +` + +// Default styles export matches Lit CSSResult structure +export const styles = typescaleStyles + +/** + * Helper to dynamically inject CSS styles into the document. + * Useful for projects not using Lit. + * @param {HTMLElement} target - Destination head or body element (defaults to document.head). + */ +export function injectTypescaleStyles(target = null) { + if (typeof document === 'undefined') return + const activeTarget = target || document.head + const existing = document.getElementById('md-typescale-styles') + if (existing) return + + const style = document.createElement('style') + style.id = 'md-typescale-styles' + style.textContent = typescaleCss + activeTarget.appendChild(style) +} From e8f1b36f335aca88a72eeeff0d829737e964dcaa Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Mon, 6 Jul 2026 22:32:53 -0700 Subject: [PATCH 12/13] Update indicators/loading.js Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- indicators/loading.js | 1 + 1 file changed, 1 insertion(+) diff --git a/indicators/loading.js b/indicators/loading.js index 88c4930..887f0e3 100644 --- a/indicators/loading.js +++ b/indicators/loading.js @@ -357,6 +357,7 @@ export class Loading extends LitElement { } startAnimation() { + if (this.paused) return if (this.rafId) return const canvas = this.renderRoot?.querySelector('#canvas') if (!canvas) return From 2a918521324489c971491b27f94bf232e9a0f2a3 Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Mon, 6 Jul 2026 22:56:09 -0700 Subject: [PATCH 13/13] fix: address PR feedback on md-loading and md-progress indicators --- indicators/loading.js | 31 +++++++++++++++++++++---------- indicators/progress.js | 11 ++++++++++- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/indicators/loading.js b/indicators/loading.js index 887f0e3..82b0a69 100644 --- a/indicators/loading.js +++ b/indicators/loading.js @@ -227,7 +227,7 @@ class M3Animator { const rawDt = Math.min((ts - this.lastTs) / 1e3, 0.1) const dt = rawDt * this.speed this.lastTs = ts - if (dt <= 0) return + if (isNaN(dt) || !isFinite(dt) || dt <= 0) return this.elapsed += dt * 1e3 const cycle = Math.floor(this.elapsed / DURATION_PER_SHAPE_MS) if (cycle > this.prevCycle) { @@ -294,7 +294,9 @@ function setupCanvas(canvas, cssSize) { canvas.style.width = `${cssSize}px` canvas.style.height = `${cssSize}px` const ctx = canvas.getContext("2d") - ctx.scale(dpr, dpr) + if (ctx) { + ctx.scale(dpr, dpr) + } return ctx } @@ -421,31 +423,40 @@ export class Loading extends LitElement { if (changedProperties.has('color') || changedProperties.has('containerColor') || changedProperties.has('contained')) { this.updateResolvedColors() } + if (changedProperties.has('paused')) { + if (this.paused) { + this.stopAnimation() + } else { + this.startAnimation() + } + } } - _resolveColor(colorString) { + _resolveColor(colorString, depth = 0) { + if (depth > 5) return '#6750a4' if (!colorString) return '#6750a4' const trimmed = colorString.trim() + const windowExists = typeof window !== 'undefined' if (trimmed === 'currentColor') { - return window.getComputedStyle(this).color || '#6750a4' + return (windowExists ? window.getComputedStyle(this).color : '') || '#6750a4' } if (trimmed.startsWith('var(') && trimmed.endsWith(')')) { const content = trimmed.substring(4, trimmed.length - 1).trim() const commaIdx = content.indexOf(',') if (commaIdx === -1) { const varName = content.trim() - const val = window.getComputedStyle(this).getPropertyValue(varName).trim() - return val ? this._resolveColor(val) : '#6750a4' + const val = windowExists ? window.getComputedStyle(this).getPropertyValue(varName).trim() : '' + return val ? this._resolveColor(val, depth + 1) : '#6750a4' } else { const varName = content.substring(0, commaIdx).trim() const fallback = content.substring(commaIdx + 1).trim() - const val = window.getComputedStyle(this).getPropertyValue(varName).trim() - return val ? this._resolveColor(val) : this._resolveColor(fallback) + const val = windowExists ? window.getComputedStyle(this).getPropertyValue(varName).trim() : '' + return val ? this._resolveColor(val, depth + 1) : this._resolveColor(fallback, depth + 1) } } if (trimmed.startsWith('--')) { - const val = window.getComputedStyle(this).getPropertyValue(trimmed).trim() - return val ? this._resolveColor(val) : '#6750a4' + const val = windowExists ? window.getComputedStyle(this).getPropertyValue(trimmed).trim() : '' + return val ? this._resolveColor(val, depth + 1) : '#6750a4' } return trimmed } diff --git a/indicators/progress.js b/indicators/progress.js index ff698ee..955a77c 100644 --- a/indicators/progress.js +++ b/indicators/progress.js @@ -41,6 +41,7 @@ export class Progress extends LitElement { */ this.buffer = 0 this.shape = 'flat' + this.wavyCirclePath = null } firstUpdated() { @@ -49,6 +50,7 @@ export class Progress extends LitElement { updated(changedProperties) { if (changedProperties.has('shape') || changedProperties.has('type')) { + this.wavyCirclePath = null this.updateWavyAnimationState() } } @@ -73,7 +75,10 @@ export class Progress extends LitElement { const loop = (ts) => { if (!this.wavyAnimationActive) return this.wavyPhase += 0.08 - const path = this.renderRoot.querySelector('.active-track.wavy-circle') + if (!this.wavyCirclePath) { + this.wavyCirclePath = this.renderRoot.querySelector('.active-track.wavy-circle') + } + const path = this.wavyCirclePath if (path) { const isIndeterminateWavy = this.indeterminate && this.shape === 'wavy' const progress = isIndeterminateWavy ? 0.25 : (this.value / this.max) @@ -696,6 +701,10 @@ export class Progress extends LitElement { customElements.define('md-progress', Progress) function getWavyCirclePath(cx, cy, r, amplitude, frequency, phase, progress = 1.0) { + if (isNaN(progress) || !isFinite(progress)) { + progress = 0 + } + progress = Math.max(0, Math.min(1, progress)) const points = [] const steps = Math.max(10, Math.round(180 * progress)) for (let i = 0; i <= steps; i++) {