41

I have a simple issue with React and event handling. My component looks like this (basically a table):

const MyList = ({ items, onBlur }) =>
<table onBlur={onBlur}}>
    <thead>
    <tr>
        <th>ID</th>
        <th>Title</th>
        <th>Publisher</th>
        <th>Year</th>
        <th>Author</th>
        <th>System</th>
        <th/>
    </tr>
    </thead>
    <tbody>
    {items.map(item => <MyListRow key={item.Id} item={item}/>)}
    </tbody>
</table>;

I want the blur event to fire only if the focus goes out of the table. Instead the event fires on each child element of the table when it loses focus.

According to the docs React lets focus events bubble up.

The question is: How can I get my onBlur method fire only when the focus gets out of the table? IOW: How can I filter out and discard the unwanted events bubbling up so that I reveal only the events which indicate a lost of focus for the table?

Peter Perot
  • 1,003
  • 1
  • 10
  • 15

5 Answers5

39

The problem is that a table doesn't actually have a concept of focus since it's not an input itself.

When the onBlur fires on the contained inputs we will check the relatedTarget of the onBlur event which should be set to the element that has RECEIVED focus (or null). We then use a function that will traverse upwards through parentNodes from that newly focused element and ensure that our event's currentTarget (the table) is not an ancestor of the newly focused element. If the condition passes it is assumed that the table no longer has any focus.

const focusInCurrentTarget = ({ relatedTarget, currentTarget }) => {
  if (relatedTarget === null) return false;
  
  var node = relatedTarget.parentNode;
        
  while (node !== null) {
    if (node === currentTarget) return true;
    node = node.parentNode;
  }

  return false;
}

const onBlur = (e) => {
  if (!focusInCurrentTarget(e)) {
    console.log('table blurred');
  }
}

const MyList = ({ items, onBlur }) => (
  <table onBlur={onBlur}>
    <thead>
      <tr>
        <th>ID</th>
        <th>Title</th>
        <th>Publisher</th>
        <th>Year</th>
        <th>Author</th>
        <th>System</th>
        <th/>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>1</td>
        <td>
          <input type="text" />
        </td>
        <td>
          <input type="text" />
        </td>
        <td>
          <input type="text" />
        </td>
        <td>
          <input type="text" />
        </td>
        <td>
          <input type="text" />
        </td>
      </tr>
    </tbody>
  </table>
);
    
