430 lines
12 KiB
JavaScript
430 lines
12 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";
|
|
|
|
/**
|
|
* 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 = Math.min(
|
|
Math.max(
|
|
month,
|
|
selectedYear === minYear ? this.state.min.getMonth() : 0
|
|
),
|
|
selectedYear === maxYear ? this.state.max.getMonth() : 11
|
|
);
|
|
} 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));
|
|
},
|
|
};
|
|
}
|