Skip to content

Commit

Permalink
wip: giving aria-description on calendar UI to clarify start vs end date
Browse files Browse the repository at this point in the history
  • Loading branch information
zarend committed Aug 9, 2022
1 parent 00d5f27 commit b4047a5
Show file tree
Hide file tree
Showing 15 changed files with 413 additions and 22 deletions.
16 changes: 8 additions & 8 deletions src/dev-app/datepicker/datepicker-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,8 @@ <h2>Range picker</h2>
[comparisonStart]="comparisonStart"
[comparisonEnd]="comparisonEnd"
[dateFilter]="filterOdd ? dateFilter : undefined!">
<input matStartDate formControlName="start" placeholder="Start date"/>
<input matEndDate formControlName="end" placeholder="End date"/>
<input matStartDate formControlName="start" aria-label="Start date"/>
<input matEndDate formControlName="end" aria-label="End date"/>
</mat-date-range-input>
<mat-datepicker-toggle [for]="range1Picker" matSuffix></mat-datepicker-toggle>
<mat-date-range-picker
Expand Down Expand Up @@ -237,8 +237,8 @@ <h2>Range picker</h2>
[comparisonStart]="comparisonStart"
[comparisonEnd]="comparisonEnd"
[dateFilter]="filterOdd ? dateFilter : undefined!">
<input matStartDate formControlName="start" placeholder="Start date"/>
<input matEndDate formControlName="end" placeholder="End date"/>
<input matStartDate formControlName="start" aria-label="Start date"/>
<input matEndDate formControlName="end" aria-label="End date"/>
</mat-date-range-input>
<mat-datepicker-toggle [for]="range2Picker" matSuffix></mat-datepicker-toggle>
<mat-date-range-picker
Expand Down Expand Up @@ -267,8 +267,8 @@ <h2>Range picker</h2>
[comparisonStart]="comparisonStart"
[comparisonEnd]="comparisonEnd"
[dateFilter]="filterOdd ? dateFilter : undefined!">
<input matStartDate formControlName="start" placeholder="Start date"/>
<input matEndDate formControlName="end" placeholder="End date"/>
<input matStartDate formControlName="start" aria-label="Start date"/>
<input matEndDate formControlName="end" aria-label="End date"/>
</mat-date-range-input>
<mat-datepicker-toggle [for]="range3Picker" matSuffix></mat-datepicker-toggle>
<mat-date-range-picker
Expand All @@ -290,8 +290,8 @@ <h2>Range picker with custom selection strategy</h2>
<mat-form-field>
<mat-label>Enter a date range</mat-label>
<mat-date-range-input [rangePicker]="range4Picker">
<input matStartDate placeholder="Start date"/>
<input matEndDate placeholder="End date"/>
<input matStartDate aria-label="Start date"/>
<input matEndDate aria-label="End date"/>
</mat-date-range-input>
<mat-datepicker-toggle [for]="range4Picker" matSuffix></mat-datepicker-toggle>
<mat-date-range-picker customRangeStrategy #range4Picker>
Expand Down
162 changes: 162 additions & 0 deletions src/material/datepicker/aria-accessible-name.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* @fileoverview A description of this module. What would someone
* new to your team want to know about the code in this file?
* (DO NOT SUBMIT as is; replace this comment.)
*/
import {_computeAriaAccessibleName} from './aria-accessible-name';

