From e5b32224500d5292cbfcbbd99a84da28fe44976f Mon Sep 17 00:00:00 2001 From: gpolychronis-amadeus Date: Mon, 7 Oct 2019 16:01:33 +0200 Subject: [PATCH] feat(datepicker): export NgbDatepickerKeyboardService --- .../datepicker/datepicker.module.ts | 9 ++ .../demos/keyboard/datepicker-keyboard.html | 3 + .../keyboard/datepicker-keyboard.module.ts | 15 ++ .../demos/keyboard/datepicker-keyboard.ts | 49 +++++++ .../datepicker-overview.component.html | 6 + src/datepicker/datepicker-integration.spec.ts | 65 ++++++++- .../datepicker-keyboard-service.spec.ts | 130 +++++++++++++++++ src/datepicker/datepicker-keyboard-service.ts | 54 +++++++ .../datepicker-keymap-service.spec.ts | 133 ------------------ src/datepicker/datepicker-keymap-service.ts | 62 -------- src/datepicker/datepicker-service.spec.ts | 53 ++----- src/datepicker/datepicker-service.ts | 4 - src/datepicker/datepicker.module.ts | 3 +- src/datepicker/datepicker.spec.ts | 80 ++++++++++- src/datepicker/datepicker.ts | 66 ++++++++- src/index.ts | 2 + 16 files changed, 488 insertions(+), 246 deletions(-) create mode 100644 demo/src/app/components/datepicker/demos/keyboard/datepicker-keyboard.html create mode 100644 demo/src/app/components/datepicker/demos/keyboard/datepicker-keyboard.module.ts create mode 100644 demo/src/app/components/datepicker/demos/keyboard/datepicker-keyboard.ts create mode 100644 src/datepicker/datepicker-keyboard-service.spec.ts create mode 100644 src/datepicker/datepicker-keyboard-service.ts delete mode 100644 src/datepicker/datepicker-keymap-service.spec.ts delete mode 100644 src/datepicker/datepicker-keymap-service.ts diff --git a/demo/src/app/components/datepicker/datepicker.module.ts b/demo/src/app/components/datepicker/datepicker.module.ts index 67aab84c39..92911bd424 100644 --- a/demo/src/app/components/datepicker/datepicker.module.ts +++ b/demo/src/app/components/datepicker/datepicker.module.ts @@ -35,6 +35,8 @@ import { NgbdDatepickerOverviewComponent } from './overview/datepicker-overview. import { NgbdDatepickerOverviewDemoComponent } from './overview/demo/datepicker-overview-demo.component'; import { NgbdDatepickerPositiontargetModule } from './demos/positiontarget/datepicker-position-target.module'; import { NgbdDatepickerPositiontarget } from './demos/positiontarget/datepicker-positiontarget'; +import { NgbdDatepickerKeyboard } from './demos/keyboard/datepicker-keyboard'; +import { NgbdDatepickerKeyboardModule } from './demos/keyboard/datepicker-keyboard.module'; const OVERVIEW = { 'basic-usage': 'Basic Usage', @@ -117,6 +119,12 @@ const DEMOS = { code: require('!!raw-loader!./demos/positiontarget/datepicker-positiontarget').default, markup: require('!!raw-loader!./demos/positiontarget/datepicker-positiontarget.html').default }, + keyboard: { + title: 'Custom keyboard navigation', + type: NgbdDatepickerKeyboard, + code: require('!!raw-loader!./demos/keyboard/datepicker-keyboard').default, + markup: require('!!raw-loader!./demos/keyboard/datepicker-keyboard.html').default + }, config: { title: 'Global configuration of datepickers', type: NgbdDatepickerConfig, @@ -156,6 +164,7 @@ export const ROUTES = [ NgbdDatepickerRangeModule, NgbdDatepickerRangePopupModule, NgbdDatepickerAdapterModule, + NgbdDatepickerKeyboardModule, ...DEMO_CALENDAR_MODULES ], declarations: [ diff --git a/demo/src/app/components/datepicker/demos/keyboard/datepicker-keyboard.html b/demo/src/app/components/datepicker/demos/keyboard/datepicker-keyboard.html new file mode 100644 index 0000000000..6a1b898173 --- /dev/null +++ b/demo/src/app/components/datepicker/demos/keyboard/datepicker-keyboard.html @@ -0,0 +1,3 @@ +

This datepicker uses alt instead of shift keyboard shortcuts.

