Skip to content

Commit 8554e15

Browse files
authored
feat(material/datepicker): Support drag and drop to adjust date ranges (#25548)
1 parent a1357a4 commit 8554e15

11 files changed

+575
-26
lines changed

src/material/datepicker/calendar-body.spec.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {waitForAsync, ComponentFixture, TestBed} from '@angular/core/testing';
22
import {Component} from '@angular/core';
33
import {MatCalendarBody, MatCalendarCell, MatCalendarUserEvent} from './calendar-body';
44
import {By} from '@angular/platform-browser';
5-
import {dispatchMouseEvent, dispatchFakeEvent} from '../../cdk/testing/private';
5+
import {dispatchMouseEvent, dispatchFakeEvent, dispatchTouchEvent} from '../../cdk/testing/private';
66

77
describe('MatCalendarBody', () => {
88
beforeEach(waitForAsync(() => {
@@ -630,6 +630,53 @@ describe('MatCalendarBody', () => {
630630
}),
631631
).toBe(false);
632632
});
633+
634+
describe('drag and drop ranges', () => {
635+
beforeEach(() => {
636+
// Pre-select a range to drag.
637+
fixture.componentInstance.startValue = 4;
638+
fixture.componentInstance.endValue = 6;
639+
fixture.detectChanges();
640+
});
641+
642+
it('triggers and previews a drag (mouse)', () => {
643+
dispatchMouseEvent(cells[3], 'mousedown');
644+
fixture.detectChanges();
645+
expect(fixture.componentInstance.drag).not.toBe(null);
646+
647+
// Expand to earlier.
648+
dispatchMouseEvent(cells[2], 'mouseenter');
649+
fixture.detectChanges();
650+
expect(cells[2].classList).toContain(previewStartClass);
651+
expect(cells[3].classList).toContain(inPreviewClass);
652+
expect(cells[4].classList).toContain(inPreviewClass);
653+
expect(cells[5].classList).toContain(previewEndClass);
654+
655+
// End drag.
656+
dispatchMouseEvent(cells[2], 'mouseup');
657+
expect(fixture.componentInstance.drag).toBe(null);
658+
});
659+
660+
it('triggers and previews a drag (touch)', () => {
661+
dispatchTouchEvent(cells[3], 'touchstart');
662+
fixture.detectChanges();
663+
expect(fixture.componentInstance.drag).not.toBe(null);
664+
665+
// Expand to earlier.
666+
const rect = cells[2].getBoundingClientRect();
667+
668+
dispatchTouchEvent(cells[2], 'touchmove', rect.left, rect.top, rect.left, rect.top);
669+
fixture.detectChanges();
670+
expect(cells[2].classList).toContain(previewStartClass);
671+
expect(cells[3].classList).toContain(inPreviewClass);
672+
expect(cells[4].classList).toContain(inPreviewClass);
673+
expect(cells[5].classList).toContain(previewEndClass);
674+
675+
// End drag.
676+
dispatchTouchEvent(cells[2], 'touchend', rect.left, rect.top, rect.left, rect.top);
677+
expect(fixture.componentInstance.drag).toBe(null);
678+
});
679+
});
633680
});
634681
});
635682

@@ -672,7 +719,10 @@ class StandardCalendarBody {
672719
[previewStart]="previewStart"
673720
[previewEnd]="previewEnd"
674721
(selectedValueChange)="onSelect($event)"
675-
(previewChange)="previewChanged($event)">
722+
(previewChange)="previewChanged($event)"
723+
(dragStarted)="dragStarted($event)"
724+
(dragEnded)="dragEnded($event)"
725+
>
676726
</table>`,
677727
})
678728
class RangeCalendarBody {
@@ -683,6 +733,7 @@ class RangeCalendarBody {
683733
comparisonEnd: number | null;
684734
previewStart: number | null;
685735
previewEnd: number | null;
736+
drag: MatCalendarUserEvent<unknown> | null = null;
686737

687738
onSelect(event: MatCalendarUserEvent<number>) {
688739
const value = event.value;
@@ -699,6 +750,20 @@ class RangeCalendarBody {
699750
previewChanged(event: MatCalendarUserEvent<MatCalendarCell<Date> | null>) {
700751
this.previewStart = this.startValue;
701752
this.previewEnd = event.value?.compareValue || null;
753+
754+
if (this.drag) {
755+
// For sake of testing, hardcode a preview for drags.
756+
this.previewStart = this.startValue! - 1;
757+
this.previewEnd = this.endValue;
758+
}
759+
}
760+
761+
dragStarted(event: MatCalendarUserEvent<unknown>) {
762+
this.drag = event;
763+
}
764+
765+
dragEnded(event: MatCalendarUserEvent<unknown>) {
766+
this.drag = null;
702767
}
703768
}
704769

@@ -728,6 +793,8 @@ function createCalendarCells(weeks: number): MatCalendarCell[][] {
728793
`${cell}-label`,
729794
true,
730795
cell % 2 === 0 ? 'even' : undefined,
796+
cell,
797+
cell,
731798
);
732799
}),
733800
);

src/material/datepicker/calendar-body.ts

Lines changed: 141 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

src/material/datepicker/calendar.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
[comparisonEnd]="comparisonEnd"
1414
[startDateAccessibleName]="startDateAccessibleName"
1515
[endDateAccessibleName]="endDateAccessibleName"
16-
(_userSelection)="_dateSelected($event)">
16+
(_userSelection)="_dateSelected($event)"
17+
(dragStarted)="_dragStarted($event)"
18+
(dragEnded)="_dragEnded($event)"
19+
[activeDrag]="_activeDrag"
20+
>
1721
</mat-month-view>
1822

1923
<mat-year-view

0 commit comments

Comments
 (0)