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 #1551

Closed
wants to merge 2 commits into from

Conversation

maxokorokov
Copy link
Member

This essentially contains two things:

Problems with the datepicker before refactoring:

  • All the logic is inside of the NgbDatepicker component - became a huge mess after adding of the keyboard handling
  • Very little unit tests for the logic because of the above

Refactoring:

  • Public API unchanged
  • NgbDatepicker component is as dumb as possible, just displays DatepickerViewModel and propagates @Inputs to the service
  • NgbDatepickerService generates the DatepickerViewModel with a very simple API
  • NgbDatepickerKeyMappingService handles key event mapping to actions
  • Many more unit tests for the service
  • OnPush everywhere for the datepicker components

So now it looks something like this:
dp design

P.S. It might seem a lot, but 2/3 of the PR are unit tests
P.P.S. Tried lots of different solutions based on observables, but for now they add only boilerplate code...

Copy link
Member

@divdavem divdavem left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a great PR. Thank you for your hard work! I have added some remarks. Note that I have not reviewed test files yet.

@@ -20,14 +21,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]': '!disabled',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, the btn-secondary class should be there without the !disabled condition, so that it is possible to see the focus even on disabled dates.

// rebuilding months
if (startDate) {
const forceRebuild =
!!patch['firstDayOfWeek'] || !!patch['markDisabled'] || !!patch['minDate'] || !!patch['maxDate'];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, !!patch['firstDayOfWeek'] does not exactly mean that patch contains firstDayOfWeek, but that it contains it and it is neither 0 nor any other falsy value... firstDayOfWeek could be 0 in some calendars (even if 0 is not used in the default calendar), and in that case, we would still need to have forceRebuild = true.

I think it would be better to use instead the 'firstDayOfWeek' in patch syntax.
The same should be done everywhere in this function ('minDate' in patch, forceRebuild = 'firstDayOfWeek' in patch || 'markDisabled' in patch || 'minDate' in patch || 'maxDate' in patch ...) so that we do not have to wonder whether each value can be falsy.

});

const modelSubscription = _service.model$.subscribe(model => {
const newDate = model.months[0].firstDate;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be written more simply: newDate = model.firstDate
and the same for oldDate.

[minDate]="_minDate"
[maxDate]="_maxDate"
[months]="months.length"
[date]="model.months[0].firstDate"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be written more simply model.firstDate.


onNavigateEvent(event: NavigationEvent) {
switch (event) {
case NavigationEvent.PREV:
this._setViewWithinLimits(this._calendar.getPrev(this.months[0].firstDate, 'm'));
this._service.open(this._calendar.getPrev(this.model.months[0].firstDate, 'm', 1));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be written more simply this.model.firstDate (and the same for line 316).

state.months = months;
state.firstDate = months.length > 0 ? months[0].firstDate : undefined;
state.lastDate = months.length > 0 ? months[months.length - 1].lastDate : undefined;
state.viewDate = state.firstDate;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As viewDate seems to always be the same as firstDate, is it really needed to have both? Maybe we could remove viewDate from the model and only keep firstDate.

@maxokorokov
Copy link
Member Author

Thanks, @divdavem!

Amended fixes for all your comments.
Also added the BREAKING CHANGE label because of the OnPush introduction

@maxokorokov
Copy link
Member Author

Aligned with master, resolved conflicts

@pkozlowski-opensource
Copy link
Member

@maxokorokov I don't see TravisCI being run on those commits, I'm not sure it is because of getting commits from multiple people. But I want to see "green tick" before merging this one :-)

class="custom-select d-inline-block"
[value]="date?.month"
(change)="changeMonth($event.target.value)"
tabindex="-1">

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why tabindex="-1"?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the current implementation its the ngb-datepicker itself that captures all events and prevents default behaviour to avoid page scrolling when hitting arrows, space, etc.

Now that I think of, it might be better in future to focus only a specific date instead of everything, so the default navigation behaviour with keyboard select boxes and arrows would continue working as before

@@ -541,6 +572,143 @@ describe('ngb-datepicker', () => {
expect(getYearSelect(fixture.nativeElement).value).toBe(`${today.getFullYear()}`);
});

it('should correctly navigate with keyboard', () => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is quite big, and if one condition fails we would have hard time figuring out what failed exactly.

Could you split it into multiple small tests? (ideally one test for one navigation key)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think of it of as a big integration test :)
key-mapping service has unit tests of its own, but yes, we should split this one

@pkozlowski-opensource
Copy link
Member

Great stuff @divdavem @maxokorokov ! I've left some comments. The diff is big so I can't really comment on every line of code, but I haven't seen anything scary :-)

@maxokorokov could you please expand on the "BREAKING CHANGES" (that is, take it from the user's point of view?)

@maxokorokov
Copy link
Member Author

maxokorokov commented Jul 10, 2017

Split the datepicker.spec.ts keyboard test into smaller pieces

divdavem and others added 2 commits July 10, 2017 16:20
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
BREAKING CHANGE:

component uses `ChangeDetectionStrategy.OnPush` now for most of the internal implementation.
Things like the dynamic internationalization or calendar change might not work anymore as these are injected services. Any internal changes in these services in runtime will not trigger datepicker re-rendering.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants