API Docs for: 1.0.0
Show:

File: src/gallery-datetime/js/DateTime.js

"use strict";

var blackout_min_seconds = -40,
	blackout_max_seconds = +40,
	change_after_focus   = (0 < Y.UA.ie);

/**
 * @module gallery-datetime
 */

/**********************************************************************
 * Manages a date input field and an optional time field.  Calendars and
 * time selection widgets can be attached to these fields, but will not be
 * managed by this class.
 * 
 * Date/time values can be specified as either a Date object or an object
 * specifying year,month,day (all 1-based) or date_str and optionally
 * hour,minute or time_str.  Individual values take precedence over string
 * values.  Time resolution is in minutes.
 * 
 * @main gallery-datetime
 * @class DateTime
 * @extends Base
 * @constructor
 * @param config {Object}
 */

function DateTime(config)
{
	DateTime.superclass.constructor.apply(this, arguments);
}

DateTime.NAME = "datetime";

function setNode(n)
{
	return Y.one(n) || Attribute.INVALID_VALUE;
}

function dateTimeSetter(value)
{
	return value ? Y.DateTimeUtils.normalize(value, this.get('blankTime')) : null;
};

DateTime.ATTRS =
{
	/**
	 * Date input field to use.  Can be augmented with a Calendar via
	 * gallery-input-calendar-sync.
	 * 
	 * @attribute dateInput
	 * @type {Node|String}
	 * @required
	 * @writeonce
	 */
	dateInput:
	{
		setter:    setNode,
		writeOnce: true
	},

	/**
	 * Time input field to use.  Can be enhanced with gallery-timepicker.
	 * 
	 * @attribute timeInput
	 * @type {Node|String}
	 * @writeonce
	 */
	timeInput:
	{
		setter:    setNode,
		writeOnce: true
	},

	/**
	 * Set to false to require a date to be entered.  If dateInput is
	 * configured with Y.InputCalendarSync, allowBlank will be copied from
	 * there.
	 *
	 * @attribute allowBlank
	 * @type {Boolean}
	 * @default true
	 */
	allowBlank:
	{
		value:     true,
		validator: Y.Lang.isBoolean
	},

	/**
	 * Default date and time, used during initialization and by resetDateTime().
	 * 
	 * @attribute defaultDateTime
	 * @type {Object}
	 */
	defaultDateTime:
	{
		setter: dateTimeSetter
	},

	/**
	 * Minimum date and time.
	 * 
	 * @attribute minDateTime
	 * @type {Object}
	 */
	minDateTime:
	{
		setter: dateTimeSetter
	},

	/**
	 * Maximum date and time.
	 * 
	 * @attribute minDateTime
	 * @type {Object}
	 */
	maxDateTime:
	{
		setter: dateTimeSetter
	},

	/**
	 * Time value to use when no time is specified as part of a date.
	 * 
	 * @attribute blankTime
	 * @type {Object}
	 * @default { hour:0, minute:0 }
	 */
	blankTime:
	{
		value:     { hour:0, minute:0 },
		validator: function(value)
		{
			return (Y.Lang.isObject(value) &&
					Y.Lang.isNumber(value.hour) &&
					Y.Lang.isNumber(value.minute));
		}
	},

	/**
	 * Blackout ranges, specified as a list of objects, each defining start
	 * and end.  The data is overwritten, so this is a write-only value.
	 * 
	 * @attribute blackout
	 * @type {Array}
	 * @default []
	 */
	blackouts:
	{
		value:     [],
		validator: Y.Lang.isArray,
		setter:    function(ranges)
		{
			ranges = ranges || [];

			// store ranges in ascending order of start time

			var blackouts  = [],
				blank_time = this.get('blankTime');
			for (var i=0; i<ranges.length; i++)
			{
				var r   = ranges[i];
				r.start = Y.DateTimeUtils.normalize(r.start, blank_time);
				r.end   = Y.DateTimeUtils.normalize(r.end,   blank_time);

				r =
				[
					new Date(r.start.year, r.start.month-1, r.start.day,
							 r.start.hour, r.start.minute, blackout_min_seconds)
							 .getTime(),
					new Date(r.end.year, r.end.month-1, r.end.day,
							 r.end.hour, r.end.minute, blackout_max_seconds)
							 .getTime()
				];

				var inserted = false;
				for (var j=0; j<blackouts.length; j++)
				{
					var r1 = blackouts[j];
					if (r[0] <= r1[0])
					{
						if (j > 0 &&
							r[0] <  blackouts[j-1][1] &&
							r[1] <= blackouts[j-1][1])
						{
							// covered by prev
						}
						else if (j > 0 &&
								 r[0] - 60000 < blackouts[j-1][1] &&
								 r1[0] < r[1] + 60000)
						{
							// overlaps prev and next
							r = [ blackouts[j-1][0], r[1] ];
							blackouts.splice(j-1, 2, r);
						}
						else if (j > 0 &&
								 r[0] - 60000 < blackouts[j-1][1])
						{
							// overlaps prev
							blackouts[j-1][1] = r[1];
						}
						else if (r1[0] < r[1] + 60000)
						{
							// overlaps next
							r1[0] = r[0];
						}
						else
						{
							blackouts.splice(j, 0, r);
						}
						inserted = true;
						break;
					}
				}

				// j == blackouts.length

				if (!inserted && j > 0 &&
					r[0] <  blackouts[j-1][1] &&
					r[1] <= blackouts[j-1][1])
				{
					// covered by prev
				}
				else if (!inserted && j > 0 &&
						 r[0] - 60000 < blackouts[j-1][1])
				{
					// overlaps prev
					blackouts[j-1][1] = r[1];
				}
				else if (!inserted)
				{
					blackouts.push(r);
				}
			}

			return blackouts;
		}
	},

	/**
	 * The direction to push the selected date and time when the user
	 * selects a day with partial blackout.  The default value of zero
	 * means go to the nearest available time.
	 *
	 * @attribute blackoutSnapDirection
	 * @type {-1,0,+1}
	 * @default 0
	 */
	blackoutSnapDirection:
	{
		value:     0,
		validator: function(value)
		{
			return (value == -1 || value === 0 || value == +1);
		}
	},

	/**
	 * Duration of visual ping in milliseconds when the value of an input
	 * field is modified because of a min/max or blackout restriction.  Set
	 * to zero to disable.
	 * 
	 * @attribute pingDuration
	 * @type {Number}
	 * @default 2000
	 */
	pingDuration:
	{
		value:     2000,
		validator: Y.Lang.isNumber
	},

	/**
	 * CSS class applied to input field when it is pinged.
	 * 
	 * @attribute pingClass
	 * @type {String}
	 * @default "yui3-datetime-ping"
	 */
	pingClass:
	{
		value:     'yui3-datetime-ping',
		validator: Y.Lang.isString
	}
};