fdescribe('_computeAriaAccessibleName', () => {
let rootElement: HTMLSpanElement;

beforeEach(() => {
rootElement = document.createElement('span');
document.body.appendChild(rootElement);
});

afterEach(() => {
rootElement.innerHTML = '';
document.body.removeChild(rootElement);
});

it('should use aria-labelledby over aria-label', () => {
rootElement.innerHTML = `
<label id='test-label'>Aria Labelledby</label>
<input id='test-el' aria-labelledby='test-label' aria-label='Aria Label'/>
`;

const input = rootElement.querySelector('#test-el')!;
expect(_computeAriaAccessibleName(input)).toBe('Aria Labelledby');
});

it('should use aria-label over for/id', () => {
rootElement.innerHTML = `
<label for='test-el'>For</label>
<input id='test-el' aria-label='Aria Label'/>
`;

const input = rootElement.querySelector('#test-el')!;
expect(_computeAriaAccessibleName(input)).toBe('Aria Label');
});

it('should use a label with for/id over a title attribute', () => {
rootElement.innerHTML = `
<label for='test-el'>For</label>
<input id='test-el' title='Title'/>
`;

const input = rootElement.querySelector('#test-el')!;
expect(_computeAriaAccessibleName(input)).toBe('For');
});

it('should return the title with argument input that has only title attribute', () => {
rootElement.innerHTML = `<input id="test-el" title='Title'/>`;

const input = rootElement.querySelector('#test-el')!;
expect(_computeAriaAccessibleName(input)).toBe('Title');
});

it('should return the placeholder with argument input that has both title and placeholder', () => {
rootElement.innerHTML = `<input id="test-el" title='Title' placeholder='Placeholder'/>`;

const input = rootElement.querySelector('#test-el')!;
expect(_computeAriaAccessibleName(input)).toBe('Placeholder');
});

it('should return the aria-label with argument input that has an aria-label, title and placeholder', () => {
rootElement.innerHTML = `<input id="test-el" title='Title' placeholder='Placeholder'
aria-label="Aria Label"/>`;

const input = rootElement.querySelector('#test-el')!;
expect(_computeAriaAccessibleName(input)).toBe('Aria Label');
});

it('should include both textnode and element children', () => {
rootElement.innerHTML = `
<label for="test-el">
Hello
<span>
Wo
<span><span>r</span></span>
<span> ld </span>
</span>
!
</label>
<input id='test-el'/>
`;

const input = rootElement.querySelector('#test-el')!;
expect(_computeAriaAccessibleName(input)).toBe('Hello Wo r ld !');
});

it('return computed name of hiden label which has for/id', () => {
rootElement.innerHTML = `
<label for="test-el" aria-hidden="true" style="display: none;">For</label>
<input id='test-el'/>
`;

const input = rootElement.querySelector('#test-el')!;
expect(_computeAriaAccessibleName(input)).toBe('For');
});

it('return computed names of existing elements when 2 of 3 targets of aria-labelledby exist', () => {
rootElement.innerHTML = `
<label id="label-1-of-2" aria-hidden="true" style="display: none;">Label1</label>
<label id="label-2-of-2" aria-hidden="true" style="display: none;">Label2</label>
<input id="test-el" aria-labelledby="label-1-of-2 label-2-of-2 non-existant-label"/>
`;

const input = rootElement.querySelector('#test-el')!;
expect(_computeAriaAccessibleName(input)).toBe('Label1 Label2');
});

it('return computed name twice when there are duplicate ids in aria-labelledby', () => {
rootElement.innerHTML = `
<label id="label-1-of-1" aria-hidden="true" style="display: none;">Label1</label>
<input id="test-el" aria-labelledby="label-1-of-1 label-1-of-1"/>
`;

const input = rootElement.querySelector('#test-el')!;
expect(_computeAriaAccessibleName(input)).toBe('Label1 Label1');
});

it('returns empty string when passed `<input id="test-el"/>`', () => {
rootElement.innerHTML = `<input id="test-el"/>`;

const input = rootElement.querySelector('#test-el')!;
expect(_computeAriaAccessibleName(input)).toBe('');
});

it('returns empty string when target of aria-labelledby also has an aria-labelledby', () => {
rootElement.innerHTML = `
<label id="transitive-label">Label</label>
<div id="transitive-div" aria-labelled-by="transitive-label"></div>
<input id="test-el" aria-labelled-by="transitive-div"/>
`;

const input = rootElement.querySelector('#test-el')!;
expect(_computeAriaAccessibleName(input)).toBe('');
});

it('returns empty string when when label with for/id has an aria-labelledby', () => {
rootElement.innerHTML = `
<label for="transitive2-label" aria-labelledby="transitive2-div"></label>
<div id="transitive2-div">Div</div>
<input id="test-el" aria-labelled-by="transitive2-label"/>
`;

const input = rootElement.querySelector('#test-el')!;
expect(_computeAriaAccessibleName(input)).toBe('');
});

it('does not crash when passed input that is aria-labelledby itself', () => {
rootElement.innerHTML = `
<input id="test-el" aria-labelled-by="test-el"/>
`;

const input = rootElement.querySelector('#test-el')!;
const computedName = _computeAriaAccessibleName(input);
expect(typeof computedName)
.withContext('should return value of type string')
.toBe('string');
});
});
91 changes: 91 additions & 0 deletions src/material/datepicker/aria-accessible-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* @fileoverview A description of this module. What would someone
* new to your team want to know about the code in this file?
* (DO NOT SUBMIT as is; replace this comment.)
*/

export function _computeAriaAccessibleName(element: Element): string {
return _computeAriaAccessibleNameInternal(element, true);
}

