Skip to content

Commit 25fff00

Browse files
committed
feat(material/datepicker): Support drag and drop to adjust date ranges
1 parent 147a354 commit 25fff00

11 files changed

+534
-23
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: 107 additions & 10 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,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,16 +455,56 @@ 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+
private _mousedownHandler = (event: Event) => {
459+
this._didDragSinceMouseDown = false;
460+
// Begin a drag if a cell within the current range was targeted.
461+
const cell = event.target && this._getCellFromElement(event.target as HTMLElement);
462+
if (!cell || !this._isInRange(cell.rawValue)) {
463+
return;
464+
}
465+
466+
this._ngZone.run(() => {
467+
this.dragStarted.emit({
468+
value: cell.rawValue,
469+
event,
470+
});
471+
});
472+
};
422473

423-
if (isTableCell(element)) {
424-
cell = element;
425-
} else if (isTableCell(element.parentNode!)) {
426-
cell = element.parentNode as HTMLElement;
474+
private _mouseupHandler = (event: Event) => {
475+
const cellElement = getCellElement(event.target as HTMLElement);
476+
if (!cellElement) {
477+
// Mouseup happened outside of datepicker. Cancel drag.
478+
this._ngZone.run(() => {
479+
this.dragEnded.emit({value: null, event});
480+
});
481+
return;
482+
}
483+
484+
if (cellElement.closest('.mat-calendar-body') !== this._elementRef.nativeElement) {
485+
// Mouseup happened inside a different month instance.
486+
// Allow it to handle the event.
487+
return;
427488
}
428489

490+
this._ngZone.run(() => {
491+
const cell = this._getCellFromElement(cellElement);
492+
this.dragEnded.emit({value: cell?.rawValue ?? null, event});
493+
});
494+
};
495+
496+
private _touchendHandler = (event: TouchEvent) => {
497+
const target = getActualTouchTarget(event);
498+
499+
if (target) {
500+
this._mouseupHandler({target} as unknown as Event);
501+
}
502+
};
503+
504+
/** Finds the MatCalendarCell that corresponds to a DOM node. */
505+
private _getCellFromElement(element: HTMLElement): MatCalendarCell | null {
506+
const cell = getCellElement(element);
507+
429508
if (cell) {
430509
const row = cell.getAttribute('data-mat-row');
431510
const col = cell.getAttribute('data-mat-col');
@@ -446,8 +525,21 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
446525
}
447526

448527
/** Checks whether a node is a table cell element. */
449-
function isTableCell(node: Node): node is HTMLTableCellElement {
450-
return node.nodeName === 'TD';
528+
function isTableCell(node: Node | undefined | null): node is HTMLTableCellElement {
529+
return node?.nodeName === 'TD';
530+
}
531+
532+
function getCellElement(element: HTMLElement): HTMLElement | null {
533+
let cell: HTMLElement | undefined;
534+
if (isTableCell(element)) {
535+
cell = element;
536+
} else if (isTableCell(element.parentNode)) {
537+
cell = element.parentNode as HTMLElement;
538+
} else if (isTableCell(element.parentNode?.parentNode)) {
539+
cell = element.parentNode!.parentNode as HTMLElement;
540+
}
541+
542+
return cell?.getAttribute('data-mat-row') != null ? cell : null;
451543
}
452544

453545
/** Checks whether a value is the start of a range. */
@@ -476,3 +568,8 @@ function isInRange(
476568
value <= end
477569
);
478570
}
571+
572+
function getActualTouchTarget(event: TouchEvent): Element | null {
573+
const touchLocation = event.changedTouches[0];
574+
return document.elementFromPoint(touchLocation.clientX, touchLocation.clientY);
575+
}

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

src/material/datepicker/calendar.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,9 @@ export class MatCalendar<D> implements AfterContentInit, AfterViewChecked, OnDes
342342
@Output() readonly _userSelection: EventEmitter<MatCalendarUserEvent<D | null>> =
343343
new EventEmitter<MatCalendarUserEvent<D | null>>();
344344

345+
/** Emits a new date range value when the user completes a drag drop operation. */
346+
@Output() readonly _userDragDrop = new EventEmitter<MatCalendarUserEvent<DateRange<D>>>();
347+
345348
/** Reference to the current month view component. */
346349
@ViewChild(MatMonthView) monthView: MatMonthView<D>;
347350

@@ -380,6 +383,9 @@ export class MatCalendar<D> implements AfterContentInit, AfterViewChecked, OnDes
380383
}
381384
private _currentView: MatCalendarView;
382385

386+
/** Origin of active drag, or null when dragging is not active. */
387+
protected _activeDrag: MatCalendarUserEvent<D> | null = null;
388+
383389
/**
384390
* Emits whenever there is a state change that the header may need to respond to.
385391
*/
@@ -498,6 +504,20 @@ export class MatCalendar<D> implements AfterContentInit, AfterViewChecked, OnDes
498504
this.currentView = view;
499505
}
500506

507+
_dragStarted(event: MatCalendarUserEvent<D>) {
508+
this._activeDrag = event;
509+
}
510+
511+
_dragEnded(event: MatCalendarUserEvent<DateRange<D> | null>) {
512+
if (!this._activeDrag) return;
513+
514+
if (event.value) {
515+
this._userDragDrop.emit(event as MatCalendarUserEvent<DateRange<D>>);
516+
}
517+
518+
this._activeDrag = null;
519+
}
520+
501521
/** Returns the component instance that corresponds to the current calendar view. */
502522
private _getCurrentViewComponent(): MatMonthView<D> | MatYearView<D> | MatMultiYearView<D> {
503523
// The return type is explicitly written as a union to ensure that the Closure compiler does

0 commit comments

Comments
 (0)