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 committed Jan 31, 2017
1 parent e2691b9 commit 76f5994
Show file tree
Hide file tree
Showing 6 changed files with 193 additions and 26 deletions.
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 @@ -22,4 +22,9 @@ export interface DayTemplateContext {
* True if current date is selected
*/
selected: boolean;

/**
* True if current date is focused
*/
focused: boolean;
}
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,7 +20,8 @@ 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 }}`
})
Expand All @@ -29,6 +30,7 @@ export class NgbDatepickerDayView {
@Input() date: NgbDateStruct;
@Input() disabled: boolean;
@Input() selected: boolean;
@Input() focused: boolean;

isMuted() { return !this.selected && (this.date.month !== this.currentMonth || this.disabled); }
}
10 changes: 8 additions & 2 deletions src/datepicker/datepicker-month-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ 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),
selected: isSelected(day.date)}">
selected: isSelected(day.date),
focused: isFocused(day.date)}">
</template>
</div>
</div>
Expand All @@ -55,6 +56,7 @@ export class NgbDatepickerMonthView {
@Input() month: MonthViewModel;
@Input() outsideDays: 'visible' | 'hidden' | 'collapsed';
@Input() selectedDate: NgbDate;
@Input() focusedDate: NgbDate;
@Input() showWeekdays;
@Input() showWeekNumbers;

Expand All @@ -70,7 +72,11 @@ export class NgbDatepickerMonthView {

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

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

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

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

Expand Down
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
177 changes: 161 additions & 16 deletions src/datepicker/datepicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import {
OnInit,
SimpleChanges,
EventEmitter,
Output
Output,
ElementRef,
HostListener
} from '@angular/core';
import {NG_VALUE_ACCESSOR, ControlValueAccessor} from '@angular/forms';
import {NgbCalendar} from './ngb-calendar';
import {NgbCalendar, NgbPeriod} from './ngb-calendar';
import {NgbDate} from './ngb-date';
import {NgbDatepickerService} from './datepicker-service';
import {MonthViewModel, NavigationEvent} from './datepicker-view-model';
Expand Down Expand Up @@ -47,7 +49,7 @@ export interface NgbDatepickerNavigateEvent {
@Component({
exportAs: 'ngbDatepicker',
selector: 'ngb-datepicker',
host: {'class': 'd-inline-block rounded'},
host: {'class': 'd-inline-block rounded', '[attr.tabindex]': 'disabled ? undefined : "0"'},
styles: [`
:host {
border: 1px solid rgba(0, 0, 0, 0.125);
Expand All @@ -68,11 +70,17 @@ export interface NgbDatepickerNavigateEvent {
font-size: larger;
height: 2rem;
line-height: 2rem;
}
}
`],
template: `
<template #dt let-date="date" let-currentMonth="currentMonth" let-selected="selected" let-disabled="disabled">
<div ngbDatepickerDayView [date]="date" [currentMonth]="currentMonth" [selected]="selected" [disabled]="disabled"></div>
<template #dt let-date="date" let-currentMonth="currentMonth" let-selected="selected" let-disabled="disabled" let-focused="focused">
<div ngbDatepickerDayView
[date]="date"
[currentMonth]="currentMonth"
[selected]="selected"
[disabled]="disabled"
[focused]="focused">
</div>
</template>
<div class="ngb-dp-header bg-faded pt-1 rounded-top" [style.height.rem]="getHeaderHeight()"
Expand All @@ -99,6 +107,7 @@ export interface NgbDatepickerNavigateEvent {
<ngb-datepicker-month-view
[month]="month"
[selectedDate]="model"
[focusedDate]="focusedDate"
[dayTemplate]="dayTemplate || dt"
[showWeekdays]="showWeekdays"
[showWeekNumbers]="showWeekNumbers"
Expand All @@ -120,6 +129,7 @@ export class NgbDatepicker implements OnChanges,

model: NgbDate;
months: MonthViewModel[] = [];
focusedDate: NgbDate;

/**
* Reference for the custom template for the day display
Expand Down Expand Up @@ -195,7 +205,7 @@ export class NgbDatepicker implements OnChanges,

constructor(
private _service: NgbDatepickerService, private _calendar: NgbCalendar, public i18n: NgbDatepickerI18n,
config: NgbDatepickerConfig) {
config: NgbDatepickerConfig, private _elementRef: ElementRef) {
this.dayTemplate = config.dayTemplate;
this.displayMonths = config.displayMonths;
this.firstDayOfWeek = config.firstDayOfWeek;
Expand Down Expand Up @@ -250,17 +260,26 @@ export class NgbDatepicker implements OnChanges,
}
}

onDateSelect(date: NgbDate) {
this._setViewWithinLimits(date);
isDisplayedDateSelectable(date: NgbDate) {
let selectable = false;
const month = this.months.find(curMonth => curMonth.year === date.year && curMonth.number === date.month);
if (month) {
month.weeks.find(week => {
const day = week.days.find(day => date.equals(day.date));
if (day && !day.disabled) {
selectable = true;
}
return !!day;
});
}
return selectable;
}

onDateSelect(date: NgbDate) {
this._setFocusedDateWithinLimits(date);
this.onTouched();
this.writeValue(date);
this.onChange({year: date.year, month: date.month, day: date.day});

// switch current month
if (this._date.month !== this.months[0].number && this.displayMonths === 1) {
this._updateData();
}
}

onNavigateDateSelect(date: NgbDate) {
Expand All @@ -271,16 +290,102 @@ export class NgbDatepicker implements OnChanges,
onNavigateEvent(event: NavigationEvent) {
switch (event) {
case NavigationEvent.PREV:
this._setViewWithinLimits(this._calendar.getPrev(this.months[0].firstDate, 'm'));
this._setRelativeFocusedDate('m', -1);
break;
case NavigationEvent.NEXT:
this._setViewWithinLimits(this._calendar.getNext(this.months[0].firstDate, 'm'));
this._setRelativeFocusedDate('m', 1);
break;
}

this._updateData();
}

@HostListener('keydown', ['$event'])
onKeyDown(event: KeyboardEvent) {
let focusedDate = this.focusedDate;
if (!focusedDate) {
return;
}
switch (event.keyCode) {
case 33 /* page up */:
if (event.shiftKey) {
this._setRelativeFocusedDate('y', -1);
} else {
this._setRelativeFocusedDate('m', -1);
}
break;
case 34 /* page down */:
if (event.shiftKey) {
this._setRelativeFocusedDate('y', 1);
} else {
this._setRelativeFocusedDate('m', 1);
}
break;
case 35 /* end */:
if (event.shiftKey) {
this._setFocusedDateWithinLimits(this._maxDate);
} else {
this._setFocusedDateWithinLimits(this.getLastDisplayedDate());
}
break;
case 36 /* home */:
if (event.shiftKey) {
this._setFocusedDateWithinLimits(this._minDate);
} else {
this._setFocusedDateWithinLimits(this.getFirstDisplayedDate());
}
break;
case 37 /* left arrow */:
this._setRelativeFocusedDate('d', -1);
break;
case 38 /* up arrow */:
this._setRelativeFocusedDate('d', -this._calendar.getDaysPerWeek());
break;
case 39 /* right arrow */:
this._setRelativeFocusedDate('d', 1);
break;
case 40 /* down arrow */:
this._setRelativeFocusedDate('d', this._calendar.getDaysPerWeek());
break;
case 13 /* enter */:
case 32 /* space */:
if (this.isDisplayedDateSelectable(focusedDate)) {
this.onDateSelect(NgbDate.from(focusedDate));
}
break;
default:
return;
}
event.preventDefault();
}