function _computeAriaAccessibleNameInternal(
currentNode: Node,
isDirectlyReferenced: boolean,
): string {
// FIXME: If `currentNode`'s role prohibits naming, return the empty string (""). This is not
// relevant for Datepicker's use case.

// Step 2A.
// FIXME: return empty string if current element is hidden and not directly referenced by
// aria-labelledby. This is not relevant for Datepicker's use case.

// Step 2B. aria-labelledby
if (currentNode instanceof Element && isDirectlyReferenced) {
const labelledbyIds: string[] =
currentNode.getAttribute?.('aria-labelledby')?.split(/\s+/g) || [];
const validIdRefs: HTMLElement[] = labelledbyIds.reduce((accum, curr) => {
const elem = document.getElementById(curr);
if (elem) {
accum.push(elem);
}
return accum;
}, [] as HTMLElement[]);

if (validIdRefs.length) {
return validIdRefs
.map(idRef => {
return _computeAriaAccessibleNameInternal(idRef, false);
})
.join(' ');
}
}

// 2C. aria-label
if (currentNode instanceof Element) {
const ariaLabel = currentNode.getAttribute('aria-label')?.trim();

if (ariaLabel) {
return ariaLabel;
}
}

// 2D. attribute or element that defines a text alternative
// only implemented for `<input/>` element
//
// FIXME: Implement for all elements that have an attribute or element that defines a text
// alternative. Only `<label>` and `<input/>` elements are relevant for Datepicker's use case.
if (currentNode instanceof HTMLInputElement) {
// handle label with a for attribute referencing the current node
// ```
// <label for="current-node">Label</label>
// <input id="current-node"/>
// ```
const id = currentNode.id;
const fors = document.querySelectorAll(`[for="${id}"]`);
if (fors.length) {
return Array.from(fors)
.map(x => _computeAriaAccessibleNameInternal(x, false))
.join(' ');
}

// use the input's placeholder if available `<input placeholder="06/03/1990"/>`
const placeholder = currentNode.getAttribute('placeholder')?.trim();
if (placeholder) return placeholder;

// use the input's title if available `<input title="Check-In"/>`
const title = currentNode.getAttribute('title')?.trim();
if (title) return title;
}

// 2. E. embedded control's value
// FIXME: implement embedded controls such as textbox, listbox, range, etc. This is not relevant
// for Datepicker's use case.

// FIXME: Implement check in step 2F. check if current role allows for text from content
// FIXME: Implement step 2Fii. Include CSS generated textual content such as `:before` and
// `:after` pseudo element.
// FIXME: Implement Step 2I. If the curret node has a Tooltip attribute, return it's value.

// Use textContent to accomplish steps 2F, 2G and 2H.
return (currentNode.textContent || '').replace(/\s+/g, ' ').trim();
}
8 changes: 8 additions & 0 deletions src/material/datepicker/calendar-body.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
[attr.aria-disabled]="!item.enabled || null"
[attr.aria-pressed]="_isSelected(item.compareValue)"
[attr.aria-current]="todayValue === item.compareValue ? 'date' : null"
[attr.aria-describedby]="_getDescribedby(item.compareValue)"
(click)="_cellClicked(item, $event)"
(focus)="_emitActiveDateChange(item, $event)">
<div class="mat-calendar-body-cell-content mat-focus-indicator"
Expand All @@ -75,3 +76,10 @@
</button>
</td>
</tr>

<label [id]="_startDateLabelId" class="cdk-visually-hidden" aria-hidden="true">
{{startDateLabelledby}}
</label>
<label [id]="_endDateLabelId" class="cdk-visually-hidden" aria-hidden="true">
{{endDateLabelledby}}
</label>
35 changes: 35 additions & 0 deletions src/material/datepicker/calendar-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
SimpleChanges,
OnDestroy,
AfterViewChecked,
Inject,
} from '@angular/core';
import {take} from 'rxjs/operators';

Expand Down Expand Up @@ -53,6 +54,8 @@ export interface MatCalendarUserEvent<D> {
event: Event;
}

let calendarBodyId = 1;

/**
* An internal component used to display calendar data in a table.
* @docs-private
Expand Down Expand Up @@ -132,6 +135,12 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
/** End of the preview range. */
@Input() previewEnd: number | null = null;

// TODO(zarend): rename to startDateAccessibleName
@Input() startDateLabelledby: string | null;

// TODO(zarend): rename to endDateAccessibleName
@Input() endDateLabelledby: string | null;

/** Emits when a new value is selected. */
@Output() readonly selectedValueChange = new EventEmitter<MatCalendarUserEvent<number>>();

Expand Down Expand Up @@ -356,6 +365,26 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
return isInRange(value, this.previewStart, this.previewEnd, this.isRange);
}

/** Gets ids of aria descriptions for the start and end of a date range. */
_getDescribedby(value: number): string | null {
if (!this.isRange) {
return null;
}
const ids: string[] = [];

if (this.startValue === value) {
ids.push(this._startDateLabelId);
}
if (this.endValue === value) {
ids.push(this._endDateLabelId);
}

if (ids.length) {
return ids.join(' ');
}
return null;
}

/**
* Event handler for when the user enters an element
* inside the calendar body (e.g. by hovering in or focus).
Expand Down Expand Up @@ -413,6 +442,12 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {

return null;
}

private _id = `mat-calendar-body-${calendarBodyId++}`;

_startDateLabelId = `${this._id}-start-date`;

_endDateLabelId = `${this._id}-end-date`;
}

/** Checks whether a node is a table cell element. */
Expand Down
2 changes: 2 additions & 0 deletions src/material/datepicker/calendar.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
[dateClass]="dateClass"
[comparisonStart]="comparisonStart"
[comparisonEnd]="comparisonEnd"
[startDateLabelledby]="startDateLabelledby"
[endDateLabelledby]="endDateLabelledby"
(_userSelection)="_dateSelected($event)">
</mat-month-view>

Expand Down

0 comments on commit b4047a5

Please sign in to comment.