Kyle Truong Kyle Truong - 1 month ago 51
React JSX Question

How to onFocus and onBlur a React/Redux form field that's connected to React Date Picker?

I've got this simple v6 redux-form with an input that renders a custom component that populates its value using react-day-picker.

https://github.com/gpbl/react-day-picker

I chose react-day-picker over others because it doesn't depend on moment.js and works with my current set up.

When I focus the field, I want the datepicker to pop up, but if I click anywhere that's not the datepicker, I want it to disappear.

Essentially, I want my React datepicker to work like the jQueryUI one in:

https://jqueryui.com/datepicker/

The three scenarios I end up with are:


  1. I click the field, the datepicker pops up, but will not disappear unless I select a date or click the field again (this is too rigid for our needs).

  2. I click the field, the datepicker pops up, but will disappear TOO quickly if I click anywhere, as the input field's onBlur gets called before it processes the click event for the datepicker to populate the field with the chosen date.

  3. I click the field, the datepicker pops up, gets auto-focused, blurs properly, except when I click anything that's not < body> or the datepicker.



I first tried to use a sibling empty div that wraps the whole page, so when I click the empty div, it'll toggle the datepicker properly. This worked OK with z-indexes and position: fixed until I changed the datepicker's month, which seems to re-render the datepicker and messed with the order of the clicking, which led to situation 2) again.

My most current attempt is to auto-focus the datepicker div when it pops up, so when I blur anything that's not the datepicker, it will toggle the datepicker. This worked in theory, except the datepicker is a component with many nested < div>'s inside it to control day, week, month, disabled days... so when I click a 'day', it registers a blur because I'm clicking the 'day'
<div>
, not the root 'datepicker'
<div>
, which is what was initially focused.

The solution to the above was to tweak 'datepicker'
<div>
's onBlur such that it will only toggle the datepicker when document.activeElement is < body>, but that only works if I don't click another form field.

WizardFormPageOne.js:

