Skip to content

Commit

Permalink
fix(datepicker): make datepicker work with screen readers (#4349)
Browse files Browse the repository at this point in the history
* first pass a11y

* escape key support

* month labels

* don't steal focus from fwd/back buttons

* fix tests

* address some comments

* fix lint
  • Loading branch information
mmalerba committed May 5, 2017
1 parent 0a5fae0 commit 9a9d453
Show file tree
Hide file tree
Showing 15 changed files with 103 additions and 34 deletions.
7 changes: 4 additions & 3 deletions src/demo-app/datepicker/datepicker-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,22 @@ <h1>Work in progress, not ready for use.</h1>
</p>
<br>
<p>
<input [mdDatepicker]="dp" [(ngModel)]="date">
<input [mdDatepicker]="dp" [(ngModel)]="date" aria-label="Departure date">
<button [mdDatepickerToggle]="dp"></button>
<md-datepicker #dp [touchUi]="touch"></md-datepicker>
</p>
<p>
<md-input-container>
<input mdInput [mdDatepicker]="dp2" [(ngModel)]="date">
<input mdInput [mdDatepicker]="dp2" [(ngModel)]="date" aria-label="Departure date">
<button mdSuffix [mdDatepickerToggle]="dp2"></button>
<md-datepicker #dp2 [touchUi]="touch"></md-datepicker>
</md-input-container>
</p>
<p>
<button [mdDatepickerToggle]="dp3"></button>
<md-input-container>
<input mdInput [mdDatepicker]="dp3" [(ngModel)]="date" min="1/1/16" max="1/1/18">
<input mdInput [mdDatepicker]="dp3" [(ngModel)]="date" min="1/1/16" max="1/1/18"
aria-label="Departure date">
<md-datepicker #dp3 [touchUi]="touch" startAt="1/1/17" [dateFilter]="dateFilter">
</md-datepicker>
</md-input-container>
Expand Down
2 changes: 2 additions & 0 deletions src/lib/core/datetime/date-formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export type MdDateFormats = {
display: {
dateInput: any,
monthYearLabel: any,
dateA11yLabel: any,
monthYearA11yLabel: any,
}
};

Expand Down
2 changes: 2 additions & 0 deletions src/lib/core/datetime/native-date-formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@ export const MD_NATIVE_DATE_FORMATS: MdDateFormats = {
display: {
dateInput: {year: 'numeric', month: 'numeric', day: 'numeric'},
monthYearLabel: {year: 'numeric', month: 'short'},
dateA11yLabel: {year: 'numeric', month: 'long', day: 'numeric'},
monthYearA11yLabel: {year: 'numeric', month: 'long'},
}
};
16 changes: 13 additions & 3 deletions src/lib/datepicker/calendar-body.html
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
<!-- If there's not enough space in the first row, create a separate label row. -->
<tr *ngIf="_firstRowOffset < labelMinRequiredCells">
<!--
If there's not enough space in the first row, create a separate label row. We mark this row as
aria-hidden because we don't want it to be read out as one of the weeks in the month.
-->
<tr *ngIf="_firstRowOffset < labelMinRequiredCells" aria-hidden="true">
<td class="mat-calendar-body-label" [attr.colspan]="numCols" >{{label}}</td>
</tr>

<!-- Create the first row separately so we can include a special spacer cell. -->
<tr *ngFor="let row of rows; let rowIndex = index">
<tr *ngFor="let row of rows; let rowIndex = index" role="row">
<!--
We mark this cell as aria-hidden so it doesn't get read out as one of the days in the week.
-->
<td *ngIf="rowIndex === 0 && _firstRowOffset"
aria-hidden="true"
class="mat-calendar-body-label"
[attr.colspan]="_firstRowOffset">
{{_firstRowOffset >= labelMinRequiredCells ? label : ''}}
</td>
<td *ngFor="let item of row; let colIndex = index"
role="gridcell"
class="mat-calendar-body-cell"
[tabindex]="_isActiveCell(rowIndex, colIndex) ? 0 : -1"
[class.mat-calendar-body-disabled]="!item.enabled"
[class.mat-calendar-body-active]="_isActiveCell(rowIndex, colIndex)"
[attr.aria-label]="item.ariaLabel"
(click)="_cellClicked(item)">
<div class="mat-calendar-body-cell-content"
[class.mat-calendar-body-selected]="selectedValue === item.value"
Expand Down
1 change: 1 addition & 0 deletions src/lib/datepicker/calendar-body.scss
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ $mat-calendar-body-cell-content-size: 100% - $mat-calendar-body-cell-content-mar
line-height: 0;
padding: $mat-calendar-body-cell-padding 0;
text-align: center;
outline: none;
}

