зеркало из https://github.com/mozilla/gecko-dev.git
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:
Родитель
b13a65e0cc
Коммит
9a70801bcb
|
@ -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
|
||||
|
|
Загрузка…
Ссылка в новой задаче