485 lines
17 KiB
JavaScript
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();
|
|
}
|
|
},
|
|
};
|