Skip to content

Commit 38c5ea0

Browse files
committed
feat(material/datepicker): Support drag and drop to adjust date ranges
1 parent 94b694f commit 38c5ea0

11 files changed

+563
-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: 129 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,31 +424,99 @@ 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.
413-
if (event.target && this._getCellFromElement(event.target as HTMLElement)) {
452+
if (
453+
event.target &&
454+
this._getCellFromElement(event.target as HTMLElement) &&
455+
!(
456+
(event as MouseEvent).relatedTarget &&
457+
this._getCellFromElement((event as MouseEvent).relatedTarget as HTMLElement)
458+
)
459+
) {
414460
this._ngZone.run(() => this.previewChange.emit({value: null, event}));
415461
}
416462
}
417463
};
418464

419-
/** Finds the MatCalendarCell that corresponds to a DOM node. */
420-
private _getCellFromElement(element: HTMLElement): MatCalendarCell | null {
421-
let cell: HTMLElement | undefined;
465+
/**
466+
* Triggered on mousedown or touchstart on a date cell.
467+
* Respsonsible for starting a drag sequence.
468+
*/
469+
private _mousedownHandler = (event: Event) => {
470+
this._didDragSinceMouseDown = false;
471+
// Begin a drag if a cell within the current range was targeted.
472+
const cell = event.target && this._getCellFromElement(event.target as HTMLElement);
473+
if (!cell || !this._isInRange(cell.rawValue)) {
474+
return;
475+
}
476+
477+
this._ngZone.run(() => {
478+
this.dragStarted.emit({
479+
value: cell.rawValue,
480+
event,
481+
});
482+
});
483+
};
484+
485+
/** Triggered on mouseup anywhere. Respsonsible for ending a drag sequence. */
486+
private _mouseupHandler = (event: Event) => {
487+
const cellElement = getCellElement(event.target as HTMLElement);
488+
if (!cellElement) {
489+
// Mouseup happened outside of datepicker. Cancel drag.
490+
this._ngZone.run(() => {
491+
this.dragEnded.emit({value: null, event});
492+
});
493+
return;
494+
}
495+
496+
if (cellElement.closest('.mat-calendar-body') !== this._elementRef.nativeElement) {
497+
// Mouseup happened inside a different month instance.
498+
// Allow it to handle the event.
499+
return;
500+
}
422501

423-
if (isTableCell(element)) {
424-
cell = element;
425-
} else if (isTableCell(element.parentNode!)) {
426-
cell = element.parentNode as HTMLElement;
502+
this._ngZone.run(() => {
503+
const cell = this._getCellFromElement(cellElement);
504+
this.dragEnded.emit({value: cell?.rawValue ?? null, event});
505+
});
506+
};
507+
508+
/** Triggered on touchend anywhere. Respsonsible for ending a drag sequence. */
509+
private _touchendHandler = (event: TouchEvent) => {
510+
const target = getActualTouchTarget(event);
511+
512+
if (target) {
513+
this._mouseupHandler({target} as unknown as Event);
427514
}
515+
};
516+
517+
/** Finds the MatCalendarCell that corresponds to a DOM node. */
518+
private _getCellFromElement(element: HTMLElement): MatCalendarCell | null {
519+
const cell = getCellElement(element);
428520

429521
if (cell) {
430522
const row = cell.getAttribute('data-mat-row');
@@ -446,8 +538,25 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
446538
}
447539

448540
/** Checks whether a node is a table cell element. */
449-
function isTableCell(node: Node): node is HTMLTableCellElement {
450-
return node.nodeName === 'TD';
541+
function isTableCell(node: Node | undefined | null): node is HTMLTableCellElement {
542+
return node?.nodeName === 'TD';
543+
}
544+
545+
/**
546+
* Gets the date table cell element that is or contains the specified element.
547+
* Or returns null if element is not part of a date cell.
548+
*/
549+
function getCellElement(element: HTMLElement): HTMLElement | null {
550+
let cell: HTMLElement | undefined;
551+
if (isTableCell(element)) {
552+
cell = element;
553+
} else if (isTableCell(element.parentNode)) {
554+
cell = element.parentNode as HTMLElement;
555+
} else if (isTableCell(element.parentNode?.parentNode)) {
556+
cell = element.parentNode!.parentNode as HTMLElement;
557+
}
558+
559+
return cell?.getAttribute('data-mat-row') != null ? cell : null;
451560
}
452561

453562
/** Checks whether a value is the start of a range. */
@@ -476,3 +585,12 @@ function isInRange(
476585
value <= end
477586
);
478587
}
588+
589+
/**
590+
* Extracts the element that actually corresponds to a touch event's location
591+
* (rather than the element that initiated the sequence of touch events).
592+
*/
593+
function getActualTouchTarget(event: TouchEvent): Element | null {
594+
const touchLocation = event.changedTouches[0];
595+
return document.elementFromPoint(touchLocation.clientX, touchLocation.clientY);
596+
}

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: 25 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,25 @@ export class MatCalendar<D> implements AfterContentInit, AfterViewChecked, OnDes
498504
this.currentView = view;
499505
}
500506

507+
/** Called when the user starts dragging to change a date range. */
508+
_dragStarted(event: MatCalendarUserEvent<D>) {
509+
this._activeDrag = event;
510+
}
511+
512+
/**
513+
* Called when a drag completes. It may end in cancelation or in the selection
514+
* of a new range.
515+
*/
516+
_dragEnded(event: MatCalendarUserEvent<DateRange<D> | null>) {
517+
if (!this._activeDrag) return;
518+
519+
if (event.value) {
520+
this._userDragDrop.emit(event as MatCalendarUserEvent<DateRange<D>>);
521+
}
522+
523+
this._activeDrag = null;
524+
}
525+
501526
/** Returns the component instance that corresponds to the current calendar view. */
502527
private _getCurrentViewComponent(): MatMonthView<D> | MatYearView<D> | MatMultiYearView<D> {
503528
// The return type is explicitly written as a union to ensure that the Closure compiler does

0 commit comments

Comments
 (0)