Skip to content

[SPFx] Transform the Fluent UI ProgressIndicator component into a countdown timer

Info

As you know, the well-know Office UI Fabric front-end infrastructure has been renamed as Fluent UI ! (more info here)

Recently, I had to develop a certification exam with SPFx. One of the features was to provide a countdown within the questions. As I'm familiar with React and the Fluent UI components, I was wondering if it was possible to reuse the ProgressIndicator component and set it up in reverse mode.

So here we go !

Existing sample

First, let's have a look at the sample provided here.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import * as React from 'react';
import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator';
import { Async } from 'office-ui-fabric-react/lib/Utilities';

export interface IProgressIndicatorBasicExampleState {
  percentComplete: number;
}

const INTERVAL_DELAY = 100;
const INTERVAL_INCREMENT = 0.01;
const RESTART_WAIT_TIME = 2000;

export class ProgressIndicatorBasicExample extends React.Component<{}, IProgressIndicatorBasicExampleState> {
  private _interval: number;
  private _async: Async;

  constructor(props: {}) {
    super(props);

    this._async = new Async(this);

    this.state = {
      percentComplete: 0,
    };
    this._startProgressDemo = this._startProgressDemo.bind(this);
  }

  public componentDidMount(): void {
    this._startProgressDemo();
  }

  public componentWillUnmount(): void {
    this._async.dispose();
  }

  public render(): JSX.Element {
    const { percentComplete } = this.state;

    return (
      <ProgressIndicator
        label="Example title"
        description="Example description"
        percentComplete={percentComplete} />
    );
  }

  private _startProgressDemo(): void {
    // reset the demo
    this.setState({
      percentComplete: 0,
    });

    // update progress
    this._interval = this._async.setInterval(() => {
      let percentComplete = this.state.percentComplete + INTERVAL_INCREMENT;

      // once complete, set the demo to start again
      if (percentComplete >= 1.0) {
        percentComplete = 1.0;
        this._async.clearInterval(this._interval);
        this._async.setTimeout(this._startProgressDemo, RESTART_WAIT_TIME);
      }
      this.setState({
        percentComplete: percentComplete,
      });
    }, INTERVAL_DELAY);
  }
}

This code gives this behavior:

alt text

Countdown mode

