tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 7374c542014937ba08c1df36cf4b17abdad25379
parent 00d49864ddaf2301fa40f3cbf3a76a048e7e2a79
Author: Anna Yeddi <ayeddi@mozilla.com>
Date:   Fri, 24 Oct 2025 15:25:35 +0000

Bug 1802201 - Make timepicker on datetimepicker panel accessible. r=fluent-reviewers,mconley,Jamie,emilio,flod,desktop-theme-reviewers,tgiles,bolsson

This patch makes the time picker panel accessible by implementing proper ARIA roles and properties, and ensuring the navigation with keyboard and assistive technology is working as expected.

Accessibility improvements:
- Time picker panel uses dialog role with an informative accessible name (when shown alone, as a timepicker)
- Spinbuttons and their increment/decrement controls on the timepicker have accessible names
- All the timepicker's ARIA labels are localized
- L10n files for datetimepickers are merged and migration script provided for the existing datepicker strings
- Datetimepicker dialog markup is adjusted to accommodate both date and time pickers when they are visible together

Interaction improvements:
- Time picker panel respects prefers-reduced-motion preference
- Keyboard focus is managed on the timepicker's initialization and during interaction
- Timepicker handles `Space`/`Enter` and `Escape` keys as expected
- Keyboard events between the date and time picker sections in a combined datetimepicker panel are isolated

Tests are added for time picker panel and `fail-if` expectation is removed from the timepicker mochitest manifest.

Differential Revision: https://phabricator.services.mozilla.com/D167463

Diffstat:
Apython/l10n/fluent_migrations/bug_1802201_timepicker_input_accessibility.py | 42++++++++++++++++++++++++++++++++++++++++++
Mtoolkit/content/datetimepicker.xhtml | 15+++++++++++----
Mtoolkit/content/tests/browser/datetime/browser.toml | 7++++++-
Mtoolkit/content/tests/browser/datetime/browser_datetime_datetimepicker.js | 4++--
Atoolkit/content/tests/browser/datetime/browser_datetime_timepicker.js | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/content/tests/browser/datetime/browser_datetime_timepicker_keynav.js | 228+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtoolkit/content/tests/browser/datetime/head.js | 22++++++++++++++++++----
Mtoolkit/content/widgets/datepicker.js | 11++++++-----
Mtoolkit/content/widgets/datetimebox.js | 6+++---
Mtoolkit/content/widgets/spinner.js | 20++++++++++----------
Mtoolkit/content/widgets/timepicker.js | 153++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Dtoolkit/locales/en-US/toolkit/global/datepicker.ftl | 48------------------------------------------------
Atoolkit/locales/en-US/toolkit/global/datetimepicker.ftl | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtoolkit/themes/shared/datetimeinputpickers.css | 12++++++------
14 files changed, 690 insertions(+), 84 deletions(-)

