From 852b0edd2e9c00f0a8064f60ad94b8411bc74e70 Mon Sep 17 00:00:00 2001 From: Marin Popov Date: Thu, 19 Dec 2024 13:54:25 +0200 Subject: [PATCH] refactor(progressbar): Animate the value change using CSS counters and custom CSS props (#1492) --- src/components/progress/base.ts | 150 +++++----- .../progress/circular-progress.spec.ts | 259 +++++++++--------- src/components/progress/circular-progress.ts | 34 ++- .../progress/linear-progress.spec.ts | 222 ++++++++------- src/components/progress/linear-progress.ts | 22 +- .../progress/themes/animations.scss | 191 +++++++++---- .../circular/circular.progress.base.scss | 90 ++++-- .../shared/circular.progress.bootstrap.scss | 13 +- .../shared/circular.progress.common.scss | 31 +-- .../shared/circular.progress.fluent.scss | 23 +- .../shared/circular.progress.indigo.scss | 7 +- .../themes/linear/linear.progress.base.scss | 59 +++- .../shared/linear.progress.bootstrap.scss | 55 +--- .../linear/shared/linear.progress.common.scss | 111 ++------ .../linear/shared/linear.progress.fluent.scss | 36 +-- .../linear/shared/linear.progress.indigo.scss | 2 - src/components/progress/themes/vars.scss | 1 - src/styles/themes/base/_theme.scss | 13 + stories/circular-progress.stories.ts | 2 +- 19 files changed, 686 insertions(+), 635 deletions(-) diff --git a/src/components/progress/base.ts b/src/components/progress/base.ts index 6a5e5c0f0..5894c9007 100644 --- a/src/components/progress/base.ts +++ b/src/components/progress/base.ts @@ -5,26 +5,42 @@ import { queryAssignedElements, state, } from 'lit/decorators.js'; - +import type { StyleInfo } from 'lit/directives/style-map.js'; import { watch } from '../common/decorators/watch.js'; -import { asPercent, clamp, formatString } from '../common/util.js'; +import { + asPercent, + clamp, + formatString, + isEmpty, + partNameMap, +} from '../common/util.js'; import type { StyleVariant } from '../types.js'; export abstract class IgcProgressBaseComponent extends LitElement { - private __internals: ElementInternals; - private _ticker!: number; + private readonly __internals: ElementInternals; @queryAssignedElements() - protected assignedElements!: Array; + protected _assignedElements!: HTMLElement[]; + + @query('[part="base"]', true) + protected _base!: HTMLElement; - @query('[part~="fill"]', true) - protected progressIndicator!: Element; + @state() + protected _percentage = 0; + + @state() + protected _progress = 0; @state() - protected percentage = 0; + protected _hasFraction = false; @state() - protected progress = 0; + protected _styleInfo: StyleInfo = { + '--_progress-whole': '0.00', + '--_progress-integer': '0', + '--_progress-fraction': '0', + '--_transition-duration': '0ms', + }; /** * Maximum value of the control. @@ -78,40 +94,31 @@ export abstract class IgcProgressBaseComponent extends LitElement { @property({ attribute: 'label-format' }) public labelFormat!: string; - @watch('indeterminate', { waitUntilFirstUpdate: true }) + @watch('indeterminate') protected indeterminateChange() { - this.cancelAnimations(); - if (!this.indeterminate) { - this._setProgress(); - this.animateLabelTo(0, this.value); + this._updateProgress(); } } - @watch('max', { waitUntilFirstUpdate: true }) + @watch('max') protected maxChange() { this.max = Math.max(0, this.max); - if (this.value > this.max) { this.value = this.max; } - this._setProgress(); - if (!this.indeterminate) { - cancelAnimationFrame(this._ticker); - this.animateLabelTo(this.max, this.value); + this._updateProgress(); } } - @watch('value', { waitUntilFirstUpdate: true }) - protected valueChange(previous: number) { + @watch('value') + protected valueChange() { this.value = clamp(this.value, 0, this.max); - this._setProgress(); if (!this.indeterminate) { - cancelAnimationFrame(this._ticker); - this.animateLabelTo(previous, this.value); + this._updateProgress(); } } @@ -119,8 +126,11 @@ export abstract class IgcProgressBaseComponent extends LitElement { super(); this.__internals = this.attachInternals(); - this.__internals.role = 'progressbar'; - this.__internals.ariaValueMin = '0'; + Object.assign(this.__internals, { + role: 'progressbar', + ariaValueMin: '0', + ariaValueNow: '0', + }); } protected override createRenderRoot() { @@ -134,64 +144,38 @@ export abstract class IgcProgressBaseComponent extends LitElement { } private _updateARIA() { - const internals = this.__internals; - const text = this.labelFormat - ? this.renderLabelFormat() - : `${this.percentage}%`; - - internals.ariaValueMax = `${this.max}`; - internals.ariaValueNow = this.indeterminate ? null : `${this.value}`; - internals.ariaValueText = this.indeterminate ? null : text; - } + const text = this.labelFormat ? this.renderLabelFormat() : `${this.value}%`; - private _setProgress() { - this.progress = this.value / this.max; + Object.assign(this.__internals, { + ariaValueMax: this.max.toString(), + ariaValueNow: this.indeterminate ? null : this.value.toString(), + ariaValueText: this.indeterminate ? null : text, + }); } - public override async connectedCallback() { - super.connectedCallback(); + private _updateProgress() { + const percentage = asPercent(this.value, Math.max(1, this.max)); + const fractionValue = Math.round((percentage % 1) * 100); + this._hasFraction = fractionValue > 0; - await this.updateComplete; - if (!this.indeterminate) { - requestAnimationFrame(() => { - this._setProgress(); - this.animateLabelTo(0, this.value); - }); - } + this._styleInfo = { + '--_progress-whole': percentage.toFixed(2), + '--_progress-integer': Math.floor(percentage), + '--_progress-fraction': fractionValue, + '--_transition-duration': `${this.animationDuration}ms`, + }; } - protected cancelAnimations() { - cancelAnimationFrame(this._ticker); - this.progressIndicator?.getAnimations().forEach((animation) => { - if (animation instanceof CSSTransition) { - animation.cancel(); - } + protected renderLabel() { + const parts = partNameMap({ + label: true, + value: true, + fraction: this._hasFraction, }); - } - - protected animateLabelTo(start: number, end: number) { - let t0: number; - - const tick = (t1: number) => { - t0 = t0 ?? t1; - const delta = Math.min( - (t1 - t0) / Math.max(this.animationDuration, 1), - 1 - ); - - this.percentage = Math.floor( - asPercent(delta * (end - start) + start, this.max) - ); - - if (delta < 1) { - this._ticker = requestAnimationFrame(tick); - } else { - cancelAnimationFrame(this._ticker); - } - }; - - requestAnimationFrame(tick); + return this.labelFormat + ? html`${this.renderLabelFormat()}` + : html``; } protected renderLabelFormat() { @@ -199,18 +183,12 @@ export abstract class IgcProgressBaseComponent extends LitElement { } protected renderDefaultSlot() { - const hasNoLabel = - this.indeterminate || this.hideLabel || this.assignedElements.length; + const hideDefaultLabel = + this.indeterminate || this.hideLabel || !isEmpty(this._assignedElements); return html` - ${hasNoLabel - ? nothing - : html`${this.renderLabelText()}`} + ${hideDefaultLabel ? nothing : this.renderLabel()} `; } - - protected renderLabelText() { - return this.labelFormat ? this.renderLabelFormat() : `${this.percentage}%`; - } } diff --git a/src/components/progress/circular-progress.spec.ts b/src/components/progress/circular-progress.spec.ts index 8d037e046..1e8caf19f 100644 --- a/src/components/progress/circular-progress.spec.ts +++ b/src/components/progress/circular-progress.spec.ts @@ -5,82 +5,34 @@ import { html, nextFrame, } from '@open-wc/testing'; - import { defineComponents } from '../common/definitions/defineComponents.js'; -import { getAnimationsFor } from '../common/utils.spec.js'; +import { first } from '../common/util.js'; import IgcCircularGradientComponent from './circular-gradient.js'; import IgcCircularProgressComponent from './circular-progress.js'; -function createBasicProgress() { - return html``; -} - -function createNonAnimatingProgress() { - return html``; -} - -function createSlottedNonAnimatingProgress() { - return html` - Custom Label - `; -} - -function createSlottedGradientProgress() { - return html` - - - - - - `; -} - describe('Circular progress component', () => { let progress: IgcCircularProgressComponent; - const queryShadowRoot = (qs: string) => - progress.shadowRoot!.querySelector(qs); - - const getLabelPart = () => queryShadowRoot(`[part~='value']`); - const getIndeterminatePart = () => queryShadowRoot(`[part~='indeterminate']`); - const getSvgPart = () => queryShadowRoot(`[part~='svg']`); - const getLabelSlotNodes = () => - (queryShadowRoot(`slot[part='label']`) as HTMLSlotElement).assignedNodes({ - flatten: true, - }); - - const updateProgress = async ( + const updateProgress = async < + T extends keyof Omit, + >( prop: T, value: IgcCircularProgressComponent[T] ) => { Object.assign(progress, { [prop]: value }); await elementUpdated(progress); await nextFrame(); - await nextFrame(); }; - before(() => defineComponents(IgcCircularProgressComponent)); + before(() => { + defineComponents(IgcCircularProgressComponent); + }); describe('DOM', () => { beforeEach(async () => { - progress = await fixture( - createBasicProgress() - ); + progress = await fixture(html` + + `); }); it('is accessible', async () => { @@ -110,17 +62,17 @@ describe('Circular progress component', () => { describe('Attributes and Properties', () => { beforeEach(async () => { - progress = await fixture( - createNonAnimatingProgress() - ); + progress = await fixture(html` + + `); }); it('show/hides the default label based on hideLabel', async () => { await updateProgress('hideLabel', true); - expect(getLabelPart()).to.be.null; + expect(getDOM(progress).label).to.be.null; await updateProgress('hideLabel', false); - expect(getLabelPart()).not.to.be.null; + expect(getDOM(progress).label).to.exist; }); it('reflects variant attribute', async () => { @@ -141,21 +93,28 @@ describe('Circular progress component', () => { it('value is correctly reflected', async () => { await updateProgress('value', 50); - expect(getLabelPart()?.textContent).to.equal('50%'); + expect(getDOM(progress).integerLabel).to.equal('50'); + }); + + it('fractional values are correctly reflected', async () => { + await updateProgress('value', 3.14); + + expect(getDOM(progress).integerLabel).to.equal('3'); + expect(getDOM(progress).fractionLabel).to.equal('14'); }); it('clamps negative values', async () => { await updateProgress('value', -100); expect(progress.value).to.equal(0); - expect(getLabelPart()?.textContent).to.equal('0%'); + expect(getDOM(progress).integerLabel).to.equal('0'); }); it('clamps value larger than max', async () => { await updateProgress('value', 200); expect(progress.value).to.equal(100); - expect(getLabelPart()?.textContent).to.equal('100%'); + expect(getDOM(progress).integerLabel).to.equal('100'); }); it('clamps value to new max when new max is less than current value', async () => { @@ -163,7 +122,7 @@ describe('Circular progress component', () => { await updateProgress('max', 25); expect(progress.value).to.equal(25); - expect(getLabelPart()?.textContent).to.equal('100%'); + expect(getDOM(progress).integerLabel).to.equal('100'); }); it('does not change value when max is changed and new max is greater than value', async () => { @@ -171,23 +130,23 @@ describe('Circular progress component', () => { await updateProgress('max', 200); expect(progress.value).to.equal(100); - expect(getLabelPart()?.textContent).to.equal('50%'); + expect(getDOM(progress).integerLabel).to.equal('50'); }); it('correctly reflects indeterminate attribute', async () => { await updateProgress('indeterminate', true); - expect(getIndeterminatePart()).not.to.be.null; + expect(getDOM(progress).indeterminate).to.exist; await updateProgress('indeterminate', false); - expect(getIndeterminatePart()).to.be.null; + expect(getDOM(progress).indeterminate).to.be.null; }); it('hides the default label when in indeterminate mode', async () => { await updateProgress('indeterminate', true); - expect(getLabelPart()).to.be.null; + expect(getDOM(progress).label).to.be.null; await updateProgress('indeterminate', false); - expect(getLabelPart()).not.to.be.null; + expect(getDOM(progress).label).to.exist; }); it('reflects updates to value in indeterminate mode and then switching to determinate', async () => { @@ -196,7 +155,7 @@ describe('Circular progress component', () => { await updateProgress('indeterminate', false); expect(progress.value).to.equal(50); - expect(getLabelPart()?.textContent).to.equal('50%'); + expect(getDOM(progress).integerLabel).to.equal('50'); }); it('reflects updates to max in indeterminate mode and then switching to determinate', async () => { @@ -204,115 +163,143 @@ describe('Circular progress component', () => { await updateProgress('value', 100); await updateProgress('max', 80); await updateProgress('max', 100); - await updateProgress('indeterminate', false); expect(progress.value).to.equal(80); - expect(getLabelPart()?.textContent).to.equal('80%'); - }); - - it('switches animations when indeterminate <-> determinate', async () => { - await updateProgress('indeterminate', true); - - let animations = getAnimationsFor(getSvgPart()!); - - expect(animations).not.to.be.empty; - expect((animations[0] as CSSAnimation).animationName).to.equal( - 'rotate-center' - ); - - await updateProgress('indeterminate', false); - - animations = getAnimationsFor(getSvgPart()!); - expect(animations).to.be.empty; - }); - - it('switches indeterminate animation direction in rtl context', async () => { - progress.dir = 'rtl'; - await updateProgress('indeterminate', true); - - const animations = getAnimationsFor(getSvgPart()!); - - expect(animations).not.to.be.empty; - expect(getComputedStyle(getSvgPart()!).animationDirection).to.equal( - 'reverse' - ); + expect(getDOM(progress).integerLabel).to.equal('80'); }); it('applies custom label format', async () => { - expect(getLabelPart()?.textContent?.trim()).to.equal('0%'); + expect(getDOM(progress).label.textContent).to.be.empty; await updateProgress('labelFormat', 'Task {0} of {1} completed'); await updateProgress('value', 8); await updateProgress('max', 10); - expect(getLabelPart()?.textContent?.trim()).to.equal( + expect(getDOM(progress).customLabel.textContent).to.equal( 'Task 8 of 10 completed' ); }); }); - describe('Slots', () => { + describe('Rendering', () => { beforeEach(async () => { progress = await fixture( - createSlottedNonAnimatingProgress() + html`Custom Label` ); }); - it('default slot projection', async () => { - expect(getLabelSlotNodes()).not.to.be.empty; + it('renders default slot content', async () => { + const slot = getDOM(progress).slot; + + expect(slot).to.exist; + expect(first(slot.assignedNodes()).textContent).to.equal('Custom Label'); }); - it('hideLabel attribute does not affect slotted label', async () => { + it('`hideLabel` does not affect slotted label', async () => { await updateProgress('hideLabel', true); - expect(getLabelSlotNodes()).not.to.be.empty; + expect(first(getDOM(progress).slot.assignedNodes()).textContent).to.equal( + 'Custom Label' + ); }); - it('indeterminate attribute does not affect slotted label', async () => { + it('indeterminate does not affect slotted label', async () => { await updateProgress('indeterminate', true); - expect(getLabelSlotNodes()).not.to.be.empty; + expect(first(getDOM(progress).slot.assignedNodes()).textContent).to.equal( + 'Custom Label' + ); }); }); - describe('Gradients slot', () => { + describe('Gradients', () => { beforeEach(async () => { - progress = await fixture( - createSlottedGradientProgress() - ); + progress = await fixture(html` + + + + `); }); - it('reflects slotted gradient children', async () => { - const gradients = Array.from( - progress.querySelectorAll(IgcCircularGradientComponent.tagName) + it('renders slotted gradient correctly', async () => { + const gradients = progress.querySelectorAll( + IgcCircularGradientComponent.tagName ); - const stops = Array.from(queryShadowRoot('linearGradient')!.children); - const attrs: [string, keyof IgcCircularGradientComponent][] = [ - ['stop-color', 'color'], - ['offset', 'offset'], - ['stop-opacity', 'opacity'], - ]; - expect(stops).lengthOf(3); + expect(gradients).to.have.lengthOf(1); - for (const [idx, stop] of stops.entries()) { - for (const [attr, prop] of attrs) { - expect(stop).to.have.attribute(attr, `${gradients[idx][prop]}`); - } - } + const stop = first(getDOM(progress).gradients); + expect(stop).to.have.attribute('offset', '50%'); + expect(stop).to.have.attribute('stop-color', '#ff0079'); + expect(stop).to.have.attribute('stop-opacity', '0.8'); }); }); - describe('issue 1083', () => { - it('setting value on initializing should not reset it', async () => { + describe('Issues', () => { + it('#1083 - setting value on initialization should not reset it', async () => { progress = document.createElement(IgcCircularProgressComponent.tagName); progress.value = 88; - document.body.appendChild(progress); + document.body.append(progress); await elementUpdated(progress); expect(progress.value).to.equal(88); - progress.remove(); }); }); }); + +function getDOM(progress: IgcCircularProgressComponent) { + return { + get slot() { + return progress.renderRoot.querySelector( + 'slot:not([name])' + )!; + }, + get svg() { + return progress.renderRoot.querySelector('[part~="svg"]')!; + }, + get indeterminate() { + return progress.renderRoot.querySelector( + '[part~="indeterminate"]' + )!; + }, + get fill() { + return progress.renderRoot.querySelector('[part~="fill"]')!; + }, + get striped() { + return progress.renderRoot.querySelector( + '[part~="striped"]' + )!; + }, + get customLabel() { + return progress.renderRoot.querySelector( + '[part="label value"]' + )!; + }, + get label() { + return progress.renderRoot.querySelector( + '[part="label value counter"]' + )!; + }, + get integerLabel() { + return getComputedStyle( + progress.renderRoot.querySelector('[part~="counter"]')! + ).getPropertyValue('--_progress-integer'); + }, + get fractionLabel() { + return getComputedStyle( + progress.renderRoot.querySelector('[part~="fraction"]')! + ).getPropertyValue('--_progress-fraction'); + }, + get gradients() { + return Array.from( + progress.renderRoot.querySelectorAll('linearGradient stop')! + ); + }, + }; +} diff --git a/src/components/progress/circular-progress.ts b/src/components/progress/circular-progress.ts index 5785d7a2d..dbd315f0d 100644 --- a/src/components/progress/circular-progress.ts +++ b/src/components/progress/circular-progress.ts @@ -1,10 +1,9 @@ import { html, svg } from 'lit'; import { queryAssignedElements } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; - import { themes } from '../../theming/theming-decorator.js'; import { registerComponent } from '../common/definitions/register.js'; -import { createCounter, partNameMap } from '../common/util.js'; +import { createCounter, isEmpty, partNameMap } from '../common/util.js'; import { IgcProgressBaseComponent } from './base.js'; import IgcCircularGradientComponent from './circular-gradient.js'; import { styles } from './themes/circular/circular.progress.base.css.js'; @@ -52,17 +51,18 @@ export default class IgcCircularProgressComponent extends IgcProgressBaseCompone private _gradientId = `circular-progress-${IgcCircularProgressComponent.increment()}`; @queryAssignedElements({ slot: 'gradient' }) - private _assignedGradients!: Array; + private _assignedGradients!: IgcCircularGradientComponent[]; protected renderSvg() { - const parts = { indeterminate: this.indeterminate, track: true }; + const parts = partNameMap({ + indeterminate: this.indeterminate, + track: true, + }); const styles = { stroke: `url(#${this._gradientId})`, - '--percentage': `${this.progress}`, - '--duration': `${this.animationDuration}ms`, }; - const gradients = this._assignedGradients.length + const gradients = !isEmpty(this._assignedGradients) ? this._assignedGradients.map( ({ offset, color, opacity }) => svg`` @@ -73,7 +73,7 @@ export default class IgcCircularProgressComponent extends IgcProgressBaseCompone `; return svg` - + @@ -84,22 +84,20 @@ export default class IgcCircularProgressComponent extends IgcProgressBaseCompone `; } - protected renderWrapper() { - const parts = { + protected override render() { + const parts = partNameMap({ svg: true, indeterminate: this.indeterminate, - }; + }); return html` - ${this.renderSvg()} - - ${this.renderDefaultSlot()} +
+ ${this.renderSvg()} + + ${this.renderDefaultSlot()} +
`; } - - protected override render() { - return this.renderWrapper(); - } } declare global { diff --git a/src/components/progress/linear-progress.spec.ts b/src/components/progress/linear-progress.spec.ts index d694f4d6a..242668aea 100644 --- a/src/components/progress/linear-progress.spec.ts +++ b/src/components/progress/linear-progress.spec.ts @@ -5,60 +5,33 @@ import { html, nextFrame, } from '@open-wc/testing'; - import { defineComponents } from '../common/definitions/defineComponents.js'; -import { getAnimationsFor } from '../common/utils.spec.js'; +import { first } from '../common/util.js'; import IgcLinearProgressComponent from './linear-progress.js'; -function createBasicProgress() { - return html``; -} - -function createNonAnimatingProgress() { - return html``; -} - -function createSlottedNonAnimatingProgress() { - return html` - - Custom Label - - `; -} - describe('Linear progress component', () => { let progress: IgcLinearProgressComponent; - const queryShadowRoot = (qs: string) => - progress.shadowRoot!.querySelector(qs); - - const getLabelPart = () => queryShadowRoot(`[part~='value']`); - const getIndeterminatePart = () => queryShadowRoot(`[part~='indeterminate']`); - const getFillPart = () => queryShadowRoot(`[part~='fill']`); - const getLabelSlotNodes = () => - (queryShadowRoot(`slot[part='label']`) as HTMLSlotElement).assignedNodes({ - flatten: true, - }); - - const updateProgress = async ( + const updateProgress = async < + T extends keyof Omit, + >( prop: T, value: IgcLinearProgressComponent[T] ) => { Object.assign(progress, { [prop]: value }); await elementUpdated(progress); await nextFrame(); - await nextFrame(); }; - before(() => defineComponents(IgcLinearProgressComponent)); + before(() => { + defineComponents(IgcLinearProgressComponent); + }); describe('DOM', () => { beforeEach(async () => { - progress = await fixture( - createBasicProgress() - ); + progress = await fixture(html` + + `); }); it('is accessible', async () => { @@ -66,7 +39,7 @@ describe('Linear progress component', () => { await expect(progress).shadowDom.to.be.accessible(); }); - it('is initialized with sensible defaults', async () => { + it('has correct initial property values', () => { const defaultProps: Partial< Record > = { @@ -91,25 +64,28 @@ describe('Linear progress component', () => { describe('Attributes and Properties', () => { beforeEach(async () => { - progress = await fixture( - createNonAnimatingProgress() - ); + progress = await fixture(html` + + `); }); - it('show/hides the default label based on hideLabel', async () => { + it('toggles the default label based on `hideLabel`', async () => { await updateProgress('hideLabel', true); - expect(getLabelPart()).to.be.null; + expect(getDOM(progress).label).to.be.null; await updateProgress('hideLabel', false); - expect(getLabelPart()).not.to.be.null; + expect(getDOM(progress).label).to.exist; }); - it('reflects striped attribute', async () => { + it('reflects the striped attribute', async () => { await updateProgress('striped', true); - expect(queryShadowRoot(`[part~='striped']`)).not.to.be.null; + expect(getDOM(progress).striped).to.exist; + + await updateProgress('striped', false); + expect(getDOM(progress).striped).to.be.null; }); - it('reflects variant attribute', async () => { + it('reflects the variant attribute', async () => { const variants: IgcLinearProgressComponent['variant'][] = [ 'primary', 'success', @@ -120,28 +96,55 @@ describe('Linear progress component', () => { for (const variant of variants) { await updateProgress('variant', variant); - expect(queryShadowRoot(`[part~='${variant}']`)).not.to.be.null; + expect(progress).to.have.attribute('variant', variant); + } + }); + + it('updates label alignment', async () => { + const alignments: IgcLinearProgressComponent['labelAlign'][] = [ + 'top-start', + 'top', + 'top-end', + 'bottom-start', + 'bottom', + 'bottom-end', + ]; + + for (const alignment of alignments) { + await updateProgress('labelAlign', alignment); + expect(progress).to.have.attribute('label-align', alignment); } }); + it('reflects the progress fill based on value', async () => { + await updateProgress('value', 50); + expect(getDOM(progress).fill).to.exist; + }); + it('value is correctly reflected', async () => { await updateProgress('value', 50); + expect(getDOM(progress).integerLabel).to.equal('50'); + }); + + it('fractional values are correctly reflected', async () => { + await updateProgress('value', 3.14); - expect(getLabelPart()?.textContent).to.equal('50%'); + expect(getDOM(progress).integerLabel).to.equal('3'); + expect(getDOM(progress).fractionLabel).to.equal('14'); }); it('clamps negative values', async () => { await updateProgress('value', -100); expect(progress.value).to.equal(0); - expect(getLabelPart()?.textContent).to.equal('0%'); + expect(getDOM(progress).integerLabel).to.equal('0'); }); it('clamps value larger than max', async () => { await updateProgress('value', 200); expect(progress.value).to.equal(100); - expect(getLabelPart()?.textContent).to.equal('100%'); + expect(getDOM(progress).integerLabel).to.equal('100'); }); it('clamps value to new max when new max is less than current value', async () => { @@ -149,7 +152,7 @@ describe('Linear progress component', () => { await updateProgress('max', 25); expect(progress.value).to.equal(25); - expect(getLabelPart()?.textContent).to.equal('100%'); + expect(getDOM(progress).integerLabel).to.equal('100'); }); it('does not change value when max is changed and new max is greater than value', async () => { @@ -157,23 +160,23 @@ describe('Linear progress component', () => { await updateProgress('max', 200); expect(progress.value).to.equal(100); - expect(getLabelPart()?.textContent).to.equal('50%'); + expect(getDOM(progress).integerLabel).to.equal('50'); }); it('correctly reflects indeterminate attribute', async () => { await updateProgress('indeterminate', true); - expect(getIndeterminatePart()).not.to.be.null; + expect(getDOM(progress).indeterminate).to.exist; await updateProgress('indeterminate', false); - expect(getIndeterminatePart()).to.be.null; + expect(getDOM(progress).indeterminate).to.be.null; }); it('hides the default label when in indeterminate mode', async () => { await updateProgress('indeterminate', true); - expect(getLabelPart()).to.be.null; + expect(getDOM(progress).label).to.be.null; await updateProgress('indeterminate', false); - expect(getLabelPart()).not.to.be.null; + expect(getDOM(progress).label).to.exist; }); it('reflects updates to value in indeterminate mode and then switching to determinate', async () => { @@ -182,83 +185,102 @@ describe('Linear progress component', () => { await updateProgress('indeterminate', false); expect(progress.value).to.equal(50); - expect(getLabelPart()?.textContent).to.equal('50%'); - }); - - it('reflects updates to max in indeterminate mode and then switching to determinate', async () => { - await updateProgress('indeterminate', true); - await updateProgress('value', 100); - await updateProgress('max', 80); - await updateProgress('max', 100); - - await updateProgress('indeterminate', false); - - expect(progress.value).to.equal(80); - expect(getLabelPart()?.textContent).to.equal('80%'); - }); - - it('switches animations when indeterminate <-> determinate', async () => { - await updateProgress('indeterminate', true); - - let animations = getAnimationsFor(getFillPart()!); - - expect(animations).not.to.be.empty; - expect((animations[0] as CSSAnimation).animationName).to.equal( - 'indeterminate-primary' - ); - - await updateProgress('indeterminate', false); - - animations = getAnimationsFor(getFillPart()!); - expect(animations).to.be.empty; + expect(getDOM(progress).integerLabel).to.equal('50'); }); it('applies custom label format', async () => { - expect(getLabelPart()?.textContent?.trim()).to.equal('0%'); + expect(getDOM(progress).label.textContent).to.be.empty; await updateProgress('labelFormat', 'Task {0} of {1} completed'); await updateProgress('value', 8); await updateProgress('max', 10); - expect(getLabelPart()?.textContent?.trim()).to.equal( + expect(getDOM(progress).customLabel.textContent).to.equal( 'Task 8 of 10 completed' ); }); }); - describe('Slots', () => { + describe('Rendering', () => { beforeEach(async () => { progress = await fixture( - createSlottedNonAnimatingProgress() + html`Custom Label` ); }); - it('default slot projection', async () => { - expect(getLabelSlotNodes()).not.to.be.empty; + it('renders default slot content', async () => { + const slot = getDOM(progress).slot; + + expect(slot).to.exist; + expect(first(slot.assignedNodes()).textContent).to.equal('Custom Label'); }); - it('hideLabel attribute does not affect slotted label', async () => { + it('`hideLabel` does not affect slotted label', async () => { await updateProgress('hideLabel', true); - expect(getLabelSlotNodes()).not.to.be.empty; + expect(first(getDOM(progress).slot.assignedNodes()).textContent).to.equal( + 'Custom Label' + ); }); - it('indeterminate attribute does not affect slotted label', async () => { + it('indeterminate does not affect slotted label', async () => { await updateProgress('indeterminate', true); - expect(getLabelSlotNodes()).not.to.be.empty; + expect(first(getDOM(progress).slot.assignedNodes()).textContent).to.equal( + 'Custom Label' + ); }); }); - describe('issue 1083', () => { - it('setting value on initializing should not reset it', async () => { + describe('Issues', () => { + it('#1083 - setting value on initialization should not reset it', async () => { progress = document.createElement(IgcLinearProgressComponent.tagName); progress.value = 88; - document.body.appendChild(progress); + document.body.append(progress); await elementUpdated(progress); expect(progress.value).to.equal(88); - progress.remove(); }); }); }); + +function getDOM(progress: IgcLinearProgressComponent) { + return { + get slot() { + return progress.renderRoot.querySelector('slot')!; + }, + get indeterminate() { + return progress.renderRoot.querySelector( + '[part~="indeterminate"]' + )!; + }, + get fill() { + return progress.renderRoot.querySelector('[part~="fill"]')!; + }, + get striped() { + return progress.renderRoot.querySelector( + '[part~="striped"]' + )!; + }, + get customLabel() { + return progress.renderRoot.querySelector( + '[part="label value"]' + )!; + }, + get label() { + return progress.renderRoot.querySelector( + '[part="label value counter"]' + )!; + }, + get integerLabel() { + return getComputedStyle( + progress.renderRoot.querySelector('[part~="counter"]')! + ).getPropertyValue('--_progress-integer'); + }, + get fractionLabel() { + return getComputedStyle( + progress.renderRoot.querySelector('[part~="fraction"]')! + ).getPropertyValue('--_progress-fraction'); + }, + }; +} diff --git a/src/components/progress/linear-progress.ts b/src/components/progress/linear-progress.ts index 02a0f3fd5..79b46e3cd 100644 --- a/src/components/progress/linear-progress.ts +++ b/src/components/progress/linear-progress.ts @@ -1,7 +1,6 @@ import { html } from 'lit'; import { property } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; - import { themes } from '../../theming/theming-decorator.js'; import { registerComponent } from '../common/definitions/register.js'; import { partNameMap } from '../common/util.js'; @@ -65,24 +64,17 @@ export default class IgcLinearProgressComponent extends IgcProgressBaseComponent fill: true, striped: this.striped, indeterminate: this.indeterminate, - primary: this.variant === 'primary', - success: this.variant === 'success', - danger: this.variant === 'danger', - warning: this.variant === 'warning', - info: this.variant === 'info', + [this.variant]: true, }); - const animation = { - width: `${this.progress * 100}%`, - '--duration': `${this.animationDuration}ms`, - }; - return html` -
-
-
+
+
+
+
+
+ ${this.renderDefaultSlot()}
- ${this.renderDefaultSlot()} `; } } diff --git a/src/components/progress/themes/animations.scss b/src/components/progress/themes/animations.scss index db21dbaef..e7cff9d03 100644 --- a/src/components/progress/themes/animations.scss +++ b/src/components/progress/themes/animations.scss @@ -1,37 +1,46 @@ +@use 'sass:map'; @use 'styles/utilities' as *; @use 'vars' as *; -// Linear animation // -// primary translate -$indeterminate-primary-translate-step-2: cubic-bezier(.5, 0, .7017, .4958); -$indeterminate-primary-translate-step-3: cubic-bezier(.3024, .3813, .55, .9563); - -// primary scale -$indeterminate-primary-scale-step-2: cubic-bezier(.3347, .124, .7858, 1); -$indeterminate-primary-scale-step-3: cubic-bezier(.06, .11, .6, 1); - -// secondary translate -$indeterminate-secondary-translate-step-1: cubic-bezier(.15, 0, .515, .4096); -$indeterminate-secondary-translate-step-2: cubic-bezier(.31, .284, .8, .7337); -$indeterminate-secondary-translate-step-3: cubic-bezier(.4, .627, .6, .902); - -// secondary scale -$indeterminate-secondary-scale-step-1: cubic-bezier(.15, 0, .515, .4096); -$indeterminate-secondary-scale-step-2: cubic-bezier(.31, .284, .8, .7337); -$indeterminate-secondary-scale-step-3: cubic-bezier(.4, .627, .6, .902); +// Animation easing curves +$easing-curves: ( + // Primary translate easing curves + primary-translate-start: cubic-bezier(0.5, 0, 0.7017, 0.4958), + primary-translate-mid: cubic-bezier(0.3024, 0.3813, 0.55, 0.9563), + + // Primary scale easing curves + primary-scale-slow-start: cubic-bezier(0.3347, 0.124, 0.7858, 1), + primary-scale-quick-end: cubic-bezier(0.06, 0.11, 0.6, 1), + + // Secondary translate easing curves + secondary-translate-start: cubic-bezier(0.15, 0, 0.515, 0.4096), + secondary-translate-mid: cubic-bezier(0.31, 0.284, 0.8, 0.7337), + secondary-translate-end: cubic-bezier(0.4, 0.627, 0.6, 0.902), + + // Secondary scale easing curves + secondary-scale-slow-start: cubic-bezier(0.15, 0, 0.515, 0.4096), + secondary-scale-mid: cubic-bezier(0.31, 0.284, 0.8, 0.7337), + secondary-scale-smooth-end: cubic-bezier(0.4, 0.627, 0.6, 0.902) +); + +// Helper function to retrieve easing curves +@function timing-function($key) { + @return map.get($easing-curves, $key); +} +// Primary animation @keyframes indeterminate-primary { 0% { transform: translateX(0); } 20% { - animation-timing-function: $indeterminate-primary-translate-step-2; + animation-timing-function: timing-function('primary-translate-start'); transform: translateX(0); } 59.15% { - animation-timing-function: $indeterminate-primary-translate-step-3; + animation-timing-function: timing-function('primary-translate-mid'); transform: translateX(83.671%); } @@ -42,37 +51,38 @@ $indeterminate-secondary-scale-step-3: cubic-bezier(.4, .627, .6, .902); @keyframes indeterminate-primary-scale { 0% { - transform: scaleX(.08); + transform: scaleX(0.08); } 36.65% { - animation-timing-function: $indeterminate-primary-scale-step-2; - transform: scaleX(.08); + animation-timing-function: timing-function('primary-scale-slow-start'); + transform: scaleX(0.08); } 69.15% { - animation-timing-function: $indeterminate-primary-scale-step-2; - transform: scaleX(.6614); + animation-timing-function: timing-function('primary-scale-quick-end'); + transform: scaleX(0.6614); } 100% { - transform: scaleX(.08); + transform: scaleX(0.08); } } +// Secondary animation @keyframes indeterminate-secondary { 0% { - animation-timing-function: $indeterminate-secondary-translate-step-1; + animation-timing-function: timing-function('secondary-translate-start'); transform: translateX(0); } 25% { - animation-timing-function: $indeterminate-secondary-translate-step-2; + animation-timing-function: timing-function('secondary-translate-mid'); transform: translateX(37.6519%); } 48.35% { - animation-timing-function: $indeterminate-secondary-translate-step-3; + animation-timing-function: timing-function('secondary-translate-end'); transform: translateX(84.3861%); } @@ -83,26 +93,26 @@ $indeterminate-secondary-scale-step-3: cubic-bezier(.4, .627, .6, .902); @keyframes indeterminate-secondary-scale { 0% { - animation-timing-function: $indeterminate-secondary-scale-step-1; - transform: scaleX(.08); + animation-timing-function: timing-function('secondary-scale-slow-start'); + transform: scaleX(0.08); } 19.15% { - animation-timing-function: $indeterminate-secondary-scale-step-2; - transform: scaleX(.4571); + animation-timing-function: timing-function('secondary-scale-mid'); + transform: scaleX(0.4571); } 44.15% { - animation-timing-function: $indeterminate-secondary-scale-step-3; - transform: scaleX(.727); + animation-timing-function: timing-function('secondary-scale-smooth-end'); + transform: scaleX(0.727); } 100% { - transform: scaleX(.08); + transform: scaleX(0.08); } } -// Fluent linear animation +// Fluent linear animations @keyframes indeterminate-bar-fluent { 0% { transform: translateX(-100%); @@ -117,17 +127,17 @@ $indeterminate-secondary-scale-step-3: cubic-bezier(.4, .627, .6, .902); @keyframes indeterminate-bar-fluent-rtl { 0% { - transform: translateX(-310%); + transform: translateX(100%); transform-origin: right; } 100% { - transform: translateX(100%); + transform: translateX(-310%); transform-origin: left; } } -// Circular animation +// Circular animations @keyframes indeterminate-accordion { 0% { stroke-dashoffset: calc(#{$circumference} * 2); @@ -150,6 +160,66 @@ $indeterminate-secondary-scale-step-3: cubic-bezier(.4, .627, .6, .902); } } +// Fluent: Circular progress animation for indeterminate state. +// Dynamically changes stroke-dasharray and rotates for a smooth spinning effect. +@keyframes indeterminate-circular-fluent { + 0% { + // Start the stroke at the correct position by adjusting the dasharray and dashoffset + stroke-dasharray: calc(#{$circumference} * 0.0001), #{$circumference}; + stroke-dashoffset: calc(-1 * #{$circumference} / 4); + + // Start at 12 o'clock + transform: rotate(-90deg); + } + + 50% { + stroke-dasharray: calc(#{$circumference} / 2), calc(#{$circumference} / 2); + + // Adjust to keep starting point correct + stroke-dashoffset: calc(-1 * #{$circumference} / 4); + + // Continue rotating smoothly + transform: rotate(360deg); + } + + 100% { + stroke-dasharray: calc(#{$circumference} * 0.0001), #{$circumference}; + + // Reset properly + stroke-dashoffset: calc(-1 * #{$circumference} / 4); + + // Complete the full rotation + transform: rotate(990deg); + } +} + +@keyframes indeterminate-circular-fluent-rtl { + 0% { + stroke-dasharray: calc(#{$circumference} * 0.0001), #{$circumference}; + + // Positive offset for opposite direction + stroke-dashoffset: calc(#{$circumference} / 4); + transform: rotate(90deg); + } + + 50% { + stroke-dasharray: calc(#{$circumference} / 2), calc(#{$circumference} / 2); + + // Positive offset for opposite direction + stroke-dashoffset: calc(#{$circumference} / 4); + transform: rotate(-360deg); + } + + 100% { + stroke-dasharray: calc(#{$circumference} * 0.0001), #{$circumference}; + + // Positive offset for opposite direction + stroke-dashoffset: calc(#{$circumference} / 4); + transform: rotate(-990deg); + } +} + +// Generic animations @keyframes rotate-center { 0% { transform: rotate(0); @@ -160,27 +230,30 @@ $indeterminate-secondary-scale-step-3: cubic-bezier(.4, .627, .6, .902); } } -%host { - align-items: center; - justify-content: center; - font-family: var(--ig-font-family); +@keyframes initial-counter { + from { + --_progress-integer: 0; + --_progress-fraction: 0; + } } -%label { - display: flex; - align-items: center; - color: inherit; - font-weight: 600; +@keyframes initial-width { + from { + width: 0; + } + + to { + width: calc(var(--_progress-whole, 0) * 1%); + } } -@mixin stripe-colors($primary, $gray, $size, $deg: -45deg) { - /* stylelint-disable */ - background: $primary repeating-linear-gradient( - $deg, - $primary, - $primary $size, - $gray $size, - $gray ($size * 2) - ); - /* stylelint-enable */ +@keyframes initial-dashoffset { + from { + /* Start with no progress (0%) */ + stroke-dashoffset: #{$circumference}; + } + + to { + stroke-dashoffset: calc(#{$circumference} - var(--_progress-percentage) * #{$circumference}); + } } diff --git a/src/components/progress/themes/circular/circular.progress.base.scss b/src/components/progress/themes/circular/circular.progress.base.scss index 03774ae47..099649b9c 100644 --- a/src/components/progress/themes/circular/circular.progress.base.scss +++ b/src/components/progress/themes/circular/circular.progress.base.scss @@ -3,20 +3,24 @@ @use '../animations' as *; @use '../vars' as *; -:host { - @extend %host !optional; - +[part~='base'] { display: inline-flex; + align-items: center; + justify-content: center; + font-family: var(--ig-font-family), serif; position: relative; } [part~='label'] { - @extend %label !optional; - + display: flex; + align-items: center; + color: inherit; position: absolute; transform: translate(-50%, -50%); top: 50%; left: 50%; + line-height: normal; + font-weight: 600; } [part~='label'], @@ -25,20 +29,39 @@ font-weight: 600; } -[part='svg indeterminate'] { - transform-origin: 50% 50%; - animation: 3s linear 0s infinite $animation-direction none running rotate-center; +[part~='counter'] { + animation: initial-counter var(--_transition-duration) ease-in-out; + transition: + --_progress-integer var(--_transition-duration) ease-in-out, + --_progress-fraction var(--_transition-duration) ease-in-out; + counter-reset: + progress-integer var(--_progress-integer, 0) + progress-fraction var(--_progress-fraction, 0); + + &::before { + @include type-style('subtitle-2'); + } +} + +[part~='counter']:not([part~='fraction'])::before { + content: counter(progress-integer) '%'; } -[part='svg indeterminate'] [part~='fill'] { - stroke-dashoffset: calc(#{$circumference} * 2); - animation: indeterminate-accordion 1.5s cubic-bezier(0, .085, .68, .53) $animation-direction infinite; +[part~='counter'][part~='fraction']::before { + content: counter(progress-integer) '.' counter(progress-fraction, decimal-leading-zero) '%'; } [part~='svg'] { width: $diameter; height: $diameter; transform: rotate(-90deg); + transform-origin: center; + + &:not([part~='indeterminate']) [part~='fill'] { + animation: initial-dashoffset var(--_transition-duration) linear; + stroke-dasharray: #{$circumference} #{$circumference}; + stroke-dashoffset: calc(#{$circumference} - var(--_progress-percentage) * #{$circumference}); + } } [part~='track'], @@ -49,10 +72,6 @@ cx: calc(#{$diameter} / 2); cy: calc(#{$diameter} / 2); r: $radius; - - // Do not use % values inside transform origin - // This will break the component in Safari - // https://github.com/IgniteUI/igniteui-webcomponents/issues/377 transform-origin: center; } @@ -61,30 +80,47 @@ } [part~='fill'] { + --_progress-percentage: calc(var(--_progress-whole, 0) / 100); + stroke-dasharray: #{$circumference} #{$circumference}; - stroke-dashoffset: calc(#{$circumference} - var(--percentage) * #{$circumference}); - transition: stroke-dashoffset var(--duration) linear; - stroke-width: calc(var(--stroke-thickness) + rem(0.75px)); + stroke-dashoffset: calc(#{$circumference} - var(--_progress-whole, 0) * #{$circumference}); + transition: stroke-dashoffset var(--_transition-duration) linear; + stroke-width: calc(#{var(--stroke-thickness)} + rem(0.75px)); +} + +[part~='svg'][part~='indeterminate'] { + transform-origin: 50% 50%; + animation: 3s linear 0s infinite $animation-direction none running rotate-center; - --percentage: 0; - --duration: 500ms; + [part~='fill'] { + stroke-dashoffset: calc(#{$circumference} * 2); + animation: indeterminate-accordion 1.5s cubic-bezier(0, 0.085, 0.68, 0.53) $animation-direction infinite; + } } +:host(:dir(rtl)), :host([dir='rtl']) { - [part='svg indeterminate'] [part~='fill'] { + [part~='svg'][part~='indeterminate'] [part~='fill'] { animation-name: indeterminate-accordion-rtl; } [part~='indeterminate'] { animation-direction: $animation-direction-rtl; - } - // Valid only for circular progress bar - [part~='indeterminate'] [part~='track'] { - animation-direction: $animation-direction-rtl; + [part~='track'] { + animation-direction: $animation-direction-rtl; + } + + [part~='fill'] { + stroke-dashoffset: calc(#{$circumference} + var(--_progress-percentage) * #{$circumference}); + } } - [part~='fill'] { - stroke-dashoffset: calc(#{$circumference} + var(--percentage) * #{$circumference}); + [part~='svg'] { + &:not([part~='indeterminate']) [part~='fill'] { + animation: initial-dashoffset-rtl var(--_transition-duration) linear; + stroke-dasharray: #{$circumference} #{$circumference}; + stroke-dashoffset: calc(#{$circumference} + var(--_progress-percentage) * #{$circumference}); + } } } diff --git a/src/components/progress/themes/circular/shared/circular.progress.bootstrap.scss b/src/components/progress/themes/circular/shared/circular.progress.bootstrap.scss index 049b6697f..f5b2f19d2 100644 --- a/src/components/progress/themes/circular/shared/circular.progress.bootstrap.scss +++ b/src/components/progress/themes/circular/shared/circular.progress.bootstrap.scss @@ -4,6 +4,9 @@ $theme: $bootstrap; :host { + // Do not use rem values here + // This will break the component in Safari + // https://github.com/IgniteUI/igniteui-webcomponents/issues/377 --stroke-thickness: 2px; --scale-factor: 3.05; } @@ -13,13 +16,13 @@ $theme: $bootstrap; font-weight: 700; } -[part~='svg'][part~='indeterminate'] { +[part~='svg'][part~='indeterminate'] { animation-duration: .75s; -} -[part='svg indeterminate'] [part~='fill'] { - stroke-dashoffset: 60% !important; - animation: none; + [part~='fill'] { + stroke-dashoffset: 60% !important; + animation: none; + } } [part~='track'][part~='indeterminate'] { diff --git a/src/components/progress/themes/circular/shared/circular.progress.common.scss b/src/components/progress/themes/circular/shared/circular.progress.common.scss index 3568d1a84..9a3024349 100644 --- a/src/components/progress/themes/circular/shared/circular.progress.common.scss +++ b/src/components/progress/themes/circular/shared/circular.progress.common.scss @@ -26,27 +26,20 @@ $theme: $material; stroke: var-get($theme, 'base-circle-color'); } -[part~='gradient_start'], -[part~='gradient_end'] { - stop-color: var-get($theme, 'progress-circle-color'); -} - -:host([variant='danger']) [part~='gradient_start'], -:host([variant='danger']) [part~='gradient_end'] { - stop-color: color(error, 500); +// Mixin for Gradient Colors +@mixin gradient-variant($variant, $color) { + :host([variant='#{$variant}']) { + --gradient-stop-color: #{$color}; + } } -:host([variant='warning']) [part~='gradient_start'], -:host([variant='warning']) [part~='gradient_end'] { - stop-color: color(warn, 500); -} +@include gradient-variant('danger', color(error, 500)); +@include gradient-variant('warning', color(warn, 500)); +@include gradient-variant('info', color(info, 500)); +@include gradient-variant('success', color(success, 500)); -:host([variant='info']) [part~='gradient_start'], -:host([variant='info']) [part~='gradient_end'] { - stop-color: color(info, 500); +[part~='gradient_start'], +[part~='gradient_end'] { + stop-color: var(--gradient-stop-color, var-get($theme, 'progress-circle-color')); } -:host([variant='success']) [part~='gradient_start'], -:host([variant='success']) [part~='gradient_end'] { - stop-color: color(success, 500); -} diff --git a/src/components/progress/themes/circular/shared/circular.progress.fluent.scss b/src/components/progress/themes/circular/shared/circular.progress.fluent.scss index e84b92c6f..bbfeeaad3 100644 --- a/src/components/progress/themes/circular/shared/circular.progress.fluent.scss +++ b/src/components/progress/themes/circular/shared/circular.progress.fluent.scss @@ -1,11 +1,14 @@ @use 'styles/utilities' as *; @use '../light/themes' as *; +@use '../../animations' as *; +@use '../../vars' as *; $theme: $fluent; -// TODO If possible make the animation the same as the MS fluent - :host { + // Do not use rem values here + // This will break the component in Safari + // https://github.com/IgniteUI/igniteui-webcomponents/issues/377 --stroke-thickness: 2px; --scale-factor: 2.75; } @@ -18,3 +21,19 @@ $theme: $fluent; [part~='fill'] { stroke-width: var(--stroke-thickness); } + +[part~='svg'][part~='indeterminate'] { + animation: none; + + [part~='fill'] { + stroke-linecap: round; + animation: 2s linear 0s infinite normal none running indeterminate-circular-fluent + } +} + +:host(:dir(rtl)), +:host([dir='rtl']) { + [part~='svg'][part~='indeterminate'] [part~='fill'] { + animation-name: indeterminate-circular-fluent-rtl; + } +} diff --git a/src/components/progress/themes/circular/shared/circular.progress.indigo.scss b/src/components/progress/themes/circular/shared/circular.progress.indigo.scss index 086ab6851..a6ae22d82 100644 --- a/src/components/progress/themes/circular/shared/circular.progress.indigo.scss +++ b/src/components/progress/themes/circular/shared/circular.progress.indigo.scss @@ -4,10 +4,13 @@ $theme: $indigo; :host { - --stroke-thickness: 2px; + // Do not use rem values here + // This will break the component in Safari + // https://github.com/IgniteUI/igniteui-webcomponents/issues/377 + --stroke-thickness: 3px; --scale-factor: 3.4; } [part~='fill'] { - stroke-width: calc(var(--stroke-thickness) + rem(1px)); + stroke-width: var(--stroke-thickness); } diff --git a/src/components/progress/themes/linear/linear.progress.base.scss b/src/components/progress/themes/linear/linear.progress.base.scss index 0599c0d8d..7ff105078 100644 --- a/src/components/progress/themes/linear/linear.progress.base.scss +++ b/src/components/progress/themes/linear/linear.progress.base.scss @@ -1,21 +1,26 @@ +@use 'sass:math'; @use 'styles/common/component'; @use 'styles/utilities' as *; @use '../animations' as *; @use '../vars' as *; :host { - @extend %host !optional; + --track-size: #{rem(4px)}; + --linear-animation-duration: 2000ms; +} +[part~='base'] { display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + font-family: var(--ig-font-family), serif; position: relative; width: 100%; - flex-direction: column; flex: 1 1 100%; - - --track-size: #{rem(4px)}; - --linear-animation-duration: 2000ms; } +:host(:dir(rtl)[indeterminate]), :host([dir='rtl'][indeterminate]) { [part~='track'] { transform: rotateY(180deg); @@ -23,7 +28,10 @@ } [part~='label'] { - @extend %label !optional; + display: flex; + align-items: center; + color: inherit; + font-weight: 600; } [part~='track'] { @@ -35,9 +43,15 @@ } [part~='fill'] { - transition: width var(--duration) linear, background-color var(--duration) linear; + width: calc(var(--_progress-whole, 0) * 1%); + transition: width var(--_transition-duration) linear; position: relative; - height: inherit; + height: 100%; +} + +[part~='fill']:not([part~='indeterminate']) { + overflow: hidden; + animation: initial-width var(--_transition-duration) linear; } [part~='indeterminate'], @@ -83,9 +97,32 @@ @include type-style('subtitle-2'); } -[part~='striped'][part~='primary']:not([part~='indeterminate']) { - width: 100%; - height: 100%; +[part~='counter'] { + animation: initial-counter var(--_transition-duration) ease-in-out; + counter-reset: + progress-integer var(--_progress-integer, 0) + progress-fraction var(--_progress-fraction, 0); + transition: + --_progress-integer var(--_transition-duration) ease-in-out, + --_progress-fraction var(--_transition-duration) ease-in-out; + display: block; + + &::before { + // fix safari bug with label jumping + @include type-style('subtitle-2'); + } +} + +[part~='counter']:not([part~='fraction']) { + &::before { + content: counter(progress-integer) '%'; + } +} + +[part~='counter'][part~='fraction'] { + &::before { + content: counter(progress-integer) "." counter(progress-fraction, decimal-leading-zero) '%'; + } } // Label Positions diff --git a/src/components/progress/themes/linear/shared/linear.progress.bootstrap.scss b/src/components/progress/themes/linear/shared/linear.progress.bootstrap.scss index 8a0afd913..a4bf37fca 100644 --- a/src/components/progress/themes/linear/shared/linear.progress.bootstrap.scss +++ b/src/components/progress/themes/linear/shared/linear.progress.bootstrap.scss @@ -4,62 +4,11 @@ @use '../../animations' as *; $theme: $bootstrap; -$stripe-size: rem(5px); :host { --track-size: #{rem(16px)}; } -[part~='striped'][part~='primary']:not([part~='indeterminate']) { - @include stripe-colors(var-get($theme, 'fill-color-default'), var-get($theme, 'stripes-color'), $stripe-size, 45deg); -} - -[part~='striped'][part~='primary'] { - &::after { - @include stripe-colors(var-get($theme, 'fill-color-default'), var-get($theme, 'stripes-color'), $stripe-size, 45deg); - } -} - -[part~='danger']:not([part~='indeterminate']) { - background-color: var-get($theme, 'fill-color-default'); -} - -[part~='striped'][part~='danger']:not([part~='indeterminate']) { - @include stripe-colors(var-get($theme, 'fill-color-danger'), var-get($theme, 'stripes-color'), $stripe-size, 45deg); -} - -[part~='striped'][part~='danger'] { - &::after { - @include stripe-colors(var-get($theme, 'fill-color-danger'), var-get($theme, 'stripes-color'), $stripe-size, 45deg); - } -} - -[part~='striped'][part~='warning']:not([part~='indeterminate']) { - @include stripe-colors(var-get($theme, 'fill-color-warning'), var-get($theme, 'stripes-color'), $stripe-size, 45deg); -} - -[part~='striped'][part~='warning'] { - &::after { - @include stripe-colors(var-get($theme, 'fill-color-warning'), var-get($theme, 'stripes-color'), $stripe-size, 45deg); - } -} - -[part~='striped'][part~='info']:not([part~='indeterminate']) { - @include stripe-colors(var-get($theme, 'fill-color-info'), var-get($theme, 'stripes-color'), $stripe-size, 45deg); -} - -[part~='striped'][part~='info'] { - &::after { - @include stripe-colors(var-get($theme, 'fill-color-info'), var-get($theme, 'stripes-color'), $stripe-size, 45deg); - } -} - -[part~='striped'][part~='success']:not([part~='indeterminate']) { - @include stripe-colors(var-get($theme, 'fill-color-success'), var-get($theme, 'stripes-color'), $stripe-size, 45deg); -} - -[part~='striped'][part~='success'] { - &::after { - @include stripe-colors(var-get($theme, 'fill-color-success'), var-get($theme, 'stripes-color'), $stripe-size, 45deg); - } +:host([striped]) { + --stripe-size: #{rem(5px)}; } diff --git a/src/components/progress/themes/linear/shared/linear.progress.common.scss b/src/components/progress/themes/linear/shared/linear.progress.common.scss index 91638a1b1..ddc181fc9 100644 --- a/src/components/progress/themes/linear/shared/linear.progress.common.scss +++ b/src/components/progress/themes/linear/shared/linear.progress.common.scss @@ -14,102 +14,51 @@ $theme: $material; color: var-get($theme, 'text-color'); } -[part~='primary']:not([part~='indeterminate']) { - background-color: var-get($theme, 'fill-color-default'); +:host([striped]) { + --stripe-size: #{rem(16px)}; } -[part~='primary'] { - &::after { - background-color: var-get($theme, 'fill-color-default'); +@mixin part-styles($part, $color-key) { + :host([variant='#{$part}']) { + --fill-bg: #{var-get($theme, $color-key)}; } -} - -[part~='striped'][part~='primary']:not([part~='indeterminate']) { - @include stripe-colors(var-get($theme, 'fill-color-default'), var-get($theme, 'stripes-color'), $stripe-size, -45deg); -} -[part~='striped'][part~='primary'] { - &::after { - @include stripe-colors(var-get($theme, 'fill-color-default'), var-get($theme, 'stripes-color'), $stripe-size, -45deg); + :host([striped][variant='#{$part}']) { + --striped-bg: #{var-get($theme, $color-key) repeating-linear-gradient( + -45deg, + var-get($theme, $color-key), + var-get($theme, $color-key) var(--stripe-size), + var-get($theme, 'stripes-color') var(--stripe-size), + var-get($theme, 'stripes-color') calc(var(--stripe-size) * 2) + )}; } } -[part~='danger']:not([part~='indeterminate']) { - background-color: var-get($theme, 'fill-color-danger'); +// Generate styles for each variant +@each $part, $color-key in ( + 'primary': 'fill-color-default', + 'danger': 'fill-color-danger', + 'warning': 'fill-color-warning', + 'info': 'fill-color-info', + 'success': 'fill-color-success' +) { + @include part-styles($part, $color-key); } -[part~='danger'] { - &::after { - background-color: var-get($theme, 'fill-color-danger'); +:host(:not([indeterminate])) { + [part~='fill'] { + background-color: var(--fill-bg); } } -[part~='striped'][part~='danger']:not([part~='indeterminate']) { - @include stripe-colors(var-get($theme, 'fill-color-danger'), var-get($theme, 'stripes-color'), $stripe-size, -45deg); -} - -[part~='striped'][part~='danger'] { +[part~='fill'] { &::after { - @include stripe-colors(var-get($theme, 'fill-color-danger'), var-get($theme, 'stripes-color'), $stripe-size, -45deg); + background-color: var(--fill-bg); } } -[part~='warning']:not([part~='indeterminate']) { - background-color: var-get($theme, 'fill-color-warning'); -} - -[part~='warning'] { - &::after { - background-color: var-get($theme, 'fill-color-warning'); - } -} - -[part~='striped'][part~='warning']:not([part~='indeterminate']) { - @include stripe-colors(var-get($theme, 'fill-color-warning'), var-get($theme, 'stripes-color'), $stripe-size, -45deg); -} - -[part~='striped'][part~='warning'] { - &::after { - @include stripe-colors(var-get($theme, 'fill-color-warning'), var-get($theme, 'stripes-color'), $stripe-size, -45deg); - } -} - -[part~='info']:not([part~='indeterminate']) { - background-color: var-get($theme, 'fill-color-info'); -} - -[part~='info'] { - &::after { - background-color: var-get($theme, 'fill-color-info'); - } -} - -[part~='striped'][part~='info']:not([part~='indeterminate']) { - @include stripe-colors(var-get($theme, 'fill-color-info'), var-get($theme, 'stripes-color'), $stripe-size, -45deg); -} - -[part~='striped'][part~='info'] { - &::after { - @include stripe-colors(var-get($theme, 'fill-color-info'), var-get($theme, 'stripes-color'), $stripe-size, -45deg); - } -} - -[part~='success']:not([part~='indeterminate']) { - background-color: var-get($theme, 'fill-color-success'); -} - -[part~='success'] { - &::after { - background-color: var-get($theme, 'fill-color-success'); - } -} - -[part~='striped'][part~='success']:not([part~='indeterminate']) { - @include stripe-colors(var-get($theme, 'fill-color-success'), var-get($theme, 'stripes-color'), $stripe-size, -45deg); -} - -[part~='striped'][part~='success'] { - &::after { - @include stripe-colors(var-get($theme, 'fill-color-success'), var-get($theme, 'stripes-color'), $stripe-size, -45deg); +:host([striped]:not([indeterminate])) { + [part~='fill'] { + background: var(--striped-bg); } } diff --git a/src/components/progress/themes/linear/shared/linear.progress.fluent.scss b/src/components/progress/themes/linear/shared/linear.progress.fluent.scss index 52d1bb360..43899190e 100644 --- a/src/components/progress/themes/linear/shared/linear.progress.fluent.scss +++ b/src/components/progress/themes/linear/shared/linear.progress.fluent.scss @@ -8,10 +8,14 @@ $track-color: color(gray, 200); :host { --track-size: #{rem(2px)}; - --linear-animation-duration: 3000ms; } +:host(:dir(rtl)[indeterminate]), :host([dir='rtl']) { + [part~='track'] { + transform: rotateY(0deg); + } + [part~='indeterminate'] { animation-name: indeterminate-bar-fluent-rtl; } @@ -51,22 +55,20 @@ $track-color: color(gray, 200); display: none; } -[part~='primary'][part~='indeterminate'] { - background: linear-gradient(90deg, transparent 0%, var-get($theme, 'fill-color-default') 50%, transparent 100%); -} - -[part~='danger'][part~='indeterminate'] { - background: linear-gradient(90deg, transparent 0%, var-get($theme, 'fill-color-danger') 50%, transparent 100%); -} - -[part~='warning'][part~='indeterminate'] { - background: linear-gradient(90deg, transparent 0%, var-get($theme, 'fill-color-warning') 50%, transparent 100%); -} - -[part~='info'][part~='indeterminate'] { - background: linear-gradient(90deg, transparent 0%, var-get($theme, 'fill-color-info') 50%, transparent 100%); +// Mixin for Gradient Backgrounds +@mixin gradient-indeterminate($part, $color-key) { + [part~='#{$part}'][part~='indeterminate'] { + background: linear-gradient(90deg, transparent 0%, var-get($theme, $color-key) 50%, transparent 100%); + } } -[part~='success'][part~='indeterminate'] { - background: linear-gradient(90deg, transparent 0%, var-get($theme, 'fill-color-success') 50%, transparent 100%); +// Apply Gradient Backgrounds +@each $part, $color-key in ( + 'primary': 'fill-color-default', + 'danger': 'fill-color-danger', + 'warning': 'fill-color-warning', + 'info': 'fill-color-info', + 'success': 'fill-color-success' +) { + @include gradient-indeterminate($part, $color-key); } diff --git a/src/components/progress/themes/linear/shared/linear.progress.indigo.scss b/src/components/progress/themes/linear/shared/linear.progress.indigo.scss index 2ea8137c8..bf9180d45 100644 --- a/src/components/progress/themes/linear/shared/linear.progress.indigo.scss +++ b/src/components/progress/themes/linear/shared/linear.progress.indigo.scss @@ -24,7 +24,6 @@ $stripe-space: rem(14px); } [part~='striped']:not([part~='indeterminate']) { - /* stylelint-disable */ background: var-get($theme, 'fill-color-default') repeating-linear-gradient( 45deg, var-get($theme, 'fill-color-default'), @@ -32,5 +31,4 @@ $stripe-space: rem(14px); var-get($theme, 'stripes-color') $stripe-size, var-get($theme, 'stripes-color') $stripe-space ); - /* stylelint-enable */ } diff --git a/src/components/progress/themes/vars.scss b/src/components/progress/themes/vars.scss index d046dd195..04c213cff 100644 --- a/src/components/progress/themes/vars.scss +++ b/src/components/progress/themes/vars.scss @@ -1,7 +1,6 @@ @use 'styles/utilities' as *; $background-size: #{rem(40px)} #{rem(40px)}; -$stripe-size: rem(16px); $animation-direction: normal; $animation-direction-rtl: reverse; $diameter: calc(var(--circular-size) + var(--stroke-thickness)); diff --git a/src/styles/themes/base/_theme.scss b/src/styles/themes/base/_theme.scss index 7d812eaab..c749fcc13 100644 --- a/src/styles/themes/base/_theme.scss +++ b/src/styles/themes/base/_theme.scss @@ -2,6 +2,19 @@ @use 'igniteui-theming' as *; @mixin theme($palette, $elevations, $typeface, $type-scale, $variant) { + /* Two counters for integer and fractional parts */ + @property --_progress-integer { + syntax: ''; + initial-value: 0; + inherits: true; + } + + @property --_progress-fraction { + syntax: ''; + initial-value: 0; + inherits: true; + } + :root { --ig-theme: #{map.get($palette, '_meta', 'variant')}; --ig-theme-variant: #{$variant}; diff --git a/stories/circular-progress.stories.ts b/stories/circular-progress.stories.ts index 40ff5cf3f..2c6f9a0cc 100644 --- a/stories/circular-progress.stories.ts +++ b/stories/circular-progress.stories.ts @@ -137,7 +137,7 @@ const Template = ({ - ${value} + SVG