/**
 * @event limitsEnforced
 * @description Fires after min/max and blackouts have been enforced.
 */

function checkEnforceDateTimeLimits()
{
	if (!this.ignore_value_set)
	{
		enforceDateTimeLimits.call(this, 'same-day');
	}
}

function enforceDateTimeLimits(
	/* string */	algo)
{
	var date = this.getDateTime();
	if (!date && this.get('allowBlank'))
	{
		var date_len = this.get('dateInput').get('value').length;
		if (date_len === 0)
		{
			this.ignore_value_set = true;
			this.get('timeInput').set('value', '');
			this.ignore_value_set = false;

			this.prev_date_time = null;
			this.fire('limitsEnforced');
			return;
		}
		else if (date_len > 0 && this.get('timeInput').get('value').length === 0)
		{
			this.get('timeInput').set('value', Y.DateTimeUtils.formatTime(this.get('blankTime')));		// recursive
			this.fire('limitsEnforced');
			return;
		}
	}

	if (!date && this.prev_date_time)
	{
		date = Y.clone(this.prev_date_time);
		this.ignore_value_set = true;
		this.get('dateInput').set('value', Y.DateTimeUtils.formatDate(date));
		this.get('timeInput').set('value', Y.DateTimeUtils.formatTime(date));
		this.ignore_value_set = false;
	}
	else if (!date)
	{
		return;
	}
	date = date.date;

	var orig_date = new Date(date.getTime());

	// blackout ranges

	var blackouts = this.get('blackouts');
	if (blackouts.length > 0)
	{
		var t      = date.getTime(),
			orig_t = t,
			snap   = algo == 'same-day' ? 0 : this.get('blackoutSnapDirection');

		for (var i=0; i<blackouts.length; i++)
		{
			var blackout = blackouts[i];
			if (blackout[0] < t && t < blackout[1])
			{
				if (snap > 0)
				{
					t = blackout[1] + 60000;
				}
				else if (snap < 0)
				{
					t = blackout[0];
				}
				else if (t - blackout[0] < blackout[1] - t)
				{
					t = blackout[0];
				}
				else
				{
					t = blackout[1] + 60000;
				}

				break;
			}
		}

		if (t != orig_t)
		{
			date = new Date(t);
		}
	}

	// min/max last, shrink inward if blackout dates extend outside [min,max] range

	var min = this.get('minDateTime');
	if (min)
	{
		var t = min.date.getTime();

		if (blackouts.length > 0)
		{
			var orig_t = t;
			var i      = 0;
			while (i < blackouts.length && blackouts[i][0] < t)
			{
				t = Math.max(orig_t, blackouts[i][1]);
				i++;
			}
		}

		if (date.getTime() < t)
		{
			date = new Date(t);
		}
	}

	var max = this.get('maxDateTime');
	if (max)
	{
		var t = max.date.getTime();

		if (blackouts.length > 0)
		{
			var orig_t = t;
			var i      = blackouts.length - 1;
			while (i >= 0 && t < blackouts[i][1])
			{
				t = Math.min(orig_t, blackouts[i][0]);
				i--;
			}
		}

		if (t < date.getTime())
		{
			date = new Date(t);
		}
	}

	// update controls that changed

	if (date.getFullYear() !== orig_date.getFullYear() ||
		date.getMonth()    !== orig_date.getMonth()    ||
		date.getDate()     !== orig_date.getDate())
	{
		var timer       = getEnforceTimer.call(this);
		timer.dateInput = Y.DateTimeUtils.formatDate(date);
		timer.timeInput = Y.DateTimeUtils.formatTime(date);
	}
	else if (date.getHours() !== orig_date.getHours() ||
			 date.getMinutes() !== orig_date.getMinutes())
	{
		var timer       = getEnforceTimer.call(this);
		timer.timeInput = Y.DateTimeUtils.formatTime(date);
	}
	else
	{
		this.fire('limitsEnforced');
	}

	// save valid input, in case use types invalid date next time

	this.prev_date_time = dateTimeSetter.call(this, date);
}

