Skip to content

Commit

Permalink
feat(datepicker): add keyboard navigation
Browse files Browse the repository at this point in the history
The following shortcuts are available:
Arrow left/right: previous/next day
Arrow up/down: previous/next week
Page up/down: previous/next month
Shift+page up/down: previous/next year
Home/end: beginning/end of the current view
Shift+home/end: min/max selectable date
  • Loading branch information
divdavem authored and pkozlowski-opensource committed Jul 10, 2017
1 parent d2ed7f8 commit bd94215
Show file tree
Hide file tree
Showing 9 changed files with 384 additions and 40 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
</div>
</form>

<ng-template #customDay let-date="date" let-currentMonth="currentMonth" let-selected="selected" let-disabled="disabled">
<span class="custom-day" [class.weekend]="isWeekend(date)"
<ng-template #customDay let-date="date" let-currentMonth="currentMonth" let-selected="selected" let-disabled="disabled" let-focused="focused">
<span class="custom-day" [class.weekend]="isWeekend(date)" [class.focused]="focused"
[class.bg-primary]="selected" [class.hidden]="date.month !== currentMonth" [class.text-muted]="disabled">
{{ date.day }}
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {NgbDateStruct} from '@ng-bootstrap/ng-bootstrap';
display: inline-block;
width: 2rem;
}
.custom-day:hover {
.custom-day:hover, .custom-day.focused {
background-color: #e6e6e6;
}
.weekend {
Expand Down
5 changes: 5 additions & 0 deletions src/datepicker/datepicker-day-template-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export interface DayTemplateContext {
*/
disabled: boolean;

/**
* True if current date is focused
*/
focused: boolean;

/**
* True if current date is selected
*/
Expand Down
4 changes: 3 additions & 1 deletion src/datepicker/datepicker-day-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,16 @@ import {NgbDateStruct} from './ngb-date-struct';
'[class.text-white]': 'selected',
'[class.text-muted]': 'isMuted()',
'[class.outside]': 'isMuted()',
'[class.btn-secondary]': '!disabled'
'[class.btn-secondary]': 'true',
'[class.active]': 'focused'
},
template: `{{ date.day }}`
})
export class NgbDatepickerDayView {
@Input() currentMonth: number;
@Input() date: NgbDateStruct;
@Input() disabled: boolean;
@Input() focused: boolean;
@Input() selected: boolean;

isMuted() { return !this.selected && (this.date.month !== this.currentMonth || this.disabled); }
Expand Down
19 changes: 14 additions & 5 deletions src/datepicker/datepicker-month-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {DayTemplateContext} from './datepicker-day-template-context';
}
.ngb-dp-day, .ngb-dp-weekday, .ngb-dp-week-number {
width: 2rem;
height: 2rem;
height: 2rem;
}
.ngb-dp-day {
cursor: pointer;
Expand All @@ -37,6 +37,10 @@ import {DayTemplateContext} from './datepicker-day-template-context';
<ng-template [ngIf]="!isHidden(day)">
<ng-template [ngTemplateOutlet]="dayTemplate"
[ngOutletContext]="_getDayContext(day, month)">
currentMonth: month.number,
disabled: isDisabled(day),
focused: isFocused(day.date),
selected: isSelected(day.date)}">
</ng-template>
</ng-template>
</div>
Expand All @@ -47,6 +51,7 @@ import {DayTemplateContext} from './datepicker-day-template-context';
export class NgbDatepickerMonthView {
@Input() dayTemplate: TemplateRef<DayTemplateContext>;
@Input() disabled: boolean;
@Input() focusedDate: NgbDate;
@Input() month: MonthViewModel;
@Input() outsideDays: 'visible' | 'hidden' | 'collapsed';
@Input() selectedDate: NgbDate;
Expand All @@ -72,16 +77,20 @@ export class NgbDatepickerMonthView {
};
}

isDisabled(day: DayViewModel) { return this.disabled || day.disabled; }

isSelected(date: NgbDate) { return this.selectedDate && this.selectedDate.equals(date); }

isCollapsed(week: WeekViewModel) {
return this.outsideDays === 'collapsed' && week.days[0].date.month !== this.month.number &&
week.days[week.days.length - 1].date.month !== this.month.number;
}

isDisabled(day: DayViewModel) { return this.disabled || day.disabled; }

isFocused(date: NgbDate) {
return !!(this.focusedDate && this.focusedDate.equals(date) && this.month.number === date.month);
}

isHidden(day: DayViewModel) {
return (this.outsideDays === 'hidden' || this.outsideDays === 'collapsed') && this.month.number !== day.date.month;
}

