Skip to content

Commit

Permalink
fix(material/datepicker): add aria-descriptions to calendar for start…
Browse files Browse the repository at this point in the history
…/end dates

For date ranges, add aria-descriptions to the cell of the current start
date and also for end date.

Popuplate aria descriptions with the expected value of the ARIA accessible name of the
`matStartDate` and `matEndDate` inputs. Introduces `_computeAriaAccessibleName` function to
implement ARIA acc-name-1.2 specificiation.

Fixes angular#23442 and angular#23445
  • Loading branch information
zarend committed Aug 12, 2022
1 parent 00f4abe commit e99bd68
Show file tree
Hide file tree
Showing 15 changed files with 478 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
159 changes: 159 additions & 0 deletions src/material/datepicker/aria-accessible-name.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import {_computeAriaAccessibleName} from './aria-accessible-name';

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

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

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

it('uses 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 as HTMLInputElement)).toBe('Aria Labelledby');
});

it('uses 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 as HTMLInputElement)).toBe('Aria Label');
});

it('uses 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 as HTMLInputElement)).toBe('For');
});

it('returns title when argument has a specifieid title', () => {
rootElement.innerHTML = `<input id="test-el" title='Title'/>`;

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

// match browser behavior of giving placeholder attribute preference over title attribute
it('uses placeholder over title', () => {
rootElement.innerHTML = `<input id="test-el" title='Title' placeholder='Placeholder'/>`;

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

it('uses aria-label over 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 as HTMLInputElement)).toBe('Aria Label');
});

it('includes both textnode and element children of label with for/id', () => {
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 as HTMLInputElement)).toBe('Hello Wo r ld !');
});

it('return computed name of hidden 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 as HTMLInputElement)).toBe('For');
});

it('returns 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 as HTMLInputElement)).toBe('Label1 Label2');
});

it('returns repeated label 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 as HTMLInputElement)).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 as HTMLInputElement)).toBe('');
});

it('ignores the aria-labelledby of an aria-labelledby', () => {
rootElement.innerHTML = `
<label id="label" aria-labelledby="transitive-label">Label</label>
<label id="transitive-label" aria-labelled-by="transitive-label">Transitive Label</div>
<input id="test-el" aria-labelledby="label"/>
`;

const input = rootElement.querySelector('#test-el')!;
const label = rootElement.querySelector('#label')!;
expect(_computeAriaAccessibleName(label as any)).toBe('Transitive Label');
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Label');
});

it('ignores the aria-labelledby on a label with for/id', () => {
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 as HTMLInputElement)).toBe('');
});

it('returns empty string when argument input 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 as HTMLInputElement);
expect(typeof computedName)
.withContext('should return value of type string')
.toBe('string');
});
});
148 changes: 148 additions & 0 deletions src/material/datepicker/aria-accessible-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

/**
* Computes what we *expect* the ARIA accessible name would be for argument element. There is not an
* API available to ask what the ARIA accessible name of an element is, so we determine what we
* expect it to be.
*
* ARIA specification [Accessible Name and Description Computation 1.2](
* https://www.w3.org/TR/accname-1.2/) defines how to calculate an accessible name. This function
* partially implements accname-1.2. Implements a subset of accname-1.2 to be used for the
* Datepicker's use case of the `matStartDate` and `matEndDate` inputs. This is not a general
* use implementation, and it is inteded to *only* be used for the Datepicker.
*
* Argument element is the "Root node" is the accname-1.2 specification.
*
* Limitations:
* - Only covers the needs of `matStartDate` and `matEndDate`. Does not support other use cases.
* - See FIXME's in code for specific details on what parts of the accname-1.2 specification are
* not implemented.
*
* To summarize this algorithm, it trys the following methods in order and returns result of first
* method that works.
*
* 1. `aria-labelledby` attribute
* ```
* <!-- example using aria-labelledby-->
* <label id='label'>Start Date</label>
* <input aria-labelledby='label'/>
* ```
* 2. `aria-label` attribute (e.g. `<input aria-label="Departure"/>`)
* 3. Label with `for`/`id`
* ```
* <!-- example using for/id -->
* <label for="current-node">Label</label>
* <input id="current-node"/>
* ```
* 4. `placeholder` attribute (e.g. `<input placeholder="06/03/1990"/>`)
* 5. `title` attribute (e.g. `<input title="Check-In"/>`)
* 6. text content
* ```
* <!-- example using text content -->
* <label for="current-node"><span>Departure</span> Date</label>
* <input id="current-node"/>
* ```
*
* @param element {HTMLInputElement} native <input/> element of `matStartDate` or `matEndDate` component.
*
* @return expected ARIA accessible name of argument <input/>
*/
export function _computeAriaAccessibleName(element: HTMLInputElement): string {
return _computeAriaAccessibleNameInternal(element, true);
}

/**
* Calculate the expected ARIA accessible name for given DOM Node. Given DOM Node may be either the "Root node" or "Current node" from accname-1.2 specification.
*
* @return the accessible name of argument DOM Node
*
* @param currentNode node to determine accessible name of
* @param isDirectlyReferenced true if `currentNode` is the root node to calculate ARIA accessible
* name of. False if it is a result of recursion.
*/
function _computeAriaAccessibleNameInternal(
currentNode: Node,
isDirectlyReferenced: boolean,
): string {
// FIXME: Implement Step 1. of accname-1.2: '''If `currentNode`'s role prohibits naming, return the
// empty string ("")'''.

// FIXME: Implement Step 2.A. of accname-1.2: '''if current node is hidden and not directly referenced by
// aria-labelledby... return the empty string.'''

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

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

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

if (ariaLabel) {
return ariaLabel;
}
}

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

// use the input's placeholder if available
const placeholder = currentNode.getAttribute('placeholder')?.trim();
if (placeholder) return placeholder;

// use the input's title if available
const title = currentNode.getAttribute('title')?.trim();
if (title) return title;
}

// FIXME: implement acc-name-1.2 Step 2.E.: '''if the current node is a control embedded
// within the label... then include the embedded control as part of the text alternative in the
// following manner...'''. Step 2E applies to embedded controls such as textbox, listbox, range,
// etc.

// FIXME: Implement from acc-name-1.2 step 2.F.: check that '''role allows name from content''',
// which applies to `currentNode` and its children.
// of `currentNode`.
// FIXME: Implement acc-name-1.2 Step 2.F.ii.: '''Check for CSS generated textual content''' (e.g.
// :before and :after).

// FIXME: Implement acc-name-1.2 Step 2.I.: '''if the current node has a Tooltip attribute, return
// its value'''

// Return text content with whitespace collapsed into a single space character. Accomplish
// acc-name-1.2 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>

0 comments on commit e99bd68

Please sign in to comment.