React TypeScript: Outside Click Detector
Tags: react, typescript, testing, webdev
Published: 2022-12-02 23:57:28

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

1. We can use forward Ref; React way or,
2. We can use document.getElementById to listen for the click

Let's define our Interface for the props for our HOC

```typescript
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
1. We should be able to use Mouse events to interact
2. We should be able to use the keyboard to interact
3. 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

```typescript
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.

```typescript
// 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

```typescript
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

```typescript
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.

```typescript
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

```typescript
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!