Bug 1803608 - Update calendar code to handle key events using dateKeeper. r=mconley,kcochrane

Adds `focusedDate` into the Calendar state object to track and update focusable elements for the grid when a dateView is rendered and especially handle keyboard events. The calculation of the next focused date is improved and delegated to the combination of the dateKeeper's `setCalendarMonth` method and vanilla JavaScript Date object methods.

This patch also refactors the logic for updating the grid based on the different states of the next focused day (i.e. when it is a day from another month, when it is the same day of another month, or the first of the month). This resolves the [Page Up/Page Down related bug 1806645](https://bugzilla.mozilla.org/show_bug.cgi?id=1806645) as well.

Correct focus placement when Previous/Next Month buttons are used. This will be another patch in this stack - in the D167310

Differential Revision: https://phabricator.services.mozilla.com/D167099
This commit is contained in:
Anna Yeddi
2023-01-20 21:39:58 +00:00
parent 1b7d73c75e
commit ce22b51a6f
3 changed files with 159 additions and 159 deletions

View File

@@ -309,7 +309,8 @@ add_task(async function test_datepicker_markup_refresh() {
"Less than min date is programmatically disabled" "Less than min date is programmatically disabled"
); );
// Change month view to check an updated markup // Change month view from December 2016 to January 2017
// to check an updated markup
helper.click(helper.getElement(BTN_NEXT_MONTH)); helper.click(helper.getElement(BTN_NEXT_MONTH));
const secondRowJan = helper.getChildren(DAYS_VIEW)[1].children; const secondRowJan = helper.getChildren(DAYS_VIEW)[1].children;
@@ -337,10 +338,16 @@ add_task(async function test_datepicker_markup_refresh() {
!secondRowJan[0].hasAttribute("aria-disabled"), !secondRowJan[0].hasAttribute("aria-disabled"),
"Day with the same as less than min date is not programmatically disabled" "Day with the same as less than min date is not programmatically disabled"
); );
// 2016-12-05 was focused before the change, thus the same day of the month
// is expected to be focused now (2017-01-05):
Assert.equal( Assert.equal(
secondRowJan[0].getAttribute("tabindex"), secondRowJan[4].getAttribute("tabindex"),
"0", "0",
"The first day of the month is made focusable" "The same day of the month is made focusable"
);
Assert.ok(
!secondRowJan[0].hasAttribute("tabindex"),
"The first day of the month is not focusable"
); );
Assert.ok( Assert.ok(
!secondRowJan[1].hasAttribute("tabindex"), !secondRowJan[1].hasAttribute("tabindex"),

View File

@@ -14,7 +14,8 @@
* {Function} getDayString: Transform day number to string * {Function} getDayString: Transform day number to string
* {Function} getWeekHeaderString: Transform day of week number to string * {Function} getWeekHeaderString: Transform day of week number to string
* {Function} setSelection: Set selection for dateKeeper * {Function} setSelection: Set selection for dateKeeper
* {Function} setMonthByOffset: Update the month shown by the dateView * {Function} setCalendarMonth: Update the month shown by the dateView
* to a specific month of a specific year
* } * }
* @param {Object} context * @param {Object} context
* { * {
@@ -29,9 +30,10 @@ function Calendar(options, context) {
days: [], days: [],
weekHeaders: [], weekHeaders: [],
setSelection: options.setSelection, setSelection: options.setSelection,
setMonthByOffset: options.setMonthByOffset, setCalendarMonth: options.setCalendarMonth,
getDayString: options.getDayString, getDayString: options.getDayString,
getWeekHeaderString: options.getWeekHeaderString, getWeekHeaderString: options.getWeekHeaderString,
focusedDate: null,
}; };
this.elements = { this.elements = {
weekHeaders: this._generateNodes( weekHeaders: this._generateNodes(
@@ -95,9 +97,10 @@ Calendar.prototype = {
items: weekHeaders, items: weekHeaders,
prevState: this.state.weekHeaders, prevState: this.state.weekHeaders,
}); });
// Update the state to current // Update the state to current and place keyboard focus
this.state.days = days; this.state.days = days;
this.state.weekHeaders = weekHeaders; this.state.weekHeaders = weekHeaders;
this.focusDay();
} }
}, },
@@ -111,9 +114,10 @@ Calendar.prototype = {
* } * }
*/ */
_render({ elements, items, prevState }) { _render({ elements, items, prevState }) {
let selectedEl; let selected = {};
let todayEl; let today = {};
let firstDayEl; let sameDay = {};
let firstDay = {};
for (let i = 0, l = items.length; i < l; i++) { for (let i = 0, l = items.length; i < l; i++) {
let el = elements[i]; let el = elements[i];
@@ -136,16 +140,32 @@ Calendar.prototype = {
el.removeAttribute("aria-current"); el.removeAttribute("aria-current");
// Set new states and properties // Set new states and properties
if (
this.state.focusedDate &&
this._isSameDayOfMonth(items[i].dateObj, this.state.focusedDate) &&
!el.classList.contains("outside")
) {
// When any other date was focused previously, send the focus
// to the same day of month, but only within the current month
sameDay.el = el;
sameDay.dateObj = items[i].dateObj;
}
if (el.classList.contains("today")) { if (el.classList.contains("today")) {
// Current date/today is communicated to assistive technology // Current date/today is communicated to assistive technology
el.setAttribute("aria-current", "date"); el.setAttribute("aria-current", "date");
todayEl = el; if (!el.classList.contains("outside")) {
today.el = el;
today.dateObj = items[i].dateObj;
}
} }
if (el.classList.contains("selection")) { if (el.classList.contains("selection")) {
// Selection is included in the focus order, if from the current month // Selection is communicated to assistive technology
// and may be included in the focus order when from the current month
el.setAttribute("aria-selected", "true"); el.setAttribute("aria-selected", "true");
if (!el.classList.contains("outside")) { if (!el.classList.contains("outside")) {
selectedEl = el; selected.el = el;
selected.dateObj = items[i].dateObj;
} }
} else if (el.classList.contains("out-of-range")) { } else if (el.classList.contains("out-of-range")) {
// Dates that are outside of the range are not selected and cannot be // Dates that are outside of the range are not selected and cannot be
@@ -155,26 +175,36 @@ Calendar.prototype = {
// Other dates are not selected, but could be // Other dates are not selected, but could be
el.setAttribute("aria-selected", "false"); el.setAttribute("aria-selected", "false");
} }
// When no selection or current day/today is present, make the first if (el.textContent === "1" && !firstDay.el) {
// of the month focusable // When no previous day, no selection, or no current day/today
if (el.textContent === "1" && !firstDayEl) { // is present, make the first of the month focusable
let firstDay = new Date(items[i].dateObj); firstDay.dateObj = items[i].dateObj;
firstDay.setUTCDate("1"); firstDay.dateObj.setUTCDate("1");
if (this._isSameDay(items[i].dateObj, firstDay)) {
firstDayEl = el; if (this._isSameDay(items[i].dateObj, firstDay.dateObj)) {
firstDay.el = el;
firstDay.dateObj = items[i].dateObj;
} }
} }
} }
} }
// Selected date is always focusable on init, otherwise make focusable // The previously focused date (if the picker is updated and the grid still
// the current day/today or the first day of the month // contains the date) is always focusable. The selected date on init is also
if (selectedEl) { // always focusable. If neither exist, we make the current day or the first
selectedEl.setAttribute("tabindex", "0"); // day of the month focusable.
} else if (todayEl) { if (sameDay.el) {
todayEl.setAttribute("tabindex", "0"); sameDay.el.setAttribute("tabindex", "0");
} else if (firstDayEl) { this.state.focusedDate = new Date(sameDay.dateObj);
firstDayEl.setAttribute("tabindex", "0"); } else if (selected.el) {
selected.el.setAttribute("tabindex", "0");
this.state.focusedDate = new Date(selected.dateObj);
} else if (today.el) {
today.el.setAttribute("tabindex", "0");
this.state.focusedDate = new Date(today.dateObj);
} else if (firstDay.el) {
firstDay.el.setAttribute("tabindex", "0");
this.state.focusedDate = new Date(firstDay.dateObj);
} }
}, },
@@ -257,76 +287,58 @@ Calendar.prototype = {
case "ArrowRight": { case "ArrowRight": {
// Moves focus to the next day. If the next day is // Moves focus to the next day. If the next day is
// out-of-range, update the view to show the next month // out-of-range, update the view to show the next month
this._handleKeydownEvent(event.target, 1 * direction); this._handleKeydownEvent(1 * direction);
break; break;
} }
case "ArrowLeft": { case "ArrowLeft": {
// Moves focus to the previous day. If the next day is // Moves focus to the previous day. If the next day is
// out-of-range, update the view to show the previous month // out-of-range, update the view to show the previous month
this._handleKeydownEvent(event.target, -1 * direction); this._handleKeydownEvent(-1 * direction);
break; break;
} }
case "ArrowUp": { case "ArrowUp": {
// Moves focus to the same day of the previous week. If the next // Moves focus to the same day of the previous week. If the next
// day is out-of-range, update the view to show the previous month // day is out-of-range, update the view to show the previous month
this._handleKeydownEvent( this._handleKeydownEvent(-1 * this.context.DAYS_IN_A_WEEK);
event.target,
-1,
this.context.DAYS_IN_A_WEEK
);
break; break;
} }
case "ArrowDown": { case "ArrowDown": {
// Moves focus to the same day of the next week. If the next // Moves focus to the same day of the next week. If the next
// day is out-of-range, update the view to show the previous month // day is out-of-range, update the view to show the previous month
this._handleKeydownEvent( this._handleKeydownEvent(1 * this.context.DAYS_IN_A_WEEK);
event.target,
1,
this.context.DAYS_IN_A_WEEK
);
break; break;
} }
case "Home": { case "Home": {
// Moves focus to the first day (ie. Sunday) of the current week // Moves focus to the first day (ie. Sunday) of the current week
let nextId;
if (event.ctrlKey) { if (event.ctrlKey) {
// Moves focus to the first day of the current month // Moves focus to the first day of the current month
for (let i = 0; i < this.state.days.length; i++) { this.state.focusedDate.setUTCDate(1);
if (this.state.days[i].dateObj.getUTCDate() == 1) { this._updateKeyboardFocus();
nextId = i;
break;
}
}
} else { } else {
nextId = this._handleKeydownEvent(
Number(event.target.dataset.id) - this.state.focusedDate.getUTCDay() * -1
(Number(event.target.dataset.id) % );
this.context.DAYS_IN_A_WEEK);
nextId = this._updateViewIfOutside(nextId, -1);
} }
this._updateKeyboardFocus(event.target, nextId);
break; break;
} }
case "End": { case "End": {
// Moves focus to the last day (ie. Saturday) of the current week // Moves focus to the last day (ie. Saturday) of the current week
let nextId;
if (event.ctrlKey) { if (event.ctrlKey) {
// Moves focus to the last day of the current month // Moves focus to the last day of the current month
for (let i = this.state.days.length - 1; i >= 0; i--) { let lastDateOfMonth = new Date(
if (this.state.days[i].dateObj.getUTCDate() == 1) { this.state.focusedDate.getUTCFullYear(),
nextId = i - 1; this.state.focusedDate.getUTCMonth() + 1,
break; 0
} );
} this.state.focusedDate = lastDateOfMonth;
this._updateKeyboardFocus();
} else { } else {
nextId = this._handleKeydownEvent(
Number(event.target.dataset.id) + this.context.DAYS_IN_A_WEEK -
(this.context.DAYS_IN_A_WEEK - 1) - 1 -
(Number(event.target.dataset.id) % this.state.focusedDate.getUTCDay()
this.context.DAYS_IN_A_WEEK); );
nextId = this._updateViewIfOutside(nextId, 1);
} }
this._updateKeyboardFocus(event.target, nextId);
break; break;
} }
case "PageUp": { case "PageUp": {
@@ -334,23 +346,20 @@ Calendar.prototype = {
// and sets focus on the same day. // and sets focus on the same day.
// If that day does not exist, then moves focus // If that day does not exist, then moves focus
// to the same day of the same week. // to the same day of the same week.
let targetId = event.target.dataset.id;
let nextDate = this.state.days[targetId].dateObj;
if (event.shiftKey) { if (event.shiftKey) {
// Previous year // Previous year
this.state.setMonthByOffset(-12); let prevYear = this.state.focusedDate.getUTCFullYear() - 1;
nextDate.setYear(nextDate.getFullYear() - 1); this.state.focusedDate.setUTCFullYear(prevYear);
} else { } else {
// Previous month // Previous month
this.state.setMonthByOffset(-1); let prevMonth = this.state.focusedDate.getUTCMonth() - 1;
nextDate.setMonth(nextDate.getMonth() - 1); this.state.focusedDate.setUTCMonth(prevMonth);
} }
let nextId = this._calculateNextId(nextDate); this.state.setCalendarMonth(
// Outside dates for the previous month (ie. when moving from this.state.focusedDate.getUTCFullYear(),
// the March 30th back to February where 30th does not exist) this.state.focusedDate.getUTCMonth()
// occur at the end of the month, thus month offset is 1 );
nextId = this._updateViewIfOutside(nextId, 1); this._updateKeyboardFocus();
this._updateKeyboardFocus(event.target, nextId);
break; break;
} }
case "PageDown": { case "PageDown": {
@@ -358,20 +367,20 @@ Calendar.prototype = {
// and sets focus on the same day. // and sets focus on the same day.
// If that day does not exist, then moves focus // If that day does not exist, then moves focus
// to the same day of the same week. // to the same day of the same week.
let targetId = event.target.dataset.id;
let nextDate = this.state.days[targetId].dateObj;
if (event.shiftKey) { if (event.shiftKey) {
// Next year // Next year
this.state.setMonthByOffset(12); let nextYear = this.state.focusedDate.getUTCFullYear() + 1;
nextDate.setYear(nextDate.getFullYear() + 1); this.state.focusedDate.setUTCFullYear(nextYear);
} else { } else {
// Next month // Next month
this.state.setMonthByOffset(1); let nextMonth = this.state.focusedDate.getUTCMonth() + 1;
nextDate.setMonth(nextDate.getMonth() + 1); this.state.focusedDate.setUTCMonth(nextMonth);
} }
let nextId = this._calculateNextId(nextDate); this.state.setCalendarMonth(
nextId = this._updateViewIfOutside(nextId, 1); this.state.focusedDate.getUTCFullYear(),
this._updateKeyboardFocus(event.target, nextId); this.state.focusedDate.getUTCMonth()
);
this._updateKeyboardFocus();
break; break;
} }
} }
@@ -416,83 +425,61 @@ Calendar.prototype = {
); );
}, },
/**
* Comparing two date objects to ensure they produce the same day of the month,
* while being on different months
* @param {Date} dateObj1: Date object from the updated state
* @param {Date} dateObj2: Date object from the previous state
* @return {Boolean} If two date objects are the same day of the month
*/
_isSameDayOfMonth(dateObj1, dateObj2) {
return dateObj1.getUTCDate() == dateObj2.getUTCDate();
},
/** /**
* Manage focus for the keyboard navigation for the daysView grid * Manage focus for the keyboard navigation for the daysView grid
* @param {DOMElement} eTarget: The event.target day element * @param {Number} offsetDays: The direction and the number of days to move
* @param {Number} offsetDir: The direction where the focus should move, * the focus by, where a negative number (i.e. -1)
* where a negative number (-1) moves backwards * moves the focus to the previous day
* @param {Number} offsetSize: The number of days to move the focus by.
*/ */
_handleKeydownEvent(eTarget, offsetDir, offsetSize = 1) { _handleKeydownEvent(offsetDays) {
let offset = offsetDir * offsetSize; let newFocusedDay = this.state.focusedDate.getUTCDate() + offsetDays;
let nextId = Number(eTarget.dataset.id) + offset; let newFocusedDate = new Date(this.state.focusedDate);
if (!this.state.days[nextId]) { newFocusedDate.setUTCDate(newFocusedDay);
nextId = this._updateViewIfUndefined(nextId, offset, eTarget.dataset.id);
// Update the month, if the next focused element is outside
if (newFocusedDate.getUTCMonth() !== this.state.focusedDate.getUTCMonth()) {
this.state.setCalendarMonth(
newFocusedDate.getUTCFullYear(),
newFocusedDate.getUTCMonth()
);
} }
nextId = this._updateViewIfOutside(nextId, offsetDir); this.state.focusedDate.setUTCDate(newFocusedDate.getUTCDate());
this._updateKeyboardFocus(eTarget, nextId); this._updateKeyboardFocus();
}, },
/** /**
* Add gridcell attributes and move focus to the next dayView element * Update the daysView grid and send focus to the next day
* @param {DOMElement} targetEl: Day element as an event.target * based on the current state fo the Calendar
* @param {Number} nextId: The data-id of the next HTML day element to focus
*/ */
_updateKeyboardFocus(targetEl, nextId) { _updateKeyboardFocus() {
const nextEl = this.elements.daysView[nextId]; this._render({
elements: this.elements.daysView,
targetEl.removeAttribute("tabindex"); items: this.state.days,
nextEl.setAttribute("tabindex", "0"); prevState: this.state.days,
});
nextEl.focus(); this.focusDay();
}, },
/** /**
* Find Data-id of the next element to focus on the daysView grid if * Place keyboard focus on the calendar grid, when the datepicker is initiated or updated.
* the next element has "outside" class and belongs to another month * A "tabindex" attribute is provided to only one date within the grid
* @param {Number} nextId: The data-id of the next HTML day element to focus * by the "render()" method and this focusable element will be focused.
* @param {Number} offset: The direction for the month view offset
* @return {Number} The data-id of the next HTML day element to focus
*/ */
_updateViewIfOutside(nextId, offset) { focusDay() {
if (this.elements.daysView[nextId].classList.contains("outside")) { const focusable = this.context.daysView.querySelector('[tabindex="0"]');
let nextDate = this.state.days[nextId].dateObj; if (focusable) {
this.state.setMonthByOffset(offset); focusable.focus();
nextId = this._calculateNextId(nextDate);
}
return nextId;
},
/**
* Find Data-id of the next element to focus on the daysView grid if
* the next element is outside of the current daysView calendar
* @param {Number} nextId: The data-id of the next HTML day element to focus
* @param {Number} offset: The number of days to move by,
* where a negative number moves backwards.
* @param {Number} targetId: The data-id for the event target day element
* @return {Number} The data-id of the next HTML day element to focus
*/
_updateViewIfUndefined(nextId, offset, targetId) {
let targetDate = this.state.days[targetId].dateObj;
let nextDate = targetDate;
// Get the date that needs to be focused next:
nextDate.setDate(targetDate.getDate() + offset);
// Update the month view to include this date:
this.state.setMonthByOffset(Math.sign(offset));
// Find the "data-id" of the date element:
nextId = this._calculateNextId(nextDate);
return nextId;
},
/**
* Place keyboard focus on the calendar grid, when the datepicker is initiated.
* The selected day is what gets focused, if such a day exists. If it does not,
* today's date will be focused.
*/
focus() {
const focus = this.context.daysView.querySelector('[tabindex="0"]');
if (focus) {
focus.focus();
} }
}, },
}; };

