UNPKG

@busy-web/ember-date-time

Version:

Date time & and range picker for ember addon

984 lines (859 loc) 26.8 kB
/** * @module Components * */ import $ from 'jquery'; import Component from '@ember/component'; import EmberObject, { set, get, computed } from '@ember/object'; import { next, later } from '@ember/runloop'; import { underscore, classify } from '@ember/string'; import { on } from '@ember/object/evented'; import { isEmpty, isNone } from '@ember/utils'; import { loc } from '@ember/string'; import { assert } from '@ember/debug'; import _state from '@busy-web/ember-date-time/utils/state'; import _time, { i18n } from '@busy-web/ember-date-time/utils/time'; import keyEvent from '@busy-web/ember-date-time/utils/key-event'; import { longFormatDate } from '@busy-web/ember-date-time/utils/format'; import layout from '../templates/components/ember-date-range-picker'; import { DAY_FLAG } from '@busy-web/ember-date-time/utils/constant'; /** * `Component/Busyweb/EmberDateTimePicker` * * @class EmberDateTimePicker */ export default Component.extend({ /** * @private * @property classNames * @type String */ classNames: ['busyweb', 'emberdatetime', 'c-date-range-picker'], classNameBindings: ['large', 'right'], layout, large: false, right: false, /** * timestamp that is passed in as a milliseconds timestamp * * @private * @property timestamp * @type Number */ startTime: null, endTime: null, /** * timestamp that is passed in as a seconds timestamp * * @public * @property unix * @type number */ startUnix: null, endUnix: null, /** * can be passed in so a date after the maxDate cannot be selected * * @private * @property maxDate * @type Number * @optional */ maxDate: null, /** * can be passed in so a date before the minDate cannot be selected * * @private * @property minDate * @type Number * @optional */ minDate: null, /** * set to true if the values passed in should not be converted to local time * * @public * @property utc * @type boolean */ utc: false, format: 'MMM DD, YYYY', _start: null, _end: null, _min: null, _max: null, hideTime: true, hideDate: false, defaultAction: '', stateManager: null, calendar: null, isStart: true, isListOpen: false, isCustom: false, allowCustom: true, activeDates: null, weekStart: 0, defaultDateOnChange: true, restrictAfterNow: false, inputDataName: computed('elementId', function() { return `range-picker-${get(this, 'elementId')}`; }), _startDate: computed('_start', function() { return _time(get(this, '_start')); }), _endDate: computed('_end', function() { return _time(get(this, '_end')); }), disableNext: computed('selected', '_start', '_max', function() { if (get(this, 'restrictAfterNow')) { const today = _time(); const start = _time(getStart(this)); const end = _time(getEnd(this)); if (today.valueOf() >= start.valueOf() && today.valueOf() <= end.valueOf()) { return true; } } const { startTime } = this.getInterval(1); return get(this, '_max') < startTime; }), disablePrev: computed('selected', '_end', '_min', function() { const { end } = this.getInterval(-1); return get(this, '_min') > end; }), isInRange(date, start, end) { return (date.valueOf() >= start.valueOf() && date.valueOf() <= end.valueOf()) }, selectedDateRange: computed('selected', '_start', '_end', 'format', function() { const { id } = get(this, 'selected'); const start = _time(getStart(this)); const end = _time(getEnd(this)); let isCurrent = false; if (this.isInRange(_time(), start, end)) { isCurrent = true; } if (id === 'monthly') { if (isCurrent) { return i18n('this_month'); } else if (this.isInRange(_time().add(1, 'months'), start, end)) { return i18n('next_month'); } else if (this.isInRange(_time().subtract(1, 'months'), start, end)) { return i18n('last_month'); } else if (_time().year() !== start.year()) { return classify(start.format('MMMM')) + ' ' + start.year(); } return classify(start.format('MMMM')); } else if (id === 'weekly') { if (isCurrent) { return i18n('this_week'); } else if (this.isInRange(_time().add(1, 'weeks'), start, end)) { return i18n('next_week'); } else if (this.isInRange(_time().subtract(1, 'weeks'), start, end)) { return i18n('last_week'); } else if (_time().year() !== start.year() && start.year() !== end.year()) { return `${start.format('MMM D YYYY')} - ${end.format('MMM D YYYY')}`; } else if (_time().year() !== start.year()) { return `${start.format('MMM D')} - ${end.format('MMM D YYYY')}`; } return `${start.format('MMM D')} - ${end.format('MMM D')}`; } else if (id === 'daily') { if (isCurrent) { return i18n('this_day'); } else if (this.isInRange(_time().add(1, 'days'), start, end)) { return i18n('next_day'); } else if (this.isInRange(_time().subtract(1, 'days'), start, end)) { return i18n('last_day'); } else if (_time().year() === start.year() && _time().week() === start.week()) { return start.format('dddd'); } else if (_time().year() !== start.year()) { return `${start.format('MMM D')} - ${end.format('MMM D YYYY')}`; } return `${start.format('MMM D')}`; } else if (start.year() !== end.year()) { return `${start.format('ll')} - ${end.format('ll')}`; } else { return `${start.format('MMM D')} - ${end.format('MMM D')}`; } }), getAttr(key) { const attrs = get(this, 'attrs'); if (!isNone(get(attrs, key))) { return get(this, key); } return null; }, setup: on('init', function() { const isUnix = !isNone(get(this, 'startUnix')) || !isNone(get(this, 'endUnix')); set(this, '_isUnix', isUnix); // get locale converted format str let format = get(this, 'format'); format = longFormatDate(format); assert( `Moment format "${get(this, 'format')}" is not supported. All format strings must be a combination of "DD" "MM" "YYYY" with one of the following delimeters [ -./, ] and no spaces`, /^(DD.MM.YYYY|DD.YYYY.MM|MM.DD.YYYY|MM.YYYY.DD|YYYY.MM.DD|YYYY.DD.MM)$/.test(format) ); set(this, '__dayIndex', format.search(/D(D|o)/)); set(this, '__monthIndex', format.search(/M(M|o)/)); set(this, '__format', format); if (isNone(get(this, 'activeDates'))) { set(this, 'activeDates', []); } if (!get(this, 'changeFired') && (!isNone(this.getAttr('startTime')) || !isNone(this.getAttr('startUnix')))) { setStart(this, setUserStart(this, (this.getAttr('startTime') || this.getAttr('startUnix')))); } else if (isNone(getStart(this))) { setStart(this, _time().timestamp()); } if (!get(this, 'changeFired') && (!isNone(this.getAttr('endTime')) || !isNone(this.getAttr('endUnix')))) { setEnd(this, setUserEnd(this, (this.getAttr('endTime') || this.getAttr('endUnix')))); } else if (isNone(getEnd(this))) { setEnd(this, _time().timestamp()); } if (get(this, 'changeFired')) { set(this, 'changeFired', false); } else { this.setState(); } if (!isNone(this.getAttr('minDate'))) { let min = this.getAttr('minDate'); if (isUnix) { min = _time.unix(min).timestamp(); } min = _time(min).startOf('day').timestamp(); if (get(this, '_min') !== min) { set(this, '_min', min); } } if (!isNone(get(this, 'maxDate'))) { let max = get(this, 'maxDate'); if (isUnix) { max = _time.unix(max).timestamp(); } max = _time(max).endOf('day').valueOf(); if (get(this, '_max') !== max) { set(this, '_max', max); } } let actionList = get(this, '__actionList') || []; if (isEmpty(actionList)) { let tList = []; let sortKey = 400; (this.getAttr('actionList') || []).forEach(item => { if (!item.get && !item.set) { item = EmberObject.create(item); } let name = get(item, 'name'); assert("Action list items must contain a `name` property", !isEmpty(name)); if (isNone(item, 'id')) { set(item, 'id', underscore(name)); } if (isNone(get(item, 'sort'))) { set(item, 'sort', sortKey); sortKey += 1; } set(item, 'selected', false); tList.push(item); }); actionList = tList; // action list is the list used in the select menu. // // id {string} - string id passed around for reference to a list item // name {string} - the label to display in the list // span {number|function} - the time span in time relational to {type} if function is provided it will be passed the current timestamp // type {string} - the units used to calculate the time {span} // sort {number} - a weighted number used to sort the list // selected {boolean} a true if the item is currently the selected item actionList.push(EmberObject.create({id: 'daily', name: loc('Daily'), span: 1, type: 'days', sort: 100, selected: false})); actionList.push(EmberObject.create({id: 'weekly', name: loc('Weekly'), span: 1, type: 'weeks', sort: 200, selected: false})); actionList.push(EmberObject.create({id: 'monthly', name: loc('Monthly'), span: 1, type: 'months', sort: 300, selected: false})); actionList = actionList.sort((a, b) => get(a, 'sort') > get(b, 'sort') ? 1 : -1); set(this, '__actionList', actionList); } if (isNone(get(this, 'selected'))) { this.setSelected(); this.saveState(); } let elementId = get(this, 'elementId'); $('body').off(`keydown.${elementId}`); $('body').on(`keydown.${elementId}`, keyDownEventHandler(this)); $('body').off(`click.${elementId}`); $('body').on(`click.${elementId}`, clickEventHandler(this)); }), destroy: on('willDestroyElement', function() { $('body').off(`keydown.${get(this, 'elementId')}`); $('body').off(`click.${get(this, 'elementId')}`); }), getDefaultAction() { if (get(this, 'isCustom')) { const span = _time.daysApart(getStart(this), getEnd(this)) + 1; return EmberObject.create({name: loc('Custom'), span, type: 'days'}); } else if (!isEmpty(get(this, 'defaultAction'))) { return get(this, '__actionList').findBy('id', get(this, 'defaultAction')); } else { const start = getStart(this); const end = getEnd(this); const startDate = _time(start); const endDate = _time(end); let span = Math.abs(startDate.diff(endDate, 'days')); let diff = Number.MAX_VALUE; let selected; get(this, '__actionList').forEach(item => { if (typeof item.span !== 'function') { const timeSpan = _time(start).add(item.span, item.type); const itemSpan = Math.abs(startDate.diff(timeSpan, 'days')); const nDiff = Math.abs(itemSpan - span); if (diff > nDiff) { selected = item; diff = nDiff; } } }); return selected; } }, setActiveState(isStart) { if (get(this, 'allowCustom')) { set(this, 'isStart', isStart); } else { set(this, 'isStart', true); } }, getInterval(direction=0) { let { span, type } = get(this, 'selected'); let start, end; if (!isEmpty(type) && !isNone(span)) { start = _time(getStart(this)).valueOf(); end = _time(getEnd(this)).valueOf(); let currentSpanInSeconds = _time(end).unix() - _time(start).unix(); let selectedSpanInSeconds = _time(start).add(span, type).unix() - _time(start).unix(); if (typeof span === 'function') { start = getUserStart(this); end = getUserEnd(this); // get range defined by span function let range = span.call(get(this, 'selected'), start, end, direction); start = setUserStart(this, range.start); end = setUserEnd(this, range.end); } else { // if the current time span is greater than selected time span // then set the new start to the end of the current period. // this will keep the date more current. if (currentSpanInSeconds > selectedSpanInSeconds) { start = _time(getEnd(this)).startOf('day').valueOf(); if (!isNone(get(this, '_max')) && start > get(this, '_max')) { start = _time(get(this, '_max')).subtract(span, type).startOf('day').valueOf(); } } if (type === 'weeks') { if (_time(start).day() !== get(this, 'weekStart')) { let __start = _time(start).day(get(this, 'weekStart')).valueOf(); if (start < __start) { __start = _time(__start).subtract(7, 'days').valueOf(); } start = __start; } } else if (type === 'months') { // for months use startof month for proper month alignment start = _time(start).startOf('month').valueOf(); } if (direction === -1) { start = _time(start).subtract(span, type).valueOf(); } else if (direction === 1) { start = _time(start).add(span, type).valueOf(); } else { start = _time(start).startOf('day').valueOf(); } start = start.valueOf(); end = _time(start).add(span, type).subtract(1, 'days').endOf('day').valueOf(); } } return { start, end }; }, changeInterval(direction=0) { let intervalWait = get(this, '__intervalWait'); if (!isNone(intervalWait)) { clearTimeout(intervalWait); intervalWait = null; } const { start, end } = this.getInterval(direction); if (!isNone(start) && !isNone(end)) { setStart(this, start); setEnd(this, end); intervalWait = setTimeout(() => { this.triggerDateChange(); }, 500); } set(this, '__intervalWait', intervalWait); }, /** * sets the state object for * the date-picker component to get date information * * @public * @method setState */ setState() { let start = getStart(this); let end = getEnd(this); let timestamp = start; let calendarDate = get(this, 'calendarDate'); let minDate = get(this, '_min'); let maxDate = get(this, '_max'); let format = get(this, 'format'); if (!get(this, 'isStart')) { timestamp = end; } const startRange = _time(start).startOf('day').valueOf(); const endRange = _time(end).startOf('day').valueOf(); if (isNone(get(this, 'stateManager'))) { set(this, 'stateManager', _state({ isOpen: true, // isOpen should always be true timestamp, calendarDate, minDate, maxDate, format, range: [startRange, endRange] })); } else { set(this, 'stateManager.timestamp', timestamp); set(this, 'stateManager.calendarDate', calendarDate); set(this, 'stateManager.minDate', minDate); set(this, 'stateManager.maxDate', maxDate); set(this, 'stateManager.format', format); set(this, 'stateManager.range', [startRange, endRange]); } }, /** * triggeres a date change event to send off * to listeners of `onChange` * * @public * @method triggerDateChange */ triggerDateChange() { let start = getUserStart(this); let end = getUserEnd(this); this.setState(); set(this, 'changeFired', true); let selectedType = get(this, 'isCustom') ? 'custom' : get((get(this, '__actionList').findBy('selected', true) || {id: 'weekly'}), 'id'); this.sendAction('onChange', start, end, get(this, 'isCustom'), selectedType); }, /** * sets the focus to on of the inputs based on the boolean passed in. * * true sets focus to the start time input * * @public * @method focusActive * @params isStart {boolean} */ focusActive(selection=0) { let isStart = get(this, 'isStart'); set(this, '__saveFocus', { isStart, selection }); let input = (isStart) ? 'start' : 'end'; let el = this.$(`.state.${input} > input`); if (el && el.data) { el.data('selection', selection); el.data('position', 0); next(() => el.focus()); } }, /** * Update the start or end time date where the date will also set the other * if it is incalid * * @public * @method updateDates * @params type {string} the type of setter day or month * @params timestamp {number} milliseconds timestamp * @params calendar {number} milliseconds timestamp * @params singleSet {boolean} flag to only set start or end time unless a special case exists. This is for keyboards inputs */ updateDates(type, time, calendar, singleSet=false) { let isStart = get(this, 'isStart'); if (!get(this, 'allowCustom')) { isStart = true; } if (type === DAY_FLAG) { if (!singleSet && !isStart && time < getStart(this)) { isStart = true; } // set the start or end time // based off the current active state if (isStart) { setStart(this, time); } else { setEnd(this, time); } if (!singleSet && isStart) { // if active state is the start and its // not a singleSet mode then set the end as well setEnd(this, time); } else if (isStart && time > getEnd(this)) { // if active state is start and the time // is greater than the end then set the end time setEnd(this, time); } else if (!isStart && time < getStart(this)) { // if active state is end and the time // is less than the start then set the start time setStart(this, time); } // always set the calendar for start times and all // single set times if (isStart || singleSet) { set(this, 'calendarDate', time); } // set the active state this.setActiveState(get(this, 'isStart')); // update the dates for the calendar this.setState(); } else { set(this, 'calendarDate', calendar); } return isStart; }, /** * set the select menu to the selected type by id * * @public * @method setSelected * @params id {string} the id of the menu type to set as the selected menu item * @return {object} the selected item */ setSelected(id) { // reset selected list get(this, '__actionList').forEach(item => set(item, 'selected', false)); let selected; if (isNone(id)) { selected = this.getDefaultAction(); } else if (id === 'custom') { set(this, 'isCustom', true); const span = _time.daysApart(getStart(this), getEnd(this)) + 1; selected = EmberObject.create({id: 'custom', name: loc('Custom'), span, type: 'days'}); } else { set(this, 'isCustom', false); selected = get(this, '__actionList').findBy('id', id); if (get(this, 'defaultDateOnChange')) { if (get(this, 'selected.id') !== get(selected, 'id')) { setStart(this, _time().startOf('day').valueOf()); setEnd(this, _time().endOf('day').valueOf()); } } } set(selected, 'selected', true); set(this, 'selected', selected); return selected; }, /** * Save the current state of the select menu and * start end dates * * @public * @method saveState */ saveState() { let id = get(this, 'selected.id'); set(this, '__saveState', { isCustom: id === 'custom', selectedId: id, start: getStart(this), end: getEnd(this) }); }, /** * restore the state to the previous state of the select menu * and start end dates * * @public * @method restoreState */ restoreState() { if (!isNone(get(this, '__saveState'))) { setStart(this, get(this, '__saveState.start')); setEnd(this, get(this, '__saveState.end')); this.setSelected(get(this, '__saveState.selectedId')); } }, closeMenu() { let focus = this.$('.date-range-picker-dropdown > .focus'); if (focus.length) { focus.removeClass('focus'); } set(this, 'isListOpen', false); }, openMenu() { set(this, 'isListOpen', true); this.focusActive(); }, click(event) { if (get(this, 'isListOpen')) { let el = $(event.target); if (el.parents('.date-range-picker-menu').length || el.hasClass('date-range-picker-menu')) { let focus = get(this, '__saveFocus'); this.setActiveState(focus.isStart); this.focusActive(focus.selected); } } }, actions: { intervalBack() { if (!get(this, 'disablePrev')) { this.changeInterval(-1); } }, intervalForward() { if (!get(this, 'disableNext')) { this.changeInterval(1); } }, toggleList() { if (!get(this, 'isListOpen')) { this.openMenu(); } else { this.closeMenu(); } }, setFocus(val, event) { let index = event.target.selectionStart; if (val === 'start') { this.setActiveState(true); set(this, '__saveFocus', { isStart: true, selected: index }); } else if (val === 'end') { this.setActiveState(false); set(this, '__saveFocus', { isStart: false, selected: index }); } }, selectItem(id) { if (!get(this, 'selected') !== id) { this.saveState(); this.setSelected(id); } this.closeMenu(); this.changeInterval(); }, selectCustom() { this.saveState(); this.setSelected('custom'); later(() => { this.focusActive(); }, 100); }, applyRange() { this.saveState(); if (get(this, 'allowCustom')) { this.setSelected('custom'); } this.closeMenu(); this.setActiveState(true); this.changeInterval(); }, cancelRange() { this.restoreState(); this.closeMenu(); this.setActiveState(true); }, dateChange(time) { this.updateDates(DAY_FLAG, time, null, true); }, updateTime(state, time) { let isStart = this.updateDates(DAY_FLAG, time); this.setActiveState(!isStart); // resets the focus after the user clicks a day this.focusActive(get(this, '__dayIndex')); }, tabAction() { this.setActiveState(!get(this, 'isStart')); // change focus to next input this.focusActive(); }, /** * update the clicked value for days and months * and set the focus back to the input when done * */ calendarChange(type, time, calendar) { let isStart; if (type === DAY_FLAG) { isStart = this.updateDates(type, time, calendar); this.setActiveState(!isStart); this.focusActive(get(this, '__dayIndex')); } else { this.updateDates(type, time, calendar); this.focusActive(get(this, '__monthIndex')); } } } }); function getUserStart(target) { let time = getStart(target); if (get(target, 'utc')) { time = _time.utcFromLocal(time).timestamp(); } if (get(target, '_isUnix')) { time = _time(time).unix(); } return time; } function getUserEnd(target) { let time = getEnd(target); if (get(target, 'utc')) { time = _time.utcFromLocal(time).timestamp(); } if (get(target, '_isUnix')) { time = _time(time).unix(); } return time; } function setUserStart(target, time) { if (get(target, '_isUnix')) { time = _time.unix(time).timestamp(); } if (get(target, 'utc')) { time = _time.utcToLocal(time).timestamp(); } return time; } function setUserEnd(target, time) { if (get(target, '_isUnix')) { time = _time.unix(time).timestamp(); } if (get(target, 'utc')) { time = _time.utcToLocal(time).timestamp(); } return time; } function getStart(target) { return get(target, '_start'); } function getEnd(target) { return get(target, '_end'); } function setStart(target, time) { time = _time(time).startOf('day').valueOf(); if (getStart(target) !== time) { set(target, '_start', time); } } function setEnd(target, time) { time = _time(time).endOf('day').valueOf(); if (getEnd(target) !== time) { set(target, '_end', time); } } function findAction(target, key) { let actions = get(target, '__actionList').map(i => { let id = i.id; let regx = new RegExp('^' + id.charAt(0).toLowerCase() + '$'); return { id, regx }; }); actions.push({ id: 'custom', regx: /^C$/}); let res = actions.find(i => i.regx.test(key)); if (isNone(res)) { res = { id: null, regx: null }; } return res; } function handleAltKeys(target, keyName, isOpen) { if (keyName === '/') { target.send('toggleList'); } else { let selectedId = get(target, 'selected.id'); let { id } = findAction(target, keyName); if (!isNone(id)) { if (selectedId !== id) { if (id === 'custom') { if (!isOpen) { target.send('selectCustom'); } target.send('toggleList'); } else { target.send('selectItem', id); } } } } } function keyDownEventHandler(target) { return function(event) { let isOpen = get(target, 'isListOpen'); let handler = keyEvent({ event }); if (event.altKey) { if (handler.type === 'letter' || handler.type === 'math') { handleAltKeys(target, handler.keyName, isOpen); } else if (handler.keyName === 'ArrowLeft') { if (!isOpen) { target.send('intervalBack'); } } else if (handler.keyName === 'ArrowRight') { if (!isOpen) { target.send('intervalForward'); } } } else { if (handler.keyName === 'Tab') { if (isOpen) { target.send('tabAction', event); } } else if (handler.keyName === 'Escape') { if (isOpen) { target.send('toggleList'); } } else if (handler.keyName === 'ArrowDown') { if (isOpen) { nextListItem(target); } } else if (handler.keyName === 'ArrowUp') { if (isOpen) { prevListItem(target); } } else if (handler.keyName === 'Enter') { if (isOpen) { let selected = target.$('.date-range-picker-dropdown > .focus'); if (selected.length) { selected.click(); } } } } return true; } } function nextListItem(target) { let element = target.$('.date-range-picker-dropdown'); let active = element.find('.focus'); if (!active.length) { active = element.find('.active'); } let next = active.next('.item'); if (!next.length) { next = element.children().first(); } next.addClass('focus'); active.removeClass('focus'); } function prevListItem(target) { let element = target.$('.date-range-picker-dropdown'); let active = element.find('.focus'); if (!active.length) { active = element.find('.active'); } let next = active.prev('.item'); if (!next.length) { next = element.children('.item').last(); } next.addClass('focus'); active.removeClass('focus'); } function clickEventHandler(target) { return function(evt) { let el = $(evt.target); let isOpen = get(target, 'isListOpen'); if (isOpen) { if (el.parents('.c-date-range-picker').length) { // is in date picker if (!el.parents('.date-range-picker-dropdown').length && !el.hasClass('select') && !el.hasClass('date-range-picker-dropdown')) { target.send('toggleList'); } } else { target.send('toggleList'); } } } }