UNPKG

@lemonadejs/calendar

Version:

LemonadeJS reactive JavaScript calendar plugin

1,160 lines (1,015 loc) 40.4 kB
if (! lemonade && typeof (require) === 'function') { var lemonade = require('lemonadejs'); } if (! Modal && typeof (require) === 'function') { var Modal = require('@lemonadejs/modal'); } if (! utils && typeof (require) === 'function') { var utils = require('@jsuites/utils'); } const Helpers = utils.Helpers; const Mask = utils.Mask; ; (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : global.Calendar = factory(); }(this, (function () { class CustomEvents extends Event { constructor(type, props, options) { super(type, { bubbles: true, composed: true, ...options, }); if (props) { for (const key in props) { // Avoid assigning if property already exists anywhere on `this` if (! (key in this)) { this[key] = props[key]; } } } } } // Dispatcher const Dispatch = function(method, type, options) { // Try calling the method directly if provided if (typeof method === 'function') { let a = Object.values(options); return method(...a); } else if (this.tagName) { this.dispatchEvent(new CustomEvents(type, options)); } } // Translations const T = function(t) { if (typeof(document) !== "undefined" && document.dictionary) { return document.dictionary[t] || t; } else { return t; } } const filterData = function(year, month) { // Data for the month let data = {}; if (Array.isArray(this.data)) { this.data.map(function (v) { if (!v || typeof v !== 'object' || typeof v.date !== 'string') { return; } 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(firstDayOfWeek) { const reorderedWeekdays = []; for (let i = 0; i < 7; i++) { const dayIndex = (firstDayOfWeek + i) % 7; reorderedWeekdays.push(Helpers.weekdays[dayIndex]); } return reorderedWeekdays.map(w => { return { title: w.substring(0, 1) }; }); } const Views = function(self) { const view = {}; // Create years container view.years = []; view.months = []; view.days = []; view.hours = []; view.minutes = []; for (let i = 0; i < 16; i++) { view.years.push({ title: null, value: null, selected: false, }); } for (let i = 0; i < 12; i++) { view.months.push({ title: null, value: null, selected: false, }); } for (let i = 0; i < 42; i++) { view.days.push({ title: null, value: null, selected: false, }); } for (let i = 0; i < 24; i++) { view.hours.push({ title: Helpers.two(i), value: i }); } for (let i = 0; i < 60; i++) { view.minutes.push({ title: Helpers.two(i), value: i }); } view.years.update = function(date) { let year = date.getUTCFullYear(); let start = year - (year % 16); for (let i = 0; i < 16; i++) { let item = view.years[i]; let value = start + i; item.title = value item.value = value; if (self.cursor.y === value) { item.selected = true; // Current item self.cursor.current = item; } else { item.selected = false; } } } view.months.update = function(date) { let year = date.getUTCFullYear(); for (let i = 0; i < 12; i++) { let item = view.months[i]; item.title = Helpers.months[i].substring(0,3); item.value = i; if (self.cursor.y === year && self.cursor.m === i) { item.selected = true; // Current item self.cursor.current = item; } else { item.selected = false; } } } view.days.update = function(date) { let year = date.getUTCFullYear(); let month = date.getUTCMonth(); let data = filterData.call(self, year, month); // First day let tmp = new Date(Date.UTC(year, month, 1, 0, 0, 0)); let firstDayOfMonth = tmp.getUTCDay(); let firstDayOfWeek = self.startingDay ?? 0; // Calculate offset based on desired first day of week. firstDayOfWeek: 0 = Sunday, 1 = Monday, 2 = Tuesday, etc. let offset = (firstDayOfMonth - firstDayOfWeek + 7) % 7; let index = -1 * offset; for (let i = 0; i < 42; i++) { index++; // Item let item = view.days[i]; // Get the day tmp = new Date(Date.UTC(year, month, index, 0, 0, 0)); // Day let day = tmp.getUTCDate(); // Create the item item.title = day; item.value = index; item.number = Helpers.dateToNum(tmp.toISOString().substring(0, 10)); // Reset range properties for each item item.start = false; item.end = false; item.range = false; item.last = false; item.disabled = false; item.data = null; // Check selections if (tmp.getUTCMonth() !== 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]; } item.grey = false; } // Month let m = tmp.getUTCMonth(); // Select cursor if (self.cursor.y === year && self.cursor.m === m && self.cursor.d === day) { item.selected = true; // Current item self.cursor.current = item; } else { item.selected = false; } // Valid ranges if (self.validRange) { if (typeof self.validRange === 'function') { let ret = self.validRange(day,m,year,item); if (typeof ret !== 'undefined') { item.disabled = ret; } } else { let current = year + '-' + Helpers.two(m+1) + '-' + Helpers.two(day); let test1 = !self.validRange[0] || current >= self.validRange[0].substr(0, 10); let test2 = !self.validRange[1] || current <= self.validRange[1].substr(0, 10); if (! (test1 && test2)) { item.disabled = true; } } } // Select range if (self.range && self.rangeValues) { // Only mark start/end if the number matches item.start = self.rangeValues[0] === item.number; item.end = self.rangeValues[1] === item.number; // Mark as part of range if between start and end item.range = self.rangeValues[0] && self.rangeValues[1] && self.rangeValues[0] <= item.number && self.rangeValues[1] >= item.number; } } } return view; } const isTrue = function(v) { return v === true || v === 'true'; } const isNumber = function (num) { if (typeof(num) === 'string') { num = num.trim(); } return !isNaN(num) && num !== null && num !== ''; } const Calendar = function(children, { onchange, onload, track }) { let self = this; // Event let change = self.onchange; self.onchange = null; // Coerce startingDay to a number so string inputs ('1') don't trigger string concat in modulo math if (typeof self.startingDay !== 'number') { self.startingDay = Number(self.startingDay) || 0; } // Weekdays self.weekdays = getWeekdays(self.startingDay); // Cursor self.cursor = {}; // Time self.time = !! self.time; // Range values self.rangeValues = null; // Calendar date let date = new Date(); // Views const views = Views(self); const hours = views.hours; const minutes = views.minutes; // Initial view self.view = 'days'; // Auto Input if (self.input === 'auto') { self.input = document.createElement('input'); self.input.type = 'text'; } // Get the position of the data based on the view const getPosition = function() { let position = 2; if (self.view === 'years') { position = 0; } else if (self.view === 'months') { position = 1; } return position; } const setView = function(e) { if (typeof e === 'object') { e = this.getAttribute('data-view'); } // Valid views const validViews = ['days', 'months', 'years']; // Define new view if (validViews.includes(e) && self.view !== e) { self.view = e; } } const reloadView = function(reset) { if (reset) { // Update options to the view self.options = views[self.view]; } // Update the values of hte options of hte view views[self.view]?.update.call(self, date); } const getValue = function() { let value = null; if (isTrue(self.range)) { if (Array.isArray(self.rangeValues)) { if (isTrue(self.numeric)) { value = self.rangeValues; } else { value = [ Helpers.numToDate(self.rangeValues[0]).substring(0, 10), Helpers.numToDate(self.rangeValues[1]).substring(0, 10) ]; } } } else { value = getDate(); if (isTrue(self.numeric)) { value = Helpers.dateToNum(value); } } return value; } const setValue = function(v) { let d = new Date(); if (v) { // Accept native Date objects by converting to ISO string if (v instanceof Date) { v = v.toISOString().substring(0, 10); } if (isTrue(self.range)) { if (v) { if (! Array.isArray(v)) { v = v.toString().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]; } } else if (typeof v === 'string' && v.includes(',')) { v = v.split(',')[0]; } if (v) { v = isNumber(v) ? Helpers.numToDate(v) : v; d = new Date(v + ' GMT+0'); } // if no date is defined if (! Helpers.isValidDate(d)) { d = new Date(); } } // Update the internal calendar date setDate(d, true); // Update the view reloadView(); } const getDate = function() { let v = [ self.cursor.y, self.cursor.m, self.cursor.d, self.hour, self.minute ]; let d = new Date(Date.UTC(...v)); // Update the headers of the calendar if (self.time) { return d.toISOString().substring(0, 19).replace('T', ' '); } else { return d.toISOString().substring(0, 10); } } const setDate = function(d, update) { if (Array.isArray(d)) { d = new Date(Date.UTC(...d)); } else if (typeof(d) === 'string') { d = new Date(d); } // Update the date let value = d.toISOString().substring(0,10).split('-'); let month = Helpers.months[parseInt(value[1])-1]; let year = parseInt(value[0]); if (self.month !== month) { self.month = month; } if (self.year !== year) { self.year = year; } // Update the time let time = d.toISOString().substring(11,19).split(':'); let hour = parseInt(time[0]); let minute = parseInt(time[1]); if (self.hour !== hour) { self.hour = hour; } if (self.minute !== minute) { self.minute = minute; } // Update internal date date = d; // Update cursor information if (update) { updateCursor(); } } const updateCursor = function() { self.cursor.y = date.getUTCFullYear(); self.cursor.m = date.getUTCMonth(); self.cursor.d = date.getUTCDate(); } const resetCursor = function() { // Remove selection from the current object let current = self.cursor.current; // Current item if (typeof current !== 'undefined') { current.selected = false; } } const setCursor = function(s) { // Reset current visual cursor resetCursor(); // Update cursor based on the object position if (s) { // Update current self.cursor.current = s; // Update selected property s.selected = true; } updateCursor(); // Update range if (isTrue(self.range)) { updateRange(s) } Dispatch.call(self, self.onupdate, 'update', { instance: self, value: date.toISOString(), }); } const select = function(e, s) { if (self.disabled === true) { return; } // Get new date content let d = updateDate(s.value, getPosition()); // New date setDate(new Date(Date.UTC(...d))) // Based where was the click if (self.view !== 'days') { // Back to the days self.view = 'days'; } else if (! s.disabled) { if (isTrue(self.range)) { // Start a new range if (self.rangeValues && (self.rangeValues[0] >= s.number || self.rangeValues[1])) { destroyRange(); } // Range s.range = true; // Update range if (! self.rangeValues) { s.start = true; self.rangeValues = [s.number, null]; } else { s.end = true; self.rangeValues[1] = s.number; } setCursor(s); } else { setCursor(s); update(e); } } } // Update Calendar const update = function(e) { self.setValue(getValue()); if (! (e && e.type === 'click' && e.target.tagName === 'DIV' && self.time === true)) { self.close({ origin: 'button' }); } } const reset = function() { self.setValue(''); self.close({ origin: 'button' }); } const updateDate = function(v, position) { // Current internal date let value = [date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), self.hour, self.minute, 0]; // Update internal date value[position] = v; // Return new value return value; } const move = function(direction) { // Reset visual cursor resetCursor(); // Value let value; // Update the new internal date if (self.view === 'days') { // Select the new internal date value = updateDate(date.getUTCMonth()+direction, 1); } else if (self.view === 'months') { // Select the new internal date value = updateDate(date.getUTCFullYear()+direction, 0); } else if (self.view === 'years') { // Select the new internal date value = updateDate(date.getUTCFullYear()+(direction*16), 0); } // Update view setDate(value); // Reload content of the view reloadView(); } const getJump = function(e) { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { return self.view === 'days' ? 7 : 4; } return 1; } const prev = function(e) { if (e && e.type === 'keydown') { // Current index let total = self.options.length; let position = self.options.indexOf(self.cursor.current) - getJump(e); if (position < 0) { // Next month move(-1); // New position position = total + position; } // Update cursor setCursor(self.options[position]) } else { move(-1); } } const next = function(e) { if (e && e.type === 'keydown') { // Current index let total = self.options.length; let position = self.options.indexOf(self.cursor.current) + getJump(e); if (position >= total) { // Next month move(1); // New position position = position - total; } // Update cursor setCursor(self.options[position]) } else { move(1); } } const getInput = function() { let input = self.input; if (input && input.current) { input = input.current; } else { if (self.input) { input = self.input; } } return input; } 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++) { let v = self.options[i].number; // Update property condition self.options[i].range = v >= self.rangeValues[0] && v <= number; self.options[i].last = (v === number); } } } } } const destroyRange = function() { if (self.range) { for (let i = 0; i < self.options.length; i++) { if (self.options[i].range !== false) { self.options[i].range = false; } if (self.options[i].start !== false) { self.options[i].start = false; } if (self.options[i].end !== false) { self.options[i].end = false; } if (self.options[i].last !== false) { self.options[i].last = false; } } self.rangeValues = null; } } const render = function(v) { if (v) { if (! Array.isArray(v)) { v = v.toString().split(','); } v = v.map(entry => { return Mask.render(entry, self.format || 'YYYY-MM-DD'); }).join(','); } return v; } const normalize = function(v) { if (v instanceof Date) { v = Helpers.dateToString ? Helpers.dateToString(v) : v.toISOString().substring(0, 10); } if (! Array.isArray(v)) { v = v.toString().split(','); } return v.map(item => { if (item instanceof Date) { return Helpers.dateToString ? Helpers.dateToString(item) : item.toISOString().substring(0, 10); } if (Number(item) == item) { return Helpers.numToDate(item); } else { if (Helpers.isValidDateFormat(item)) { return item; } else if (self.format) { let tmp = Mask.extractDateFromString(item, self.format); if (tmp) { return tmp; } } } }) } const extractValueFromInput = function() { let input = getInput(); if (input) { let v; if (input.tagName === 'INPUT' || input.tagName === 'TEXTAREA') { v = input.value; } else if (input.isContentEditable) { v = input.textContent; } if (v) { return normalize(v).join(','); } return v; } } const onopen = function() { let isEditable = false; let value = self.value; let input = getInput(); if (input) { if (input.tagName === 'INPUT' || input.tagName === 'TEXTAREA') { isEditable = !input.hasAttribute('readonly') && !input.hasAttribute('disabled'); } else if (input.isContentEditable) { isEditable = true; } let ret = extractValueFromInput(); if (ret && ret !== value) { value = ret; } } if (! isEditable) { self.content.focus(); } // Update the internal date values setValue(value); // Open event Dispatch.call(self, self.onopen, 'open', { instance: self }); } const onclose = function(modal, origin) { // Cancel range events destroyRange(); // Close event Dispatch.call(self, self.onclose, 'close', { instance: self, origin: origin, }); } const dispatchOnChangeEvent = function() { // Destroy range destroyRange(); // Update the internal controllers setValue(self.value); // Events Dispatch.call(self, change, 'change', { instance: self, value: self.value, }); // Update input let input = getInput(); if (input) { // Update input value input.value = render(self.value); // Dispatch event Dispatch.call(input, null, 'change', { instance: self, value: self.value, }); } } const events = { focusin: (e) => { if (self.modal && self.isClosed()) { self.open(); } }, focusout: (e) => { if (self.modal && ! self.isClosed()) { if (! (e.relatedTarget && self.modal.el.contains(e.relatedTarget))) { self.modal.close({ origin: 'focusout' }); } } }, click: (e) => { if (e.target.classList.contains('lm-calendar-input')) { self.open(); } }, keydown: (e) => { if (self.modal) { if (e.code === 'ArrowUp' || e.code === 'ArrowDown') { if (! self.isClosed()) { self.content.focus(); } else { self.open(); } } else if (e.code === 'Enter') { if (! self.isClosed()) { update(e); } else { self.open(); } } else if (e.code === 'Escape') { if (! self.isClosed()) { self.modal.close({origin: 'escape'}); } } } }, input: (e) => { let input = e.target; if (input.classList.contains('lm-calendar-input')) { if (! isTrue(self.range)) { // TODO: process with range // Apply mask if (self.format) { Mask.oninput(e, self.format); } let value = null; // Content let content = (input.tagName === 'INPUT' || input.tagName === 'TEXTAREA') ? input.value : input.textContent; // Check if that is a valid date if (Helpers.isValidDateFormat(content)) { value = content; } else if (self.format) { let tmp = Mask.extractDateFromString(content, self.format); if (tmp) { value = tmp; } } // Change the calendar view if (value) { setValue(value); } } } } } // Onload onload(() => { if (self.type !== "inline") { // Create modal instance self.modal = { width: 300, closed: true, focus: false, onopen: onopen, onclose: onclose, position: 'absolute', 'auto-close': false, 'auto-adjust': true, }; // Generate modal Modal(self.el, self.modal); } let ret; // Create input controls if (self.input && self.initInput !== false) { if (! self.input.parentNode) { self.el.parentNode.insertBefore(self.input, self.el); } let input = getInput(); if (input && input.tagName) { input.classList.add('lm-input'); input.classList.add('lm-calendar-input'); input.addEventListener('click', events.click); input.addEventListener('input', events.input); input.addEventListener('keydown', events.keydown); input.addEventListener('focusin', events.focusin); input.addEventListener('focusout', events.focusout); if (self.placeholder) { input.setAttribute('placeholder', self.placeholder); } if (self.onChange) { input.addEventListener('change', self.onChange); } // Retrieve the value if (self.value) { input.value = render(self.value); } else { let value = extractValueFromInput(); if (value && value !== self.value) { ret = value; } } } } // Update the internal date values if (ret) { self.setValue(ret); } else { setValue(self.value); } // Reload view reloadView(true); /** * Handler keyboard * @param {object} e - event */ self.el.addEventListener('keydown', function(e) { let prevent = false; if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { if (e.target !== self.content) { self.content.focus(); } prev(e); prevent = true; } else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { if (e.target !== self.content) { self.content.focus(); } next(e); prevent = true; } else if (e.key === 'Enter') { if (e.target === self.content) { // Item if (self.cursor.current) { // Select select(e, self.cursor.current); prevent = true; } } } else if (e.key === 'Escape') { if (! self.isClosed()) { self.close({ origin: 'escape' }); prevent = true; } } 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) { prev(e); } else { next(e); } e.preventDefault(); } }, { passive: false }); /** * 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({ origin: 'focusout' }); } }); }); onchange((prop) => { if (prop === 'view') { reloadView(true); } else if (prop === 'startingDay') { if (typeof self.startingDay !== 'number') { self.startingDay = Number(self.startingDay) || 0; } self.weekdays = getWeekdays(self.startingDay); } else if (prop === 'value') { dispatchOnChangeEvent(); } }) // Tracking variables track('value'); // Public methods self.open = function(e) { if (self.modal) { if (self.type === 'auto') { self.type = window.innerWidth > 640 ? self.type = 'default' : 'picker'; } self.modal.open(); } } self.close = function(options) { if (self.modal) { if (options && options.origin) { self.modal.close(options) } else { self.modal.close({ origin: 'button' }) } } } self.isClosed = function() { if (self.modal) { return self.modal.isClosed(); } } self.getValue = function() { return self.value; } self.setValue = function(v) { // Update value if (v) { let ret = normalize(v); if (isTrue(self.numeric)) { ret = ret.map(entry => { return Helpers.dateToNum(entry); }) } if (! Array.isArray(v)) { ret = ret.join(','); } if (ret == Number(ret)) { ret = Number(ret); } v = ret; } // Events if (v !== self.value) { self.value = v; } } self.onevent = function(e) { if (events[e.type]) { events[e.type](e); } } self.update = update; self.next = next; self.prev = prev; self.reset = reset; self.setView = setView; self.helpers = Helpers; self.helpers.getDate = Mask.getDate; return render => render`<div class="lm-calendar" data-grid="{{self.grid}}" data-type="{{self.type}}" data-disabled="{{self.disabled}}" data-starting-day="{{self.startingDay}}"> <div class="lm-calendar-options"> <button type="button" onclick="${reset}">${T('Reset')}</button> <button type="button" onclick="${update}">${T('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="${setView}" data-view="months">{{self.month}}</button> <button type="button" onclick="${setView}" data-view="years">{{self.year}}</button></div> <div class="lm-calendar-navigation"> <button type="button" class="lm-calendar-icon lm-ripple" onclick="${prev}" tabindex="0">expand_less</button> <button type="button" class="lm-calendar-icon lm-ripple" onclick="${next}" tabindex="0">expand_more</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-last="{{self.last}}" data-range="{{self.range}}" data-event="{{self.data}}" data-grey="{{self.grey}}" data-bold="{{self.bold}}" data-selected="{{self.selected}}" data-disabled="{{self.disabled}}" onclick="${select}">{{self.title}}</div> </div> <div class="lm-calendar-footer" data-visible="{{self.footer}}"> <div class="lm-calendar-time" data-visible="{{self.time}}"><select :loop="${hours}" :bind="self.hour" class="lm-calendar-control"><option value="{{self.value}}">{{self.title}}</option></select>:<select :loop="${minutes}" :bind="self.minute" class="lm-calendar-control"><option value="{{self.value}}">{{self.title}}</option></select></div> <div class="lm-calendar-update"><input type="button" value="${T('Update')}" onclick="${update}" class="lm-ripple lm-input"></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) } } })));