.mat-calendar-body-cell-content {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/datepicker/calendar-body.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,5 +171,5 @@ class CalendarBodyWithDisabledCells {


function createCell(value: number) {
return new MdCalendarCell(value, `${value}`, true);
return new MdCalendarCell(value, `${value}`, `${value}-label`, true);
}
5 changes: 4 additions & 1 deletion src/lib/datepicker/calendar-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import {
* @docs-private
*/
export class MdCalendarCell {
constructor(public value: number, public displayValue: string, public enabled: boolean) {}
constructor(public value: number,
public displayValue: string,
public ariaLabel: string,
public enabled: boolean) {}
}


Expand Down
2 changes: 1 addition & 1 deletion src/lib/datepicker/calendar.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
</div>
</div>

<div class="mat-calendar-content" tabindex="0" (keydown)="_handleCalendarBodyKeydown($event)"
<div class="mat-calendar-content" (keydown)="_handleCalendarBodyKeydown($event)"
cdkMonitorSubtreeFocus>
<md-month-view
*ngIf="_monthView"
Expand Down
42 changes: 30 additions & 12 deletions src/lib/datepicker/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import {
AfterContentInit,
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Inject,
Input,
NgZone,
Optional,
Output,
ViewEncapsulation
Expand Down Expand Up @@ -126,7 +128,9 @@ export class MdCalendar<D> implements AfterContentInit {
return this._monthView ? this._intl.nextMonthLabel : this._intl.nextYearLabel;
}

constructor(private _intl: MdDatepickerIntl,
constructor(private _elementRef: ElementRef,
private _intl: MdDatepickerIntl,
private _ngZone: NgZone,
@Optional() private _dateAdapter: DateAdapter<D>,
@Optional() @Inject(MD_DATE_FORMATS) private _dateFormats: MdDateFormats) {
if (!this._dateAdapter) {
Expand All @@ -139,6 +143,7 @@ export class MdCalendar<D> implements AfterContentInit {

ngAfterContentInit() {
this._activeDate = this.startAt || this._dateAdapter.today();
this._focusActiveCell();
this._monthView = this.startView != 'year';
}

Expand Down Expand Up @@ -187,14 +192,6 @@ export class MdCalendar<D> implements AfterContentInit {
return !this.maxDate || !this._isSameView(this._activeDate, this.maxDate);
}

/** Whether the two dates represent the same view in the current view mode (month or year). */
private _isSameView(date1: D, date2: D): boolean {
return this._monthView ?
this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2) &&
this._dateAdapter.getMonth(date1) == this._dateAdapter.getMonth(date2) :
this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2);
}

/** Handles keydown events on the calendar body. */
_handleCalendarBodyKeydown(event: KeyboardEvent): void {
// TODO(mmalerba): We currently allow keyboard navigation to disabled dates, but just prevent
Expand All @@ -207,6 +204,22 @@ export class MdCalendar<D> implements AfterContentInit {
}
}

/** Focuses the active cell after the microtask queue is empty. */
_focusActiveCell() {
this._ngZone.runOutsideAngular(() => this._ngZone.onStable.first().subscribe(() => {
let activeEl = this._elementRef.nativeElement.querySelector('.mat-calendar-body-active');
activeEl.focus();
}));
}

/** Whether the two dates represent the same view in the current view mode (month or year). */
private _isSameView(date1: D, date2: D): boolean {
return this._monthView ?
this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2) &&
this._dateAdapter.getMonth(date1) == this._dateAdapter.getMonth(date2) :
this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2);
}

/** Handles keydown events on the calendar body when calendar is in month view. */
private _handleCalendarBodyKeydownInMonthView(event: KeyboardEvent): void {
switch (event.keyCode) {
Expand Down Expand Up @@ -244,14 +257,17 @@ export class MdCalendar<D> implements AfterContentInit {
case ENTER:
if (this._dateFilterForViews(this._activeDate)) {
this._dateSelected(this._activeDate);
break;
// Prevent unexpected default actions such as form submission.
event.preventDefault();
}
return;
default:
// Don't prevent default on keys that we don't explicitly handle.
// Don't prevent default or focus active cell on keys that we don't explicitly handle.
return;
}

this._focusActiveCell();
// Prevent unexpected default actions such as form submission.
event.preventDefault();
}

Expand Down Expand Up @@ -290,10 +306,12 @@ export class MdCalendar<D> implements AfterContentInit {
this._monthSelected(this._activeDate);
break;
default:
// Don't prevent default on keys that we don't explicitly handle.
// Don't prevent default or focus active cell on keys that we don't explicitly handle.
return;
}

this._focusActiveCell();
// Prevent unexpected default actions such as form submission.
event.preventDefault();
}

Expand Down
3 changes: 2 additions & 1 deletion src/lib/datepicker/datepicker-toggle.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ $mat-datepicker-toggle-icon-size: 24px !default;

.mat-datepicker-toggle {
display: inline-block;
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="currentColor"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/></svg>') no-repeat;
// Note: SVG needs to be base64 encoded or it will not work on IE11.
background: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNHB4IiBoZWlnaHQ9IjI0cHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iY3VycmVudENvbG9yIj48cGF0aCBkPSJNMCAwaDI0djI0SDB6IiBmaWxsPSJub25lIi8+PHBhdGggZD0iTTE5IDNoLTFWMWgtMnYySDhWMUg2djJINWMtMS4xMSAwLTEuOTkuOS0xLjk5IDJMMyAxOWMwIDEuMS44OSAyIDIgMmgxNGMxLjEgMCAyLS45IDItMlY1YzAtMS4xLS45LTItMi0yem0wIDE2SDVWOGgxNHYxMXpNNyAxMGg1djVIN3oiLz48L3N2Zz4=') no-repeat;
background-size: contain;
height: $mat-datepicker-toggle-icon-size;
width: $mat-datepicker-toggle-icon-size;
Expand Down
26 changes: 23 additions & 3 deletions src/lib/datepicker/datepicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import {
ChangeDetectionStrategy,
Component,
ComponentRef,
ElementRef,
EventEmitter,
Inject,
Input,
OnDestroy,
Optional,
Output,
ViewChild,
ViewContainerRef,
ViewEncapsulation
} from '@angular/core';
Expand All @@ -33,6 +33,8 @@ import {MdDialogConfig} from '../dialog/dialog-config';
import {DateAdapter} from '../core/datetime/index';
import {createMissingDateImplError} from './datepicker-errors';
import {MD_DATE_FORMATS, MdDateFormats} from '../core/datetime/date-formats';
import {ESCAPE} from '../core/keyboard/keycodes';
import {MdCalendar} from './calendar';


/** Used to generate a unique ID for each datepicker instance. */
Expand All @@ -54,17 +56,35 @@ let datepickerUid = 0;
host: {
'class': 'mat-datepicker-content',
'[class.mat-datepicker-content-touch]': 'datepicker.touchUi',
'(keydown)': '_handleKeydown($event)',
},
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MdDatepickerContent<D> implements AfterContentInit {
datepicker: MdDatepicker<D>;

constructor(private _elementRef: ElementRef) {}
@ViewChild(MdCalendar) _calendar: MdCalendar<D>;

ngAfterContentInit() {
this._elementRef.nativeElement.querySelector('.mat-calendar-content').focus();
this._calendar._focusActiveCell();
}

/**
* Handles keydown event on datepicker content.
* @param event The event.
*/
_handleKeydown(event: KeyboardEvent): void {
switch (event.keyCode) {
case ESCAPE:
this.datepicker.close();
break;
default:
// Return so that we don't preventDefault on keys that are not explicitly handled.
return;
}

event.preventDefault();
}
}

Expand Down
5 changes: 3 additions & 2 deletions src/lib/datepicker/month-view.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<table class="mat-calendar-table">
<thead class="mat-calendar-table-header">
<tr><th *ngFor="let day of _weekdays">{{day}}</th></tr>
<tr><th class="mat-calendar-table-header-divider" colspan="7"></th></tr>
<tr><th *ngFor="let day of _weekdays" [attr.aria-label]="day.long">{{day.narrow}}</th></tr>
<tr><th class="mat-calendar-table-header-divider" colspan="7" aria-hidden="true"></th></tr>
</thead>
<tbody md-calendar-body
role="grid"
[label]="_monthLabel"
[rows]="_weeks"
[todayValue]="_todayDate"
Expand Down
18 changes: 12 additions & 6 deletions src/lib/datepicker/month-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export class MdMonthView<D> implements AfterContentInit {
_todayDate: number;

/** The names of the weekdays. */
_weekdays: string[];
_weekdays: {long: string, narrow: string}[];

constructor(@Optional() public _dateAdapter: DateAdapter<D>,
@Optional() @Inject(MD_DATE_FORMATS) private _dateFormats: MdDateFormats) {
Expand All @@ -91,9 +91,13 @@ export class MdMonthView<D> implements AfterContentInit {
}

const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek();
const weekdays = this._dateAdapter.getDayOfWeekNames('narrow');
const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow');
const longWeekdays = this._dateAdapter.getDayOfWeekNames('long');

// Rotate the labels for days of the week based on the configured first day of the week.
let weekdays = longWeekdays.map((long, i) => {
return {long, narrow: narrowWeekdays[i]};
});
this._weekdays = weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek));

this._activeDate = this._dateAdapter.today();
Expand Down Expand Up @@ -140,12 +144,14 @@ export class MdMonthView<D> implements AfterContentInit {
this._weeks.push([]);
cell = 0;
}
let date = this._dateAdapter.createDate(
this._dateAdapter.getYear(this.activeDate),
this._dateAdapter.getMonth(this.activeDate), i + 1);
let enabled = !this.dateFilter ||
this.dateFilter(this._dateAdapter.createDate(
this._dateAdapter.getYear(this.activeDate),
this._dateAdapter.getMonth(this.activeDate), i + 1));
this.dateFilter(date);
let ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel);
this._weeks[this._weeks.length - 1]
.push(new MdCalendarCell(i + 1, dateNames[i], enabled));
.push(new MdCalendarCell(i + 1, dateNames[i], ariaLabel, enabled));
}
}

Expand Down
1 change: 1 addition & 0 deletions src/lib/datepicker/year-view.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<tr><th class="mat-calendar-table-header-divider" colspan="7"></th></tr>
</thead>
<tbody md-calendar-body
role="grid"
allowDisabledSelection="true"
[label]="_yearLabel"
[rows]="_months"
Expand Down
5 changes: 4 additions & 1 deletion src/lib/datepicker/year-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,11 @@ export class MdYearView<D> implements AfterContentInit {

/** Creates an MdCalendarCell for the given month. */
private _createCellForMonth(month: number, monthName: string) {
let ariaLabel = this._dateAdapter.format(
this._dateAdapter.createDate(this._dateAdapter.getYear(this.activeDate), month, 1),
this._dateFormats.display.monthYearA11yLabel);
return new MdCalendarCell(
month, monthName.toLocaleUpperCase(), this._isMonthEnabled(month));
month, monthName.toLocaleUpperCase(), ariaLabel, this._isMonthEnabled(month));
}

/** Whether the given month is enabled. */
Expand Down

0 comments on commit 9a9d453

Please sign in to comment.