addon/components/private/date-input.js
/**
* @module components
*
*/
import $ from 'jquery';
import { Promise as EmberPromise, resolve } from 'rsvp';
import { observer, computed, get, getWithDefault, set } from '@ember/object';
import { isNone, isEmpty } from '@ember/utils';
//import { on } from '@ember/object/evented';
import { run, later } from '@ember/runloop';
import TextField from "@ember/component/text-field"
import keyEvent from '@busy-web/ember-date-time/utils/key-event';
import _time from '@busy-web/ember-date-time/utils/time';
import { bind, unbind, isEventLocal } from '@busy-web/ember-date-time/utils/event';
import {
getCursorPosition,
getFormatSection,
longFormatDate
} from '@busy-web/ember-date-time/utils/format';
import {
YEAR_FLAG,
MONTH_FLAG,
//WEEKDAY_FLAG,
DAY_FLAG,
HOUR_FLAG,
MINUTE_FLAG,
SECONDS_FLAG,
MERIDIAN_FLAG
} from '@busy-web/ember-date-time/utils/constant';
/***/
/**
*
*/
export default TextField.extend({
classNames: ['busyweb', 'emberdatetime', 'p-date-input'],
classNameBindings: ['isDateInRange::invalid', 'active'],
type: 'text',
timestamp: null,
min: null,
max: null,
format: 'MM/DD/YYYY',
_date: null,
//value: '',
active: false,
name: null,
selection: 0,
position: 0,
defaultFocus: 0,
isDateInRange: computed('value', function() {
// before render the position has not been set so
// resume calculations as if position were 0
let position = 0;
if (this.$() && this.$().length && !isEmpty(getData(this.$(), 'format'))) {
let meta = getMeta(this);
position = meta.position;
}
// when value is null or lastNumIndex is null or lastNumIndex is not 0 then it is in a temp state when invalid is ignored
if (isNone(getValue(this)) || isNone(position) || position !== 0) {
return true;
}
// get the current value as a date object
const date = getDate(this);
// date must be valid and within range to be a valid date range
if (date.isValid() && this.isDateInBounds(date)) {
return true;
}
return false;
}),
/**
* initialize the value before the component
* is rendered.
*
*/
init(...args) {
this._super(...args);
let date;
if (!isNone(get(this, 'timestamp'))) {
date = _time(get(this, 'timestamp'));
} else {
date = _time();
}
setValue(this, date.format(get(this, 'format')));
set(this, '_date', date.timestamp());
set(this, '_dateRef', date.timestamp());
},
/**
* initialize the DOM object after render has
* finished.
*
*/
didInsertElement(...args) {
this._super(...args);
const target = this.$().get(0);
bind(
target,
'click',
`ember-date-time.date-input.${this.get('elementId')}`,
(...args) => this.clickEvent(...args),
{ capture: true, rebind: true }
);
bind(
target,
'focusin',
`ember-date-time.date-input.${this.get('elementId')}`,
(...args) => this.focusInEvent(...args),
{ capture: true, rebind: true }
);
bind(
target,
'focusout',
`ember-date-time.date-input.${this.get('elementId')}`,
(...args) => this.focusOutEvent(...args),
{ capture: true, rebind: true }
);
let format = get(this, 'format');
format = longFormatDate(format);
setData(this.$(), 'format', format);
setData(this.$(), 'selection', 0);
setData(this.$(), 'position', 0);
setData(this.$(), 'linked', null);
set(this, 'keyboard', getKeyboardStyle());
},
willDestroyElement(...args) {
this._super(...args);
const target = this.$().get(0);
unbind(target, 'click', `ember-date-time.date-input.${this.get('elementId')}`);
unbind(target, 'focusin', `ember-date-time.date-input.${this.get('elementId')}`);
unbind(target, 'focusout', `ember-date-time.date-input.${this.get('elementId')}`);
},
timestampChange: observer('timestamp', function() {
let date = _time(get(this, 'timestamp'));
setValue(this, date.format(get(this, 'format')));
set(this, '_date', date.timestamp());
set(this, '_dateRef', date.timestamp());
}),
isValid(date) {
if (date.isValid()) {
return true;
}
return false;
},
isDateInBounds(date) {
const max = get(this, 'max');
const min = get(this, 'min');
if (isNone(max) && isNone(min)) { // no min or max date
return true;
} else if (date.timestamp() >= min && isNone(max)) { // date is greater than min date and no max date
return true;
} else if (isNone(min) && date.timestamp() <= max) { // no min date and date is less than max date
return true;
} else if (date.timestamp() >= min && date.timestamp() <= max) { // date is greater than min date and date is less than max date
return true;
}
return false;
},
submitChange(value) {
if (this.isValid(value)) {
let val = value.timestamp();
set(this, '_date', val);
this.sendAction('onchange', val);
return true;
}
return false;
},
finalizeDateSection() {
let date = getInBoundsDate(getDate(this), get(this, 'min'), get(this, 'max'));
let selectRound = getWithDefault(this, 'selectRound', 1);
if (selectRound%date.minute() !== 0) {
date = _time(_time.round(date.timestamp(), selectRound));
setValue(this, date.format(get(this, 'format')));
}
if (date.isValid()) {
if (get(this, '_date') !== date.timestamp()) {
this.submitChange(date);
}
setData(this.$(), 'position', 0);
return true;
}
return false;
},
fixDate() {
let { start, end } = getMeta(this);
let { min, max } = sectionBounds(this);
let valueSection = getValue(this).substring(start, end);
if (parseInt(valueSection, 10) < min) {
valueSection = ('0000000' + min).substr(-(`${max}`.length));
} else if (parseInt(valueSection, 10) > max) {
valueSection = `${max}`;
}
let newVal = mergeString(getValue(this), valueSection, start, end);
setValue(this, newVal);
},
setDateSection(valueSection, position) {
let { sectionFormat, start, end } = getMeta(this);
let { min, max } = sectionBounds(this);
// moves the start ahead on position when the valueSection
// is intended for a format section that has less position
// than the max numbers positions.
if (sectionFormat.length < `${max}`.length) {
if (position > sectionFormat.length) {
start += 1;
}
}
// merge the date string section into the current date string
let newVal = mergeString(getValue(this), valueSection, start, end);
setValue(this, newVal);
// set new number position and handle cursor selection
let num = parseInt(valueSection, 10);
if ((position >= `${max}`.length) || (num > min && num*10 > max)) {
// create a new date object
this.fixDate();
this.finalizeDateSection();
moveToNextPosition(this);
} else {
setData(this.$(), 'position', position);
handleCursor(this, '');
}
},
handleNumberKeys(event, handler) {
let { sectionFormat, position } = getMeta(this);
if (!/^(D(o|D))|(M(o|M))|(Y{1,4})|(hh?)|(HH?)|(mm?)|(ss?)$/.test(sectionFormat)) {
return handler.preventDefault();
}
let valueSection = createSection(this, handler.keyName, position, false);
position = position + 1;
this.setDateSection(valueSection, position);
return handler.preventDefault();
},
handleLetterKeys(event, handler) {
let { sectionFormat } = getMeta(this);
if (!/^(MMMM?|A|a)$/.test(sectionFormat)) {
return handler.preventDefault();
}
if (/^[aA]$/.test(sectionFormat)) {
meridianLetters(this, handler.keyName);
} else {
monthLetters(this, handler.keyName);
}
return handler.preventDefault();
},
handleDeleteKey(event, handler) {
let { position } = getMeta(this);
if (position > 0) {
let valueSection = createSection(this, 0, position, true);
position = position - 1;
this.setDateSection(valueSection, position);
} else {
moveToPrevPosition(this).then(result => {
if (result.isNewInput) {
let e = $.Event( 'keydown', { which: event.which } );
result.input.trigger(e);
} else {
this.handleDeleteKey(event, handler);
}
});
}
return handler.preventDefault();
},
upDownArrows(keyName) {
const { sectionFormat, start, end } = getMeta(this);
const type = sectionFormatType(this);
let _date = get(this, '_date');
let isUp = ['ArrowUp', '=', '+'].indexOf(keyName) !== -1;
let isDown = ['ArrowDown', '-', '_'].indexOf(keyName) !== -1;
// for am pm up down arrows increase the time or decrease
// the time accordingly so that the day does not change.
// So hitting up arrow 2 times will change from 'am' => 'pm' => 'am'
// and the actual date has not changed.
if (type === MERIDIAN_FLAG) {
const substr = getValue(this).substring(start, end);
if (isUp && substr.toLowerCase() === 'pm') {
isUp = false;
isDown = true;
} else if (isDown && substr.toLowerCase() === 'am') {
isDown = false;
isUp = true;
}
} else if (/^mm?/.test(type)) {
if (getWithDefault(this, 'selectRound', 1) > 1) {
if (isUp) {
_date = _time(_date).add(get(this, 'selectRound')-1, 'minutes').timestamp();
} else {
_date = _time(_date).subtract(get(this, 'selectRound')+1, 'minutes').timestamp();
}
}
}
let val;
if (isUp) {
val = _time(_date).addFormatted(1, sectionFormat);
} else if (isDown) {
val = _time(_date).subFormatted(1, sectionFormat);
}
let bounds = _time.isDateInBounds(val, get(this, 'min'), get(this, 'max'));
if (bounds.isAfter || bounds.isBefore) {
if (/^M(M|o)?$/.test(sectionFormat)) {
if (isUp && !_time.isDateAfter(_time(val).startOf('month'), get(this, 'max'))) {
val = val.date(_time(get(this, 'max')).date());
bounds = _time.isDateInBounds(val, get(this, 'min'), get(this, 'max'));
} else if (!isUp && !_time.isDateBefore(_time(val).endOf('month'))) {
val = val.date(_time(get(this, 'min')).date());
bounds = _time.isDateInBounds(val, get(this, 'min'), get(this, 'max'));
}
} else if (/^Y{1,4}$/.test(sectionFormat)) {
if (isUp && !_time.isDateAfter(_time(val).startOf('year'), get(this, 'max'))) {
val = val.date(_time(get(this, 'max')).date());
bounds = _time.isDateInBounds(val, get(this, 'min'), get(this, 'max'));
} else if (!isUp && !_time.isDateBefore(_time(val).endOf('year'))) {
val = val.date(_time(get(this, 'min')).date());
bounds = _time.isDateInBounds(val, get(this, 'min'), get(this, 'max'));
}
}
}
if (val) {
if (!bounds.isBefore && !bounds.isAfter) {
const newVal = val.format(get(this, 'format'));
fixSelection(this, newVal);
setValue(this, newVal);
handleCursor(this, '');
} else {
flashWarn(this);
}
}
},
handleArrowKeys(event, handler) {
if (/^[*/><^]$/.test(handler.keyName)) {
return handler.preventDefault();
}
if (handler.keyName === 'ArrowLeft') {
handleCursor(this, 'prev');
} else if (handler.keyName === 'ArrowRight') {
handleCursor(this, 'next');
} else {
this.upDownArrows(handler.keyName);
}
this.finalizeDateSection();
return handler.preventDefault();
},
focusInEvent(event) {
//let data = this.$().data();
// prevent open picker from selecting the wrong section
//if (!this.$().is(':active') || get(data, 'forceSelection') === true) {
this.$().data('forceSelection', false);
let { selection, position } = getMeta(this, this.$());
handleFocus(this, selection, position, true);
//}
let type = sectionFormatType(this);
this.sendAction('onfocus', event, type);
let os = getOSType();
if (os === 'iOS') {
if (!getData(this.$(), 'overrideFocus')) {
this.$().blur();
} else {
setData(this.$(), 'overrideFocus', false);
}
} else if (os === 'Android') {
this.$().blur();
}
},
focusOutEvent(event) {
// check if the evt.target is local to this elements main parent
let isLocal = isEventLocal(event, get(this, 'elementId'), '.emberdatetime');
if (!isLocal) {
this.set('active', false);
this.sendAction('onblur', event);
}
},
clickEvent(event) {
event.stopPropagation();
let index = event.target.selectionStart;
set(this, '__lastType', null);
handleFocus(this, index, 0);
let type = sectionFormatType(this);
this.sendAction('onclick', event, index, type);
// hand mobile focus
let os = getOSType();
if (os === 'iOS' || os === 'Android') {
setData(this.$(), 'overrideFocus', true);
later(() => this.$().focus(), 10);
}
},
dragStart() {
// store the current value so it can be fixed after the
// drag is complete
set(this, '__saveDragValue', getValue(this));
},
dragEnd() {
// if the drag has changed the value then
// replace it with the old value so the date format
// is not changed
if (getValue(this) !== get(this, '__saveDragValue')) {
setValue(this, get(this, '__saveDragValue'));
}
},
keyDown(event) {
if (isModifierKeyActive(this, event)) {
return true;
}
let handler = keyEvent({ event, disable: ['composition'] });
if (handler.allowed) {
if (!handler.throttle) {
if (handler.type === 'arrow' || handler.type === 'math' || handler.keyName === '_') {
return this.handleArrowKeys(event, handler);
} else if (handler.type === 'number') {
return this.handleNumberKeys(event, handler);
} else if (handler.type === 'letter') {
return this.handleLetterKeys(event, handler);
} else if (handler.keyName === 'Backspace') {
return this.handleDeleteKey(event, handler);
} else if (handler.keyName === 'Tab') {
if (this.get('allowTab')) {
return this.sendAction('ontabkey', event, handler);
} else {
this.sendAction('ontabkey', event, handler);
}
} else if (handler.keyName === 'Enter') {
this.sendAction('onsubmit', event, get(this, '_date'), handler);
}
}
}
return handler.preventDefault();
}
});
function flashWarn(target) {
const el = target.$().parents('.emberdatetime');
const className = 'flash-warn';
el.addClass(className);
setTimeout(() => el.removeClass(className), 100);
}
function getOSType() {
if (/(iPhone|iPad|iPod)/.test(window.navigator.userAgent)) {
return 'iOS';
} else if (/Android/.test(window.navigator.userAgent)) {
return 'Android';
}
}
function getDate(target) {
let format = getData(target.$(), 'format');
if (isEmpty(format)) {
format = get(target, 'format');
format = longFormatDate(format);
}
let value = getValue(target);
value = value.replace(/[ ]*$/, '');
({ value, format } = addFormat(target, format, value));
return _time(value, format);
}
function addFormat(target, format, value) {
let dRef = _time(get(target, '_dateRef'));
let stdTypes = _time.standardFormatTypes();
Object.keys(stdTypes).forEach((key) => {
let typeExp = new RegExp(stdTypes[key]);
if (!typeExp.test(format)) {
format = format + ' ' + key;
value = value + ' ' + dRef.format(key);
}
});
return { value, format };
}
function setValue(target, value) {
set(target, 'value', value.replace(/[ ]*$/, ' '));
}
function getValue(target) {
return get(target, 'value');
}
function isModifierKeyActive(target, event) {
let keyboard = getKeyboardStyle();
if (keyboard === 'MAC' && event.metaKey) {
return true;
} else if (keyboard === 'PC' && event.ctrlKey) {
return true;
} else if (event.altKey) {
event.preventDefault();
return true;
}
return false;
}
function getKeyboardStyle() {
if (/Macintosh/.test(window.navigator.userAgent)) {
return 'MAC';
} else {
return 'PC';
}
}
function getMeta(target, el) {
el = el || target.$();
let format = getData(el, 'format');
let selection = getData(el, 'selection', 0);
let position = getData(el, 'position', 0);
let start, end, sectionFormat;
let value = getValue(target);
sectionFormat = getFormatSection(format, value, selection);
let cursor = getCursorPosition(format, value, selection);
if (!isNone(cursor)) {
start = cursor.start;
end = cursor.end;
}
return { format, selection, position, sectionFormat, start, end };
}
function fixSelection(target, val) {
let { selection } = getMeta(target);
let value = getValue(target).trim()
let v1 = value.substr(selection - value.length);
let v2 = val.substr(selection - val.length);
if (v2.length > v1.length) {
setData(target.$(), 'selection', selection+1);
}
}
function sectionBounds(target) {
let type = sectionFormatType(target);
let max, min = 1;
if (type === DAY_FLAG) {
const date = _time(get(target, '_date'));
max = date.daysInMonth();
} else if (type === MONTH_FLAG) {
max = 12;
} else if (type === YEAR_FLAG) {
max = isNone(get(target, 'max')) ? _time().add(100, 'years').endOf('year').year() : _time(get(target, 'max')).endOf('year').year();
min = isNone(get(target, 'min')) ? _time().subtract(100, 'years').startOf('year').year() : _time(get(target, 'min')).startOf('year').year();
} else if (type === HOUR_FLAG) {
max = 12;
} else if (type === `m-${HOUR_FLAG}`) {
min = 0;
max = 24;
} else if (type === MINUTE_FLAG) {
min = 0;
max = 59;
} else if (type === SECONDS_FLAG) {
min = 0;
max = 59;
}
return { min, max }
}
function sectionFormatType(target) {
const { sectionFormat } = getMeta(target);
return _time.formatStringType(sectionFormat);
}
function handleFocus(target, index=0, num=0, triggerChange=true) {
const defaultFocus = get(target, 'defaultFocus') || 0;
const value = getValue(target);
if (index >= value.length) {
index = defaultFocus;
} else if (index === value.length-1) {
index = index-1;
}
// set cursor index and last num index
setData(target.$(), 'selection', index);
setData(target.$(), 'position', num);
handleCursor(target, '', triggerChange);
set(target, 'active', true);
}
function handleCursor(target, action='', triggerChange=true) {
return new EmberPromise(resolve => {
later(() => {
const { format, selection } = getMeta(target);
const value = getValue(target);
let cursor;
if (action === 'next') {
cursor = getCursorPosition(format, value, selection, true, false);
} else if (action === 'prev') {
cursor = getCursorPosition(format, value, selection, false, true);
} else {
cursor = getCursorPosition(format, value, selection, false, false);
}
if (cursor.end !== value.length-1) {
setSelectionRange(target.$(), cursor.start, cursor.end);
if (selection !== cursor.start) {
setData(target.$(), 'selection', cursor.start);
}
// test for section with escape chars and skip the section
// if they are found.
let { start, sectionFormat } = getMeta(target);
if (/^\[([\s\S])*\]$/.test(sectionFormat)) {
if (action !== '') {
handleCursor(target, action, triggerChange);
} else {
handleCursor(target, (start === 0 ? 'next' : 'prev'), triggerChange);
}
}
if (triggerChange) {
let lastType = get(target, '__lastType');
let type = sectionFormatType(target);
if (lastType !== type) {
set(target, '__lastType', type);
target.sendAction('oncursor', type);
}
}
} else {
handleCursor(target, '', triggerChange);
}
run(null, resolve, null);
}, 10);
});
}
function linkedInputData(target) {
let inputData = getData(target.$(), 'linked');
if (isNone(inputData)) {
let link = get(target, 'name');
if (!isEmpty(link)) {
let inputs = $(`input[name=${link}]`);
let elementId = get(target, 'elementId');
let self = inputs.filter((key, el) => el.id === elementId);
let index = inputs.index(self);
inputData = { inputs, self, index };
setData(target.$(), 'linked', inputData);
}
}
return inputData;
}
function inputFor(target, type='') {
let { inputs, index } = linkedInputData(target);
if (type === 'next') {
index = index+1;
} else if (type === 'prev') {
index = index-1;
}
if (index < 0) {
index = inputs.length-1;
} else if (index >= inputs.length) {
index = 0;
}
let input = inputs[index];
return { input, index };
}
function moveToNextPosition(target) {
const { format, end } = getMeta(target);
setData(target.$(), 'position', 0);
if (end === format.length) {
let { input } = inputFor(target, 'next');
input = $(input);
setData(input, 'selection', 0);
setData(input, 'position', 0);
input.focus();
return resolve({ isNewInput: true, input });
} else {
return handleCursor(target, 'next').then(() => {
setData(target.$(), 'position', 0);
return { isNewInput: false, input: target };
});
}
}
function moveToPrevPosition(target) {
const { start } = getMeta(target);
if (start === 0) {
let { input } = inputFor(target, 'prev');
input = $(input);
const { sectionFormat, format } = getMeta(input);
setData(input, 'selection', format.length);
setData(input, 'position', sectionFormat.length);
input.focus();
return EmberPromise(resolve => {
later(() => {
run(null, resolve, { isNewInput: true, input });
}, 1);
});
} else {
return handleCursor(target, 'prev').then(() => {
const { sectionFormat } = getMeta(target);
setData(target.$(), 'position', sectionFormat.length);
return { isNewInput: false, input: target };
});
}
}
function meridianLetters(target, keyName) {
const value = getValue(target);
let newVal;
if (/^[aA]$/.test(keyName)) {
newVal = value.replace(/[AP]M/, 'AM').replace(/[ap]m/, 'am');
} else if (/^[pP]$/.test(keyName)) {
newVal = value.replace(/[AP]M/, 'PM').replace(/[ap]m/, 'pm');
} else if (/^[mM]$/.test(keyName)) {
target.finalizeDateSection();
moveToNextPosition(target);
}
if (!isEmpty(newVal)) {
setValue(target, newVal);
handleCursor(target, '');
}
}
function monthLetters(target, keyName) {
const { sectionFormat, position, start, end } = getMeta(target);
const value = getValue(target);
const current = value.substr(start, end-start);
const localeData = _time.localeData();
let monthData = localeData._monthsShort;
if (sectionFormat === 'MMMM') {
monthData = localeData._months;
}
let startStr = current.substr(0, position) + keyName;
let reg = new RegExp('^' + startStr, 'i');
let results = monthData.filter(i => reg.test(i));
let first = results[0];
if (!isEmpty(first)) {
let newVal = value.replace(current, first);
setValue(target, newVal);
if (results.length > 1) {
setData(target.$(), 'position', position+1);
} else {
target.finalizeDateSection();
moveToNextPosition(target);
}
}
}
function createSection(target, num, position, isReverse=false) {
const value = getValue(target);
const { sectionFormat, start, end } = getMeta(target);
const substr = value.substring(start, end);
let ds;
// calculate new date from input
if (isReverse) {
ds = shiftRight(substr, num);
} else {
ds = shiftLeft(substr, num);
if (position === 0) {
let template = `000000000`.slice(0, sectionFormat.length);
ds = shiftLeft(template, num);
}
}
return ds;
}
function shiftLeft(str, val) {
return (str + val).substr(1);
}
function shiftRight(str, val) {
return (val + str).substr(0, str.length);
}
function mergeString(str, insert, start, end) {
return str.substr(0, start) + insert + str.substr(end);
}
function setSelectionRange(el, start, end) {
el.get(0).setSelectionRange(start, end);
}
function setData(el, key, value) {
el.data(key, value);
}
function getData(el, key, defaultValue=null) {
return el.data(key) || defaultValue;
}
function getInBoundsDate(date, min, max) {
const adjustTime = (d1, d2) => {
if (d1.hours() < d2.hours()) d2.hours(d1.hours());
if (d1.minutes() < d2.minutes()) d2.minutes(d1.minutes());
if (d1.seconds() < d2.seconds()) d2.seconds(d1.seconds());
return d2;
};
if (_time.isDateAfter(date, max)) {
return adjustTime(_time(date), _time(max));
} else if (_time.isDateBefore(date, min)) {
return adjustTime(_time(min), _time(date));
}
return _time(date);
}