From 5397c7e9e82f1ad1554983ac7faf77b86e93aada Mon Sep 17 00:00:00 2001 From: Max Okorokov Date: Thu, 9 Jan 2020 17:02:47 +0100 Subject: [PATCH] feat(datepicker): add 'restoreFocus' input 'restoreFocus' will define re-focusing strategy when closing the input datepicker. ```ts @Input() restoreFocus: true | string | HTMLElement; // defaults to true ``` By default it re-focuses element focused at the moment of datepicker opening. Otherwise accepts either selector or element reference. Fixes #3483 --- .../datepicker-input-config.spec.ts | 1 + src/datepicker/datepicker-input-config.ts | 1 + src/datepicker/datepicker-input.spec.ts | 110 ++++++++++++++++++ src/datepicker/datepicker-input.ts | 24 +++- src/util/util.ts | 4 + 5 files changed, 137 insertions(+), 3 deletions(-) diff --git a/src/datepicker/datepicker-input-config.spec.ts b/src/datepicker/datepicker-input-config.spec.ts index 7df4346ada..b37b06ed4a 100644 --- a/src/datepicker/datepicker-input-config.spec.ts +++ b/src/datepicker/datepicker-input-config.spec.ts @@ -8,5 +8,6 @@ describe('NgbInputDatepickerConfig', () => { expect(config.container).toBeUndefined(); expect(config.positionTarget).toBeUndefined(); expect(config.placement).toEqual(['bottom-left', 'bottom-right', 'top-left', 'top-right']); + expect(config.restoreFocus).toBe(true); }); }); diff --git a/src/datepicker/datepicker-input-config.ts b/src/datepicker/datepicker-input-config.ts index 3ed85c6b31..7392ff6d65 100644 --- a/src/datepicker/datepicker-input-config.ts +++ b/src/datepicker/datepicker-input-config.ts @@ -15,4 +15,5 @@ export class NgbInputDatepickerConfig extends NgbDatepickerConfig { container: null | 'body'; positionTarget: string | HTMLElement; placement: PlacementArray = ['bottom-left', 'bottom-right', 'top-left', 'top-right']; + restoreFocus: true | HTMLElement | string = true; } diff --git a/src/datepicker/datepicker-input.spec.ts b/src/datepicker/datepicker-input.spec.ts index 437b87986c..0be33d94f0 100644 --- a/src/datepicker/datepicker-input.spec.ts +++ b/src/datepicker/datepicker-input.spec.ts @@ -910,6 +910,116 @@ describe('NgbInputDatepicker', () => { })); }); + describe('focus restore', () => { + + function open(fixture: ComponentFixture) { + const dp = fixture.debugElement.query(By.directive(NgbInputDatepicker)).injector.get(NgbInputDatepicker); + dp.open(); + fixture.detectChanges(); + } + + function selectDateAndClose(fixture: ComponentFixture) { + fixture.nativeElement.querySelectorAll('.ngb-dp-day')[3].click(); // 1 MAR 2018 + fixture.detectChanges(); + } + + it('should focus previously focused element', () => { + const fixture = createTestCmpt(` +
+ + `); + + // initial focus + const focusableEl = fixture.nativeElement.querySelector('#focusable'); + focusableEl.focus(); + expect(document.activeElement).toBe(focusableEl); + + open(fixture); + expect(document.activeElement).not.toBe(focusableEl); + + selectDateAndClose(fixture); + expect(document.activeElement).toBe(focusableEl); + }); + + it('should focus using selector provided via [restoreFocus]', () => { + const fixture = createTestCmpt(` +
+ + `); + + const focusableEl = fixture.nativeElement.querySelector('#focusable'); + expect(document.activeElement).not.toBe(focusableEl); + + open(fixture); + expect(document.activeElement).not.toBe(focusableEl); + + selectDateAndClose(fixture); + expect(document.activeElement).toBe(focusableEl); + }); + + it('should focus using element provided via [restoreFocus]', () => { + const fixture = createTestCmpt(` +
+ + `); + + const focusableEl = fixture.nativeElement.querySelector('#focusable'); + expect(document.activeElement).not.toBe(focusableEl); + + open(fixture); + expect(document.activeElement).not.toBe(focusableEl); + + selectDateAndClose(fixture); + expect(document.activeElement).toBe(focusableEl); + }); + + it('should fallback to focused element if [restoreFocus] selector is invalid', () => { + const fixture = createTestCmpt(` +
+ + `); + + const focusableEl = fixture.nativeElement.querySelector('#focusable'); + focusableEl.focus(); + expect(document.activeElement).toBe(focusableEl); + + open(fixture); + expect(document.activeElement).not.toBe(focusableEl); + + selectDateAndClose(fixture); + expect(document.activeElement).toBe(focusableEl); + }); + + it('should fallback to focused element if [restoreFocus] value is invalid', () => { + const fixture = createTestCmpt(` +
+ + `); + + const focusableEl = fixture.nativeElement.querySelector('#focusable'); + focusableEl.focus(); + expect(document.activeElement).toBe(focusableEl); + + open(fixture); + expect(document.activeElement).not.toBe(focusableEl); + + selectDateAndClose(fixture); + expect(document.activeElement).toBe(focusableEl); + }); + + it('should fallback to body if [restoreFocus] value is invalid', () => { + const fixture = createTestCmpt(``); + + expect(document.activeElement).toBe(document.body); + + open(fixture); + expect(document.activeElement).not.toBe(document.body); + + selectDateAndClose(fixture); + expect(document.activeElement).toBe(document.body); + }); + }); + describe('Native adapter', () => { beforeEach(() => { diff --git a/src/datepicker/datepicker-input.ts b/src/datepicker/datepicker-input.ts index 971b7f0abd..bd0f56d08d 100644 --- a/src/datepicker/datepicker-input.ts +++ b/src/datepicker/datepicker-input.ts @@ -33,6 +33,7 @@ import {NgbDateParserFormatter} from './ngb-date-parser-formatter'; import {NgbDateStruct} from './ngb-date-struct'; import {NgbInputDatepickerConfig} from './datepicker-input-config'; import {NgbDatepickerConfig} from './datepicker-config'; +import {isFocusable, isString} from '../util/util'; const NGB_DATEPICKER_VALUE_ACCESSOR = { provide: NG_VALUE_ACCESSOR, @@ -183,6 +184,14 @@ export class NgbInputDatepicker implements OnChanges, */ @Input() placement: PlacementArray; + /** + * If `true`, when closing datepicker will focus element that was focused before datepicker was opened. + * + * Alternatively you could provide an `HTMLElement` or a selector to focus. If the element doesn't exist or not + * focusable, we'll still focus previously focused element. + */ + @Input() restoreFocus: true | string | HTMLElement; + /** * If `true`, weekdays will be displayed. */ @@ -373,7 +382,16 @@ export class NgbInputDatepicker implements OnChanges, this._changeDetector.markForCheck(); // restore focus - const elementToFocus = this._elWithFocus && this._elWithFocus['focus'] ? this._elWithFocus : this._document.body; + let elementToFocus = isFocusable(this._elWithFocus) ? this._elWithFocus : this._document.body; + if (isString(this.restoreFocus)) { + const testElement = this._document.querySelector(this.restoreFocus); + if (isFocusable(testElement)) { + elementToFocus = testElement; + } + } else if (isFocusable(this.restoreFocus)) { + elementToFocus = this.restoreFocus; + } + elementToFocus.focus(); } } @@ -469,8 +487,8 @@ export class NgbInputDatepicker implements OnChanges, } let hostElement: HTMLElement; - if (typeof this.positionTarget === 'string') { - hostElement = window.document.querySelector(this.positionTarget); + if (isString(this.positionTarget)) { + hostElement = this._document.querySelector(this.positionTarget); } else if (this.positionTarget instanceof HTMLElement) { hostElement = this.positionTarget; } else { diff --git a/src/util/util.ts b/src/util/util.ts index eb6470dc5c..6d72c849cb 100644 --- a/src/util/util.ts +++ b/src/util/util.ts @@ -34,6 +34,10 @@ export function padNumber(value: number) { } } +export function isFocusable(element: any) { + return element instanceof HTMLElement && element['focus']; +} + export function regExpEscape(text) { return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); }