function WizardFormPageOne({ handleSubmit }) {
return (
<form onSubmit={handleSubmit} className="col-xs-6">
<h1>WizardFormPageOne</h1>

<div className="card">
<div className="card-block">
<div className="form-group">
<label htmlFor="first">Label 1</label>
<Field type="text" name="first" component={DateInput} className="form-control" />
</div>
...

export default reduxForm({
form: 'wizardForm',
destroyOnUnmount: false,
})(WizardFormPageOne);


DateInput.js:

import React from 'react';

import styles from './styles.css';
import DatePicker from '../DatePicker';

class DateInput extends React.Component { // eslint-disable-line react/prefer-stateless-function
constructor(props) {
super(props);

this.state = {
dateValue: new Date(),
activeDateWidget: false,
};
}

changeDate = (date) => {
this.setState({
dateValue: date,
});
}

changeActiveDateWidget = (e) => {
e.stopPropagation();
this.setState({
activeDateWidget: !this.state.activeDateWidget,
});
}

render() {
const { input, meta } = this.props;
const { dateValue, activeDateWidget } = this.state;
return (
<div className={styles.dateInput}>
<input
{...input}
className="form-control"
type="text"
value={dateValue}
onClick={this.changeActiveDateWidget}
// onBlur={this.changeActiveDateWidget}
/>

{activeDateWidget ? (
<div>
<DatePicker
changeActiveDateWidget={this.changeActiveDateWidget}
changeDate={this.changeDate}
dateValue={dateValue}
/>
</div>
) : (
<div></div>
)}
</div>
);
}
}

export default DateInput;


DatePicker.js:

import React from 'react';
import 'react-day-picker/lib/style.css';
import DayPicker, { DateUtils } from 'react-day-picker';

import styles from './styles.css';
import disabledDays from './disabledDays';


class DatePicker extends React.Component { // eslint-disable-line react/prefer-stateless-function
constructor(props) {
super(props);
this.state = {
selectedDay: new Date(),
};
}

componentDidMount() {
if (this._input) {
this._input.focus();
}
}

handleDayClick = (e, day, { disabled }) => {
e.stopPropagation();
if (disabled) {
return;
}
this.setState({ selectedDay: day }, () => {
this.props.changeDate(day);
this.props.changeActiveDateWidget();
});
}

focusThisComponent = (e) => {
if (e) {
this._input = e;
}
}

focusIt = () => {
console.log('focusing');
}

blurIt = () => {
console.log('blurring');
setTimeout(() => {
if (document.activeElement == document.body) {
this.props.changeActiveDateWidget();
}
}, 1);
}

render() {
const { changeActiveDateWidget } = this.props;
const { selectedDay } = this.state;
return (
<div
className={styles.datePicker}
ref={this.focusThisComponent}
tabIndex="1"
onFocus={this.focusIt}
onBlur={this.blurIt}
>
<DayPicker
id="THISTHING"
initialMonth={selectedDay}
disabledDays={disabledDays}
selectedDays={(day) => DateUtils.isSameDay(selectedDay, day)}
onDayClick={this.handleDayClick}
/>
</div>
);
}
}

export default DatePicker;


Here's a screencast of the issue I'm having now:

http://screencast.com/t/kZuIwUzl

The datepicker toggles properly, except when clicking on another field, at which point it stops blurring/toggling properly. All my tinkering either led me make to one of the three scenarios listed above.

Answer

I couldn't get Steffen's answer to work for my scenario, and the example in http://react-day-picker.js.org/examples/?overlay doesn't blur properly if you open the date picker, click the non-active parts of the date picker, then click outside the date picker. I may be nitpicking at this point, and his solution is probably far easier to implement, but here's what I did to solve it:

In DatePicker.js, set an empty array that will serve as a collection of valid < div>'s. When onBlur is triggered, invoke a recursive function that takes the root DatePicker < div>, parses all it's children, and add them to the empty array. After that, check document.activeElement to see if it's in the array. If not, then toggle the DatePicker widget, else, do nothing.

Note that the check for document.activeElement must be done one tick after the blur, or else activeElement will be < body>.

Related links:

Get the newly focussed element (if any) from the onBlur event.

Which HTML elements can receive focus?

/**
*
* DatePicker
*
*/

import React from 'react';
import 'react-day-picker/lib/style.css';
import DayPicker, { DateUtils } from 'react-day-picker';

import styles from './styles.css';
import disabledDays from './disabledDays';

class DatePicker extends React.Component { // eslint-disable-line react/prefer-stateless-function
  constructor(props) {
    super(props);
    this.state = {
      selectedDay: new Date(),
    };

    this.validElements = [];
  }

  componentDidMount() {
    // Once DatePicker successfully sets a ref, component will mount
    // and autofocus onto DatePicker's wrapper div.
    if (this.refComponent) {
      this.refComponent.focus();
    }
  }

  setRefComponent = (e) => {
    if (e) {
      this.refComponent = e;
    }
  }

  findDatePickerDOMNodes = (element) => {
    if (element.hasChildNodes()) {
      this.validElements.push(element);
      const children = element.childNodes;
      for (let i = 0; i < children.length; i++) {
        this.validElements.push(children[i]);
        this.findDatePickerDOMNodes(children[i]);
      }
      return;
    }
  }

  handleDayClick = (e, day, { disabled }) => {
    if (disabled) {
      return;
    }
    this.setState({ selectedDay: day }, () => {
      this.props.changeDate(day);
      this.props.changeActiveDateWidget();
    });
  }

  handleBlur = () => {
    // Since DatePicker's wrapper div has been autofocused on mount, all
    // that needs to be done is to blur on anything that's not the DatePicker.
    // DatePicker has many child divs that handle things like day, week, month...
    // invoke a recursive function to gather all children of root DatePicker div, then run a test against valid DatePicker elements. If test fails, then changeActiveDateWidget.
    setTimeout(() => {
      const rootDatePickerElement = document.getElementById('datePickerWidget');
      this.findDatePickerDOMNodes(rootDatePickerElement);
      if (!this.validElements.includes(document.activeElement)) {
        this.props.changeActiveDateWidget();
      }
    }, 1);
  }

  render() {
    const { selectedDay } = this.state;
    return (
      <div
        className={styles.datePicker}
        onBlur={this.handleBlur}
        // tabIndex necessary for element to be auto-focused.
        tabIndex="1"
        ref={this.setRefComponent}
      >
        <DayPicker
          initialMonth={selectedDay}
          disabledDays={disabledDays}
          selectedDays={(day) => DateUtils.isSameDay(selectedDay, day)}
          onDayClick={this.handleDayClick}
          id="datePickerWidget"
        />
      </div>
    );
  }
}

DatePicker.propTypes = {
  changeDate: React.PropTypes.func,
  changeActiveDateWidget: React.PropTypes.func,
};

export default DatePicker;

and in DateInput.js, clicking the input may cause the toggle to trigger twice, so i just set it to always toggle true if clicking the input:

render() {
    const { input, meta } = this.props;
    const { dateValue, activeDateWidget } = this.state;
    return (
      <div className={styles.dateInput}>
        <input
          {...input}
          className="form-control"
          type="text"
          value={dateValue}
          onClick={() => { this.setState({ activeDateWidget: true }); }}
        />
Comments