Bug 1676068 - Datepicker Pt.2 - Add ARIA Spinbutton properties and updates CSS for consistency. r=Jamie,morgan,fluent-reviewers,mconley,kcochrane,flod

Done:
- Provided spinner component with expected ARIA roles and properties for a Spinbutton pattern
- Ensured the programmatic and on-screen visibility is handled when a Spinner dialog is opened/closed
- Provided localized strings for controls of the Spinner
- Updated markup of the Spinner dialog to ensure logical keyboard navigation and consistent on-screen presentation
- Handled live region for the month-year button with and without spinners visible to avoid redundant announcements
- Added tests for the month-year spinner and its localization

Further patches:
1. Pt.4 - Ensure keyboard support in accordance with the ARIA Design Practices 1.2

Depends on D139980

Differential Revision: https://phabricator.services.mozilla.com/D139981
This commit is contained in:
ayeddi 2022-12-01 22:35:07 +00:00
Родитель b13a65e0cc
Коммит 9a70801bcb
8 изменённых файлов: 345 добавлений и 5 удалений

Просмотреть файл

@ -22,7 +22,7 @@
<div class="month-year-nav" data-l10n-id="date-spinner-label">
<button class="prev" data-l10n-id="date-picker-previous" />
<div class="month-year-container">
<button class="month-year" id="month-year-label" />
<button class="month-year" id="month-year-label" aria-live="polite" />
</div>
<template id="spinner-template">
<div class="spinner-container">

Просмотреть файл

@ -84,6 +84,7 @@ run-if = crashreporter
[browser_datetime_datepicker_markup.js]
[browser_datetime_datepicker_keynav.js]
[browser_datetime_datepicker_mousenav.js]
[browser_spinner.js]
skip-if =
tsan # Frequently times out on TSan
os == "win" && asan && fission # fails on asan/fission

Просмотреть файл

@ -115,7 +115,7 @@ async function verifyPickerPosition(browsingContext, inputId) {
function is_close(got, exp, msg) {
// on some platforms we see differences of a fraction of a pixel - so
// allow any difference of < 1 pixels as being OK.
ok(
Assert.ok(
Math.abs(got - exp) < 1,
msg + ": " + got + " should be equal(-ish) to " + exp
);
@ -635,7 +635,7 @@ add_task(async function test_datetime_focus_to_input() {
return content.document.querySelector("#datetime").matches(":focus");
});
ok(isFocused, "<input> should still be focused");
Assert.ok(isFocused, "<input> should still be focused");
await helper.tearDown();
});
@ -731,7 +731,7 @@ add_task(async function test_datetime_local_min_select_invalid() {
);
Assert.equal(value, "2016-12-05T05:00", "Value should've changed");
ok(invalid, "input should be now invalid");
Assert.ok(invalid, "input should be now invalid");
await helper.tearDown();
});

Просмотреть файл

