DEV Community

Lalit Yadav
Lalit Yadav

Posted on

React TypeScript: Outside Click Detector

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

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 // 
}
Enter fullscreen mode Exit fullscreen mode

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

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>;
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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>) => {

})
Enter fullscreen mode Exit fullscreen mode

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>
})
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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')
  })
})
Enter fullscreen mode Exit fullscreen mode

If you enjoy the article or have any comments please feel free to comment. Happy coding!

Oldest comments (0)