diff --git a/python/l10n/fluent_migrations/bug_1802201_timepicker_input_accessibility.py b/python/l10n/fluent_migrations/bug_1802201_timepicker_input_accessibility.py @@ -0,0 +1,42 @@ +# Any copyright is dedicated to the Public Domain. +# http://creativecommons.org/publicdomain/zero/1.0/ + +from fluent.migrate.helpers import transforms_from + + +def migrate(ctx): + """Bug 1802201 - Make timepicker panel for time inputs accessible, part {index}.""" + + source = "toolkit/toolkit/global/datepicker.ftl" + target = "toolkit/toolkit/global/datetimepicker.ftl" + + ctx.add_transforms( + target, + target, + transforms_from( + """ +date-picker-label = + .aria-label = {COPY_PATTERN(from_path, "date-picker-label.aria-label")} +date-spinner-label = + .aria-label = {COPY_PATTERN(from_path, "date-spinner-label.aria-label")} +date-picker-clear-button = {COPY_PATTERN(from_path, "date-picker-clear-button")} +date-picker-previous = + .aria-label = {COPY_PATTERN(from_path, "date-picker-previous.aria-label")} +date-picker-next = + .aria-label = {COPY_PATTERN(from_path, "date-picker-next.aria-label")} +date-spinner-month = + .aria-label = {COPY_PATTERN(from_path, "date-spinner-month.aria-label")} +date-spinner-year = + .aria-label = {COPY_PATTERN(from_path, "date-spinner-year.aria-label")} +date-spinner-month-previous = + .aria-label = {COPY_PATTERN(from_path, "date-spinner-month-previous.aria-label")} +date-spinner-month-next = + .aria-label = {COPY_PATTERN(from_path, "date-spinner-month-next.aria-label")} +date-spinner-year-previous = + .aria-label = {COPY_PATTERN(from_path, "date-spinner-year-previous.aria-label")} +date-spinner-year-next = + .aria-label = {COPY_PATTERN(from_path, "date-spinner-year-next.aria-label")} +""", + from_path=source, + ), + ) diff --git a/toolkit/content/datetimepicker.xhtml b/toolkit/content/datetimepicker.xhtml @@ -16,7 +16,7 @@ rel="stylesheet" href="chrome://global/skin/datetimeinputpickers.css" /> - <link rel="localization" href="toolkit/global/datepicker.ftl" /> + <link rel="localization" href="toolkit/global/datetimepicker.ftl" /> <script src="chrome://global/content/bindings/datekeeper.js"></script> <script src="chrome://global/content/bindings/spinner.js"></script> <script src="chrome://global/content/bindings/calendar.js"></script> @@ -56,13 +56,20 @@ <button id="clear-button" data-l10n-id="date-picker-clear-button" /> </div> - <div id="time-picker" hidden="true" class="picker"></div> + <div + id="time-picker" + hidden="true" + class="picker" + role="dialog" + aria-modal="true" + data-l10n-id="time-picker-label" + ></div> </div> <template id="spinner-template"> <div class="spinner-container"> - <button class="up" tabindex="-1" /> + <button class="prev" tabindex="-1" /> <div class="spinner"></div> - <button class="down" tabindex="-1" /> + <button class="next" tabindex="-1" /> </div> </template> </body> diff --git a/toolkit/content/tests/browser/datetime/browser.toml b/toolkit/content/tests/browser/datetime/browser.toml @@ -75,7 +75,6 @@ skip-if = [ ] ["browser_datetime_datetimepicker.js"] -fail-if = ["a11y_checks"] # Patch D167463 for Bug 1802201 is addressing the remaining failures skip-if = [ "os == 'linux' && fission && socketprocess_networking && !debug", # high frequency intermittent, Bug 1673140 "os == 'linux' && os_version == '24.04' && arch == 'x86_64' && opt && a11y_checks && swgl && verify-standalone", @@ -86,6 +85,12 @@ skip-if = [ ["browser_datetime_showPicker.js"] # do not skip +["browser_datetime_timepicker.js"] +# do not skip + +["browser_datetime_timepicker_keynav.js"] +# do not skip + ["browser_datetime_toplevel.js"] fail-if = ["a11y_checks"] # Bug 1854538 clicked input may not be accessible diff --git a/toolkit/content/tests/browser/datetime/browser_datetime_datetimepicker.js b/toolkit/content/tests/browser/datetime/browser_datetime_datetimepicker.js @@ -56,10 +56,10 @@ add_task(async function test_datetimepicker_time_clicked() { let browser = helper.tab.linkedBrowser; Assert.equal(helper.panel.state, "open", "Panel should be opened"); - // Click the first item (top-left corner) of the calendar + // Click the first item (top-left corner) of the time section let promise = BrowserTestUtils.waitForContentEvent(browser, "input"); helper.click( - helper.getElement(TIMEPICKER).querySelector(".spinner-container .up") + helper.getElement(TIMEPICKER).querySelector(".spinner-container .prev") ); await promise; diff --git a/toolkit/content/tests/browser/datetime/browser_datetime_timepicker.js b/toolkit/content/tests/browser/datetime/browser_datetime_timepicker.js @@ -0,0 +1,114 @@ +/* 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"; + +add_setup(async function setPrefsReducedMotion() { + // Set "prefers-reduced-motion" media to "reduce" + // to avoid intermittent scroll failures (1803612, 1803687) + await SpecialPowers.pushPrefEnv({ + set: [["ui.prefersReducedMotion", 1]], + }); + Assert.ok( + matchMedia("(prefers-reduced-motion: reduce)").matches, + "The reduce motion mode is active" + ); + + // TODO: Remove pref setting when the time picker is enabled (bug 1726107) + // Set "dom.forms.datetime.timepicker" in config to "true" + await SpecialPowers.pushPrefEnv({ + set: [["dom.forms.datetime.timepicker", true]], + }); +}); + +/** + * Test that the time spinners open with an accessible markup + */ +add_task(async function test_time_spinner_markup() { + info("Test that the time picker opens with an accessible markup"); + + await helper.openPicker(`data:text/html, <input type="time">`); + + Assert.equal(helper.panel.state, "open", "Panel should be opened"); + Assert.equal( + helper.getElement(DIALOG_TIME_PICKER).getAttribute("role"), + "dialog", + "Timepicker dialog has an appropriate ARIA role" + ); + Assert.ok( + helper.getElement(DIALOG_TIME_PICKER).getAttribute("aria-modal"), + "Timepicker dialog is a modal" + ); + + info("Test that spinners open with an accessible markup"); + + // Hour (HH): + const spinnerHour = helper.getElement(SPINNER_HOUR); + const spinnerHourPrev = helper.getElement(BTN_PREV_HOUR); + const spinnerHourNext = helper.getElement(BTN_NEXT_HOUR); + // Minute (MM): + const spinnerMin = helper.getElement(SPINNER_MIN); + const spinnerMinPrev = helper.getElement(BTN_PREV_MIN); + const spinnerMinNext = helper.getElement(BTN_NEXT_MIN); + // Time of the day (AM/PM): + const spinnerTime = helper.getElement(SPINNER_TIME); + const spinnerTimePrev = helper.getElement(BTN_PREV_TIME); + const spinnerTimeNext = helper.getElement(BTN_NEXT_TIME); + + const spinners = [spinnerHour, spinnerMin, spinnerTime]; + const prevBtns = [spinnerHourPrev, spinnerMinPrev, spinnerTimePrev]; + const nextBtns = [spinnerHourNext, spinnerMinNext, spinnerTimeNext]; + + // Check spinner controls: + for (const el of spinners) { + Assert.equal( + el.getAttribute("role"), + "spinbutton", + `Spinner control ${el.id} is a spinbutton` + ); + Assert.equal( + el.getAttribute("tabindex"), + "0", + `Spinner control ${el.id} is included in the focus order` + ); + Assert.ok( + /* "12" is a min value for Hour spinners */ + ["0", "12"].includes(el.getAttribute("aria-valuemin")), + `Spinner control ${el.id} has a min value set` + ); + Assert.ok( + /* "0" and "12" are the only values for Time of the day spinners */ + ["11", "23", "59", "12"].includes(el.getAttribute("aria-valuemax")), + `Spinner control ${el.id} has a max value set` + ); + + testAttribute(el, "aria-valuenow"); + testAttribute(el, "aria-valuetext"); + testAttribute(el, "aria-label"); + + let visibleEls = el.querySelectorAll(":scope > :not([aria-hidden])"); + Assert.equal( + visibleEls.length, + 0, + "There should be no children of the spinner without aria-hidden" + ); + + await testReducedMotionProp(el, "scroll-behavior", "smooth", "auto"); + } + + // Check Previous/Next buttons: + for (const btnGroup of [prevBtns, nextBtns]) { + for (const btn of btnGroup) { + Assert.equal( + btn.tagName, + "button", + `Spinner's ${btn.id} control is a button` + ); + + testAttribute(btn, "aria-label"); + } + } + + await helper.tearDown(); +}); diff --git a/toolkit/content/tests/browser/datetime/browser_datetime_timepicker_keynav.js b/toolkit/content/tests/browser/datetime/browser_datetime_timepicker_keynav.js @@ -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"; + +add_setup(async function setPrefsReducedMotion() { + // Set "prefers-reduced-motion" media to "reduce" + // to avoid intermittent scroll failures (1803612, 1803687) + await SpecialPowers.pushPrefEnv({ + set: [["ui.prefersReducedMotion", 1]], + }); + Assert.ok( + matchMedia("(prefers-reduced-motion: reduce)").matches, + "The reduce motion mode is active" + ); + + // TODO: Remove pref setting when the time picker is enabled (bug 1726107) + // Set "dom.forms.datetime.timepicker" in config to "true" + await SpecialPowers.pushPrefEnv({ + set: [["dom.forms.datetime.timepicker", true]], + }); +}); + +/** + * Ensure the datetime panel closes on Escape key. + */ +add_task(async function test_datetime_panel_escape() { + info("Ensure time spinners follow arrow key bindings appropriately."); + + const inputValue = "01:01"; + + await helper.openPicker( + `data:text/html, <input type="time" value="${inputValue}">` + ); + + Assert.equal(helper.panel.state, "open", "Panel should be opened"); + let closed = helper.promisePickerClosed(); + + info("Testing general keyboard navigation"); + + // Close the picker with keyboard: + EventUtils.synthesizeKey("KEY_Escape", {}); + + await closed; + + Assert.equal( + helper.panel.state, + "closed", + "Panel should be closed on Escape" + ); + + await helper.tearDown(); +}); + +/** + * Ensure time spinners follow main key bindings appropriately. + */ +add_task(async function test_time_spinner_keyboard() { + info("Ensure time spinners follow arrow key bindings appropriately."); + + const inputValue = "01:01"; + + await helper.openPicker( + `data:text/html, <input type="time" value="${inputValue}">` + ); + + Assert.equal(helper.panel.state, "open", "Panel should be opened"); + + // Hour (HH): + const spinnerHour = helper.getElement(SPINNER_HOUR); + // Minute (MM): + const spinnerMin = helper.getElement(SPINNER_MIN); + // Time of the day (AM/PM): + const spinnerTime = helper.getElement(SPINNER_TIME); + + Assert.ok( + spinnerHour.matches(":focus"), + `The keyboard focus is placed on the Hour spinner` + ); + Assert.equal( + spinnerHour.getAttribute("aria-valuenow"), + "1", + "The hour spinner is ready" + ); + Assert.equal( + spinnerMin.getAttribute("aria-valuenow"), + "1", + "The minute spinner is ready" + ); + Assert.equal( + spinnerTime.getAttribute("aria-valuenow"), + "0" /** AM */, + "The time of the day spinner is ready" + ); + + info("Testing Up Arrow key behavior of the Hour Spinner"); + + // Change the hour value from 1 to 0/12: + EventUtils.synthesizeKey("KEY_ArrowUp", {}); + + await BrowserTestUtils.waitForMutationCondition( + spinnerHour, + { attributes: "ariaValueNow" }, + () => { + return spinnerHour.ariaValueNow == "0"; + }, + `Should change to 0, instead got ${ + helper.getElement(SPINNER_HOUR).ariaValueNow + }` + ); + + Assert.equal( + spinnerHour.getAttribute("aria-valuenow"), + "0", + "Up Arrow selects the previous hour" + ); + Assert.equal( + spinnerMin.getAttribute("aria-valuenow"), + "1", + "Up Arrow on an hour spinner does not update the minute spinner" + ); + Assert.equal( + spinnerTime.getAttribute("aria-valuenow"), + "0" /** AM */, + "Up Arrow on an hour spinner does not update the time of the day spinner" + ); + + info("Testing Down Arrow key behavior of the Minute Spinner"); + + // Move focus to the MM section of the time input: + EventUtils.synthesizeKey("KEY_Tab", {}); + + Assert.ok( + spinnerMin.matches(":focus"), + `The keyboard focus is placed on the minute spinner` + ); + + // Change the hour value from 1 to 2: + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + + await BrowserTestUtils.waitForMutationCondition( + spinnerMin, + { attributes: "ariaValueNow" }, + () => { + return spinnerMin.ariaValueNow == "2"; + }, + `Should change to 2, instead got ${ + helper.getElement(SPINNER_MIN).ariaValueNow + }` + ); + + Assert.equal( + spinnerHour.getAttribute("aria-valuenow"), + "0", + "Down Arrow on a minute spinner does not update the hour spinner" + ); + Assert.equal( + spinnerMin.getAttribute("aria-valuenow"), + "2", + "Down Arrow selects the next minute" + ); + Assert.equal( + spinnerTime.getAttribute("aria-valuenow"), + "0" /** AM */, + "Down Arrow on a minute spinner does not update the time of the day spinner" + ); + + info("Testing Down Arrow key behavior of the Time of the day Spinner"); + + // Move focus to the AM/PM section of the time input: + EventUtils.synthesizeKey("KEY_ArrowRight", {}); + + Assert.ok( + spinnerTime.matches(":focus"), + `The keyboard focus is placed on the AM/PM spinner` + ); + + // Change the hour value from 0/AM to 12/PM: + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + + await BrowserTestUtils.waitForMutationCondition( + spinnerTime, + { attributes: "ariaValueNow" }, + () => { + return spinnerTime.ariaValueNow == "12"; + }, + `Should change to 12, instead got ${ + helper.getElement(SPINNER_TIME).ariaValueNow + }` + ); + + // Wait for the hour spinner to update as well, since changing AM/PM affects it + await BrowserTestUtils.waitForMutationCondition( + spinnerHour, + { attributes: "ariaValueNow" }, + () => { + return spinnerHour.ariaValueNow == "12"; + }, + `Hour should change to 12, instead got ${ + helper.getElement(SPINNER_HOUR).ariaValueNow + }` + ); + + Assert.equal( + spinnerHour.getAttribute("aria-valuenow"), + "12" /** was 0 in AM */, + "Down Arrow on a time of the day spinner updates the hour spinner within 24 hr" + ); + Assert.equal( + spinnerTime.getAttribute("aria-valuenow"), + "12" /** PM */, + "Down Arrow on a time of the day selects the PM in the spinner" + ); + + info("Testing Space/Enter key behavior of the panel"); + + let closed = helper.promisePickerClosed(); + + // Confirm the selection and close the panel on Space/Enter + EventUtils.synthesizeKey("KEY_Enter", {}); + + await closed; + + Assert.equal(helper.panel.state, "closed", "Panel should be closed on Enter"); + + await helper.tearDown(); +}); diff --git a/toolkit/content/tests/browser/datetime/head.js b/toolkit/content/tests/browser/datetime/head.js @@ -44,8 +44,12 @@ class DateTimeTestHelper { if (openMethod === "click") { await SpecialPowers.spawn(bc, [], () => { const input = content.document.querySelector("input"); - const shadowRoot = SpecialPowers.wrap(input).openOrClosedShadowRoot; - shadowRoot.getElementById("calendar-button").click(); + if (input.type == "time") { + input.click(); + } else { + const shadowRoot = SpecialPowers.wrap(input).openOrClosedShadowRoot; + shadowRoot.getElementById("calendar-button").click(); + } }); } else if (openMethod === "showPicker") { await SpecialPowers.spawn(bc, [], function () { @@ -149,18 +153,28 @@ registerCleanupFunction(() => { const BTN_MONTH_YEAR = "#month-year-label", BTN_NEXT_MONTH = ".next", BTN_PREV_MONTH = ".prev", + BTN_NEXT_HOUR = "#spinner-hour-next", + BTN_PREV_HOUR = "#spinner-hour-previous", + BTN_NEXT_MIN = "#spinner-minute-next", + BTN_PREV_MIN = "#spinner-minute-previous", + BTN_NEXT_TIME = "#spinner-time-next", + BTN_PREV_TIME = "#spinner-time-previous", BTN_CLEAR = "#clear-button", DAY_SELECTED = ".selection", DAY_TODAY = ".today", DAYS_VIEW = ".days-view", DIALOG_PICKER = "#date-picker", + DIALOG_TIME_PICKER = "#time-picker", MONTH_YEAR = ".month-year", MONTH_YEAR_NAV = ".month-year-nav", MONTH_YEAR_VIEW = ".month-year-view", SPINNER_MONTH = "#spinner-month", SPINNER_YEAR = "#spinner-year", - WEEK_HEADER = ".week-header", - TIMEPICKER = "#time-picker"; + SPINNER_HOUR = "#spinner-hour", + SPINNER_MIN = "#spinner-minute", + SPINNER_TIME = "#spinner-time", + TIMEPICKER = "#time-picker", + WEEK_HEADER = ".week-header"; const DATE_FORMAT = new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", diff --git a/toolkit/content/widgets/datepicker.js b/toolkit/content/widgets/datepicker.js @@ -266,7 +266,8 @@ function DatePicker(context) { document.addEventListener("mouseup", this, { passive: true }); document.addEventListener("pointerdown", this, { passive: true }); document.addEventListener("mousedown", this); - document.addEventListener("keydown", this); + // Only listen to events in #date-picker (not in a timepicker) + this.context.root.addEventListener("keydown", this); }, /** @@ -582,19 +583,19 @@ function DatePicker(context) { "date-spinner-year" ); document.l10n.setAttributes( - this.components.month.elements.up, + this.components.month.elements.prev, "date-spinner-month-previous" ); document.l10n.setAttributes( - this.components.month.elements.down, + this.components.month.elements.next, "date-spinner-month-next" ); document.l10n.setAttributes( - this.components.year.elements.up, + this.components.year.elements.prev, "date-spinner-year-previous" ); document.l10n.setAttributes( - this.components.year.elements.down, + this.components.year.elements.next, "date-spinner-year-next" ); document.l10n.translateRoots(); diff --git a/toolkit/content/widgets/datetimebox.js b/toolkit/content/widgets/datetimebox.js @@ -215,7 +215,7 @@ this.DateTimeBoxWidget = class { // This is to open the picker when input element is tapped on Android // or for type=time inputs (this includes padding area). this.isAndroid = this.window.navigator.appVersion.includes("Android"); - if (this.isAndroid || this.type == "time") { + if (this.showPickerOnClick) { this.mInputElement.addEventListener( "click", this, @@ -679,8 +679,8 @@ this.DateTimeBoxWidget = class { } switch (aEvent.key) { - // Toggle the picker on Space/Enter on Calendar button or Space on input, - // close on Escape anywhere. + // Toggle the date picker on Space/Enter on Calendar button or Space on input, + // time picker on Space on input, close picker on Escape anywhere. case "Escape": { if (this.mIsPickerOpen) { this.closeDateTimePicker(); diff --git a/toolkit/content/widgets/spinner.js b/toolkit/content/widgets/spinner.js @@ -35,7 +35,7 @@ function Spinner(props, context) { * as localized strings. * {Number} viewportSize [optional]: Number of items in a * viewport. - * {Boolean} hideButtons [optional]: Hide up & down buttons + * {Boolean} hideButtons [optional]: Hide Prev & Next buttons * {Number} rootFontSize [optional]: Used to support zoom in/out * } */ @@ -73,8 +73,8 @@ function Spinner(props, context) { this.elements = { container: spinnerElement.querySelector(".spinner-container"), spinner: spinnerElement.querySelector(".spinner"), - up: spinnerElement.querySelector(".up"), - down: spinnerElement.querySelector(".down"), + prev: spinnerElement.querySelector(".prev"), + next: spinnerElement.querySelector(".next"), itemsViewElements: [], }; @@ -84,11 +84,11 @@ function Spinner(props, context) { // its properties to assistive technology this.elements.spinner.setAttribute("role", "spinbutton"); this.elements.spinner.setAttribute("tabindex", "0"); - // Remove up/down buttons from the focus order, because a keyboard-only + // Remove Prev/Next buttons from the focus order, because a keyboard-only // user can adjust values by pressing Up/Down arrow keys on a spinbutton, // otherwise it creates extra, redundant tab order stops for users - this.elements.up.setAttribute("tabindex", "-1"); - this.elements.down.setAttribute("tabindex", "-1"); + this.elements.prev.setAttribute("tabindex", "-1"); + this.elements.next.setAttribute("tabindex", "-1"); if (id) { this.elements.container.id = id; @@ -326,7 +326,7 @@ function Spinner(props, context) { handleEvent(event) { const { mouseState = {}, index, itemsView } = this.state; const { viewportTopOffset, setValue } = this.props; - const { spinner, up, down } = this.elements; + const { spinner, prev, next } = this.elements; switch (event.type) { case "scroll": { @@ -343,13 +343,13 @@ function Spinner(props, context) { layerX: event.layerX, layerY: event.layerY, }; - if (event.target == up) { + if (event.target == prev) { // An "active" class is needed to simulate :active pseudo-class // because element is not focused. event.target.classList.add("active"); this._smoothScrollToIndex(index - 1); } - if (event.target == down) { + if (event.target == next) { event.target.classList.add("active"); this._smoothScrollToIndex(index + 1); } @@ -362,7 +362,7 @@ function Spinner(props, context) { } case "mouseup": { this.state.mouseState.down = false; - if (event.target == up || event.target == down) { + if (event.target == prev || event.target == next) { event.target.classList.remove("active"); } if (event.target.parentNode == spinner) { diff --git a/toolkit/content/widgets/timepicker.js b/toolkit/content/widgets/timepicker.js @@ -34,6 +34,25 @@ function TimePicker(context) { if (props.type == "date") { return; } + if (props.type == "datetime-local") { + // When both date and time pickers are shown, we have to adjust the + // picker panel markup. Otherwise one panel would include two different + // modal dialogs (which is not appropriate) and would be missing + // a common title (which is confusing). + // TODO(bug 1993756): Handle the panel dialog markups in a better location. + const timepicker = this.context; + const datetimepicker = timepicker.parentNode; + const datepicker = datetimepicker.children.namedItem("date-picker"); + // Each date and time picker to become a group instead of a modal: + timepicker.setAttribute("role", "group"); + timepicker.removeAttribute("aria-modal"); + datepicker.setAttribute("role", "group"); + datepicker.removeAttribute("aria-modal"); + // Parent container to become a modal dialog container for both groups: + datetimepicker.setAttribute("role", "dialog"); + datetimepicker.setAttribute("aria-modal", "true"); + datetimepicker.setAttribute("data-l10n-id", "datetime-picker-label"); + } this.context.hidden = false; this.props = props || {}; this._setDefaultState(); @@ -42,6 +61,10 @@ function TimePicker(context) { // TODO(bug 1828721): This is a bit sad. window.PICKER_READY = true; document.dispatchEvent(new CustomEvent("PickerReady")); + // Manage focus for a timepicker dialog: + if (props.type == "time") { + this.components.hour.elements.spinner.focus(); + } }, /* @@ -141,6 +164,7 @@ function TimePicker(context) { insertBefore: this.components.dayPeriod.elements.container, }); } + this._updateButtonIds(); }, /** @@ -227,9 +251,43 @@ function TimePicker(context) { "*" ); }, + + /** + * Dispatch CustomEvent to ask the panel to close picker. + */ + _closePopup() { + // The panel is listening to window for postMessage event, so we + // do postMessage to itself to close the panel without sending new data + window.postMessage( + { + name: "ClosePopup", + }, + "*" + ); + }, _attachEventListeners() { window.addEventListener("message", this); document.addEventListener("mousedown", this); + document.addEventListener("keydown", this); + }, + + /** + * Move the keyboard focus between spinners of the picker. + * + * @param {Boolean} isReverse: Does the navigation expected to be following + * the focus order (false) or not (true/isReverse) + */ + focusNextSpinner(isReverse) { + let focusedSpinner = document.activeElement; + let spinners = + focusedSpinner.parentNode.parentNode.querySelectorAll(".spinner"); + spinners = [...spinners]; + + let next = isReverse + ? spinners[spinners.indexOf(focusedSpinner) - 1] + : spinners[spinners.indexOf(focusedSpinner) + 1]; + + next?.focus(); }, /** @@ -246,7 +304,42 @@ function TimePicker(context) { case "mousedown": { // Use preventDefault to keep focus on input boxes event.preventDefault(); - event.target.setCapture(); + event.target.setPointerCapture(event.pointerId); + break; + } + case "keydown": { + if ( + this.context.parentNode.id == "datetime-picker" && + !event.target.closest("#time-picker") + ) { + // The target was not a timepicker (likely a datepicker) + break; + } + switch (event.key) { + case "Enter": + case " ": { + // Update the value and close the picker panel + event.stopPropagation(); + event.preventDefault(); + this._dispatchState(); + this._closePopup(); + break; + } + case "Escape": { + // Close the time picker on Escape from within the panel + event.stopPropagation(); + event.preventDefault(); + // TODO: Revert the input value to it's state before the timepicker was opened + this._closePopup(); + break; + } + case "ArrowLeft": + case "ArrowRight": { + const isReverse = event.key == "ArrowLeft"; + this.focusNextSpinner(isReverse); + break; + } + } break; } } @@ -273,6 +366,64 @@ function TimePicker(context) { }, /** + * Update attributes, localizable IDs of spinners and their Prev/Next buttons: + */ + _updateButtonIds() { + const buttons = [ + [ + this.components.hour.elements.prev, + "spinner-hour-previous", + "time-spinner-hour-previous", + ], + [ + this.components.hour.elements.spinner, + "spinner-hour", + "time-spinner-hour-label", + ], + [ + this.components.hour.elements.next, + "spinner-hour-next", + "time-spinner-hour-next", + ], + [ + this.components.minute.elements.prev, + "spinner-minute-previous", + "time-spinner-minute-previous", + ], + [ + this.components.minute.elements.spinner, + "spinner-minute", + "time-spinner-minute-label", + ], + [ + this.components.minute.elements.next, + "spinner-minute-next", + "time-spinner-minute-next", + ], + [ + this.components.dayPeriod.elements.prev, + "spinner-time-previous", + "time-spinner-day-period-previous", + ], + [ + this.components.dayPeriod.elements.spinner, + "spinner-time", + "time-spinner-day-period-label", + ], + [ + this.components.dayPeriod.elements.next, + "spinner-time-next", + "time-spinner-day-period-next", + ], + ]; + + for (const [btn, id, l10nId] of buttons) { + btn.setAttribute("id", id); + document.l10n.setAttributes(btn, l10nId); + } + }, + + /** * Set the time state and update the components with the new state. * * @param {Object} timeState diff --git a/toolkit/locales/en-US/toolkit/global/datepicker.ftl b/toolkit/locales/en-US/toolkit/global/datepicker.ftl @@ -1,48 +0,0 @@ -# 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/. - -### Datepicker - Dialog for default HTML's <input type="date"> - -## These labels are used by screenreaders and other assistive technology -## to indicate the purpose of a date picker calendar and a month-year selection -## spinner dialogs for HTML's <input type="date"> - -date-picker-label = - .aria-label = Choose a date -date-spinner-label = - .aria-label = Choose a month and a year - -## Text of the clear button - -date-picker-clear-button = Clear - -## These labels are used by screenreaders and other assistive technology -## to indicate the purpose of buttons that leaf through months of a calendar - -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 diff --git a/toolkit/locales/en-US/toolkit/global/datetimepicker.ftl b/toolkit/locales/en-US/toolkit/global/datetimepicker.ftl @@ -0,0 +1,92 @@ +# 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/. + +### Datetimepicker - Dialog for default HTML's <input type="datetime-local"> + +## These labels are used by screenreaders and other assistive technology +## to indicate the purpose of this picker as both a calendar with a month-year +## and a time selection dialog for HTML's <input type="datetime-local"> + +datetime-picker-label = + .aria-label = Choose a date and a time + +## These labels are used by screenreaders and other assistive technology +## to indicate the purpose of a date picker calendar and a month-year selection +## spinner dialogs for HTML's default <input type="date"> + +date-picker-label = + .aria-label = Choose a date +date-spinner-label = + .aria-label = Choose a month and a year + +## Text of the clear button + +date-picker-clear-button = Clear + +## These labels are used by screenreaders and other assistive technology +## to indicate the purpose of buttons that leaf through months of a calendar + +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 + +## This label is used by screenreaders and other assistive technology +## to indicate the purpose of a time picker dialog +## for HTML's default <input type="time"> + +time-picker-label = + .aria-label = Choose a time + +## These labels are used by screenreaders and other assistive technology +## to indicate the type of a value/unit that is being selected within a +## time spinners on a timepicker dialog + +time-spinner-hour-label = + .aria-label = Hour +time-spinner-minute-label = + .aria-label = Minute +# For example, in English, when the 24 hours of the day are divided into two +# periods of 12 hours, the time of the day, or the period of the day is either +# AM (for 00:00-11:59) or PM (for 12:00-23:59), i.e. noon is 12 PM, midnight - 12 AM +time-spinner-day-period-label = + .aria-label = Period of the day + +## These labels are used by screenreaders and other assistive technology +## to indicate the purpose of buttons that leaf through time units of a spinner on a timepicker dialog + +time-spinner-hour-previous = + .aria-label = Previous hour +time-spinner-hour-next = + .aria-label = Next hour +time-spinner-minute-previous = + .aria-label = Previous minute +time-spinner-minute-next = + .aria-label = Next minute +time-spinner-day-period-previous = + .aria-label = Previous period of the day +time-spinner-day-period-next = + .aria-label = Next period of the day diff --git a/toolkit/themes/shared/datetimeinputpickers.css b/toolkit/themes/shared/datetimeinputpickers.css @@ -319,14 +319,14 @@ button.month-year.active { .spinner-container > button { height: var(--spinner-button-height); -} -.spinner-container > button.up { - background-image: url("chrome://global/skin/icons/arrow-up.svg"); -} + &.prev { + background-image: url("chrome://global/skin/icons/arrow-up.svg"); + } -.spinner-container > button.down { - background-image: url("chrome://global/skin/icons/arrow-down.svg"); + &.next { + background-image: url("chrome://global/skin/icons/arrow-down.svg"); + } } .spinner-container.hide-buttons > button {