SPFx/React Dropdown and Click Outside

As we build custom components such as header extensions for Sharepoint using SPFx, we often include dropdowns — dropdown menus, toggled sections, and the like — where we want to be able to close the dropdown by clicking outside of the element. In this tutorial, I’ll go through how to create a React dropdown component for an SPFx extension or web part and add the ability to click outside of the dropdown to close it.

Let’s say I have a basic React component in my SPFx extension called DropdownContainer, which for simplicity’s sake will contain both my button and the actual dropdown. I’ll include a state variable called dropdownOpen (boolean) which will help me keep track of whether or not the dropdown should be open. I’ll then put the dropdown div inside a conditional section so that it will only render if the dropdownOpen state is true.

DropdownContainer.tsx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import * as React from 'react';
import styles from './DropdownContainer.module.scss';
import { IDropdownContainerProps } from './IDropdownContainerProps';
import { IDropdownContainerState } from './IDropdownContainerState';

export default class DropdownContainer extends React.Component<IDropdownContainerProps, IDropdownContainerState> {
constructor(props: IDropdownContainerProps) {
super(props);
this.state = {
dropdownOpen: false
}
}

public render(): React.ReactElement<IDropdownContainerProps> {
return (
<div>
<button>Toggle dropdown</button>
{ this.state.dropdownOpen && (
<div className={styles.dropdown}>This is the dropdown</div>
)}
</div>
);
}

IDropdownContainerState.ts:

1
2
3
export interface IDropdownContainerState {
dropdownOpen: boolean;
}

IDropdownContainerProps.ts doesn’t really have anything in it for my example, but could hold whatever props you need to pass to the component.

1
export interface IDropdownContainerProps { }

DropdownContainer.module.scss

1
2
3
4
.dropdown {
border: solid 1px #ccc;
padding: 10px;
}

First, I’ll add a method that will fire when I click the button. This will go inside the DropdownContainer class, outside of the render method:

1
2
3
4
5
6
7
8
9
10
private buttonClick = (e) => {
// keep the button click from propagating up and reloading the page
e.preventDefault();
e.stopPropagation();

// set the state for dropdownOpen to be the opposite of what it currently is
this.setState({
dropdownOpen: !this.state.dropdownOpen
});
}

Now I’m ready to call my buttonClick method from the button’s onClick method. In the render method, I’ll add on to the button element:

1
<button onClick={(e) => this.buttonClick(e)}>Toggle dropdown</button>

Adding the onClick as an arrow function is what allows me to pass in the event so that I can stop propagation on the button. The button calls the buttonClick method, which simply sets state. Initially, the dropdown isn’t open because dropdownOpen is set to false from the constructor. When the button is clicked, dropdownOpen gets set to its opposite–or, true–and now the dropdown div is rendered by the render method.

At this point, we have a button that opens and closes a dropdown when clicked. Now let’s look at how to modify this so that clicking outside of the dropdown will close it. This is where React refs come into play. While you may find it helpful to read the documentation, following their examples didn’t quite work for me with SPFx. Instead, look below to see what worked for me:

Above the constructor, add a private variable for the ref.

1
2
3
export default class DropdownContainer extends React.Component<IDropdownContainerProps, IDropdownContainerState> {
private dropdownRef: HTMLElement;
...

This variable will be tied to the part of the component that you want to close when you click outside of it. In this example, I’ll attach it to the dropdown div by adding a ref to that div.

1
2
3
4
5
6
<div>
<button onClick={(e) => this.buttonClick(e)} >Toggle dropdown</button>
{ this.state.dropdownOpen && (
<div className={styles.dropdown} ref={el => this.dropdownRef = el}>This is the dropdown</div>
)}
</div>

However, I will also want to create another ref for the button itself. Why? Well, the button is part of the “outside” of the dropdown. So if clicking outside of the dropdown closes the dropdown, then trying to click the button to open the dropdown would trigger the click-outside function and immediately close it. By creating a ref for the button as well, I can check to make sure that I’m truly clicking outside of the dropdown as well as the button itself.

1
2
3
export default class DropdownContainer extends React.Component<IDropdownContainerProps, IDropdownContainerState> {
private dropdownRef: HTMLElement;
private buttonRef: HTMLElement;

And in the render method:

1
<button onClick={(e) => this.buttonClick(e)} ref={el => this.buttonRef = el}>Toggle dropdown</button>

My next step is to come up with a method that would be called when I click outside of the dropdown element. I’ll create a new private method, which first checks to see if the dropdown is even open, then checks the click event to see if it’s inside or outside of the referenced elements. If it’s inside, we won’t do anything. If it’s outside, we’ll call the buttonClick event again to close the dropdown.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private handleOutsideClick = (event) : void => {
// we set a variable to reference the ref
var dropdown = this.dropdownRef;
var button = this.buttonRef;

if( dropdown ){ // if the dropdown exists
if( dropdown.contains(event.target) || button.contains(event.target)){
// this means that we've clicked inside the element, so we don't want anything to happen
return;
}
} else {
// the dropdown doesn't exist yet, so just return
return;
}

// the click is outside of our element, so now we want to do something -- to close the dropdown
this.buttonClick(event);
}

To actually fire this method, we need to add a listener to the document to track the clicks. React is awesome because it’s very easy to add the listener in the componentDidMount() method and remove it in the componentWillUnmount() method. This keeps everything nice and clean!

1
2
3
4
5
6
7
public componentDidMount(){
document.addEventListener('click', this.handleOutsideClick, false);
}

public componentWillUnmount(){
document.removeEventListener('click', this.handleOutsideClick, false);
}

Now at this point, everything is working beautifully! Clicking on the button toggles the dropdown, and when the dropdown is open, clicking outside of it closes the dropdown.

But let’s take this a step further. In some cases, you won’t have such a simple dropdown, but need the dropdown itself to be its own React component. I’ll take my initial example from before, clear out the refs and click outside stuff, and add a Dropdown component:

DropdownContainer.tsx:

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
import * as React from 'react';
import { IDropdownContainerProps } from './IDropdownContainerProps';
import { IDropdownContainerState } from './IDropdownContainerState';
import { Dropdown } from './Dropdown/Dropdown';

export default class DropdownContainer extends React.Component<IDropdownContainerProps, IDropdownContainerState> {

constructor(props: IDropdownContainerProps) {
super(props);
this.state = {
dropdownOpen: false
}
}

public render(): React.ReactElement<IDropdownContainerProps> {
return (
<div>
<button onClick={(e) => this.buttonClick(e)}>Toggle dropdown</button>
{ this.state.dropdownOpen && (
<Dropdown/>
)}
</div>
);
}

private buttonClick = (e) => {
// keep the button click from propagating up and reloading the page
e.preventDefault();
e.stopPropagation();

// set the state for dropdownOpen to be the opposite of what it currently is
this.setState({
dropdownOpen: !this.state.dropdownOpen
});
}

}

…where <Dropdown/> is defined as a separate React component, in files that look something like this:

Dropdown.tsx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import * as React from 'react';
import { IDropdownProps } from './IDropdownProps';
import styles from '../DropdownContainer.module.scss';

export class Dropdown extends React.Component<IDropdownProps> {
constructor(props: IDropdownProps) {
super(props);
}

public render(): React.ReactElement<IDropdownProps> {
return (
<div className={styles.dropdown}>
This is the dropdown
</div>
);
}
}

IDropdownProps.ts is currently empty, but not for long:

1
export interface IDropdownProps { }

We want the dropdown to be closed when the user clicks outside of the dropdown, so as before, we add a ref to the Dropdown component:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export class Dropdown extends React.Component<IDropdownProps> {
private dropdownRef: HTMLElement;
constructor(props: IDropdownProps) {
super(props);
}

public render(): React.ReactElement<IDropdownProps> {
return (
<div ref={el => this.dropdownRef = el} className={styles.dropdown}>
This is the dropdown
</div>
);
}
}

And then we’ll start adding the method for handling the click and the listeners:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public componentDidMount(){
document.addEventListener('click', this.handleOutsideClick, false);
}

public componentWillUnmount(){
document.removeEventListener('click', this.handleOutsideClick, false);
}

private handleOutsideClick = (event) : void => {
var myElement = this.dropdownRef;

if( myElement.contains(event.target)){
return;
}

// the click is outside of our element, so now we want to do something - to close the dropdown
}

Now in our previous example, after line 16, we called the buttonClick method. But that method is in the parent component. We need to somehow talk to the parent component to tell it to change the state and hide the dropdown.

This is where callback functions come in! In the Dropdown component, we can add a property for a callback function, typed as a function that returns void.

IDropdownProps.ts:

1
2
3
export interface IDropdownProps {
clickOutsideCallback: (e) => void;
}

Back in the Dropdown.tsx file, we call this callback function in the handleClickOutside:

1
2
3
4
5
6
7
8
9
10
private handleOutsideClick = (event) : void => {
var myElement = this.dropdownRef;

if( myElement.contains(event.target)){
return;
}

// the click is outside of our element, so now we want to do something - to close the dropdown
this.props.clickOutsideCallback(event);
}

Now we head out to the containing component, DropdownContainer.tsx, to fix the Dropdown element with the property attribute:

1
<Dropdown clickOutsideCallback={} />

Recall that the type of clickOutsideCallback is a function. So what do we pass in? Well, how about buttonClick, which does exactly what we want already? So the Dropdown element would now look like this:

1
<Dropdown clickOutsideCallback={(e) => this.buttonClick(e)} />

And that’s it! The Dropdown itself is now its own component, so you can start making it as complex as you need it to be. But the rendering of the Dropdown still happens in the DropdownContainer component, so the callback function allows a way for Dropdown to talk to DropdownContainer and get the click outside effect that we want.

These fairly simple and lean components give a clear picture of how to use refs and event listeners to set up a click-outside function. In summary, we looked at:

