Skip to content

Commit ff2cd71

Browse files
committed
feat(material/datepicker): Allow user to jump between start and end dates with arrow keys
1 parent 5b8d521 commit ff2cd71

File tree

3 files changed

+177
-7
lines changed

3 files changed

+177
-7
lines changed

src/material/datepicker/date-range-input-parts.ts

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Directive,
1111
ElementRef,
1212
Optional,
13+
inject,
1314
InjectionToken,
1415
Inject,
1516
OnInit,
@@ -36,9 +37,12 @@ import {
3637
MatDateFormats,
3738
ErrorStateMatcher,
3839
} from '@angular/material/core';
39-
import {BACKSPACE} from '@angular/cdk/keycodes';
40+
import {Directionality} from '@angular/cdk/bidi';
41+
import {BACKSPACE, LEFT_ARROW, RIGHT_ARROW} from '@angular/cdk/keycodes';
4042
import {MatDatepickerInputBase, DateFilterFn} from './datepicker-input-base';
4143
import {DateRange, DateSelectionModelChange} from './date-selection-model';
44+
import {Subject} from 'rxjs';
45+
import {filter, take} from 'rxjs/operators';
4246

4347
/** Parent component that should be wrapped around `MatStartDate` and `MatEndDate`. */
4448
export interface MatDateRangeInputParent<D> {
@@ -86,17 +90,20 @@ abstract class MatDateRangeInputPartBase<D>
8690
protected abstract override _assignValueToModel(value: D | null): void;
8791
protected abstract override _getValueFromModel(modelValue: DateRange<D>): D | null;
8892

93+
protected readonly _dir = inject(Directionality, InjectFlags.Optional);
94+
private readonly _onKeyupSubject = new Subject<KeyboardEvent>();
95+
8996
constructor(
9097
@Inject(MAT_DATE_RANGE_INPUT_PARENT) public _rangeInput: MatDateRangeInputParent<D>,
91-
elementRef: ElementRef<HTMLInputElement>,
98+
public override _elementRef: ElementRef<HTMLInputElement>,
9299
public _defaultErrorStateMatcher: ErrorStateMatcher,
93100
private _injector: Injector,
94101
@Optional() public _parentForm: NgForm,
95102
@Optional() public _parentFormGroup: FormGroupDirective,
96103
@Optional() dateAdapter: DateAdapter<D>,
97104
@Optional() @Inject(MAT_DATE_FORMATS) dateFormats: MatDateFormats,
98105
) {
99-
super(elementRef, dateAdapter, dateFormats);
106+
super(_elementRef, dateAdapter, dateFormats);
100107
}
101108

102109
ngOnInit() {
@@ -181,6 +188,26 @@ abstract class MatDateRangeInputPartBase<D>
181188
) as MatDateRangeInputPartBase<D> | undefined;
182189
opposite?._validatorOnChange();
183190
}
191+
192+
protected _onKeyup(event: KeyboardEvent) {
193+
this._onKeyupSubject.next(event);
194+
}
195+
196+
protected _moveFocusOnKeyup(
197+
target: MatDateRangeInputPartBase<D>,
198+
downEvent: KeyboardEvent,
199+
cursorPosition: number,
200+
) {
201+
this._onKeyupSubject
202+
.pipe(
203+
filter(upEvent => upEvent.keyCode === downEvent.keyCode),
204+
take(1),
205+
)
206+
.subscribe(() => {
207+
target._elementRef.nativeElement.setSelectionRange(cursorPosition, cursorPosition);
208+
target.focus();
209+
});
210+
}
184211
}
185212

