From c3bf8400461f1d9076ef8e45f38d2c1f53fbe131 Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Wed, 15 Jul 2015 14:59:04 -0700 Subject: [PATCH] feat(calendar): more fine-tuned a11y --- src/components/calendar/calendar.js | 17 ++++++---- src/components/calendar/calendar.spec.js | 2 +- src/components/calendar/calendarMonth.js | 22 ++++++++++--- src/components/calendar/dateLocaleProvider.js | 16 ++++++++++ src/components/calendar/datePicker.js | 32 +++++++++++++------ src/components/calendar/datePicker.spec.js | 2 +- 6 files changed, 68 insertions(+), 23 deletions(-) diff --git a/src/components/calendar/calendar.js b/src/components/calendar/calendar.js index 1e07e22b2ce..a63e05703a2 100644 --- a/src/components/calendar/calendar.js +++ b/src/components/calendar/calendar.js @@ -15,7 +15,6 @@ // PRE RELEASE // TODO(mchen): Date "isComplete" logic - // TODO(jelbourn): Fix NVDA stealing key presses (IE) ??? // POST RELEASE // TODO(jelbourn): Animated month transition on ng-model change (virtual-repeat) @@ -41,7 +40,6 @@ function calendarDirective() { return { template: - //'' + '' + '
' + '' + @@ -52,8 +50,7 @@ 'md-item-size="' + TBODY_HEIGHT + '">' + '' + '' + - '
' + - '
', + '', scope: {}, require: ['ngModel', 'mdCalendar'], controller: CalendarCtrl, @@ -269,8 +266,13 @@ this.$scope.$apply(function() { // Capture escape and emit back up so that a wrapping component // (such as a date-picker) can decide to close. - if (event.which == self.keyCode.ESCAPE) { - self.$scope.$emit('md-calendar-escape'); + if (event.which == self.keyCode.ESCAPE || event.which == self.keyCode.TAB) { + self.$scope.$emit('md-calendar-close'); + + if (event.which == self.keyCode.TAB) { + event.preventDefault(); + } + return; } @@ -285,7 +287,6 @@ // Selection isn't occuring, so the key event is either navigation or nothing. var date = self.getFocusDateFromKeyEvent(event); if (date) { - console.log('key event'); event.preventDefault(); event.stopPropagation(); @@ -410,6 +411,7 @@ self.calendarElement.querySelector('#' + self.getDateId(previousSelectedDate)); if (prevDateCell) { prevDateCell.classList.remove(SELECTED_DATE_CLASS); + prevDateCell.setAttribute('aria-selected', 'false'); } } @@ -418,6 +420,7 @@ var dateCell = self.calendarElement.querySelector('#' + self.getDateId(date)); if (dateCell) { dateCell.classList.add(SELECTED_DATE_CLASS); + dateCell.setAttribute('aria-selected', 'true'); } } }); diff --git a/src/components/calendar/calendar.spec.js b/src/components/calendar/calendar.spec.js index 5405ab1f8c4..40c60b93fca 100644 --- a/src/components/calendar/calendar.spec.js +++ b/src/components/calendar/calendar.spec.js @@ -275,7 +275,7 @@ describe('md-calendar', function() { it('should fire an event when escape is pressed', function() { var escapeHandler = jasmine.createSpy('escapeHandler'); - pageScope.$on('md-calendar-escape', escapeHandler); + pageScope.$on('md-calendar-close', escapeHandler); pageScope.myDate = new Date(2014, FEB, 11); applyDateChange(); diff --git a/src/components/calendar/calendarMonth.js b/src/components/calendar/calendarMonth.js index 44aaf62fdaf..af7f5b5114b 100644 --- a/src/components/calendar/calendarMonth.js +++ b/src/components/calendar/calendarMonth.js @@ -127,6 +127,7 @@ if (this.dateUtil.isValidDate(calendarCtrl.selectedDate) && this.dateUtil.isSameDay(opt_date, calendarCtrl.selectedDate)) { cell.classList.add(SELECTED_DATE_CLASS); + cell.setAttribute('aria-selected', 'true'); } if (calendarCtrl.focusDate && this.dateUtil.isSameDay(opt_date, calendarCtrl.focusDate)) { @@ -136,10 +137,21 @@ return cell; }; - - CalendarMonthCtrl.prototype.buildDateRow = function() { + + /** + * Builds a `tr` element for the calendar grid. + * @param rowNumber The week number within the month. + * @returns {HTMLElement} + */ + CalendarMonthCtrl.prototype.buildDateRow = function(rowNumber) { var row = document.createElement('tr'); row.setAttribute('role', 'row'); + + // Because of an NVDA bug (with Firefox), the row needs an aria-label in order + // to prevent the entire row being read aloud when the user moves between rows. + // See http://community.nvda-project.org/ticket/4643. + //row.setAttribute('aria-label', this.dateLocale.weekNumberFormatter(rowNumber)); + return row; }; @@ -158,7 +170,8 @@ // Store rows for the month in a document fragment so that we can append them all at once. var monthBody = document.createDocumentFragment(); - var row = this.buildDateRow(); + var rowNumber = 1; + var row = this.buildDateRow(rowNumber); monthBody.appendChild(row); // Add a label for the month. If the month starts on a Sun/Mon/Tues, the month label @@ -196,7 +209,8 @@ // If we've reached the end of the week, start a new row. if (dayOfWeek === 7) { dayOfWeek = 0; - row = this.buildDateRow(); + rowNumber++; + row = this.buildDateRow(rowNumber); monthBody.appendChild(row); } diff --git a/src/components/calendar/dateLocaleProvider.js b/src/components/calendar/dateLocaleProvider.js index 59f8e6565ec..1411d4738eb 100644 --- a/src/components/calendar/dateLocaleProvider.js +++ b/src/components/calendar/dateLocaleProvider.js @@ -43,6 +43,12 @@ */ this.monthHeaderFormatter = null; + /** + * Function that formats a week number into a label for the week. + * @type {function(number): string} + */ + this.weekNumberFormatter = null; + /** * Function that formats a date into a short aria-live announcement that is read when * the focused date changes within the same month. @@ -104,6 +110,15 @@ return service.shortMonths[date.getMonth()] + ' ' + date.getFullYear(); } + /** + * Default week number formatter. + * @param number + * @returns {string} + */ + function defaultWeekNumberFormatter(number) { + return 'Week ' + number; + } + /** * Default formatter for short aria-live announcements. * @param {!Date} date @@ -154,6 +169,7 @@ formatDate: this.formatDate || defaultFormatDate, parseDate: this.parseDate || defaultParseDate, monthHeaderFormatter: this.monthHeaderFormatter || defaultMonthHeaderFormatter, + weekNumberFormatter: this.weekNumberFormatter || defaultWeekNumberFormatter, shortAnnounceFormatter: this.shortAnnounceFormatter || defaultShortAnnounceFormatter, longAnnounceFormatter: this.longAnnounceFormatter || defaultLongAnnounceFormatter, msgCalendar: this.msgCalendar || defaultMsgCalendar, diff --git a/src/components/calendar/datePicker.js b/src/components/calendar/datePicker.js index 7a15e4302d2..af1a3976a87 100644 --- a/src/components/calendar/datePicker.js +++ b/src/components/calendar/datePicker.js @@ -26,16 +26,16 @@ // interaction on the text input, and multiple tab stops for one component (picker) // may be confusing. '' + + 'tabindex="-1" aria-hidden="true" ' + + 'ng-click="ctrl.openCalendarPane($event)">' + '' + '' + '
' + - '' + - 'Press Alt + Down to open the calendar' + + '' + + //'Press Alt + Down to open the calendar' + '' + '
' + '
' + @@ -127,6 +127,13 @@ /** @type {boolean} Whether the date-picker's calendar pane is open. */ this.isCalendarOpen = false; + /** + * Element from which the calendar pane was opened. Keep track of this so that we can return + * focus to it when the pane is closed. + * @type {HTMLElement} + */ + this.calendarPaneOpenedFrom = null; + this.calendarPane.id = 'md-date-pane' + $mdUtil.nextUid(); /** Pre-bound click handler is saved so that the event listener can be removed. */ @@ -204,12 +211,12 @@ angular.element(self.inputElement).on('keydown', function(event) { $scope.$apply(function() { if (event.altKey && event.keyCode == keyCodes.DOWN_ARROW) { - self.openCalendarPane(); + self.openCalendarPane(event); } }); }); - self.$scope.$on('md-calendar-escape', function() { + self.$scope.$on('md-calendar-close', function() { self.closeCalendarPane(); }); }; @@ -272,10 +279,14 @@ this.calendarPane.parentNode.removeChild(this.calendarPane); }; - /** Open the floating calendar pane. */ - DatePickerCtrl.prototype.openCalendarPane = function() { + /** + * Open the floating calendar pane. + * @param {Event} event + */ + DatePickerCtrl.prototype.openCalendarPane = function(event) { if (!this.isCalendarOpen && !this.isDisabled) { this.isCalendarOpen = true; + this.calendarPaneOpenedFrom = event.target; this.attachCalendarPane(); this.focusCalendar(); @@ -292,7 +303,8 @@ DatePickerCtrl.prototype.closeCalendarPane = function() { this.isCalendarOpen = false; this.detachCalendarPane(); - this.inputElement.focus(); + this.calendarPaneOpenedFrom.focus(); + this.calendarPaneOpenedFrom = null; document.body.removeEventListener('click', this.bodyClickHandler); }; diff --git a/src/components/calendar/datePicker.spec.js b/src/components/calendar/datePicker.spec.js index 8bcabe1746a..600313dc72d 100644 --- a/src/components/calendar/datePicker.spec.js +++ b/src/components/calendar/datePicker.spec.js @@ -62,7 +62,7 @@ describe('md-date-picker', function() { expect(controller.calendarPane.offsetHeight).toBeGreaterThan(0); // Fake an escape event coming the the calendar. - pageScope.$broadcast('md-calendar-escape'); + pageScope.$broadcast('md-calendar-close'); });