Repository: mlaursen/react-md
Pull request #779 (2018-08-08 19:42:16)

Improve accessibility of the Datepicker
I branched off `release/1.5.x` here because I saw it mentioned that `release/2.0.x` was a dead branch and because the `documentation` package in `2.0.x` appeared to be incomplete. I am open to putting this wherever makes sense, but I figured this would be a good place to start.

These are a few changes to improve the screen reader experience when using the Datepicker component.

Proposed changes:

1. The calendar will be laid out as a grid with rows and cells so the dates can be more easily navigated (horizontally and vertically). Currently, screen reader users must navigate through days one by one rather than being able to skip down through weeks.

2. The year picker button will have `aria-expanded` to indicate whether it is open or closed. This makes it easier for screen reader users to understand what is happening rather than having to scroll through every single one of the years to get out of the (long) list.

3. The year picker will close (go back to "calendar" mode) after you choose a year. This is improving the issue of needing to navigate through every single year in the picker to get back to the calendar. I also feel it's a better experience as a visual (non-screenreader) user for the picker to go back to the calendar after choosing a year, since the user will know exactly what year they are looking for and then they'll want to hone in on a date after that. I can't think of a use case where this wouldn't be the case. I do recognize that this is a subjective opinion about UX and I'm open to other ideas 😄  A gif of this experience is attached below.

![datepicker-year](https://user-images.githubusercontent.com/2423414/43860259-5d0c26ee-9b10-11e8-952d-ef10656bc85b.gif)

mlaursen: Thank you, this looks great!

Yeah, I forgot to remove the dead branch of `release/2.0.x` since I am doing the new work in `next`, so I think the `release/1.5.x` branch is a good target.

Just to make this "fully backwards compatible", would you mind adding a prop called `swapToCalendar` (or something better, naming is hard) that defaults to `false`. When it is enabled, it will add the new functionality that selecting a year will immediately swap to the calendar view in the date picker.
leahjlou: @mlaursen Thanks for the quick review and feedback! I've added a prop `closeYearOnSelect`. Naming _is_ hard 🤷‍♂️  Let me know your thoughts.
mlaursen: Looks good to me, thanks. I'll see if I can have this release out by end of day tomorrow 

Patch:
diff --git a/src/js/Pickers/CalendarDate.js b/src/js/Pickers/CalendarDate.js
index eb571a972d..87edf8ce19 100644
--- a/src/js/Pickers/CalendarDate.js
+++ b/src/js/Pickers/CalendarDate.js
@@ -69,29 +69,34 @@ export default class CalendarDate extends PureComponent {
 
     const fullyActive = today && !active && !desktopActive;
     return (
-      <button
-        type="button"
-        ref={this._setFocus}
-        onFocus={this._setActive}
-        onBlur={this._setInactive}
-        onMouseOver={this._setActive}
-        onMouseLeave={this._setInactive}
-        className={cn('md-calendar-date md-calendar-date--btn', {
-          'md-calendar-date--btn-active': active || desktopActive,
-          'md-pointer--hover': !disabled,
-        }, themeColors({ disabled, primary: fullyActive }), 'md-btn', className)}
-        onClick={this._handleClick}
-        disabled={disabled}
+      <div
+        role="gridcell"
+        className="md-calendar-date"
+        style={{ display: 'inline-block' }}
       >
-        <span
-          className={cn('md-calendar-date--date', {
-            'md-picker-text--active': active || desktopActive,
-            'md-font-bold': fullyActive,
-          })}
+        <button
+          ref={this._setFocus}
+          onFocus={this._setActive}
+          onBlur={this._setInactive}
+          onMouseOver={this._setActive}
+          onMouseLeave={this._setInactive}
+          className={cn('md-calendar-date--btn', {
+            'md-calendar-date--btn-active': active || desktopActive,
+            'md-pointer--hover': !disabled,
+          }, themeColors({ disabled, primary: fullyActive }), 'md-btn', className)}
+          onClick={this._handleClick}
+          disabled={disabled}
         >
-          {date}
-        </span>
-      </button>
+          <span
+            className={cn('md-calendar-date--date', {
+              'md-picker-text--active': active || desktopActive,
+              'md-font-bold': fullyActive,
+            })}
+          >
+            {date}
+          </span>
+        </button>
+      </div>
     );
   }
 }
diff --git a/src/js/Pickers/CalendarMonth.js b/src/js/Pickers/CalendarMonth.js
index 2acf37b1ab..5e61731753 100644
--- a/src/js/Pickers/CalendarMonth.js
+++ b/src/js/Pickers/CalendarMonth.js
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
 import cn from 'classnames';
 
 import addDate from '../utils/dates/addDate';
+import chunk from '../utils/chunk';
 import getLastDay from '../utils/dates/getLastDay';
 import stripTime from '../utils/dates/stripTime';
 import toDayOfWeek from '../utils/dates/toDayOfWeek';
@@ -146,16 +147,22 @@ export default class CalendarMonth extends PureComponent {
           />
         );
       } else {
-        date = <div key={key} className="md-calendar-date" />;
+        date = <div key={key} className="md-calendar-date" role="gridcell" />;
       }
 
       days.push(date);
       currentDate = addDate(currentDate, 1, 'D');
     }
 
+    const weeks = chunk(days, 7);
+
     return (
-      <div className={cn('md-calendar-month', className)} {...props}>
-        {days}
+      <div role="grid" className={cn('md-calendar-month', className)} {...props}>
+        {weeks.map((week, i) => (
+          <div key={i} role="row">
+            {week}
+          </div>
+        ))}
       </div>
     );
   }
