Skip to content

Commit

Permalink
feat(material/datepicker): Allow user to jump between start and end d…
Browse files Browse the repository at this point in the history
…ates with arrow keys (#25359)
  • Loading branch information
kseamon committed Aug 5, 2022
1 parent 119dd4b commit 3960e4f
Show file tree
Hide file tree
Showing 3 changed files with 146 additions and 9 deletions.
54 changes: 47 additions & 7 deletions src/material/datepicker/date-range-input-parts.ts
Expand Up @@ -10,6 +10,7 @@ import {
Directive,
ElementRef,
Optional,
inject,
InjectionToken,
Inject,
OnInit,
Expand All @@ -36,7 +37,8 @@ import {
MatDateFormats,
ErrorStateMatcher,
} from '@angular/material/core';
import {BACKSPACE} from '@angular/cdk/keycodes';
import {Directionality} from '@angular/cdk/bidi';
import {BACKSPACE, LEFT_ARROW, RIGHT_ARROW} from '@angular/cdk/keycodes';
import {MatDatepickerInputBase, DateFilterFn} from './datepicker-input-base';
import {DateRange, DateSelectionModelChange} from './date-selection-model';

Expand Down Expand Up @@ -86,17 +88,19 @@ abstract class MatDateRangeInputPartBase<D>
protected abstract override _assignValueToModel(value: D | null): void;
protected abstract override _getValueFromModel(modelValue: DateRange<D>): D | null;

protected readonly _dir = inject(Directionality, InjectFlags.Optional);

constructor(
@Inject(MAT_DATE_RANGE_INPUT_PARENT) public _rangeInput: MatDateRangeInputParent<D>,
elementRef: ElementRef<HTMLInputElement>,
public override _elementRef: ElementRef<HTMLInputElement>,
public _defaultErrorStateMatcher: ErrorStateMatcher,
private _injector: Injector,
@Optional() public _parentForm: NgForm,
@Optional() public _parentFormGroup: FormGroupDirective,
@Optional() dateAdapter: DateAdapter<D>,
@Optional() @Inject(MAT_DATE_FORMATS) dateFormats: MatDateFormats,
) {
super(elementRef, dateAdapter, dateFormats);
super(_elementRef, dateAdapter, dateFormats);
}

ngOnInit() {
Expand Down Expand Up @@ -284,6 +288,26 @@ export class MatStartDate<D> extends _MatDateRangeInputBase<D> implements CanUpd
const value = element.value;
return value.length > 0 ? value : element.placeholder;
}

override _onKeydown(event: KeyboardEvent) {
const endInput = this._rangeInput._endInput;
const element = this._elementRef.nativeElement;
const isLtr = this._dir?.value !== 'rtl';

// If the user hits RIGHT (LTR) when at the end of the input (and no
// selection), move the cursor to the start of the end input.
if (
((event.keyCode === RIGHT_ARROW && isLtr) || (event.keyCode === LEFT_ARROW && !isLtr)) &&
element.selectionStart === element.value.length &&
element.selectionEnd === element.value.length
) {
event.preventDefault();
endInput._elementRef.nativeElement.setSelectionRange(0, 0);
endInput.focus();
} else {
super._onKeydown(event);
}
}
}

/** Input for entering the end date in a `mat-date-range-input`. */
Expand Down Expand Up @@ -370,11 +394,27 @@ export class MatEndDate<D> extends _MatDateRangeInputBase<D> implements CanUpdat
}

