Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(datepicker): add keyboard navigation #1273

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
</div>
</form>

<template #customDay let-date="date" let-currentMonth="currentMonth" let-selected="selected" let-disabled="disabled">
<span class="custom-day" [class.weekend]="isWeekend(date)"
<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
16 changes: 11 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 @@ -39,6 +39,7 @@ import {DayTemplateContext} from './datepicker-day-template-context';
[ngOutletContext]="{date: {year: day.date.year, month: day.date.month, day: day.date.day},
currentMonth: month.number,
disabled: isDisabled(day),
focused: isFocused(day.date),
selected: isSelected(day.date)}">
</template>
</template>
Expand All @@ -50,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 @@ -66,16 +68,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