From a74dc0fc47abbc7ae62252b33e455f2df9ec5cd2 Mon Sep 17 00:00:00 2001 From: Karl Seamon Date: Mon, 22 Aug 2022 18:04:14 -0400 Subject: [PATCH] feat(material/datepicker): Support drag and drop to adjust date ranges --- src/material/datepicker/calendar-body.spec.ts | 71 ++++++- src/material/datepicker/calendar-body.ts | 152 +++++++++++++- src/material/datepicker/calendar.html | 6 +- src/material/datepicker/calendar.ts | 25 +++ .../date-range-selection-strategy.ts | 47 +++++ src/material/datepicker/datepicker-base.ts | 4 + .../datepicker/datepicker-content.html | 3 +- src/material/datepicker/month-view.html | 2 + src/material/datepicker/month-view.spec.ts | 195 +++++++++++++++++- src/material/datepicker/month-view.ts | 71 ++++++- tools/public_api_guard/material/datepicker.md | 25 ++- 11 files changed, 575 insertions(+), 26 deletions(-) diff --git a/src/material/datepicker/calendar-body.spec.ts b/src/material/datepicker/calendar-body.spec.ts index 8385e1499764..800ffe6c7ae2 100644 --- a/src/material/datepicker/calendar-body.spec.ts +++ b/src/material/datepicker/calendar-body.spec.ts @@ -2,7 +2,7 @@ import {waitForAsync, ComponentFixture, TestBed} from '@angular/core/testing'; import {Component} from '@angular/core'; import {MatCalendarBody, MatCalendarCell, MatCalendarUserEvent} from './calendar-body'; import {By} from '@angular/platform-browser'; -import {dispatchMouseEvent, dispatchFakeEvent} from '../../cdk/testing/private'; +import {dispatchMouseEvent, dispatchFakeEvent, dispatchTouchEvent} from '../../cdk/testing/private'; describe('MatCalendarBody', () => { beforeEach(waitForAsync(() => { @@ -630,6 +630,53 @@ describe('MatCalendarBody', () => { }), ).toBe(false); }); + + describe('drag and drop ranges', () => { + beforeEach(() => { + // Pre-select a range to drag. + fixture.componentInstance.startValue = 4; + fixture.componentInstance.endValue = 6; + fixture.detectChanges(); + }); + + it('triggers and previews a drag (mouse)', () => { + dispatchMouseEvent(cells[3], 'mousedown'); + fixture.detectChanges(); + expect(fixture.componentInstance.drag).not.toBe(null); + + // Expand to earlier. + dispatchMouseEvent(cells[2], 'mouseenter'); + fixture.detectChanges(); + expect(cells[2].classList).toContain(previewStartClass); + expect(cells[3].classList).toContain(inPreviewClass); + expect(cells[4].classList).toContain(inPreviewClass); + expect(cells[5].classList).toContain(previewEndClass); + + // End drag. + dispatchMouseEvent(cells[2], 'mouseup'); + expect(fixture.componentInstance.drag).toBe(null); + }); + + it('triggers and previews a drag (touch)', () => { + dispatchTouchEvent(cells[3], 'touchstart'); + fixture.detectChanges(); + expect(fixture.componentInstance.drag).not.toBe(null); + + // Expand to earlier. + const rect = cells[2].getBoundingClientRect(); + + dispatchTouchEvent(cells[2], 'touchmove', rect.left, rect.top, rect.left, rect.top); + fixture.detectChanges(); + expect(cells[2].classList).toContain(previewStartClass); + expect(cells[3].classList).toContain(inPreviewClass); + expect(cells[4].classList).toContain(inPreviewClass); + expect(cells[5].classList).toContain(previewEndClass); + + // End drag. + dispatchTouchEvent(cells[2], 'touchend', rect.left, rect.top, rect.left, rect.top); + expect(fixture.componentInstance.drag).toBe(null); + }); + }); }); }); @@ -672,7 +719,10 @@ class StandardCalendarBody { [previewStart]="previewStart" [previewEnd]="previewEnd" (selectedValueChange)="onSelect($event)" - (previewChange)="previewChanged($event)"> + (previewChange)="previewChanged($event)" + (dragStarted)="dragStarted($event)" + (dragEnded)="dragEnded($event)" + > `, }) class RangeCalendarBody { @@ -683,6 +733,7 @@ class RangeCalendarBody { comparisonEnd: number | null; previewStart: number | null; previewEnd: number | null; + drag: MatCalendarUserEvent | null = null; onSelect(event: MatCalendarUserEvent) { const value = event.value; @@ -699,6 +750,20 @@ class RangeCalendarBody { previewChanged(event: MatCalendarUserEvent | null>) { this.previewStart = this.startValue; this.previewEnd = event.value?.compareValue || null; + + if (this.drag) { + // For sake of testing, hardcode a preview for drags. + this.previewStart = this.startValue! - 1; + this.previewEnd = this.endValue; + } + } + + dragStarted(event: MatCalendarUserEvent) { + this.drag = event; + } + + dragEnded(event: MatCalendarUserEvent) { + this.drag = null; } } @@ -728,6 +793,8 @@ function createCalendarCells(weeks: number): MatCalendarCell[][] { `${cell}-label`, true, cell % 2 === 0 ? 'even' : undefined, + cell, + cell, ); }), ); diff --git a/src/material/datepicker/calendar-body.ts b/src/material/datepicker/calendar-body.ts index ceeaef2516c9..c9b6db00d1c0 100644 --- a/src/material/datepicker/calendar-body.ts +++ b/src/material/datepicker/calendar-body.ts @@ -70,7 +70,7 @@ let calendarBodyId = 1; encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked { +export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked { /** * Used to skip the next focus event when rendering the preview range. * We need a flag like this, because some browsers fire focus events asynchronously. @@ -150,6 +150,12 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked { @Output() readonly activeDateChange = new EventEmitter>(); + /** Emits the date at the possible start of a drag event. */ + @Output() readonly dragStarted = new EventEmitter>(); + + /** Emits the date at the conclusion of a drag, or null if mouse was not released on a date. */ + @Output() readonly dragEnded = new EventEmitter>(); + /** The number of blank cells to put at the beginning for the first row. */ _firstRowOffset: number; @@ -159,18 +165,31 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked { /** Width of an individual cell. */ _cellWidth: string; + private _didDragSinceMouseDown = false; + constructor(private _elementRef: ElementRef, private _ngZone: NgZone) { _ngZone.runOutsideAngular(() => { const element = _elementRef.nativeElement; element.addEventListener('mouseenter', this._enterHandler, true); + element.addEventListener('touchmove', this._touchmoveHandler, true); element.addEventListener('focus', this._enterHandler, true); element.addEventListener('mouseleave', this._leaveHandler, true); element.addEventListener('blur', this._leaveHandler, true); + element.addEventListener('mousedown', this._mousedownHandler); + element.addEventListener('touchstart', this._mousedownHandler); + window.addEventListener('mouseup', this._mouseupHandler); + window.addEventListener('touchend', this._touchendHandler); }); } /** Called when a cell is clicked. */ _cellClicked(cell: MatCalendarCell, event: MouseEvent): void { + // Ignore "clicks" that are actually canceled drags (eg the user dragged + // off and then went back to this cell to undo). + if (this._didDragSinceMouseDown) { + return; + } + if (cell.enabled) { this.selectedValueChange.emit({value: cell.value, event}); } @@ -207,9 +226,14 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked { ngOnDestroy() { const element = this._elementRef.nativeElement; element.removeEventListener('mouseenter', this._enterHandler, true); + element.removeEventListener('touchmove', this._touchmoveHandler, true); element.removeEventListener('focus', this._enterHandler, true); element.removeEventListener('mouseleave', this._leaveHandler, true); element.removeEventListener('blur', this._leaveHandler, true); + element.removeEventListener('mousedown', this._mousedownHandler); + element.removeEventListener('touchstart', this._mousedownHandler); + window.removeEventListener('mouseup', this._mouseupHandler); + window.removeEventListener('touchend', this._touchendHandler); } /** Returns whether a cell is active. */ @@ -400,6 +424,25 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked { } }; + private _touchmoveHandler = (event: TouchEvent) => { + if (!this.isRange) return; + + const target = getActualTouchTarget(event); + const cell = target ? this._getCellFromElement(target as HTMLElement) : null; + + if (target !== event.target) { + this._didDragSinceMouseDown = true; + } + + // If the initial target of the touch is a date cell, prevent default so + // that the move is not handled as a scroll. + if (getCellElement(event.target as HTMLElement)) { + event.preventDefault(); + } + + this._ngZone.run(() => this.previewChange.emit({value: cell?.enabled ? cell : null, event})); + }; + /** * Event handler for when the user's pointer leaves an element * inside the calendar body (e.g. by hovering out or blurring). @@ -407,25 +450,86 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked { private _leaveHandler = (event: Event) => { // We only need to hit the zone when we're selecting a range. if (this.previewEnd !== null && this.isRange) { + if (event.type !== 'blur') { + this._didDragSinceMouseDown = true; + } + // Only reset the preview end value when leaving cells. This looks better, because // we have a gap between the cells and the rows and we don't want to remove the // range just for it to show up again when the user moves a few pixels to the side. - if (event.target && this._getCellFromElement(event.target as HTMLElement)) { + if ( + event.target && + this._getCellFromElement(event.target as HTMLElement) && + !( + (event as MouseEvent).relatedTarget && + this._getCellFromElement((event as MouseEvent).relatedTarget as HTMLElement) + ) + ) { this._ngZone.run(() => this.previewChange.emit({value: null, event})); } } }; - /** Finds the MatCalendarCell that corresponds to a DOM node. */ - private _getCellFromElement(element: HTMLElement): MatCalendarCell | null { - let cell: HTMLElement | undefined; + /** + * Triggered on mousedown or touchstart on a date cell. + * Respsonsible for starting a drag sequence. + */ + private _mousedownHandler = (event: Event) => { + if (!this.isRange) return; - if (isTableCell(element)) { - cell = element; - } else if (isTableCell(element.parentNode!)) { - cell = element.parentNode as HTMLElement; + this._didDragSinceMouseDown = false; + // Begin a drag if a cell within the current range was targeted. + const cell = event.target && this._getCellFromElement(event.target as HTMLElement); + if (!cell || !this._isInRange(cell.rawValue)) { + return; } + this._ngZone.run(() => { + this.dragStarted.emit({ + value: cell.rawValue, + event, + }); + }); + }; + + /** Triggered on mouseup anywhere. Respsonsible for ending a drag sequence. */ + private _mouseupHandler = (event: Event) => { + if (!this.isRange) return; + + const cellElement = getCellElement(event.target as HTMLElement); + if (!cellElement) { + // Mouseup happened outside of datepicker. Cancel drag. + this._ngZone.run(() => { + this.dragEnded.emit({value: null, event}); + }); + return; + } + + if (cellElement.closest('.mat-calendar-body') !== this._elementRef.nativeElement) { + // Mouseup happened inside a different month instance. + // Allow it to handle the event. + return; + } + + this._ngZone.run(() => { + const cell = this._getCellFromElement(cellElement); + this.dragEnded.emit({value: cell?.rawValue ?? null, event}); + }); + }; + + /** Triggered on touchend anywhere. Respsonsible for ending a drag sequence. */ + private _touchendHandler = (event: TouchEvent) => { + const target = getActualTouchTarget(event); + + if (target) { + this._mouseupHandler({target} as unknown as Event); + } + }; + + /** Finds the MatCalendarCell that corresponds to a DOM node. */ + private _getCellFromElement(element: HTMLElement): MatCalendarCell | null { + const cell = getCellElement(element); + if (cell) { const row = cell.getAttribute('data-mat-row'); const col = cell.getAttribute('data-mat-col'); @@ -446,8 +550,25 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked { } /** Checks whether a node is a table cell element. */ -function isTableCell(node: Node): node is HTMLTableCellElement { - return node.nodeName === 'TD'; +function isTableCell(node: Node | undefined | null): node is HTMLTableCellElement { + return node?.nodeName === 'TD'; +} + +/** + * Gets the date table cell element that is or contains the specified element. + * Or returns null if element is not part of a date cell. + */ +function getCellElement(element: HTMLElement): HTMLElement | null { + let cell: HTMLElement | undefined; + if (isTableCell(element)) { + cell = element; + } else if (isTableCell(element.parentNode)) { + cell = element.parentNode as HTMLElement; + } else if (isTableCell(element.parentNode?.parentNode)) { + cell = element.parentNode!.parentNode as HTMLElement; + } + + return cell?.getAttribute('data-mat-row') != null ? cell : null; } /** Checks whether a value is the start of a range. */ @@ -476,3 +597,12 @@ function isInRange( value <= end ); } + +/** + * Extracts the element that actually corresponds to a touch event's location + * (rather than the element that initiated the sequence of touch events). + */ +function getActualTouchTarget(event: TouchEvent): Element | null { + const touchLocation = event.changedTouches[0]; + return document.elementFromPoint(touchLocation.clientX, touchLocation.clientY); +} diff --git a/src/material/datepicker/calendar.html b/src/material/datepicker/calendar.html index 1072d92384cc..a10d76696519 100644 --- a/src/material/datepicker/calendar.html +++ b/src/material/datepicker/calendar.html @@ -13,7 +13,11 @@ [comparisonEnd]="comparisonEnd" [startDateAccessibleName]="startDateAccessibleName" [endDateAccessibleName]="endDateAccessibleName" - (_userSelection)="_dateSelected($event)"> + (_userSelection)="_dateSelected($event)" + (dragStarted)="_dragStarted($event)" + (dragEnded)="_dragEnded($event)" + [activeDrag]="_activeDrag" + > implements AfterContentInit, AfterViewChecked, OnDes @Output() readonly _userSelection: EventEmitter> = new EventEmitter>(); + /** Emits a new date range value when the user completes a drag drop operation. */ + @Output() readonly _userDragDrop = new EventEmitter>>(); + /** Reference to the current month view component. */ @ViewChild(MatMonthView) monthView: MatMonthView; @@ -380,6 +383,9 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes } private _currentView: MatCalendarView; + /** Origin of active drag, or null when dragging is not active. */ + protected _activeDrag: MatCalendarUserEvent | null = null; + /** * Emits whenever there is a state change that the header may need to respond to. */ @@ -498,6 +504,25 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes this.currentView = view; } + /** Called when the user starts dragging to change a date range. */ + _dragStarted(event: MatCalendarUserEvent) { + this._activeDrag = event; + } + + /** + * Called when a drag completes. It may end in cancelation or in the selection + * of a new range. + */ + _dragEnded(event: MatCalendarUserEvent | null>) { + if (!this._activeDrag) return; + + if (event.value) { + this._userDragDrop.emit(event as MatCalendarUserEvent>); + } + + this._activeDrag = null; + } + /** Returns the component instance that corresponds to the current calendar view. */ private _getCurrentViewComponent(): MatMonthView | MatYearView | MatMultiYearView { // The return type is explicitly written as a union to ensure that the Closure compiler does diff --git a/src/material/datepicker/date-range-selection-strategy.ts b/src/material/datepicker/date-range-selection-strategy.ts index 3a857499159b..3507138ae8cf 100644 --- a/src/material/datepicker/date-range-selection-strategy.ts +++ b/src/material/datepicker/date-range-selection-strategy.ts @@ -37,6 +37,23 @@ export interface MatDateRangeSelectionStrategy { * `mouseenter`/`mouseleave` or `focus`/`blur` depending on how the user is navigating. */ createPreview(activeDate: D | null, currentRange: DateRange, event: Event): DateRange; + + /** + * Called when the user has dragged a date in the currently selected range to another + * date. Returns the date updated range that should result from this interaction. + * + * @param dateOrigin The date the user started dragging from. + * @param originalRange The originally selected date range. + * @param newDate The currently targeted date in the drag operation. + * @param event DOM event that triggered the updated drag state. Will be + * `mouseenter`/`mouseup` or `touchmove`/`touchend` depending on the device type. + */ + createDrag?( + dragOrigin: D, + originalRange: DateRange, + newDate: D, + event: Event, + ): DateRange | null; } /** Provides the default date range selection behavior. */ @@ -70,6 +87,36 @@ export class DefaultMatCalendarRangeStrategy implements MatDateRangeSelection return new DateRange(start, end); } + + createDrag(dragOrigin: D, originalRange: DateRange, newDate: D) { + let start = originalRange.start; + let end = originalRange.end; + + if (!start || !end) { + // Can't drag from an incomplete range. + return null; + } + + const diff = this._dateAdapter.compareDate(newDate, dragOrigin); + const isRange = this._dateAdapter.compareDate(start, end) !== 0; + + if (isRange && this._dateAdapter.sameDate(dragOrigin, originalRange.start)) { + start = newDate; + if (this._dateAdapter.compareDate(newDate, end) > 0) { + end = this._dateAdapter.addCalendarDays(end, diff); + } + } else if (isRange && this._dateAdapter.sameDate(dragOrigin, originalRange.end)) { + end = newDate; + if (this._dateAdapter.compareDate(newDate, start) < 0) { + start = this._dateAdapter.addCalendarDays(start, diff); + } + } else { + start = this._dateAdapter.addCalendarDays(start, diff); + end = this._dateAdapter.addCalendarDays(end, diff); + } + + return new DateRange(start, end); + } } /** @docs-private */ diff --git a/src/material/datepicker/datepicker-base.ts b/src/material/datepicker/datepicker-base.ts index d6c6376bee4c..c94dd6482ae4 100644 --- a/src/material/datepicker/datepicker-base.ts +++ b/src/material/datepicker/datepicker-base.ts @@ -240,6 +240,10 @@ export class MatDatepickerContent> } } + _handleUserDragDrop(event: MatCalendarUserEvent>) { + this._model.updateSelection(event.value as unknown as S, this); + } + _startExitAnimation() { this._animationState = 'void'; this._changeDetectorRef.markForCheck(); diff --git a/src/material/datepicker/datepicker-content.html b/src/material/datepicker/datepicker-content.html index fa280568e767..0a42ad017bfa 100644 --- a/src/material/datepicker/datepicker-content.html +++ b/src/material/datepicker/datepicker-content.html @@ -25,7 +25,8 @@ (yearSelected)="datepicker._selectYear($event)" (monthSelected)="datepicker._selectMonth($event)" (viewChanged)="datepicker._viewChanged($event)" - (_userSelection)="_handleUserSelection($event)"> + (_userSelection)="_handleUserSelection($event)" + (_userDragDrop)="_handleUserDragDrop($event)"> diff --git a/src/material/datepicker/month-view.html b/src/material/datepicker/month-view.html index ef727c191e6a..626a2ac015fa 100644 --- a/src/material/datepicker/month-view.html +++ b/src/material/datepicker/month-view.html @@ -26,6 +26,8 @@ (selectedValueChange)="_dateSelected($event)" (activeDateChange)="_updateActiveDate($event)" (previewChange)="_previewChanged($event)" + (dragStarted)="dragStarted.emit($event)" + (dragEnded)="_dragEnded($event)" (keyup)="_handleCalendarBodyKeyup($event)" (keydown)="_handleCalendarBodyKeydown($event)"> diff --git a/src/material/datepicker/month-view.spec.ts b/src/material/datepicker/month-view.spec.ts index ef02c737868b..8cc97beabee6 100644 --- a/src/material/datepicker/month-view.spec.ts +++ b/src/material/datepicker/month-view.spec.ts @@ -24,7 +24,7 @@ import {waitForAsync, ComponentFixture, TestBed} from '@angular/core/testing'; import {MAT_DATE_FORMATS, MatNativeDateModule} from '@angular/material/core'; import {DEC, FEB, JAN, MAR, NOV} from '../testing'; import {By} from '@angular/platform-browser'; -import {MatCalendarBody} from './calendar-body'; +import {MatCalendarUserEvent, MatCalendarBody} from './calendar-body'; import {MatMonthView} from './month-view'; import {DateRange} from './date-selection-model'; import { @@ -109,6 +109,130 @@ describe('MatMonthView', () => { expect(cellEls[4].classList).toContain('mat-calendar-body-active'); }); + describe('drag and drop with default range strategy', () => { + const initialRange = new DateRange(new Date(2017, JAN, 10), new Date(2017, JAN, 13)); + + beforeEach(() => { + testComponent.selected = initialRange; + fixture.detectChanges(); + }); + + function getDaysMatching(selector: string) { + return Array.from(monthViewNativeElement.querySelectorAll(selector)).map(elem => + Number(elem.textContent!.trim()), + ); + } + + it('drags the range start', () => { + const cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); + + dispatchMouseEvent(cellEls[9], 'mousedown'); + fixture.detectChanges(); + + // Grow range. + dispatchMouseEvent(cellEls[8], 'mouseenter'); + fixture.detectChanges(); + + expect(getDaysMatching('.mat-calendar-body-in-preview')).toEqual([9, 10, 11, 12, 13]); + + // Shrink range. + dispatchMouseEvent(cellEls[10], 'mouseenter'); + fixture.detectChanges(); + + expect(getDaysMatching('.mat-calendar-body-in-preview')).toEqual([11, 12, 13]); + + // Move range past end. + dispatchMouseEvent(cellEls[13], 'mouseenter'); + fixture.detectChanges(); + + expect(getDaysMatching('.mat-calendar-body-in-preview')).toEqual([14, 15, 16, 17]); + + // End drag. + dispatchMouseEvent(cellEls[13], 'mouseup'); + fixture.detectChanges(); + + expect(testComponent.selected).toEqual( + new DateRange(new Date(2017, JAN, 14), new Date(2017, JAN, 17)), + ); + }); + + it('drags the range end', () => { + const cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); + + dispatchMouseEvent(cellEls[12], 'mousedown'); + fixture.detectChanges(); + + // Grow range. + dispatchMouseEvent(cellEls[13], 'mouseenter'); + fixture.detectChanges(); + + expect(getDaysMatching('.mat-calendar-body-in-preview')).toEqual([10, 11, 12, 13, 14]); + + // Shrink range. + dispatchMouseEvent(cellEls[11], 'mouseenter'); + fixture.detectChanges(); + + expect(getDaysMatching('.mat-calendar-body-in-preview')).toEqual([10, 11, 12]); + + // Move range before start. + dispatchMouseEvent(cellEls[8], 'mouseenter'); + fixture.detectChanges(); + + expect(getDaysMatching('.mat-calendar-body-in-preview')).toEqual([6, 7, 8, 9]); + + // End drag. + dispatchMouseEvent(cellEls[8], 'mouseup'); + fixture.detectChanges(); + + expect(testComponent.selected).toEqual( + new DateRange(new Date(2017, JAN, 6), new Date(2017, JAN, 9)), + ); + }); + + it('drags the range middle', () => { + const cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); + + dispatchMouseEvent(cellEls[11], 'mousedown'); + fixture.detectChanges(); + + // Move range down. + dispatchMouseEvent(cellEls[10], 'mouseenter'); + fixture.detectChanges(); + + expect(getDaysMatching('.mat-calendar-body-in-preview')).toEqual([9, 10, 11, 12]); + + // Move range up. + dispatchMouseEvent(cellEls[12], 'mouseenter'); + fixture.detectChanges(); + + expect(getDaysMatching('.mat-calendar-body-in-preview')).toEqual([11, 12, 13, 14]); + + // End drag. + dispatchMouseEvent(cellEls[12], 'mouseup'); + fixture.detectChanges(); + + expect(testComponent.selected).toEqual( + new DateRange(new Date(2017, JAN, 11), new Date(2017, JAN, 14)), + ); + }); + + it('does nothing when dragging outside range', () => { + const cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); + + dispatchMouseEvent(cellEls[8], 'mousedown'); + fixture.detectChanges(); + dispatchMouseEvent(cellEls[7], 'mouseenter'); + fixture.detectChanges(); + + expect(getDaysMatching('.mat-calendar-body-in-preview')).toEqual([]); + + dispatchMouseEvent(cellEls[7], 'mouseup'); + fixture.detectChanges(); + + expect(testComponent.selected).toEqual(initialRange); + }); + }); + describe('a11y', () => { it('should set the correct role on the internal table node', () => { const table = monthViewNativeElement.querySelector('table')!; @@ -394,6 +518,54 @@ describe('MatMonthView', () => { }, ); + it('cancels the active drag but not the selection on escape during an active drag', () => { + const cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); + + const selectedRange = new DateRange(new Date(2017, JAN, 10), new Date(2017, JAN, 17)); + testComponent.selected = selectedRange; + fixture.detectChanges(); + + dispatchMouseEvent(cellEls[11], 'mousedown'); + fixture.detectChanges(); + dispatchMouseEvent(cellEls[4], 'mouseenter'); + fixture.detectChanges(); + + const rangeStarts = monthViewNativeElement.querySelectorAll( + '.mat-calendar-body-preview-start', + ).length; + const rangeMids = monthViewNativeElement.querySelectorAll( + '.mat-calendar-body-in-preview', + ).length; + const rangeEnds = monthViewNativeElement.querySelectorAll( + '.mat-calendar-body-preview-end', + ).length; + + // Note that here we only care that _some_ kind of range is rendered. There are + // plenty of tests in the calendar body which assert that everything is correct. + expect(rangeStarts).toBeGreaterThan(0); + expect(rangeMids).toBeGreaterThan(0); + expect(rangeEnds).toBeGreaterThan(0); + + const event = createKeyboardEvent('keydown', ESCAPE, 'Escape'); + spyOn(event, 'stopPropagation'); + dispatchEvent(calendarBodyEl, event); + fixture.detectChanges(); + + expect( + monthViewNativeElement.querySelectorAll('.mat-calendar-body-preview-start').length, + ).toBe(0); + expect( + monthViewNativeElement.querySelectorAll('.mat-calendar-body-in-preview').length, + ).toBe(0); + expect( + monthViewNativeElement.querySelectorAll('.mat-calendar-body-preview-end').length, + ).toBe(0); + + expect(event.stopPropagation).toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(true); + expect(testComponent.selected).toEqual(selectedRange); + }); + it('should clear the preview range when the user is done selecting', () => { const cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); @@ -670,13 +842,32 @@ describe('MatMonthView', () => { [(activeDate)]="date" [(selected)]="selected" (selectedChange)="selectedChangeSpy($event)" - (_userSelection)="userSelectionSpy($event)">`, + (_userSelection)="userSelectionSpy($event)" + (dragStarted)="dragStarted($event)" + (dragEnded)="dragEnded($event)" + [activeDrag]="activeDrag"> + `, }) class StandardMonthView { date = new Date(2017, JAN, 5); selected: Date | DateRange = new Date(2017, JAN, 10); selectedChangeSpy = jasmine.createSpy('selectedChange'); userSelectionSpy = jasmine.createSpy('userSelection'); + activeDrag: MatCalendarUserEvent | null = null; + + dragStarted(event: MatCalendarUserEvent) { + this.activeDrag = event; + } + + dragEnded(event: MatCalendarUserEvent | null>) { + if (!this.activeDrag) return; + + if (event.value) { + this.selected = event.value; + } + + this.activeDrag = null; + } } @Component({ diff --git a/src/material/datepicker/month-view.ts b/src/material/datepicker/month-view.ts index 95487a0a0d56..4514e3fc92f2 100644 --- a/src/material/datepicker/month-view.ts +++ b/src/material/datepicker/month-view.ts @@ -145,6 +145,9 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { /** ARIA Accessible name of the `` */ @Input() endDateAccessibleName: string | null; + /** Origin of active drag, or null when dragging is not active. */ + @Input() activeDrag: MatCalendarUserEvent | null = null; + /** Emits when a new date is selected. */ @Output() readonly selectedChange: EventEmitter = new EventEmitter(); @@ -152,6 +155,15 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { @Output() readonly _userSelection: EventEmitter> = new EventEmitter>(); + /** Emits when the user initiates a date range drag via mouse or touch. */ + @Output() readonly dragStarted = new EventEmitter>(); + + /** + * Emits when the user completes or cancels a date range drag. + * Emits null when the drag was canceled or the newly selected date range if completed. + */ + @Output() readonly dragEnded = new EventEmitter | null>>(); + /** Emits when any date is activated. */ @Output() readonly activeDateChange: EventEmitter = new EventEmitter(); @@ -227,6 +239,10 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { if (comparisonChange && !comparisonChange.firstChange) { this._setRanges(this.selected); } + + if (changes['activeDrag'] && !this.activeDrag) { + this._clearPreview(); + } } ngOnDestroy() { @@ -252,7 +268,7 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { } this._userSelection.emit({value: selectedDate, event: event.event}); - this._previewStart = this._previewEnd = null; + this._clearPreview(); this._changeDetectorRef.markForCheck(); } @@ -337,9 +353,15 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { case ESCAPE: // Abort the current range selection if the user presses escape mid-selection. if (this._previewEnd != null && !hasModifierKey(event)) { - this._previewStart = this._previewEnd = null; - this.selectedChange.emit(null); - this._userSelection.emit({value: null, event}); + this._clearPreview(); + // If a drag is in progress, cancel the drag without changing the + // current selection. + if (this.activeDrag) { + this.dragEnded.emit({value: null, event}); + } else { + this.selectedChange.emit(null); + this._userSelection.emit({value: null, event}); + } event.preventDefault(); event.stopPropagation(); // Prevents the overlay from closing. } @@ -420,6 +442,20 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { this._previewStart = this._getCellCompareValue(previewRange.start); this._previewEnd = this._getCellCompareValue(previewRange.end); + if (this.activeDrag && value) { + const dragRange = this._rangeStrategy.createDrag?.( + this.activeDrag.value, + this.selected as DateRange, + value, + event, + ); + + if (dragRange) { + this._previewStart = this._getCellCompareValue(dragRange.start); + this._previewEnd = this._getCellCompareValue(dragRange.end); + } + } + // Note that here we need to use `detectChanges`, rather than `markForCheck`, because // the way `_focusActiveCell` is set up at the moment makes it fire at the wrong time // when navigating one month back using the keyboard which will cause this handler @@ -428,6 +464,28 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { } } + /** + * Called when the user has ended a drag. If the drag/drop was successful, + * computes and emits the new range selection. + */ + protected _dragEnded(event: MatCalendarUserEvent) { + if (!this.activeDrag) return; + + if (event.value) { + // Propagate drag effect + const dragDropResult = this._rangeStrategy?.createDrag?.( + this.activeDrag.value, + this.selected as DateRange, + event.value, + event.event, + ); + + this.dragEnded.emit({value: dragDropResult ?? null, event: event.event}); + } else { + this.dragEnded.emit({value: null, event: event.event}); + } + } + /** * Takes a day of the month and returns a new date in the same month and year as the currently * active date. The returned date will have the same day of the month as the argument date. @@ -554,4 +612,9 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { private _canSelect(date: D) { return !this.dateFilter || this.dateFilter(date); } + + /** Clears out preview state. */ + private _clearPreview() { + this._previewStart = this._previewEnd = null; + } } diff --git a/tools/public_api_guard/material/datepicker.md b/tools/public_api_guard/material/datepicker.md index a761685505fb..a5b400290422 100644 --- a/tools/public_api_guard/material/datepicker.md +++ b/tools/public_api_guard/material/datepicker.md @@ -91,6 +91,8 @@ export interface DateSelectionModelChange { export class DefaultMatCalendarRangeStrategy implements MatDateRangeSelectionStrategy { constructor(_dateAdapter: DateAdapter); // (undocumented) + createDrag(dragOrigin: D, originalRange: DateRange, newDate: D): DateRange | null; + // (undocumented) createPreview(activeDate: D | null, currentRange: DateRange): DateRange; // (undocumented) selectionFinished(date: D, currentRange: DateRange): DateRange; @@ -142,6 +144,7 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes constructor(_intl: MatDatepickerIntl, _dateAdapter: DateAdapter, _dateFormats: MatDateFormats, _changeDetectorRef: ChangeDetectorRef); get activeDate(): D; set activeDate(value: D); + protected _activeDrag: MatCalendarUserEvent | null; _calendarHeaderPortal: Portal; comparisonEnd: D | null; comparisonStart: D | null; @@ -150,6 +153,8 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes dateClass: MatCalendarCellClassFunction; dateFilter: (date: D) => boolean; _dateSelected(event: MatCalendarUserEvent): void; + _dragEnded(event: MatCalendarUserEvent | null>): void; + _dragStarted(event: MatCalendarUserEvent): void; endDateAccessibleName: string | null; focusActiveCell(): void; _goToDateInView(date: D, view: 'month' | 'year' | 'multi-year'): void; @@ -179,19 +184,20 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes startView: MatCalendarView; readonly stateChanges: Subject; updateTodaysDate(): void; + readonly _userDragDrop: EventEmitter>>; readonly _userSelection: EventEmitter>; readonly viewChanged: EventEmitter; readonly yearSelected: EventEmitter; _yearSelectedInMultiYearView(normalizedYear: D): void; yearView: MatYearView; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration, "mat-calendar", ["matCalendar"], { "headerComponent": "headerComponent"; "startAt": "startAt"; "startView": "startView"; "selected": "selected"; "minDate": "minDate"; "maxDate": "maxDate"; "dateFilter": "dateFilter"; "dateClass": "dateClass"; "comparisonStart": "comparisonStart"; "comparisonEnd": "comparisonEnd"; "startDateAccessibleName": "startDateAccessibleName"; "endDateAccessibleName": "endDateAccessibleName"; }, { "selectedChange": "selectedChange"; "yearSelected": "yearSelected"; "monthSelected": "monthSelected"; "viewChanged": "viewChanged"; "_userSelection": "_userSelection"; }, never, never, false, never>; + static ɵcmp: i0.ɵɵComponentDeclaration, "mat-calendar", ["matCalendar"], { "headerComponent": "headerComponent"; "startAt": "startAt"; "startView": "startView"; "selected": "selected"; "minDate": "minDate"; "maxDate": "maxDate"; "dateFilter": "dateFilter"; "dateClass": "dateClass"; "comparisonStart": "comparisonStart"; "comparisonEnd": "comparisonEnd"; "startDateAccessibleName": "startDateAccessibleName"; "endDateAccessibleName": "endDateAccessibleName"; }, { "selectedChange": "selectedChange"; "yearSelected": "yearSelected"; "monthSelected": "monthSelected"; "viewChanged": "viewChanged"; "_userSelection": "_userSelection"; "_userDragDrop": "_userDragDrop"; }, never, never, false, never>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, [null, { optional: true; }, { optional: true; }, null]>; } // @public -export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked { +export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked { constructor(_elementRef: ElementRef, _ngZone: NgZone); activeCell: number; // (undocumented) @@ -202,6 +208,8 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked { _cellWidth: string; comparisonEnd: number | null; comparisonStart: number | null; + readonly dragEnded: EventEmitter>; + readonly dragStarted: EventEmitter>; // (undocumented) _emitActiveDateChange(cell: MatCalendarCell, event: FocusEvent): void; endDateAccessibleName: string | null; @@ -247,9 +255,9 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked { startValue: number; todayValue: number; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration, "[mat-calendar-body]", ["matCalendarBody"], { "label": "label"; "rows": "rows"; "todayValue": "todayValue"; "startValue": "startValue"; "endValue": "endValue"; "labelMinRequiredCells": "labelMinRequiredCells"; "numCols": "numCols"; "activeCell": "activeCell"; "isRange": "isRange"; "cellAspectRatio": "cellAspectRatio"; "comparisonStart": "comparisonStart"; "comparisonEnd": "comparisonEnd"; "previewStart": "previewStart"; "previewEnd": "previewEnd"; "startDateAccessibleName": "startDateAccessibleName"; "endDateAccessibleName": "endDateAccessibleName"; }, { "selectedValueChange": "selectedValueChange"; "previewChange": "previewChange"; "activeDateChange": "activeDateChange"; "dragStarted": "dragStarted"; "dragEnded": "dragEnded"; }, never, never, false, never>; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration, never>; } // @public @@ -385,6 +393,8 @@ export class MatDatepickerContent> extend // (undocumented) _handleAnimationEvent(event: AnimationEvent_2): void; // (undocumented) + _handleUserDragDrop(event: MatCalendarUserEvent>): void; + // (undocumented) _handleUserSelection(event: MatCalendarUserEvent): void; _isAbove: boolean; _isAnimating: boolean; @@ -631,6 +641,7 @@ export class MatDateRangePicker extends MatDatepickerBase { + createDrag?(dragOrigin: D, originalRange: DateRange, newDate: D, event: Event): DateRange | null; createPreview(activeDate: D | null, currentRange: DateRange, event: Event): DateRange; selectionFinished(date: D | null, currentRange: DateRange, event: Event): DateRange; } @@ -683,6 +694,7 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { get activeDate(): D; set activeDate(value: D); readonly activeDateChange: EventEmitter; + activeDrag: MatCalendarUserEvent | null; // (undocumented) readonly _changeDetectorRef: ChangeDetectorRef; comparisonEnd: D | null; @@ -694,6 +706,9 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { dateClass: MatCalendarCellClassFunction; dateFilter: (date: D) => boolean; _dateSelected(event: MatCalendarUserEvent): void; + readonly dragEnded: EventEmitter | null>>; + protected _dragEnded(event: MatCalendarUserEvent): void; + readonly dragStarted: EventEmitter>; endDateAccessibleName: string | null; _firstWeekOffset: number; _focusActiveCell(movePreview?: boolean): void; @@ -732,7 +747,7 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { }[]; _weeks: MatCalendarCell[][]; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration, "mat-month-view", ["matMonthView"], { "activeDate": "activeDate"; "selected": "selected"; "minDate": "minDate"; "maxDate": "maxDate"; "dateFilter": "dateFilter"; "dateClass": "dateClass"; "comparisonStart": "comparisonStart"; "comparisonEnd": "comparisonEnd"; "startDateAccessibleName": "startDateAccessibleName"; "endDateAccessibleName": "endDateAccessibleName"; }, { "selectedChange": "selectedChange"; "_userSelection": "_userSelection"; "activeDateChange": "activeDateChange"; }, never, never, false, never>; + static ɵcmp: i0.ɵɵComponentDeclaration, "mat-month-view", ["matMonthView"], { "activeDate": "activeDate"; "selected": "selected"; "minDate": "minDate"; "maxDate": "maxDate"; "dateFilter": "dateFilter"; "dateClass": "dateClass"; "comparisonStart": "comparisonStart"; "comparisonEnd": "comparisonEnd"; "startDateAccessibleName": "startDateAccessibleName"; "endDateAccessibleName": "endDateAccessibleName"; "activeDrag": "activeDrag"; }, { "selectedChange": "selectedChange"; "_userSelection": "_userSelection"; "dragStarted": "dragStarted"; "dragEnded": "dragEnded"; "activeDateChange": "activeDateChange"; }, never, never, false, never>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, [null, { optional: true; }, { optional: true; }, { optional: true; }, { optional: true; }]>; }