function getEnforceTimer()
{
	if (!this.enforce_timer)
	{
		this.enforce_timer = Y.later(0, this, enforceTimerCallback);
	}
	return this.enforce_timer;
}

function enforceTimerCallback()
{
	var timer          = this.enforce_timer;
	this.enforce_timer = null;

	var ping_list         = [];
	this.ignore_value_set = true;

	Y.each(['dateInput', 'timeInput'], function(name)
	{
		if (timer[name])
		{
			this.get(name).set('value', timer[name]);
			ping_list.push(name);
		}
	},
	this);

	this.ignore_value_set = false;
	ping.apply(this, ping_list);

	this.fire('limitsEnforced');
}

function updateCalendarRendering()
{
	if (!this.calendar)
	{
		return;
	}

	function mkpath()
	{
		var obj = rules;
		Y.each(arguments, function(key)
		{
			if (!obj[key])
			{
				obj[key] = {};
			}
			obj = obj[key];
		});
	}

	function set(date, type)
	{
		var y = date.getFullYear(),
			m = date.getMonth(),
			d = date.getDate();

		mkpath(y, m, d);

		rules[y][m][d] = type;
	}

	function disableRemaining(date, delta)
	{
		var d = new Date(date);
		d.setDate(d.getDate() + delta);

		while (d.getMonth() == date.getMonth())
		{
			set(d, 'disabled');
			d.setDate(d.getDate() + delta);
		}
	}

	var blackouts = this.get('blackouts').slice(0),
		rules     = {};

	var min = this.get('minDateTime');
	if (min)
	{
		if (blackouts.length > 0)
		{
			var t       = min.date.getTime();
			var changed = false;
			for (var i=0; i < blackouts.length; i++)
			{
				var blackout = blackouts[i];
				if (blackout[1] <= t)
				{
					blackouts.shift();
					i--;
				}
				else if (blackout[0] < t)
				{
					var start = new Date(blackout[0]);
					start.setHours(0);
					start.setMinutes(0);
					start.setSeconds(blackout_min_seconds);
					blackouts[i] = [ start.getTime(), blackout[1] ];
					changed      = true;
					break;
				}
			}
		}

		if (!changed &&
			(min.hour > 0 || min.minute > 0))
		{
			set(min.date, 'partial');
		}

		disableRemaining(min.date, -1);
	}

	var max = this.get('maxDateTime');
	if (max)
	{
		if (blackouts.length > 0)
		{
			var t       = max.date.getTime();
			var changed = false;
			for (var i=blackouts.length-1; i>=0; i--)
			{
				var blackout = blackouts[i];
				if (t <= blackout[0])
				{
					blackouts.pop();
				}
				else if (t < blackout[1])
				{
					var end = new Date(blackout[1]);
					end.setHours(23);
					end.setMinutes(59);
					end.setSeconds(blackout_max_seconds);
					blackouts[i] = [ blackout[0], end.getTime() ];
					changed      = true;
					break;
				}
			}
		}

		if (!changed &&
			(max.hour < 23 || max.minute < 59))
		{
			set(max.date, 'partial');
		}

		disableRemaining(max.date, +1);
	}

	for (var i=0; i<blackouts.length; i++)
	{
		var blackout = blackouts[i];
		var start    = new Date(blackout[0] + blackout_max_seconds*1000);
		var end      = new Date(blackout[1] + blackout_min_seconds*1000);

		if (start.getHours() > 0 || start.getMinutes() > 0)
		{
			set(start, 'partial');
			start.setDate(start.getDate()+1);
			start.setHours(0);
		}

		if (end.getHours() < 23 || end.getMinutes() < 59)
		{
			set(end, 'partial');
			end.setDate(end.getDate()-1);
			end.setHours(23);
		}

		while (start.getTime() < end.getTime())
		{
			set(start, 'disabled');
			start.setDate(start.getDate()+1);
		}
	}

	this.calendar.set('customRenderer',
	{
		rules:          rules,
		filterFunction: renderFilter
	});
}

