From 5589fcc38239139a2162483631fca65424e706d6 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Tue, 13 Sep 2016 23:04:56 +0200 Subject: [PATCH] feat(datepicker): migrate to mdPanel Migrates the datepicker's positioning logic to mdPanel. All the functionality should work as before. Fixes #9564. --- src/components/datepicker/datePicker.js | 3 +- src/components/datepicker/datePicker.scss | 19 +- src/components/datepicker/js/calendar.js | 56 +-- src/components/datepicker/js/calendar.spec.js | 12 +- .../datepicker/js/datepickerDirective.js | 364 ++++++++---------- .../datepicker/js/datepickerDirective.spec.js | 224 ++++------- 6 files changed, 272 insertions(+), 406 deletions(-) diff --git a/src/components/datepicker/datePicker.js b/src/components/datepicker/datePicker.js index 8f25256b71a..40e60d07e7b 100644 --- a/src/components/datepicker/datePicker.js +++ b/src/components/datepicker/datePicker.js @@ -7,5 +7,6 @@ angular.module('material.components.datepicker', [ 'material.core', 'material.components.icon', - 'material.components.virtualRepeat' + 'material.components.virtualRepeat', + 'material.components.panel' ]); diff --git a/src/components/datepicker/datePicker.scss b/src/components/datepicker/datePicker.scss index 650b29dcec7..6b3b51af49b 100644 --- a/src/components/datepicker/datePicker.scss +++ b/src/components/datepicker/datePicker.scss @@ -116,19 +116,8 @@ md-datepicker { } } -.md-datepicker-is-showing .md-scroll-mask { - z-index: $z-index-calendar-pane - 1; -} - // Floating pane that contains the calendar at the bottom of the input. .md-datepicker-calendar-pane { - // On most browsers the `scale(0)` below prevents this element from - // overflowing it's parent, however IE and Edge seem to disregard it. - // The `left: -100%` pulls the element back in order to ensure that - // it doesn't cause an overflow. - position: absolute; - top: 0; - left: -100%; z-index: $z-index-calendar-pane; border-width: 1px; border-style: solid; @@ -149,19 +138,15 @@ md-datepicker { width: $md-calendar-width; position: relative; overflow: hidden; - - background: transparent; - pointer-events: none; - cursor: text; } // The calendar portion of the floating pane (vs. the input mask). .md-datepicker-calendar { - opacity: 0; // Use a modified timing function (from swift-ease-out) so that the opacity part of the // animation doesn't come in as quickly so that the floating pane doesn't ever seem to // cover up the trigger input. transition: opacity $md-datepicker-open-animation-duration cubic-bezier(0.5, 0, 0.25, 1); + opacity: 0; .md-pane-open & { opacity: 1; @@ -194,7 +179,7 @@ md-datepicker { .md-datepicker-triangle-button { position: absolute; @include rtl-prop(right, left, 0, auto); - top: $md-date-arrow-size; + top: $md-date-arrow-size / 2; // TODO(jelbourn): This position isn't great on all platforms. @include rtl(transform, translateY(-25%) translateX(45%), translateY(-25%) translateX(-45%)); diff --git a/src/components/datepicker/js/calendar.js b/src/components/datepicker/js/calendar.js index 42ba31fb976..24c5981a832 100644 --- a/src/components/datepicker/js/calendar.js +++ b/src/components/datepicker/js/calendar.js @@ -58,7 +58,10 @@ minDate: '=mdMinDate', maxDate: '=mdMaxDate', dateFilter: '=mdDateFilter', - _currentView: '@mdCurrentView' + _currentView: '@mdCurrentView', + + // private way of passing in the panel from the datepicker + _panelRef: '=mdPanelRef' }, require: ['ngModel', 'mdCalendar'], controller: CalendarCtrl, @@ -197,13 +200,18 @@ var boundKeyHandler = angular.bind(this, this.handleKeyEvent); - // Bind the keydown handler to the body, in order to handle cases where the focused - // element gets removed from the DOM and stops propagating click events. - angular.element(document.body).on('keydown', boundKeyHandler); + if (this._panelRef) { + // Bind the keydown handler to the body, in order to handle cases where the focused + // element gets removed from the DOM and stops propagating key events. + angular.element(document.body).on('keydown', boundKeyHandler); - $scope.$on('$destroy', function() { - angular.element(document.body).off('keydown', boundKeyHandler); - }); + $scope.$on('$destroy', function() { + angular.element(document.body).off('keydown', boundKeyHandler); + }); + } else { + // If the calendar on it's own, it shouldn't bind global key handlers. + $element.on('keydown', boundKeyHandler); + } if (this.minDate && this.minDate > $mdDateLocale.firstRenderableDate) { this.firstRenderableDate = this.minDate; @@ -345,27 +353,25 @@ CalendarCtrl.prototype.handleKeyEvent = function(event) { var self = this; - this.$scope.$apply(function() { - // Capture escape and emit back up so that a wrapping component - // (such as a date-picker) can decide to close. - if (event.which == self.keyCode.ESCAPE || event.which == self.keyCode.TAB) { - self.$scope.$emit('md-calendar-close'); - - if (event.which == self.keyCode.TAB) { + if (!this._panelRef || this._panelRef.isAttached) { + this.$scope.$apply(function() { + // Capture tabbing and emit back up so that a wrapping component + // (such as a date-picker) can decide to close. + if (event.which === self.keyCode.TAB) { + self.$scope.$emit('md-calendar-close'); event.preventDefault(); + return; } - return; - } - - // Broadcast the action that any child controllers should take. - var action = self.getActionFromKeyEvent(event); - if (action) { - event.preventDefault(); - event.stopPropagation(); - self.$scope.$broadcast('md-calendar-parent-action', action); - } - }); + // Broadcast the action that any child controllers should take. + var action = self.getActionFromKeyEvent(event); + if (action) { + event.preventDefault(); + event.stopPropagation(); + self.$scope.$broadcast('md-calendar-parent-action', action); + } + }); + } }; /** diff --git a/src/components/datepicker/js/calendar.spec.js b/src/components/datepicker/js/calendar.spec.js index 740912864f7..e4726301615 100644 --- a/src/components/datepicker/js/calendar.spec.js +++ b/src/components/datepicker/js/calendar.spec.js @@ -113,7 +113,7 @@ describe('md-calendar', function() { function dispatchKeyEvent(keyCode, opt_modifiers) { var mod = opt_modifiers || {}; - angular.element(document.body).triggerHandler({ + calendarController.$element.triggerHandler({ type: 'keydown', keyCode: keyCode, which: keyCode, @@ -665,19 +665,19 @@ describe('md-calendar', function() { }); }); - it('should fire an event when escape is pressed', function() { - var escapeHandler = jasmine.createSpy('escapeHandler'); - pageScope.$on('md-calendar-close', escapeHandler); + it('should fire an event when tabbing away', function() { + var tabHandler = jasmine.createSpy('tabHandler'); + pageScope.$on('md-calendar-close', tabHandler); pageScope.myDate = new Date(2014, FEB, 11); applyDateChange(); var selectedDate = element.querySelector('.md-calendar-selected-date'); selectedDate.focus(); - dispatchKeyEvent(keyCodes.ESCAPE); + dispatchKeyEvent(keyCodes.TAB); pageScope.$apply(); - expect(escapeHandler).toHaveBeenCalled(); + expect(tabHandler).toHaveBeenCalled(); }); }); diff --git a/src/components/datepicker/js/datepickerDirective.js b/src/components/datepicker/js/datepickerDirective.js index d8aebdc95b8..81c71d06d72 100644 --- a/src/components/datepicker/js/datepickerDirective.js +++ b/src/components/datepicker/js/datepickerDirective.js @@ -92,22 +92,6 @@ 'ng-focus="ctrl.setFocused(true)" ' + 'ng-blur="ctrl.setFocused(false)"> ' + triangleButton + - '' + - - // This pane will be detached from here and re-attached to the document body. - '
' + - '
' + - '
' + - '
' + - '
' + - '' + - '' + - '
' + '
'; }, require: ['ngModel', 'mdDatepicker', '?^mdInputContainer', '?^form'], @@ -216,13 +200,31 @@ */ var CALENDAR_PANE_WIDTH = 360; + var CALENDAR = '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + /** * Controller for md-datepicker. * * @ngInject @constructor */ function DatePickerCtrl($scope, $element, $attrs, $window, $mdConstant, - $mdTheming, $mdUtil, $mdDateLocale, $$mdDateUtil, $$rAF, $mdGesture, $filter) { + $mdTheming, $mdUtil, $mdDateLocale, $$mdDateUtil, $$rAF, $mdGesture, + $filter, $mdPanel) { /** @final */ this.$window = $window; @@ -242,14 +244,8 @@ /** @final */ this.$$rAF = $$rAF; - /** - * The root document element. This is used for attaching a top-level click handler to - * close the calendar panel when a click outside said panel occurs. We use `documentElement` - * instead of body because, when scrolling is disabled, some browsers consider the body element - * to be completely off the screen and propagate events directly to the html element. - * @type {!angular.JQLite} - */ - this.documentElement = angular.element(document.documentElement); + /** @final */ + this.$mdTheming = $mdTheming; /** @type {!angular.NgModelController} */ this.ngModelCtrl = null; @@ -263,18 +259,9 @@ /** @type {HTMLElement} */ this.inputContainer = $element[0].querySelector('.md-datepicker-input-container'); - /** @type {HTMLElement} Floating calendar pane. */ - this.calendarPane = $element[0].querySelector('.md-datepicker-calendar-pane'); - /** @type {HTMLElement} Calendar icon button. */ this.calendarButton = $element[0].querySelector('.md-datepicker-button'); - /** - * Element covering everything but the input in the top of the floating calendar pane. - * @type {!angular.JQLite} - */ - this.inputMask = angular.element($element[0].querySelector('.md-datepicker-input-mask-opaque')); - /** @final {!angular.JQLite} */ this.$element = $element; @@ -291,12 +278,23 @@ this.isFocused = false; /** @type {boolean} */ - this.isDisabled; - this.setDisabled($element[0].disabled || angular.isString($attrs.disabled)); + this.isDisabled = false; + this.setDisabled($element[0].disabled || $attrs.hasOwnProperty('disabled')); - /** @type {boolean} Whether the date-picker's calendar pane is open. */ + /** @type {boolean} Whether the date-picker's calendar pane is open. Used internally. */ this.isCalendarOpen = false; + /** + * Whether calendar should be open. Used when triggering the calendar externally. + * It needs to be separate from `isCalendarOpen` in order to avoid some mixups + * where it might trigger the calendar a couple of times in a row. + * @type {boolean} + */ + this.isOpen = false; + + /** @type {boolean} Used to prevent infinite loops when using mdOpenOnFocus. */ + this.preventInputFocus = false; + /** @type {boolean} Whether the calendar should open when the input is focused. */ this.openOnFocus = $attrs.hasOwnProperty('mdOpenOnFocus'); @@ -310,12 +308,6 @@ */ this.calendarPaneOpenedFrom = null; - /** @type {String} Unique id for the calendar pane. */ - this.calendarPane.id = 'md-date-pane' + $mdUtil.nextUid(); - - /** Pre-bound click handler is saved so that the event listener can be removed. */ - this.bodyClickHandler = angular.bind(this, this.handleBodyClick); - /** * Name of the event that will trigger a close. Necessary to sniff the browser, because * the resize event doesn't make sense on mobile and can have a negative impact since it @@ -338,6 +330,28 @@ /** @type {Number} Extra margin for the top of the floating calendar. Gets determined on the first open. */ this.topMargin = null; + /** @type {Number} Vertical offset for the panel that helps it align the overlay properly. */ + this.topOffset = null; + + /** @type {!MdPanelPosition} Position object for the mdPanel instance. */ + this.panelPosition = $mdPanel.newPanelPosition() + .relativeTo(this.inputContainer) + .addPanelPosition($mdPanel.xPosition.ALIGN_START, $mdPanel.yPosition.BELOW) + .withOffsetX(-this.leftMargin + 'px') + .withOffsetY(angular.bind(this, this.getPanelYOffset)); + + /** @type {!MdPanelRef} Reference to the mdPanel instance of the calendar. */ + this.panelRef = $mdPanel.create({ + attachTo: document.body, + template: CALENDAR, + clickOutsideToClose: true, + escapeToClose: true, + focusOnOpen: false, + scope: this.$scope.$new(), + position: this.panelPosition, + onDomRemoved: angular.bind(this, this.closeCalendarPane) + }); + // Unless the user specifies so, the datepicker should not be a tab stop. // This is necessary because ngAria might add a tabindex to anything with an ng-model // (based on whether or not the user has turned that particular feature on/off). @@ -349,29 +363,27 @@ } $mdTheming($element); - $mdTheming(angular.element(this.calendarPane)); this.installPropertyInterceptors(); this.attachChangeListeners(); this.attachInteractionListeners(); - var self = this; - - $scope.$on('$destroy', function() { - self.detachCalendarPane(); - }); - if ($attrs.mdIsOpen) { $scope.$watch('ctrl.isOpen', function(shouldBeOpen) { if (shouldBeOpen) { - self.openCalendarPane({ - target: self.inputElement - }); + self.openCalendarPane({ target: self.inputElement }); } else { self.closeCalendarPane(); } }); } + + var self = this; + + $scope.$on('$destroy', function() { + self.detachCalendarPane(); + self.panelRef.destroy(); + }); } /** @@ -450,18 +462,25 @@ DatePickerCtrl.prototype.attachInteractionListeners = function() { var self = this; var $scope = this.$scope; - var keyCodes = this.$mdConstant.KEY_CODE; // Add event listener through angular so that we can triggerHandler in unit tests. self.ngInputElement.on('keydown', function(event) { - if (event.altKey && event.keyCode == keyCodes.DOWN_ARROW) { + if (event.altKey && event.keyCode === self.$mdConstant.KEY_CODE.DOWN_ARROW) { self.openCalendarPane(event); $scope.$digest(); } }); if (self.openOnFocus) { - self.ngInputElement.on('focus', angular.bind(self, self.openCalendarPane)); + self.ngInputElement.on('focus', function(event) { + if (!self.preventInputFocus && !self.inputFocusedOnWindowBlur) { + self.preventInputFocus = true; + self.openCalendarPane(event); + } else if (self.inputFocusedOnWindowBlur) { + self.inputFocusedOnWindowBlur = false; + } + }); + angular.element(self.$window).on('blur', self.windowBlurHandler); $scope.$on('$destroy', function() { @@ -611,103 +630,53 @@ /** Position and attach the floating calendar to the document. */ DatePickerCtrl.prototype.attachCalendarPane = function() { - var calendarPane = this.calendarPane; - var body = document.body; - - calendarPane.style.transform = ''; - this.$element.addClass(OPEN_CLASS); - this.mdInputContainer && this.mdInputContainer.element.addClass(OPEN_CLASS); - angular.element(body).addClass('md-datepicker-is-showing'); - - var elementRect = this.inputContainer.getBoundingClientRect(); - var bodyRect = body.getBoundingClientRect(); - - if (!this.topMargin || this.topMargin < 0) { - this.topMargin = (this.inputMask.parent().prop('clientHeight') - this.ngInputElement.prop('clientHeight')) / 2; - } - - // Check to see if the calendar pane would go off the screen. If so, adjust position - // accordingly to keep it within the viewport. - var paneTop = elementRect.top - bodyRect.top - this.topMargin; - var paneLeft = elementRect.left - bodyRect.left - this.leftMargin; - - // If ng-material has disabled body scrolling (for example, if a dialog is open), - // then it's possible that the already-scrolled body has a negative top/left. In this case, - // we want to treat the "real" top as (0 - bodyRect.top). In a normal scrolling situation, - // though, the top of the viewport should just be the body's scroll position. - var viewportTop = (bodyRect.top < 0 && document.body.scrollTop == 0) ? - -bodyRect.top : - document.body.scrollTop; - - var viewportLeft = (bodyRect.left < 0 && document.body.scrollLeft == 0) ? - -bodyRect.left : - document.body.scrollLeft; - - var viewportBottom = viewportTop + this.$window.innerHeight; - var viewportRight = viewportLeft + this.$window.innerWidth; - - // Creates an overlay with a hole the same size as element. We remove a pixel or two - // on each end to make it overlap slightly. The overlay's background is added in - // the theme in the form of a box-shadow with a huge spread. - this.inputMask.css({ - position: 'absolute', - left: this.leftMargin + 'px', - top: this.topMargin + 'px', - width: (elementRect.width - 1) + 'px', - height: (elementRect.height - 2) + 'px' - }); - - // If the right edge of the pane would be off the screen and shifting it left by the - // difference would not go past the left edge of the screen. If the calendar pane is too - // big to fit on the screen at all, move it to the left of the screen and scale the entire - // element down to fit. - if (paneLeft + CALENDAR_PANE_WIDTH > viewportRight) { - if (viewportRight - CALENDAR_PANE_WIDTH > 0) { - paneLeft = viewportRight - CALENDAR_PANE_WIDTH; - } else { - paneLeft = viewportLeft; - var scale = this.$window.innerWidth / CALENDAR_PANE_WIDTH; - calendarPane.style.transform = 'scale(' + scale + ')'; - } - - calendarPane.classList.add('md-datepicker-pos-adjusted'); - } - - // 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. - if (paneTop + CALENDAR_PANE_HEIGHT > viewportBottom && - viewportBottom - CALENDAR_PANE_HEIGHT > viewportTop) { - paneTop = viewportBottom - CALENDAR_PANE_HEIGHT; - calendarPane.classList.add('md-datepicker-pos-adjusted'); - } + var self = this; - calendarPane.style.left = paneLeft + 'px'; - calendarPane.style.top = paneTop + 'px'; - document.body.appendChild(calendarPane); + return this.panelRef.open().then(function(panelRef) { + var calendarPane = panelRef.panelEl[0].querySelector('.md-datepicker-calendar-pane'); + var inputMask = calendarPane.querySelector('.md-datepicker-input-mask-opaque'); + var elementRect = self.inputContainer.getBoundingClientRect(); + + self.$mdTheming(angular.element(calendarPane)); + + // Creates an overlay with a hole the same size as element. We remove a pixel or two + // on each end to make it overlap slightly. The overlay's background is added in + // the theme in the form of a box-shadow with a huge spread. + angular.element(inputMask).css({ + position: 'absolute', + left: self.leftMargin + 'px', + top: self.topMargin + 'px', + width: elementRect.width + 'px', + height: (elementRect.height - 2) + 'px' + }); - // Add CSS class after one frame to trigger open animation. - this.$$rAF(function() { - calendarPane.classList.add('md-pane-open'); + // Add the CSS classes after one frame to trigger open animation. Note that it needs + // an extra timeout, because in some casses rAF might fire too early (e.g. when the + // input focus opens the calendar), breaking the animation. + self.$mdUtil.nextTick(function() { + self.$$rAF(function() { + calendarPane.classList.add('md-pane-open'); + self.$element.addClass(OPEN_CLASS); + self.mdInputContainer && self.mdInputContainer.element.addClass(OPEN_CLASS); + }); + }, false); }); }; /** Detach the floating calendar pane from the document. */ DatePickerCtrl.prototype.detachCalendarPane = function() { + var panelRef = this.panelRef; + this.$element.removeClass(OPEN_CLASS); this.mdInputContainer && this.mdInputContainer.element.removeClass(OPEN_CLASS); - angular.element(document.body).removeClass('md-datepicker-is-showing'); - this.calendarPane.classList.remove('md-pane-open'); - this.calendarPane.classList.remove('md-datepicker-pos-adjusted'); - if (this.isCalendarOpen) { - this.$mdUtil.enableScrolling(); + if (panelRef.panelEl) { + panelRef.panelEl[0] + .querySelector('.md-datepicker-calendar-pane') + .classList.remove('md-pane-open'); } - if (this.calendarPane.parentNode) { - // Use native DOM removal because we do not want any of the - // angular state of this element to be disposed. - this.calendarPane.parentNode.removeChild(this.calendarPane); - } + panelRef.close(); }; /** @@ -715,70 +684,40 @@ * @param {Event} event */ DatePickerCtrl.prototype.openCalendarPane = function(event) { - if (!this.isCalendarOpen && !this.isDisabled && !this.inputFocusedOnWindowBlur) { + if (!this.isCalendarOpen && !this.isDisabled) { + var self = this; + this.isCalendarOpen = this.isOpen = true; this.calendarPaneOpenedFrom = event.target; - // Because the calendar pane is attached directly to the body, it is possible that the - // rest of the component (input, etc) is in a different scrolling container, such as - // an md-content. This means that, if the container is scrolled, the pane would remain - // stationary. To remedy this, we disable scrolling while the calendar pane is open, which - // also matches the native behavior for things like `