Skip to content

[SPFx] Transform the Fluent UI Breadcrumb component into a step indicator

As I was developing an SPA with a business workflow, I was wondering how to show customers the progression of their requests and how many steps left before the end.

So I've been thinking about customizing the Breadcrumb component which is quite flexible to covert my needs.

Existing sample

Below one of the examples provided in the component page:

 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
import * as React from 'react';
import { Breadcrumb, IBreadcrumbItem, IDividerAsProps } from 'office-ui-fabric-react/lib/Breadcrumb';
import { Label, ILabelStyles } from 'office-ui-fabric-react/lib/Label';
import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip';
import { Icon } from 'office-ui-fabric-react/lib/Icon';

const labelStyles: Partial<ILabelStyles> = {
  root: { margin: '10px 0', selectors: { '&:not(:first-child)': { marginTop: 24 } } },
};

const itemsWithHeading: IBreadcrumbItem[] = [
  { text: 'Files', key: 'Files', onClick: _onBreadcrumbItemClicked },
  { text: 'Folder 1', key: 'd1', onClick: _onBreadcrumbItemClicked },
  // Generally, only the last item should ever be a heading.
  // It would typically be h1 or h2, but we're using h4 here to better fit the structure of the page.
  { text: 'Folder 2', key: 'd2', isCurrentItem: true, as: 'h4' },
];

export const BreadcrumbBasicExample: React.FunctionComponent = () => {
  return (
    <div>
      // Other examples

      <Label styles={labelStyles}>With custom rendered divider and overflow icon</Label>
      <Breadcrumb
        items={itemsWithHeading}
        maxDisplayedItems={3}
        ariaLabel="With custom rendered divider and overflow icon"
        dividerAs={_getCustomDivider}
        onRenderOverflowIcon={_getCustomOverflowIcon}
        overflowAriaLabel="More links"
      />
    </div>
  );
};

function _onBreadcrumbItemClicked(ev: React.MouseEvent<HTMLElement>, item: IBreadcrumbItem): void {
  console.log(`Breadcrumb item with key "${item.key}" has been clicked.`);
}

function _getCustomDivider(dividerProps: IDividerAsProps): JSX.Element {
  const tooltipText = dividerProps.item ? dividerProps.item.text : '';
  return (
    <TooltipHost content={`Show ${tooltipText} contents`} calloutProps={{ gapSpace: 0 }}>
      <span aria-hidden="true" style={{ cursor: 'pointer', padding: 5 }}>
        /
      </span>
    </TooltipHost>
  );
}

function _getCustomOverflowIcon(): JSX.Element {
  return <Icon iconName={'ChevronDown'} />;
}

In this example, we saw that there is a specific separator and a selected item.

alt text

Now, let's see what can we do to improve this display.

Step indicator mode

The first thing here is to setup the component as stateless.

No... The first thing is to upgrade your SPFx solution to use Fluent UI library instead of Office UI Fabric.

So execute npm i @fluentui/react, then execute npm uninstall office-ui-fabric-react.

Now, we can work on our component 😁

Just start by adding a folder called "StepIndicator", in which you'll register a stateless component and a Sass module, both called "StepIndicator".

In the tsx file, declare these props :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import * as React from 'react';
import styles from './StepIndicator.module.scss';
import { IBreadcrumbItem } from '@fluentui/react';

export interface IStepIndicatorProps {
  currentStep: number;
  stepBreadcrumbItems: IBreadcrumbItem[];
}

export default class StepIndicator extends React.Component<IStepIndicatorProps> {
    // ...
}

Like this, you can provide all the steps, including the reached one, in you process.

Now, we add two render methods that will manage the display of:

  1. Steps (renderBreadcrumbItem)
  2. Separators / dividers (renderBreadcrumbDivider)
 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
import * as React from 'react';
import styles from './StepIndicator.module.scss';
import {
  Stack, Breadcrumb, IBreadcrumbItem,
  Label, IDividerAsProps
} from '@fluentui/react';

export interface IStepIndicatorProps {
  currentStep: number;
  stepBreadcrumbItems: IBreadcrumbItem[];
}

export default class StepIndicator extends React.Component<IStepIndicatorProps> {

  public render(): React.ReactElement<IStepIndicatorProps> {
    return (
      <div>
        {/*
          TODO : Add Breadcrumb here
        */}
      </div>
    );
  }