override _onKeydown(event: KeyboardEvent) {
const startInput = this._rangeInput._startInput;
const element = this._elementRef.nativeElement;
const isLtr = this._dir?.value !== 'rtl';

// If the user is pressing backspace on an empty end input, move focus back to the start.
if (event.keyCode === BACKSPACE && !this._elementRef.nativeElement.value) {
this._rangeInput._startInput.focus();
if (event.keyCode === BACKSPACE && !element.value) {
startInput.focus();
}
// If the user hits LEFT (LTR) when at the start of the input (and no
// selection), move the cursor to the end of the start input.
else if (
((event.keyCode === LEFT_ARROW && isLtr) || (event.keyCode === RIGHT_ARROW && !isLtr)) &&
element.selectionStart === 0 &&
element.selectionEnd === 0
) {
event.preventDefault();
const endPosition = startInput._elementRef.nativeElement.value.length;
startInput._elementRef.nativeElement.setSelectionRange(endPosition, endPosition);
startInput.focus();
} else {
super._onKeydown(event);
}

super._onKeydown(event);
}
}
99 changes: 97 additions & 2 deletions src/material/datepicker/date-range-input.spec.ts
@@ -1,4 +1,4 @@
import {Type, Component, ViewChild, ElementRef, Directive} from '@angular/core';
import {Type, Component, ViewChild, ElementRef, Directive, Provider} from '@angular/core';
import {ComponentFixture, TestBed, inject, fakeAsync, tick, flush} from '@angular/core/testing';
import {
FormsModule,
Expand All @@ -10,14 +10,15 @@ import {
NgModel,
} from '@angular/forms';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {Directionality} from '@angular/cdk/bidi';
import {OverlayContainer} from '@angular/cdk/overlay';
import {ErrorStateMatcher, MatNativeDateModule} from '@angular/material/core';
import {MatDatepickerModule} from './datepicker-module';
import {MatLegacyFormFieldModule} from '@angular/material/legacy-form-field';
import {MatLegacyInputModule} from '@angular/material/legacy-input';
import {dispatchFakeEvent, dispatchKeyboardEvent} from '../../cdk/testing/private';
import {FocusMonitor} from '@angular/cdk/a11y';
import {BACKSPACE} from '@angular/cdk/keycodes';
import {BACKSPACE, LEFT_ARROW, RIGHT_ARROW} from '@angular/cdk/keycodes';
import {MatDateRangeInput} from './date-range-input';
import {MatDateRangePicker} from './date-range-picker';
import {MatStartDate, MatEndDate} from './date-range-input-parts';
Expand All @@ -27,6 +28,7 @@ describe('MatDateRangeInput', () => {
function createComponent<T>(
component: Type<T>,
declarations: Type<any>[] = [],
providers: Provider[] = [],
): ComponentFixture<T> {
TestBed.configureTestingModule({
imports: [
Expand All @@ -38,6 +40,7 @@ describe('MatDateRangeInput', () => {
ReactiveFormsModule,
MatNativeDateModule,
],
providers,
declarations: [component, ...declarations],
});

Expand Down Expand Up @@ -721,6 +724,98 @@ describe('MatDateRangeInput', () => {
expect(start.nativeElement.focus).not.toHaveBeenCalled();
});

it('moves focus between fields with arrow keys when cursor is at edge (LTR)', () => {
const fixture = createComponent(StandardRangePicker);
fixture.detectChanges();
const {start, end} = fixture.componentInstance;

start.nativeElement.value = '09/10/2020';
end.nativeElement.value = '10/10/2020';

start.nativeElement.focus();
start.nativeElement.setSelectionRange(9, 9);
dispatchKeyboardEvent(start.nativeElement, 'keydown', RIGHT_ARROW);
fixture.detectChanges();
expect(document.activeElement).toBe(start.nativeElement);

start.nativeElement.setSelectionRange(10, 10);
dispatchKeyboardEvent(start.nativeElement, 'keydown', LEFT_ARROW);
fixture.detectChanges();
expect(document.activeElement).toBe(start.nativeElement);

start.nativeElement.setSelectionRange(10, 10);
dispatchKeyboardEvent(start.nativeElement, 'keydown', RIGHT_ARROW);
fixture.detectChanges();
expect(document.activeElement).toBe(end.nativeElement);

end.nativeElement.setSelectionRange(1, 1);
dispatchKeyboardEvent(end.nativeElement, 'keydown', LEFT_ARROW);
fixture.detectChanges();
expect(document.activeElement).toBe(end.nativeElement);

end.nativeElement.setSelectionRange(0, 0);
dispatchKeyboardEvent(end.nativeElement, 'keydown', RIGHT_ARROW);
fixture.detectChanges();
expect(document.activeElement).toBe(end.nativeElement);

end.nativeElement.setSelectionRange(0, 0);
dispatchKeyboardEvent(end.nativeElement, 'keydown', LEFT_ARROW);
fixture.detectChanges();
expect(document.activeElement).toBe(start.nativeElement);
});

it('moves focus between fields with arrow keys when cursor is at edge (RTL)', () => {
class RTL extends Directionality {
override readonly value = 'rtl';
}
const fixture = createComponent(
StandardRangePicker,
[],
[
{
provide: Directionality,
useFactory: () => new RTL(null),
},
],
);
fixture.detectChanges();
const {start, end} = fixture.componentInstance;

start.nativeElement.value = '09/10/2020';
end.nativeElement.value = '10/10/2020';

start.nativeElement.focus();
start.nativeElement.setSelectionRange(9, 9);
dispatchKeyboardEvent(start.nativeElement, 'keydown', LEFT_ARROW);
fixture.detectChanges();
expect(document.activeElement).toBe(start.nativeElement);

start.nativeElement.setSelectionRange(10, 10);
dispatchKeyboardEvent(start.nativeElement, 'keydown', RIGHT_ARROW);
fixture.detectChanges();
expect(document.activeElement).toBe(start.nativeElement);

start.nativeElement.setSelectionRange(10, 10);
dispatchKeyboardEvent(start.nativeElement, 'keydown', LEFT_ARROW);
fixture.detectChanges();
expect(document.activeElement).toBe(end.nativeElement);

end.nativeElement.setSelectionRange(1, 1);
dispatchKeyboardEvent(end.nativeElement, 'keydown', RIGHT_ARROW);
fixture.detectChanges();
expect(document.activeElement).toBe(end.nativeElement);

end.nativeElement.setSelectionRange(0, 0);
dispatchKeyboardEvent(end.nativeElement, 'keydown', LEFT_ARROW);
fixture.detectChanges();
expect(document.activeElement).toBe(end.nativeElement);

end.nativeElement.setSelectionRange(0, 0);
dispatchKeyboardEvent(end.nativeElement, 'keydown', RIGHT_ARROW);
fixture.detectChanges();
expect(document.activeElement).toBe(start.nativeElement);
});

it('should be able to get the input placeholder', () => {
const fixture = createComponent(StandardRangePicker);
fixture.detectChanges();
Expand Down
2 changes: 2 additions & 0 deletions tools/public_api_guard/material/datepicker.md
Expand Up @@ -893,6 +893,8 @@ export class MatStartDate<D> extends _MatDateRangeInputBase<D> implements CanUpd
// (undocumented)
protected _getValueFromModel(modelValue: DateRange<D>): D | null;
// (undocumented)
_onKeydown(event: KeyboardEvent): void;
// (undocumented)
protected _shouldHandleChangeEvent(change: DateSelectionModelChange<DateRange<D>>): boolean;
// (undocumented)
protected _validator: ValidatorFn | null;
Expand Down

0 comments on commit 3960e4f

Please sign in to comment.