diff options
Diffstat (limited to 'toolkit/content/widgets/datekeeper.js')
-rw-r--r-- | toolkit/content/widgets/datekeeper.js | 424 |
1 files changed, 424 insertions, 0 deletions
diff --git a/toolkit/content/widgets/datekeeper.js b/toolkit/content/widgets/datekeeper.js new file mode 100644 index 0000000000..d720491d4b --- /dev/null +++ b/toolkit/content/widgets/datekeeper.js @@ -0,0 +1,424 @@ +/* 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"; + +/** + * DateKeeper keeps track of the date states. + */ +function DateKeeper(props) { + this.init(props); +} + +{ + const DAYS_IN_A_WEEK = 7, + MONTHS_IN_A_YEAR = 12, + YEAR_VIEW_SIZE = 200, + YEAR_BUFFER_SIZE = 10, + // The min value is 0001-01-01 based on HTML spec: + // https://html.spec.whatwg.org/#valid-date-string + MIN_DATE = -62135596800000, + // The max value is derived from the ECMAScript spec (275760-09-13): + // http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.1 + MAX_DATE = 8640000000000000, + MAX_YEAR = 275760, + MAX_MONTH = 9, + // One day in ms since epoch. + ONE_DAY = 86400000; + + DateKeeper.prototype = { + get year() { + return this.state.dateObj.getUTCFullYear(); + }, + + get month() { + return this.state.dateObj.getUTCMonth(); + }, + + get selection() { + return this.state.selection; + }, + + /** + * Initialize DateKeeper + * @param {Number} year + * @param {Number} month + * @param {Number} day + * @param {Number} min + * @param {Number} max + * @param {Number} step + * @param {Number} stepBase + * @param {Number} firstDayOfWeek + * @param {Array<Number>} weekends + * @param {Number} calViewSize + */ + init({ + year, + month, + day, + min, + max, + step, + stepBase, + firstDayOfWeek = 0, + weekends = [0], + calViewSize = 42, + }) { + const today = new Date(); + + this.state = { + step, + firstDayOfWeek, + weekends, + calViewSize, + // min & max are NaN if empty or invalid + min: new Date(Number.isNaN(min) ? MIN_DATE : min), + max: new Date(Number.isNaN(max) ? MAX_DATE : max), + stepBase: new Date(stepBase), + today: this._newUTCDate( + today.getFullYear(), + today.getMonth(), + today.getDate() + ), + weekHeaders: this._getWeekHeaders(firstDayOfWeek, weekends), + years: [], + dateObj: new Date(0), + selection: { year, month, day }, + }; + + if (year === undefined) { + year = today.getFullYear(); + } + if (month === undefined) { + month = today.getMonth(); + } + + const minYear = this.state.min.getFullYear(); + const maxYear = this.state.max.getFullYear(); + + // Choose a valid year for the value/min/max properties + const selectedYear = Math.min(Math.max(year, minYear), maxYear); + + // Choose the month that correspond to the selectedYear + let selectedMonth = 0; + + if (selectedYear === year) { + selectedMonth = month; + } else if (selectedYear === minYear) { + selectedMonth = this.state.min.getMonth(); + } else if (selectedYear === maxYear) { + selectedMonth = this.state.max.getMonth(); + } + + this.setCalendarMonth({ + year: selectedYear, + month: selectedMonth, + }); + }, + + /** + * Set new calendar month. The year is always treated as full year, so the + * short-form is not supported. + * @param {Object} date parts + * { + * {Number} year [optional] + * {Number} month [optional] + * } + */ + setCalendarMonth({ year = this.year, month = this.month }) { + // Make sure the date is valid before setting. + // Use setUTCFullYear so that year 99 doesn't get parsed as 1999 + if (year > MAX_YEAR || (year === MAX_YEAR && month >= MAX_MONTH)) { + this.state.dateObj.setUTCFullYear(MAX_YEAR, MAX_MONTH - 1, 1); + } else if (year < 1 || (year === 1 && month < 0)) { + this.state.dateObj.setUTCFullYear(1, 0, 1); + } else { + this.state.dateObj.setUTCFullYear(year, month, 1); + } + }, + + /** + * Set selection date + * @param {Number} year + * @param {Number} month + * @param {Number} day + */ + setSelection({ year, month, day }) { + this.state.selection.year = year; + this.state.selection.month = month; + this.state.selection.day = day; + }, + + /** + * Set month. Makes sure the day is <= the last day of the month + * @param {Number} month + */ + setMonth(month) { + this.setCalendarMonth({ year: this.year, month }); + }, + + /** + * Set year. Makes sure the day is <= the last day of the month + * @param {Number} year + */ + setYear(year) { + this.setCalendarMonth({ year, month: this.month }); + }, + + /** + * Set month by offset. Makes sure the day is <= the last day of the month + * @param {Number} offset + */ + setMonthByOffset(offset) { + this.setCalendarMonth({ year: this.year, month: this.month + offset }); + }, + + /** + * Generate the array of months + * @return {Array<Object>} + * { + * {Number} value: Month in int + * {Boolean} enabled + * } + */ + getMonths() { + let months = []; + + const currentYear = this.year; + + const minYear = this.state.min.getFullYear(); + const minMonth = this.state.min.getMonth(); + const maxYear = this.state.max.getFullYear(); + const maxMonth = this.state.max.getMonth(); + + for (let i = 0; i < MONTHS_IN_A_YEAR; i++) { + const disabled = + (currentYear == minYear && i < minMonth) || + (currentYear == maxYear && i > maxMonth); + months.push({ + value: i, + enabled: !disabled, + }); + } + + return months; + }, + + /** + * Generate the array of years + * @return {Array<Object>} + * { + * {Number} value: Year in int + * {Boolean} enabled + * } + */ + getYears() { + let years = []; + + const firstItem = this.state.years[0]; + const lastItem = this.state.years[this.state.years.length - 1]; + const currentYear = this.year; + + const minYear = Math.max(this.state.min.getFullYear(), 1); + const maxYear = Math.min(this.state.max.getFullYear(), MAX_YEAR); + + // Generate new years array when the year is outside of the first & + // last item range. If not, return the cached result. + if ( + !firstItem || + !lastItem || + currentYear <= firstItem.value + YEAR_BUFFER_SIZE || + currentYear >= lastItem.value - YEAR_BUFFER_SIZE + ) { + // The year is set in the middle with items on both directions + for (let i = -(YEAR_VIEW_SIZE / 2); i < YEAR_VIEW_SIZE / 2; i++) { + const year = currentYear + i; + + if (year >= minYear && year <= maxYear) { + years.push({ + value: year, + enabled: true, + }); + } + } + this.state.years = years; + } + return this.state.years; + }, + + /** + * Get days for calendar + * @return {Array<Object>} + * { + * {Date} dateObj + * {Number} content + * {Array<String>} classNames + * {Boolean} enabled + * } + */ + getDays() { + const firstDayOfMonth = this._getFirstCalendarDate( + this.state.dateObj, + this.state.firstDayOfWeek + ); + const month = this.month; + let days = []; + + for (let i = 0; i < this.state.calViewSize; i++) { + const dateObj = this._newUTCDate( + firstDayOfMonth.getUTCFullYear(), + firstDayOfMonth.getUTCMonth(), + firstDayOfMonth.getUTCDate() + i + ); + + let classNames = []; + let enabled = true; + + const isValid = + dateObj.getTime() >= MIN_DATE && dateObj.getTime() <= MAX_DATE; + if (!isValid) { + classNames.push("out-of-range"); + enabled = false; + + days.push({ + classNames, + enabled, + }); + continue; + } + + const isWeekend = this.state.weekends.includes(dateObj.getUTCDay()); + const isCurrentMonth = month == dateObj.getUTCMonth(); + const isSelection = + this.state.selection.year == dateObj.getUTCFullYear() && + this.state.selection.month == dateObj.getUTCMonth() && + this.state.selection.day == dateObj.getUTCDate(); + // The date is at 00:00, so if the minimum is that day at e.g. 01:00, + // we should arguably still be able to select that date. So we need to + // compare the date at the very end of the day for minimum purposes. + const isOutOfRange = + dateObj.getTime() + ONE_DAY - 1 < this.state.min.getTime() || + dateObj.getTime() > this.state.max.getTime(); + const isToday = this.state.today.getTime() == dateObj.getTime(); + const isOffStep = this._checkIsOffStep( + dateObj, + this._newUTCDate( + dateObj.getUTCFullYear(), + dateObj.getUTCMonth(), + dateObj.getUTCDate() + 1 + ) + ); + + if (isWeekend) { + classNames.push("weekend"); + } + if (!isCurrentMonth) { + classNames.push("outside"); + } + if (isSelection && !isOutOfRange && !isOffStep) { + classNames.push("selection"); + } + if (isOutOfRange) { + classNames.push("out-of-range"); + enabled = false; + } + if (isToday) { + classNames.push("today"); + } + if (isOffStep) { + classNames.push("off-step"); + enabled = false; + } + days.push({ + dateObj, + content: dateObj.getUTCDate(), + classNames, + enabled, + }); + } + return days; + }, + + /** + * Check if a date is off step given a starting point and the next increment + * @param {Date} start + * @param {Date} next + * @return {Boolean} + */ + _checkIsOffStep(start, next) { + // If the increment is larger or equal to the step, it must not be off-step. + if (next - start >= this.state.step) { + return false; + } + // Calculate the last valid date + const lastValidStep = Math.floor( + (next - 1 - this.state.stepBase) / this.state.step + ); + const lastValidTimeInMs = + lastValidStep * this.state.step + this.state.stepBase.getTime(); + // The date is off-step if the last valid date is smaller than the start date + return lastValidTimeInMs < start.getTime(); + }, + + /** + * Get week headers for calendar + * @param {Number} firstDayOfWeek + * @param {Array<Number>} weekends + * @return {Array<Object>} + * { + * {Number} content + * {Array<String>} classNames + * } + */ + _getWeekHeaders(firstDayOfWeek, weekends) { + let headers = []; + let dayOfWeek = firstDayOfWeek; + + for (let i = 0; i < DAYS_IN_A_WEEK; i++) { + headers.push({ + content: dayOfWeek % DAYS_IN_A_WEEK, + classNames: weekends.includes(dayOfWeek % DAYS_IN_A_WEEK) + ? ["weekend"] + : [], + }); + dayOfWeek++; + } + return headers; + }, + + /** + * Get the first day on a calendar month + * @param {Date} dateObj + * @param {Number} firstDayOfWeek + * @return {Date} + */ + _getFirstCalendarDate(dateObj, firstDayOfWeek) { + const daysOffset = 1 - DAYS_IN_A_WEEK; + let firstDayOfMonth = this._newUTCDate( + dateObj.getUTCFullYear(), + dateObj.getUTCMonth() + ); + let dayOfWeek = firstDayOfMonth.getUTCDay(); + + return this._newUTCDate( + firstDayOfMonth.getUTCFullYear(), + firstDayOfMonth.getUTCMonth(), + // When first calendar date is the same as first day of the week, add + // another row on top of it. + firstDayOfWeek == dayOfWeek + ? daysOffset + : (firstDayOfWeek - dayOfWeek + daysOffset) % DAYS_IN_A_WEEK + ); + }, + + /** + * Helper function for creating UTC dates + * @param {...[Number]} parts + * @return {Date} + */ + _newUTCDate(...parts) { + return new Date(new Date(0).setUTCFullYear(...parts)); + }, + }; +} |