  private renderBreadcrumbItem = (item: IBreadcrumbItem) => {
    let stepClassName = styles.step;
    let currentItemIndex = this.props.stepBreadcrumbItems.indexOf(item);
    let isStepItemReached = currentItemIndex == this.props.currentStep;

    // Adapt the display of step whether it was reached or not
    if (currentItemIndex <= this.props.currentStep) {
      stepClassName = styles.stepvisited;
    }

    return (
    <Stack className={styles.breadcrumbitem}>
      <Label className={stepClassName}>{this.props.stepBreadcrumbItems.indexOf(item) + 1}</Label>
      <Label className={isStepItemReached ? styles.statuscurrent : styles.status}>{item.text}</Label>
    </Stack>);
  }

  private renderBreadcrumbDivider = (dividerProps: IDividerAsProps) => {
    let separatorClassName = styles.separator;
    let currentItemIndex = this.props.stepBreadcrumbItems.indexOf(dividerProps.item);

    // Change the color of the separator, if current step is reached
    if (currentItemIndex < this.props.currentStep) {
      separatorClassName = styles.separatorvisited;
    }

    return <div className={separatorClassName}></div>;
  }
}

Now, we want to setup styles correctly, following the breadcrumb customizations.

 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
70
/* /!\ update the reference to use Fluent UI or you'll get an error */
@import '~@fluentui/react/dist/sass/References.scss';

$bg-color-unreached: $ms-color-themeLight;
$bg-color-reached: $ms-color-themePrimary;
$font-color-reached: #ffffff;

.stepindicator {

  /* Style of a step (number and text) */
  .breadcrumbitem {
    width: 150px;
    padding: 0px 10px;

    /* Style of the text displayed below the step, reached or not */
    .status {
      text-align: center;
      font-size: 0.7rem;
      white-space: pre-line;
      min-height: 40px;

      &current {
        @extend .status;
        margin-left: -5px;
        font-weight: bold;
      }
    }

    /*
        Style of the step 'icon' (number)
        Here, it's displayed as a circle
    */
    .step {
      border-radius: 50%;
      height: 26px;
      width: 26px;
      line-height: 26px;
      font-size: 1rem;
      text-align: center;
      padding: 0px;
      left: 40%;
      position: relative;
      background-color: $bg-color-unreached;
      z-index: 1;

      &visited {
        @extend .step;
        color: $font-color-reached;
        background-color: $bg-color-reached;
      }
    }
  }

  /* Style of the divider */
  .separator {
    display: inline-block;
    position: absolute;
    content: '';
    height: 4px;
    width: 95%;
    top: 17%;
    left: 55%;
    background-color: $bg-color-unreached;

    &visited {
      @extend .separator;
      background-color: $bg-color-reached;
    }
  }
}

After that, we just have to reference the Breadcrumb component which will use our declared methods and styling

 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
import * as React from 'react';
import styles from './StepIndicator.module.scss';
import {
  Stack, Breadcrumb, IBreadcrumbItem, Label,
  IDividerAsProps, IBreadcrumbData
} from '@fluentui/react';

// ...

export default class StepIndicator extends React.Component<IStepIndicatorProps> {

  public render(): React.ReactElement<IStepIndicatorProps> {
    return (
      <Stack className={styles.stepindicator} horizontalAlign="center">
        <Breadcrumb
          onReduceData={this._reduceBreadcrumbData}
          onRenderItem={this._renderBreadcrumbItem}
          dividerAs={this._renderBreadcrumbDivider}
          items={this.props.stepBreadcrumbItems} />
      </Stack>
    );
  }

  private _reduceBreadcrumbData = (data: IBreadcrumbData) => {
    return undefined;
  }

  // ...
}

For this sample, I set the onReduceData to not shrink the component because I want to show every step.

Full sample

So here is the complete sample.

 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
70
71
72
73
74
import { Dropdown, IDropdownOption, Stack } from '@fluentui/react';
import * as React from 'react';
import StepIndicator from '../StepIndicator';
import styles from './HelloWorld.module.scss';
import { IHelloWorldProps } from './IHelloWorldProps';

interface IHelloWorldState {
  selectedStatusIndex: number;
}

export default class HelloWorld extends React.Component<IHelloWorldProps, IHelloWorldState> {
  public constructor(props) {
    super(props);

    this.state = {
      selectedStatusIndex: 0,
    };
  }

  // For this example, I'll use this property for both Breadcrumb items and DropDown options
  private _steps: any[] = [
    {
      key: 'init',
      text: 'Create purchase order'
    },
    {
      key: 'select',
      text: 'Analyze & select vendor'
    },
    {
      key: 'negociate',
      text: 'Negociate contract'
    },
    {
      key: 'pay',
      text: 'Authorize & pay vendor'
    },
    {
      key: 'end',
      text: 'Done'
    }
  ];