function renderFilter(date, node, rules)
{
	if (Y.Array.indexOf(rules, 'partial') >= 0)
	{
		node.addClass('yui3-datetime-partial');
	}
	else if (Y.Array.indexOf(rules, 'disabled') >= 0)
	{
		node.addClass('yui3-calendar-selection-disabled');
	}
}

function ping()
{
	var duration = this.get('pingDuration');
	if (duration <= 0)
	{
		return;
	}

	var nodes = new Y.NodeList(Y.reduce(arguments, [], function(list, name)
	{
		list.push(this.get(name));
		return list;
	},
	this));

	var ping_class = this.get('pingClass');
	if (this.ping_task)
	{
		this.ping_task.nodes.removeClass(ping_class);
		this.ping_task.cancel();
		nodes = nodes.concat(this.ping_task.nodes);
	}

	nodes.addClass(ping_class);

	this.ping_task = Y.later(duration, this, function()
	{
		this.ping_task = null;
		nodes.removeClass(ping_class);
	});

	this.ping_task.nodes = nodes;
}

Y.extend(DateTime, Y.Base,
{
	initializer: function(
		/* object/string */	container,
		/* map */			config)
	{
		var date_input = this.get('dateInput'),
			time_input = this.get('timeInput');
		if (!time_input)
		{
			time_input = Y.Node.create('<input type="hidden"></input>');
			this.set('timeInput', time_input);
			time_input.set('value', Y.DateTimeUtils.formatTime(this.get('blankTime')));
			var created_time_input = true;
		}

		var default_date_time = this.get('defaultDateTime');
		if (default_date_time)
		{
			date_input.set('value', Y.DateTimeUtils.formatDate(default_date_time));
			if (!created_time_input)
			{
				time_input.set('value', Y.DateTimeUtils.formatTime(default_date_time));
			}

			this.prev_date_time = default_date_time;
		}

		if (date_input.calendarSync)
		{
			this.calendar = date_input.calendarSync.get('calendar');

			if (this.calendar && default_date_time)
			{
				this.calendar.set('date', default_date_time.date);
			}

			var t = this.get('minDateTime');
			if (this.calendar && t)
			{
				this.calendar.set('minimumDate', t.date);
			}

			t = this.get('maxDateTime');
			if (this.calendar && t)
			{
				this.calendar.set('maximumDate', t.date);
			}

			this.set('allowBlank', date_input.calendarSync.get('allowBlank'));
		}

		// changes

		date_input.on('change', enforceDateTimeLimits, this);
		date_input.after('valueSet', checkEnforceDateTimeLimits, this);

		time_input.on('change', enforceDateTimeLimits, this);
		time_input.after('valueSet', checkEnforceDateTimeLimits, this);

		enforceDateTimeLimits.call(this);

		function updateLimit(key, e)
		{
			if (e.newVal)
			{
				enforceDateTimeLimits.call(this);
				if (this.calendar)
				{
					this.calendar.set(key, e.newVal.date);
				}
			}
			else if (this.calendar)
			{
				this.calendar.set(key, null);
			}

			updateCalendarRendering.call(this);
		}

		this.after('minDateTimeChange', Y.bind(updateLimit, this, 'minimumDate'));
		this.after('maxDateTimeChange', Y.bind(updateLimit, this, 'maximumDate'));

		this.after('blackoutsChange', function()
		{
			enforceDateTimeLimits.call(this);
			updateCalendarRendering.call(this);
		});

		updateCalendarRendering.call(this);
	},

	/**
	 * Get the currently selected date and time.
	 * 
	 * @method getDateTime
	 * @return {Object} year,month,day,hour,minute,date,date_str,time_str
	 */
	getDateTime: function()
	{
		try
		{
			var date = Y.DateTimeUtils.parseDate(this.get('dateInput').get('value'));
			if (!date)
			{
				return false;
			}
		}
		catch (e)
		{
			return false;
		}

		try
		{
			var time = Y.DateTimeUtils.parseTime(this.get('timeInput').get('value'));
			if (!time)
			{
				return false;
			}
		}
		catch (e)
		{
			return false;
		}

		var result      = Y.DateTimeUtils.normalize(Y.mix(date, time));
		result.date_str = Y.DateTimeUtils.formatDate(result);
		result.time_str = Y.DateTimeUtils.formatTime(result);
		return result;
	},

	/**
	 * Set the date and time.
	 *
	 * @method setDateTime
	 * @param date_time {Object} date and time
	 */
	setDateTime: function(
		/* object */	date_time)
	{
		date_time = dateTimeSetter.call(this, date_time);
		if (date_time)
		{
			this.ignore_value_set = true;
			this.get('dateInput').set('value', Y.DateTimeUtils.formatDate(date_time));
			this.get('timeInput').set('value', Y.DateTimeUtils.formatTime(date_time));
			this.ignore_value_set = false;

			enforceDateTimeLimits.call(this);
		}
	},

	/**
	 * Reset the date and time to the values in `defaultDateTime`.
	 * 
	 * @method resetDateTime
	 */
	resetDateTime: function()
	{
		var d = this.get('defaultDateTime');
		if (d)
		{
			this.setDateTime(d);
		}
		else if (this.get('allowBlank'))
		{
			this.clearDateTime();
		}
		else
		{
			return;
		}

		enforceDateTimeLimits.call(this);
	},

	/**
	 * Clear the date and time.
	 *
	 * @method clearDateTime
	 */
	clearDateTime: function()
	{
		if (this.get('allowBlank'))
		{
			this.get('dateInput').set('value', '');
			// timeInput is automatically cleared
		}
		else
		{
			this.resetDateTime();
		}
	}
});

Y.DateTime = DateTime;