Skip to content

feat(datepicker): allow for the preview range logic to be customized #19088

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 17, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions src/dev-app/datepicker/datepicker-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,18 +92,43 @@ export class PreserveRangeStrategy<D> implements MatCalendarRangeSelectionStrate
selectionFinished(date: D, currentRange: DateRange<D>) {
let {start, end} = currentRange;

if (start && end) {
return this._getRangeRelativeToDate(date, start, end);
}

if (start == null) {
start = date;
} else if (end == null) {
end = date;
} else if (this._dateAdapter.compareDate(start, date) > 0) {
start = date;
} else {
end = date;
}

return new DateRange<D>(start, end);
}

createPreview(activeDate: D | null, currentRange: DateRange<D>): DateRange<D> {
if (activeDate) {
if (currentRange.start && currentRange.end) {
return this._getRangeRelativeToDate(activeDate, currentRange.start, currentRange.end);
} else if (currentRange.start && !currentRange.end) {
return new DateRange(currentRange.start, activeDate);
}
}

return new DateRange<D>(null, null);
}

private _getRangeRelativeToDate(date: D | null, start: D, end: D): DateRange<D> {
let rangeStart: D | null = null;
let rangeEnd: D | null = null;

if (date) {
const delta = Math.round(Math.abs(this._dateAdapter.compareDate(start, end)) / 2);
rangeStart = this._dateAdapter.addCalendarDays(date, -delta);
rangeEnd = this._dateAdapter.addCalendarDays(date, delta);
}

return new DateRange(rangeStart, rangeEnd);
}
}

