We want to write a type safe outside click detector component that can be wrapped around any component to listen for an outside click. Additionally, we will also listen for keypress for the scenarios when the user presses Escape to close a modal, or dropdown.
Requirements
Our Higher Order Component(HOC) should support two basic functionality for compositive usage
- We can use forward Ref; React way or,
- We can use document.getElementById to listen for the click
Let's define our Interface for the props for our HOC
export interface IClickOutsideProps {
children: React.ReactNode
wrapperId: string? // Id of our outside wrapper where we will listen for click
listen: boolean // Toggle to listen for click
onClickOutside: () => void //
}
Besides, our functional requirements we should also define our non-functional requirements
- We should be able to use Mouse events to interact
- We should be able to use the keyboard to interact
- We should be able to support touch events as well
Implementation
Now, let's write our basic component around the prop definition. But, we are missing ref But, we cannot simply pass it as a prop, we will need to use React's forwardRefAPI and before continuing, let's do a quick refresher on React's documentation on Ref forwarding
Ref forwarding is an opt-in feature that lets some components take ref they receive and pass it further down (in other words, "forward" it) to a child.
Example from React's documentation
const FancyButton = React.forwardRef((props, ref) => (
<button ref={ref} className="FancyButton">
{props.children}
</button>
));
// You can now get a ref directly to the DOM button:
const ref = React.createRef();
<FancyButton ref={ref}>Click me!</FancyButton>;
We need to typeSafe our component so let's write a fundamental component that satisfies our requirements.
// Defining our imports
import React, { useEffect, useRef, forwardRef, ForwardedRef, MutableRefObject } from 'react'
If we recall the function definition for forwardRef from React's definition is React.forwardRef((props, ref) => React.ReactNode)
We already have the type of our props defined earlier as IClickOutsideDetectorProps
, for ref, we have already imported ForwardRef
from React. So, ref: ForwardRef
should work right? Not quite yet, we still need to define the inner generic type for our ForwardRef. Our component should look like this
const ClickOutsideDetector = forwardRef((props: IClickOutsideDetectorProps, ref: ForwardedRef<HTMLDivElement>) => {
})
Let's take a moment and think about our functional requirements. We should be able to use HOC with the ref or with HTML Node id to select our Node.
When we have our wrapper element we use the neat trick to check if the wrapper contains the element where the click happened if yes we return otherwise we run the callback
const ClickOutsideDetector = forwardRef((props: IClickOutsideDetectorProps, ref: ForwardedRef<HTMLDivElement>) => {
const { children, listen, wrapperId, onClickOutside } = props
const container = ref || useRef<HTMLDivElement>(null)
const handleEvent = (e: MouseEvent) => {
if ((container as MutableRefObject<HTMLDivElement>).current.contains(e.target as Node)) return
const wrapperElm = document.getElementById(wrapperId)
if (wrapperElm && wrapperElm.contains(e.target as Node)) return
onClickOutside()
}
return <div ref={container}>{children}</div>
})
Okay, HOC has our logic in place we still need to handle MouseEvent, KeyboardEvent, and Touch events.
const ClickOutsideDetector = forwardRef((props: IClickOutsideDetectorProps, ref: ForwardedRef<HTMLDivElement>) => {
const { children, listen, wrapperId, onClickOutside } = props
const container = ref || useRef<HTMLDivElement>(null)
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Escape') onClickOutside()
}
const handleEvent = (e: KeyboardEvent | MouseEvent | TouchEvent) => {
if ((container as MutableRefObject<HTMLDivElement>).current.contains(e.target as Node)) return
const wrapperElm = document.getElementById(wrapperId)
if (wrapperElm && wrapperElm.contains(e.target as Node)) return
onClickOutside()
}
useEffect(() => {
if (listen) {
document.addEventListener('mousedown', handleEvent, false)
document.addEventListener('touchend', handleEvent, false)
document.addEventListener('keyup', onKeyUp, false)
}
return () => {
document.removeEventListener('mousedown', handleEvent, false)
document.removeEventListener('touchend', handleEvent, false)
document.removeEventListener('keyup', onKeyUp, false)
}
})
return <div ref={container}>{children}</div>
})
// For debugging
ClickOutsideDetector.displayName = 'ClickOutsideDetector'
Testing
We can test our component simply by using ReactTestingLibrary underneath. There is not much to explain here, the code is self-explanatory
import React, { createRef, useState } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom'
import { ClickOutsideDetector } from './index'
const TestComponent = () => {
const [isOpen, setIsOpen] = useState(true)
const containerRef = createRef<HTMLDivElement>()
const handleClickOutSide = () => setIsOpen(false)
return (
<div>
<div data-testid='clickableArea'>
<button>Click happens here</button>
</div>
<div id='wrapperId'>
<ClickOutsideDetector
ref={containerRef}
wrapperId='wrapperId'
listen={true}
onClickOutside={handleClickOutSide}
>
<p data-testid='renderedItem'>{isOpen ? 'open' : 'closed'}</p>
</ClickOutsideDetector>
</div>
</div>
)
}
describe('Running functional test for ClickOutsideDetector', () => {
test('Check if ClickOutside is working on Click', async () => {
render(<TestComponent />)
expect(screen.getByTestId('renderedItem')).toHaveTextContent('open')
await userEvent.click(screen.getByTestId('clickableArea'))
expect(screen.getByTestId('renderedItem')).toHaveTextContent('closed')
})
test('Check if ClickOutside is working on Keyboard', async () => {
render(<TestComponent />)
expect(screen.getByTestId('renderedItem')).toHaveTextContent('open')
await userEvent.keyboard('{Escape}')
expect(screen.getByTestId('renderedItem')).toHaveTextContent('closed')
})
})
If you enjoy the article or have any comments please feel free to comment. Happy coding!
Top comments (0)