From 3adab6ae192489db5d5eb1dfd54552f69a0f0ad7 Mon Sep 17 00:00:00 2001 From: Material Web Team Date: Thu, 30 Mar 2023 15:28:59 -0700 Subject: [PATCH] feat(circular-progress): adds circular-progress element PiperOrigin-RevId: 520758465 --- README.md | 2 +- circularprogress/_circular-progress.scss | 6 + circularprogress/circular-progress.ts | 31 ++ circularprogress/circular-progress_test.ts | 15 + circularprogress/harness.ts | 14 + circularprogress/lib/_circular-progress.scss | 282 ++++++++++++++++++ .../lib/circular-progress-styles.scss | 8 + circularprogress/lib/circular-progress.ts | 91 ++++++ .../_md-comp-circular-progress-indicator.scss | 17 +- 9 files changed, 464 insertions(+), 2 deletions(-) create mode 100644 circularprogress/_circular-progress.scss create mode 100644 circularprogress/circular-progress.ts create mode 100644 circularprogress/circular-progress_test.ts create mode 100644 circularprogress/harness.ts create mode 100644 circularprogress/lib/_circular-progress.scss create mode 100644 circularprogress/lib/circular-progress-styles.scss create mode 100644 circularprogress/lib/circular-progress.ts diff --git a/README.md b/README.md index 7232e888c0..91156a65d2 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Field | ✅ | ✅ | 🟡 Icon | ✅ | ✅ | ❌ List | ✅ | 🟡 | 🟡 Menu | ✅ | 🟡 | 🟡 -Progress indicator (circular) | 🟡 | ❌ | ❌ +Progress indicator (circular) | ✅ | 🟡 | ❌ Progress indicator (linear) | 🟡 | ❌ | ❌ Radio button | ✅ | ✅ | ❌ Ripple | ✅ | ✅ | 🟡 diff --git a/circularprogress/_circular-progress.scss b/circularprogress/_circular-progress.scss new file mode 100644 index 0000000000..5d9f12b1b9 --- /dev/null +++ b/circularprogress/_circular-progress.scss @@ -0,0 +1,6 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@forward './lib/circular-progress' show theme; diff --git a/circularprogress/circular-progress.ts b/circularprogress/circular-progress.ts new file mode 100644 index 0000000000..d31bd6067f --- /dev/null +++ b/circularprogress/circular-progress.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {customElement} from 'lit/decorators.js'; + +import {CircularProgress} from './lib/circular-progress.js'; +import {styles} from './lib/circular-progress-styles.css.js'; + +declare global { + interface HTMLElementTagNameMap { + 'md-circular-progress': MdCircularProgress; + } +} + +/** + * @summary Circular progress indicators display progress by animating along an + * invisible circular track in a clockwise direction. They can be applied + * directly to a surface, such as a button or card. + * + * @description + * Progress indicators inform users about the status of ongoing processes. + * - Determinate indicators display how long a process will take. + * - Indeterminate indicators express an unspecified amount of wait time. + */ +@customElement('md-circular-progress') +export class MdCircularProgress extends CircularProgress { + static override styles = [styles]; +} diff --git a/circularprogress/circular-progress_test.ts b/circularprogress/circular-progress_test.ts new file mode 100644 index 0000000000..c1fd3c9732 --- /dev/null +++ b/circularprogress/circular-progress_test.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {createTokenTests} from '../testing/tokens.js'; + +import {MdCircularProgress} from './circular-progress.js'; + +describe('', () => { + describe('.styles', () => { + createTokenTests(MdCircularProgress.styles); + }); +}); diff --git a/circularprogress/harness.ts b/circularprogress/harness.ts new file mode 100644 index 0000000000..92f5341303 --- /dev/null +++ b/circularprogress/harness.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Harness} from '../testing/harness.js'; + +import {CircularProgress} from './lib/circular-progress.js'; + +/** + * Test harness for circular-progress. + */ +export class CircularProgressHarness extends Harness {} diff --git a/circularprogress/lib/_circular-progress.scss b/circularprogress/lib/_circular-progress.scss new file mode 100644 index 0000000000..a6895222c8 --- /dev/null +++ b/circularprogress/lib/_circular-progress.scss @@ -0,0 +1,282 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// go/keep-sorted start +@use '../../sass/theme'; +@use '../../tokens'; +// go/keep-sorted end + +@mixin theme($tokens) { + $reference: tokens.md-comp-circular-progress-indicator-values(); + $tokens: theme.validate-theme($reference, $tokens); + $tokens: theme.create-theme-vars($tokens, 'circular-progress'); + + @include theme.emit-theme-vars($tokens); +} + +@mixin styles() { + $tokens: tokens.md-comp-circular-progress-indicator-values(); + $tokens: theme.create-theme-vars($tokens, 'circular-progress'); + + $container-padding: 4px; + + // note, these value come from the m2 version but match current gm3 values. + // Constants: + // ARCSIZE = 270 degrees (amount of circle the arc takes up) + // ARCTIME = 1333ms (time it takes to expand and contract arc) + // ARCSTARTROT = 216 degrees (how much the start location of the arc + // should rotate each time, 216 gives us a + // 5 pointed star shape (it's 360/5 * 3). + // For a 7 pointed star, we might do + // 360/7 * 3 = 154.286) + // ARCTIME + $arc-duration: 1333ms; + // 4 * ARCTIME + $cycle-duration: calc(4 * $arc-duration); + // ARCTIME * 360 / (ARCSTARTROT + (360-ARCSIZE)) + $linear-rotate-duration: calc($arc-duration * 360 / 306); + + $indeterminate-easing: cubic-bezier(0.4, 0, 0.2, 1); + + :host { + @each $token, $value in $tokens { + --_#{$token}: #{$value}; + } + + display: inline-flex; + vertical-align: middle; + min-block-size: var(--_size); + min-inline-size: var(--_size); + position: relative; + align-items: center; + justify-content: center; + + // `contain` and `content-visibility` are performance optimizations + // important here because progress indicators are often used when a cpu + // intensive task is underway so it's especially important to minimize + // their cpu consumption. + contain: strict; + content-visibility: auto; + } + + .circular-progress { + flex: 1; + align-self: stretch; + margin: $container-padding; + } + + .circular-progress, + .spinner, + .left, + .right, + .circle, + svg, + .track, + .progress { + position: absolute; + inset: 0; + } + + svg { + transform: rotate(-90deg); + } + + circle { + cx: 50%; + cy: 50%; + r: calc(50% * (1 - var(--_active-indicator-width) / 100)); + // match size to indeterminate border width + stroke-width: calc(var(--_active-indicator-width) * 1%); + // note, pathLength is set so this can be normalized + stroke-dasharray: 100; + fill: transparent; + } + + .progress { + // note, these value come from the m2 version but match current gm3 values. + transition: stroke-dashoffset 500ms cubic-bezier(0, 0, 0.2, 1); + stroke: var(--_active-indicator-color); + } + + .track { + stroke: transparent; + } + + .circular-progress.indeterminate { + will-change: transform; + animation: linear infinite linear-rotate; + animation-duration: $linear-rotate-duration; + } + + .spinner { + will-change: transform; + animation: infinite both rotate-arc; + animation-duration: $cycle-duration; + animation-timing-function: $indeterminate-easing; + } + + .left { + overflow: hidden; + inset: 0 50% 0 0; + } + + .right { + overflow: hidden; + inset: 0 0 0 50%; + } + + .circle { + box-sizing: border-box; + border-radius: 50%; + // match size to svg stroke width, which is a fraction of the overall + // padding box width. + $_padding-box-width: calc(var(--_size) - 2 * $container-padding); + $_active-indicator-fraction: calc(var(--_active-indicator-width) / 100); + border: solid calc($_active-indicator-fraction * $_padding-box-width); + border-color: var(--_active-indicator-color) var(--_active-indicator-color) + transparent transparent; + will-change: transform; + animation: expand-arc; + animation-iteration-count: infinite; + animation-fill-mode: both; + animation-duration: $arc-duration, $cycle-duration; + animation-timing-function: $indeterminate-easing; + } + + .four-color .circle { + animation-name: expand-arc, four-color; + } + + .left .circle { + rotate: 135deg; + inset: 0 -100% 0 0; + } + .right .circle { + rotate: 100deg; + inset: 0 0 0 -100%; + animation-delay: calc(-0.5 * $arc-duration), 0ms; + } + + @media screen and (forced-colors: active) { + .progress { + stroke: CanvasText; + } + + .circle { + border-color: CanvasText CanvasText Canvas Canvas; + } + } + + // Indeterminate mode is 3 animations composed together: + // 1. expand-arc: an arc is expanded/contracted between 10deg and 270deg. + // 2. rotate-arc: at the same time, the arc is rotated in increments + // of 270deg. + // 3. linear-rotate: that rotating arc is then linearly rotated to produce + // a spinning expanding/contracting arc. + // + // See original implementation: + // https://github.com/PolymerElements/paper-spinner/blob/master/paper-spinner-styles.js. + + // 1. expand-arc + // This is used on 2 divs which each represent half the desired + // 270deg arc with one offset by 50%. This creates an arc which expands from + // 10deg to 270deg. + @keyframes expand-arc { + 0% { + transform: rotate(265deg); + } + 50% { + transform: rotate(130deg); + } + 100% { + transform: rotate(265deg); + } + } + + // 2. rotate-arc + // The arc seamlessly travels around the circle indefinitely so it needs to + // end at a full rotation of the circle. This rotates the 270 deg + // (270/360 = 3/4) arc 4x (4 * 3/4 = 3) so it ends at + // (3 * 360 = 1080). + // This is sub-divided into increments of 135deg since the arc is rendered + // with 2 divs acting together. + @keyframes rotate-arc { + 12.5% { + transform: rotate(135deg); + } + 25% { + transform: rotate(270deg); + } + 37.5% { + transform: rotate(405deg); + } + 50% { + transform: rotate(540deg); + } + 62.5% { + transform: rotate(675deg); + } + 75% { + transform: rotate(810deg); + } + 87.5% { + transform: rotate(945deg); + } + 100% { + transform: rotate(1080deg); + } + } + + // 3. linear-rotate + // The traveling expanding arc is linearly rotated to produce the spinner + // effect. + @keyframes linear-rotate { + to { + transform: rotate(360deg); + } + } + + // This animates between 4 colors which are each shown for 25% of the time. + // Each color is shown solid for 3/5 of that time (3/5 * 25% = 15%) and + // transitions to the next color for 2/5 of that time (2/5 * 25% = 10%). + @keyframes four-color { + 0% { + border-top-color: var(--_four-color-active-indicator-one-color); + border-right-color: var(--_four-color-active-indicator-one-color); + } + 15% { + border-top-color: var(--_four-color-active-indicator-one-color); + border-right-color: var(--_four-color-active-indicator-one-color); + } + 25% { + border-top-color: var(--_four-color-active-indicator-two-color); + border-right-color: var(--_four-color-active-indicator-two-color); + } + 40% { + border-top-color: var(--_four-color-active-indicator-two-color); + border-right-color: var(--_four-color-active-indicator-two-color); + } + 50% { + border-top-color: var(--_four-color-active-indicator-three-color); + border-right-color: var(--_four-color-active-indicator-three-color); + } + 65% { + border-top-color: var(--_four-color-active-indicator-three-color); + border-right-color: var(--_four-color-active-indicator-three-color); + } + 75% { + border-top-color: var(--_four-color-active-indicator-four-color); + border-right-color: var(--_four-color-active-indicator-four-color); + } + 90% { + border-top-color: var(--_four-color-active-indicator-four-color); + border-right-color: var(--_four-color-active-indicator-four-color); + } + 100% { + border-top-color: var(--_four-color-active-indicator-one-color); + border-right-color: var(--_four-color-active-indicator-one-color); + } + } +} diff --git a/circularprogress/lib/circular-progress-styles.scss b/circularprogress/lib/circular-progress-styles.scss new file mode 100644 index 0000000000..a1ab3f5a47 --- /dev/null +++ b/circularprogress/lib/circular-progress-styles.scss @@ -0,0 +1,8 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@use './circular-progress'; + +@include circular-progress.styles; diff --git a/circularprogress/lib/circular-progress.ts b/circularprogress/lib/circular-progress.ts new file mode 100644 index 0000000000..99faddd473 --- /dev/null +++ b/circularprogress/lib/circular-progress.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {html, LitElement, nothing, TemplateResult} from 'lit'; +import {property} from 'lit/decorators.js'; +import {classMap} from 'lit/directives/class-map.js'; + +import {ariaProperty} from '../../decorators/aria-property.js'; + +/** + * Circular Progress component. + */ +export class CircularProgress extends LitElement { + /** + * Progress to display, a fraction between 0 and 1. + */ + @property({type: Number}) progress = 0; + + + /** + * Whether or not to display an animated spinner representing indeterminate + * progress. + */ + @property({type: Boolean}) indeterminate = false; + + /** + * Whether or not to render indeterminate mode using 4 colors instead of one. + * + */ + @property({type: Boolean, attribute: 'four-color'}) fourColor = false; + + @property({type: String, attribute: 'data-aria-label', noAccessor: true}) + // tslint:disable-next-line:no-new-decorators + @ariaProperty + override ariaLabel!: string; + + protected override render(): TemplateResult { + const classes = { + 'indeterminate': this.indeterminate, + 'four-color': this.fourColor + }; + + return html` +
+ ${ + this.indeterminate ? this.renderIndeterminateContainer() : + this.renderDeterminateContainer()} +
+ `; + } + + // Determinate mode is rendered with an svg so the progress arc can be + // easily animated via stroke-dashoffset. + protected renderDeterminateContainer() { + const dashOffset = (1 - this.progress) * 100; + // note, dash-array/offset are relative to Setting `pathLength` but + // Chrome seems to render this inaccurately and using a large viewbox helps. + const pathLength = 100; + return html` + + + `; + } + + // Indeterminate mode rendered with 2 bordered-divs. The borders are + // clipped into half circles by their containers. The divs are then carefully + // animated to produce changes to the spinner arc size. + // This approach has 4.5x the FPS of rendering via svg on Chrome 111. + // See https://lit.dev/playground/#gist=febb773565272f75408ab06a0eb49746. + protected renderIndeterminateContainer() { + return html` +
+
+
+
+
+
+
+
`; + } +} \ No newline at end of file diff --git a/tokens/_md-comp-circular-progress-indicator.scss b/tokens/_md-comp-circular-progress-indicator.scss index d461533198..41ee68c33f 100644 --- a/tokens/_md-comp-circular-progress-indicator.scss +++ b/tokens/_md-comp-circular-progress-indicator.scss @@ -3,6 +3,9 @@ // SPDX-License-Identifier: Apache-2.0 // +// go/keep-sorted start +@use 'sass:map'; +// go/keep-sorted end // go/keep-sorted start @use './md-sys-color'; @use './md-sys-shape'; @@ -14,9 +17,21 @@ $_default: ( 'md-sys-shape': md-sys-shape.values(), ); +$_unsupported-tokens: ( + // must be circular + 'active-indicator-shape' +); + @function values($deps: $_default, $exclude-hardcoded-values: false) { - @return md-comp-circular-progress-indicator.values( + $tokens: md-comp-circular-progress-indicator.values( $deps, $exclude-hardcoded-values ); + $tokens: map.remove($tokens, $_unsupported-tokens...); + + // must be set as a raw % for compatibility between rendering border or svg. + // so the default value of 4px is converted to 8.33% by removing + // and re-adding the token. + $tokens: map.set($tokens, 'active-indicator-width', 8.33); + @return $tokens; }