@Directive({
Expand Down
33 changes: 22 additions & 11 deletions src/material/datepicker/calendar-body.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ describe('MatCalendarBody', () => {

it('should not mark a cell as a start bridge if there is no end range value', () => {
testComponent.startValue = 1;
testComponent.endValue = undefined;
testComponent.endValue = null;
testComponent.comparisonStart = 5;
testComponent.comparisonEnd = 10;
fixture.detectChanges();
Expand All @@ -217,7 +217,7 @@ describe('MatCalendarBody', () => {
testComponent.comparisonStart = 1;
testComponent.comparisonEnd = 5;
testComponent.startValue = 5;
testComponent.endValue = undefined;
testComponent.endValue = null;
fixture.detectChanges();

expect(cells.some(cell => cell.classList.contains(bridgeEnd))).toBe(false);
Expand Down Expand Up @@ -375,7 +375,7 @@ describe('MatCalendarBody', () => {
});

it('should not show a range if there is no start', () => {
testComponent.startValue = undefined;
testComponent.startValue = null;
testComponent.endValue = 10;
fixture.detectChanges();

Expand All @@ -384,7 +384,7 @@ describe('MatCalendarBody', () => {
});

it('should not show a comparison range if there is no start', () => {
testComponent.comparisonStart = undefined;
testComponent.comparisonStart = null;
testComponent.comparisonEnd = 10;
fixture.detectChanges();

Expand All @@ -394,7 +394,7 @@ describe('MatCalendarBody', () => {

it('should not show a comparison range if there is no end', () => {
testComponent.comparisonStart = 10;
testComponent.comparisonEnd = undefined;
testComponent.comparisonEnd = null;
fixture.detectChanges();

expect(cells.some(cell => cell.classList.contains(inComparisonClass))).toBe(false);
Expand Down Expand Up @@ -599,20 +599,26 @@ class StandardCalendarBody {
@Component({
template: `
<table mat-calendar-body
[isRange]="true"
[rows]="rows"
[startValue]="startValue"
[endValue]="endValue"
[comparisonStart]="comparisonStart"
[comparisonEnd]="comparisonEnd"
(selectedValueChange)="onSelect($event)">
[previewStart]="previewStart"
[previewEnd]="previewEnd"
(selectedValueChange)="onSelect($event)"
(previewChange)="previewChanged($event)">
</table>`,
})
class RangeCalendarBody {
rows = createCalendarCells(4);
startValue: number | undefined;
endValue: number | undefined;
comparisonStart: number | undefined;
comparisonEnd: number | undefined;
startValue: number | null;
endValue: number | null;
comparisonStart: number | null;
comparisonEnd: number | null;
previewStart: number | null;
previewEnd: number | null;

onSelect(event: MatCalendarUserEvent<number>) {
const value = event.value;
Expand All @@ -622,9 +628,14 @@ class RangeCalendarBody {
this.endValue = value;
} else {
this.startValue = value;
this.endValue = undefined;
this.endValue = null;
}
}

previewChanged(event: MatCalendarUserEvent<MatCalendarCell<Date> | null>) {
this.previewStart = this.startValue;
this.previewEnd = event.value?.compareValue || null;
}
}

/**
Expand Down
83 changes: 46 additions & 37 deletions src/material/datepicker/calendar-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,14 @@ export type MatCalendarCellCssClasses = string | string[] | Set<string> | {[key:
* An internal class that represents the data corresponding to a single calendar cell.
* @docs-private
*/
export class MatCalendarCell {
export class MatCalendarCell<D = any> {
constructor(public value: number,
public displayValue: string,
public ariaLabel: string,
public enabled: boolean,
public cssClasses: MatCalendarCellCssClasses = {},
public compareValue = value) {}
public compareValue = value,
public rawValue?: D) {}
}

/** Event emitted when a date inside the calendar is triggered as a result of a user action. */
Expand All @@ -64,6 +65,12 @@ export interface MatCalendarUserEvent<D> {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatCalendarBody implements OnChanges, OnDestroy {
/**
* 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.
*/
private _skipNextFocus: boolean;

/** The label for the table. (e.g. "Jan 2017"). */
@Input() label: string;

Expand All @@ -88,6 +95,9 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
/** The cell number of the active cell in the table. */
@Input() activeCell: number = 0;

/** Whether a range is being selected. */
@Input() isRange: boolean = false;

/**
* The aspect ratio (width / height) to use for the cells in the table. This aspect ratio will be
* maintained even as the table resizes.
Expand All @@ -100,10 +110,19 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
/** End of the comparison range. */
@Input() comparisonEnd: number | null;

/** Start of the preview range. */
@Input() previewStart: number | null = null;

/** End of the preview range. */
@Input() previewEnd: number | null = null;

/** Emits when a new value is selected. */
@Output() readonly selectedValueChange: EventEmitter<MatCalendarUserEvent<number>> =
new EventEmitter<MatCalendarUserEvent<number>>();

/** Emits when the preview has changed as a result of a user action. */
@Output() previewChange = new EventEmitter<MatCalendarUserEvent<MatCalendarCell | null>>();

/** The number of blank cells to put at the beginning for the first row. */
_firstRowOffset: number;

Expand All @@ -113,12 +132,6 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
/** Width of an individual cell. */
_cellWidth: string;

/**
* Value that the user is either currently hovering over or is focusing
* using the keyboard. Only applies when selecting the end of a date range.
*/
_previewEnd = -1;

constructor(
private _elementRef: ElementRef<HTMLElement>,
private _changeDetectorRef: ChangeDetectorRef,
Expand Down Expand Up @@ -160,10 +173,6 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
if (columnChanges || !this._cellWidth) {
this._cellWidth = `${100 / numCols}%`;
}

if (changes['startValue'] || changes['endValue']) {
this._previewEnd = -1;
}
}

ngOnDestroy() {
Expand All @@ -187,24 +196,23 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
}

/** Focuses the active cell after the microtask queue is empty. */
_focusActiveCell() {
_focusActiveCell(movePreview = true) {
this._ngZone.runOutsideAngular(() => {
this._ngZone.onStable.asObservable().pipe(take(1)).subscribe(() => {
const activeCell: HTMLElement | null =
this._elementRef.nativeElement.querySelector('.mat-calendar-body-active');

if (activeCell) {
if (!movePreview) {
this._skipNextFocus = true;
}

activeCell.focus();
}
});
});
}

/** Gets whether the calendar is currently selecting a range. */
_isSelectingRange(): boolean {
return this.startValue !== this.endValue;
}

/** Gets whether a value is the start of the main range. */
_isRangeStart(value: number) {
return value === this.startValue;
Expand All @@ -217,7 +225,8 @@ export class MatCalendarBody implements OnChanges, OnDestroy {

/** Gets whether a value is within the currently-selected range. */
_isInRange(value: number): boolean {
return this._isSelectingRange() && value >= this.startValue && value <= this.endValue;
return this.isRange && this.startValue !== null && this.endValue !== null &&
value >= this.startValue && value <= this.endValue;
}

/** Gets whether a value is the start of the comparison range. */
Expand Down Expand Up @@ -272,41 +281,41 @@ export class MatCalendarBody implements OnChanges, OnDestroy {

/** Gets whether a value is the start of the preview range. */
_isPreviewStart(value: number) {
return this._previewEnd > -1 && this._isRangeStart(value);
return value === this.previewStart && this.previewEnd && value < this.previewEnd;
}

/** Gets whether a value is the end of the preview range. */
_isPreviewEnd(value: number) {
return value === this._previewEnd;
return value === this.previewEnd && this.previewStart && value > this.previewStart;
}

/** Gets whether a value is inside the preview range. */
_isInPreview(value: number) {
return this._isSelectingRange() && value >= this.startValue && value <= this._previewEnd;
if (!this.isRange) {
return false;
}

const {previewStart, previewEnd} = this;
return previewStart !== null && previewEnd !== null && previewStart !== previewEnd &&
value >= previewStart && value <= previewEnd;
}

/**
* Event handler for when the user enters an element
* inside the calendar body (e.g. by hovering in or focus).
*/
private _enterHandler = (event: Event) => {
// We only need to hit the zone when we're selecting a range, we have a
// start value without an end value and we've hovered over a date cell.
if (!event.target || !this.startValue || this.endValue || !this._isSelectingRange()) {
if (this._skipNextFocus && event.type === 'focus') {
this._skipNextFocus = false;
return;
}

const cell = this._getCellFromElement(event.target as HTMLElement);

if (cell) {
const value = cell.compareValue;

// Only set as the preview end value if we're after the start of the range.
const previewEnd = (cell.enabled && value > this.startValue) ? value : -1;
// We only need to hit the zone when we're selecting a range.
if (event.target && this.isRange) {
const cell = this._getCellFromElement(event.target as HTMLElement);

if (previewEnd !== this._previewEnd) {
this._previewEnd = previewEnd;
this._ngZone.run(() => this._changeDetectorRef.markForCheck());
if (cell) {
this._ngZone.run(() => this.previewChange.emit({value: cell.enabled ? cell : null, event}));
}
}
}
Expand All @@ -317,13 +326,13 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
*/
private _leaveHandler = (event: Event) => {
// We only need to hit the zone when we're selecting a range.
if (this._previewEnd !== -1 && this._isSelectingRange()) {
if (this.previewEnd !== null && this.isRange) {
// 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 && isTableCell(event.target as HTMLElement)) {
this._ngZone.run(() => {
this._previewEnd = -1;
this.previewChange.emit({value: null, event});

// 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
Expand Down
32 changes: 31 additions & 1 deletion src/material/datepicker/calendar-range-selection-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,26 @@ export const MAT_CALENDAR_RANGE_SELECTION_STRATEGY =

/** Object that can be provided in order to customize the date range selection behavior. */
export interface MatCalendarRangeSelectionStrategy<D> {
/** Called when the user has finished selecting a value. */
/**
* Called when the user has finished selecting a value.
* @param date Date that was selected. Will be null if the user cleared the selection.
* @param currentRange Range that is currently show in the calendar.
* @param event DOM event that triggered the selection. Currently only corresponds to a `click`
* event, but it may get expanded in the future.
*/
selectionFinished(date: D | null, currentRange: DateRange<D>, event: Event): DateRange<D>;

/**
* Called when the user has activated a new date (e.g. by hovering over
* it or moving focus) and the calendar tries to display a date range.
*
* @param activeDate Date that the user has activated. Will be null if the user moved
* focus to an element that's no a calendar cell.
* @param currentRange Range that is currently shown in the calendar.
* @param event DOM event that caused the preview to be changed. Will be either a
* `mouseenter`/`mouseleave` or `focus`/`blur` depending on how the user is navigating.
*/
createPreview(activeDate: D | null, currentRange: DateRange<D>, event: Event): DateRange<D>;
}

/** Provides the default date range selection behavior. */
Expand All @@ -42,4 +60,16 @@ export class DefaultMatCalendarRangeStrategy<D> implements MatCalendarRangeSelec

return new DateRange<D>(start, end);
}

createPreview(activeDate: D | null, currentRange: DateRange<D>) {
let start: D | null = null;
let end: D | null = null;

if (currentRange.start && !currentRange.end && activeDate) {
start = currentRange.start;
end = activeDate;
}

return new DateRange<D>(start, end);
}
}
2 changes: 1 addition & 1 deletion src/material/datepicker/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ export class MatCalendar<D> implements AfterContentInit, AfterViewChecked, OnDes
}

focusActiveCell() {
this._getCurrentViewComponent()._focusActiveCell();
this._getCurrentViewComponent()._focusActiveCell(false);
}

/** Updates today's date after an update of the active date */
Expand Down
Loading