186213
const _MatDateRangeInputBase = mixinErrorState(MatDateRangeInputPartBase);
@@ -194,6 +221,7 @@ const _MatDateRangeInputBase = mixinErrorState(MatDateRangeInputPartBase);
194221
'(input)': '_onInput($event.target.value)',
195222
'(change)': '_onChange()',
196223
'(keydown)': '_onKeydown($event)',
224+
'(keyup)': '_onKeyup($event)',
197225
'[attr.id]': '_rangeInput.id',
198226
'[attr.aria-haspopup]': '_rangeInput.rangePicker ? "dialog" : null',
199227
'[attr.aria-owns]': '(_rangeInput.rangePicker?.opened && _rangeInput.rangePicker.id) || null',
@@ -284,6 +312,24 @@ export class MatStartDate<D> extends _MatDateRangeInputBase<D> implements CanUpd
284312
const value = element.value;
285313
return value.length > 0 ? value : element.placeholder;
286314
}
315+
316+
override _onKeydown(event: KeyboardEvent) {
317+
const endInput = this._rangeInput._endInput;
318+
const element = this._elementRef.nativeElement;
319+
const isLtr = this._dir?.value !== 'rtl';
320+
321+
// If the user hits RIGHT (LTR) when at the end of the input (and no
322+
// selection), move the cursor to the start of the end input.
323+
if (
324+
((event.keyCode === RIGHT_ARROW && isLtr) || (event.keyCode === LEFT_ARROW && !isLtr)) &&
325+
element.selectionStart === element.value.length &&
326+
element.selectionEnd === element.value.length
327+
) {
328+
this._moveFocusOnKeyup(endInput, event, 0);
329+
}
330+
331+
super._onKeydown(event);
332+
}
287333
}
288334

289335
/** Input for entering the end date in a `mat-date-range-input`. */
@@ -295,6 +341,7 @@ export class MatStartDate<D> extends _MatDateRangeInputBase<D> implements CanUpd
295341
'(input)': '_onInput($event.target.value)',
296342
'(change)': '_onChange()',
297343
'(keydown)': '_onKeydown($event)',
344+
'(keyup)': '_onKeyup($event)',
298345
'[attr.aria-haspopup]': '_rangeInput.rangePicker ? "dialog" : null',
299346
'[attr.aria-owns]': '(_rangeInput.rangePicker?.opened && _rangeInput.rangePicker.id) || null',
300347
'[attr.min]': '_getMinDate() ? _dateAdapter.toIso8601(_getMinDate()) : null',
@@ -370,9 +417,23 @@ export class MatEndDate<D> extends _MatDateRangeInputBase<D> implements CanUpdat
370417
}
371418