View File

@@ -40,7 +40,7 @@ function DatePicker(context) {
this._setDefaultState(); this._setDefaultState();
this._createComponents(); this._createComponents();
this._update(); this._update();
this.components.calendar.focus(); this.components.calendar.focusDay();
document.dispatchEvent(new CustomEvent("PickerReady")); document.dispatchEvent(new CustomEvent("PickerReady"));
}, },
@@ -141,7 +141,15 @@ function DatePicker(context) {
calViewSize: CAL_VIEW_SIZE, calViewSize: CAL_VIEW_SIZE,
locale: this.state.locale, locale: this.state.locale,
setSelection: this.state.setSelection, setSelection: this.state.setSelection,
setMonthByOffset: this.state.setMonthByOffset, // Year and month could be changed without changing a selection
setCalendarMonth: (year, month) => {
this.state.dateKeeper.setCalendarMonth({
year,
month,
});
this._update();
this._dispatchState();
},
getDayString: this.state.getDayString, getDayString: this.state.getDayString,
getWeekHeaderString: this.state.getWeekHeaderString, getWeekHeaderString: this.state.getWeekHeaderString,
}, },
@@ -277,7 +285,7 @@ function DatePicker(context) {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
this.state.toggleMonthPicker(); this.state.toggleMonthPicker();
this.components.calendar.focus(); this.components.calendar.focusDay();
break; break;
} }
if (event.key == "Escape") { if (event.key == "Escape") {
@@ -287,12 +295,10 @@ function DatePicker(context) {
} }
if (event.target == this.context.buttonPrev) { if (event.target == this.context.buttonPrev) {
event.target.classList.add("active"); event.target.classList.add("active");
this.state.dateKeeper.setMonthByOffset(-1); this.state.setMonthByOffset(-1);
this._update();
} else if (event.target == this.context.buttonNext) { } else if (event.target == this.context.buttonNext) {
event.target.classList.add("active"); event.target.classList.add("active");
this.state.dateKeeper.setMonthByOffset(1); this.state.setMonthByOffset(1);
this._update();
} }
break; break;
} }