Demo

Demo Link

Note

Dropdown is a common component. There are two types of dropdown:

  1. After expanding Dropdown Menu, clicking anywhere should collapse menu.
  2. After expanding Dropdown Menu, click inside menu, menu will not be folded up, only click outside menu, menu will be folded up.

, in the era of jQuery dropdown can be well implemented, directly in the document. The addEventListener (” click “, handler), listening to the click event of the document, Then hide dropdown’s menu. If you want the click inside menu not to collapse menu, let the click inside Menu execute Event.stopPropagation ().

Just start to do the React development, don’t know where to receive ideas, find the document. The addEventListener () API so not React, very exclusive use. Thus, when implementing the Dropdown Component, dealing with the menu being closed when clicked outside the menu becomes a headache.

In order to receive the onBlur event, the menu must contain a component of input type or tabIndex property. After tabIndex is added, When the Component is in onFocus, it adds an extra shadow style to the border, as shown in the image below, which requires additional CSS processing. In short, the logic gets complicated.

Later made music players React, look at the others, the implementation of the source code found. They are mostly used audioElement addEventListener (‘ play ‘, handler) the native API, and, if it’s not native events can’t handle some logic, For example, there seems to be no other way to listen for window’s resize event than to use window.addeventListener (‘resize’, handler). So back to see the dropdown can achieve, if also use the document. The addEventListener (” click “, handler) after treatment menu click, logic is simpler.

Still, there are pitfalls.

React event.stopPropagation() does not prevent native events from bubping up to document.

See the article for details:

  • React prevents bubbling of synthetic events and native events

The React of issue:

  • e.stopPropagation() seems to not be working as expect.

React with two sets of the event system, a system is a native event, is the document. The addEventListener () this API, the other is a React their definition, called SyntheticEvent (synthetic) events, such as the example, the onClick.

<a onClick={this.clickLink}>Open</a>
Copy the code

React composite events are bound to the Document (proxy), not to the component. When you execute the event.stopPropagation(), The actual native event has already reached the document.

React event.stopPropagation() only prevents composite events from bubbling up, but does not prevent native events from bubbling up to document.

So you can see why I have stopPropagation in the click event handler inside menu and why the global Click handler will still execute, that’s the reason.

But! React’s stopPropagation for composite events does not prevent events from bubbling to Document, but it does prevent events from bubbling to Windows.