Now, let's reverse the process. Let's say that you want your countdown set to 30 seconds. You can replace the const variable INTERVAL_INCREMENT (which won't be used anymore) by COUNTDOWN_DURATION. For this demo, the duration will be in milliseconds.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import * as React from 'react';
import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator';
import { Async } from 'office-ui-fabric-react/lib/Utilities';

export interface IProgressIndicatorBasicExampleState {
  percentComplete: number;
}

const INTERVAL_DELAY = 100;
const COUNTDOWN_DURATION = 30000;
const RESTART_WAIT_TIME = 2000;
//...

Then, you can set the percentComplete state to 1 in the constructor (remember that this variable is used as a parameter of the component, which goes from 0 to 1). So that the initial state of the Indicator is 1.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
//...
export class ProgressIndicatorBasicExample extends React.Component<{}, IProgressIndicatorBasicExampleState> {
  private _interval: number;
  private _async: Async;

  constructor(props: {}) {
    super(props);

    this._async = new Async(this);

    this.state = {
      percentComplete: 1, // --> initial state
    };
    this._startProgressDemo = this._startProgressDemo.bind(this);
  }
  //...
}

In the method called when the component is mounted, make the same update.

1
2
3
4
5
6
7
8
//...
 private _startProgressDemo(): void {
    // reset the demo
    this.setState({
      percentComplete: 1,
    });
//...
 }

Now comes the tricky part. When you want to properly decrease the progress indicator, you have to calculate the value to decrease from 1 (the initial state, remember ?). This depends on the delay of the countdown. You can add this variable as a private member of the component.

1
2
3
4
5
6
7
8
//...
export class ProgressIndicatorBasicExample extends React.Component<{}, IProgressIndicatorBasicExampleState> {
  private _interval: number;
  private _async: Async;
  // value = [initial state of ProgressIndicator] / [countdown duration in milliseconds] / [interval of refresh in milliseconds]
  private _intervalDecrement: number = 1 / COUNTDOWN_DURATION / INTERVAL_DELAY;
  //...
}

We want now to calculate the remaining time of the countdown, to refresh the progress indicator component.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
//...
this._interval = this._async.setInterval(() => {
    let percentComplete = this.state.percentComplete - this._intervalDecrement;

    // once 0 reached, set the demo to start again
    if (percentComplete <= 0) {
    this._async.clearInterval(this._interval);
    this._async.setTimeout(this._startProgressDemo, RESTART_WAIT_TIME);
    }

    this.setState({
    percentComplete: percentComplete,
    });
}, INTERVAL_DELAY)

Updated code

So here is the complete updated code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import * as React from 'react';
import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator';
import { Async } from 'office-ui-fabric-react/lib/Utilities';

export interface IProgressIndicatorBasicExampleState {
  percentComplete: number;
}

const INTERVAL_DELAY = 100;
const COUNTDOWN_DURATION = 30000;
const RESTART_WAIT_TIME = 2000;

export class ProgressIndicatorBasicExample extends React.Component<{}, IProgressIndicatorBasicExampleState> {
  private _interval: number;
  private _async: Async;
  // value = [initial state of ProgressIndicator] / ([countdown duration in milliseconds] / [interval of refresh in milliseconds])
  private _intervalDecrement: number = 1 / (COUNTDOWN_DURATION / INTERVAL_DELAY);

  constructor(props: {}) {
    super(props);

    this._async = new Async(this);

    this.state = {
      percentComplete: 1, // --> initial state
    };
    this._startProgressDemo = this._startProgressDemo.bind(this);
  }

  public componentDidMount(): void {
    this._startProgressDemo();
  }

  public componentWillUnmount(): void {
    this._async.dispose();
  }

  public render(): JSX.Element {
    const { percentComplete } = this.state;

    return (
      <ProgressIndicator
        label="Example title"
        description="Example description"
        percentComplete={percentComplete} />
    );
  }

  private _startProgressDemo(): void {
    // reset the demo
    this.setState({
      percentComplete: 1,
    });

    this._interval = this._async.setInterval(() => {
      let percentComplete = this.state.percentComplete - this._intervalDecrement;

      // once 0 reached, set the demo to start again
      if (percentComplete <= 0) {
        this._async.clearInterval(this._interval);
        this._async.setTimeout(this._startProgressDemo, RESTART_WAIT_TIME);
      }

      this.setState({
        percentComplete: percentComplete,
      });
    }, INTERVAL_DELAY)
  }
}

And that's it! You can find the Codepen example here.

Furthermore

If you want this control to be reset (for example from a question to another in a session exam), you can turn it into a fully uncontrolled component using a key!

You can also add some text to display the remaining time like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
//...
constructor(props) {
    super(props);

    this._async = new Async(this);

    this.state = {
      percentComplete: 1,
      timeLeft: COUNTDOWN_DURATION, // --> init timeleft to 30 seconds
    };
  }
//...
1
2
3
4
5
6
7
8
9
public render(): JSX.Element {
    const { percentComplete } = this.state;

    return (
        <ProgressIndicator
            label={`Remaining time : ${this.state.timeLeft > 0 ? Math.floor(this.state.timeLeft / 1000) : 0} seconds`}
            percentComplete={percentComplete} />
    );
  }
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//...
private _startProgressDemo(): void {
    // reset the demo
    this.setState({
      percentComplete: 1,
      timeLeft: COUNTDOWN_DURATION,
    });

    this._interval = this._async.setInterval(() => {
      let percentComplete = this.state.percentComplete - this._intervalDecrement;
      let remainingTime = this.state.timeLeft - INTERVAL_DELAY; // --> update the remaining time

      // once 0 reached, set the demo to start again
      if (percentComplete <= 0) {
        this._async.clearInterval(this._interval);
        this._async.setTimeout(this._startProgressDemo, RESTART_WAIT_TIME);
      }

      this.setState({
        percentComplete: percentComplete,
        timeLeft: remainingTime,
      });
    }, INTERVAL_DELAY)
}

Here's the result:

alt text

You could change the color of the progress indicator component regarding the remaining time and so on... Use your imagination 😉

Happy coding!

(Thanks to Charlie B for its help)