diff --git a/src/material/datepicker/datepicker-base.ts b/src/material/datepicker/datepicker-base.ts index d09208929e90..04bbfcd7c59b 100644 --- a/src/material/datepicker/datepicker-base.ts +++ b/src/material/datepicker/datepicker-base.ts @@ -8,7 +8,16 @@ import {Directionality} from '@angular/cdk/bidi'; import {BooleanInput, coerceBooleanProperty, coerceStringArray} from '@angular/cdk/coercion'; -import {ESCAPE, hasModifierKey, UP_ARROW} from '@angular/cdk/keycodes'; +import { + DOWN_ARROW, + ESCAPE, + hasModifierKey, + LEFT_ARROW, + PAGE_DOWN, + PAGE_UP, + RIGHT_ARROW, + UP_ARROW, +} from '@angular/cdk/keycodes'; import { Overlay, OverlayConfig, @@ -657,6 +666,25 @@ export abstract class MatDatepickerBase< this.close(); }); + // The `preventDefault` call happens inside the calendar as well, however focus moves into + // it inside a timeout which can give browsers a chance to fire off a keyboard event in-between + // that can scroll the page (see #24969). Always block default actions of arrow keys for the + // entire overlay so the page doesn't get scrolled by accident. + overlayRef.keydownEvents().subscribe(event => { + const keyCode = event.keyCode; + + if ( + keyCode === UP_ARROW || + keyCode === DOWN_ARROW || + keyCode === LEFT_ARROW || + keyCode === RIGHT_ARROW || + keyCode === PAGE_UP || + keyCode === PAGE_DOWN + ) { + event.preventDefault(); + } + }); + this._componentRef = overlayRef.attach(portal); this._forwardContentValues(this._componentRef.instance); diff --git a/src/material/datepicker/datepicker.spec.ts b/src/material/datepicker/datepicker.spec.ts index accef0d8ee6d..035cc5d45171 100644 --- a/src/material/datepicker/datepicker.spec.ts +++ b/src/material/datepicker/datepicker.spec.ts @@ -1,5 +1,14 @@ import {Directionality} from '@angular/cdk/bidi'; -import {DOWN_ARROW, ENTER, ESCAPE, RIGHT_ARROW, UP_ARROW} from '@angular/cdk/keycodes'; +import { + DOWN_ARROW, + ENTER, + ESCAPE, + LEFT_ARROW, + PAGE_DOWN, + PAGE_UP, + RIGHT_ARROW, + UP_ARROW, +} from '@angular/cdk/keycodes'; import {Overlay} from '@angular/cdk/overlay'; import {ScrollDispatcher} from '@angular/cdk/scrolling'; import { @@ -605,6 +614,24 @@ describe('MatDatepicker', () => { expect(document.querySelector('.mat-datepicker-content')).toBeNull(); })); + + it('should prevent the default action of navigation keys before the focus timeout has elapsed', fakeAsync(() => { + testComponent.datepicker.open(); + fixture.detectChanges(); + + // Do the assertions before flushing the delays since we want + // to check specifically what happens before they have fired. + [UP_ARROW, DOWN_ARROW, LEFT_ARROW, RIGHT_ARROW, PAGE_UP, PAGE_DOWN].forEach(keyCode => { + const event = dispatchKeyboardEvent(document.body, 'keydown', keyCode); + fixture.detectChanges(); + expect(event.defaultPrevented) + .withContext(`Expected default action to be prevented for key code ${keyCode}`) + .toBe(true); + }); + + tick(); + flush(); + })); }); describe('datepicker with too many inputs', () => {