  • How to use an onClick method to toggle the dropdown div via a component’s state
  • How to use refs with SPFx
  • How to use refs with event listeners for click outside functionality
  • How to use callback functions to allow a component to affect its parent’s state

If you’d like, you can see all the code on github.

Leave a Comment

Your email address will not be published. Required fields are marked *

Enter Code *

Filed Under

Related

Days off? Never heard of them. Join the #PixelMillWebinars this Thursday, 10/1 as @EricOverfield tells us all about how to get started with the #SharePoint Starter Kit. http://ow.ly/tMM450BBKwb

#MSIgnite the virtual version is over but the learning doesn't have to stop there. There's a lot to unpack here, so we curated a list of must-see sessions & resources from this year's event to help you quickly get up to speed. http://ow.ly/w7dx50BBKaC

#MSIgnite the virtual version is over but the learning doesn't have to stop there. We know what you're thinking, there's a lot to unpack here! So we curated a list of must-see sessions & resources from this year's event to help you quickly get up to speed. http://ow.ly/w7dx50BBKaC

#FBF in honor of #nationalcomicbookday! 2020 got you down? Have no fear, the #PixelLeague is here!

A ginormous and heartfelt THANK YOU to everyone who was able to join us today for this special edition of the #PixelMillWebinars review of #MSIgnite, and to @EricOverfield , @MichalPisarek @buckleyplanet and @ShrPntKnight for being our amazing panelists!

Subscribe to PixelMill's
E-news

* indicates required

Let's Talk Digital
Workspaces Today

Get In Touch