diff --git a/src/js/Pickers/DatePickerContainer.js b/src/js/Pickers/DatePickerContainer.js
index bcd2701a4e..fa29e01ca3 100644
--- a/src/js/Pickers/DatePickerContainer.js
+++ b/src/js/Pickers/DatePickerContainer.js
@@ -502,6 +502,12 @@ export default class DatePickerContainer extends PureComponent {
      */
     lastChild: PropTypes.bool,
 
+    /**
+     * True if the datepicker should swap to calendar mode automatically after a year is picked
+     * while in `year` mode.
+     */
+    closeYearOnSelect: PropTypes.bool,
+
     previousIconChildren: deprecated(PropTypes.node, 'Use the `previousIcon` prop instead'),
     previousIconClassName: deprecated(PropTypes.string, 'Use the `previousIcon` prop instead'),
     nextIconChildren: deprecated(PropTypes.node, 'use the `nextIcon` prop instead'),
@@ -537,6 +543,7 @@ export default class DatePickerContainer extends PureComponent {
     cancelLabel: 'Cancel',
     cancelPrimary: true,
     closeOnEsc: true,
+    closeYearOnSelect: false,
     disableScrollLocking: false,
     'aria-label': 'Pick a date',
   };
@@ -805,6 +812,10 @@ export default class DatePickerContainer extends PureComponent {
       calendarDate: nextDate,
       calendarTempDate: nextTemp,
     });
+
+    if (this.props.closeYearOnSelect) {
+      this._changeCalendarMode('calendar');
+    }
   };
 
   /**
diff --git a/src/js/Pickers/DatePickerHeader.js b/src/js/Pickers/DatePickerHeader.js
index 1de8a05ee5..a5c28c520b 100644
--- a/src/js/Pickers/DatePickerHeader.js
+++ b/src/js/Pickers/DatePickerHeader.js
@@ -58,9 +58,14 @@ export default class DatePickerHeader extends PureComponent {
   render() {
     const { year, weekday, date } = this.state;
     const { calendarMode, className } = this.props;
+    const isYearMode = calendarMode === 'year';
     return (
       <header className={cn('md-picker-header', className)}>
-        <PickerControl onClick={this._selectYear} active={calendarMode === 'year'}>
+        <PickerControl
+          onClick={this._selectYear}
+          active={isYearMode}
+          aria-expanded={isYearMode}
+        >
           <h6 className="md-subheading-1">{year}</h6>
         </PickerControl>
         <PickerControl onClick={this._selectCalendar} active={calendarMode === 'calendar'}>
diff --git a/src/js/utils/chunk.js b/src/js/utils/chunk.js
new file mode 100644
index 0000000000..79cf699273
--- /dev/null
+++ b/src/js/utils/chunk.js
@@ -0,0 +1,21 @@
+/** @module utils/omit */
+
+/**
+ * This should hopefully be very similar to lodash's chunk function. It will
+ * take an array and split it into chunks of the given size. Any remainder will
+ * be in the last chunk.
+ *
+ * @param {Array} arr - the array to split into chunks
+ * @param {integer} size - the desired length of each chunk
+ * @return {Array} - the chunked array
+ */
+export default function chunk(arr, size) {
+  if (!arr) {
+    return [];
+  }
+  return arr.reduce((chunks, item, index) => (
+    index % size === 0
+      ? [...chunks, arr.slice(index, index + size)]
+      : chunks
+  ), []);
+}


Test patch:
diff --git a/src/js/Pickers/__tests__/CalendarDate.js b/src/js/Pickers/__tests__/CalendarDate.js
index 3f32b9764c..184e110399 100644
--- a/src/js/Pickers/__tests__/CalendarDate.js
+++ b/src/js/Pickers/__tests__/CalendarDate.js
@@ -24,7 +24,8 @@ describe('CalendarDate', () => {
 
     const date = renderIntoDocument(<CalendarDate {...props} />);
     const node = findDOMNode(date);
-    expect(node.classList.contains(props.className)).toBe(true);
+    const button = node.children[0];
+    expect(button.classList.contains(props.className)).toBe(true);
   });
 
   it('calls the onClick prop with the date prop when not disabled', () => {
@@ -38,13 +39,15 @@ describe('CalendarDate', () => {
 
     let date = renderIntoDocument(<CalendarDate {...props} />);
     let dateNode = findDOMNode(date);
-    Simulate.click(dateNode);
+    let button = dateNode.children[0];
+    Simulate.click(button);
     expect(props.onClick.mock.calls.length).toBe(1);
 
     const newProps = Object.assign({}, props, { disabled: true });
     date = renderIntoDocument(<CalendarDate {...newProps} />);
     dateNode = findDOMNode(date);
-    Simulate.click(dateNode);
+    button = dateNode.children[0];
+    Simulate.click(button);
     expect(props.onClick.mock.calls.length).toBe(1);
   });
 
diff --git a/src/js/Pickers/__tests__/CalendarMonth.js b/src/js/Pickers/__tests__/CalendarMonth.js
index f473b35cec..7e76772678 100644
--- a/src/js/Pickers/__tests__/CalendarMonth.js
+++ b/src/js/Pickers/__tests__/CalendarMonth.js
@@ -90,7 +90,8 @@ describe('CalendarMonth', () => {
     }
 
     function checkDayNode(node) {
-      expect(node.nodeName).toBe('BUTTON');
+      expect(node.nodeName).toBe('DIV');
+      expect(node.children[0].nodeName).toBe('BUTTON');
     }
 
     const props = {