(This reminds me of a project where I used React event.stopPropagation() and turbolinks didn’t work. If it’s tied to a tag, it shouldn’t stop working. Turbolinks’ click event is tied to a window.

So, in order to implement the React dropdown, there are two methods:

  1. Use Windows. AddEventLister (” click “, handler) instead of the document. The addEventListener (” click “, handler), within the menu click on at the same time, Call event.stopPropagation() for composite events

  2. Instead of calling Event.StopPropagation (), let the event bubble to the Document Click Handler to determine whether the Event. Target is inside or outside the Menu. Use the domNode.contains () method to determine. This method requires the React ref attribute to save the menu reference as shown below:

     <div className="dropdown-body" ref={ref=>this._dropdown_body=ref}>
    Copy the code

    Judge:

     handleGlobalClick = (event) => {
       console.log('global click')
    
       // use DOMNode.contains() method to judge click target is in or out of the dropdown body
       if (this._dropdown_body && this._dropdown_body.contains(event.target)) return
    
       this.setState({dropDownExpanded: false})
       document.removeEventListener('click', this.handleGlobalClick)
     }
    Copy the code

The second pitfall is that in the handler of native events, this.setstate () is synchronous, not asynchronous, which surprises me. I thought this.setstate () must be asynchronous.

A detailed analysis can be found in this article – do you really understand setState?

Conclusion:

SetState is “asynchronous” only in synthesized events and lifecycle functions, and synchronous in both native events and setTimeout.

But look at Dan on Twitter and see if there will be a unified asynchronous operation in the future.

Other details:

  1. The Document Click Handler is registered only when menu is expanded and removed when folded, which is dynamic.

     handleGlobalClick = () => {
       console.log('global click')
    
       this.setState({dropDownExpanded: false})
       document.removeEventListener('click', this.handleGlobalClick)
     }
    Copy the code
  2. To achieve the effect of toggle, click the button to expand dropdown menu, and then click the button to receive menu, the simplest way is to bind the button with click handler only when menu is folded up. When menu is expanded, Buttons don’t have a Click handler, let the Document Click handler handle it. Otherwise, calling this.setstate () in both the synthesized event handler and the native event handler, one asynchronous and one synchronous, might cause trouble.

     <div className="dropdown-head">
       {
         dropDownExpanded ?
         <button>Collapse dropdown menu - 1</button> :
         <button onClick={this.handleHeadClick}>Open dropdown menu - 1</button>
       }
     </div>
    Copy the code
  3. When you register the Click handler for document, it must be done in the setTimeout callback.

     handleHeadClick = () => {
       console.log('head click')
    
       this.setState({dropDownExpanded: true})
       setTimeout(()=>{
         // must run in the next tick
         document.addEventListener('click', this.handleGlobalClick)
       }, 0)
     }
    Copy the code
  4. Remove the Document Click handler in componentWillUnmount() to avoid memory leaks.

     componentWillUnmount() {
       // important! we need remove global click handler when unmout
       document.removeEventListener('click', this.handleGlobalClick)
     }
    Copy the code

Update

I’ve been using it everywhere since I discovered that window.addeventListener (‘click’, handler) is a handy way to collapse the React Dropdown. To avoid writing window.addeventlister (‘click’, handler) too many times, I encapsulate a Component of NativeClickListener with a few lines like this:

export default class NativeClickListener extends React.Component {
  static propTypes = {
    onClick: PropTypes.func
  }

  clickHandler = (event) => {
    console.log('NativeClickListener click')
    const { onClick } = this.props
    onClick && onClick(event)
  }

  componentDidMount() {
    window.addEventListener('click', this.clickHandler)
  }

  componentWillUnmount() {
    window.removeEventListener('click', this.clickHandler)
  }

  render() {
    return this.props.children
  }
}
Copy the code

Use:

<div className="dropdown-container"> <div className="dropdown-head"> <button onClick={this.handleHeadClick}> {dropDownExpanded ? 'Collapse' : 'Open'} dropdown menu - 5 </button> </div> { dropDownExpanded && <NativeClickListener onClick={()=>this.setState({dropDownExpanded: false})}> <div className="dropdown-body" onClick={this.handleBodyClick}> ... </div> </NativeClickListener> } </div> handleHeadClick = (event) => { console.log('head click') this.setState(prevState => ({dropDownExpanded: ! prevState.dropDownExpanded})) event.stopPropagation() } handleBodyClick = (event) => { console.log('body click') // just  can stop event propagate from document to window event.stopPropagation() }Copy the code

The React component library uses a native addEventListener to implement the Dropdown function. However, the listener listener is also used to implement the Dropdown function. They didn’t use Windows addEventListener, and is done with the document. AddEventListener and node. The contains method.

  1. Material Kit React

    The component library Dropdown can use @ material – UI/core/ClickAwayListener, take a look at it.

    handleClickAway = event => { ... if ( doc.documentElement && doc.documentElement.contains(event.target) && ! this.node.contains(event.target) ) { this.props.onClickAway(event); } } render() { const { children, mouseEvent, touchEvent, onClickAway, ... other } = this.props; const listenerProps = {}; if (mouseEvent ! == false) { listenerProps[mouseEvent] = this.handleClickAway; } if (touchEvent ! == false) { listenerProps[touchEvent] = this.handleClickAway; } return ( <React.Fragment> {children} <EventListener target="document" {... listenerProps} {... other} /> </React.Fragment> ); }Copy the code

    The logic for addEventListener appears in EventListener, which comes from the react-event-Listener library. And from target=”document”, event is tied to document.

    class EventListener extends React.PureComponent { componentDidMount() { this.applyListeners(on); } applyListeners(onOrOff, props = this.props) { const { target } = props; if (target) { let element = target; if (typeof target === 'string') { element = window[target]; } forEachListener(props, onOrOff.bind(null, element)); }... } function on(target, eventName, callback, options) { // eslint-disable-next-line prefer-spread target.addEventListener.apply(target, getEventListenerArgs(eventName, callback, options)); } function off(target, eventName, callback, options) { // eslint-disable-next-line prefer-spread target.removeEventListener.apply(target, getEventListenerArgs(eventName, callback, options)); }Copy the code
  2. The implementation of Dropdown in Ant Design can ultimately be traced back to the React-Component /trigger component.

    // We must listen to `mousedown` or `touchstart`, edge case: // https://github.com/ant-design/ant-design/issues/5804 // https://github.com/react-component/calendar/issues/250 // https://github.com/react-component/trigger/issues/50 if (state.popupVisible) { let currentDocument; if (! this.clickOutsideHandler && (this.isClickToHide() || this.isContextMenuToShow())) { currentDocument = props.getDocument(); this.clickOutsideHandler = addEventListener(currentDocument, 'mousedown', this.onDocumentClick); } // always hide on mobile if (! this.touchOutsideHandler) { currentDocument = currentDocument || props.getDocument(); this.touchOutsideHandler = addEventListener(currentDocument, 'touchstart', this.onDocumentClick); } // close popup when trigger type contains 'onContextMenu' and document is scrolling. if (! this.contextMenuOutsideHandler1 && this.isContextMenuToShow()) { currentDocument = currentDocument || props.getDocument(); this.contextMenuOutsideHandler1 = addEventListener(currentDocument, 'scroll', this.onContextMenuClose); } // close popup when trigger type contains 'onContextMenu' and window is blur. if (! this.contextMenuOutsideHandler2 && this.isContextMenuToShow()) { this.contextMenuOutsideHandler2 = addEventListener(window, 'blur', this.onContextMenuClose); } return; } onDocumentClick = (event) => { if (this.props.mask && ! this.props.maskClosable) { return; } const target = event.target; const root = findDOMNode(this); if (! contains(root, target) && ! this.hasPopupMouseDown) { this.close(); }}Copy the code
  3. JetBrain’s Ring-UI Dropdown doesn’t make it collapse when clicked elsewhere, which is a bit of a surprise…

I didn’t understand it at first, but later I realized that if I use window.addeventListener (‘click’, handler) to collapse the Dropdown, if there are multiple dropdowns in a page, I first open A Dropdown menu (called A) and then click another Dropdown menu (called B), because event.stopPropagation() is called in the click event of Dropdown B. Therefore, Dropdown A’s Global Click handler will not fire and Dropdown A will not collapse.

Even if there is only one Dropdown, if event.stopPropagation() is called in an event handler anywhere else on the page, the Dropdown may not be dropped.

But the use of the document. The addEventListener (” click “, handler) with the node. The contains () method does not have this problem, so an Epiphany, I see why open source component libraries don’t use the window.addeventListener () approach.

NativeClickListener2:

export default class NativeClickListener extends React.Component {
  static propTypes = {
    onClick: PropTypes.func
  }

  clickHandler = (event) => {
    console.log('NativeClickListener click')
    if(this._container.contains(event.target)) return

    const { onClick } = this.props
    onClick && onClick(event)
  }

  componentDidMount() {
    document.addEventListener('click', this.clickHandler)
  }

  componentWillUnmount() {
    document.removeEventListener('click', this.clickHandler)
  }

  render() {
    return (
      <div ref={ref=>this._container=ref}>
        {this.props.children}
      </div>
    )
  }
}
Copy the code

Use:

<div className="dropdown-container"> <div className="dropdown-head"> <button onClick={this.handleHeadClick}> {dropDownExpanded ? 'Collapse' : 'Open'} dropdown menu - 5 </button> </div> { dropDownExpanded && <NativeClickListener2 onClick={()=>this.setState({dropDownExpanded: false})}> <div className="dropdown-body" onClick={this.handleBodyClick}> ... </div> </NativeClickListener2> } </div> handleHeadClick = (event) => { console.log('head click') this.setState(prevState  => ({dropDownExpanded: ! prevState.dropDownExpanded})) // no need // event.stopPropagation() } handleBodyClick = (event) => { console.log('body click') // no need // event.stopPropagation() }Copy the code