@@ -70,7 +70,7 @@ let calendarBodyId = 1;
7070 encapsulation : ViewEncapsulation . None ,
7171 changeDetection : ChangeDetectionStrategy . OnPush ,
7272} )
73- export class MatCalendarBody implements OnChanges , OnDestroy , AfterViewChecked {
73+ export class MatCalendarBody < D = any > implements OnChanges , OnDestroy , AfterViewChecked {
7474 /**
7575 * Used to skip the next focus event when rendering the preview range.
7676 * We need a flag like this, because some browsers fire focus events asynchronously.
@@ -150,6 +150,12 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
150150
151151 @Output ( ) readonly activeDateChange = new EventEmitter < MatCalendarUserEvent < number > > ( ) ;
152152
153+ /** Emits the date at the possible start of a drag event. */
154+ @Output ( ) readonly dragStarted = new EventEmitter < MatCalendarUserEvent < D > > ( ) ;
155+
156+ /** Emits the date at the conclusion of a drag, or null if mouse was not released on a date. */
157+ @Output ( ) readonly dragEnded = new EventEmitter < MatCalendarUserEvent < D | null > > ( ) ;
158+
153159 /** The number of blank cells to put at the beginning for the first row. */
154160 _firstRowOffset : number ;
155161
@@ -159,18 +165,31 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
159165 /** Width of an individual cell. */
160166 _cellWidth : string ;
161167
168+ private _didDragSinceMouseDown = false ;
169+
162170 constructor ( private _elementRef : ElementRef < HTMLElement > , private _ngZone : NgZone ) {
163171 _ngZone . runOutsideAngular ( ( ) => {
164172 const element = _elementRef . nativeElement ;
165173 element . addEventListener ( 'mouseenter' , this . _enterHandler , true ) ;
174+ element . addEventListener ( 'touchmove' , this . _touchmoveHandler , true ) ;
166175 element . addEventListener ( 'focus' , this . _enterHandler , true ) ;
167176 element . addEventListener ( 'mouseleave' , this . _leaveHandler , true ) ;
168177 element . addEventListener ( 'blur' , this . _leaveHandler , true ) ;
178+ element . addEventListener ( 'mousedown' , this . _mousedownHandler ) ;
179+ element . addEventListener ( 'touchstart' , this . _mousedownHandler ) ;
180+ window . addEventListener ( 'mouseup' , this . _mouseupHandler ) ;
181+ window . addEventListener ( 'touchend' , this . _touchendHandler ) ;
169182 } ) ;
170183 }
171184
172185 /** Called when a cell is clicked. */
173186 _cellClicked ( cell : MatCalendarCell , event : MouseEvent ) : void {
187+ // Ignore "clicks" that are actually canceled drags (eg the user dragged
188+ // off and then went back to this cell to undo).
189+ if ( this . _didDragSinceMouseDown ) {
190+ return ;
191+ }
192+
174193 if ( cell . enabled ) {
175194 this . selectedValueChange . emit ( { value : cell . value , event} ) ;
176195 }
@@ -207,9 +226,14 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
207226 ngOnDestroy ( ) {
208227 const element = this . _elementRef . nativeElement ;
209228 element . removeEventListener ( 'mouseenter' , this . _enterHandler , true ) ;
229+ element . removeEventListener ( 'touchmove' , this . _touchmoveHandler , true ) ;
210230 element . removeEventListener ( 'focus' , this . _enterHandler , true ) ;
211231 element . removeEventListener ( 'mouseleave' , this . _leaveHandler , true ) ;
212232 element . removeEventListener ( 'blur' , this . _leaveHandler , true ) ;
233+ element . removeEventListener ( 'mousedown' , this . _mousedownHandler ) ;
234+ element . removeEventListener ( 'touchstart' , this . _mousedownHandler ) ;
235+ window . removeEventListener ( 'mouseup' , this . _mouseupHandler ) ;
236+ window . removeEventListener ( 'touchend' , this . _touchendHandler ) ;
213237 }
214238
215239 /** Returns whether a cell is active. */
@@ -400,32 +424,112 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
400424 }
401425 } ;
402426
427+ private _touchmoveHandler = ( event : TouchEvent ) => {
428+ if ( ! this . isRange ) return ;
429+
430+ const target = getActualTouchTarget ( event ) ;
431+ const cell = target ? this . _getCellFromElement ( target as HTMLElement ) : null ;
432+
433+ if ( target !== event . target ) {
434+ this . _didDragSinceMouseDown = true ;
435+ }
436+
437+ // If the initial target of the touch is a date cell, prevent default so
438+ // that the move is not handled as a scroll.
439+ if ( getCellElement ( event . target as HTMLElement ) ) {
440+ event . preventDefault ( ) ;
441+ }
442+
443+ this . _ngZone . run ( ( ) => this . previewChange . emit ( { value : cell ?. enabled ? cell : null , event} ) ) ;
444+ } ;
445+
403446 /**
404447 * Event handler for when the user's pointer leaves an element
405448 * inside the calendar body (e.g. by hovering out or blurring).
406449 */
407450 private _leaveHandler = ( event : Event ) => {
408451 // We only need to hit the zone when we're selecting a range.
409452 if ( this . previewEnd !== null && this . isRange ) {
453+ if ( event . type !== 'blur' ) {
454+ this . _didDragSinceMouseDown = true ;
455+ }
456+
410457 // Only reset the preview end value when leaving cells. This looks better, because
411458 // we have a gap between the cells and the rows and we don't want to remove the
412459 // range just for it to show up again when the user moves a few pixels to the side.
413- if ( event . target && this . _getCellFromElement ( event . target as HTMLElement ) ) {
460+ if (
461+ event . target &&
462+ this . _getCellFromElement ( event . target as HTMLElement ) &&
463+ ! (
464+ ( event as MouseEvent ) . relatedTarget &&
465+ this . _getCellFromElement ( ( event as MouseEvent ) . relatedTarget as HTMLElement )
466+ )
467+ ) {
414468 this . _ngZone . run ( ( ) => this . previewChange . emit ( { value : null , event} ) ) ;
415469 }
416470 }
417471 } ;
418472
419- /** Finds the MatCalendarCell that corresponds to a DOM node. */
420- private _getCellFromElement ( element : HTMLElement ) : MatCalendarCell | null {
421- let cell : HTMLElement | undefined ;
473+ /**
474+ * Triggered on mousedown or touchstart on a date cell.
475+ * Respsonsible for starting a drag sequence.
476+ */
477+ private _mousedownHandler = ( event : Event ) => {
478+ if ( ! this . isRange ) return ;
422479
423- if ( isTableCell ( element ) ) {
424- cell = element ;
425- } else if ( isTableCell ( element . parentNode ! ) ) {
426- cell = element . parentNode as HTMLElement ;
480+ this . _didDragSinceMouseDown = false ;
481+ // Begin a drag if a cell within the current range was targeted.
482+ const cell = event . target && this . _getCellFromElement ( event . target as HTMLElement ) ;
483+ if ( ! cell || ! this . _isInRange ( cell . rawValue ) ) {
484+ return ;
427485 }
428486
487+ this . _ngZone . run ( ( ) => {
488+ this . dragStarted . emit ( {
489+ value : cell . rawValue ,
490+ event,
491+ } ) ;
492+ } ) ;
493+ } ;
494+
495+ /** Triggered on mouseup anywhere. Respsonsible for ending a drag sequence. */
496+ private _mouseupHandler = ( event : Event ) => {
497+ if ( ! this . isRange ) return ;
498+
499+ const cellElement = getCellElement ( event . target as HTMLElement ) ;
500+ if ( ! cellElement ) {
501+ // Mouseup happened outside of datepicker. Cancel drag.
502+ this . _ngZone . run ( ( ) => {
503+ this . dragEnded . emit ( { value : null , event} ) ;
504+ } ) ;
505+ return ;
506+ }
507+
508+ if ( cellElement . closest ( '.mat-calendar-body' ) !== this . _elementRef . nativeElement ) {
509+ // Mouseup happened inside a different month instance.
510+ // Allow it to handle the event.
511+ return ;
512+ }
513+
514+ this . _ngZone . run ( ( ) => {
515+ const cell = this . _getCellFromElement ( cellElement ) ;
516+ this . dragEnded . emit ( { value : cell ?. rawValue ?? null , event} ) ;
517+ } ) ;
518+ } ;
519+
520+ /** Triggered on touchend anywhere. Respsonsible for ending a drag sequence. */
521+ private _touchendHandler = ( event : TouchEvent ) => {
522+ const target = getActualTouchTarget ( event ) ;
523+
524+ if ( target ) {
525+ this . _mouseupHandler ( { target} as unknown as Event ) ;
526+ }
527+ } ;
528+
529+ /** Finds the MatCalendarCell that corresponds to a DOM node. */
530+ private _getCellFromElement ( element : HTMLElement ) : MatCalendarCell | null {
531+ const cell = getCellElement ( element ) ;
532+
429533 if ( cell ) {
430534 const row = cell . getAttribute ( 'data-mat-row' ) ;
431535 const col = cell . getAttribute ( 'data-mat-col' ) ;
@@ -446,8 +550,25 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
446550}
447551
448552/** Checks whether a node is a table cell element. */
449- function isTableCell ( node : Node ) : node is HTMLTableCellElement {
450- return node . nodeName === 'TD' ;
553+ function isTableCell ( node : Node | undefined | null ) : node is HTMLTableCellElement {
554+ return node ?. nodeName === 'TD' ;
555+ }
556+
557+ /**
558+ * Gets the date table cell element that is or contains the specified element.
559+ * Or returns null if element is not part of a date cell.
560+ */
561+ function getCellElement ( element : HTMLElement ) : HTMLElement | null {
562+ let cell : HTMLElement | undefined ;
563+ if ( isTableCell ( element ) ) {
564+ cell = element ;
565+ } else if ( isTableCell ( element . parentNode ) ) {
566+ cell = element . parentNode as HTMLElement ;
567+ } else if ( isTableCell ( element . parentNode ?. parentNode ) ) {
568+ cell = element . parentNode ! . parentNode as HTMLElement ;
569+ }
570+
571+ return cell ?. getAttribute ( 'data-mat-row' ) != null ? cell : null ;
451572}
452573
453574/** Checks whether a value is the start of a range. */
@@ -476,3 +597,12 @@ function isInRange(
476597 value <= end
477598 ) ;
478599}
600+
601+ /**
602+ * Extracts the element that actually corresponds to a touch event's location
603+ * (rather than the element that initiated the sequence of touch events).
604+ */
605+ function getActualTouchTarget ( event : TouchEvent ) : Element | null {
606+ const touchLocation = event . changedTouches [ 0 ] ;
607+ return document . elementFromPoint ( touchLocation . clientX , touchLocation . clientY ) ;
608+ }
0 commit comments