UNPKG

vanillajs-datepicker

Version:

A vanilla JavaScript remake of bootstrap-datepicker for Bulma and other CSS frameworks

424 lines (383 loc) 13.2 kB
import {lastItemOf, isInRange, limitToRange} from '../lib/utils.js'; import {today, regularizeDate} from '../lib/date.js'; import { parseHTML, getParent, showElement, hideElement, emptyChildNodes, } from '../lib/dom.js'; import {registerListeners} from '../lib/event.js'; import pickerTemplate from './templates/pickerTemplate.js'; import DaysView from './views/DaysView.js'; import MonthsView from './views/MonthsView.js'; import YearsView from './views/YearsView.js'; import { triggerDatepickerEvent, clearSelection, goToOrSelectToday, } from '../events/functions.js'; import { onClickViewSwitch, onClickPrevButton, onClickNextButton, onClickView, onMousedownPicker, } from '../events/pickerListeners.js'; const orientClasses = ['left', 'top', 'right', 'bottom'].reduce((obj, key) => { obj[key] = `datepicker-orient-${key}`; return obj; }, {}); const toPx = num => num ? `${num}px` : num; function processPickerOptions(picker, options) { if ('title' in options) { if (options.title) { picker.controls.title.textContent = options.title; showElement(picker.controls.title); } else { picker.controls.title.textContent = ''; hideElement(picker.controls.title); } } if (options.prevArrow) { const prevButton = picker.controls.prevButton; emptyChildNodes(prevButton); options.prevArrow.forEach((node) => { prevButton.appendChild(node.cloneNode(true)); }); } if (options.nextArrow) { const nextButton = picker.controls.nextButton; emptyChildNodes(nextButton); options.nextArrow.forEach((node) => { nextButton.appendChild(node.cloneNode(true)); }); } if (options.locale) { picker.controls.todayButton.textContent = options.locale.today; picker.controls.clearButton.textContent = options.locale.clear; } if ('todayButton' in options) { if (options.todayButton) { showElement(picker.controls.todayButton); } else { hideElement(picker.controls.todayButton); } } if ('minDate' in options || 'maxDate' in options) { const {minDate, maxDate} = picker.datepicker.config; picker.controls.todayButton.disabled = !isInRange(today(), minDate, maxDate); } if ('clearButton' in options) { if (options.clearButton) { showElement(picker.controls.clearButton); } else { hideElement(picker.controls.clearButton); } } } // Compute view date to reset, which will be... // - the last item of the selected dates or defaultViewDate if no selection // - limitted to minDate or maxDate if it exceeds the range function computeResetViewDate(datepicker) { const {dates, config, rangeSideIndex} = datepicker; const viewDate = dates.length > 0 ? lastItemOf(dates) : regularizeDate(config.defaultViewDate, config.pickLevel, rangeSideIndex); return limitToRange(viewDate, config.minDate, config.maxDate); } // Change current view's view date function setViewDate(picker, newDate) { if (!('_oldViewDate' in picker) && newDate !== picker.viewDate) { picker._oldViewDate = picker.viewDate; } picker.viewDate = newDate; // return whether the new date is in different period on time from the one // displayed in the current view // when true, the view needs to be re-rendered on the next UI refresh. const {id, year, first, last} = picker.currentView; const viewYear = new Date(newDate).getFullYear(); switch (id) { case 0: return newDate < first || newDate > last; case 1: return viewYear !== year; default: return viewYear < first || viewYear > last; } } function getTextDirection(el) { return window.getComputedStyle(el).direction; } // find the closet scrollable ancestor elemnt under the body function findScrollParents(el) { const parent = getParent(el); if (parent === document.body || !parent) { return; } // checking overflow only is enough because computed overflow cannot be // visible or a combination of visible and other when either axis is set // to other than visible. // (Setting one axis to other than 'visible' while the other is 'visible' // results in the other axis turning to 'auto') return window.getComputedStyle(parent).overflow !== 'visible' ? parent : findScrollParents(parent); } // Class representing the picker UI export default class Picker { constructor(datepicker) { const {config, inputField} = this.datepicker = datepicker; const template = pickerTemplate.replace(/%buttonClass%/g, config.buttonClass); const element = this.element = parseHTML(template).firstChild; const [header, main, footer] = element.firstChild.children; const title = header.firstElementChild; const [prevButton, viewSwitch, nextButton] = header.lastElementChild.children; const [todayButton, clearButton] = footer.firstChild.children; const controls = { title, prevButton, viewSwitch, nextButton, todayButton, clearButton, }; this.main = main; this.controls = controls; const elementClass = inputField ? 'dropdown' : 'inline'; element.classList.add(`datepicker-${elementClass}`); processPickerOptions(this, config); this.viewDate = computeResetViewDate(datepicker); // set up event listeners registerListeners(datepicker, [ [element, 'mousedown', onMousedownPicker], [main, 'click', onClickView.bind(null, datepicker)], [controls.viewSwitch, 'click', onClickViewSwitch.bind(null, datepicker)], [controls.prevButton, 'click', onClickPrevButton.bind(null, datepicker)], [controls.nextButton, 'click', onClickNextButton.bind(null, datepicker)], [controls.todayButton, 'click', goToOrSelectToday.bind(null, datepicker)], [controls.clearButton, 'click', clearSelection.bind(null, datepicker)], ]); // set up views this.views = [ new DaysView(this), new MonthsView(this), new YearsView(this, {id: 2, name: 'years', cellClass: 'year', step: 1}), new YearsView(this, {id: 3, name: 'decades', cellClass: 'decade', step: 10}), ]; this.currentView = this.views[config.startView]; this.currentView.render(); this.main.appendChild(this.currentView.element); if (config.container) { config.container.appendChild(this.element); } else { inputField.after(this.element); } } setOptions(options) { processPickerOptions(this, options); this.views.forEach((view) => { view.init(options, false); }); this.currentView.render(); } detach() { this.element.remove(); } show() { if (this.active) { return; } const {datepicker, element} = this; const inputField = datepicker.inputField; if (inputField) { // ensure picker's direction matches input's const inputDirection = getTextDirection(inputField); if (inputDirection !== getTextDirection(getParent(element))) { element.dir = inputDirection; } else if (element.dir) { element.removeAttribute('dir'); } // Determine the picker's position first to prevent `orientation: 'auto'` // from being miscalculated to 'bottom' after the picker temporarily // shown below the input field expands the document height if the field // is at the bottom edge of the document this.place(); element.classList.add('active'); if (datepicker.config.disableTouchKeyboard) { inputField.blur(); } } else { element.classList.add('active'); } this.active = true; triggerDatepickerEvent(datepicker, 'show'); } hide() { if (!this.active) { return; } this.datepicker.exitEditMode(); this.element.classList.remove('active'); this.active = false; triggerDatepickerEvent(this.datepicker, 'hide'); } place() { const {classList, style} = this.element; // temporarily display the picker to get its size and offset parent style.display = 'block'; const { width: calendarWidth, height: calendarHeight, } = this.element.getBoundingClientRect(); const offsetParent = this.element.offsetParent; // turn the picker back to hidden so that the position is determined with // the state before it is shown. style.display = ''; const {config, inputField} = this.datepicker; const { left: inputLeft, top: inputTop, right: inputRight, bottom: inputBottom, width: inputWidth, height: inputHeight } = inputField.getBoundingClientRect(); let {x: orientX, y: orientY} = config.orientation; let left = inputLeft; let top = inputTop; // caliculate offsetLeft/Top of inputField if (offsetParent === document.body || !offsetParent) { left += window.scrollX; top += window.scrollY; } else { const offsetParentRect = offsetParent.getBoundingClientRect(); left -= offsetParentRect.left - offsetParent.scrollLeft; top -= offsetParentRect.top - offsetParent.scrollTop; } // caliculate the boundaries of the visible area that contains inputField const scrollParent = findScrollParents(inputField); let scrollAreaLeft = 0; let scrollAreaTop = 0; let { clientWidth: scrollAreaRight, clientHeight: scrollAreaBottom, } = document.documentElement; if (scrollParent) { const scrollParentRect = scrollParent.getBoundingClientRect(); if (scrollParentRect.top > 0) { scrollAreaTop = scrollParentRect.top; } if (scrollParentRect.left > 0) { scrollAreaLeft = scrollParentRect.left; } if (scrollParentRect.right < scrollAreaRight) { scrollAreaRight = scrollParentRect.right; } if (scrollParentRect.bottom < scrollAreaBottom) { scrollAreaBottom = scrollParentRect.bottom; } } // determine the horizontal orientation and left position let adjustment = 0; if (orientX === 'auto') { if (inputLeft < scrollAreaLeft) { orientX = 'left'; adjustment = scrollAreaLeft - inputLeft; } else if (inputLeft + calendarWidth > scrollAreaRight) { orientX = 'right'; if (scrollAreaRight < inputRight) { adjustment = scrollAreaRight - inputRight; } } else if (getTextDirection(inputField) === 'rtl') { orientX = inputRight - calendarWidth < scrollAreaLeft ? 'left' : 'right'; } else { orientX = 'left'; } } if (orientX === 'right') { left += inputWidth - calendarWidth; } left += adjustment; // determine the vertical orientation and top position if (orientY === 'auto') { if (inputTop - calendarHeight > scrollAreaTop) { orientY = inputBottom + calendarHeight > scrollAreaBottom ? 'top' : 'bottom'; } else { orientY = 'bottom'; } } if (orientY === 'top') { top -= calendarHeight; } else { top += inputHeight; } classList.remove(...Object.values(orientClasses)); classList.add(orientClasses[orientX], orientClasses[orientY]); style.left = toPx(left); style.top = toPx(top); } setViewSwitchLabel(labelText) { this.controls.viewSwitch.textContent = labelText; } setPrevButtonDisabled(disabled) { this.controls.prevButton.disabled = disabled; } setNextButtonDisabled(disabled) { this.controls.nextButton.disabled = disabled; } changeView(viewId) { const currentView = this.currentView; if (viewId !== currentView.id) { if (!this._oldView) { this._oldView = currentView; } this.currentView = this.views[viewId]; this._renderMethod = 'render'; } return this; } // Change the focused date (view date) changeFocus(newViewDate) { this._renderMethod = setViewDate(this, newViewDate) ? 'render' : 'refreshFocus'; this.views.forEach((view) => { view.updateFocus(); }); return this; } // Apply the change of the selected dates update(viewDate = undefined) { const newViewDate = viewDate === undefined ? computeResetViewDate(this.datepicker) : viewDate; this._renderMethod = setViewDate(this, newViewDate) ? 'render' : 'refresh'; this.views.forEach((view) => { view.updateFocus(); view.updateSelection(); }); return this; } // Refresh the picker UI render(quickRender = true) { const {currentView, datepicker, _oldView: oldView} = this; const oldViewDate = new Date(this._oldViewDate); const renderMethod = (quickRender && this._renderMethod) || 'render'; delete this._oldView; delete this._oldViewDate; delete this._renderMethod; currentView[renderMethod](); if (oldView) { this.main.replaceChild(currentView.element, oldView.element); triggerDatepickerEvent(datepicker, 'changeView'); } if (!isNaN(oldViewDate)) { const newViewDate = new Date(this.viewDate); if (newViewDate.getFullYear() !== oldViewDate.getFullYear()) { triggerDatepickerEvent(datepicker, 'changeYear'); } if (newViewDate.getMonth() !== oldViewDate.getMonth()) { triggerDatepickerEvent(datepicker, 'changeMonth'); } } } }