fune/toolkit/content/widgets/calendar.js
Anna Yeddi 951d338a23 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
2023-01-20 21:39:58 +00:00

485 lines
17 KiB
JavaScript

/* 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";
/**
* Initialize the Calendar and generate nodes for week headers and days, and
* attach event listeners.
*
* @param {Object} options
* {
* {Number} calViewSize: Number of days to appear on a calendar view
* {Function} getDayString: Transform day number to string
* {Function} getWeekHeaderString: Transform day of week number to string
* {Function} setSelection: Set selection for dateKeeper
* {Function} setCalendarMonth: Update the month shown by the dateView
* to a specific month of a specific year
* }
* @param {Object} context
* {
* {DOMElement} weekHeader
* {DOMElement} daysView
* }
*/
function Calendar(options, context) {
this.context = context;
this.context.DAYS_IN_A_WEEK = 7;
this.state = {
days: [],
weekHeaders: [],
setSelection: options.setSelection,
setCalendarMonth: options.setCalendarMonth,
getDayString: options.getDayString,
getWeekHeaderString: options.getWeekHeaderString,
focusedDate: null,
};
this.elements = {
weekHeaders: this._generateNodes(
this.context.DAYS_IN_A_WEEK,
context.weekHeader
),
daysView: this._generateNodes(options.calViewSize, context.daysView),
};
this._attachEventListeners();
}
Calendar.prototype = {
/**
* Set new properties and render them.
*
* @param {Object} props
* {
* {Boolean} isVisible: Whether or not the calendar is in view
* {Array<Object>} days: Data for days
* {
* {Date} dateObj
* {Number} content
* {Array<String>} classNames
* {Boolean} enabled
* }
* {Array<Object>} weekHeaders: Data for weekHeaders
* {
* {Number} content
* {Array<String>} classNames
* }
* }
*/
setProps(props) {
if (props.isVisible) {
// Transform the days and weekHeaders array for rendering
const days = props.days.map(
({ dateObj, content, classNames, enabled }) => {
return {
dateObj,
textContent: this.state.getDayString(content),
className: classNames.join(" "),
enabled,
};
}
);
const weekHeaders = props.weekHeaders.map(({ content, classNames }) => {
return {
textContent: this.state.getWeekHeaderString(content),
className: classNames.join(" "),
};
});
// Update the DOM nodes states
this._render({
elements: this.elements.daysView,
items: days,
prevState: this.state.days,
});
this._render({
elements: this.elements.weekHeaders,
items: weekHeaders,
prevState: this.state.weekHeaders,
});
// Update the state to current and place keyboard focus
this.state.days = days;
this.state.weekHeaders = weekHeaders;
this.focusDay();
}
},
/**
* Render the items onto the DOM nodes
* @param {Object}
* {
* {Array<DOMElement>} elements
* {Array<Object>} items
* {Array<Object>} prevState: state of items from last render
* }
*/
_render({ elements, items, prevState }) {
let selected = {};
let today = {};
let sameDay = {};
let firstDay = {};
for (let i = 0, l = items.length; i < l; i++) {
let el = elements[i];
// Check if state from last render has changed, if so, update the elements
if (!prevState[i] || prevState[i].textContent != items[i].textContent) {
el.textContent = items[i].textContent;
}
if (!prevState[i] || prevState[i].className != items[i].className) {
el.className = items[i].className;
}
if (el.tagName === "td") {
el.setAttribute("role", "gridcell");
// Flush states from the previous view
el.removeAttribute("tabindex");
el.removeAttribute("aria-disabled");
el.removeAttribute("aria-selected");
el.removeAttribute("aria-current");
// 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")) {
// Current date/today is communicated to assistive technology
el.setAttribute("aria-current", "date");
if (!el.classList.contains("outside")) {
today.el = el;
today.dateObj = items[i].dateObj;
}
}
if (el.classList.contains("selection")) {
// Selection is communicated to assistive technology
// and may be included in the focus order when from the current month
el.setAttribute("aria-selected", "true");
if (!el.classList.contains("outside")) {
selected.el = el;
selected.dateObj = items[i].dateObj;
}
} else if (el.classList.contains("out-of-range")) {
// Dates that are outside of the range are not selected and cannot be
el.setAttribute("aria-disabled", "true");
el.removeAttribute("aria-selected");
} else {
// Other dates are not selected, but could be
el.setAttribute("aria-selected", "false");
}
if (el.textContent === "1" && !firstDay.el) {
// When no previous day, no selection, or no current day/today
// is present, make the first of the month focusable
firstDay.dateObj = items[i].dateObj;
firstDay.dateObj.setUTCDate("1");
if (this._isSameDay(items[i].dateObj, firstDay.dateObj)) {
firstDay.el = el;
firstDay.dateObj = items[i].dateObj;
}
}
}
}
// The previously focused date (if the picker is updated and the grid still
// contains the date) is always focusable. The selected date on init is also
// always focusable. If neither exist, we make the current day or the first
// day of the month focusable.
if (sameDay.el) {
sameDay.el.setAttribute("tabindex", "0");
this.state.focusedDate = new Date(sameDay.dateObj);
} 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);
}
},
/**
* Generate DOM nodes with HTML table markup
*
* @param {Number} size: Number of nodes to generate
* @param {DOMElement} context: Element to append the nodes to
* @return {Array<DOMElement>}
*/
_generateNodes(size, context) {
let frag = document.createDocumentFragment();
let refs = [];
// Create table row to present a week:
let rowEl = document.createElement("tr");
for (let i = 0; i < size; i++) {
// Create table cell for a table header (weekday) or body (date)
let el;
if (context.classList.contains("week-header")) {
el = document.createElement("th");
el.setAttribute("scope", "col");
// Explicitly assigning the role as a workaround for the bug 1711273:
el.setAttribute("role", "columnheader");
} else {
el = document.createElement("td");
}
el.dataset.id = i;
refs.push(el);
rowEl.appendChild(el);
// Ensure each table row (week) has only
// seven table cells (days) for a Gregorian calendar
if ((i + 1) % this.context.DAYS_IN_A_WEEK === 0) {
frag.appendChild(rowEl);
rowEl = document.createElement("tr");
}
}
context.appendChild(frag);
return refs;
},
/**
* Handle events
* @param {DOMEvent} event
*/
handleEvent(event) {
switch (event.type) {
case "click": {
if (this.context.daysView.contains(event.target)) {
let targetId = event.target.dataset.id;
let targetObj = this.state.days[targetId];
if (targetObj.enabled) {
this.state.setSelection(targetObj.dateObj);
}
}
break;
}
case "keydown": {
// Providing keyboard navigation support in accordance with
// the ARIA Grid and Dialog design patterns
if (this.context.daysView.contains(event.target)) {
// If RTL, the offset direction for Right/Left needs to be reversed
const direction = Services.locale.isAppLocaleRTL ? -1 : 1;
switch (event.key) {
case "Enter":
case " ": {
let targetId = event.target.dataset.id;
let targetObj = this.state.days[targetId];
if (targetObj.enabled) {
this.state.setSelection(targetObj.dateObj);
}
break;
}
case "ArrowRight": {
// Moves focus to the next day. If the next day is
// out-of-range, update the view to show the next month
this._handleKeydownEvent(1 * direction);
break;
}
case "ArrowLeft": {
// Moves focus to the previous day. If the next day is
// out-of-range, update the view to show the previous month
this._handleKeydownEvent(-1 * direction);
break;
}
case "ArrowUp": {
// 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
this._handleKeydownEvent(-1 * this.context.DAYS_IN_A_WEEK);
break;
}
case "ArrowDown": {
// 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
this._handleKeydownEvent(1 * this.context.DAYS_IN_A_WEEK);
break;
}
case "Home": {
// Moves focus to the first day (ie. Sunday) of the current week
if (event.ctrlKey) {
// Moves focus to the first day of the current month
this.state.focusedDate.setUTCDate(1);
this._updateKeyboardFocus();
} else {
this._handleKeydownEvent(
this.state.focusedDate.getUTCDay() * -1
);
}
break;
}
case "End": {
// Moves focus to the last day (ie. Saturday) of the current week
if (event.ctrlKey) {
// Moves focus to the last day of the current month
let lastDateOfMonth = new Date(
this.state.focusedDate.getUTCFullYear(),
this.state.focusedDate.getUTCMonth() + 1,
0
);
this.state.focusedDate = lastDateOfMonth;
this._updateKeyboardFocus();
} else {
this._handleKeydownEvent(
this.context.DAYS_IN_A_WEEK -
1 -
this.state.focusedDate.getUTCDay()
);
}
break;
}
case "PageUp": {
// Changes the view to the previous month/year
// and sets focus on the same day.
// If that day does not exist, then moves focus
// to the same day of the same week.
if (event.shiftKey) {
// Previous year
let prevYear = this.state.focusedDate.getUTCFullYear() - 1;
this.state.focusedDate.setUTCFullYear(prevYear);
} else {
// Previous month
let prevMonth = this.state.focusedDate.getUTCMonth() - 1;
this.state.focusedDate.setUTCMonth(prevMonth);
}
this.state.setCalendarMonth(
this.state.focusedDate.getUTCFullYear(),
this.state.focusedDate.getUTCMonth()
);
this._updateKeyboardFocus();
break;
}
case "PageDown": {
// Changes the view to the next month/year
// and sets focus on the same day.
// If that day does not exist, then moves focus
// to the same day of the same week.
if (event.shiftKey) {
// Next year
let nextYear = this.state.focusedDate.getUTCFullYear() + 1;
this.state.focusedDate.setUTCFullYear(nextYear);
} else {
// Next month
let nextMonth = this.state.focusedDate.getUTCMonth() + 1;
this.state.focusedDate.setUTCMonth(nextMonth);
}
this.state.setCalendarMonth(
this.state.focusedDate.getUTCFullYear(),
this.state.focusedDate.getUTCMonth()
);
this._updateKeyboardFocus();
break;
}
}
}
break;
}
}
},
/**
* Attach event listener to daysView
*/
_attachEventListeners() {
this.context.daysView.addEventListener("click", this);
this.context.daysView.addEventListener("keydown", this);
},
/**
* Find Data-id of the next element to focus on the daysView grid
* @param {Object} nextDate: Data object of the next element to focus
*/
_calculateNextId(nextDate) {
for (let i = 0; i < this.state.days.length; i++) {
if (this._isSameDay(this.state.days[i].dateObj, nextDate)) {
return i;
}
}
return null;
},
/**
* Comparing two date objects to ensure they produce the same date
* @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
*/
_isSameDay(dateObj1, dateObj2) {
return (
dateObj1.getUTCFullYear() == dateObj2.getUTCFullYear() &&
dateObj1.getUTCMonth() == dateObj2.getUTCMonth() &&
dateObj1.getUTCDate() == dateObj2.getUTCDate()
);
},
/**
* 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
* @param {Number} offsetDays: The direction and the number of days to move
* the focus by, where a negative number (i.e. -1)
* moves the focus to the previous day
*/
_handleKeydownEvent(offsetDays) {
let newFocusedDay = this.state.focusedDate.getUTCDate() + offsetDays;
let newFocusedDate = new Date(this.state.focusedDate);
newFocusedDate.setUTCDate(newFocusedDay);
// Update the month, if the next focused element is outside
if (newFocusedDate.getUTCMonth() !== this.state.focusedDate.getUTCMonth()) {
this.state.setCalendarMonth(
newFocusedDate.getUTCFullYear(),
newFocusedDate.getUTCMonth()
);
}
this.state.focusedDate.setUTCDate(newFocusedDate.getUTCDate());
this._updateKeyboardFocus();
},
/**
* Update the daysView grid and send focus to the next day
* based on the current state fo the Calendar
*/
_updateKeyboardFocus() {
this._render({
elements: this.elements.daysView,
items: this.state.days,
prevState: this.state.days,
});
this.focusDay();
},
/**
* Place keyboard focus on the calendar grid, when the datepicker is initiated or updated.
* A "tabindex" attribute is provided to only one date within the grid
* by the "render()" method and this focusable element will be focused.
*/
focusDay() {
const focusable = this.context.daysView.querySelector('[tabindex="0"]');
if (focusable) {
focusable.focus();
}
},
};