ReactDOM.render(
  <MyList onBlur={onBlur} />,
  document.getElementById('root')
);
table {
  padding: 10px;
  border: 1px solid red;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>
<div id="root"></div>
<br />
<input type="text" />

References:

UPDATED:

Removed use of ReactDOM.findDOMNode

Community
  • 1
  • 1
TheZanke
  • 1,026
  • 8
  • 12
  • Wouldn't it be possible to simply wrap the `table` in a `div` and apply the `onBlur` to the `div`? Wouldn't that be called when you remove focus from the `table` if the `div` encompasses only the `table`? – Fizz Jun 24 '16 at 18:42
  • I don't think that would be any different than using the table element as the target of the onBlur since neither have a concept of focus themselves; you'll still need to check if it's a child/parent. Though after looking at my solution again I did come up with a cleaner idea that avoids ReactDOM.findDOMNode! Updating it now. – TheZanke Jun 24 '16 at 18:47
  • I get what you mean, although you could always set the `div`'s `tabindex` to 0 :P – Fizz Jun 24 '16 at 18:49
  • 1
    I ended up switching it to just use `event.currentTarget` for the reference to the table itself, and using that as the _parent_ in our ancestor checking function. This is much, much cleaner than it was previously; I probably wouldn't have looked at it again had you not commented. Thank you! – TheZanke Jun 24 '16 at 18:58
  • Thank you @alex-thezanke-howard, it works! :-) But one "improvement": I would do comparison to null using `==` and `!=` instead of `===` resp. `!==`; this ways we allow `undefined` to be handled as well - you never know. ;-) **But IE has a problem again**: It does not work with this approach. https://github.com/facebook/react/issues/3751. Any idea how to get it work with IE? – Peter Perot Jun 24 '16 at 19:19
  • If we switch back to using `ReactDOM.findDOMNode` we could probably avoid using currentTarget.. although, I'm not sure you'd want to do that. I'll try to find a better solution. – TheZanke Jun 24 '16 at 19:29
  • I don't have IE around me at the moment to test it but this https://facebook.github.io/react/docs/events.html _claims_ that every react event should have `currentTarget`, and since the events in react are synthetic it _should_ work cross-browser. I will have to wait until I get home later today and revisit this to test for myself! – TheZanke Jun 24 '16 at 19:32
  • Thank you for digging into it. Yes, it *should* be cross-browser, but the issue is still open at https://github.com/facebook/react/issues/3751. It was filed in 2015, so I think people have found workarounds in the meantime. I tested the code in Chrome and Firefox – positive. IE: negative. :-/ – Peter Perot Jun 24 '16 at 21:04
  • @PeterPerot the issue you mention is about `relatedTarget`. `currentTarget` is available in IE (it's the element firing the `onBlur`). In IE in order to determine if the `relatedTarget` is contained in the `currentTarget` I am using `e.currentTarget.contains( document.activeElement )`. According to MDN [`contains`](https://developer.mozilla.org/en-US/docs/Web/API/Node/contains) is supported since IE5 and [`activeElement`](https://developer.mozilla.org/en-US/docs/Web/API/Document/activeElement) since IE4. – David Riccitelli Feb 26 '17 at 08:32
  • What about this not being supported in Safari (they say)? – jayarjo May 06 '18 at 12:53
9

Just to hopefully synthesise David Riccitelli's helpful pointer, and wintondeshong's later insight, this worked for me:

class ActionRow extends Component {

  onBlur = (e) => {
    if ( !e.currentTarget.contains( e.relatedTarget ) ) {
      console.log('blur event');
    }
  }

  render() {
    return (
      <div onBlur={this.onBlur}>
        ..
      </div>
    )
  }
}

I was trying to trigger a save action based on when focus left a div full of form fields.

Chris Paul
  • 180
  • 1
  • 4
  • 1
    Just to clarify for those as ignorant as I, `e.relatedTarget` is the child element that is about to receive focus, and `e.currentTarget` is the element (`div` in this case) returning the bubbled `onBlur` event. – Chris May 09 '19 at 10:45
5

Without custom functions and Internet Explorer compatible, since node.contains and document.activeElement are supported since Internet Explorer 5, this works:

const onBlur = (e) => { if ( !e.currentTarget.contains( document.activeElement ) ) { console.log('table blurred'); } }

  • The Node.contains() method returns a Boolean value indicating whether a node is a descendant of a given node or not.
  • Document.activeElement returns the currently focused element, that is, the element that will get keystroke events if the user types any.
David Riccitelli
  • 7,491
  • 5
  • 42
  • 56
  • 1
    My modification: if ( event.relatedTarget && event.currentTarget.contains( event.relatedTarget ) ) { return console.log('not on blur') } – catamphetamine May 18 '17 at 18:18
  • 2
    In my use, `onBlur` is always called with `

    ` as the active element, like [this question](https://stackoverflow.com/questions/11299832/document-activeelement-returns-body)

    – Felipe May 24 '17 at 10:43
  • 1
    Like Felipe, `document.activeElement` is the `

    `. Instead I check `relatedTarget` to see if it is a child of the `currentTarget` like so... `if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) { console.log("child clicked"); }`

    – wintondeshong Jul 20 '17 at 14:17
  • Blur event precedes the related Focus event, so potentially `document.activeElement` might not be set yet to the proper value when you check for it in your onBlur handler. Unless it is set right away, before any Focus or Blur events. – jayarjo May 06 '18 at 12:55
0

A quick way to do this is to:

  1. Wire up an onBlur and onFocus event to your parent element
  2. Start a timer with setTimeout when blur happens
  3. Clear the timeout when the focus happens.

It would look something like this:

class MyList extends Component {
  onBlur() {
    this.blurTimeoutHandle = setTimeout(this.props.onBlur)
  }
  onFocus() {
    clearTimeout(this.blurTimeoutHandle)
  }

  render() {
    return (
      <table onBlur={this.onBlur} onFocus={this.onFocus}>
      ...
      </table>
    )
  }
)

This works because all the child blurs will bubble up to the parent and then the focus events will cancel them unless focus has moved outside the parent.

Kudos to J. Renée Beach and her great Medium article for the basics of this solution.

Kaiden
  • 188
  • 3
  • 8
0

So for my use case, I need to monitor when a user leaves the application and based on that give a warning and change the contents of the application so the user can't cheat. That being said this is how I was able to solve it based on @TheZanke Approach

Wrap the component you need to have this functionality around and pass the props in and should work perfectly. maintaining this is way easier for my case so I use this

import PropTypes from "prop-types"
import React, { useState, useEffect } from 'react'



export default function OutsideAlerter({ children, fallback, initialIsVisible, isVisible, onSetIsVisible }) {
    const [isComponentVisible, setIsComponentVisible] = useState(initialIsVisible);
    const handleClickOutside = event => {
        if (!event?.currentTarget?.contains(event?.relatedTarget)) {
            onSetIsVisible(false)
        }
    };

    const handleClickInside = event => {};

    useEffect(() => {
        setIsComponentVisible(isVisible === undefined ? initialIsVisible : isVisible);
    }, [isVisible])

    return (
        <div
            style={{
                width: '100%',
                height: '100%',
                display: 'flex',
                flexDirection: 'column',
            }}
            tabIndex="0"
            onBlur={handleClickOutside}
            onClick={handleClickInside}
        >
            {isComponentVisible ? children : fallback}
        </div>
    )
}

OutsideAlerter.propTypes = {
  children: PropTypes.element,
  fallback: PropTypes.element,
  initialIsVisible: PropTypes.bool.isRequired,
  isVisible: PropTypes.bool,
  onSetIsVisible: PropTypes.func
}


This is the fallback component passed enter image description here

<OutsideAlerter
            fallback={
                  <div style={{
                    display: 'flex',
                    flexDirection: 'column',
                    justifyContent: 'center',
                    color: '#fff',
                    alignItems: 'center',
                    height: '100vh',
                  }}>
                    <div>You have clicked outside of the application</div>
                    <button
                      name='exam'
                      onClick={(e) => handleVisible(e, true)}
                    >IsVisible</button>
                  </div>
            }
            initialIsVisible={true}
            isVisible={isVisible?.exam}
            onSetIsVisible={(e) => handleVisible({ target: { name: 'exam' } }, e)}
          >
              <BaseRoutes />
          </OutsideAlerter>
 const handleVisible = (e, bool) => {
    setIsVisible({ ...isVisible, [e.target.name]: bool })
  }
Ice_mank
  • 410
  • 3
  • 8