From 4fb483df7b9a5abd82d2556347d66badb0ecf1ca Mon Sep 17 00:00:00 2001 From: Francis Lachapelle Date: Thu, 10 Oct 2019 14:17:02 -0400 Subject: [PATCH] (js) Update sg-timepicker following md changes Added support for "required". --- .../js/Common/sgTimepicker.directive.js | 176 ++++++++++++++---- 1 file changed, 141 insertions(+), 35 deletions(-) diff --git a/UI/WebServerResources/js/Common/sgTimepicker.directive.js b/UI/WebServerResources/js/Common/sgTimepicker.directive.js index 329d496a1..45be44ec6 100644 --- a/UI/WebServerResources/js/Common/sgTimepicker.directive.js +++ b/UI/WebServerResources/js/Common/sgTimepicker.directive.js @@ -420,8 +420,8 @@ * */ - timePickerDirective.$inject = ['$mdUtil', '$mdAria']; - function timePickerDirective($mdUtil, $mdAria) { + timePickerDirective.$inject = ['$mdUtil', '$mdAria', 'inputDirective']; + function timePickerDirective($mdUtil, $mdAria, inputDirective) { return { template: function(tElement, tAttrs) { // Buttons are not in the tab order because users can open the hours pane via keyboard @@ -463,7 +463,7 @@ '' ].join(''); }, - require: ['ngModel', 'sgTimepicker', '?^form'], + require: ['ngModel', 'sgTimepicker', '?^mdInputContainer', '?^form'], scope: { placeholder: '@mdPlaceholder' }, @@ -472,20 +472,44 @@ bindToController: true, link: function(scope, element, attr, controllers) { var ngModelCtrl = controllers[0]; - var mdTimePickerCtrl = controllers[1]; - var parentForm = controllers[2]; + var sgTimePickerCtrl = controllers[1]; + var mdInputContainer = controllers[2]; + var parentForm = controllers[3]; var mdNoAsterisk = $mdUtil.parseAttributeBoolean(attr.mdNoAsterisk); - mdTimePickerCtrl.configureNgModel(ngModelCtrl); + sgTimePickerCtrl.configureNgModel(ngModelCtrl, mdInputContainer, inputDirective); - // TODO: shall we check ^mdInputContainer? - if (parentForm) { + if (mdInputContainer) { + var spacer = element[0].querySelector('.md-errors-spacer'); + + if (spacer) { + element.after(angular.element('
').append(spacer)); + } + + mdInputContainer.setHasPlaceholder(attr.mdPlaceholder); + mdInputContainer.input = element; + mdInputContainer.element + .addClass(INPUT_CONTAINER_CLASS) + .toggleClass(HAS_TIME_ICON_CLASS, attr.mdHideIcons !== 'time' && attr.mdHideIcons !== 'all'); + + if (!mdInputContainer.label) { + $mdAria.expect(element, 'aria-label', attr.mdPlaceholder); + } else if (!mdNoAsterisk) { + attr.$observe('required', function(value) { + mdInputContainer.label.toggleClass('md-required', !!value); + }); + } + + scope.$watch(mdInputContainer.isErrorGetter || function() { + return ngModelCtrl.$invalid && (ngModelCtrl.$touched || (parentForm && parentForm.$submitted)); + }, mdInputContainer.setInvalid); + } else if (parentForm) { // If invalid, highlights the input when the parent form is submitted. var parentSubmittedWatcher = scope.$watch(function() { return parentForm.$submitted; }, function(isSubmitted) { if (isSubmitted) { - mdTimePickerCtrl.updateErrorState(); + sgTimePickerCtrl.updateErrorState(); parentSubmittedWatcher(); } }); @@ -503,6 +527,12 @@ /** Class applied to the timepicker when it's open. */ var OPEN_CLASS = 'sg-timepicker-open'; + /** Class applied to the md-input-container, if a timepicker is placed inside it */ + var INPUT_CONTAINER_CLASS = '_sg-timepicker-floating-label'; + + /** Class to be applied when the time icon is enabled. */ + var HAS_TIME_ICON_CLASS = '_sg-timepicker-has-calendar-icon'; + /** Default time in ms to debounce input event by. */ var DEFAULT_DEBOUNCE_INTERVAL = 500; @@ -605,7 +635,7 @@ this.$scope = $scope; /** @type {Date} */ - this.date = null; + this.time = null; /** @type {boolean} */ this.isFocused = false; @@ -670,23 +700,56 @@ $mdTheming($element); $mdTheming(angular.element(this.timePane)); - this.installPropertyInterceptors(); - this.attachChangeListeners(); - this.attachInteractionListeners(); - var self = this; $scope.$on('$destroy', function() { self.detachTimePane(); }); + + if ($attrs.mdIsOpen) { + $scope.$watch('ctrl.isOpen', function(shouldBeOpen) { + if (shouldBeOpen) { + self.openTimePane({ + target: self.inputElement + }); + } else { + self.closeTimePane(); + } + }); + } + } + /** + * AngularJS Lifecycle hook for newer AngularJS versions. + * Bindings are not guaranteed to have been assigned in the controller, but they are in the $onInit hook. + */ + TimePickerCtrl.prototype.$onInit = function() { + this.installPropertyInterceptors(); + this.attachChangeListeners(); + this.attachInteractionListeners(); + }; + /** * Sets up the controller's reference to ngModelController. * @param {!angular.NgModelController} ngModelCtrl Instance of the ngModel controller. */ - TimePickerCtrl.prototype.configureNgModel = function(ngModelCtrl) { + TimePickerCtrl.prototype.configureNgModel = function(ngModelCtrl, mdInputContainer, inputDirective) { this.ngModelCtrl = ngModelCtrl; + this.mdInputContainer = mdInputContainer; + + // The input needs to be [type="date"] in order to be picked up by AngularJS. + this.$attrs.$set('type', 'date'); + + // Invoke the `input` directive link function, adding a stub for the element. + // This allows us to re-use AngularJS's logic for setting the timezone via ng-model-options. + // It works by calling the link function directly which then adds the proper `$parsers` and + // `$formatters` to the ngModel controller. + // inputDirective[0].link.pre(this.$scope, { + // on: angular.noop, + // val: angular.noop, + // 0: {} + // }, this.$attrs, [ngModelCtrl]); var self = this; @@ -697,16 +760,25 @@ 'Currently the model is a: ' + (typeof value)); } - self.time = value; - self.inputElement.value = self.dateLocale.formatTime(value); - self.resizeInputElement(); - self.updateErrorState(); + self.onExternalChange(value); return value; }); // Responds to external error state changes (e.g. ng-required based on another input). ngModelCtrl.$viewChangeListeners.unshift(angular.bind(this, this.updateErrorState)); + + // Forwards any events from the input to the root element. This is necessary to get `updateOn` + // working for events that don't bubble (e.g. 'blur') since AngularJS binds the handlers to + // the ``. + var updateOn = self.$mdUtil.getModelOption(ngModelCtrl, 'updateOn'); + + if (updateOn) { + this.ngInputElement.on( + updateOn, + angular.bind(this.$element, this.$element.triggerHandler, updateOn) + ); + } }; /** @@ -719,14 +791,11 @@ self.$scope.$on('sg-time-pane-change', function(event, data) { var time = new Date(data.date); - self.ngModelCtrl.$setViewValue(time); - self.time = time; - self.inputElement.value = self.dateLocale.formatTime(time); + self.setModelValue(time); + self.onExternalChange(time); if (data.changed == 'minutes') { self.closeTimePane(); } - self.resizeInputElement(); - self.inputContainer.classList.remove(INVALID_CLASS); }); self.ngInputElement.on('input', angular.bind(self, self.resizeInputElement)); @@ -806,7 +875,7 @@ * @param {Date=} opt_date Date to check. If not given, defaults to the datepicker's model value. */ TimePickerCtrl.prototype.updateErrorState = function(opt_date) { - var date = opt_date || this.date; + var date = opt_date || this.time; // Clear any existing errors to get rid of anything that's no longer relevant. this.clearErrorState(); @@ -817,12 +886,25 @@ this.ngModelCtrl.$setValidity('valid', date === null); } - // TODO(jelbourn): Change this to classList.toggle when we stop using PhantomJS in unit tests - // because it doesn't conform to the DOMTokenList spec. - // See https://github.com/ariya/phantomjs/issues/12782. - if (!this.ngModelCtrl.$valid) { - this.inputContainer.classList.add(INVALID_CLASS); + var input = this.inputElement.value; + var parsedTime = this.dateLocale.parseTime(input); + + if (!this.isInputValid(input, parsedTime) && this.ngModelCtrl.$valid) { + this.ngModelCtrl.$setValidity('valid', date == null); } + + angular.element(this.inputContainer).toggleClass(INVALID_CLASS, !this.ngModelCtrl.$valid); + }; + + /** + * Check to see if the input is valid, as the validation should fail if the model is invalid. + * + * @param {string} inputString + * @param {Date} parsedDate + * @return {boolean} Whether the input is valid + */ + TimePickerCtrl.prototype.isInputValid = function (inputString, parsedTime) { + return inputString === '' || this.dateUtil.isValidDate(parsedTime); }; /** Clears any error flags set by `updateErrorState`. */ @@ -850,14 +932,18 @@ // An input string is valid if it is either empty (representing no date) // or if it parses to a valid time that the user is allowed to select. - var isValidInput = inputString === '' || this.dateUtil.isValidDate(parsedTime); + var isValidInput = this.isInputValid(inputString, parsedTime); // The datepicker's model is only updated when there is a valid input. if (isValidInput) { var updated = new Date(this.time); - updated.setHours(parsedTime.getHours()); - updated.setMinutes(parsedTime.getMinutes()); - this.ngModelCtrl.$setViewValue(updated); + if (parsedTime) { + updated.setHours(parsedTime.getHours()); + updated.setMinutes(parsedTime.getMinutes()); + } else { + updated = null; + } + this.setModelValue(updated); this.time = updated; } @@ -931,7 +1017,7 @@ // If the bottom edge of the pane would be off the screen and shifting it up by the // difference would not go past the top edge of the screen. - var min = (typeof this.time == 'object' && this.time.getMinutes() % 5 === 0)? 'MIN5' : 'MIN1'; + var min = (this.time && this.time.getMinutes() % 5 === 0)? 'MIN5' : 'MIN1'; var paneHeight = this.$mdMedia('xs')? TIME_PANE_HEIGHT[min].XS : TIME_PANE_HEIGHT[min].GTXS; if (paneTop + paneHeight > viewportBottom && viewportBottom - paneHeight > viewportTop) { @@ -1085,4 +1171,24 @@ this.$scope.$parent.$eval(this.$attrs[attr]); } }; + + /** + * Sets the ng-model value. + * @param {Date=} value Date to be set as the model value. + */ + TimePickerCtrl.prototype.setModelValue = function(value) { + this.ngModelCtrl.$setViewValue(value); + }; + + /** + * Updates the timepicker when a model change occurred externally. + * @param {Date=} value Value that was set to the model. + */ + TimePickerCtrl.prototype.onExternalChange = function(value) { + this.time = value; + this.inputElement.value = this.dateLocale.formatTime(value); + if (this.mdInputContainer) this.mdInputContainer.setHasValue(!!value); + this.resizeInputElement(); + this.updateErrorState(); + }; })();