+ + diff --git a/demo/src/app/components/datepicker/demos/keyboard/datepicker-keyboard.module.ts b/demo/src/app/components/datepicker/demos/keyboard/datepicker-keyboard.module.ts new file mode 100644 index 0000000000..34d43f8d3e --- /dev/null +++ b/demo/src/app/components/datepicker/demos/keyboard/datepicker-keyboard.module.ts @@ -0,0 +1,15 @@ +import {NgModule} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {BrowserModule} from '@angular/platform-browser'; +import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; + +import {NgbdDatepickerKeyboard} from './datepicker-keyboard'; + +@NgModule({ + imports: [BrowserModule, FormsModule, NgbModule], + declarations: [NgbdDatepickerKeyboard], + exports: [NgbdDatepickerKeyboard], + bootstrap: [NgbdDatepickerKeyboard] +}) +export class NgbdDatepickerKeyboardModule { +} diff --git a/demo/src/app/components/datepicker/demos/keyboard/datepicker-keyboard.ts b/demo/src/app/components/datepicker/demos/keyboard/datepicker-keyboard.ts new file mode 100644 index 0000000000..f2b178e6b4 --- /dev/null +++ b/demo/src/app/components/datepicker/demos/keyboard/datepicker-keyboard.ts @@ -0,0 +1,49 @@ +import {Component, Injectable} from '@angular/core'; +import { + NgbCalendar, + NgbDatepicker, + NgbDatepickerKeyboardService, + NgbDateStruct +} from '@ng-bootstrap/ng-bootstrap'; + +const Key = { + PageUp: 'PageUp', + PageDown: 'PageDown', + End: 'End', + Home: 'Home' +}; + +@Injectable() +export class CustomKeyboardService extends NgbDatepickerKeyboardService { + processKey(event: KeyboardEvent, dp: NgbDatepicker, calendar: NgbCalendar) { + const state = dp.state; + switch (event.code) { + case Key.PageUp: + dp.focusDate(calendar.getNext(state.focusDate, event.altKey ? 'y' : 'm', -1)); + break; + case Key.PageDown: + dp.focusDate(calendar.getNext(state.focusDate, event.altKey ? 'y' : 'm', 1)); + break; + case Key.End: + dp.focusDate(event.altKey ? state.maxDate : state.lastDate); + break; + case Key.Home: + dp.focusDate(event.altKey ? state.minDate : state.firstDate); + break; + default: + super.processKey(event, dp, calendar); + return; + } + event.preventDefault(); + event.stopPropagation(); + } +} + +@Component({ + selector: 'ngbd-datepicker-keyboard', + templateUrl: './datepicker-keyboard.html', + providers: [{provide: NgbDatepickerKeyboardService, useClass: CustomKeyboardService}] +}) +export class NgbdDatepickerKeyboard { + model: NgbDateStruct; +} diff --git a/demo/src/app/components/datepicker/overview/datepicker-overview.component.html b/demo/src/app/components/datepicker/overview/datepicker-overview.component.html index 29335dd7c0..bdcea5df99 100644 --- a/demo/src/app/components/datepicker/overview/datepicker-overview.component.html +++ b/demo/src/app/components/datepicker/overview/datepicker-overview.component.html @@ -322,6 +322,12 @@

Input date parsing and formatting

+ +

+ You can customize keyboard navigation as in the custom keyboard + navigation example. The default keys are as follows: +

