Skip to content

Commit 1d9a84e

Browse files
committed
fix(datepicker): focus handling performance regression
Removes unnecessary model updates and change detections when focus moves inside the datepicker
1 parent d033ead commit 1d9a84e

File tree

4 files changed

+54
-21
lines changed

4 files changed

+54
-21
lines changed

src/datepicker/datepicker-service.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,19 @@ describe('ngb-datepicker-service', () => {
449449
expect(mock.onNext).toHaveBeenCalledTimes(1);
450450
});
451451

452+
it(`should not rebuild months when focus visibility changes`, () => {
453+
service.focus(new NgbDate(2017, 5, 1));
454+
expect(model.focusVisible).toEqual(false);
455+
expect(model.months.length).toBe(1);
456+
const month = model.months[0];
457+
const date = month.weeks[0].days[0].date;
458+
459+
service.focusVisible = true;
460+
expect(model.focusVisible).toEqual(true);
461+
expect(model.months[0]).toBe(month);
462+
expect(getDay(0).date).toBe(date);
463+
});
464+
452465
});
453466

454467
describe(`navigation`, () => {

src/datepicker/datepicker-service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,11 @@ export class NgbDatepickerService {
220220
startDate = state.selectedDate;
221221
}
222222

223+
// terminate early if only focus visibility was changed
224+
if ('focusVisible' in patch) {
225+
return state;
226+
}
227+
223228
// focus date changed
224229
if ('focusDate' in patch) {
225230
state.focusDate = checkDateInRange(state.focusDate, state.minDate, state.maxDate);

src/datepicker/datepicker.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1133,7 +1133,7 @@ describe('ngb-datepicker', () => {
11331133
});
11341134

11351135
it('should initialize inputs with provided config as provider', () => {
1136-
const fixture = createGenericTestComponent('', NgbDatepicker);
1136+
const fixture = TestBed.createComponent(NgbDatepicker);
11371137

11381138
const datepicker = fixture.componentInstance;
11391139
expectSameValues(datepicker, config);

src/datepicker/datepicker.ts

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
1-
import {Subscription} from 'rxjs';
2-
import {take} from 'rxjs/operators';
1+
import {fromEvent, merge, Subject} from 'rxjs';
2+
import {filter, take, takeUntil} from 'rxjs/operators';
33
import {
44
ChangeDetectionStrategy,
55
ChangeDetectorRef,
66
Component,
7+
ElementRef,
8+
EventEmitter,
9+
forwardRef,
710
Input,
11+
NgZone,
812
OnChanges,
9-
TemplateRef,
10-
forwardRef,
13+
OnDestroy,
1114
OnInit,
12-
SimpleChanges,
13-
EventEmitter,
1415
Output,
15-
OnDestroy,
16-
ElementRef,
17-
NgZone,
16+
SimpleChanges,
17+
TemplateRef,
18+
ViewChild,
1819
ViewEncapsulation
1920
} from '@angular/core';
20-
import {NG_VALUE_ACCESSOR, ControlValueAccessor} from '@angular/forms';
21+
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
2122
import {NgbCalendar} from './ngb-calendar';
2223
import {NgbDate} from './ngb-date';
2324
import {NgbDatepickerService} from './datepicker-service';
@@ -29,6 +30,7 @@ import {NgbDateAdapter} from './adapters/ngb-date-adapter';
2930
import {NgbDateStruct} from './ngb-date-struct';
3031
import {NgbDatepickerI18n} from './datepicker-i18n';
3132
import {isChangedDate} from './datepicker-tools';
33+
import {hasClassName} from '../util/util';
3234

3335
const NGB_DATEPICKER_VALUE_ACCESSOR = {
3436
provide: NG_VALUE_ACCESSOR,
@@ -85,7 +87,7 @@ export interface NgbDatepickerNavigateEvent {
8587
</ngb-datepicker-navigation>
8688
</div>
8789
88-
<div class="ngb-dp-months" (keydown)="onKeyDown($event)" (focusin)="showFocus(true)" (focusout)="showFocus(false)">
90+
<div #months class="ngb-dp-months" (keydown)="onKeyDown($event)">
8991
<ng-template ngFor let-month [ngForOf]="model.months" let-i="index">
9092
<div class="ngb-dp-month">
9193
<div *ngIf="navigation === 'none' || (displayMonths > 1 && navigation === 'select')"
@@ -111,9 +113,10 @@ export class NgbDatepicker implements OnDestroy,
111113
OnChanges, OnInit, ControlValueAccessor {
112114
model: DatepickerViewModel;
113115

116+
@ViewChild('months') private _monthsEl: ElementRef<HTMLElement>;
114117
private _controlValue: NgbDate;
115-
private _subscription: Subscription;
116-
private _selectSubscription: Subscription;
118+
private _destroyed$ = new Subject<void>();
119+
117120
/**
118121
* Reference for the custom template for the day display
119122
*/
@@ -214,9 +217,9 @@ export class NgbDatepicker implements OnDestroy,
214217
'maxDate', 'navigation', 'outsideDays', 'showWeekdays', 'showWeekNumbers', 'startDate']
215218
.forEach(input => this[input] = config[input]);
216219

217-
this._selectSubscription = _service.select$.subscribe(date => { this.select.emit(date); });
220+
_service.select$.pipe(takeUntil(this._destroyed$)).subscribe(date => { this.select.emit(date); });
218221

219-
this._subscription = _service.model$.subscribe(model => {
222+
_service.model$.pipe(takeUntil(this._destroyed$)).subscribe(model => {
220223
const newDate = model.firstDate;
221224
const oldDate = this.model ? this.model.firstDate : null;
222225
const newSelectedDate = model.selectedDate;
@@ -271,11 +274,25 @@ export class NgbDatepicker implements OnDestroy,
271274
this._service.open(NgbDate.from(date ? date.day ? date as NgbDateStruct : {...date, day: 1} : null));
272275
}
273276

274-
ngOnDestroy() {
275-
this._subscription.unsubscribe();
276-
this._selectSubscription.unsubscribe();
277+
ngAfterContentInit() {
278+
this._ngZone.runOutsideAngular(() => {
279+
const focusIns$ = fromEvent<FocusEvent>(this._monthsEl.nativeElement, 'focusin');
280+
const focusOuts$ = fromEvent<FocusEvent>(this._monthsEl.nativeElement, 'focusout');
281+
282+
// we're changing 'focusVisible' only when entering or leaving months view
283+
// and ignoring all focus events where both 'target' and 'related' target are day cells
284+
merge(focusIns$, focusOuts$)
285+
.pipe(
286+
filter(
287+
({target, relatedTarget}) =>
288+
!(hasClassName(target, 'ngb-dp-day') && hasClassName(relatedTarget, 'ngb-dp-day'))),
289+
takeUntil(this._destroyed$))
290+
.subscribe(({type}) => this._ngZone.run(() => this._service.focusVisible = type === 'focusin'));
291+
});
277292
}
278293

294+
ngOnDestroy() { this._destroyed$.next(); }
295+
279296
ngOnInit() {
280297
if (this.model === undefined) {
281298
['dayTemplateData', 'displayMonths', 'markDisabled', 'firstDayOfWeek', 'navigation', 'minDate', 'maxDate',
@@ -322,8 +339,6 @@ export class NgbDatepicker implements OnDestroy,
322339

323340
setDisabledState(isDisabled: boolean) { this._service.disabled = isDisabled; }
324341

325-
showFocus(focusVisible: boolean) { this._service.focusVisible = focusVisible; }
326-
327342
writeValue(value) {
328343
this._controlValue = NgbDate.from(this._ngbDateAdapter.fromModel(value));
329344
this._service.select(this._controlValue);

0 commit comments

Comments
 (0)