372419
override _onKeydown(event: KeyboardEvent) {
420+
const startInput = this._rangeInput._startInput;
421+
const element = this._elementRef.nativeElement;
422+
const isLtr = this._dir?.value !== 'rtl';
423+
373424
// If the user is pressing backspace on an empty end input, move focus back to the start.
374-
if (event.keyCode === BACKSPACE && !this._elementRef.nativeElement.value) {
375-
this._rangeInput._startInput.focus();
425+
if (event.keyCode === BACKSPACE && !element.value) {
426+
startInput.focus();
427+
}
428+
429+
// If the user hits LEFT (LTR) when at the start of the input (and no
430+
// selection), move the cursor to the end of the start input.
431+
if (
432+
((event.keyCode === LEFT_ARROW && isLtr) || (event.keyCode === RIGHT_ARROW && !isLtr)) &&
433+
element.selectionStart === 0 &&
434+
element.selectionEnd === 0
435+
) {
436+
this._moveFocusOnKeyup(startInput, event, startInput._elementRef.nativeElement.value.length);
376437
}
377438

378439
super._onKeydown(event);

src/material/datepicker/date-range-input.spec.ts

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Type, Component, ViewChild, ElementRef, Directive} from '@angular/core';
1+
import {Type, Component, ViewChild, ElementRef, Directive, Provider} from '@angular/core';
22
import {ComponentFixture, TestBed, inject, fakeAsync, tick, flush} from '@angular/core/testing';
33
import {
44
FormsModule,
@@ -10,14 +10,15 @@ import {
1010
NgModel,
1111
} from '@angular/forms';
1212
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
13+
import {Directionality} from '@angular/cdk/bidi';
1314
import {OverlayContainer} from '@angular/cdk/overlay';
1415
import {ErrorStateMatcher, MatNativeDateModule} from '@angular/material/core';
1516
import {MatDatepickerModule} from './datepicker-module';
1617
import {MatLegacyFormFieldModule} from '@angular/material/legacy-form-field';
1718
import {MatLegacyInputModule} from '@angular/material/legacy-input';
1819
import {dispatchFakeEvent, dispatchKeyboardEvent} from '../../cdk/testing/private';
1920
import {FocusMonitor} from '@angular/cdk/a11y';
20-
import {BACKSPACE} from '@angular/cdk/keycodes';
21+
import {BACKSPACE, LEFT_ARROW, RIGHT_ARROW} from '@angular/cdk/keycodes';
2122
import {MatDateRangeInput} from './date-range-input';
2223
import {MatDateRangePicker} from './date-range-picker';
2324
import {MatStartDate, MatEndDate} from './date-range-input-parts';
@@ -27,6 +28,7 @@ describe('MatDateRangeInput', () => {
2728
function createComponent<T>(
2829
component: Type<T>,
2930
declarations: Type<any>[] = [],
31+
providers: Provider[] = [],
3032
): ComponentFixture<T> {
3133
TestBed.configureTestingModule({
3234
imports: [
@@ -38,6 +40,7 @@ describe('MatDateRangeInput', () => {
3840
ReactiveFormsModule,
3941
MatNativeDateModule,
4042
],
43+
providers,
4144
declarations: [component, ...declarations],
4245
});
4346

@@ -721,6 +724,110 @@ describe('MatDateRangeInput', () => {
721724
expect(start.nativeElement.focus).not.toHaveBeenCalled();
722725
});
723726

727+
it('moves focus between fields with arrow keys when cursor is at edge (LTR)', () => {
728+
const fixture = createComponent(StandardRangePicker);
729+
fixture.detectChanges();
730+
const {start, end} = fixture.componentInstance;
731+
732+
start.nativeElement.value = '09/10/2020';
733+
end.nativeElement.value = '10/10/2020';
734+
735+
start.nativeElement.focus();
736+
start.nativeElement.setSelectionRange(9, 9);
737+
dispatchKeyboardEvent(start.nativeElement, 'keydown', RIGHT_ARROW);
738+
dispatchKeyboardEvent(start.nativeElement, 'keyup', RIGHT_ARROW);
739+
fixture.detectChanges();
740+
expect(document.activeElement).toBe(start.nativeElement);
741+
742+
start.nativeElement.setSelectionRange(10, 10);
743+
dispatchKeyboardEvent(start.nativeElement, 'keydown', LEFT_ARROW);
744+
dispatchKeyboardEvent(start.nativeElement, 'keyup', LEFT_ARROW);
745+
fixture.detectChanges();
746+
expect(document.activeElement).toBe(start.nativeElement);
747+
748+
start.nativeElement.setSelectionRange(10, 10);
749+
dispatchKeyboardEvent(start.nativeElement, 'keydown', RIGHT_ARROW);
750+
dispatchKeyboardEvent(start.nativeElement, 'keyup', RIGHT_ARROW);
751+
fixture.detectChanges();
752+
expect(document.activeElement).toBe(end.nativeElement);
753+
754+
end.nativeElement.setSelectionRange(1, 1);
755+
dispatchKeyboardEvent(end.nativeElement, 'keydown', LEFT_ARROW);
756+
dispatchKeyboardEvent(end.nativeElement, 'keyup', LEFT_ARROW);
757+
fixture.detectChanges();
758+
expect(document.activeElement).toBe(end.nativeElement);
759+
760+
end.nativeElement.setSelectionRange(0, 0);
761+
dispatchKeyboardEvent(end.nativeElement, 'keydown', RIGHT_ARROW);
762+
dispatchKeyboardEvent(end.nativeElement, 'keyup', RIGHT_ARROW);
763+
fixture.detectChanges();
764+
expect(document.activeElement).toBe(end.nativeElement);
765+
766+
end.nativeElement.setSelectionRange(0, 0);
767+
dispatchKeyboardEvent(end.nativeElement, 'keydown', LEFT_ARROW);
768+
dispatchKeyboardEvent(end.nativeElement, 'keyup', LEFT_ARROW);
769+
fixture.detectChanges();
770+
expect(document.activeElement).toBe(start.nativeElement);
771+
});
772+
773+
it('moves focus between fields with arrow keys when cursor is at edge (RTL)', () => {
774+
class RTL extends Directionality {
775+
override readonly value = 'rtl';
776+
}
777+
const fixture = createComponent(
778+
StandardRangePicker,
779+
[],
780+
[
781+
{
782+
provide: Directionality,
783+
useFactory: () => new RTL(null),
784+
},
785+
],
786+
);
787+
fixture.detectChanges();
788+
const {start, end} = fixture.componentInstance;
789+
790+
start.nativeElement.value = '09/10/2020';
791+
end.nativeElement.value = '10/10/2020';
792+
793+
start.nativeElement.focus();
794+
start.nativeElement.setSelectionRange(9, 9);
795+
dispatchKeyboardEvent(start.nativeElement, 'keydown', LEFT_ARROW);
796+
dispatchKeyboardEvent(start.nativeElement, 'keyup', LEFT_ARROW);
797+
fixture.detectChanges();
798+
expect(document.activeElement).toBe(start.nativeElement);
799+
800+
start.nativeElement.setSelectionRange(10, 10);
801+
dispatchKeyboardEvent(start.nativeElement, 'keydown', RIGHT_ARROW);
802+
dispatchKeyboardEvent(start.nativeElement, 'keyup', RIGHT_ARROW);
803+
fixture.detectChanges();
804+
expect(document.activeElement).toBe(start.nativeElement);
805+
806+
start.nativeElement.setSelectionRange(10, 10);
807+
dispatchKeyboardEvent(start.nativeElement, 'keydown', LEFT_ARROW);
808+
dispatchKeyboardEvent(start.nativeElement, 'keyup', LEFT_ARROW);
809+
fixture.detectChanges();
810+
expect(document.activeElement).toBe(end.nativeElement);
811+
812+
end.nativeElement.setSelectionRange(1, 1);
813+
dispatchKeyboardEvent(end.nativeElement, 'keydown', RIGHT_ARROW);
814+
dispatchKeyboardEvent(end.nativeElement, 'keyup', RIGHT_ARROW);
815+
fixture.detectChanges();
816+
expect(document.activeElement).toBe(end.nativeElement);
817+
818+
end.nativeElement.setSelectionRange(0, 0);
819+
dispatchKeyboardEvent(end.nativeElement, 'keydown', LEFT_ARROW);
820+
dispatchKeyboardEvent(end.nativeElement, 'keyup', LEFT_ARROW);
821+
fixture.detectChanges();
822+
expect(document.activeElement).toBe(end.nativeElement);
823+
824+
end.nativeElement.setSelectionRange(0, 0);
825+
dispatchKeyboardEvent(end.nativeElement, 'keydown', RIGHT_ARROW);
826+
dispatchKeyboardEvent(end.nativeElement, 'keyup', RIGHT_ARROW);
827+
fixture.detectChanges();
828+
expect(document.activeElement).toBe(start.nativeElement);
829+
});
830+
724831
it('should be able to get the input placeholder', () => {
725832
const fixture = createComponent(StandardRangePicker);
726833
fixture.detectChanges();

tools/public_api_guard/material/datepicker.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,8 @@ export class MatStartDate<D> extends _MatDateRangeInputBase<D> implements CanUpd
893893
// (undocumented)
894894
protected _getValueFromModel(modelValue: DateRange<D>): D | null;
895895
// (undocumented)
896+
_onKeydown(event: KeyboardEvent): void;
897+
// (undocumented)
896898
protected _shouldHandleChangeEvent(change: DateSelectionModelChange<DateRange<D>>): boolean;
897899
// (undocumented)
898900
protected _validator: ValidatorFn | null;

0 commit comments

Comments
 (0)