+ diff --git a/src/datepicker/datepicker-integration.spec.ts b/src/datepicker/datepicker-integration.spec.ts index 07768c7aa1..072323114a 100644 --- a/src/datepicker/datepicker-integration.spec.ts +++ b/src/datepicker/datepicker-integration.spec.ts @@ -1,10 +1,14 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; -import {Component, Injectable} from '@angular/core'; +import {Component, Injectable, Type} from '@angular/core'; +import {By} from '@angular/platform-browser'; import {NgbDatepickerModule, NgbDateStruct} from './datepicker.module'; import {NgbCalendar, NgbCalendarGregorian} from './ngb-calendar'; import {NgbDate} from './ngb-date'; import {getMonthSelect, getYearSelect} from '../test/datepicker/common'; import {NgbDatepickerI18n, NgbDatepickerI18nDefault} from './datepicker-i18n'; +import {NgbDatepicker} from './datepicker'; +import {NgbDatepickerKeyboardService} from './datepicker-keyboard-service'; +import {Key} from '../util/key'; describe('ngb-datepicker integration', () => { @@ -107,6 +111,65 @@ describe('ngb-datepicker integration', () => { expect(monthNames).toEqual(['A 8102', 'B 8102']); }); }); + + describe('keyboard service', () => { + + @Injectable() + class CustomKeyboardService extends NgbDatepickerKeyboardService { + processKey(event: KeyboardEvent, service: NgbDatepicker, calendar: NgbCalendar) { + const state = service.state; + // tslint:disable-next-line:deprecation + switch (event.which) { + case Key.PageUp: + service.focusDate(calendar.getNext(state.focusDate, event.altKey ? 'y' : 'm', -1)); + break; + case Key.PageDown: + service.focusDate(calendar.getNext(state.focusDate, event.altKey ? 'y' : 'm', 1)); + break; + default: + super.processKey(event, service, calendar); + return; + } + event.preventDefault(); + event.stopPropagation(); + } + } + + let fixture: ComponentFixture; + let dp: NgbDatepicker; + let ngbCalendar: NgbCalendar; + let startDate: NgbDateStruct = new NgbDate(2018, 1, 1); + + beforeEach(() => { + TestBed.overrideComponent(TestComponent, { + set: { + template: ` + `, + providers: [{provide: NgbDatepickerKeyboardService, useClass: CustomKeyboardService}] + } + }); + + fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + + dp = fixture.debugElement.query(By.css('ngb-datepicker')).injector.get(NgbDatepicker); + ngbCalendar = fixture.debugElement.query(By.css('ngb-datepicker')).injector.get(NgbCalendar as Type); + + spyOn(ngbCalendar, 'getNext'); + }); + + it('should allow customize keyboard navigation', () => { + dp.onKeyDown({which: Key.PageUp, altKey: true, preventDefault: () => {}, stopPropagation: () => {}}); + expect(ngbCalendar.getNext).toHaveBeenCalledWith(startDate, 'y', -1); + dp.onKeyDown({which: Key.PageUp, shiftKey: true, preventDefault: () => {}, stopPropagation: () => {}}); + expect(ngbCalendar.getNext).toHaveBeenCalledWith(startDate, 'm', -1); + }); + + it('should allow access to default keyboard navigation', () => { + dp.onKeyDown({which: Key.ArrowUp, altKey: true, preventDefault: () => {}, stopPropagation: () => {}}); + expect(ngbCalendar.getNext).toHaveBeenCalledWith(startDate, 'd', -7); + }); + }); }); @Component({selector: 'test-cmp', template: ''}) diff --git a/src/datepicker/datepicker-keyboard-service.spec.ts b/src/datepicker/datepicker-keyboard-service.spec.ts new file mode 100644 index 0000000000..90e519428f --- /dev/null +++ b/src/datepicker/datepicker-keyboard-service.spec.ts @@ -0,0 +1,130 @@ +import {NgbDatepicker, NgbDatepickerState} from './datepicker'; +import {NgbDatepickerKeyboardService} from './datepicker-keyboard-service'; +import {NgbCalendar, NgbCalendarGregorian} from './ngb-calendar'; +import {TestBed} from '@angular/core/testing'; +import {NgbDate} from './ngb-date'; +import {Key} from '../util/key'; +import {Type} from '@angular/core'; + +const event = (keyCode: number, shift = false) => + ({which: keyCode, shiftKey: shift, preventDefault: () => {}, stopPropagation: () => {}}); + +describe('ngb-datepicker-keyboard-service', () => { + + let service: NgbDatepickerKeyboardService; + let calendar: NgbCalendar; + let mock: Partial; + let processKey = function(e: KeyboardEvent) { service.processKey(e, mock as NgbDatepicker, calendar); }; + let state: NgbDatepickerState = Object.assign({focusDate: {day: 1, month: 1, year: 2018}}); + + beforeEach(() => { + TestBed.configureTestingModule( + {providers: [{provide: NgbCalendar, useClass: NgbCalendarGregorian}, NgbDatepickerKeyboardService]}); + + calendar = TestBed.get(NgbCalendar as Type); + service = TestBed.get(NgbDatepickerKeyboardService); + mock = {state, focusDate: () => {}, focusSelect: () => {}}; + + spyOn(mock, 'focusDate'); + spyOn(mock, 'focusSelect'); + spyOn(calendar, 'getNext'); + }); + + it('should be instantiated', () => { expect(service).toBeTruthy(); }); + + it('should move focus by 1 day or 1 week with "Arrow" keys', () => { + processKey(event(Key.ArrowUp)); + expect(calendar.getNext).toHaveBeenCalledWith(state.focusDate, 'd', -7); + + processKey(event(Key.ArrowDown)); + expect(calendar.getNext).toHaveBeenCalledWith(state.focusDate, 'd', 7); + + processKey(event(Key.ArrowLeft)); + expect(calendar.getNext).toHaveBeenCalledWith(state.focusDate, 'd', -1); + + processKey(event(Key.ArrowRight)); + expect(calendar.getNext).toHaveBeenCalledWith(state.focusDate, 'd', 1); + + expect(calendar.getNext).toHaveBeenCalledTimes(4); + }); + + it('should move focus by 1 month or year "PgUp" and "PageDown"', () => { + processKey(event(Key.PageUp)); + expect(calendar.getNext).toHaveBeenCalledWith(state.focusDate, 'm', -1); + + processKey(event(Key.PageDown)); + expect(calendar.getNext).toHaveBeenCalledWith(state.focusDate, 'm', 1); + + processKey(event(Key.PageUp, true)); + expect(calendar.getNext).toHaveBeenCalledWith(state.focusDate, 'y', -1); + + processKey(event(Key.PageDown, true)); + expect(calendar.getNext).toHaveBeenCalledWith(state.focusDate, 'y', 1); + + expect(calendar.getNext).toHaveBeenCalledTimes(4); + }); + + it('should select focused date with "Space" and "Enter"', () => { + processKey(event(Key.Enter)); + processKey(event(Key.Space)); + expect(mock.focusSelect).toHaveBeenCalledTimes(2); + }); + + it('should move focus to the first and last days in the view with "Home" and "End"', () => { + processKey(event(Key.Home)); + expect(mock.focusDate).toHaveBeenCalledWith(undefined); + + processKey(event(Key.End)); + expect(mock.focusDate).toHaveBeenCalledWith(undefined); + + Object.assign(state, {firstDate: new NgbDate(2017, 1, 1)}); + Object.assign(state, {lastDate: new NgbDate(2017, 12, 1)}); + + processKey(event(Key.Home)); + expect(mock.focusDate).toHaveBeenCalledWith(new NgbDate(2017, 1, 1)); + + processKey(event(Key.End)); + expect(mock.focusDate).toHaveBeenCalledWith(new NgbDate(2017, 12, 1)); + + expect(mock.focusDate).toHaveBeenCalledTimes(4); + }); + + it('should move focus to the "min" and "max" dates with "Home" and "End"', () => { + processKey(event(Key.Home, true)); + expect(mock.focusDate).toHaveBeenCalledWith(undefined); + + processKey(event(Key.End, true)); + expect(mock.focusDate).toHaveBeenCalledWith(undefined); + + Object.assign(state, {minDate: new NgbDate(2017, 1, 1)}); + Object.assign(state, {maxDate: new NgbDate(2017, 12, 1)}); + + processKey(event(Key.Home, true)); + expect(mock.focusDate).toHaveBeenCalledWith(new NgbDate(2017, 1, 1)); + + processKey(event(Key.End, true)); + expect(mock.focusDate).toHaveBeenCalledWith(new NgbDate(2017, 12, 1)); + + expect(mock.focusDate).toHaveBeenCalledTimes(4); + }); + + it('should prevent default and stop propagation of the known key', () => { + let e = event(Key.ArrowUp); + spyOn(e, 'preventDefault'); + spyOn(e, 'stopPropagation'); + + processKey(e); + expect(e.preventDefault).toHaveBeenCalled(); + expect(e.stopPropagation).toHaveBeenCalled(); + + // unknown key + e = event(23); + spyOn(e, 'preventDefault'); + spyOn(e, 'stopPropagation'); + + processKey(e); + expect(e.preventDefault).not.toHaveBeenCalled(); + expect(e.stopPropagation).not.toHaveBeenCalled(); + }); + +}); diff --git a/src/datepicker/datepicker-keyboard-service.ts b/src/datepicker/datepicker-keyboard-service.ts new file mode 100644 index 0000000000..a35f22eecd --- /dev/null +++ b/src/datepicker/datepicker-keyboard-service.ts @@ -0,0 +1,54 @@ +import {Injectable} from '@angular/core'; +import {NgbCalendar} from './ngb-calendar'; +import {NgbDatepicker} from './datepicker'; +import {Key} from '../util/key'; + +/** + * A service that represents the keyboard navigation. + * + * The default navigation is documented in the overview. + */ +@Injectable({providedIn: 'root'}) +export class NgbDatepickerKeyboardService { + /** + * Processes a keyboard event. + */ + processKey(event: KeyboardEvent, datepicker: NgbDatepicker, calendar: NgbCalendar) { + const state = datepicker.state; + // tslint:disable-next-line:deprecation + switch (event.which) { + case Key.PageUp: + datepicker.focusDate(calendar.getNext(state.focusDate, event.shiftKey ? 'y' : 'm', -1)); + break; + case Key.PageDown: + datepicker.focusDate(calendar.getNext(state.focusDate, event.shiftKey ? 'y' : 'm', 1)); + break; + case Key.End: + datepicker.focusDate(event.shiftKey ? state.maxDate : state.lastDate); + break; + case Key.Home: + datepicker.focusDate(event.shiftKey ? state.minDate : state.firstDate); + break; + case Key.ArrowLeft: + datepicker.focusDate(calendar.getNext(state.focusDate, 'd', -1)); + break; + case Key.ArrowUp: + datepicker.focusDate(calendar.getNext(state.focusDate, 'd', -calendar.getDaysPerWeek())); + break; + case Key.ArrowRight: + datepicker.focusDate(calendar.getNext(state.focusDate, 'd', 1)); + break; + case Key.ArrowDown: + datepicker.focusDate(calendar.getNext(state.focusDate, 'd', calendar.getDaysPerWeek())); + break; + case Key.Enter: + case Key.Space: + datepicker.focusSelect(); + break; + default: + return; + } + event.preventDefault(); + event.stopPropagation(); + } +} diff --git a/src/datepicker/datepicker-keymap-service.spec.ts b/src/datepicker/datepicker-keymap-service.spec.ts deleted file mode 100644 index a3269ae813..0000000000 --- a/src/datepicker/datepicker-keymap-service.spec.ts +++ /dev/null @@ -1,133 +0,0 @@ -import {NgbDatepickerKeyMapService} from './datepicker-keymap-service'; -import {NgbCalendar, NgbCalendarGregorian} from './ngb-calendar'; -import {NgbDatepickerService} from './datepicker-service'; -import {TestBed} from '@angular/core/testing'; -import {Subject} from 'rxjs'; -import {NgbDate} from './ngb-date'; -import {Key} from '../util/key'; -import {Type} from '@angular/core'; - -const event = (keyCode: number, shift = false) => - ({which: keyCode, shiftKey: shift, preventDefault: () => {}, stopPropagation: () => {}}); - -describe('ngb-datepicker-keymap-service', () => { - - let service: NgbDatepickerKeyMapService; - let calendar: NgbCalendar; - let mock: {focus, focusMove, focusSelect, model$}; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - NgbDatepickerKeyMapService, {provide: NgbCalendar, useClass: NgbCalendarGregorian}, { - provide: NgbDatepickerService, - useValue: {focus: () => {}, focusMove: () => {}, focusSelect: () => {}, model$: new Subject()} - } - ] - }); - - calendar = TestBed.get(NgbCalendar as Type); - service = TestBed.get(NgbDatepickerKeyMapService); - mock = TestBed.get(NgbDatepickerService); - - spyOn(mock, 'focus'); - spyOn(mock, 'focusMove'); - spyOn(mock, 'focusSelect'); - }); - - it('should be instantiated', () => { expect(service).toBeTruthy(); }); - - it('should move focus by 1 day or 1 week with "Arrow" keys', () => { - service.processKey(event(Key.ArrowUp)); - expect(mock.focusMove).toHaveBeenCalledWith('d', -7); - - service.processKey(event(Key.ArrowDown)); - expect(mock.focusMove).toHaveBeenCalledWith('d', 7); - - service.processKey(event(Key.ArrowLeft)); - expect(mock.focusMove).toHaveBeenCalledWith('d', -1); - - service.processKey(event(Key.ArrowRight)); - expect(mock.focusMove).toHaveBeenCalledWith('d', 1); - - expect(mock.focusMove).toHaveBeenCalledTimes(4); - }); - - it('should move focus by 1 month or year "PgUp" and "PageDown"', () => { - service.processKey(event(Key.PageUp)); - expect(mock.focusMove).toHaveBeenCalledWith('m', -1); - - service.processKey(event(Key.PageDown)); - expect(mock.focusMove).toHaveBeenCalledWith('m', 1); - - service.processKey(event(Key.PageUp, true)); - expect(mock.focusMove).toHaveBeenCalledWith('y', -1); - - service.processKey(event(Key.PageDown, true)); - expect(mock.focusMove).toHaveBeenCalledWith('y', 1); - - expect(mock.focusMove).toHaveBeenCalledTimes(4); - }); - - it('should select focused date with "Space" and "Enter"', () => { - service.processKey(event(Key.Enter)); - service.processKey(event(Key.Space)); - expect(mock.focusSelect).toHaveBeenCalledTimes(2); - }); - - it('should move focus to the first and last days in the view with "Home" and "End"', () => { - service.processKey(event(Key.Home)); - expect(mock.focus).toHaveBeenCalledWith(undefined); - - service.processKey(event(Key.End)); - expect(mock.focus).toHaveBeenCalledWith(undefined); - - mock.model$.next({firstDate: new NgbDate(2017, 1, 1), lastDate: new NgbDate(2017, 12, 1)}); - - service.processKey(event(Key.Home)); - expect(mock.focus).toHaveBeenCalledWith(new NgbDate(2017, 1, 1)); - - service.processKey(event(Key.End)); - expect(mock.focus).toHaveBeenCalledWith(new NgbDate(2017, 12, 1)); - - expect(mock.focus).toHaveBeenCalledTimes(4); - }); - - it('should move focus to the "min" and "max" dates with "Home" and "End"', () => { - service.processKey(event(Key.Home, true)); - expect(mock.focus).toHaveBeenCalledWith(undefined); - - service.processKey(event(Key.End, true)); - expect(mock.focus).toHaveBeenCalledWith(undefined); - - mock.model$.next({minDate: new NgbDate(2017, 1, 1), maxDate: new NgbDate(2017, 12, 1), months: []}); - - service.processKey(event(Key.Home, true)); - expect(mock.focus).toHaveBeenCalledWith(new NgbDate(2017, 1, 1)); - - service.processKey(event(Key.End, true)); - expect(mock.focus).toHaveBeenCalledWith(new NgbDate(2017, 12, 1)); - - expect(mock.focus).toHaveBeenCalledTimes(4); - }); - - it('should prevent default and stop propagation of the known key', () => { - let e = event(Key.ArrowUp); - spyOn(e, 'preventDefault'); - spyOn(e, 'stopPropagation'); - - service.processKey(e); - expect(e.preventDefault).toHaveBeenCalled(); - expect(e.stopPropagation).toHaveBeenCalled(); - - // unknown key - e = event(23); - spyOn(e, 'preventDefault'); - spyOn(e, 'stopPropagation'); - - service.processKey(e); - expect(e.preventDefault).not.toHaveBeenCalled(); - expect(e.stopPropagation).not.toHaveBeenCalled(); - }); - -}); diff --git a/src/datepicker/datepicker-keymap-service.ts b/src/datepicker/datepicker-keymap-service.ts deleted file mode 100644 index 0bf6769a25..0000000000 --- a/src/datepicker/datepicker-keymap-service.ts +++ /dev/null @@ -1,62 +0,0 @@ -import {Injectable} from '@angular/core'; -import {NgbDatepickerService} from './datepicker-service'; -import {NgbCalendar} from './ngb-calendar'; -import {Key} from '../util/key'; -import {NgbDate} from './ngb-date'; - -@Injectable() -export class NgbDatepickerKeyMapService { - private _minDate: NgbDate; - private _maxDate: NgbDate; - private _firstViewDate: NgbDate; - private _lastViewDate: NgbDate; - - constructor(private _service: NgbDatepickerService, private _calendar: NgbCalendar) { - _service.model$.subscribe(model => { - this._minDate = model.minDate; - this._maxDate = model.maxDate; - this._firstViewDate = model.firstDate; - this._lastViewDate = model.lastDate; - }); - } - - processKey(event: KeyboardEvent) { - // tslint:disable-next-line:deprecation - switch (event.which) { - case Key.PageUp: - this._service.focusMove(event.shiftKey ? 'y' : 'm', -1); - break; - case Key.PageDown: - this._service.focusMove(event.shiftKey ? 'y' : 'm', 1); - break; - case Key.End: - this._service.focus(event.shiftKey ? this._maxDate : this._lastViewDate); - break; - case Key.Home: - this._service.focus(event.shiftKey ? this._minDate : this._firstViewDate); - break; - case Key.ArrowLeft: - this._service.focusMove('d', -1); - break; - case Key.ArrowUp: - this._service.focusMove('d', -this._calendar.getDaysPerWeek()); - break; - case Key.ArrowRight: - this._service.focusMove('d', 1); - break; - case Key.ArrowDown: - this._service.focusMove('d', this._calendar.getDaysPerWeek()); - break; - case Key.Enter: - case Key.Space: - this._service.focusSelect(); - break; - default: - return; - } - - // note 'return' in default case - event.preventDefault(); - event.stopPropagation(); - } -} diff --git a/src/datepicker/datepicker-service.spec.ts b/src/datepicker/datepicker-service.spec.ts index 1a21f310a4..00a98d89cf 100644 --- a/src/datepicker/datepicker-service.spec.ts +++ b/src/datepicker/datepicker-service.spec.ts @@ -1,6 +1,6 @@ import {TestBed} from '@angular/core/testing'; import {NgbDatepickerService} from './datepicker-service'; -import {NgbCalendar, NgbCalendarGregorian} from './ngb-calendar'; +import {NgbCalendar, NgbCalendarGregorian, NgbPeriod} from './ngb-calendar'; import {NgbDate} from './ngb-date'; import {Subscription} from 'rxjs'; import {DatepickerViewModel} from './datepicker-view-model'; @@ -15,6 +15,7 @@ describe('ngb-datepicker-service', () => { let mock: {onNext}; let selectDate: NgbDate; let mockSelect: {onNext}; + let focusMove: (NgbDate, NgbPeriod?, number?) => void; let subscriptions: Subscription[]; @@ -35,6 +36,8 @@ describe('ngb-datepicker-service', () => { subscriptions = []; model = undefined; selectDate = null; + focusMove = (focusDate: NgbDate, period?: NgbPeriod, number?: number) => + service.focus(calendar.getNext(focusDate, period, number)); mock = {onNext: () => {}}; spyOn(mock, 'onNext'); @@ -306,12 +309,13 @@ describe('ngb-datepicker-service', () => { it(`should change the tabindex when changing the current month`, () => { service.displayMonths = 2; - service.focus(new NgbDate(2018, 3, 31)); + const date = new NgbDate(2018, 3, 31); + service.focus(date); expect(getDay(5, 4, 0).tabindex).toEqual(0); // 31 march in the first month block expect(getDay(5, 0, 1).tabindex).toEqual(-1); // 31 march in the second month block - service.focusMove('d', 1); + focusMove(date, 'd', 1); expect(getDay(5, 4, 0).tabindex).toEqual(-1); // 31 march in the first month block expect(getDay(5, 0, 1).tabindex).toEqual(-1); // 31 march in the second month block expect(getDay(6, 4, 0).tabindex).toEqual(-1); // 1st april in the first month block @@ -321,12 +325,13 @@ describe('ngb-datepicker-service', () => { it(`should set the aria-label when changing the current month`, () => { service.displayMonths = 2; - service.focus(new NgbDate(2018, 3, 31)); + const date = new NgbDate(2018, 3, 31); + service.focus(date); expect(getDay(5, 4, 0).ariaLabel).toEqual('Saturday, March 31, 2018'); // 31 march in the first month block expect(getDay(5, 0, 1).ariaLabel).toEqual('Saturday, March 31, 2018'); // 31 march in the second month block - service.focusMove('d', 1); + focusMove(date, 'd', 1); expect(getDay(5, 4, 0).ariaLabel).toEqual('Saturday, March 31, 2018'); // 31 march in the first month block expect(getDay(5, 0, 1).ariaLabel).toEqual('Saturday, March 31, 2018'); // 31 march in the second month block expect(getDay(6, 4, 0).ariaLabel).toEqual('Sunday, April 1, 2018'); // 1st april in the first month block @@ -364,7 +369,7 @@ describe('ngb-datepicker-service', () => { expect(model.focusDate).toEqual(today); // focusMove - service.focusMove('d', 1); // nope + focusMove(today, 'd', 1); // nope expect(model.focusDate).toEqual(today); expect(mock.onNext).toHaveBeenCalledTimes(2); @@ -1009,37 +1014,6 @@ describe('ngb-datepicker-service', () => { expect(mock.onNext).toHaveBeenCalledTimes(3); }); - it(`should move focus with 'focusMove()'`, () => { - const date = new NgbDate(2017, 5, 5); - - // days - service.focus(date); - service.focusMove('d', 1); - expect(model.focusDate).toEqual(new NgbDate(2017, 5, 6)); - - service.focus(date); - service.focusMove('d', -1); - expect(model.focusDate).toEqual(new NgbDate(2017, 5, 4)); - - // months - service.focus(date); - service.focusMove('m', 1); - expect(model.focusDate).toEqual(new NgbDate(2017, 6, 5)); - - service.focus(date); - service.focusMove('m', -1); - expect(model.focusDate).toEqual(new NgbDate(2017, 4, 5)); - - // years - service.focus(date); - service.focusMove('y', 1); - expect(model.focusDate).toEqual(new NgbDate(2018, 5, 5)); - - service.focus(date); - service.focusMove('y', -1); - expect(model.focusDate).toEqual(new NgbDate(2016, 5, 5)); - }); - it(`should move focus when 'minDate' changes`, () => { service.focus(new NgbDate(2017, 5, 5)); service.maxDate = new NgbDate(2017, 5, 1); @@ -1324,7 +1298,8 @@ describe('ngb-datepicker-service', () => { it(`should update 'focused' flag and tabindex for day template`, () => { // off - service.focus(new NgbDate(2017, 5, 1)); + const date = new NgbDate(2017, 5, 1); + service.focus(date); expect(getDayCtx(0).focused).toBeFalsy(); expect(getDayCtx(1).focused).toBeFalsy(); expect(getDay(0).tabindex).toEqual(0); @@ -1338,7 +1313,7 @@ describe('ngb-datepicker-service', () => { expect(getDay(1).tabindex).toEqual(-1); // move - service.focusMove('d', 1); + focusMove(date, 'd', 1); expect(getDayCtx(0).focused).toBeFalsy(); expect(getDayCtx(1).focused).toBeTruthy(); expect(getDay(0).tabindex).toEqual(-1); diff --git a/src/datepicker/datepicker-service.ts b/src/datepicker/datepicker-service.ts index 88b0665bfe..9aa948a370 100644 --- a/src/datepicker/datepicker-service.ts +++ b/src/datepicker/datepicker-service.ts @@ -117,10 +117,6 @@ export class NgbDatepickerService { } } - focusMove(period?: NgbPeriod, number?: number) { - this.focus(this._calendar.getNext(this._state.focusDate, period, number)); - } - focusSelect() { if (isDateSelectable(this._state.focusDate, this._state)) { this.select(this._state.focusDate, {emitEvent: true}); diff --git a/src/datepicker/datepicker.module.ts b/src/datepicker/datepicker.module.ts index a4455b2d3e..850f9ee9b3 100644 --- a/src/datepicker/datepicker.module.ts +++ b/src/datepicker/datepicker.module.ts @@ -8,7 +8,7 @@ import {NgbInputDatepicker} from './datepicker-input'; import {NgbDatepickerDayView} from './datepicker-day-view'; import {NgbDatepickerNavigationSelect} from './datepicker-navigation-select'; -export {NgbDatepicker, NgbDatepickerNavigateEvent} from './datepicker'; +export {NgbDatepicker, NgbDatepickerNavigateEvent, NgbDatepickerState} from './datepicker'; export {NgbInputDatepicker} from './datepicker-input'; export {NgbCalendar, NgbPeriod, NgbCalendarGregorian} from './ngb-calendar'; export {NgbCalendarIslamicCivil} from './hijri/ngb-calendar-islamic-civil'; @@ -28,6 +28,7 @@ export {NgbDateAdapter} from './adapters/ngb-date-adapter'; export {NgbDateNativeAdapter} from './adapters/ngb-date-native-adapter'; export {NgbDateNativeUTCAdapter} from './adapters/ngb-date-native-utc-adapter'; export {NgbDateParserFormatter} from './ngb-date-parser-formatter'; +export {NgbDatepickerKeyboardService} from './datepicker-keyboard-service'; @NgModule({ declarations: [ diff --git a/src/datepicker/datepicker.spec.ts b/src/datepicker/datepicker.spec.ts index cde4bba145..30fcbd95cb 100644 --- a/src/datepicker/datepicker.spec.ts +++ b/src/datepicker/datepicker.spec.ts @@ -6,14 +6,16 @@ import {Component, TemplateRef, DebugElement} from '@angular/core'; import {By} from '@angular/platform-browser'; import {FormsModule, ReactiveFormsModule, FormGroup, FormControl, Validators} from '@angular/forms'; +import {NgbCalendar} from './ngb-calendar'; import {NgbDatepickerModule, NgbDatepickerNavigateEvent} from './datepicker.module'; import {NgbDate} from './ngb-date'; import {NgbDatepickerConfig} from './datepicker-config'; -import {NgbDatepicker} from './datepicker'; +import {NgbDatepicker, NgbDatepickerState} from './datepicker'; import {DayTemplateContext} from './datepicker-day-template-context'; import {NgbDateStruct} from './ngb-date-struct'; import {NgbDatepickerMonthView} from './datepicker-month-view'; import {NgbDatepickerDayView} from './datepicker-day-view'; +import {NgbDatepickerKeyboardService} from './datepicker-keyboard-service'; import {NgbDatepickerNavigationSelect} from './datepicker-navigation-select'; import {NgbDatepickerNavigation} from './datepicker-navigation'; @@ -1195,6 +1197,82 @@ describe('ngb-datepicker', () => { expectSameValues(datepicker, config); }); }); + + describe('NgbDatepicker', () => { + + let mockState: NgbDatepickerState; + let dp: NgbDatepicker; + let keyboardService: NgbDatepickerKeyboardService; + const mockKeyboardService: NgbDatepickerKeyboardService = { + processKey(event: KeyboardEvent, datepicker: NgbDatepicker, calendar: NgbCalendar) { + mockState = datepicker.state; + } + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NgbDatepickerModule], + providers: [{provide: NgbDatepickerKeyboardService, useValue: mockKeyboardService}] + }); + const fixture = createTestComponent( + ``); + fixture.detectChanges(); + keyboardService = TestBed.get(NgbDatepickerKeyboardService); + dp = fixture.debugElement.query(By.directive(NgbDatepicker)).componentInstance; + }); + + it('should provide an defensive copy of minDate', () => { + dp.onKeyDown({}); + expect(mockState.firstDate).toEqual(NgbDate.from({year: 2016, month: 8, day: 1})); + expect(mockState.lastDate).toEqual(NgbDate.from({year: 2016, month: 8, day: 31})); + expect(mockState.minDate).toEqual(NgbDate.from({year: 2010, month: 1, day: 1})); + expect(mockState.maxDate).toEqual(NgbDate.from({year: 2020, month: 12, day: 31})); + Object.assign(mockState, {minDate: undefined}); + dp.onKeyDown({}); + expect(dp.model.minDate).toEqual(NgbDate.from({year: 2010, month: 1, day: 1})); + }); + + it('should provide an defensive copy of maxDate', () => { + dp.onKeyDown({}); + expect(mockState.firstDate).toEqual(NgbDate.from({year: 2016, month: 8, day: 1})); + expect(mockState.lastDate).toEqual(NgbDate.from({year: 2016, month: 8, day: 31})); + expect(mockState.minDate).toEqual(NgbDate.from({year: 2010, month: 1, day: 1})); + expect(mockState.maxDate).toEqual(NgbDate.from({year: 2020, month: 12, day: 31})); + Object.assign(mockState, {maxDate: undefined}); + dp.onKeyDown({}); + expect(dp.model.maxDate).toEqual(NgbDate.from({year: 2020, month: 12, day: 31})); + }); + + it('should provide an defensive copy of firstDate', () => { + dp.onKeyDown({}); + expect(mockState.firstDate).toEqual(NgbDate.from({year: 2016, month: 8, day: 1})); + expect(mockState.lastDate).toEqual(NgbDate.from({year: 2016, month: 8, day: 31})); + expect(mockState.minDate).toEqual(NgbDate.from({year: 2010, month: 1, day: 1})); + expect(mockState.maxDate).toEqual(NgbDate.from({year: 2020, month: 12, day: 31})); + Object.assign(mockState, {firstDate: undefined}); + dp.onKeyDown({}); + expect(dp.model.firstDate).toEqual(NgbDate.from({year: 2016, month: 8, day: 1})); + }); + + it('should provide an defensive copy of lastDate', () => { + dp.onKeyDown({}); + expect(mockState.firstDate).toEqual(NgbDate.from({year: 2016, month: 8, day: 1})); + expect(mockState.lastDate).toEqual(NgbDate.from({year: 2016, month: 8, day: 31})); + expect(mockState.minDate).toEqual(NgbDate.from({year: 2010, month: 1, day: 1})); + expect(mockState.maxDate).toEqual(NgbDate.from({year: 2020, month: 12, day: 31})); + Object.assign(mockState, {lastDate: undefined}); + dp.onKeyDown({}); + expect(dp.model.lastDate).toEqual(NgbDate.from({year: 2016, month: 8, day: 31})); + }); + + it('should provide an defensive copy of focusDate', () => { + dp.onKeyDown({}); + expect(mockState.focusDate).toEqual(NgbDate.from({year: 2016, month: 8, day: 1})); + Object.assign(mockState, {focusDate: undefined}); + dp.onKeyDown({}); + expect(mockState.focusDate).toEqual(NgbDate.from({year: 2016, month: 8, day: 1})); + }); + }); }); @Component({selector: 'test-cmp', template: ''}) diff --git a/src/datepicker/datepicker.ts b/src/datepicker/datepicker.ts index a17e65854f..7463493442 100644 --- a/src/datepicker/datepicker.ts +++ b/src/datepicker/datepicker.ts @@ -23,7 +23,7 @@ import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import {NgbCalendar} from './ngb-calendar'; import {NgbDate} from './ngb-date'; import {NgbDatepickerService} from './datepicker-service'; -import {NgbDatepickerKeyMapService} from './datepicker-keymap-service'; +import {NgbDatepickerKeyboardService} from './datepicker-keyboard-service'; import {DatepickerViewModel, NavigationEvent} from './datepicker-view-model'; import {DayTemplateContext} from './datepicker-day-template-context'; import {NgbDatepickerConfig} from './datepicker-config'; @@ -61,6 +61,37 @@ export interface NgbDatepickerNavigateEvent { preventDefault: () => void; } +/** + * An object that represents a part of the state of the datepicker. + * Required to override datepicker services i.e. the datepicker-keyboard-service. + */ +export interface NgbDatepickerState { + /** + * The minDate provided as input. + */ + readonly minDate: NgbDate; + + /** + * The maxDate provided as input. + */ + readonly maxDate: NgbDate; + + /** + * The first date of current month. + */ + readonly firstDate: NgbDate; + + /** + * The last date of current month. + */ + readonly lastDate: NgbDate; + + /** + * The focused date. + */ + readonly focusDate?: NgbDate; +} + /** * A highly configurable component that helps you with selecting calendar dates. * @@ -117,11 +148,12 @@ export interface NgbDatepickerNavigateEvent { `, - providers: [NGB_DATEPICKER_VALUE_ACCESSOR, NgbDatepickerService, NgbDatepickerKeyMapService] + providers: [NGB_DATEPICKER_VALUE_ACCESSOR, NgbDatepickerService] }) export class NgbDatepicker implements OnDestroy, OnChanges, OnInit, AfterViewInit, ControlValueAccessor { model: DatepickerViewModel; + publicState: NgbDatepickerState = Object.create({}); @ViewChild('months', {static: true}) private _monthsEl: ElementRef; private _controlValue: NgbDate; @@ -246,8 +278,8 @@ export class NgbDatepicker implements OnDestroy, onTouched = () => {}; constructor( - private _keyMapService: NgbDatepickerKeyMapService, public _service: NgbDatepickerService, - private _calendar: NgbCalendar, public i18n: NgbDatepickerI18n, config: NgbDatepickerConfig, + public _service: NgbDatepickerService, private _calendar: NgbCalendar, public i18n: NgbDatepickerI18n, + config: NgbDatepickerConfig, private _keyboardService: NgbDatepickerKeyboardService, private _cd: ChangeDetectorRef, private _elementRef: ElementRef, private _ngbDateAdapter: NgbDateAdapter, private _ngZone: NgZone) { ['dayTemplate', 'dayTemplateData', 'displayMonths', 'firstDayOfWeek', 'footerTemplate', 'markDisabled', 'minDate', @@ -298,6 +330,30 @@ export class NgbDatepicker implements OnDestroy, }); } + /** + * Returns a copy of the state of the datepicker. + */ + get state(): NgbDatepickerState { + Object.assign(this.publicState, { + maxDate: this.model.maxDate, + minDate: this.model.minDate, + firstDate: this.model.firstDate, + lastDate: this.model.lastDate, + focusDate: this.model.focusDate, + }); + return this.publicState; + } + + /** + * Focuses on given date. + */ + focusDate(date: NgbDateStruct): void { this._service.focus(NgbDate.from(date)); } + + /** + * Selects focused date. + */ + focusSelect(): void { this._service.focusSelect(); } + focus() { this._ngZone.onStable.asObservable().pipe(take(1)).subscribe(() => { const elementToFocus = @@ -367,7 +423,7 @@ export class NgbDatepicker implements OnDestroy, this._service.select(date, {emitEvent: true}); } - onKeyDown(event: KeyboardEvent) { this._keyMapService.processKey(event); } + onKeyDown(event: KeyboardEvent) { this._keyboardService.processKey(event, this, this._calendar); } onNavigateDateSelect(date: NgbDate) { this._service.open(date); } diff --git a/src/index.ts b/src/index.ts index a1d7c7a71b..ddc59a1f97 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,8 +60,10 @@ export { NgbDatepickerConfig, NgbDatepickerI18n, NgbDatepickerI18nHebrew, + NgbDatepickerKeyboardService, NgbDatepickerModule, NgbDatepickerNavigateEvent, + NgbDatepickerState, NgbDateStruct, NgbInputDatepicker, NgbPeriod