/* 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} days: Data for days * { * {Date} dateObj * {Number} content * {Array} classNames * {Boolean} enabled * } * {Array} weekHeaders: Data for weekHeaders * { * {Number} content * {Array} 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} elements * {Array} items * {Array} 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} */ _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(); } }, };