@@ -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,13 +424,28 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
400424 }
401425 } ;
402426
427+ private _touchmoveHandler = ( event : TouchEvent ) => {
428+ const target = getActualTouchTarget ( event ) ;
429+ const cell = target ? this . _getCellFromElement ( target as HTMLElement ) : null ;
430+
431+ if ( target !== event . target ) {
432+ this . _didDragSinceMouseDown = true ;
433+ }
434+
435+ this . _ngZone . run ( ( ) => this . previewChange . emit ( { value : cell ?. enabled ? cell : null , event} ) ) ;
436+ } ;
437+
403438 /**
404439 * Event handler for when the user's pointer leaves an element
405440 * inside the calendar body (e.g. by hovering out or blurring).
406441 */
407442 private _leaveHandler = ( event : Event ) => {
408443 // We only need to hit the zone when we're selecting a range.
409444 if ( this . previewEnd !== null && this . isRange ) {
445+ if ( event . type !== 'blur' ) {
446+ this . _didDragSinceMouseDown = true ;
447+ }
448+
410449 // Only reset the preview end value when leaving cells. This looks better, because
411450 // we have a gap between the cells and the rows and we don't want to remove the
412451 // range just for it to show up again when the user moves a few pixels to the side.
@@ -416,15 +455,61 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
416455 }
417456 } ;
418457
419- /** Finds the MatCalendarCell that corresponds to a DOM node. */
420- private _getCellFromElement ( element : HTMLElement ) : MatCalendarCell | null {
421- let cell : HTMLElement | undefined ;
458+ /**
459+ * Triggered on mousedown or touchstart on a date cell.
460+ * Respsonsible for starting a drag sequence.
461+ */
462+ private _mousedownHandler = ( event : Event ) => {
463+ this . _didDragSinceMouseDown = false ;
464+ // Begin a drag if a cell within the current range was targeted.
465+ const cell = event . target && this . _getCellFromElement ( event . target as HTMLElement ) ;
466+ if ( ! cell || ! this . _isInRange ( cell . rawValue ) ) {
467+ return ;
468+ }
469+
470+ this . _ngZone . run ( ( ) => {
471+ this . dragStarted . emit ( {
472+ value : cell . rawValue ,
473+ event,
474+ } ) ;
475+ } ) ;
476+ } ;
477+
478+ /** Triggered on mouseup anywhere. Respsonsible for ending a drag sequence. */
479+ private _mouseupHandler = ( event : Event ) => {
480+ const cellElement = getCellElement ( event . target as HTMLElement ) ;
481+ if ( ! cellElement ) {
482+ // Mouseup happened outside of datepicker. Cancel drag.
483+ this . _ngZone . run ( ( ) => {
484+ this . dragEnded . emit ( { value : null , event} ) ;
485+ } ) ;
486+ return ;
487+ }
488+
489+ if ( cellElement . closest ( '.mat-calendar-body' ) !== this . _elementRef . nativeElement ) {
490+ // Mouseup happened inside a different month instance.
491+ // Allow it to handle the event.
492+ return ;
493+ }
422494
423- if ( isTableCell ( element ) ) {
424- cell = element ;
425- } else if ( isTableCell ( element . parentNode ! ) ) {
426- cell = element . parentNode as HTMLElement ;
495+ this . _ngZone . run ( ( ) => {
496+ const cell = this . _getCellFromElement ( cellElement ) ;
497+ this . dragEnded . emit ( { value : cell ?. rawValue ?? null , event} ) ;
498+ } ) ;
499+ } ;
500+
501+ /** Triggered on touchend anywhere. Respsonsible for ending a drag sequence. */
502+ private _touchendHandler = ( event : TouchEvent ) => {
503+ const target = getActualTouchTarget ( event ) ;
504+
505+ if ( target ) {
506+ this . _mouseupHandler ( { target} as unknown as Event ) ;
427507 }
508+ } ;
509+
510+ /** Finds the MatCalendarCell that corresponds to a DOM node. */
511+ private _getCellFromElement ( element : HTMLElement ) : MatCalendarCell | null {
512+ const cell = getCellElement ( element ) ;
428513
429514 if ( cell ) {
430515 const row = cell . getAttribute ( 'data-mat-row' ) ;
@@ -446,8 +531,25 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
446531}
447532
448533/** Checks whether a node is a table cell element. */
449- function isTableCell ( node : Node ) : node is HTMLTableCellElement {
450- return node . nodeName === 'TD' ;
534+ function isTableCell ( node : Node | undefined | null ) : node is HTMLTableCellElement {
535+ return node ?. nodeName === 'TD' ;
536+ }
537+
538+ /**
539+ * Gets the date table cell element that is or contains the specified element.
540+ * Or returns null if element is not part of a date cell.
541+ */
542+ function getCellElement ( element : HTMLElement ) : HTMLElement | null {
543+ let cell : HTMLElement | undefined ;
544+ if ( isTableCell ( element ) ) {
545+ cell = element ;
546+ } else if ( isTableCell ( element . parentNode ) ) {
547+ cell = element . parentNode as HTMLElement ;
548+ } else if ( isTableCell ( element . parentNode ?. parentNode ) ) {
549+ cell = element . parentNode ! . parentNode as HTMLElement ;
550+ }
551+
552+ return cell ?. getAttribute ( 'data-mat-row' ) != null ? cell : null ;
451553}
452554
453555/** Checks whether a value is the start of a range. */
@@ -476,3 +578,12 @@ function isInRange(
476578 value <= end
477579 ) ;
478580}
581+
582+ /**
583+ * Extracts the element that actually corresponds to a touch event's location
584+ * (rather than the element that initiated the sequence of touch events).
585+ */
586+ function getActualTouchTarget ( event : TouchEvent ) : Element | null {
587+ const touchLocation = event . changedTouches [ 0 ] ;
588+ return document . elementFromPoint ( touchLocation . clientX , touchLocation . clientY ) ;
589+ }
0 commit comments