@HostListener('focus', ['$event'])
onFocus(event: FocusEvent) {
const firstDate = this.getFirstDisplayedDate();
const lastDate = this.getLastDisplayedDate();
const model = this.model;
this.focusedDate = (!model || model.before(firstDate) || model.after(lastDate)) ? firstDate : model;
}

@HostListener('blur', ['$event'])
onBlur(event: FocusEvent) {
this.focusedDate = null;
}

@HostListener('mousedown', ['$event'])
onMouseDown(event: MouseEvent) {
// Internet Explorer has some issues to give focus to the right element when clicking
// so this method is here to make IE behave correctly!
const target = <HTMLElement>event.target;
const tagName = target.tagName.toLowerCase();
if (tagName !== 'select' && tagName !== 'input' && tagName !== 'option') {
if (!this.focusedDate) {
this._elementRef.nativeElement.focus();
}
event.preventDefault();
}
}

registerOnChange(fn: (value: any) => any): void { this.onChange = fn; }

registerOnTouched(fn: () => any): void { this.onTouched = fn; }
Expand Down Expand Up @@ -310,6 +415,46 @@ export class NgbDatepicker implements OnChanges,
}
}

private getFirstDisplayedDate() { return this.months[0].firstDate; }

private getLastDisplayedDate() {
return this._calendar.getPrev(
this._calendar.getNext(this.months[this.months.length - 1].firstDate, 'm', 1), 'd', 1);
}

private _setFocusedDateWithinLimits(date: NgbDate) {
if (this._minDate && date.before(this._minDate)) {
date = this._minDate;
} else if (this._maxDate && date.after(this._maxDate)) {
date = this._maxDate;
}
const firstDate = this.getFirstDisplayedDate();
const lastDate = this.getLastDisplayedDate();
let newViewDate;
if (date.before(firstDate)) {
newViewDate = date;
} else if (date.after(lastDate)) {
newViewDate = this._calendar.getPrev(date, 'm', this.displayMonths - 1);
}
this.focusedDate = date;
if (newViewDate) {
this._setViewWithinLimits(newViewDate);
this._updateData();
}
}

private _setRelativeFocusedDate(period?: NgbPeriod, number?: number) {
let focusedDate = this.focusedDate;
let hasFocusedDate = !!focusedDate;
if (!hasFocusedDate) {
focusedDate = this._date;
}
this._setFocusedDateWithinLimits(this._calendar.getNext(focusedDate, period, number));
if (!hasFocusedDate) {
this.focusedDate = null;
}
}

private _setViewWithinLimits(date: NgbDate) {
if (this._minDate && date.before(this._minDate)) {
this._date = new NgbDate(this._minDate.year, this._minDate.month, 1);
Expand Down

0 comments on commit 76f5994

Please sign in to comment.