  public render(): React.ReactElement<IHelloWorldProps> {
    return (
      <div className={ styles.helloWorld }>
        <div className={ styles.container }>
          <div className={ styles.row }>
            <div className={ styles.column }>
              <span className={ styles.title }>Welcome to SharePoint!</span>
              <p className={ styles.subTitle }>Customize SharePoint experiences using Web Parts.</p>
              <p className={ styles.description }>{escape(this.props.description)}</p>
              <a href="https://aka.ms/spfx" className={ styles.button }>
                <span className={ styles.label }>Learn more</span>
              </a>
            </div>
          </div>
          <Stack tokens={{padding: '10px'}}>
            <StepIndicator currentStep={this.state.selectedStatusIndex} stepBreadcrumbItems={this._steps} />
          </Stack>
          <Stack tokens={{padding: '10px', maxWidth: '300px'}}>
            <Dropdown label="Select process step" options={this._steps} defaultSelectedKey='init' onChange={this._dropDownStepOnChange} />
          </Stack>
        </div>
      </div>
    );
  }

  private _dropDownStepOnChange = (event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption, index?: number) => {
    this.setState({
      selectedStatusIndex: index,
    });
  }
}
 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
import * as React from 'react';
import styles from './StepIndicator.module.scss';
import {
  Stack, Breadcrumb, IBreadcrumbItem, Label,
  IDividerAsProps, IBreadcrumbData
} from '@fluentui/react';

export interface IStepIndicatorProps {
  currentStep: number;
  stepBreadcrumbItems: IBreadcrumbItem[];
}

export default class StepIndicator extends React.Component<IStepIndicatorProps> {

  public render(): React.ReactElement<IStepIndicatorProps> {
    return (
      <Stack className={styles.stepindicator} horizontalAlign="center">
        <Breadcrumb
          onReduceData={this._reduceBreadcrumbData}
          onRenderItem={this._renderBreadcrumbItem}
          dividerAs={this._renderBreadcrumbDivider}
          items={this.props.stepBreadcrumbItems} />
      </Stack>
    );
  }

  private _reduceBreadcrumbData = (data: IBreadcrumbData) => {
    return undefined;
  }

  private _renderBreadcrumbItem = (item: IBreadcrumbItem) => {
    let stepClassName = styles.step;
    let currentItemIndex = this.props.stepBreadcrumbItems.indexOf(item);
    let isStepItemReached = currentItemIndex == this.props.currentStep;

    if (currentItemIndex <= this.props.currentStep) {
      stepClassName = styles.stepvisited;
    }

    return (
    <Stack className={styles.breadcrumbitem}>
      <Label className={stepClassName}>{this.props.stepBreadcrumbItems.indexOf(item) + 1}</Label>
      <Label className={isStepItemReached ? styles.statuscurrent : styles.status}>{item.text}</Label>
    </Stack>);
  }

  private _renderBreadcrumbDivider = (dividerProps: IDividerAsProps) => {
    let separatorClassName = styles.separator;
    let currentItemIndex = this.props.stepBreadcrumbItems.indexOf(dividerProps.item);

    if (currentItemIndex < this.props.currentStep) {
      separatorClassName = styles.separatorvisited;
    }

    return <div className={separatorClassName}></div>;
  }
}
 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
70
/* /!\ update the reference to use Fluent UI or you'll get an error */
@import '~@fluentui/react/dist/sass/References.scss';

$bg-color-unreached: $ms-color-themeLight;
$bg-color-reached: $ms-color-themePrimary;
$font-color-reached: #ffffff;

.stepindicator {

  /* Style of a step (number and text) */
  .breadcrumbitem {
    width: 150px;
    padding: 0px 10px;

    /* Style of the text displayed below the step, reached or not */
    .status {
      text-align: center;
      font-size: 0.7rem;
      white-space: pre-line;
      min-height: 40px;

      &current {
        @extend .status;
        margin-left: -5px;
        font-weight: bold;
      }
    }

    /*
        Style of the step 'icon' (number)
        Here, it's displayed as a circle
    */
    .step {
      border-radius: 50%;
      height: 26px;
      width: 26px;
      line-height: 26px;
      font-size: 1rem;
      text-align: center;
      padding: 0px;
      left: 40%;
      position: relative;
      background-color: $bg-color-unreached;
      z-index: 1;

      &visited {
        @extend .step;
        color: $font-color-reached;
        background-color: $bg-color-reached;
      }
    }
  }

  /* Style of the divider */
  .separator {
    display: inline-block;
    position: absolute;
    content: '';
    height: 4px;
    width: 95%;
    top: 17%;
    left: 55%;
    background-color: $bg-color-unreached;

    &visited {
      @extend .separator;
      background-color: $bg-color-reached;
    }
  }
}

And the result:

alt text

Happy coding !