fm-timepicker
Version:
FairManager Time Picker Component
742 lines (660 loc) • 26.4 kB
JavaScript
/**
* Copyright (C) 2014-2015, HARTWIG Communication & Events GmbH & Co. KG
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*
* Created: 2014-01-07 15:49
*
* @author Oliver Salzburg <oliver.salzburg@gmail.com>
* @copyright Copyright (C) 2014-2015, HARTWIG Communication & Events GmbH & Co. KG
* @license http://opensource.org/licenses/mit-license.php MIT License
*/
(function() {
"use strict";
/* globals $, angular, Hamster, moment */
fmTimepickerController.$inject = ["$scope"];
fmTimepicker.$inject = ["$timeout"];
angular.module( "fmTimepicker", [] );
angular.module( "fmTimepicker" )
.filter( "fmTimeFormat", fmTimeFormat )
.filter( "fmTimeInterval", fmTimeInterval )
.controller( "fmTimepickerController", fmTimepickerController )
.directive( "fmTimepickerToggle", fmTimepickerToggle )
.directive( "fmTimepicker", fmTimepicker );
function fmTimeFormat() {
return function fmTimeFormatFilter( input, format ) {
if( typeof input === "number" ) {
input = moment( input );
}
return moment( input ).format( format );
};
}
function fmTimeInterval() {
return function fmTimeIntervalFilter( input, start, end, interval ) {
if( !start || !end ) {
return input;
}
start = moment( start );
end = moment( end );
interval = interval || moment.duration( 30, "minutes" );
for( var time = start.clone(); +time <= +end; time.add( interval ) ) {
// We're using the UNIX offset integer value here.
// When trying to return the actual moment instance (and then later format it through a filter),
// you will get an infinite digest loop, because the returned objects in the resulting array
// will always be new, unique instances. We always need to return the identical, literal values for each input.
input.push( +time );
}
return input;
};
}
/* @ngInject */
function fmTimepickerController( $scope ) {
// Create day of reference
$scope.fmReference = $scope.fmReference ? moment( $scope.fmReference ) : moment();
$scope.fmStyle = $scope.fmStyle || "dropdown";
$scope.fmIsOpen = $scope.fmIsOpen || false;
$scope.fmFormat = $scope.fmFormat || "LT";
$scope.fmStartTime = $scope.fmStartTime || moment( $scope.fmReference ).startOf( "day" );
$scope.fmEndTime = $scope.fmEndTime || moment( $scope.fmReference ).endOf( "day" );
$scope.fmInterval = $scope.fmInterval || moment.duration( 30, "minutes" );
$scope.fmLargeInterval = $scope.fmLargeInterval || moment.duration( 60, "minutes" );
$scope.fmStrict = $scope.fmStrict || false;
$scope.fmBtnClass = $scope.fmBtnClass || "btn btn-default";
$scope.fmIconClasses = $scope.fmIconClasses || {
plus : "glyphicon glyphicon-plus",
minus : "glyphicon glyphicon-minus",
time : "glyphicon glyphicon-time"
};
if( moment.tz ) {
$scope.fmStartTime.tz( $scope.fmReference.tz() );
$scope.fmEndTime.tz( $scope.fmReference.tz() );
}
if( $scope.fmStrict ) {
// Round the model value up to the next valid time that fits the configured interval.
var modelMilliseconds = $scope.ngModel.valueOf();
var intervalMilliseconds = $scope.fmInterval.asMilliseconds();
modelMilliseconds -= modelMilliseconds % intervalMilliseconds;
modelMilliseconds += intervalMilliseconds;
$scope.ngModel = moment( modelMilliseconds );
}
/**
* Makes sure that the moment instances we work with all use the same day as fmReference.
* We need this because we might construct moment instances from all kinds of sources,
* in the time picker, we only care about time values though and we still want to compare
* them through the moment mechanics (which respect the full date).
* @param {Moment} [day] If day is given, it will be constrained to the fmReference day, otherwise all members will be constrained.
* @return {Moment} If day was provided as parameter, it will be returned as well.
*/
$scope.constrainToReference = function( day ) {
if( day ) {
if( moment.tz ) {
day.tz( $scope.fmReference.tz() );
}
if( !day.isSame( $scope.fmReference, "day" ) ) {
day.year( $scope.fmReference.year() ).month( $scope.fmReference.month() ).date( $scope.fmReference.date() );
}
return day;
} else {
if( !$scope.fmStartTime.isSame( $scope.fmReference, "day" ) ) {
$scope.fmStartTime.year( $scope.fmReference.year() ).month( $scope.fmReference.month() ).date(
$scope.fmReference.date() );
}
if( !$scope.fmEndTime.isSame( $scope.fmReference, "day" ) ) {
$scope.fmEndTime.year( $scope.fmReference.year() ).month( $scope.fmReference.month() ).date( $scope.fmReference.date() );
}
if( $scope.ngModel && !$scope.ngModel.isSame( $scope.fmReference, "day" ) ) {
$scope.ngModel.year( $scope.fmReference.year() ).month( $scope.fmReference.month() ).date( $scope.fmReference.date() );
}
}
return null;
};
$scope.constrainToReference();
/**
* Returns a time value that is within the bounds given by the start and end time parameters.
* @param {Moment} time The time value that should be constrained to be within the given bounds.
* @returns {Moment} A new time value within the bounds, or the input instance.
*/
$scope.ensureTimeIsWithinBounds = function( time ) {
// We expect "time" to be a Moment instance; otherwise bail.
if( !time || !moment.isMoment( time ) ) {
return time;
}
// Constrain model value to be in given bounds.
if( time.isBefore( $scope.fmStartTime ) ) {
return moment( $scope.fmStartTime );
}
if( time.isAfter( $scope.fmEndTime ) ) {
return moment( $scope.fmEndTime );
}
return time;
};
$scope.ngModel = $scope.ensureTimeIsWithinBounds( $scope.ngModel );
/**
* Utility method to find the index of an item, in our collection of possible values, that matches a given time value.
* @param {Moment} model A moment instance to look for in our possible values.
*/
$scope.findActiveIndex = function( model ) {
$scope.activeIndex = 0;
if( !model ) {
return;
}
// We step through each possible value instead of calculating the index directly,
// to make sure we account for DST changes in the reference day.
for( var time = $scope.fmStartTime.clone(); +time <= +$scope.fmEndTime; time.add( $scope.fmInterval ), ++$scope.activeIndex ) {
if( time.isSame( model ) ) {
break;
}
// Check if we've already passed the time value that would fit our current model.
if( time.isAfter( model ) ) {
// If we're in strict mode, set an invalid index.
if( $scope.fmStrict ) {
$scope.activeIndex = -1;
}
// If we're not in strict mode, decrease the index to select the previous item (the one we just passed).
$scope.activeIndex -= 1;
// Now bail out and use whatever index we determined.
break;
}
}
};
// The index of the last element in our time value collection.
$scope.largestPossibleIndex = Number.MAX_VALUE;
// The amount of list items we should skip when we perform a large jump through the collection.
$scope.largeIntervalIndexJump = Number.MAX_VALUE;
// Seed the active index based on the current model value.
$scope.findActiveIndex( $scope.ngModel );
// Check the supplied interval for validity.
$scope.$watch( "fmInterval", function intervalWatcher( newInterval, oldInterval ) {
if( newInterval.asMilliseconds() < 1 ) {
console.error(
"[fm-timepicker] Error: Supplied interval length is smaller than 1ms! Reverting to default." );
$scope.fmInterval = moment.duration( 30, "minutes" );
}
} );
// Check the supplied large interval for validity.
$scope.$watch( "fmLargeInterval", function largeIntervalWatcher( newInterval, oldInterval ) {
if( newInterval.asMilliseconds() < 10 ) {
console.error(
"[fm-timepicker] Error: Supplied large interval length is smaller than 10ms! Reverting to default." );
$scope.fmLargeInterval = moment.duration( 60, "minutes" );
}
} );
// Watch the given interval values.
$scope.$watchCollection( "[fmInterval,fmLargeInterval]", function intervalsWatcher( newValues ) {
// Pick array apart.
var newInterval = newValues[ 0 ];
var newLargeInterval = newValues[ 1 ];
// Get millisecond values for the intervals.
var newIntervalMilliseconds = newInterval.asMilliseconds();
var newLargeIntervalMilliseconds = newLargeInterval.asMilliseconds();
// Check if the large interval is a multiple of the interval.
if( 0 !== ( newLargeIntervalMilliseconds % newIntervalMilliseconds ) ) {
console.warn(
"[fm-timepicker] Warning: Large interval is not a multiple of interval! Using internally computed value instead." );
$scope.fmLargeInterval = moment.duration( newIntervalMilliseconds * 5 );
newLargeIntervalMilliseconds = $scope.fmLargeInterval.asMilliseconds();
}
// Calculate how many indices we need to skip for a large jump through our collection.
$scope.largeIntervalIndexJump = newLargeIntervalMilliseconds / newIntervalMilliseconds;
} );
}
function fmTimepickerToggle() {
return {
restrict : "A",
link : function postLink( scope, element, attributes ) {
// Toggle the popup when the toggle button is clicked.
element.bind( "click", function onClick() {
if( scope.fmIsOpen ) {
scope.focusInputElement();
scope.closePopup();
} else {
// Focusing the input element will automatically open the popup
scope.focusInputElement();
}
} );
}
};
}
/* @ngInject */
function fmTimepicker( $timeout ) {
return {
templateUrl : "fmTimepicker.html",
replace : true,
restrict : "EA",
scope : {
ngModel : "=",
fmFormat : "=?",
fmStartTime : "=?",
fmEndTime : "=?",
fmReference : "=?",
fmInterval : "=?",
fmLargeInterval : "=?",
fmIsOpen : "=?",
fmStyle : "=?",
fmStrict : "=?",
fmBtnClass : "=?",
fmIconClasses : "=?",
fmDisabled : "=?"
},
controller : "fmTimepickerController",
require : "ngModel",
link : function postLink( scope, element, attributes, controller ) {
// Watch our input parameters and re-validate our view when they change.
scope.$watchCollection( "[fmStartTime,fmEndTime,fmInterval,fmStrict]", function inputWatcher() {
scope.constrainToReference();
validateView();
} );
// Watch all time related parameters.
scope.$watchCollection( "[fmStartTime,fmEndTime,fmInterval,ngModel]", function timeWatcher() {
// When they change, find the index of the element in the dropdown that relates to the current model value.
scope.findActiveIndex( scope.ngModel );
} );
/**
* Invoked when we need to update the view due to a changed model value.
*/
controller.$render = function render() {
// Convert the moment instance we got to a string in our desired format.
var time = null;
if( controller.$modelValue !== null ) {
time = moment( controller.$modelValue ).format( scope.fmFormat );
}
// Check if the given time is valid.
var timeValid = checkTimeValueValid( time );
if( scope.fmStrict ) {
timeValid = timeValid && checkTimeValueWithinBounds( time ) && checkTimeValueFitsInterval( time );
}
if( timeValid || time === null ) {
// If the time is valid, store the time string in the scope used by the input box.
scope.time = time;
}
};
/**
* Reset the validity of the directive.
* @param {Boolean} to What to set the validity to?
*/
function resetValidity( to ) {
controller.$setValidity( "time", to );
controller.$setValidity( "bounds", to );
controller.$setValidity( "interval", to );
controller.$setValidity( "start", to );
controller.$setValidity( "end", to );
controller.$setValidity( "required", to );
}
/**
* Check if the value in the view is valid.
* It has to represent a valid time in itself and it has to fit within the constraints defined through our input parameters.
*/
function validateView() {
resetValidity( true );
if( !scope.time ) {
if( attributes.required ) {
controller.$setValidity( "required", false );
}
controller.$setViewValue( null );
}
// Check if the string in the input box represents a valid date according to the rules set through parameters in our scope.
var timeValid = checkTimeValueValid( scope.time );
if( scope.fmStrict ) {
timeValid = timeValid && checkTimeValueWithinBounds( scope.time ) && checkTimeValueFitsInterval(
scope.time );
}
if( !scope.fmStartTime.isValid() ) {
controller.$setValidity( "start", false );
}
if( !scope.fmEndTime.isValid() ) {
controller.$setValidity( "end", false );
}
if( timeValid ) {
// If the string is valid, convert it to a moment instance, store in the model and...
var newTime;
if( moment.tz ) {
newTime = moment.tz(
scope.time,
scope.fmFormat,
scope.fmReference.tz() );
} else {
newTime = moment( scope.time, scope.fmFormat );
}
newTime = scope.constrainToReference( newTime );
controller.$setViewValue( newTime );
// ...convert it back to a string in our desired format.
// This allows the user to input any partial format that moment accepts and we'll convert it to the format we expect.
if( moment.tz ) {
scope.time = moment.tz(
scope.time,
scope.fmFormat,
scope.fmReference.tz() ).format( scope.fmFormat );
} else {
scope.time = moment( scope.time, scope.fmFormat ).format( scope.fmFormat );
}
}
}
/**
* Check if a given string represents a valid time in our expected format.
* @param {String} timeString The timestamp is the expected format.
* @returns {boolean} true if the string is a valid time; false otherwise.
*/
function checkTimeValueValid( timeString ) {
if( !timeString ) {
return false;
}
var time;
if( moment.tz ) {
time = timeString ? moment.tz(
timeString,
scope.fmFormat,
scope.fmReference.tz() ) : moment.invalid();
} else {
time = timeString ? moment( timeString, scope.fmFormat ) : moment.invalid();
}
if( !time.isValid() ) {
controller.$setValidity( "time", false );
controller.$setViewValue( null );
return false;
} else {
controller.$setValidity( "time", true );
return true;
}
}
/**
* Check if a given string represents a time within the bounds specified through our start and end times.
* @param {String} timeString The timestamp is the expected format.
* @returns {boolean} true if the string represents a valid time and the time is within the defined bounds; false otherwise.
*/
function checkTimeValueWithinBounds( timeString ) {
var time;
if( moment.tz ) {
time = timeString ? moment.tz(
timeString,
scope.fmFormat,
scope.fmReference.tz() ) : moment.invalid();
} else {
time = timeString ? moment( timeString, scope.fmFormat ) : moment.invalid();
}
time = scope.constrainToReference( time );
if( !time.isValid() || time.isBefore( scope.fmStartTime ) || time.isAfter( scope.fmEndTime ) ) {
controller.$setValidity( "bounds", false );
controller.$setViewValue( null );
return false;
} else {
controller.$setValidity( "bounds", true );
return true;
}
}
/**
* Check if a given string represents a time that lies on a the boundary of a time interval.
* @param {String} timeString The timestamp in the expected format.
* @returns {boolean} true if the string represents a valid time and that time lies on an interval boundary; false otherwise.
*/
function checkTimeValueFitsInterval( timeString ) {
var time;
if( moment.tz ) {
time = timeString ? moment.tz(
timeString,
scope.fmFormat,
scope.fmReference.tz() ) : moment.invalid();
} else {
time = timeString ? moment( timeString, scope.fmFormat ) : moment.invalid();
}
// Check first if the time string could be parsed as a valid timestamp.
var isValid = time.isValid();
if( isValid ) {
// Calculate the amount of milliseconds that passed since the specified start time.
var durationSinceStartTime = time.diff( scope.fmStartTime );
// Calculate how many milliseconds are within the given time interval.
var intervalMilliseconds = scope.fmInterval.asMilliseconds();
// Check if the modulo operation has a remainder.
isValid = ( 0 === ( durationSinceStartTime % intervalMilliseconds ) );
}
if( !isValid ) {
controller.$setValidity( "interval", false );
controller.$setViewValue( null );
return false;
} else {
controller.$setValidity( "interval", true );
return true;
}
}
function ensureUpdatedView() {
$timeout( function runDigest() {
scope.$apply();
} );
// Scroll the selected list item into view if the popup is open.
if( scope.fmIsOpen ) {
// Use $timeout to give the DOM time to catch up.
$timeout( scrollSelectedItemIntoView );
}
}
/**
* Scroll the time that is currently selected into view.
* This applies to the dropdown below the input element.
*/
function scrollSelectedItemIntoView() {
// Find the popup.
var popupListElement = element.find( "ul" );
// Scroll it to the top, so that we can then get the correct relative offset for all list items.
$( popupListElement ).scrollTop( 0 );
// Find the selected list item.
var selectedListElement = $( "li.active", popupListElement );
// Retrieve offset from the top and height of the list element.
var top = selectedListElement.length ? selectedListElement.position().top : 0;
var height = selectedListElement.length ? selectedListElement.outerHeight( true ) : 0;
// Scroll the list to bring the selected list element into the view.
$( popupListElement ).scrollTop( top - height );
}
/**
* Open the popup dropdown list.
*/
function openPopup() {
if( !scope.fmIsOpen ) {
scope.fmIsOpen = true;
scope.modelPreview = scope.ngModel ? scope.ngModel.clone() : scope.fmStartTime.clone();
$timeout( ensureUpdatedView );
}
}
// --------------- Scope methods ---------------
/**
* Close the popup dropdown list.
*/
scope.closePopup = function( delayed ) {
if( delayed ) {
// Delay closing the popup by 200ms to ensure selection of
// list items can happen before the popup is hidden.
$timeout(
function closeDropdown() {
scope.fmIsOpen = false;
}, 200 );
} else {
scope.fmIsOpen = false;
$timeout( ensureUpdatedView );
}
};
scope.handleListClick = function handleListClick( $event ) {
// When the list scrollbar is clicked, this can cause the list to lose focus.
// Preventing the default behavior here has no undesired effects, it just stops
// the input from losing focus.
$event.preventDefault();
return false;
};
/**
* Selects a given timestamp as the new value of the timepicker.
* @param {Number} timestamp UNIX timestamp
* @param {Number} elementIndex The index of the time element in the dropdown list.
*/
scope.select = function select( timestamp, elementIndex ) {
// Construct a moment instance from the UNIX offset.
var time;
if( moment.tz && scope.fmReference.tz() ) {
time = moment( timestamp ).tz( scope.fmReference.tz() );
} else {
time = moment( timestamp );
}
// Format the time to store it in the input box.
scope.time = time.format( scope.fmFormat );
// Store the selected index
scope.activeIndex = elementIndex;
scope.update();
scope.closePopup();
};
scope.increment = function increment() {
if( scope.fmIsOpen ) {
scope.modelPreview.add( scope.fmInterval );
scope.modelPreview = scope.ensureTimeIsWithinBounds( scope.modelPreview );
} else {
scope.ngModel.add( scope.fmInterval );
scope.ngModel = scope.ensureTimeIsWithinBounds( scope.ngModel );
scope.time = scope.ngModel.format( scope.fmFormat );
}
scope.activeIndex = Math.min( scope.largestPossibleIndex, scope.activeIndex + 1 );
};
scope.decrement = function decrement() {
if( scope.fmIsOpen ) {
scope.modelPreview.subtract( scope.fmInterval );
scope.modelPreview = scope.ensureTimeIsWithinBounds( scope.modelPreview );
} else {
scope.ngModel.subtract( scope.fmInterval );
scope.ngModel = scope.ensureTimeIsWithinBounds( scope.ngModel );
scope.time = scope.ngModel.format( scope.fmFormat );
}
scope.activeIndex = Math.max( 0, scope.activeIndex - 1 );
};
/**
* Check if the value in the input control is a valid timestamp.
*/
scope.update = function update() {
var timeValid = checkTimeValueValid( scope.time ) && checkTimeValueWithinBounds( scope.time );
if( timeValid ) {
var newTime;
if( moment.tz ) {
newTime = moment.tz( scope.time,
scope.fmFormat,
scope.fmReference.tz() );
} else {
newTime = moment( scope.time, scope.fmFormat );
}
newTime = scope.constrainToReference( newTime );
controller.$setViewValue( newTime );
}
};
scope.handleKeyboardInput = function handleKeyboardInput( event ) {
switch( event.keyCode ) {
case 13:
// Enter
if( scope.modelPreview ) {
scope.ngModel = scope.modelPreview;
scope.fmIsOpen = false;
}
break;
case 27:
// Escape
scope.closePopup();
break;
case 33:
// Page up
openPopup();
scope.modelPreview.subtract( scope.fmLargeInterval );
scope.modelPreview = scope.ensureTimeIsWithinBounds( scope.modelPreview );
scope.activeIndex = Math.max( 0,
scope.activeIndex - scope.largeIntervalIndexJump );
break;
case 34:
// Page down
openPopup();
scope.modelPreview.add( scope.fmLargeInterval );
scope.modelPreview = scope.ensureTimeIsWithinBounds( scope.modelPreview );
scope.activeIndex = Math.min( scope.largestPossibleIndex,
scope.activeIndex + scope.largeIntervalIndexJump );
break;
case 38:
// Up arrow
openPopup();
scope.decrement();
break;
case 40:
// Down arrow
openPopup();
scope.increment();
break;
default:
}
$timeout( ensureUpdatedView );
};
/**
* Prevent default behavior from happening.
* @param event
*/
scope.preventDefault = function preventDefault( event ) {
event.preventDefault();
};
/**
* Remember the highest index of the existing list items.
* We use this to constrain the possible values for the index that marks a list item as active.
* @param {Number} index
*/
scope.largestPossibleIndexIs = function largestPossibleIndexIs( index ) {
scope.largestPossibleIndex = index;
};
scope.focusInputElement = function focusInputElement() {
$( inputElement ).focus();
};
var inputElement = element.find( "input" );
var popupListElement = element.find( "ul" );
/**
* Open the popup when the input box gets focus.
*/
inputElement.bind( "focus", function onFocus() {
// Without delay the popup can glitch close itself instantly after being opened.
$timeout( openPopup, 150 );
scope.isFocused = true;
} );
/**
* Invoked when the input box loses focus.
*/
inputElement.bind( "blur", function onBlur() {
// Delay any action by 150ms
$timeout( function checkFocusState() {
// Check if we didn't get refocused in the meantime.
// This can happen if the input box is selected and the user toggles the dropdown.
// This would cause a hide and close in rapid succession, so don't do it.
if( !$( inputElement ).is( ":focus" ) ) {
scope.closePopup();
validateView();
}
}, 150 );
scope.isFocused = false;
} );
popupListElement.bind( "mousedown", function onMousedown( event ) {
event.preventDefault();
} );
if( typeof Hamster === "function" ) {
Hamster( inputElement[ 0 ] ).wheel( function onMousewheel( event, delta, deltaX, deltaY ) {
if( scope.isFocused ) {
event.preventDefault();
scope.activeIndex -= delta;
scope.activeIndex = Math.min( scope.largestPossibleIndex,
Math.max( 0, scope.activeIndex ) );
scope.select( scope.dropDownOptions[ scope.activeIndex ], scope.activeIndex );
$timeout( ensureUpdatedView );
}
} );
}
}
};
}
})();