isSelected(date: NgbDate) { return !!(this.selectedDate && this.selectedDate.equals(date)); }
}
19 changes: 14 additions & 5 deletions src/datepicker/datepicker-navigation-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,20 @@ import {NgbCalendar} from './ngb-calendar';
}
`],
template: `
<select [disabled]="disabled" class="custom-select d-inline-block" [value]="date?.month" (change)="changeMonth($event.target.value)">
<option *ngFor="let m of months" [value]="m">{{ i18n.getMonthShortName(m) }}</option>
</select>` +
`<select [disabled]="disabled" class="custom-select d-inline-block" [value]="date?.year" (change)="changeYear($event.target.value)">
<option *ngFor="let y of years" [value]="y">{{ y }}</option>
<select
[disabled]="disabled"
class="custom-select d-inline-block"
[value]="date?.month"
(change)="changeMonth($event.target.value)"
tabindex="-1">
<option *ngFor="let m of months" [value]="m">{{ i18n.getMonthShortName(m) }}</option>
</select><select
[disabled]="disabled"
class="custom-select d-inline-block"
[value]="date?.year"
(change)="changeYear($event.target.value)"
tabindex="-1">
<option *ngFor="let y of years" [value]="y">{{ y }}</option>
</select>
` // template needs to be formatted in a certain way so we don't add empty text nodes
})
Expand Down
4 changes: 2 additions & 2 deletions src/datepicker/datepicker-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {NgbCalendar} from './ngb-calendar';
}
`],
template: `
<button type="button" class="btn-link" (click)="!!doNavigate(navigation.PREV)" [disabled]="prevDisabled()">
<button type="button" class="btn-link" (click)="!!doNavigate(navigation.PREV)" [disabled]="prevDisabled()" tabindex="-1">
<span class="ngb-dp-navigation-chevron"></span>
</button>
Expand All @@ -55,7 +55,7 @@ import {NgbCalendar} from './ngb-calendar';
(select)="selectDate($event)">
</ngb-datepicker-navigation-select>
<button type="button" class="btn-link" (click)="!!doNavigate(navigation.NEXT)" [disabled]="nextDisabled()">
<button type="button" class="btn-link" (click)="!!doNavigate(navigation.NEXT)" [disabled]="nextDisabled()" tabindex="-1">
<span class="ngb-dp-navigation-chevron right"></span>
</button>
`
Expand Down
170 changes: 169 additions & 1 deletion src/datepicker/datepicker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {TestBed, ComponentFixture, async, inject} from '@angular/core/testing';
import {createGenericTestComponent} from '../test/common';
import {getMonthSelect, getYearSelect, getNavigationLinks} from '../test/datepicker/common';

import {Component, TemplateRef} from '@angular/core';
import {Component, TemplateRef, DebugElement} from '@angular/core';
import {By} from '@angular/platform-browser';
import {FormsModule, ReactiveFormsModule, FormGroup, FormControl, Validators} from '@angular/forms';

Expand All @@ -13,6 +13,7 @@ import {NgbDatepicker} 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 {NgbDatepickerNavigationSelect} from './datepicker-navigation-select';
import {NgbDatepickerNavigation} from './datepicker-navigation';

Expand All @@ -31,6 +32,36 @@ function getDatepicker(element: HTMLElement): HTMLElement {
return element.querySelector('ngb-datepicker') as HTMLElement;
}

function triggerKeyDown(element: DebugElement, keyCode: number, shiftKey = false) {
let event = {
keyCode: keyCode,
shiftKey: shiftKey,
defaultPrevented: false,
propagationStopped: false,
stopPropagation: function() { this.propagationStopped = true; },
preventDefault: function() { this.defaultPrevented = true; }
};
element.triggerEventHandler('keydown', event);
return event;
}

function expectFilteredDaysToBe(
element: DebugElement, expectedDates: NgbDate[],
filterFn: (dayView: NgbDatepickerDayView, element: DebugElement) => boolean) {
const days = element.queryAll(By.directive(NgbDatepickerDayView))
.filter((day: DebugElement) => { return filterFn(day.componentInstance, day); })
.map((value: DebugElement) => NgbDate.from(value.componentInstance.date));
expect(days).toEqual(expectedDates);
}

function expectSelectedDate(element: DebugElement, date: NgbDate) {
expectFilteredDaysToBe(element, date ? [date] : [], (dayView: NgbDatepickerDayView) => dayView.selected);
}

function expectFocusedDate(element: DebugElement, date: NgbDate) {
expectFilteredDaysToBe(element, date ? [date] : [], (dayView: NgbDatepickerDayView) => dayView.focused);
}