@ -50,6 +50,16 @@ add_task(async function test_datepicker_markup() {
"button",
"Month picker view toggle is a button"
);
Assert.equal(
helper.getElement(MONTH_YEAR).getAttribute("aria-expanded"),
"false",
"Month picker view toggle is collapsed when the dialog is hidden"
);
Assert.equal(
helper.getElement(MONTH_YEAR).getAttribute("aria-live"),
"polite",
"Month picker view toggle is a live region when it's not expanded"
);
Assert.ok(
BrowserTestUtils.is_hidden(helper.getElement(MONTH_YEAR_VIEW)),
"Month-year selection spinner is not visible"
@ -146,6 +156,7 @@ add_task(async function test_datepicker_l10n() {
},
];
// Check "aria-label" attributes
for (let { selector, id, args } of testcases) {
const el = helper.getElement(selector);
const l10nAttrs = document.l10n.getAttributes(el);

Просмотреть файл

@ -0,0 +1,228 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const MONTH_YEAR = ".month-year",
BTN_MONTH_YEAR = "#month-year-label",
SPINNER_MONTH = "#spinner-month",
SPINNER_YEAR = "#spinner-year";
const DATE_FORMAT = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
timeZone: "UTC",
}).format;
/**
* Helper function to check if a DOM element has a specific attribute
*
* @param {DOMElement} el: DOM Element to be tested
* @param {String} attr: The name of the attribute to be tested
*/
function testAttribute(el, attr) {
Assert.ok(
el.hasAttribute(attr),
`The "${el}" element has a "${attr}" attribute`
);
}
/**
* Helper function to check for localization attributes of a DOM element
*
* @param {DOMElement} el: DOM Element to be tested
* @param {String} id: Value of the "data-l10n-id" attribute of the element
* @param {Object} args: Args provided by the l10n object of the element
*/
function testLocalization(el, id, args = null) {
const l10nAttrs = document.l10n.getAttributes(el);
Assert.deepEqual(
l10nAttrs,
{
id,
args,
},
`The "${id}" element is localizable`
);
}
/**
* Helper function to check for l10n of an element's attribute
*
* @param {DOMElement} el: DOM Element to be tested
* @param {String} attr: The name of the attribute to be tested
* @param {String} id: Value of the "data-l10n-id" attribute of the element
* @param {Object} args: Args provided by the l10n object of the element
*/
function testAttributeL10n(el, attr, id, args = null) {
testAttribute(el, attr);
testLocalization(el, id, args);
}
let helper = new DateTimeTestHelper();
registerCleanupFunction(() => {
helper.cleanup();
});
/**
* Test that the Month spinner opens with an accessible markup
*/
add_task(async function test_spinner_month_markup() {
info("Test that the Month spinner opens with an accessible markup");
const inputValue = "2022-09-09";
await helper.openPicker(
`data:text/html, <input type="date" value="${inputValue}">`
);
helper.click(helper.getElement(MONTH_YEAR));
const spinnerMonth = helper.getElement(SPINNER_MONTH);
const spinnerMonthPrev = spinnerMonth.children[0];
const spinnerMonthBtn = spinnerMonth.children[1];
const spinnerMonthNext = spinnerMonth.children[2];
Assert.equal(
spinnerMonthPrev.tagName,
"button",
"Spinner's Previous Month control is a button"
);
Assert.equal(
spinnerMonthBtn.getAttribute("role"),
"spinbutton",
"Spinner control is a spinbutton"
);
Assert.equal(
spinnerMonthBtn.getAttribute("tabindex"),
"0",
"Spinner control is included in the focus order"
);
Assert.equal(
spinnerMonthBtn.getAttribute("aria-valuemin"),
"0",
"Spinner control has a min value set"
);
Assert.equal(
spinnerMonthBtn.getAttribute("aria-valuemax"),
"11",
"Spinner control has a max value set"
);
// September 2022 as an example
Assert.equal(
spinnerMonthBtn.getAttribute("aria-valuenow"),
"8",
"Spinner control has a current value set"
);
Assert.equal(
spinnerMonthNext.tagName,
"button",
"Spinner's Next Month control is a button"
);
testAttribute(spinnerMonthBtn, "aria-valuetext");
let visibleEls = spinnerMonthBtn.querySelectorAll(
":scope > :not([aria-hidden])"
);
Assert.equal(
visibleEls.length,
0,
"There should be no children of the spinner without aria-hidden"
);
info("Test that the month spinner has localizable labels");
testAttributeL10n(
spinnerMonthPrev,
"aria-label",
"date-spinner-month-previous"
);
testAttributeL10n(spinnerMonthBtn, "aria-label", "date-spinner-month");
testAttributeL10n(spinnerMonthNext, "aria-label", "date-spinner-month-next");
await helper.tearDown();
});
/**
* Test that the Year spinner opens with an accessible markup
*/
add_task(async function test_spinner_year_markup() {
info("Test that the year spinner opens with an accessible markup");
const inputValue = "2022-06-06";
const inputMin = "2020-06-01";
const inputMax = "2030-12-31";
await helper.openPicker(
`data:text/html, <input type="date" value="${inputValue}" min="${inputMin}" max="${inputMax}">`
);
helper.click(helper.getElement(MONTH_YEAR));
const spinnerYear = helper.getElement(SPINNER_YEAR);
const spinnerYearPrev = spinnerYear.children[0];
const spinnerYearBtn = spinnerYear.children[1];
const spinnerYearNext = spinnerYear.children[2];
Assert.equal(
spinnerYearPrev.tagName,
"button",
"Spinner's Previous Year control is a button"
);
Assert.equal(
spinnerYearBtn.getAttribute("role"),
"spinbutton",
"Spinner control is a spinbutton"
);
Assert.equal(
spinnerYearBtn.getAttribute("tabindex"),
"0",
"Spinner control is included in the focus order"
);
Assert.equal(
spinnerYearBtn.getAttribute("aria-valuemin"),
"2020",
"Spinner control has a min value set, when the range is provided"
);
// 2020-2030 range is an example
Assert.equal(
spinnerYearBtn.getAttribute("aria-valuemax"),
"2030",
"Spinner control has a max value set, when the range is provided"
);
// June 2022 is an example
Assert.equal(
spinnerYearBtn.getAttribute("aria-valuenow"),
"2022",
"Spinner control has a current value set"
);
Assert.equal(
spinnerYearNext.tagName,
"button",
"Spinner's Next Year control is a button"
);
testAttribute(spinnerYearBtn, "aria-valuetext");
let visibleEls = spinnerYearBtn.querySelectorAll(
":scope > :not([aria-hidden])"
);
Assert.equal(
visibleEls.length,
0,
"There should be no children of the spinner without aria-hidden"
);
info("Test that the year spinner has localizable labels");
testAttributeL10n(
spinnerYearPrev,
"aria-label",
"date-spinner-year-previous"
);
testAttributeL10n(spinnerYearBtn, "aria-label", "date-spinner-year");
testAttributeL10n(spinnerYearNext, "aria-label", "date-spinner-year-next");
await helper.tearDown();
});

Просмотреть файл

@ -392,6 +392,7 @@ function DatePicker(context) {
),
};
this._updateButtonLabels();
this._attachEventListeners();
}
@ -410,9 +411,13 @@ function DatePicker(context) {
*/
setProps(props) {
this.context.monthYear.textContent = this.state.dateFormat(props.dateObj);
const spinnerDialog = this.context.monthYearView.parentNode;
if (props.isVisible) {
this.context.monthYear.classList.add("active");
this.context.monthYear.setAttribute("aria-expanded", "true");
// To prevent redundancy, as spinners will announce their value on change
this.context.monthYear.setAttribute("aria-live", "off");
this.components.month.setState({
value: props.dateObj.getUTCMonth(),
items: props.months,
@ -428,8 +433,21 @@ function DatePicker(context) {
smoothScroll: !(this.state.firstOpened || props.noSmoothScroll),
});
this.state.firstOpened = false;
// Set up spinner dialog container properties for assistive technology:
spinnerDialog.setAttribute("role", "dialog");
spinnerDialog.setAttribute("aria-modal", "true");
} else {
this.context.monthYear.classList.remove("active");
this.context.monthYear.setAttribute("aria-expanded", "false");
// To ensure calendar month's changes are announced:
this.context.monthYear.setAttribute("aria-live", "polite");
// Remove spinner dialog container properties to ensure this hidden
// modal will be ignored by assistive technology, because even though
// the dialog is hidden, the toggle button is a visible descendant,
// so we must not treat its container as a dialog:
spinnerDialog.removeAttribute("role");
spinnerDialog.removeAttribute("aria-modal");
this.state.isMonthSet = false;
this.state.isYearSet = false;
this.state.firstOpened = true;
@ -451,6 +469,37 @@ function DatePicker(context) {
}
},
/**
* Update localizable IDs of the spinner and its Prev/Next buttons
*/
_updateButtonLabels() {
document.l10n.setAttributes(
this.components.month.elements.spinner,
"date-spinner-month"
);
document.l10n.setAttributes(
this.components.year.elements.spinner,
"date-spinner-year"
);
document.l10n.setAttributes(
this.components.month.elements.up,
"date-spinner-month-previous"
);
document.l10n.setAttributes(
this.components.month.elements.down,
"date-spinner-month-next"
);
document.l10n.setAttributes(
this.components.year.elements.up,
"date-spinner-year-previous"
);
document.l10n.setAttributes(
this.components.year.elements.down,
"date-spinner-year-next"
);
document.l10n.translateRoots();
},
/**
* Attach event listener to monthYear button
*/

Просмотреть файл

@ -80,6 +80,11 @@ function Spinner(props, context) {
this.elements.spinner.style.height = ITEM_HEIGHT * viewportSize + "rem";
// Prepares the spinner container to function as a spinbutton and expose
// its properties to assistive technology
this.elements.spinner.setAttribute("role", "spinbutton");
this.elements.spinner.setAttribute("tabindex", "0");
if (id) {
this.elements.container.id = id;
}
@ -128,7 +133,24 @@ function Spinner(props, context) {
}
}
if (isValueSet && !isInvalid) {
this.elements.spinner.setAttribute(
"aria-valuemin",
this.state.items[0].value
);
this.elements.spinner.setAttribute(
"aria-valuemax",
this.state.items.at(-1).value
);
this.elements.spinner.setAttribute("aria-valuenow", this.state.value);
if (!this.elements.spinner.getAttribute("aria-valuetext")) {
this.elements.spinner.setAttribute(
"aria-valuetext",
this.props.getDisplayString(this.state.value)
);
}
// Show selection even if it's passed down from the parent
if ((isValueSet && !isInvalid) || this.state.index) {
this._updateSelection();
} else {
this._removeSelection();
@ -177,6 +199,10 @@ function Spinner(props, context) {
*/
_onScrollend() {
this.elements.spinner.classList.remove("scrolling");
this.elements.spinner.setAttribute(
"aria-valuetext",
this.props.getDisplayString(this.state.value)
);
},
/**
@ -240,6 +266,8 @@ function Spinner(props, context) {
for (let i = 0; i < diff; i++) {
let el = document.createElement("div");
// Spinbutton elements should be hidden from assistive technology:
el.setAttribute("aria-hidden", "true");
frag.appendChild(el);
this.elements.itemsViewElements.push(el);
}
@ -502,6 +530,7 @@ function Spinner(props, context) {
*/
_removeSelection() {
const { selected } = this.elements;
if (selected) {
selected.classList.remove("selection");
}

Просмотреть файл

@ -20,3 +20,25 @@ date-picker-previous =
.aria-label = Previous month
date-picker-next =
.aria-label = Next month
## These labels are used by screenreaders and other assistive technology
## to indicate the type of a value/unit that is being selected within a
## Month/Year date spinner dialogs on a datepicker calendar dialog
date-spinner-month =
.aria-label = Month
date-spinner-year =
.aria-label = Year
## These labels are used by screenreaders and other assistive technology
## to indicate the purpose of buttons that leaf through either months
## or years of a Month/Year date spinner on a datepicker calendar dialog
date-spinner-month-previous =
.aria-label = Previous month
date-spinner-month-next =
.aria-label = Next month
date-spinner-year-previous =
.aria-label = Previous year
date-spinner-year-next =
.aria-label = Next year