@lemonadejs/calendar
Version:
LemonadeJS reactive JavaScript calendar plugin
991 lines (896 loc) • 34.3 kB
JavaScript
/**
* render: ()
* valid-ranges: []
* disabled
* dateToNum UTC
* navigation with icons Enter key
*/
if (! lemonade && typeof (require) === 'function') {
var lemonade = require('lemonadejs');
}
if (! Modal && typeof (require) === 'function') {
var Modal = require('@lemonadejs/modal');
}
; (function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
global.Calendar = factory();
}(this, (function () {
const Helpers = (function() {
const component = {};
component.weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
component.months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
// Excel like dates
const excelInitialTime = Date.UTC(1900, 0, 0);
const excelLeapYearBug = Date.UTC(1900, 1, 29);
const millisecondsPerDay = 86400000;
// Transform in two digits
component.Two = function(value) {
value = '' + value;
if (value.length === 1) {
value = '0' + value;
}
return value;
}
component.isValidDate = function(d) {
return d instanceof Date && !isNaN(d.getTime());
}
component.toString = function (date, dateOnly) {
let y = null;
let m = null;
let d = null;
let h = null;
let i = null;
let s = null;
if (Array.isArray(date)) {
y = date[0];
m = date[1];
d = date[2];
h = date[3];
i = date[4];
s = date[5];
} else {
if (! date) {
date = new Date();
}
y = date.getFullYear();
m = date.getMonth() + 1;
d = date.getDate();
h = date.getHours();
i = date.getMinutes();
s = date.getSeconds();
}
if (dateOnly === true) {
return component.Two(y) + '-' + component.Two(m) + '-' + component.Two(d);
} else {
return component.Two(y) + '-' + component.Two(m) + '-' + component.Two(d) + ' ' + component.Two(h) + ':' + component.Two(i) + ':' + component.Two(s);
}
}
component.toArray = function (value) {
let date = value.split(((value.indexOf('T') !== -1) ? 'T' : ' '));
let time = date[1];
date = date[0].split('-');
let y = parseInt(date[0]);
let m = parseInt(date[1]);
let d = parseInt(date[2]);
let h = 0;
let i = 0;
if (time) {
time = time.split(':');
h = parseInt(time[0]);
i = parseInt(time[1]);
}
return [y, m, d, h, i, 0];
}
component.arrayToStringDate = function(arr) {
return component.toString(arr, true);
}
component.dateToNum = function(jsDate) {
if (typeof(jsDate) === 'string') {
jsDate = new Date(jsDate + ' GMT+0');
}
let jsDateInMilliseconds = jsDate.getTime();
if (jsDateInMilliseconds >= excelLeapYearBug) {
jsDateInMilliseconds += millisecondsPerDay;
}
jsDateInMilliseconds -= excelInitialTime;
return jsDateInMilliseconds / millisecondsPerDay;
}
component.numToDate = function(excelSerialNumber, asString){
if (! excelSerialNumber) {
return '';
}
let jsDateInMilliseconds = excelInitialTime + excelSerialNumber * millisecondsPerDay;
if (jsDateInMilliseconds >= excelLeapYearBug) {
jsDateInMilliseconds -= millisecondsPerDay;
}
const d = new Date(jsDateInMilliseconds);
let arr = [
d.getUTCFullYear(),
component.Two(d.getUTCMonth() + 1),
component.Two(d.getUTCDate()),
component.Two(d.getUTCHours()),
component.Two(d.getUTCMinutes()),
component.Two(d.getUTCSeconds()),
];
if (asString) {
return component.arrayToStringDate(arr);
} else {
return arr;
}
}
return component;
})();
const isNumber = function (num) {
if (typeof(num) === 'string') {
num = num.trim();
}
return !isNaN(num) && num !== null && num !== '';
}
/**
* Create a data calendar object based on the view
*/
const views = {
years: function(date) {
let year = date.getFullYear();
let result = [];
let start = year % 16;
let complement = 16 - start;
for (let i = year-start; i < year+complement; i++) {
let item = {
title: i,
value: i
};
result.push(item);
// Select cursor
if (this.cursor.y === i) {
// Select item
item.selected = true;
// Cursor
this.cursor.index = result.length - 1;
}
}
return result;
},
months: function(date) {
let year = date.getFullYear();
let result = [];
for (let i = 0; i < 12; i++) {
let item = {
title: Helpers.months[i].substring(0,3),
value: i
}
// Add the item to the data
result.push(item);
// Select cursor
if (this.cursor.y === year && this.cursor.m === i) {
// Select item
item.selected = true;
// Cursor
this.cursor.index = result.length - 1;
}
}
return result;
},
days: function(date) {
let year = date.getFullYear();
let month = date.getMonth();
let data = filterData.call(this, year, month);
// First day
let tmp = new Date(year, month, 1, 0, 0, 0);
let firstDay = tmp.getDay();
let result = [];
for (let i = 1-firstDay; i <= 42-firstDay; i++) {
// Get the day
tmp = new Date(year, month, i, 0, 0, 0);
// Day
let day = tmp.getDate();
// Create the item
let item = {
title: day,
value: i,
number: Helpers.dateToNum(tmp.toString())
}
// Add the item to the date
result.push(item);
// Check selections
if (tmp.getMonth() !== month) {
// Days are not in the current month
item.grey = true;
} else {
// Check for data
let d = [ year, Helpers.Two(month+1), Helpers.Two(day)].join('-');
if (data && data[d]) {
item.data = data[d];
}
}
// Month
let m = tmp.getMonth();
// Select cursor
if (this.cursor.y === year && this.cursor.m === m && this.cursor.d === day) {
// Select item
item.selected = true;
// Cursor
this.cursor.index = result.length - 1;
}
// Select range
if (this.range && this.rangeValues) {
// Mark the start and end points
if (this.rangeValues[0] === item.number) {
item.range = true;
item.start = true;
}
if (this.rangeValues[1] === item.number) {
item.range = true;
item.end = true;
}
// Re-recreate teh range
if (this.rangeValues[0] && this.rangeValues[1]) {
if (this.rangeValues[0] <= item.number && this.rangeValues[1] >= item.number) {
item.range = true;
}
}
}
}
return result;
},
hours: function() {
let result = [];
for (let i = 0; i < 24; i++) {
let item = {
title: Helpers.Two(i),
value: i
};
result.push(item);
}
return result;
},
minutes: function() {
let result = [];
for (let i = 0; i < 60; i=i+5) {
let item = {
title: Helpers.Two(i),
value: i
};
result.push(item);
}
return result;
}
}
const filterData = function(year, month) {
// Data for the month
let data = {};
if (Array.isArray(this.data)) {
this.data.map(function (v) {
let d = year + '-' + Helpers.Two(month + 1);
if (v.date.substring(0, 7) === d) {
if (!data[v.date]) {
data[v.date] = [];
}
data[v.date].push(v);
}
});
}
return data;
}
// Get the short weekdays name
const getWeekdays = function() {
return Helpers.weekdays.map(w => {
return { title: w.substring(0, 1) };
})
}
// Define the hump based on the view
const getJump = function(e) {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
return this.view === 'days' ? 7 : 4;
}
return 1;
}
// Get the position of the data based on the view
const getPosition = function() {
let position = 2;
if (this.view === 'years') {
position = 0;
} else if (this.view === 'months') {
position = 1;
}
return position;
}
const Calendar = function() {
let self = this;
const onchange = self.onchange;
// Weekdays
self.weekdays = getWeekdays();
// Cursor
self.cursor = {};
// Calendar date
let date = new Date();
// Range
self.rangeValues = null;
/**
* Update the internal date
* @param {Date|string|number[]} d instance of Date
*
*/
const setDate = function(d) {
if (Array.isArray(d)) {
d = new Date(Date.UTC(...d));
} else if (typeof(d) === 'string') {
d = new Date(d);
}
// Update internal date
date = d;
// Update the headers of the calendar
let value = d.toISOString().substring(0,10).split('-');
// Update the month label
self.month = Helpers.months[parseInt(value[1])-1];
// Update the year label
self.year = parseInt(value[0]);
// Load data
if (! self.view) {
// Start on the days view will start the data
self.view = 'days';
} else {
// Reload the data for the same view
self.options = views[self.view].call(self, date);
}
}
const getDate = function() {
let v = [ self.cursor.y, self.cursor.m, self.cursor.d ];
let d = new Date(Date.UTC(...v));
// Update the headers of the calendar
return d.toISOString().substring(0, 10);
}
/**
* Set the internal cursor
* @param {object} s
*/
const setCursor = function(s) {
// Remove selection from the current object
let item = self.options[self.cursor.index];
if (typeof(item) !== 'undefined') {
item.selected = false;
}
// Update the date based on the click
let v = updateDate(s.value, getPosition.call(self));
let d = new Date(Date.UTC(...v));
// Update cursor controller
self.cursor = {
y: d.getFullYear(),
m: d.getMonth(),
d: d.getDate(),
};
// Update cursor based on the object position
if (s) {
// Update selected property
s.selected = true;
// New cursor
self.cursor.index = self.options.indexOf(s);
}
if (typeof (self.onupdate) === 'function') {
self.onupdate(self, renderValue());
}
return d;
}
/**
* Update the current date
* @param {number} v new value for year, month or day
* @param {number} position (0,1,2 - year,month,day)
* @returns {number[]}
*/
const updateDate = function(v, position) {
// Current internal date
let value = [date.getFullYear(), date.getMonth(), date.getDate(),0,0,0];
// Update internal date
value[position] = v;
// Return new value
return value;
}
/**
* This method move the data from the view up or down
* @param direction
*/
const move = function(direction) {
let value;
// Update the new internal date
if (self.view === 'days') {
// Select the new internal date
value = updateDate(date.getMonth()+direction, 1);
} else if (self.view === 'months') {
// Select the new internal date
value = updateDate(date.getFullYear()+direction, 0);
} else if (self.view === 'years') {
// Select the new internal date
value = updateDate(date.getFullYear()+(direction*16), 0);
}
// Update view
if (value) {
setDate(value);
}
}
/**
* Keyboard handler
* @param {number} direction of the action
* @param {object} e keyboard event
*/
const moveCursor = function(direction, e) {
direction = direction * getJump.call(self, e);
// Remove the selected from the current selection
let s = self.options[self.cursor.index];
// If the selection is going outside the viewport
if (typeof(s) === 'undefined' || ! s.selected) {
// Go back to the view
setDate([ self.cursor.y, self.cursor.m, self.cursor.d ]);
}
// Jump to the index
let index = self.cursor.index + direction;
// See if the new position is in the viewport
if (typeof(self.options[index]) === 'undefined') {
// Adjust the index for next collection of data
if (self.view === 'days') {
if (index < 0) {
index = 42 + index;
} else {
index = index - 42;
}
} else if (self.view === 'years') {
if (index < 0) {
index = 4 + index;
} else {
index = index - 4;
}
} else if (self.view === 'months') {
if (index < 0) {
index = 12 + index;
} else {
index = index - 12;
}
}
// Move the data up or down
move(direction > 0 ? 1 : -1);
}
// Update the date based on the click
setCursor(self.options[index]);
// Update ranges
updateRange(self.options[index])
}
/**
* Handler blur
* @param e
*/
const blur = function(e) {
if (self.modal) {
if (!(e.relatedTarget && self.modal.el.contains(e.relatedTarget))) {
if (self.modal.closed === false) {
self.modal.closed = true
}
}
}
}
/**
* Set the limits of a range
* @param s
*/
const setRange = function(s) {
if (self.view === 'days' && self.range) {
let d = getDate();
// Date to number
let number = Helpers.dateToNum(d);
// Start a new range
if (self.rangeValues && (self.rangeValues[0] >= number || self.rangeValues[1])) {
destroyRange();
}
// Range
s.range = true;
// Update range
if (! self.rangeValues) {
s.start = true;
self.rangeValues = [number, null];
} else {
s.end = true;
self.rangeValues[1] = number;
}
}
}
/**
* Update the visible range
* @param s
*/
const updateRange = function(s) {
if (self.range && self.view === 'days' && self.rangeValues) {
// Creating a range
if (self.rangeValues[0] && ! self.rangeValues[1]) {
let number = s.number;
if (number) {
// Update range properties
for (let i = 0; i < self.options.length; i++) {
// Item number
let v = self.options[i].number;
// Update property condition
self.options[i].range = v >= self.rangeValues[0] && v <= number;
}
}
}
}
}
/**
* Destroy the range
*/
const destroyRange = function() {
for (let i = 0; i < self.options.length; i++) {
self.options[i].range = false;
self.options[i].start = false;
self.options[i].end = false;
}
self.rangeValues = null;
}
const renderValue = function() {
let value = null;
if (self.range) {
if (Array.isArray(self.rangeValues)) {
if (self.numeric) {
value = self.rangeValues;
} else {
value = [
Helpers.numToDate(self.rangeValues[0], true),
Helpers.numToDate(self.rangeValues[1], true)
];
}
}
} else {
value = getDate();
if (self.numeric) {
value = Helpers.dateToNum(value);
}
}
return value;
}
const updateValue = function(v) {
if (self.range) {
if (v) {
if (! Array.isArray(v)) {
v = v.split(',');
}
self.rangeValues = [...v];
if (v[0] && typeof(v[0]) === 'string' && v[0].indexOf('-')) {
self.rangeValues[0] = Helpers.dateToNum(v[0]);
}
if (v[1] && typeof(v[1]) === 'string' && v[1].indexOf('-')) {
self.rangeValues[1] = Helpers.dateToNum(v[1]);
}
v = v[0];
}
}
let d;
if (v) {
v = isNumber(v) ? Helpers.numToDate(v, true) : v;
d = new Date(v);
}
// if no date is defined
if (! Helpers.isValidDate(d)) {
d = new Date();
}
// Update my index
self.cursor = {
y: d.getFullYear(),
m: d.getMonth(),
d: d.getDate(),
};
// Update the internal calendar date
setDate(d);
}
let autoInput = null;
const getInput = function() {
let input = self.input;
if (input && input.current) {
input = input.current;
} else {
if (input === 'auto') {
if (! autoInput) {
autoInput = document.createElement('input');
autoInput.type = 'text';
if (self.class) {
autoInput.class = self.class;
}
if (self.name) {
autoInput.name = self.name;
}
if (self.placeholder) {
autoInput.placeholder = self.placeholder;
}
self.el.parentNode.insertBefore(autoInput, self.el);
}
input = autoInput;
}
}
return input;
}
/**
* Select an item with the enter or mouse
* @param {object} e - mouse event
* @param {object} item - selected cell
*/
self.select = function(e, item) {
// Update cursor generic
let value = setCursor(item);
// Based where was the click
if (self.view !== 'days') {
// Update the internal date
setDate(value);
// Back to the days
self.view = 'days';
} else {
if (! self.range) {
self.update();
}
}
}
self.selectRange = function(e, item) {
if (self.view === 'days' && self.range === true) {
// Update cursor generic
setCursor(item);
// Update range
setRange(item);
}
}
/**
* Next handler
* @param {object?} e mouse event
*/
self.next = function(e) {
if (! e || e.type === 'click') {
// Icon click
move(1);
} else {
// Keyboard handler
moveCursor(1, e);
}
}
/**
* Next handler
* @param {object?} e mouse event
*/
self.prev = function(e) {
if (! e || e.type === 'click') {
// Icon click
move(-1);
} else {
// Keyboard handler
moveCursor(-1, e);
}
}
/**
* Open the modal
*/
self.open = function(e) {
if (self.modal && self.modal.closed) {
let input = getInput();
// Open modal
self.modal.closed = false;
// Set the focus on the content to use the keyboard
if (! (input && e.target.getAttribute('readonly') === null)) {
self.content.focus();
}
// Populate components
self.hours = views.hours();
self.minutes = views.minutes();
// Update the internal date values
updateValue(self.value);
}
}
/**
* Close the modal
*/
self.close = function() {
if (self.modal && self.modal.closed === false) {
// Close modal
self.modal.closed = true;
// Cancel range events
destroyRange();
}
}
self.reset = function() {
self.setValue('');
self.close();
}
self.update = function() {
self.setValue(renderValue());
self.close();
}
/**
* Change the view
*/
self.setView = function() {
let v = this.getAttribute('data-view');
if (v) {
self.view = v;
}
}
/**
* Get value from cursor
* @returns {string}
*/
self.getValue = function() {
return self.value;
}
self.setValue = function(v) {
// Update the internal controllers
updateValue(v);
// Destroy range
destroyRange();
// Update input
if (self.input) {
let input = getInput();
input.value = v;
}
if (v !== self.value) {
// Update value
self.value = v;
}
}
self.onchange = function(prop) {
if (prop === 'view') {
if (typeof(views[self.view]) === 'function') {
// When change the view update the data
self.options = views[self.view].call(self, date);
}
} else if (prop === 'value') {
if (typeof (onchange) === 'function') {
onchange(self, self.value);
}
if (typeof (self.onupdate) === 'function') {
self.onupdate(self, self.value);
}
if (typeof(self.onChange) === 'function') {
let input = getInput();
if (input) {
input.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
}
}
self.setValue(self.value);
} else if (prop === 'options') {
self.content.focus();
}
}
self.onload = function() {
// Populate components
self.hours = views.hours();
self.minutes = views.minutes();
if (self.type !== "inline") {
// Create modal instance
self.modal = {
width: 300,
closed: true,
focus: false,
position: 'absolute',
'auto-close': false,
'auto-adjust': true,
};
// Generate modal
Modal(self.el, self.modal);
}
// Create input controls
if (self.input) {
let input = getInput();
input.classList.add('lm-calendar-input');
input.addEventListener('focus', self.open);
input.addEventListener('click', self.open);
input.addEventListener('blur', blur);
if (self.onChange) {
input.addEventListener('change', self.onChange);
}
// Retrieve the value
if (self.value) {
input.value = self.value;
} else if (input.value && input.value !== self.value) {
self.value = input.value;
}
}
// Update the internal date values
updateValue(self.value);
/**
* Handler keyboard
* @param {object} e - event
*/
self.content.addEventListener('keydown', function(e){
let prevent = false;
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
self.prev(e);
prevent = true;
} else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
self.next(e);
prevent = true;
} else if (e.key === 'Enter') {
// Current view
let view = self.view;
// Select
self.selectRange(e, self.options[self.cursor.index]);
self.select(e, self.options[self.cursor.index]);
// If is range do something different
if (view === 'days' && ! self.range) {
self.update();
}
prevent = true;
} else {
if (self.input) {
// TODO: mask
//jSuites.mask(e);
}
}
if (prevent) {
e.preventDefault();
e.stopImmediatePropagation();
}
});
/**
* Mouse wheel handler
* @param {object} e - mouse event
*/
self.content.addEventListener('wheel', function(e){
if (self.wheel !== false) {
if (e.deltaY < 0) {
self.prev();
} else {
self.next();
}
e.preventDefault();
}
});
/**
* Range handler
* @param {object} e - mouse event
*/
self.content.addEventListener('mouseover', function(e){
let parent = e.target.parentNode
if (parent === self.content) {
let index = Array.prototype.indexOf.call(parent.children, e.target);
updateRange(self.options[index]);
}
});
// Create event for focus out
self.el.addEventListener("focusout", (e) => {
let input = getInput();
if (e.relatedTarget !== input && ! self.el.contains(e.relatedTarget)) {
self.close();
}
});
}
return `<div class="lm-calendar" :value="self.value" data-grid="{{self.grid}}">
<div class="lm-calendar-options">
<button type="button" onclick="self.reset">Reset</button>
<button type="button" onclick="self.update">Done</button>
</div>
<div class="lm-calendar-container" data-view="{{self.view}}">
<div class="lm-calendar-header">
<div>
<div class="lm-calendar-labels"><button type="button" onclick="self.setView" data-view="months">{{self.month}}</button> <button type="button" onclick="self.setView" data-view="years">{{self.year}}</button></div>
<div class="lm-calendar-navigation">
<button type="button" class="material-icons lm-ripple" onclick="self.prev" tabindex="0">arrow_drop_up</button>
<button type="button" class="material-icons lm-ripple" onclick="self.next" tabindex="0">arrow_drop_down</button>
</div>
</div>
<div class="lm-calendar-weekdays" :loop="self.weekdays"><div>{{self.title}}</div></div>
</div>
<div class="lm-calendar-content" :loop="self.options" tabindex="0" :ref="self.content">
<div data-start="{{self.start}}" data-end="{{self.end}}" data-range="{{self.range}}" data-event="{{self.data}}" data-grey="{{self.grey}}" data-bold="{{self.bold}}" data-selected="{{self.selected}}" onclick="self.parent.select" onmousedown="self.parent.selectRange">{{self.title}}</div>
</div>
<div class="lm-calendar-footer" data-visible="{{self.footer}}">
<div class="lm-calendar-time" data-visible="{{self.time}}"><select :loop="self.hours"><option value="{{self.value}}">{{self.title}}</option></select>:<select :loop="self.minutes"><option value="{{self.value}}">{{self.title}}</option></select></div>
<div class="lm-calendar-update"><input type="button" value="Update" onclick="self.update" class="lm-ripple"></div>
</div>
</div>
</div>`
}
// Register the LemonadeJS Component
lemonade.setComponents({ Calendar: Calendar });
// Register the web component
lemonade.createWebComponent('calendar', Calendar);
return function (root, options) {
if (typeof (root) === 'object') {
lemonade.render(Calendar, root, options)
return options;
} else {
return Calendar.call(this, root)
}
}
})));