function expectSameValues(datepicker: NgbDatepicker, config: NgbDatepickerConfig) {
expect(datepicker.dayTemplate).toBe(config.dayTemplate);
expect(datepicker.displayMonths).toBe(config.displayMonths);
Expand Down Expand Up @@ -541,6 +572,143 @@ describe('ngb-datepicker', () => {
expect(getYearSelect(fixture.nativeElement).value).toBe(`${today.getFullYear()}`);
});

it('should correctly navigate with keyboard', () => {
const fixture = createTestComponent(`<ngb-datepicker #dp
[startDate]="date" [minDate]="minDate"
[maxDate]="maxDate" [displayMonths]="2"
[markDisabled]="markDisabled"></ngb-datepicker>`);

const datepicker = fixture.debugElement.query(By.directive(NgbDatepicker));
expectFocusedDate(datepicker, null);
expectSelectedDate(datepicker, null);

datepicker.triggerEventHandler('focus', {});
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 8, 1));
expectSelectedDate(datepicker, null);

triggerKeyDown(datepicker, 40 /* down arrow */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 8, 8));
expectSelectedDate(datepicker, null);

triggerKeyDown(datepicker, 32 /* space */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 8, 8));
expectSelectedDate(datepicker, new NgbDate(2016, 8, 8));

triggerKeyDown(datepicker, 39 /* right arrow */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 8, 9));
expectSelectedDate(datepicker, new NgbDate(2016, 8, 8));

triggerKeyDown(datepicker, 38 /* up arrow */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 8, 2));
expectSelectedDate(datepicker, new NgbDate(2016, 8, 8));

triggerKeyDown(datepicker, 37 /* left arrow */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 8, 1));
expectSelectedDate(datepicker, new NgbDate(2016, 8, 8));

triggerKeyDown(datepicker, 33 /* page up */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 7, 1));
expectSelectedDate(datepicker, new NgbDate(2016, 8, 8));

triggerKeyDown(datepicker, 35 /* end */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 8, 31));
expectSelectedDate(datepicker, new NgbDate(2016, 8, 8));

triggerKeyDown(datepicker, 13 /* enter */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 8, 31));
expectSelectedDate(datepicker, new NgbDate(2016, 8, 31));

triggerKeyDown(datepicker, 36 /* home */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 7, 1));
expectSelectedDate(datepicker, new NgbDate(2016, 8, 31));

triggerKeyDown(datepicker, 33 /* page up */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 6, 1));
expectSelectedDate(datepicker, null); // selection is no longer visible

triggerKeyDown(datepicker, 34 /* page down */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 7, 1));
expectSelectedDate(datepicker, null); // selection is still no longer visible

triggerKeyDown(datepicker, 35 /* end */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 7, 31));
expectSelectedDate(datepicker, null); // selection is still no longer visible

triggerKeyDown(datepicker, 39 /* right arrow */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 8, 1));
expectSelectedDate(datepicker, new NgbDate(2016, 8, 31)); // selection is visible again

triggerKeyDown(datepicker, 40 /* down arrow */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 8, 8));
expectSelectedDate(datepicker, new NgbDate(2016, 8, 31));

triggerKeyDown(datepicker, 40 /* down arrow */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 8, 15));
expectSelectedDate(datepicker, new NgbDate(2016, 8, 31));

triggerKeyDown(datepicker, 40 /* down arrow */);
fixture.detectChanges();
// we can reach the disabled date
expectFocusedDate(datepicker, new NgbDate(2016, 8, 22));
expectSelectedDate(datepicker, new NgbDate(2016, 8, 31));

triggerKeyDown(datepicker, 32 /* space */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 8, 22));
expectSelectedDate(datepicker, new NgbDate(2016, 8, 31)); // space on a disabled date does not select it

triggerKeyDown(datepicker, 13 /* enter */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2016, 8, 22));
expectSelectedDate(datepicker, new NgbDate(2016, 8, 31)); // enter on a disabled date does not select it

triggerKeyDown(datepicker, 35 /* end */, true /* with shift */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2020, 12, 31)); // maximum date
expectSelectedDate(datepicker, null); // selection is again no longer visible

triggerKeyDown(datepicker, 39 /* right arrow */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2020, 12, 31)); // stays at the maximum date

triggerKeyDown(datepicker, 36 /* home */, true /* with shift */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2010, 1, 1)); // minimum date

triggerKeyDown(datepicker, 37 /* left arrow */);
fixture.detectChanges();
expectFocusedDate(datepicker, new NgbDate(2010, 1, 1)); // stays at the minimum date

triggerKeyDown(datepicker, 34 /* page down */, true /* with shift */);
fixture.detectChanges();

expectFocusedDate(datepicker, new NgbDate(2011, 1, 1));
triggerKeyDown(datepicker, 33 /* page up */, true /* with shift */);
fixture.detectChanges();

expectFocusedDate(datepicker, new NgbDate(2010, 1, 1));
datepicker.triggerEventHandler('blur', {});
fixture.detectChanges();

expectFocusedDate(datepicker, null);
});

it('should support disabling all dates and navigation via the disabled attribute', async(() => {
const fixture = createTestComponent(
`<ngb-datepicker [(ngModel)]="model" [startDate]="date" [disabled]="true"></ngb-datepicker>`);
Expand Down

0 comments on commit bd94215

Please sign in to comment.