diff --git a/CHANGELOG.md b/CHANGELOG.md index d0bf48746fc..8a75c923016 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes for each version of this project will be documented in this file. ## 7.2.0 +- `igxCalendar` + - `igxCalendar` has been refactored to provide the ability to instantiate each view as a separate component. + - **Feature** advanced keyboard navigation support has been added. Read up more information in the [ReadMe](https://github.com/IgniteUI/igniteui-angular/tree/master/projects/igniteui-angular/src/lib/calendar/README.md) + +- **New component** `IgxMonthPicker`: + - Provides the ability to pick a specific month. Read up more information in the [ReadMe](https://github.com/IgniteUI/igniteui-angular/tree/master/projects/igniteui-angular/src/lib/month-picker/README.md) + - **New component** `IgxHierarchicalGrid`: - Provides the ability to represent and manipulate hierarchical data in which each level has a different schema. Each level is represented by a component derived from **igx-grid** and supports most of its functionality. Read up more information about the IgxHierarchicalGrid in the official [documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/hierarchicalgrid.html) or the [ReadMe](https://github.com/IgniteUI/igniteui-angular/tree/master/projects/igniteui-angular/src/lib/grids/hierarchical-grid/README.md) diff --git a/projects/igniteui-angular/src/lib/calendar/README.md b/projects/igniteui-angular/src/lib/calendar/README.md index 2616cb39b95..83b18711873 100644 --- a/projects/igniteui-angular/src/lib/calendar/README.md +++ b/projects/igniteui-angular/src/lib/calendar/README.md @@ -1,6 +1,6 @@ # igxCalendar Component -The **igxCalendar** provides a way for the user to select date(s). +The **igxCalendar** provides a way for the user to select date(s). A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/calendar.html) ## Dependencies @@ -67,16 +67,37 @@ When the **igxCalendar** component is focused: - `Shift + PageDown` will move to the next year. - `Home` will focus the first day of the current month that is into view. - `End` will focus the last day of the current month that is into view. +- `Tab` will navigate through the subheader buttons; + +When `prev` or `next` month buttons (in the subheader) are focused: +- `Space` or `Enter` will scroll into view the next or previous month. + +When `months` button (in the subheader) is focused: +- `Space` or `Enter` will open the months view. + +When `year` button (in the subheader) is focused: +- `Space` or `Enter` will open the decade view. When a day inside the current month is focused: - Arrow keys will navigate through the days. +- Arrow keys will allow navigation to previous/next month as well. - `Enter` will select the currently focused day. +When a month inside the months view is focused: +- Arrow keys will navigate through the months. +- `Home` will focus the first month inside the months view. +- `End` will focus the last month inside the months view. +- `Enter` will select the currently focused month and close the view. + +When an year inside the decade view is focused: +- Arrow keys will navigate through the years. +- `Enter` will select the currently focused year and close the view. + ## API Summary ### Inputs -- `id: string` +- `id: string` Unique identifier of the component. If not provided it will be automatically generated. diff --git a/projects/igniteui-angular/src/lib/calendar/calendar.component.html b/projects/igniteui-angular/src/lib/calendar/calendar.component.html index a9ecf93d246..ead1b7594e9 100644 --- a/projects/igniteui-angular/src/lib/calendar/calendar.component.html +++ b/projects/igniteui-angular/src/lib/calendar/calendar.component.html @@ -4,10 +4,10 @@ - + {{ formattedMonth(viewDate) }} - + {{ formattedYear(viewDate) }} @@ -22,44 +22,43 @@

-
+
keyboard_arrow_left
-
+
keyboard_arrow_right
-
- - {{ dayName | titlecase }} - -
- -
- - {{ formattedDate(day.date) }} - -
+ +
-
-
-
- {{ formattedMonth(month) | titlecase }} -
-
-
+ + -
-
- - {{ formattedYear(year) }} - -
-
+ + diff --git a/projects/igniteui-angular/src/lib/calendar/calendar.component.spec.ts b/projects/igniteui-angular/src/lib/calendar/calendar.component.spec.ts index 0dd6c9cb613..ffcdbb4c4f3 100644 --- a/projects/igniteui-angular/src/lib/calendar/calendar.component.spec.ts +++ b/projects/igniteui-angular/src/lib/calendar/calendar.component.spec.ts @@ -4,13 +4,14 @@ import { FormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { - Calendar, IgxCalendarComponent, IgxCalendarModule, isLeap, IgxCalendarDateDirective, + Calendar, IgxCalendarComponent, IgxCalendarModule, isLeap, monthRange, weekDay, WEEKDAYS } from './index'; import { UIInteractions, wait } from '../test-utils/ui-interactions.spec'; import { DateRangeDescriptor, DateRangeType } from '../core/dates/dateRange'; import { configureTestSuite } from '../test-utils/configure-suite'; +import { IgxDayItemComponent } from './days-view/day-item.component'; describe('IgxCalendar', () => { configureTestSuite(); @@ -338,13 +339,17 @@ describe('IgxCalendar', () => { fixture.detectChanges(); const calendarRows = dom.queryAll(By.css('.igx-calendar__body-row')); + // 6 weeks + week header expect(calendarRows.length).toEqual(7); // 7 calendar rows * 7 elements in each + expect( + dom.queryAll(By.css('.igx-calendar__body-row > igx-day-item')).length + ).toEqual(42); expect( dom.queryAll(By.css('.igx-calendar__body-row > span')).length - ).toEqual(49); + ).toEqual(7); // Today class applied expect( @@ -362,11 +367,7 @@ describe('IgxCalendar', () => { }); it('Calendar DOM structure - year view | month view', () => { - const collection = dom.queryAll(By.css('.igx-calendar-picker__date')); - const monthButton = collection[0]; - const yearButton = collection[1]; - - monthButton.triggerEventHandler('click', {}); + dom.queryAll(By.css('.igx-calendar-picker__date'))[0].nativeElement.click(); fixture.detectChanges(); expect(dom.query(By.css('.igx-calendar__body-row--wrap'))).toBeDefined(); @@ -376,12 +377,12 @@ describe('IgxCalendar', () => { expect(months.length).toEqual(11); expect(currentMonth.nativeElement.textContent.trim()).toMatch('Jun'); - months[0].triggerEventHandler('click', { target: months[0].nativeElement }); + months[0].nativeElement.click(); fixture.detectChanges(); expect(calendar.viewDate.getMonth()).toEqual(0); - yearButton.triggerEventHandler('click', {}); + dom.queryAll(By.css('.igx-calendar-picker__date'))[1].nativeElement.click(); fixture.detectChanges(); expect(dom.query(By.css('.igx-calendar__body-column'))).toBeDefined(); @@ -399,6 +400,7 @@ describe('IgxCalendar', () => { it('Calendar selection - single with event', () => { fixture.detectChanges(); + const target = dom.query(By.css('.igx-calendar__date--selected')); const weekDiv = target.parent; const weekDays = weekDiv.queryAll(By.css('span')); @@ -411,16 +413,16 @@ describe('IgxCalendar', () => { spyOn(calendar.onSelection, 'emit'); // Select 14th - weekDays[3].triggerEventHandler('click', {}); - + weekDays[3].nativeElement.click(); fixture.detectChanges(); + expect(calendar.onSelection.emit).toHaveBeenCalled(); expect((calendar.value as Date).toDateString()).toMatch( nextDay.toDateString() ); expect( - weekDays[3].nativeElement.classList.contains( + weekDays[3].parent.nativeElement.classList.contains( 'igx-calendar__date--selected' ) ).toBe(true); @@ -441,7 +443,7 @@ describe('IgxCalendar', () => { ); const target = parent.queryAll(By.css('span')).pop(); - target.triggerEventHandler('click', {}); + target.nativeElement.click(); fixture.detectChanges(); expect( @@ -459,7 +461,7 @@ describe('IgxCalendar', () => { const parent = dom.queryAll(By.css('.igx-calendar__body-row'))[1]; const target = parent.queryAll(By.css('span')).shift(); - target.triggerEventHandler('click', {}); + target.nativeElement.click(); fixture.detectChanges(); expect( @@ -491,7 +493,7 @@ describe('IgxCalendar', () => { nextDay.toDateString() ); expect( - weekDays[3].nativeElement.classList.contains( + weekDays[3].parent.nativeElement.classList.contains( 'igx-calendar__date--selected' ) ).toBe(true); @@ -524,10 +526,10 @@ describe('IgxCalendar', () => { 0 ); - weekDays.forEach((el) => { - el.triggerEventHandler('click', {}); + for (let index = 0; index < weekDays.length; index++) { + weekDays[index].nativeElement.click(); fixture.detectChanges(); - }); + } expect((calendar.value as Date[]).length).toEqual(7); expect((fixture.componentInstance.model as Date[]).length).toEqual( @@ -535,14 +537,14 @@ describe('IgxCalendar', () => { ); weekDays.forEach((el) => { expect( - el.nativeElement.classList.contains( + el.parent.nativeElement.classList.contains( 'igx-calendar__date--selected' ) ).toBe(true); }); // Deselect last day - weekDays[weekDays.length - 1].triggerEventHandler('click', {}); + weekDays[weekDays.length - 1].nativeElement.click(); fixture.detectChanges(); expect((calendar.value as Date[]).length).toEqual(6); @@ -558,6 +560,7 @@ describe('IgxCalendar', () => { it('Calendar selection - multiple through API', () => { fixture.detectChanges(); + const target = dom.query(By.css('.igx-calendar__date--selected')); const weekDiv = target.parent; const weekDays = weekDiv.queryAll(By.css('span')); @@ -578,7 +581,7 @@ describe('IgxCalendar', () => { lastDay.toDateString() ); expect( - weekDays[weekDays.length - 1].nativeElement.classList.contains( + weekDays[weekDays.length - 1].parent.nativeElement.classList.contains( 'igx-calendar__date--selected' ) ).toBe(true); @@ -593,13 +596,13 @@ describe('IgxCalendar', () => { expect((calendar.value as Date[]).length).toEqual(3); // 11th June expect( - weekDays[0].nativeElement.classList.contains( + weekDays[0].parent.nativeElement.classList.contains( 'igx-calendar__date--selected' ) ).toBe(true); // 12th June expect( - weekDays[1].nativeElement.classList.contains( + weekDays[1].parent.nativeElement.classList.contains( 'igx-calendar__date--selected' ) ).toBe(true); @@ -618,7 +621,7 @@ describe('IgxCalendar', () => { const firstDay = new Date(2017, 5, 11); // Toggle range selection... - weekDays[0].triggerEventHandler('click', {}); + weekDays[0].nativeElement.click(); fixture.detectChanges(); expect((fixture.componentInstance.model as Date[]).length).toEqual( @@ -629,13 +632,13 @@ describe('IgxCalendar', () => { (fixture.componentInstance.model as Date[])[0].toDateString() ).toMatch(firstDay.toDateString()); expect( - weekDays[0].nativeElement.classList.contains( + weekDays[0].parent.nativeElement.classList.contains( 'igx-calendar__date--selected' ) ).toBe(true); // ...and cancel it - weekDays[0].triggerEventHandler('click', {}); + weekDays[0].nativeElement.click(); fixture.detectChanges(); expect((fixture.componentInstance.model as Date[]).length).toEqual( @@ -643,17 +646,17 @@ describe('IgxCalendar', () => { ); expect((calendar.value as Date[]).length).toEqual(0); expect( - weekDays[0].nativeElement.classList.contains( + weekDays[0].parent.nativeElement.classList.contains( 'igx-calendar__date--selected' ) ).toBe(false); // Toggle range selection... - weekDays[0].triggerEventHandler('click', {}); + weekDays[0].nativeElement.click(); fixture.detectChanges(); // ...and complete it - weekDays[weekDays.length - 1].triggerEventHandler('click', {}); + weekDays[weekDays.length - 1].nativeElement.click(); fixture.detectChanges(); expect((fixture.componentInstance.model as Date[]).length).toEqual( @@ -670,7 +673,7 @@ describe('IgxCalendar', () => { ).toMatch(lastDay.toDateString()); weekDays.forEach((el) => { expect( - el.nativeElement.classList.contains( + el.parent.nativeElement.classList.contains( 'igx-calendar__date--selected' ) ).toBe(true); @@ -707,7 +710,7 @@ describe('IgxCalendar', () => { ).toMatch(lastDay.toDateString()); weekDays.forEach((el) => { expect( - el.nativeElement.classList.contains( + el.parent.nativeElement.classList.contains( 'igx-calendar__date--selected' ) ).toBe(true); @@ -730,7 +733,7 @@ describe('IgxCalendar', () => { ).toMatch(midDay.toDateString()); for (const i of [0, 1, 2, 3]) { expect( - weekDays[i].nativeElement.classList.contains( + weekDays[i].parent.nativeElement.classList.contains( 'igx-calendar__date--selected' ) ).toBe(true); @@ -743,7 +746,7 @@ describe('IgxCalendar', () => { expect((calendar.value as Date[]).length).toEqual(1); expect(calendar.value[0].toDateString()).toMatch(lastDay.toDateString()); expect( - weekDays[6].nativeElement.classList.contains( + weekDays[6].parent.nativeElement.classList.contains( 'igx-calendar__date--selected' ) ).toBe(true); @@ -766,7 +769,7 @@ describe('IgxCalendar', () => { ).toMatch(lastDay.toDateString()); weekDays.forEach((el) => { expect( - el.nativeElement.classList.contains( + el.parent.nativeElement.classList.contains( 'igx-calendar__date--selected' ) ).toBe(true); @@ -805,7 +808,7 @@ describe('IgxCalendar', () => { fixture.detectChanges(); const component = dom.query(By.css('.igx-calendar')); - const days = calendar.dates.filter((day) => day.isCurrentMonth); + const days = calendar.daysView.dates.filter((day) => day.isCurrentMonth); const firstDay = days[0]; const lastDay = days[days.length - 1]; @@ -830,7 +833,7 @@ describe('IgxCalendar', () => { it('Calendar keyboard navigation - Arrow keys', () => { const component = dom.query(By.css('.igx-calendar')); - const days = calendar.dates.filter((day) => day.isCurrentMonth); + const days = calendar.daysView.dates.filter((day) => day.isCurrentMonth); const firstDay = days[0]; UIInteractions.simulateKeyDownEvent(component.nativeElement, 'Home'); @@ -868,19 +871,20 @@ describe('IgxCalendar', () => { UIInteractions.triggerKeyDownEvtUponElem('Home', component.nativeElement, true); fixture.detectChanges(); - let date = calendar.dates.find((d) => d.date.date.toString() === value.date.toString()).nativeElement; + let date = calendar.daysView.dates.find((d) => d.date.date.toString() === value.date.toString()).nativeElement; UIInteractions.simulateKeyDownEvent(date, 'Enter'); fixture.detectChanges(); + expect(document.activeElement).toBe(date); value = calendarMonth[4][6]; - date = calendar.dates.find((d) => d.date.date.toString() === value.date.toString()).nativeElement; + date = calendar.daysView.dates.find((d) => d.date.date.toString() === value.date.toString()).nativeElement; UIInteractions.simulateKeyDownEvent(date, 'Enter'); fixture.detectChanges(); await wait(500); - date = calendar.dates.find((d) => d.date.date.toString() === value.date.toString()).nativeElement; + date = calendar.daysView.dates.find((d) => d.date.date.toString() === value.date.toString()).nativeElement; expect(document.activeElement).toBe(date); UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', document.activeElement, true); fixture.detectChanges(); @@ -902,7 +906,7 @@ describe('IgxCalendar', () => { UIInteractions.simulateKeyDownEvent(calendarNativeElement, 'Home'); fixture.detectChanges(); - const date = calendar.dates.filter( + const date = calendar.daysView.dates.filter( d => getDate(d).getTime() === new Date(2017, 5, 5).getTime())[0]; expect(date.nativeElement).toBe(document.activeElement); }); @@ -920,7 +924,7 @@ describe('IgxCalendar', () => { UIInteractions.simulateKeyDownEvent(calendarNativeElement, 'End'); fixture.detectChanges(); - const date = calendar.dates.filter( + const date = calendar.daysView.dates.filter( d => getDate(d).getTime() === new Date(2017, 5, 26).getTime())[0]; expect(date.nativeElement).toBe(document.activeElement); }); @@ -941,7 +945,7 @@ describe('IgxCalendar', () => { UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowUp'); fixture.detectChanges(); - const date = calendar.dates.filter( + const date = calendar.daysView.dates.filter( d => getDate(d).getTime() === new Date(2017, 5, 9).getTime())[0]; expect(date.nativeElement).toBe(document.activeElement); }); @@ -962,7 +966,7 @@ describe('IgxCalendar', () => { UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowDown'); fixture.detectChanges(); - const date = calendar.dates.filter( + const date = calendar.daysView.dates.filter( d => getDate(d).getTime() === new Date(2017, 5, 22).getTime())[0]; expect(date.nativeElement).toBe(document.activeElement); }); @@ -982,7 +986,7 @@ describe('IgxCalendar', () => { UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowLeft'); fixture.detectChanges(); - const date = calendar.dates.filter( + const date = calendar.daysView.dates.filter( d => getDate(d).getTime() === new Date(2017, 5, 1).getTime())[0]; expect(date.nativeElement).toBe(document.activeElement); }); @@ -1002,7 +1006,7 @@ describe('IgxCalendar', () => { UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowRight'); fixture.detectChanges(); - const date = calendar.dates.filter( + const date = calendar.daysView.dates.filter( d => getDate(d).getTime() === new Date(2017, 5, 30).getTime())[0]; expect(date.nativeElement).toBe(document.activeElement); }); @@ -1015,17 +1019,17 @@ describe('IgxCalendar', () => { calendar.selection = 'range'; fixture.detectChanges(); - const fromDate = calendar.dates.filter( + const fromDate = calendar.daysView.dates.filter( d => getDate(d).getTime() === new Date(2017, 5, 5).getTime())[0]; fromDate.nativeElement.click(); fixture.detectChanges(); - const toDate = calendar.dates.filter( + const toDate = calendar.daysView.dates.filter( d => getDate(d).getTime() === new Date(2017, 5, 20).getTime())[0]; toDate.nativeElement.click(); fixture.detectChanges(); - const selectedDates = calendar.dates.toArray().filter(d => { + const selectedDates = calendar.daysView.dates.toArray().filter(d => { const dateTime = getDate(d).getTime(); return (dateTime >= new Date(2017, 5, 5).getTime() && dateTime <= new Date(2017, 5, 9).getTime()) || @@ -1038,7 +1042,7 @@ describe('IgxCalendar', () => { expect(d.isSelectedCSS).toBe(true); }); - const notSelectedDates = calendar.dates.toArray().filter(d => { + const notSelectedDates = calendar.daysView.dates.toArray().filter(d => { const dateTime = getDate(d).getTime(); return dateTime >= new Date(2017, 5, 10).getTime() && dateTime <= new Date(2017, 5, 15).getTime(); @@ -1057,7 +1061,7 @@ describe('IgxCalendar', () => { const calendar = fixture.componentInstance.calendar; expect(calendar.specialDates).toEqual([{type: DateRangeType.Between, dateRange: [new Date(2017, 5, 1), new Date(2017, 5, 6)]}]); expect(calendar.disabledDates).toEqual([{type: DateRangeType.Between, dateRange: [new Date(2017, 5, 23), new Date(2017, 5, 29)]}]); - let specialDates = calendar.dates.toArray().filter(d => { + let specialDates = calendar.daysView.dates.toArray().filter(d => { const dateTime = getDate(d).getTime(); return (dateTime >= new Date(2017, 5, 1).getTime() && dateTime <= new Date(2017, 5, 6).getTime()); @@ -1068,7 +1072,7 @@ describe('IgxCalendar', () => { expect(d.isSpecialCSS).toBe(true); }); - let disabledDates = calendar.dates.toArray().filter(d => { + let disabledDates = calendar.daysView.dates.toArray().filter(d => { const dateTime = getDate(d).getTime(); return (dateTime >= new Date(2017, 5, 23).getTime() && dateTime <= new Date(2017, 5, 29).getTime()); @@ -1086,7 +1090,7 @@ describe('IgxCalendar', () => { expect(calendar.disabledDates).toEqual([{type: DateRangeType.Before, dateRange: [new Date(2017, 5, 10)]}]); expect(calendar.specialDates).toEqual([{type: DateRangeType.After, dateRange: [new Date(2017, 5, 19)]}]); - specialDates = calendar.dates.toArray().filter(d => { + specialDates = calendar.daysView.dates.toArray().filter(d => { const dateTime = getDate(d).getTime(); return (dateTime >= new Date(2017, 5, 20).getTime()); }); @@ -1096,7 +1100,7 @@ describe('IgxCalendar', () => { expect(d.isSpecialCSS).toBe(true); }); - disabledDates = calendar.dates.toArray().filter(d => { + disabledDates = calendar.daysView.dates.toArray().filter(d => { const dateTime = getDate(d).getTime(); return (dateTime <= new Date(2017, 5, 9).getTime()); }); @@ -1380,7 +1384,7 @@ describe('IgxCalendar', () => { expect(selectedDates.length).toBe(0); }); - it('Deselect using API. Should deselect in "range" selection mode whe period is not included in the selected dates', () => { + it('Deselect using API. Should deselect in "range" selection mode when period is not included in the selected dates', () => { ci.model = []; calendar.selection = 'range'; fixture.detectChanges(); @@ -1601,6 +1605,313 @@ describe('IgxCalendar', () => { expect(calendar.value).toEqual([]); }); }); + + describe('Advanced KB Navigation', () => { + configureTestSuite(); + + beforeEach( + async(() => { + TestBed.configureTestingModule({ + declarations: [IgxCalendarSampleComponent], + imports: [IgxCalendarModule, FormsModule, NoopAnimationsModule] + }).compileComponents(); + }) + ); + + it('AKB - should navigate to the previous/next month via KB.', async () => { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + fixture.detectChanges(); + + const calendar = fixture.componentInstance.calendar; + const dom = fixture.debugElement; + + const prev = dom.queryAll(By.css('.igx-calendar-picker__prev'))[0]; + prev.nativeElement.focus(); + + expect(prev.nativeElement).toBe(document.activeElement); + + UIInteractions.simulateKeyDownEvent(prev.nativeElement, 'Enter'); + fixture.detectChanges(); + + expect(calendar.viewDate.getMonth()).toEqual(4); + + const next = dom.queryAll(By.css('.igx-calendar-picker__next'))[0]; + next.nativeElement.focus(); + + expect(next.nativeElement).toBe(document.activeElement); + + UIInteractions.simulateKeyDownEvent(next.nativeElement, 'Enter'); + UIInteractions.simulateKeyDownEvent(next.nativeElement, 'Enter'); + fixture.detectChanges(); + + expect(calendar.viewDate.getMonth()).toEqual(6); + }); + + it('AKB - should open years view, navigate through and select an year via KB.', async () => { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + fixture.detectChanges(); + + const calendar = fixture.componentInstance.calendar; + const dom = fixture.debugElement; + + const year = dom.queryAll(By.css('.igx-calendar-picker__date'))[1]; + year.nativeElement.focus(); + + expect(year.nativeElement).toBe(document.activeElement); + + UIInteractions.simulateKeyDownEvent(document.activeElement, 'Enter'); + fixture.detectChanges(); + + const years = dom.queryAll(By.css('.igx-calendar__year')); + let currentYear = dom.query(By.css('.igx-calendar__year--current')); + + expect(years.length).toEqual(6); + expect(currentYear.nativeElement.textContent.trim()).toMatch('2017'); + + UIInteractions.simulateKeyDownEvent(currentYear.nativeElement, 'ArrowDown'); + fixture.detectChanges(); + + currentYear = dom.query(By.css('.igx-calendar__year--current')); + expect(currentYear.nativeElement.textContent.trim()).toMatch('2018'); + + UIInteractions.simulateKeyDownEvent(currentYear.nativeElement, 'ArrowUp'); + UIInteractions.simulateKeyDownEvent(currentYear.nativeElement, 'ArrowUp'); + fixture.detectChanges(); + + currentYear = dom.query(By.css('.igx-calendar__year--current')); + expect(currentYear.nativeElement.textContent.trim()).toMatch('2016'); + + UIInteractions.simulateKeyDownEvent(currentYear.nativeElement, 'Enter'); + fixture.detectChanges(); + + expect(calendar.viewDate.getFullYear()).toEqual(2016); + }); + + it('AKB - should open months view, navigate through and select a month via KB.', async () => { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + fixture.detectChanges(); + + const calendar = fixture.componentInstance.calendar; + const dom = fixture.debugElement; + + const month = dom.queryAll(By.css('.igx-calendar-picker__date'))[0]; + month.nativeElement.focus(); + + expect(month.nativeElement).toBe(document.activeElement); + + UIInteractions.simulateKeyDownEvent(document.activeElement, 'Enter'); + fixture.detectChanges(); + + const months = dom.queryAll(By.css('.igx-calendar__month')); + let currentMonth = dom.query(By.css('.igx-calendar__month--current')); + + expect(months.length).toEqual(11); + expect(currentMonth.nativeElement.textContent.trim()).toMatch('Jun'); + + UIInteractions.simulateKeyDownEvent(currentMonth.nativeElement, 'Home'); + fixture.detectChanges(); + + currentMonth = dom.query(By.css('.igx-calendar__month--current')); + expect(currentMonth.nativeElement.textContent.trim()).toMatch('Jan'); + + UIInteractions.simulateKeyDownEvent(currentMonth.nativeElement, 'End'); + fixture.detectChanges(); + + currentMonth = dom.query(By.css('.igx-calendar__month--current')); + expect(currentMonth.nativeElement.textContent.trim()).toMatch('Dec'); + + UIInteractions.simulateKeyDownEvent(currentMonth.nativeElement, 'ArrowLeft'); + fixture.detectChanges(); + + currentMonth = dom.query(By.css('.igx-calendar__month--current')); + UIInteractions.simulateKeyDownEvent(currentMonth.nativeElement, 'ArrowUp'); + fixture.detectChanges(); + + currentMonth = dom.query(By.css('.igx-calendar__month--current')); + UIInteractions.simulateKeyDownEvent(currentMonth.nativeElement, 'ArrowRight'); + fixture.detectChanges(); + + currentMonth = dom.query(By.css('.igx-calendar__month--current')); + expect(currentMonth.nativeElement.textContent.trim()).toMatch('Sep'); + + UIInteractions.simulateKeyDownEvent(currentMonth.nativeElement, 'Enter'); + fixture.detectChanges(); + + expect(calendar.viewDate.getMonth()).toEqual(8); + }); + + it('AKB - should navigate to the first enabled date from the previous month when using "arrow up" key.', async () => { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + fixture.detectChanges(); + + const calendar = fixture.componentInstance.calendar; + const dom = fixture.debugElement; + + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const specificDates = [ + new Date(2017, 4, 25), + new Date(2017, 4, 11) + ]; + + dateRangeDescriptors.push({ type: DateRangeType.Specific, dateRange: specificDates }); + + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + await wait(50); + + const calendarNativeElement = dom.query(By.css('.igx-calendar')).nativeElement; + + UIInteractions.simulateKeyDownEvent(calendarNativeElement, 'Home'); + fixture.detectChanges(); + + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowUp'); + fixture.detectChanges(); + await wait(400); + + let date = calendar.daysView.dates.find(d => getDate(d).getTime() === new Date(2017, 4, 18).getTime()); + expect(date.nativeElement).toBe(document.activeElement); + + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowUp'); + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowUp'); + fixture.detectChanges(); + await wait(400); + + date = calendar.daysView.dates.find(d => getDate(d).getTime() === new Date(2017, 3, 27).getTime()); + expect(date.nativeElement).toBe(document.activeElement); + }); + + it('AKB - should navigate to the first enabled date from the previous month when using "arrow left" key.', async () => { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + fixture.detectChanges(); + + const calendar = fixture.componentInstance.calendar; + const dom = fixture.debugElement; + + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const specificDates = [ + new Date(2017, 4, 27), + new Date(2017, 4, 25) + ]; + + dateRangeDescriptors.push({ type: DateRangeType.Specific, dateRange: specificDates }); + + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + await wait(50); + + const calendarNativeElement = dom.query(By.css('.igx-calendar')).nativeElement; + + UIInteractions.simulateKeyDownEvent(calendarNativeElement, 'Home'); + fixture.detectChanges(); + + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowLeft'); + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowLeft'); + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowLeft'); + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowLeft'); + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowLeft'); + fixture.detectChanges(); + await wait(400); + + let date = calendar.daysView.dates.find(d => getDate(d).getTime() === new Date(2017, 4, 26).getTime()); + expect(date.nativeElement).toBe(document.activeElement); + + UIInteractions.simulateKeyDownEvent(calendarNativeElement, 'Home'); + fixture.detectChanges(); + + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowLeft'); + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowLeft'); + fixture.detectChanges(); + await wait(400); + + date = calendar.daysView.dates.find(d => getDate(d).getTime() === new Date(2017, 3, 29).getTime()); + expect(date.nativeElement).toBe(document.activeElement); + }); + + it('AKB - should navigate to the first enabled date from the next month when using "arrow down" key.', async () => { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + fixture.detectChanges(); + + const calendar = fixture.componentInstance.calendar; + const dom = fixture.debugElement; + + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const specificDates = [ + new Date(2017, 6, 14), + new Date(2017, 6, 28) + ]; + + dateRangeDescriptors.push({ type: DateRangeType.Specific, dateRange: specificDates }); + + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + await wait(50); + + const calendarNativeElement = dom.query(By.css('.igx-calendar')).nativeElement; + + UIInteractions.simulateKeyDownEvent(calendarNativeElement, 'End'); + fixture.detectChanges(); + + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowDown'); + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowDown'); + fixture.detectChanges(); + await wait(400); + + let date = calendar.daysView.dates.find(d => getDate(d).getTime() === new Date(2017, 6, 21).getTime()); + expect(date.nativeElement).toBe(document.activeElement); + + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowDown'); + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowDown'); + fixture.detectChanges(); + await wait(400); + + date = calendar.daysView.dates.find(d => getDate(d).getTime() === new Date(2017, 7, 11).getTime()); + expect(date.nativeElement).toBe(document.activeElement); + }); + + it('AKB - should navigate to the first enabled date from the next month when using "arrow right" key.', async () => { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + fixture.detectChanges(); + + const calendar = fixture.componentInstance.calendar; + const dom = fixture.debugElement; + + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const specificDates = [ + new Date(2017, 6, 9), + new Date(2017, 6, 10) + ]; + + dateRangeDescriptors.push({ type: DateRangeType.Specific, dateRange: specificDates }); + + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + await wait(50); + + const calendarNativeElement = dom.query(By.css('.igx-calendar')).nativeElement; + + UIInteractions.simulateKeyDownEvent(calendarNativeElement, 'End'); + fixture.detectChanges(); + + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowDown'); + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowRight'); + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowRight'); + fixture.detectChanges(); + await wait(400); + + let date = calendar.daysView.dates.find(d => getDate(d).getTime() === new Date(2017, 6, 11).getTime()); + expect(date.nativeElement).toBe(document.activeElement); + + date = calendar.daysView.dates.find(d => getDate(d).getTime() === new Date(2017, 7, 5).getTime()); + date.nativeElement.focus(); + + UIInteractions.simulateKeyDownEvent(document.activeElement, 'ArrowRight'); + fixture.detectChanges(); + await wait(400); + + date = calendar.daysView.dates.find(d => getDate(d).getTime() === new Date(2017, 7, 6).getTime()); + expect(date.nativeElement).toBe(document.activeElement); + }); + }); }); @Component({ @@ -1640,7 +1951,7 @@ export class IgxCalendarDisabledSpecialDatesComponent { class DateTester { // tests whether a date is disabled or not - static testDatesAvailability(dates: IgxCalendarDateDirective[], disabled: boolean) { + static testDatesAvailability(dates: IgxDayItemComponent[], disabled: boolean) { for (const date of dates) { expect(date.isDisabled).toBe(disabled, date.date.date.toLocaleDateString() + ' is not disabled'); @@ -1650,7 +1961,7 @@ class DateTester { } // tests whether a dates is special or not - static testDatesSpeciality(dates: IgxCalendarDateDirective[], special: boolean): void { + static testDatesSpeciality(dates: IgxDayItemComponent[], special: boolean): void { for (const date of dates) { expect(date.isSpecial).toBe(special); expect(date.isSpecialCSS).toBe(special); @@ -1660,8 +1971,8 @@ class DateTester { type assignDateRangeDescriptors = (component: IgxCalendarComponent, dateRangeDescriptors: DateRangeDescriptor[]) => void; -type testDatesRange = (inRange: IgxCalendarDateDirective[], - outOfRange: IgxCalendarDateDirective[]) => void; +type testDatesRange = (inRange: IgxDayItemComponent[], + outOfRange: IgxDayItemComponent[]) => void; class DateRangesPropertiesTester { static testAfter(assignFunc: assignDateRangeDescriptors, @@ -1677,7 +1988,7 @@ class DateRangesPropertiesTester { assignFunc(calendar, dateRangeDescriptors); fixture.detectChanges(); - const dates = calendar.dates.toArray(); + const dates = calendar.daysView.dates.toArray(); const inRangeDates = dates.filter(d => getDate(d).getTime() > afterDate.getTime()); const outOfRangeDates = dates.filter(d => getDate(d).getTime() <= afterDate.getTime()); testRangesFunc(inRangeDates, outOfRangeDates); @@ -1696,7 +2007,7 @@ class DateRangesPropertiesTester { assignFunc(calendar, dateRangeDescriptors); fixture.detectChanges(); - const dates = calendar.dates.toArray(); + const dates = calendar.daysView.dates.toArray(); const inRangeDates = dates.filter(d => getDate(d).getTime() < beforeDate.getTime()); const outOfRangeDates = dates.filter(d => getDate(d).getTime() >= beforeDate.getTime()); testRangesFunc(inRangeDates, outOfRangeDates); @@ -1732,7 +2043,7 @@ class DateRangesPropertiesTester { assignFunc(calendar, dateRangeDescriptors); fixture.detectChanges(); - const dates = calendar.dates.toArray(); + const dates = calendar.daysView.dates.toArray(); const inRangeDates = dates.filter(d => getDate(d).getTime() >= betweenMin.getTime() && getDate(d).getTime() <= betweenMax.getTime()); const outOfRangeDates = dates.filter(d => getDate(d).getTime() < betweenMin.getTime() && @@ -1753,7 +2064,7 @@ class DateRangesPropertiesTester { const specificDatesSet = new Set(); specificDates.map(d => specificDatesSet.add(d.getTime())); - const dates = calendar.dates.toArray(); + const dates = calendar.daysView.dates.toArray(); const inRangeDates = dates.filter(d => specificDatesSet.has(getDate(d).getTime())); const outOfRangeDates = dates.filter(d => !specificDatesSet.has(getDate(d).getTime())); testRangesFunc(inRangeDates, outOfRangeDates); @@ -1768,7 +2079,7 @@ class DateRangesPropertiesTester { assignFunc(calendar, dateRangeDescriptors); fixture.detectChanges(); - const dates = calendar.dates.toArray(); + const dates = calendar.daysView.dates.toArray(); const inRangeDates = dates.filter(d => d.date.date.getDay() !== 0 && d.date.date.getDay() !== 6); const outOfRangeDates = dates.filter(d => d.date.date.getDay() === 0 || @@ -1785,7 +2096,7 @@ class DateRangesPropertiesTester { assignFunc(calendar, dateRangeDescriptors); fixture.detectChanges(); - const dates = calendar.dates.toArray(); + const dates = calendar.daysView.dates.toArray(); const inRangeDates = dates.filter(d => d.date.date.getDay() === 0 || d.date.date.getDay() === 6); const outOfRangeDates = dates.filter(d => d.date.date.getDay() !== 0 && @@ -1809,7 +2120,7 @@ class DateRangesPropertiesTester { assignFunc(calendar, dateRangeDescriptors); fixture.detectChanges(); - const dates = calendar.dates.toArray(); + const dates = calendar.daysView.dates.toArray(); const inRangeDates = dates.filter(d => getDate(d).getTime() >= firstBetweenMin.getTime() && getDate(d).getTime() <= secondBetweenMax.getTime()); const outOfRangeDates = dates.filter(d => getDate(d).getTime() < firstBetweenMin.getTime() && @@ -1835,7 +2146,7 @@ class DateRangesPropertiesTester { assignFunc(calendar, dateRangeDescriptors); fixture.detectChanges(); - const dates = calendar.dates.toArray(); + const dates = calendar.daysView.dates.toArray(); const enabledDateTime = new Date(2017, 5, 29).getTime(); const inRangesDates = dates.filter(d => getDate(d).getTime() !== enabledDateTime); const outOfRangeDates = dates.filter(d => getDate(d).getTime() === enabledDateTime); @@ -1852,7 +2163,7 @@ class DateRangesPropertiesTester { assignFunc(calendar, dateRangeDescriptors); fixture.detectChanges(); - const dates = calendar.dates.toArray(); + const dates = calendar.daysView.dates.toArray(); let inRangesDates = dates.filter(d => getDate(d).getTime() === specificDate.getTime()); let outOfRangesDates = dates.filter(d => getDate(d).getTime() !== specificDate.getTime()); testRangesFunc(inRangesDates, outOfRangesDates); @@ -1881,7 +2192,7 @@ class DateRangesPropertiesTester { const calendarNativeElement = debugEl.query(By.css('.igx-calendar')).nativeElement; UIInteractions.simulateKeyDownEvent(calendarNativeElement, 'PageUp'); fixture.detectChanges(); - testRangesFunc(calendar.dates.toArray(), []); + testRangesFunc(calendar.daysView.dates.toArray(), []); } static assignDisableDatesDescriptors(component: IgxCalendarComponent, @@ -1889,8 +2200,8 @@ class DateRangesPropertiesTester { component.disabledDates = dateRangeDescriptors; } - static testDisabledDates(inRange: IgxCalendarDateDirective[], - outOfRange: IgxCalendarDateDirective[]) { + static testDisabledDates(inRange: IgxDayItemComponent[], + outOfRange: IgxDayItemComponent[]) { DateTester.testDatesAvailability(inRange, true); DateTester.testDatesAvailability(outOfRange, false); } @@ -1900,14 +2211,14 @@ class DateRangesPropertiesTester { component.specialDates = dateRangeDescriptors; } - static testSpecialDates(inRange: IgxCalendarDateDirective[], - outOfRange: IgxCalendarDateDirective[]) { + static testSpecialDates(inRange: IgxDayItemComponent[], + outOfRange: IgxDayItemComponent[]) { DateTester.testDatesSpeciality(inRange, true); DateTester.testDatesSpeciality(outOfRange, false); } } -function getDate(dateDirective: IgxCalendarDateDirective) { +function getDate(dateDirective: IgxDayItemComponent) { const fullDate = dateDirective.date.date; const date = new Date(fullDate.getFullYear(), fullDate.getMonth(), fullDate.getDate()); return date; diff --git a/projects/igniteui-angular/src/lib/calendar/calendar.component.ts b/projects/igniteui-angular/src/lib/calendar/calendar.component.ts index 579788187ae..bb63c41b7a3 100644 --- a/projects/igniteui-angular/src/lib/calendar/calendar.component.ts +++ b/projects/igniteui-angular/src/lib/calendar/calendar.component.ts @@ -2,49 +2,27 @@ import { transition, trigger, useAnimation } from '@angular/animations'; import { Component, ContentChild, - EventEmitter, forwardRef, HostBinding, HostListener, Input, - OnInit, - Output, - QueryList, - ViewChildren, - Injectable + ViewChild, + ElementRef } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { HAMMER_GESTURE_CONFIG, HammerGestureConfig } from '@angular/platform-browser'; -import { fadeIn, scaleInCenter, slideInLeft, slideInRight } from '../animations/main'; -import { Calendar, ICalendarDate, range, WEEKDAYS, IGX_CALENDAR_COMPONENT } from './calendar'; +import { fadeIn, scaleInCenter } from '../animations/main'; import { - IgxCalendarDateDirective, IgxCalendarHeaderTemplateDirective, IgxCalendarSubheaderTemplateDirective } from './calendar.directives'; -import { DateRangeDescriptor, DateRangeType } from '../core/dates/dateRange'; +import { IgxDaysViewComponent, CalendarView } from './days-view/days-view.component'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { IgxYearsViewComponent } from './years-view/years-view.component'; +import { IgxMonthsViewComponent } from './months-view/months-view.component'; +import { KEYS } from '../core/utils'; +import { ICalendarDate } from './calendar'; let NEXT_ID = 0; -export enum CalendarView { - DEFAULT, - YEAR, - DECADE -} - -export enum CalendarSelection { - SINGLE = 'single', - MULTI = 'multi', - RANGE = 'range' -} - -@Injectable() -export class CalendarHammerConfig extends HammerGestureConfig { - public overrides = { - pan: { direction: Hammer.DIRECTION_VERTICAL, threshold: 1 } - }; -} - /** * **Ignite UI for Angular Calendar** - * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/calendar.html) @@ -58,6 +36,13 @@ export class CalendarHammerConfig extends HammerGestureConfig { * ``` */ @Component({ + providers: [ + { + multi: true, + provide: NG_VALUE_ACCESSOR, + useExisting: IgxCalendarComponent + } + ], animations: [ trigger('animateView', [ transition('void => 0', useAnimation(fadeIn)), @@ -67,39 +52,12 @@ export class CalendarHammerConfig extends HammerGestureConfig { fromScale: .9 } })) - ]), - trigger('animateChange', [ - transition('* => prev', useAnimation(slideInLeft, { - params: { - fromPosition: 'translateX(-30%)' - } - })), - transition('* => next', useAnimation(slideInRight, { - params: { - fromPosition: 'translateX(30%)' - } - })) ]) ], - providers: [ - { - multi: true, - provide: NG_VALUE_ACCESSOR, - useExisting: IgxCalendarComponent - }, - { - provide: HAMMER_GESTURE_CONFIG, - useClass: CalendarHammerConfig - }, - { - provide: IGX_CALENDAR_COMPONENT, - useExisting: IgxCalendarComponent - } - ], selector: 'igx-calendar', templateUrl: 'calendar.component.html' }) -export class IgxCalendarComponent implements OnInit, ControlValueAccessor { +export class IgxCalendarComponent extends IgxDaysViewComponent { /** * Sets/gets the `id` of the calendar. * If not set, the `id` will have value `"igx-calendar-0"`. @@ -114,171 +72,6 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { @HostBinding('attr.id') @Input() public id = `igx-calendar-${NEXT_ID++}`; - /** - * Gets the start day of the week. - * Can return a numeric or an enum representation of the week day. - * Defaults to `Sunday` / `0`. - * ```typescript - * let weekStart = this.calendar.weekStart; - * ``` - * @memberof IgxCalendarComponent - */ - @Input() - public get weekStart(): WEEKDAYS | number { - return this.calendarModel.firstWeekDay; - } - /** - * Sets the start day of the week. - * Can be assigned to a numeric value or to `WEEKDAYS` enum value. - * ```html - * - * ``` - * @memberof IgxCalendarComponent - */ - public set weekStart(value: WEEKDAYS | number) { - this.calendarModel.firstWeekDay = value; - } - - /** - * Gets the `locale` of the calendar. - * Default value is `"en"`. - * ```typescript - * let locale = this.calendar.locale; - * ``` - * @memberof IgxCalendarComponent - */ - @Input() - public get locale(): string { - return this._locale; - } - - /** - * Sets the `locale` of the calendar. - * Expects a valid BCP 47 language tag. - * Default value is `"en"`. - * ```html - * - * ``` - * @memberof IgxCalendarComponent - */ - public set locale(value: string) { - this._locale = value; - this.initFormatters(); - } - - /** - * - * Gets the selection type of the calendar. - * Default value is `"single"`. - * Changing the type of selection in the calendar resets the currently - * selected values if any. - * ```typescript - * let selectionType = this.calendar.selection; - * ``` - * @memberof IgxCalendarComponent - */ - @Input() - public get selection(): string { - return this._selection; - } - /** - * Sets the selection type of the calendar. - * ```html - * - * ``` - * @memberof IgxCalendarComponent - */ - public set selection(value: string) { - switch (value) { - case 'single': - this.selectedDates = null; - break; - case 'multi': - case 'range': - this.selectedDates = []; - break; - default: - throw new Error('Invalid selection value'); - } - this._onChangeCallback(this.selectedDates); - this.rangeStarted = false; - this._selection = value; - } - - /** - * Gets the date that is presented in the calendar. - * By default it is the current date. - * ```typescript - * let date = this.calendar.viewDate; - * ``` - * @memberof IgxCalendarComponent - */ - @Input() - public get viewDate(): Date { - return this._viewDate; - } - /** - * Sets the date that will be presented in the default view when the calendar renders. - * ```html - * - * ``` - * @memberof IgxCalendarComponent - */ - public set viewDate(value: Date) { - this._viewDate = this.getDateOnly(value); - } - - /** - * Gets the selected date(s) of the calendar. - * - * When the calendar selection is set to `single`, it returns - * a single `Date` object. - * Otherwise it is an array of `Date` objects. - * ```typescript - * let selectedDates = this.calendar.value; - * ``` - * @memberof IgxCalendarComponent - */ - @Input() - public get value(): Date | Date[] { - return this.selectedDates; - } - /** - * Sets the selected date(s) of the calendar. - * - * When the calendar selection is set to `single`, it accepts - * a single `Date` object. - * Otherwise it is an array of `Date` objects. - * ```typescript - * this.calendar.value = new Date(`2016-06-12`); - * ``` - * @memberof IgxCalendarComponent - */ - public set value(value: Date | Date[]) { - this.selectDate(value); - } - - /** - * Gets the date format options of the calendar. - * ```typescript - * let dateFormatOptions = this.calendar.formatOptions. - * ``` - */ - @Input() - public get formatOptions(): object { - return this._formatOptions; - } - /** - * Sets the date format options of the calendar. - * ```html - * [formatOptions] = "{ day: '2-digit', month: 'short', weekday: 'long', year: 'numeric' }" - * ``` - * @memberof IgxCalendarComponent - */ - public set formatOptions(formatOptions: object) { - this._formatOptions = Object.assign(this._formatOptions, formatOptions); - this.initFormatters(); - } /** * Gets whether the `day`, `month` and `year` should be rendered @@ -316,77 +109,6 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { @Input() public vertical = false; - /** - * Gets the disabled dates descriptors. - * ```typescript - * let disabledDates = this.calendar.disabledDates; - * ``` - */ - @Input() - public get disabledDates(): DateRangeDescriptor[] { - return this._disabledDates; - } - - /** - * Sets the disabled dates' descriptors. - * ```typescript - *@ViewChild("MyCalendar") - *public calendar: IgCalendarComponent; - *ngOnInit(){ - * this.calendar.disabledDates = [ - * {type: DateRangeType.Between, dateRange: [new Date("2020-1-1"), new Date("2020-1-15")]}, - * {type: DateRangeType.Weekends}]; - *} - *``` - */ - public set disabledDates(value: DateRangeDescriptor[]) { - this._disabledDates = value; - } - - /** - * Gets the special dates descriptors. - * ```typescript - * let specialDates = this.calendar.specialDates; - * ``` - */ - @Input() - public get specialDates(): DateRangeDescriptor[] { - return this._specialDates; - } - - /** - * Sets the special dates' descriptors. - * ```typescript - *@ViewChild("MyCalendar") - *public calendar: IgCalendarComponent; - *ngOnInit(){ - * this.calendar.specialDates = [ - * {type: DateRangeType.Between, dateRange: [new Date("2020-1-1"), new Date("2020-1-15")]}, - * {type: DateRangeType.Weekends}]; - *} - *``` - */ - public set specialDates(value: DateRangeDescriptor[]) { - this._specialDates = value; - } - - /** - * Emits an event when a selection is made in the calendar. - * Provides reference the `selectedDates` property in the `IgxCalendarComponent`. - * ```html - * - * ``` - * @memberof IgxCalendarComponent - */ - @Output() - public onSelection = new EventEmitter(); - - /** - * @hidden - */ - @ViewChildren(forwardRef(() => IgxCalendarDateDirective), { read: IgxCalendarDateDirective }) - public dates: QueryList; - /** * The default `tabindex` attribute for the component. * @@ -416,62 +138,58 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { * * @hidden */ - @HostBinding('class') - get styleClass(): string { - if (this.vertical) { - return 'igx-calendar--vertical'; - } - return 'igx-calendar'; + @HostBinding('class.igx-calendar--vertical') + get styleVerticalClass(): boolean { + return this.vertical; } /** - * Returns an array of date objects which are then used to - * properly render the month names. - * - * Used in the template of the component - * * @hidden */ - get months(): Date[] { - let start = new Date(this._viewDate.getFullYear(), 0, 1); - const result = []; - - for (let i = 0; i < 12; i++) { - result.push(start); - start = this.calendarModel.timedelta(start, 'month', 1); - } + @ViewChild('decade', {read: IgxYearsViewComponent}) + public dacadeView: IgxYearsViewComponent; - return result; - } + /** + * @hidden + */ + @ViewChild('months', {read: IgxMonthsViewComponent}) + public monthsView: IgxMonthsViewComponent; /** - * Returns an array of date objects which are then used to properly - * render the years. - * - * Used in the template of the component. - * * @hidden */ - get decade(): number[] { - const result = []; - const start = this._viewDate.getFullYear() - 3; - const end = this._viewDate.getFullYear() + 4; + @ViewChild('days', {read: IgxDaysViewComponent}) + public daysView: IgxDaysViewComponent; - for (const year of range(start, end)) { - result.push(new Date(year, this._viewDate.getMonth(), this._viewDate.getDate())); - } + /** + * @hidden + */ + @ViewChild('monthsBtn') + public monthsBtn: ElementRef; - return result; - } + /** + * @hidden + */ + @ViewChild('yearsBtn') + public yearsBtn: ElementRef; + /** + * @hidden + */ get isDefaultView(): boolean { return this._activeView === CalendarView.DEFAULT; } + /** + * @hidden + */ get isYearView(): boolean { return this._activeView === CalendarView.YEAR; } + /** + * @hidden + */ get isDecadeView(): boolean { return this._activeView === CalendarView.DECADE; } @@ -485,6 +203,15 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { get activeView(): CalendarView { return this._activeView; } + /** + * Sets the current active view of the calendar. + * ```typescript + * this.calendar.activeView = activeView; + * ``` + */ + set activeView(val: CalendarView) { + this._activeView = val; + } /** * @hidden @@ -492,6 +219,12 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { get monthAction(): string { return this._monthAction; } + /** + * @hidden + */ + set monthAction(val: string) { + this._monthAction = val; + } /** * Gets the header template. * ```typescript @@ -557,7 +290,7 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { * ``` */ get context() { - const date: Date = this._viewDate; + const date: Date = this.viewDate; return this.generateContext(date); } @@ -580,47 +313,15 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { // tslint:disable-next-line:max-line-length @ContentChild(forwardRef(() => IgxCalendarSubheaderTemplateDirective), { read: IgxCalendarSubheaderTemplateDirective }) private subheaderTemplateDirective: IgxCalendarSubheaderTemplateDirective; - /** - *@hidden - */ - private _viewDate: Date; - /** - *@hidden - */ - private calendarModel: Calendar; + /** *@hidden */ private _activeView = CalendarView.DEFAULT; - /** - *@hidden - */ - private selectedDates; - /** - *@hidden - */ - private _selection: CalendarSelection | string = CalendarSelection.SINGLE; - /** - *@hidden - */ - private rangeStarted = false; /** *@hidden */ private _monthAction = ''; - /** - *@hidden - */ - private _locale = 'en'; - /** - *@hidden - */ - private _formatOptions = { - day: 'numeric', - month: 'short', - weekday: 'short', - year: 'numeric' - }; /** *@hidden */ @@ -629,84 +330,12 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { month: true, year: false }; - /** - *@hidden - */ - private _disabledDates: DateRangeDescriptor[] = null; - /** - *@hidden - */ - private formatterMonth; - /** - *@hidden - */ - private formatterDay; - /** - *@hidden - */ - private formatterYear; - /** - *@hidden - */ - private formatterMonthday; - /** - *@hidden - */ - private formatterWeekday; - /** - *@hidden - */ - private _specialDates: DateRangeDescriptor[] = null; - /** - * @hidden - */ - constructor() { - this.calendarModel = new Calendar(); - } /** - * @hidden - */ - public ngOnInit() { - const today = new Date(); - - this.calendarModel.firstWeekDay = this.weekStart; - this._viewDate = this._viewDate ? this._viewDate : today; - this.initFormatters(); - } - - /** - * Resets the formatters when locale or formatOptions are changed - * - * @hidden - */ - private initFormatters() { - this.formatterMonth = new Intl.DateTimeFormat(this._locale, { month: this._formatOptions.month }); - this.formatterDay = new Intl.DateTimeFormat(this._locale, { day: this._formatOptions.day }); - this.formatterYear = new Intl.DateTimeFormat(this._locale, { year: this._formatOptions.year }); - this.formatterMonthday = new Intl.DateTimeFormat(this._locale, { month: this._formatOptions.month, day: this._formatOptions.day }); - this.formatterWeekday = new Intl.DateTimeFormat(this._locale, { weekday: this._formatOptions.weekday }); - } - - /** - * @hidden - */ - public registerOnChange(fn: (v: Date) => void) { - this._onChangeCallback = fn; - } - - /** - * @hidden - */ - public registerOnTouched(fn: () => void) { - this._onTouchedCallback = fn; - } - - /** - * @hidden + *@hidden */ - public writeValue(value: Date | Date[]) { - this.selectedDates = value; + constructor(public elementRef: ElementRef) { + super(); } /** @@ -722,19 +351,6 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { return `${value.getMonth()}`; } - /** - * Returns the locale representation of the date in the default view if enabled, - * otherwise returns the default `Date.getDate()` value. - * - * @hidden - */ - public formattedDate(value: Date): string { - if (this._formatViews.day) { - return this.formatterDay.format(value); - } - return `${value.getDate()}`; - } - /** * Returns the locale representation of the year in the year view if enabled, * otherwise returns the default `Date.getFullYear()` value. @@ -751,45 +367,63 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { /** * @hidden */ - public isCurrentMonth(value: Date): boolean { - return this.viewDate.getMonth() === value.getMonth(); + public previousMonth(isKeydownTrigger: boolean = false) { + this.viewDate = this.calendarModel.timedelta(this.viewDate, 'month', -1); + this._monthAction = 'prev'; + + if (this.daysView) { + this.daysView.isKeydownTrigger = isKeydownTrigger; + } } /** * @hidden */ - public isCurrentYear(value: Date): boolean { - return this.viewDate.getFullYear() === value.getFullYear(); + public previousMonthKB(event) { + if (event.key === KEYS.SPACE || event.key === KEYS.SPACE_IE || event.key === KEYS.ENTER) { + event.preventDefault(); + event.stopPropagation(); + + this.previousMonth(true); + } } /** * @hidden */ - public previousMonth() { - this._viewDate = this.calendarModel.timedelta(this._viewDate, 'month', -1); - this._monthAction = 'prev'; + public nextMonth(isKeydownTrigger: boolean = false) { + this.viewDate = this.calendarModel.timedelta(this.viewDate, 'month', 1); + this._monthAction = 'next'; + + if (this.daysView) { + this.daysView.isKeydownTrigger = isKeydownTrigger; + } } /** * @hidden */ - public nextMonth() { - this._viewDate = this.calendarModel.timedelta(this._viewDate, 'month', 1); - this._monthAction = 'next'; + public nextMonthKB(event) { + if (event.key === KEYS.SPACE || event.key === KEYS.SPACE_IE || event.key === KEYS.ENTER) { + event.preventDefault(); + event.stopPropagation(); + + this.nextMonth(true); + } } /** * @hidden */ public previousYear() { - this._viewDate = this.calendarModel.timedelta(this._viewDate, 'year', -1); + this.viewDate = this.calendarModel.timedelta(this.viewDate, 'year', -1); } /** * @hidden */ public nextYear() { - this._viewDate = this.calendarModel.timedelta(this._viewDate, 'year', 1); + this.viewDate = this.calendarModel.timedelta(this.viewDate, 'year', 1); } /** @@ -821,135 +455,35 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { this.onSelection.emit(this.selectedDates); } - public animationDone(event, isLast: boolean) { - if (isLast) { - const date = this.dates.find((d) => d.selected); - if (date) { - setTimeout(() => date.nativeElement.focus(), - parseInt(slideInRight.options.params.duration, 10)); - } - } - } - - /** - * Selects date(s) (based on the selection type). - *```typescript - * this.calendar.selectDate(new Date(`2018-06-12`)); - *``` - */ - public selectDate(value: Date | Date[]) { - if (value === null || value === undefined || (Array.isArray(value) && value.length === 0)) { - throw new Error('Date or array should be set for the selectDate method.'); - } - - switch (this.selection) { - case 'single': - this.selectSingle(value as Date); - break; - case 'multi': - this.selectMultiple(value); - break; - case 'range': - this.selectRange(value); - break; - } - } - - /** - * Deselects date(s) (based on the selection type). - *```typescript - * this.calendar.deselectDate(new Date(`2018-06-12`)); - *```` - */ - public deselectDate(value?: Date | Date[]) { - if (this.selectedDates === null || this.selectedDates === []) { - return; - } - - if (value === null || value === undefined) { - this.selectedDates = this.selection === 'single' ? null : []; - this.rangeStarted = false; - this._onChangeCallback(this.selectedDates); - return; - } - - switch (this.selection) { - case 'single': - this.deselectSingle(value as Date); - break; - case 'multi': - this.deselectMultiple(value as Date[]); - break; - case 'range': - this.deselectRange(value as Date[]); - break; - } - } - /** - * Checks whether a date is disabled. - *```typescript - * this.calendar.isDateDisabled(new Date(`2018-06-12`)); - *``` * @hidden */ - public isDateDisabled(date: Date) { - if (this.disabledDates === null) { - return false; - } - - return this.isDateInRanges(date, this.disabledDates); - } - - /** - * Checks whether a date is special. - *```typescript - * this.calendar.isDateSpecial(new Date(`2018-06-12`)); - *``` - * @hidden - */ - public isDateSpecial(date: Date) { - if (this.specialDates === null) { - return false; - } - - return this.isDateInRanges(date, this.specialDates); - } - - /** - * @hidden - */ - public generateWeekHeader(): string[] { - const dayNames = []; - const rv = this.calendarModel.monthdatescalendar(this.viewDate.getFullYear(), this.viewDate.getMonth())[0]; - for (const day of rv) { - dayNames.push(this.formatterWeekday.format(day.date)); - } - - return dayNames; - } - - /** - * @hidden - */ - public get getCalendarMonth(): ICalendarDate[][] { - return this.calendarModel.monthdatescalendar(this.viewDate.getFullYear(), this.viewDate.getMonth(), true); + public viewChanged(event) { + this.viewDate = this.calendarModel.timedelta(event, 'month', 0); } /** * @hidden */ public changeYear(event: Date) { - this._viewDate = new Date(event.getFullYear(), this._viewDate.getMonth()); + this.viewDate = new Date(event.getFullYear(), this.viewDate.getMonth()); this._activeView = CalendarView.DEFAULT; + + requestAnimationFrame(() => { + this.yearsBtn.nativeElement.focus(); + }); } /** * @hidden */ public changeMonth(event: Date) { - this._viewDate = new Date(this._viewDate.getFullYear(), event.getMonth()); + this.viewDate = new Date(this.viewDate.getFullYear(), event.getMonth()); this._activeView = CalendarView.DEFAULT; + + requestAnimationFrame(() => { + this.monthsBtn.nativeElement.focus(); + }); } /** @@ -957,47 +491,52 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { */ public activeViewYear(): void { this._activeView = CalendarView.YEAR; + requestAnimationFrame(() => { + this.monthsView.el.nativeElement.focus(); + }); } /** * @hidden */ - public activeViewDecade(): void { - this._activeView = CalendarView.DECADE; + public activeViewYearKB(event): void { + if (event.key === KEYS.SPACE || event.key === KEYS.SPACE_IE || event.key === KEYS.ENTER) { + event.preventDefault(); + this.activeViewYear(); + } } /** * @hidden */ - public onScroll(event) { - event.preventDefault(); - event.stopPropagation(); - - const delta = event.deltaY < 0 ? -1 : 1; - this.generateYearRange(delta); + public activeViewDecade(): void { + this._activeView = CalendarView.DECADE; + requestAnimationFrame(() => { + this.dacadeView.el.nativeElement.focus(); + }); } /** * @hidden */ - public onPan(event) { - const delta = event.deltaY < 0 ? 1 : -1; - this.generateYearRange(delta); + public activeViewDecadeKB(event) { + if (event.key === KEYS.SPACE || event.key === KEYS.SPACE_IE || event.key === KEYS.ENTER) { + event.preventDefault(); + this.activeViewDecade(); + } } /** - *@hidden + * Deselects date(s) (based on the selection type). + *```typescript + * this.calendar.deselectDate(new Date(`2018-06-12`)); + *```` */ - public focusActiveDate() { - let date = this.dates.find((d) => d.selected); - - if (!date) { - date = this.dates.find((d) => d.isToday); - } + public deselectDate(value?: Date | Date[]) { + super.deselectDate(value); - if (date) { - date.nativeElement.focus(); - } + this.daysView.selectedDates = this.selectedDates; + this._onChangeCallback(this.selectedDates); } /** @@ -1007,6 +546,10 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { public onKeydownPageUp(event: KeyboardEvent) { event.preventDefault(); this.previousMonth(); + + if (this.daysView) { + this.daysView.isKeydownTrigger = true; + } } /** @@ -1016,6 +559,10 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { public onKeydownPageDown(event: KeyboardEvent) { event.preventDefault(); this.nextMonth(); + + if (this.daysView) { + this.daysView.isKeydownTrigger = true; + } } /** @@ -1025,6 +572,10 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { public onKeydownShiftPageUp(event: KeyboardEvent) { event.preventDefault(); this.previousYear(); + + if (this.daysView) { + this.daysView.isKeydownTrigger = true; + } } /** @@ -1034,81 +585,9 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { public onKeydownShiftPageDown(event: KeyboardEvent) { event.preventDefault(); this.nextYear(); - } - - /** - * @hidden - */ - @HostListener('keydown.arrowup', ['$event']) - public onKeydownArrowUp(event: KeyboardEvent) { - event.preventDefault(); - - const node = this.dates.find((date) => date.nativeElement === event.target); - if (!node) { return; } - const dates = this.dates.toArray(); - for (let index = dates.indexOf(node); index - 7 > -1; index -= 7) { - const date = dates[index - 7]; - if (!date.isDisabled) { - date.nativeElement.focus(); - break; - } - } - } - - /** - * @hidden - */ - @HostListener('keydown.arrowdown', ['$event']) - public onKeydownArrowDown(event: KeyboardEvent) { - event.preventDefault(); - - const node = this.dates.find((date) => date.nativeElement === event.target); - if (!node) { return; } - const dates = this.dates.toArray(); - for (let index = dates.indexOf(node); index + 7 < this.dates.length; index += 7) { - const date = dates[index + 7]; - if (!date.isDisabled) { - date.nativeElement.focus(); - break; - } - } - } - - /** - * @hidden - */ - @HostListener('keydown.arrowleft', ['$event']) - public onKeydownArrowLeft(event: KeyboardEvent) { - event.preventDefault(); - const node = this.dates.find((date) => date.nativeElement === event.target); - if (!node) { return; } - const dates = this.dates.toArray(); - for (let index = dates.indexOf(node); index > 0; index--) { - const date = dates[index - 1]; - if (!date.isDisabled) { - date.nativeElement.focus(); - break; - } - } - } - - /** - * @hidden - */ - @HostListener('keydown.arrowright', ['$event']) - public onKeydownArrowRight(event: KeyboardEvent) { - event.preventDefault(); - - const node = this.dates.find((date) => date.nativeElement === event.target); - if (!node) { return; } - const dates = this.dates.toArray(); - for (let index = dates.indexOf(node); index < this.dates.length - 1; index++) { - const date = dates[index + 1]; - if (!date.isDisabled) { - date.nativeElement.focus(); - break; - } + if (this.daysView) { + this.daysView.isKeydownTrigger = true; } } @@ -1117,14 +596,8 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { */ @HostListener('keydown.home', ['$event']) public onKeydownHome(event: KeyboardEvent) { - event.preventDefault(); - - const dates = this.dates.filter(d => d.isCurrentMonth); - for (let i = 0; i < dates.length; i++) { - if (!dates[i].isDisabled) { - dates[i].nativeElement.focus(); - break; - } + if (this.daysView) { + this.daysView.onKeydownHome(event); } } @@ -1133,240 +606,11 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { */ @HostListener('keydown.end', ['$event']) public onKeydownEnd(event: KeyboardEvent) { - event.preventDefault(); - - const dates = this.dates.filter(d => d.isCurrentMonth); - for (let i = dates.length - 1; i >= 0; i--) { - if (!dates[i].isDisabled) { - dates[i].nativeElement.focus(); - break; - } + if (this.daysView) { + this.daysView.onKeydownEnd(event); } } - /** - * @hidden - */ - public dateTracker(index, item): string { - return `${item.date.getMonth()}--${item.date.getDate()}`; - } - - /** - * @hidden - */ - public rowTracker(index, item): string { - return `${item[index].date.getMonth()}${item[index].date.getDate()}`; - } - - /** - * Performs a single selection. - * @hidden - */ - private selectSingle(value: Date) { - this.selectedDates = this.getDateOnly(value); - this._onChangeCallback(this.selectedDates); - } - - /** - * Performs a multiple selection - * @hidden - */ - private selectMultiple(value: Date | Date[]) { - if (Array.isArray(value)) { - this.selectedDates = this.selectedDates.concat(value.map(v => this.getDateOnly(v))); - } else { - const valueDateOnly = this.getDateOnly(value); - if (this.selectedDates.every((date: Date) => date.getTime() !== valueDateOnly.getTime())) { - this.selectedDates.push(valueDateOnly); - } else { - this.selectedDates = this.selectedDates.filter( - (date: Date) => date.getTime() !== valueDateOnly.getTime() - ); - } - } - - this._onChangeCallback(this.selectedDates); - } - /** - *@hidden - */ - private selectRange(value: Date | Date[], excludeDisabledDates: boolean = false) { - let start: Date; - let end: Date; - - if (Array.isArray(value)) { - this.rangeStarted = false; - value.sort((a: Date, b: Date) => a.valueOf() - b.valueOf()); - start = this.getDateOnly(value[0]); - end = this.getDateOnly(value[value.length - 1]); - this.selectedDates = [start, ...this.generateDateRange(start, end)]; - } else { - if (!this.rangeStarted) { - this.rangeStarted = true; - this.selectedDates = [value]; - } else { - this.rangeStarted = false; - - if (this.selectedDates[0].getTime() === value.getTime()) { - this.selectedDates = []; - this._onChangeCallback(this.selectedDates); - return; - } - - this.selectedDates.push(value); - this.selectedDates.sort((a: Date, b: Date) => a.valueOf() - b.valueOf()); - - start = this.selectedDates.shift(); - end = this.selectedDates.pop(); - this.selectedDates = [start, ...this.generateDateRange(start, end)]; - } - } - - if (excludeDisabledDates) { - this.selectedDates = this.selectedDates.filter(d => !this.isDateDisabled(d)); - } - - this._onChangeCallback(this.selectedDates); - } - - /** - * Performs a single deselection. - * @hidden - */ - private deselectSingle(value: Date) { - if (this.selectedDates !== null && - this.getDateOnlyInMs(value as Date) === this.getDateOnlyInMs(this.selectedDates)) { - this.selectedDates = null; - this._onChangeCallback(this.selectedDates); - } - } - - /** - * Performs a multiple deselection. - * @hidden - */ - private deselectMultiple(value: Date[]) { - value = value.filter(v => v !== null); - const selectedDatesCount = this.selectedDates.length; - const datesInMsToDeselect: Set = new Set( - value.map(v => this.getDateOnlyInMs(v))); - - for (let i = this.selectedDates.length - 1; i >= 0; i--) { - if (datesInMsToDeselect.has(this.getDateOnlyInMs(this.selectedDates[i]))) { - this.selectedDates.splice(i, 1); - } - } - - if (this.selectedDates.length !== selectedDatesCount) { - this._onChangeCallback(this.selectedDates); - } - } - - /** - * Performs a range deselection. - * @hidden - */ - private deselectRange(value: Date[]) { - value = value.filter(v => v !== null); - if (value.length < 1) { - return; - } - - value.sort((a: Date, b: Date) => a.valueOf() - b.valueOf()); - const valueStart = this.getDateOnlyInMs(value[0]); - const valueEnd = this.getDateOnlyInMs(value[value.length - 1]); - - this.selectedDates.sort((a: Date, b: Date) => a.valueOf() - b.valueOf()); - const selectedDatesStart = this.getDateOnlyInMs(this.selectedDates[0]); - const selectedDatesEnd = this.getDateOnlyInMs(this.selectedDates[this.selectedDates.length - 1]); - - if (!(valueEnd < selectedDatesStart) && !(valueStart > selectedDatesEnd)) { - this.selectedDates = []; - this.rangeStarted = false; - this._onChangeCallback(this.selectedDates); - } - } - - /** - * @hidden - */ - private selectDateFromClient(value: Date) { - switch (this.selection) { - case 'single': - case 'multi': - if (!this.isDateDisabled(value)) { - this.selectDate(value); - } - - break; - case 'range': - this.selectRange(value, true); - break; - } - } - /** - *@hidden - */ - private isDateInRanges(date: Date, ranges: DateRangeDescriptor[]): boolean { - date = new Date(date.getFullYear(), date.getMonth(), date.getDate()); - const dateInMs = date.getTime(); - - for (const descriptor of ranges) { - const dRanges = descriptor.dateRange ? descriptor.dateRange.map( - r => new Date(r.getFullYear(), r.getMonth(), r.getDate())) : undefined; - switch (descriptor.type) { - case (DateRangeType.After): - if (dateInMs > dRanges[0].getTime()) { - return true; - } - - break; - case (DateRangeType.Before): - if (dateInMs < dRanges[0].getTime()) { - return true; - } - - break; - case (DateRangeType.Between): - const dRange = dRanges.map(d => d.getTime()); - const min = Math.min(dRange[0], dRange[1]); - const max = Math.max(dRange[0], dRange[1]); - if (dateInMs >= min && dateInMs <= max) { - return true; - } - - break; - case (DateRangeType.Specific): - const datesInMs = dRanges.map(d => d.getTime()); - for (const specificDateInMs of datesInMs) { - if (dateInMs === specificDateInMs) { - return true; - } - } - - break; - case (DateRangeType.Weekdays): - const day = date.getDay(); - if (day % 6 !== 0) { - return true; - } - - break; - case (DateRangeType.Weekends): - const weekday = date.getDay(); - if (weekday % 6 === 0) { - return true; - } - - break; - default: - return false; - } - } - - return false; - } - /** * Helper method building and returning the context object inside * the calendar templates. @@ -1381,50 +625,4 @@ export class IgxCalendarComponent implements OnInit, ControlValueAccessor { }; return { $implicit: formatObject }; } - /** - *@hidden - */ - private generateDateRange(start: Date, end: Date): Date[] { - const result = []; - start = this.getDateOnly(start); - end = this.getDateOnly(end); - while (start.getTime() !== end.getTime()) { - start = this.calendarModel.timedelta(start, 'day', 1); - result.push(start); - } - - return result; - } - /** - *@hidden - */ - private generateYearRange(delta: number) { - const currentYear = new Date().getFullYear(); - - if ((delta > 0 && this._viewDate.getFullYear() - currentYear >= 95) || - (delta < 0 && currentYear - this._viewDate.getFullYear() >= 95)) { - return; - } - this._viewDate = this.calendarModel.timedelta(this._viewDate, 'year', delta); - } - /** - *@hidden - */ - private getDateOnlyInMs(date: Date) { - return this.getDateOnly(date).getTime(); - } - /** - *@hidden - */ - private getDateOnly(date: Date) { - return new Date(date.getFullYear(), date.getMonth(), date.getDate()); - } - /** - *@hidden - */ - private _onTouchedCallback: () => void = () => { }; - /** - *@hidden - */ - private _onChangeCallback: (_: Date) => void = () => { }; } diff --git a/projects/igniteui-angular/src/lib/calendar/calendar.directives.ts b/projects/igniteui-angular/src/lib/calendar/calendar.directives.ts index 8c9278a89ec..fa19bd38c97 100644 --- a/projects/igniteui-angular/src/lib/calendar/calendar.directives.ts +++ b/projects/igniteui-angular/src/lib/calendar/calendar.directives.ts @@ -6,17 +6,14 @@ */ import { Directive, - ElementRef, EventEmitter, - Host, HostBinding, HostListener, Input, Output, TemplateRef, - Inject + ElementRef } from '@angular/core'; -import { ICalendarDate, IGX_CALENDAR_COMPONENT, IgxCalendarBase } from './calendar'; /** * @hidden @@ -29,6 +26,9 @@ export class IgxCalendarYearDirective { @Input('igxCalendarYear') public value: Date; + @Input() + public date: Date; + @Output() public onYearSelection = new EventEmitter(); @@ -43,11 +43,9 @@ export class IgxCalendarYearDirective { } get isCurrentYear(): boolean { - return this.calendar.isCurrentYear(this.value); + return this.date.getFullYear() === this.value.getFullYear(); } - constructor(@Inject(IGX_CALENDAR_COMPONENT) public calendar: IgxCalendarBase) {} - @HostListener('click') public onClick() { this.onYearSelection.emit(this.value); @@ -62,6 +60,9 @@ export class IgxCalendarMonthDirective { @Input('igxCalendarMonth') public value: Date; + @Input() + public date: Date; + @Input() public index; @@ -79,136 +80,19 @@ export class IgxCalendarMonthDirective { } get isCurrentMonth(): boolean { - return this.calendar.isCurrentMonth(this.value); - } - - constructor(@Inject(IGX_CALENDAR_COMPONENT) public calendar: IgxCalendarBase) {} - - @HostListener('click') - public onClick() { - this.onMonthSelection.emit(this.value); - } -} - -@Directive({ - selector: '[igxCalendarDate]' -}) -export class IgxCalendarDateDirective { - - @Input('igxCalendarDate') - public date: ICalendarDate; - - get selected(): boolean { - const date = this.date.date; - - if (!this.calendar.value) { - return; - } - - if (this.calendar.selection === 'single') { - this._selected = (this.calendar.value as Date).getTime() === date.getTime(); - } else { - this._selected = (this.calendar.value as Date[]) - .some((each) => each.getTime() === date.getTime()); - } - return this._selected; - } - - set selected(value: boolean) { - this._selected = value; - } - - @Output() - public onDateSelection = new EventEmitter(); - - get isCurrentMonth(): boolean { - return this.date.isCurrentMonth; - } - - get isPreviousMonth(): boolean { - return this.date.isPrevMonth; - } - - get isNextMonth(): boolean { - return this.date.isNextMonth; + return this.date.getMonth() === this.value.getMonth(); } get nativeElement() { return this.elementRef.nativeElement; } - get isInactive(): boolean { - return this.date.isNextMonth || this.date.isPrevMonth; - } - - get isToday(): boolean { - const today = new Date(Date.now()); - const date = this.date.date; - return (date.getFullYear() === today.getFullYear() && - date.getMonth() === today.getMonth() && - date.getDate() === today.getDate() - ); - } - - get isWeekend(): boolean { - const day = this.date.date.getDay(); - return day === 0 || day === 6; - } - - get isDisabled(): boolean { - return this.calendar.isDateDisabled(this.date.date); - } - - get isSpecial(): boolean { - return this.calendar.isDateSpecial(this.date.date); - } - - @HostBinding('attr.tabindex') - public tabindex = 0; - - @HostBinding('class.igx-calendar__date') - get defaultCSS(): boolean { - return this.date.isCurrentMonth && !(this.isWeekend && this.selected); - } - - @HostBinding('class.igx-calendar__date--inactive') - get isInactiveCSS(): boolean { - return this.isInactive; - } - - @HostBinding('class.igx-calendar__date--current') - get isTodayCSS(): boolean { - return this.isToday && !this.selected; - } - - @HostBinding('class.igx-calendar__date--selected') - get isSelectedCSS(): boolean { - return this.selected; - } - - @HostBinding('class.igx-calendar__date--weekend') - get isWeekendCSS(): boolean { - return this.isWeekend; - } - - @HostBinding('class.igx-calendar__date--disabled') - get isDisabledCSS(): boolean { - return this.isDisabled; - } - - @HostBinding('class.igx-calendar__date--special') - get isSpecialCSS(): boolean { - return this.isSpecial; - } - - private _selected = false; - - constructor(@Inject(IGX_CALENDAR_COMPONENT) public calendar: IgxCalendarBase, private elementRef: ElementRef) { } + constructor(public elementRef: ElementRef) {} @HostListener('click') - @HostListener('keydown.enter') - public onSelect() { - this.onDateSelection.emit(this.date); + public onClick() { + const date = new Date(this.value.getFullYear(), this.value.getMonth(), this.date.getDate()); + this.onMonthSelection.emit(date); } } diff --git a/projects/igniteui-angular/src/lib/calendar/calendar.module.ts b/projects/igniteui-angular/src/lib/calendar/calendar.module.ts index 761072dd774..a063911a72c 100644 --- a/projects/igniteui-angular/src/lib/calendar/calendar.module.ts +++ b/projects/igniteui-angular/src/lib/calendar/calendar.module.ts @@ -4,25 +4,34 @@ import { FormsModule } from '@angular/forms'; import { IgxIconModule } from '../icon/index'; import { IgxCalendarComponent } from './calendar.component'; import { - IgxCalendarDateDirective, IgxCalendarHeaderTemplateDirective, IgxCalendarMonthDirective, IgxCalendarSubheaderTemplateDirective, IgxCalendarYearDirective } from './calendar.directives'; +import { IgxMonthsViewComponent } from './months-view/months-view.component'; +import { IgxYearsViewComponent } from './years-view/years-view.component'; +import { IgxDaysViewComponent } from './days-view/days-view.component'; +import { IgxDayItemComponent } from './days-view/day-item.component'; + @NgModule({ declarations: [ + IgxDayItemComponent, + IgxDaysViewComponent, IgxCalendarComponent, - IgxCalendarDateDirective, IgxCalendarHeaderTemplateDirective, IgxCalendarMonthDirective, IgxCalendarYearDirective, - IgxCalendarSubheaderTemplateDirective + IgxCalendarSubheaderTemplateDirective, + IgxMonthsViewComponent, + IgxYearsViewComponent ], exports: [ IgxCalendarComponent, - IgxCalendarDateDirective, + IgxDaysViewComponent, + IgxMonthsViewComponent, + IgxYearsViewComponent, IgxCalendarHeaderTemplateDirective, IgxCalendarMonthDirective, IgxCalendarYearDirective, diff --git a/projects/igniteui-angular/src/lib/calendar/calendar.ts b/projects/igniteui-angular/src/lib/calendar/calendar.ts index bfc5cfc960c..5babfeb1ba0 100644 --- a/projects/igniteui-angular/src/lib/calendar/calendar.ts +++ b/projects/igniteui-angular/src/lib/calendar/calendar.ts @@ -1,3 +1,4 @@ +import { DateRangeDescriptor, DateRangeType } from '../core/dates'; const MDAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; const FEBRUARY = 1; @@ -47,6 +48,66 @@ export function monthRange(year: number, month: number): number[] { return [day, nDays]; } +export function isDateInRanges(date: Date, ranges: DateRangeDescriptor[]): boolean { + date = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const dateInMs = date.getTime(); + + for (const descriptor of ranges) { + const dRanges = descriptor.dateRange ? descriptor.dateRange.map( + r => new Date(r.getFullYear(), r.getMonth(), r.getDate())) : undefined; + switch (descriptor.type) { + case (DateRangeType.After): + if (dateInMs > dRanges[0].getTime()) { + return true; + } + + break; + case (DateRangeType.Before): + if (dateInMs < dRanges[0].getTime()) { + return true; + } + + break; + case (DateRangeType.Between): + const dRange = dRanges.map(d => d.getTime()); + const min = Math.min(dRange[0], dRange[1]); + const max = Math.max(dRange[0], dRange[1]); + if (dateInMs >= min && dateInMs <= max) { + return true; + } + + break; + case (DateRangeType.Specific): + const datesInMs = dRanges.map(d => d.getTime()); + for (const specificDateInMs of datesInMs) { + if (dateInMs === specificDateInMs) { + return true; + } + } + + break; + case (DateRangeType.Weekdays): + const day = date.getDay(); + if (day % 6 !== 0) { + return true; + } + + break; + case (DateRangeType.Weekends): + const weekday = date.getDay(); + if (weekday % 6 === 0) { + return true; + } + + break; + default: + return false; + } + } + + return false; +} + export interface ICalendarDate { date: Date; isCurrentMonth: boolean; @@ -60,6 +121,13 @@ export interface IFormattedParts { combined: string; } +export interface IFormattingOptions { + day?: string; + month?: string; + weekday?: string; + year?: string; +} + export enum WEEKDAYS { SUNDAY = 0, MONDAY = 1, @@ -130,6 +198,7 @@ export class Calendar { value = this.generateICalendarDate(date, year, month); res.push(value); + date = this.timedelta(date, 'day', 1); if ((date.getMonth() !== month) && (date.getDay() === this.firstWeekDay)) { @@ -270,14 +339,3 @@ export class Calendar { return date.getFullYear() > year; } } - -export const IGX_CALENDAR_COMPONENT = 'IgxCalendarComponentToken'; - -export interface IgxCalendarBase { - value: Date | Date[]; - selection: string; - isCurrentYear(value: Date): boolean; - isCurrentMonth(value: Date): boolean; - isDateDisabled(value: Date): boolean; - isDateSpecial(value: Date): boolean; -} diff --git a/projects/igniteui-angular/src/lib/calendar/days-view/day-item.component.html b/projects/igniteui-angular/src/lib/calendar/days-view/day-item.component.html new file mode 100644 index 00000000000..b4cc5e00615 --- /dev/null +++ b/projects/igniteui-angular/src/lib/calendar/days-view/day-item.component.html @@ -0,0 +1,3 @@ + + + diff --git a/projects/igniteui-angular/src/lib/calendar/days-view/day-item.component.ts b/projects/igniteui-angular/src/lib/calendar/days-view/day-item.component.ts new file mode 100644 index 00000000000..3981f257e09 --- /dev/null +++ b/projects/igniteui-angular/src/lib/calendar/days-view/day-item.component.ts @@ -0,0 +1,160 @@ +import { Component, Input, Output, EventEmitter, HostBinding, ElementRef, HostListener } from '@angular/core'; +import { ICalendarDate, isDateInRanges } from '../calendar'; +import { DateRangeDescriptor } from '../../core/dates'; + +/** + *@hidden + */ +@Component({ + selector: 'igx-day-item', + templateUrl: 'day-item.component.html' +}) +export class IgxDayItemComponent { + @Input() + public date: ICalendarDate; + + @Input() + public selection: string; + + @Input() + public value: Date | Date[]; + + @Input() + public disabledDates: DateRangeDescriptor[]; + + @Input() + public outOfRangeDates: DateRangeDescriptor[]; + + @Input() + public specialDates: DateRangeDescriptor[]; + + @Output() + public onDateSelection = new EventEmitter(); + + get selected(): boolean { + const date = this.date.date; + + if (!this.value) { + return; + } + + if (this.selection === 'single') { + this._selected = (this.value as Date).getTime() === date.getTime(); + } else { + this._selected = (this.value as Date[]) + .some((each) => each.getTime() === date.getTime()); + } + + return this._selected; + } + + set selected(value: boolean) { + this._selected = value; + } + + get isCurrentMonth(): boolean { + return this.date.isCurrentMonth; + } + + get isPreviousMonth(): boolean { + return this.date.isPrevMonth; + } + + get isNextMonth(): boolean { + return this.date.isNextMonth; + } + + get nativeElement() { + return this.elementRef.nativeElement; + } + + get isInactive(): boolean { + return this.date.isNextMonth || this.date.isPrevMonth; + } + + get isToday(): boolean { + const today = new Date(Date.now()); + const date = this.date.date; + return (date.getFullYear() === today.getFullYear() && + date.getMonth() === today.getMonth() && + date.getDate() === today.getDate() + ); + } + + get isWeekend(): boolean { + const day = this.date.date.getDay(); + return day === 0 || day === 6; + } + + get isDisabled(): boolean { + if (this.disabledDates === null) { + return false; + } + + return isDateInRanges(this.date.date, this.disabledDates); + } + + get isOutOfRange(): boolean { + if (!this.outOfRangeDates) { + return false; + } + + return isDateInRanges(this.date.date, this.outOfRangeDates); + } + + get isSpecial(): boolean { + if (this.specialDates === null) { + return false; + } + + return isDateInRanges(this.date.date, this.specialDates); + } + + @HostBinding('attr.tabindex') + public tabindex = 0; + + @HostBinding('class.igx-calendar__date') + get defaultCSS(): boolean { + return this.date.isCurrentMonth && !(this.isWeekend && this.selected); + } + + @HostBinding('class.igx-calendar__date--inactive') + get isInactiveCSS(): boolean { + return this.isInactive; + } + + @HostBinding('class.igx-calendar__date--current') + get isTodayCSS(): boolean { + return this.isToday && !this.selected; + } + + @HostBinding('class.igx-calendar__date--selected') + get isSelectedCSS(): boolean { + return this.selected; + } + + @HostBinding('class.igx-calendar__date--weekend') + get isWeekendCSS(): boolean { + return this.isWeekend; + } + + @HostBinding('class.igx-calendar__date--disabled') + get isDisabledCSS(): boolean { + return this.isDisabled || this.isOutOfRange; + } + + @HostBinding('class.igx-calendar__date--special') + get isSpecialCSS(): boolean { + return this.isSpecial; + } + + private _selected = false; + + constructor(private elementRef: ElementRef) { } + + @HostListener('click') + @HostListener('keydown.enter') + public onSelect() { + this.onDateSelection.emit(this.date); + } +} diff --git a/projects/igniteui-angular/src/lib/calendar/days-view/days-view.component.html b/projects/igniteui-angular/src/lib/calendar/days-view/days-view.component.html new file mode 100644 index 00000000000..643a9a9fd30 --- /dev/null +++ b/projects/igniteui-angular/src/lib/calendar/days-view/days-view.component.html @@ -0,0 +1,11 @@ +
+ + {{ dayName | titlecase }} + +
+ +
+ + {{ formattedDate(day.date) }} + +
diff --git a/projects/igniteui-angular/src/lib/calendar/days-view/days-view.component.ts b/projects/igniteui-angular/src/lib/calendar/days-view/days-view.component.ts new file mode 100644 index 00000000000..6aec4f7c6c2 --- /dev/null +++ b/projects/igniteui-angular/src/lib/calendar/days-view/days-view.component.ts @@ -0,0 +1,1092 @@ +import { + Component, + Output, + EventEmitter, + Input, + HostListener, + ViewChildren, + QueryList, + HostBinding, + DoCheck +} from '@angular/core'; +import { ICalendarDate, Calendar, WEEKDAYS, isDateInRanges, IFormattingOptions } from '../../calendar'; +import { trigger, transition, useAnimation } from '@angular/animations'; +import { slideInLeft, slideInRight } from '../../animations/main'; +import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'; +import { IgxDayItemComponent } from './day-item.component'; +import { DateRangeDescriptor, DateRangeType } from '../../core/dates'; + +let NEXT_ID = 0; + +/** + * Sets the calender view - days, months or years. + */ +export enum CalendarView { + DEFAULT, + YEAR, + DECADE +} + +/** + * Sets the selction type - single, multi or range. + */ +export enum CalendarSelection { + SINGLE = 'single', + MULTI = 'multi', + RANGE = 'range' +} + +@Component({ + providers: [ + { + multi: true, + provide: NG_VALUE_ACCESSOR, + useExisting: IgxDaysViewComponent + } + ], + animations: [ + trigger('animateChange', [ + transition('* => prev', useAnimation(slideInLeft, { + params: { + fromPosition: 'translateX(-30%)' + } + })), + transition('* => next', useAnimation(slideInRight, { + params: { + fromPosition: 'translateX(30%)' + } + })) + ]) + ], + selector: 'igx-days-view', + templateUrl: 'days-view.component.html' +}) +export class IgxDaysViewComponent implements ControlValueAccessor, DoCheck { + /** + * Sets/gets the `id` of the days view. + * If not set, the `id` will have value `"igx-days-view-0"`. + * ```html + * + * ``` + * ```typescript + * let daysViewId = this.daysView.id; + * ``` + * @memberof IgxDaysViewComponent + */ + @HostBinding('attr.id') + @Input() + public id = `igx-days-view-${NEXT_ID++}`; + + /** + * Gets the start day of the week. + * Can return a numeric or an enum representation of the week day. + * Defaults to `Sunday` / `0`. + * ```typescript + * let weekStart = this.calendar.weekStart; + * ``` + * @memberof IgxCalendarComponent + */ + @Input() + public get weekStart(): WEEKDAYS | number { + return this.calendarModel.firstWeekDay; + } + + /** + * Sets the start day of the week. + * Can be assigned to a numeric value or to `WEEKDAYS` enum value. + * ```html + * + * ``` + * @memberof IgxCalendarComponent + */ + public set weekStart(value: WEEKDAYS | number) { + this.calendarModel.firstWeekDay = value; + } + + /** + * Gets the `locale` of the calendar. + * Default value is `"en"`. + * ```typescript + * let locale = this.calendar.locale; + * ``` + * @memberof IgxCalendarComponent + */ + @Input() + public get locale(): string { + return this._locale; + } + + /** + * Sets the `locale` of the calendar. + * Expects a valid BCP 47 language tag. + * Default value is `"en"`. + * ```html + * + * ``` + * @memberof IgxCalendarComponent + */ + public set locale(value: string) { + this._locale = value; + this.initFormatters(); + } + + /** + * + * Gets the selection type of the calendar. + * Default value is `"single"`. + * Changing the type of selection in the calendar resets the currently + * selected values if any. + * ```typescript + * let selectionType = this.calendar.selection; + * ``` + * @memberof IgxCalendarComponent + */ + @Input() + public get selection(): string { + return this._selection; + } + + /** + * Sets the selection type of the calendar. + * ```html + * + * ``` + * @memberof IgxCalendarComponent + */ + public set selection(value: string) { + switch (value) { + case 'single': + this.selectedDates = null; + break; + case 'multi': + case 'range': + this.selectedDates = []; + break; + default: + throw new Error('Invalid selection value'); + } + this._onChangeCallback(this.selectedDates); + this.rangeStarted = false; + this._selection = value; + } + + /** + * Gets the date that is presented in the calendar. + * By default it is the current date. + * ```typescript + * let date = this.calendar.viewDate; + * ``` + * @memberof IgxCalendarComponent + */ + @Input() + public get viewDate(): Date { + return this._viewDate; + } + + /** + * Sets the date that will be presented in the default view when the calendar renders. + * ```html + * + * ``` + * @memberof IgxCalendarComponent + */ + public set viewDate(value: Date) { + this._viewDate = this.getDateOnly(value); + } + + /** + * Gets the selected date(s) of the calendar. + * + * When the calendar selection is set to `single`, it returns + * a single `Date` object. + * Otherwise it is an array of `Date` objects. + * ```typescript + * let selectedDates = this.calendar.value; + * ``` + * @memberof IgxCalendarComponent + */ + @Input() + public get value(): Date | Date[] { + return this.selectedDates; + } + + /** + * Sets the selected date(s) of the calendar. + * + * When the calendar selection is set to `single`, it accepts + * a single `Date` object. + * Otherwise it is an array of `Date` objects. + * ```typescript + * this.calendar.value = new Date(`2016-06-12`); + * ``` + * @memberof IgxCalendarComponent + */ + public set value(value: Date | Date[]) { + this.selectDate(value); + } + + /** + * Gets the date format options of the calendar. + * ```typescript + * let dateFormatOptions = this.calendar.formatOptions. + * ``` + */ + @Input() + public get formatOptions(): IFormattingOptions { + return this._formatOptions; + } + + /** + * Sets the date format options of the calendar. + * ```html + * [formatOptions] = "{ day: '2-digit', month: 'short', weekday: 'long', year: 'numeric' }" + * ``` + * @memberof IgxCalendarComponent + */ + public set formatOptions(formatOptions: IFormattingOptions) { + this._formatOptions = Object.assign(this._formatOptions, formatOptions); + this.initFormatters(); + } + + /** + * Gets the disabled dates descriptors. + * ```typescript + * let disabledDates = this.calendar.disabledDates; + * ``` + */ + @Input() + public get disabledDates(): DateRangeDescriptor[] { + return this._disabledDates; + } + + /** + * Sets the disabled dates' descriptors. + * ```typescript + *@ViewChild("MyCalendar") + *public calendar: IgCalendarComponent; + *ngOnInit(){ + * this.calendar.disabledDates = [ + * {type: DateRangeType.Between, dateRange: [new Date("2020-1-1"), new Date("2020-1-15")]}, + * {type: DateRangeType.Weekends}]; + *} + *``` + */ + public set disabledDates(value: DateRangeDescriptor[]) { + this._disabledDates = value; + } + + /** + * Gets the special dates descriptors. + * ```typescript + * let specialDates = this.calendar.specialDates; + * ``` + */ + @Input() + public get specialDates(): DateRangeDescriptor[] { + return this._specialDates; + } + + /** + * Sets the special dates' descriptors. + * ```typescript + *@ViewChild("MyCalendar") + *public calendar: IgCalendarComponent; + *ngOnInit(){ + * this.calendar.specialDates = [ + * {type: DateRangeType.Between, dateRange: [new Date("2020-1-1"), new Date("2020-1-15")]}, + * {type: DateRangeType.Weekends}]; + *} + *``` + */ + public set specialDates(value: DateRangeDescriptor[]) { + this._specialDates = value; + } + + /** + * @hidden + */ + @Input() + public animationAction: any = ''; + + /** + * @hidden + */ + @Input() + public changeDaysView = false; + + /** + * Emits an event when a date is selected. + * Provides reference the `selectedDates` property. + * ```html + * + * ``` + * @memberof IgxCalendarComponent + */ + @Output() + public onSelection = new EventEmitter(); + + /** + * @hidden + */ + @Output() + public onDateSelection = new EventEmitter(); + + /** + * @hidden + */ + @Output() + public onViewChanged = new EventEmitter(); + + /** + * @hidden + */ + @ViewChildren(IgxDayItemComponent, { read: IgxDayItemComponent }) + public dates: QueryList; + + + /** + *@hidden + */ + private _viewDate: Date; + /** + *@hidden + */ + private _locale = 'en'; + /** + *@hidden + */ + private _disabledDates: DateRangeDescriptor[] = null; + /** + *@hidden + */ + private _specialDates: DateRangeDescriptor[] = null; + /** + *@hidden + */ + private _selection: CalendarSelection | string = CalendarSelection.SINGLE; + /** + *@hidden + */ + private rangeStarted = false; + /** + * @hidden + */ + private _nextDate: Date; + /** + * @hidden + */ + private callback: (dates?, next?) => void; + + /** + *@hidden + */ + protected formatterWeekday; + /** + *@hidden + */ + protected formatterMonth; + /** + *@hidden + */ + protected formatterMonthday; + /** + *@hidden + */ + protected formatterYear; + /** + *@hidden + */ + protected formatterDay; + + /** + *@hidden + */ + public calendarModel: Calendar; + /** + *@hidden + */ + public _formatOptions = { + day: 'numeric', + month: 'short', + weekday: 'short', + year: 'numeric' + }; + /** + * @hidden + */ + public isKeydownTrigger = false; + /** + * @hidden + */ + public outOfRangeDates: DateRangeDescriptor[]; + /** + *@hidden + */ + public selectedDates; + + /** + * The default css class applied to the component. + * + * @hidden + */ + @HostBinding('class.igx-calendar') + public styleClass = true; + + /** + * The default `tabindex` attribute for the component. + * + * @hidden + */ + @HostBinding('attr.tabindex') + public tabindex = 0; + + /** + * @hidden + */ + public get getCalendarMonth(): ICalendarDate[][] { + return this.calendarModel.monthdatescalendar(this.viewDate.getFullYear(), this.viewDate.getMonth(), true); + } + + /** + *@hidden + */ + protected _onTouchedCallback: () => void = () => { }; + /** + *@hidden + */ + protected _onChangeCallback: (_: Date) => void = () => { }; + + /** + * @hidden + */ + constructor() { + this.calendarModel = new Calendar(); + + this.calendarModel.firstWeekDay = this.weekStart; + this._viewDate = this._viewDate ? this._viewDate : new Date(); + this.initFormatters(); + } + + /** + * @hidden + */ + public ngDoCheck() { + if (!this.changeDaysView && this.dates) { + this.disableOutOfRangeDates(); + } + } + + /** + * Resets the formatters when locale or formatOptions are changed + * + * @hidden + */ + private initFormatters() { + this.formatterMonth = new Intl.DateTimeFormat(this._locale, { month: this._formatOptions.month }); + this.formatterDay = new Intl.DateTimeFormat(this._locale, { day: this._formatOptions.day }); + this.formatterYear = new Intl.DateTimeFormat(this._locale, { year: this._formatOptions.year }); + this.formatterMonthday = new Intl.DateTimeFormat(this._locale, { month: this._formatOptions.month, day: this._formatOptions.day }); + this.formatterWeekday = new Intl.DateTimeFormat(this._locale, { weekday: this._formatOptions.weekday }); + } + + /** + *@hidden + */ + private getDateOnly(date: Date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); + } + + /** + *@hidden + */ + private getDateOnlyInMs(date: Date) { + return this.getDateOnly(date).getTime(); + } + + /** + *@hidden + */ + private generateDateRange(start: Date, end: Date): Date[] { + const result = []; + start = this.getDateOnly(start); + end = this.getDateOnly(end); + while (start.getTime() !== end.getTime()) { + start = this.calendarModel.timedelta(start, 'day', 1); + result.push(start); + } + + return result; + } + + /** + * Performs a single selection. + * @hidden + */ + private selectSingle(value: Date) { + this.selectedDates = this.getDateOnly(value); + this._onChangeCallback(this.selectedDates); + } + + /** + * Performs a multiple selection + * @hidden + */ + private selectMultiple(value: Date | Date[]) { + if (Array.isArray(value)) { + this.selectedDates = this.selectedDates.concat(value.map(v => this.getDateOnly(v))); + } else { + const valueDateOnly = this.getDateOnly(value); + const newSelection = []; + if (this.selectedDates.every((date: Date) => date.getTime() !== valueDateOnly.getTime())) { + newSelection.push(valueDateOnly); + } else { + this.selectedDates = this.selectedDates.filter( + (date: Date) => date.getTime() !== valueDateOnly.getTime() + ); + } + + if (newSelection.length > 0) { + this.selectedDates = this.selectedDates.concat(newSelection); + } + } + + this._onChangeCallback(this.selectedDates); + } + + /** + *@hidden + */ + private selectRange(value: Date | Date[], excludeDisabledDates: boolean = false) { + let start: Date; + let end: Date; + + if (Array.isArray(value)) { + // this.rangeStarted = false; + value.sort((a: Date, b: Date) => a.valueOf() - b.valueOf()); + start = this.getDateOnly(value[0]); + end = this.getDateOnly(value[value.length - 1]); + this.selectedDates = [start, ...this.generateDateRange(start, end)]; + } else { + if (!this.rangeStarted) { + this.rangeStarted = true; + this.selectedDates = [value]; + } else { + this.rangeStarted = false; + + if (this.selectedDates[0].getTime() === value.getTime()) { + this.selectedDates = []; + this._onChangeCallback(this.selectedDates); + return; + } + + this.selectedDates.push(value); + this.selectedDates.sort((a: Date, b: Date) => a.valueOf() - b.valueOf()); + + start = this.selectedDates.shift(); + end = this.selectedDates.pop(); + this.selectedDates = [start, ...this.generateDateRange(start, end)]; + } + } + + if (excludeDisabledDates) { + this.selectedDates = this.selectedDates.filter(d => !this.isDateDisabled(d)); + } + + this._onChangeCallback(this.selectedDates); + } + + /** + * Performs a single deselection. + * @hidden + */ + private deselectSingle(value: Date) { + if (this.selectedDates !== null && + this.getDateOnlyInMs(value as Date) === this.getDateOnlyInMs(this.selectedDates)) { + this.selectedDates = null; + this._onChangeCallback(this.selectedDates); + } + } + + /** + * Performs a multiple deselection. + * @hidden + */ + private deselectMultiple(value: Date[]) { + value = value.filter(v => v !== null); + const selectedDatesCount = this.selectedDates.length; + const datesInMsToDeselect: Set = new Set( + value.map(v => this.getDateOnlyInMs(v))); + + for (let i = this.selectedDates.length - 1; i >= 0; i--) { + if (datesInMsToDeselect.has(this.getDateOnlyInMs(this.selectedDates[i]))) { + this.selectedDates.splice(i, 1); + } + } + + if (this.selectedDates.length !== selectedDatesCount) { + this._onChangeCallback(this.selectedDates); + } + } + + /** + * Performs a range deselection. + * @hidden + */ + private deselectRange(value: Date[]) { + value = value.filter(v => v !== null); + if (value.length < 1) { + return; + } + + value.sort((a: Date, b: Date) => a.valueOf() - b.valueOf()); + const valueStart = this.getDateOnlyInMs(value[0]); + const valueEnd = this.getDateOnlyInMs(value[value.length - 1]); + + this.selectedDates.sort((a: Date, b: Date) => a.valueOf() - b.valueOf()); + const selectedDatesStart = this.getDateOnlyInMs(this.selectedDates[0]); + const selectedDatesEnd = this.getDateOnlyInMs(this.selectedDates[this.selectedDates.length - 1]); + + if (!(valueEnd < selectedDatesStart) && !(valueStart > selectedDatesEnd)) { + this.selectedDates = []; + this.rangeStarted = false; + this._onChangeCallback(this.selectedDates); + } + } + + /** + * @hidden + */ + private focusPreviousUpDate(target, prevView = false) { + const node = this.dates.find((date) => date.nativeElement === target); + if (!node) { return; } + + const dates = this.dates.toArray(); + for (let index = dates.indexOf(node); index - 7 > -1; index -= 7) { + const date = prevView ? dates[index] : dates[index - 7]; + if (!date.isDisabled) { + if (!date.isOutOfRange) { + date.nativeElement.focus(); + break; + } + } + } + + if (this.changeDaysView && dates.indexOf(node) - 7 < 0) { + const dayItem = dates[dates.indexOf(node)]; + this._nextDate = new Date(dayItem.date.date); + + this._nextDate.setDate(this._nextDate.getDate() - 7); + + this.isKeydownTrigger = true; + this.animationAction = 'prev'; + + this.callback = (items?, next?) => { + const day = items.find((item) => item.date.date.getTime() === next.getTime()); + if (day) { + this.focusPreviousUpDate(day.nativeElement, true); + } + }; + + this.onViewChanged.emit(this._nextDate); + } + } + + /** + * @hidden + */ + private focusNextDownDate(target, nextView = false) { + const node = this.dates.find((date) => date.nativeElement === target); + if (!node) { return; } + + const dates = this.dates.toArray(); + for (let index = dates.indexOf(node); index + 7 < this.dates.length; index += 7) { + const date = nextView ? dates[index] : dates[index + 7]; + if (!date.isDisabled) { + if (!date.isOutOfRange) { + date.nativeElement.focus(); + break; + } + } + } + + if (this.changeDaysView && dates.indexOf(node) + 7 > this.dates.length - 1) { + const dayItem = dates[dates.indexOf(node)]; + this._nextDate = new Date(dayItem.date.date); + + this._nextDate.setDate(this._nextDate.getDate() + 7); + + this.isKeydownTrigger = true; + this.animationAction = 'next'; + + this.callback = (items?, next?) => { + const day = items.find((item) => item.date.date.getTime() === next.getTime()); + if (day) { + this.focusNextDownDate(day.nativeElement, true); + } + }; + + this.onViewChanged.emit(this._nextDate); + } + } + + /** + * @hidden + */ + private focusPreviousDate(target) { + const node = this.dates.find((date) => date.nativeElement === target); + if (!node) { return; } + + const dates = this.dates.toArray(); + for (let index = dates.indexOf(node); index > 0; index--) { + const date = dates[index - 1]; + if (!date.isDisabled) { + if (!date.isOutOfRange) { + date.nativeElement.focus(); + break; + } + } + } + + if (this.changeDaysView && dates.indexOf(node) === 0) { + const dayItem = dates[dates.indexOf(node)]; + this._nextDate = new Date(dayItem.date.date); + + this.isKeydownTrigger = true; + this.animationAction = 'prev'; + + this.callback = (items?, next?) => { + const day = items.find((item) => item.date.date.getTime() === next.getTime()); + if (day) { + this.focusPreviousDate(day.nativeElement); + } + }; + + this.onViewChanged.emit(this._nextDate); + } + } + + /** + * @hidden + */ + private focusNextDate(target) { + const node = this.dates.find((date) => date.nativeElement === target); + if (!node) { return; } + + const dates = this.dates.toArray(); + + for (let index = dates.indexOf(node); index < this.dates.length - 1; index++) { + const date = dates[index + 1]; + if (!date.isDisabled) { + if (!date.isOutOfRange) { + date.nativeElement.focus(); + break; + } + } + } + + if (this.changeDaysView && dates.indexOf(node) === this.dates.length - 1) { + const dayItem = dates[dates.indexOf(node)]; + this._nextDate = new Date(dayItem.date.date); + + this.isKeydownTrigger = true; + this.animationAction = 'next'; + + this.callback = (items?, next?) => { + const day = items.find((item) => item.date.date.getTime() === next.getTime()); + if (day) { + this.focusNextDate(day.nativeElement); + } + }; + + this.onViewChanged.emit(this._nextDate); + } + } + + /** + * @hidden + */ + private disableOutOfRangeDates() { + const dateRange = []; + this.dates.toArray().forEach((date) => { + if (!date.isCurrentMonth) { + dateRange.push(date.date.date); + } + }); + + this.outOfRangeDates = [{ + type: DateRangeType.Specific, + dateRange: dateRange + }]; + } + + + /** + * @hidden + */ + public registerOnChange(fn: (v: Date) => void) { + this._onChangeCallback = fn; + } + + /** + * @hidden + */ + public registerOnTouched(fn: () => void) { + this._onTouchedCallback = fn; + } + + /** + * @hidden + */ + public writeValue(value: Date | Date[]) { + this.selectedDates = value; + } + + /** + * Checks whether a date is disabled. + *```typescript + * this.calendar.isDateDisabled(new Date(`2018-06-12`)); + *``` + * @hidden + */ + public isDateDisabled(date: Date) { + if (this.disabledDates === null) { + return false; + } + + return isDateInRanges(date, this.disabledDates); + } + + /** + * Selects date(s) (based on the selection type). + *```typescript + * this.calendar.selectDate(new Date(`2018-06-12`)); + *``` + */ + public selectDate(value: Date | Date[]) { + if (value === null || value === undefined || (Array.isArray(value) && value.length === 0)) { + return new Date(); + } + + switch (this.selection) { + case 'single': + this.selectSingle(value as Date); + break; + case 'multi': + this.selectMultiple(value); + break; + case 'range': + this.selectRange(value, true); + break; + } + } + + /** + * Deselects date(s) (based on the selection type). + *```typescript + * this.calendar.deselectDate(new Date(`2018-06-12`)); + *```` + */ + public deselectDate(value?: Date | Date[]) { + if (this.selectedDates === null || this.selectedDates === []) { + return; + } + + if (value === null || value === undefined) { + this.selectedDates = this.selection === 'single' ? null : []; + this.rangeStarted = false; + this._onChangeCallback(this.selectedDates); + return; + } + + switch (this.selection) { + case 'single': + this.deselectSingle(value as Date); + break; + case 'multi': + this.deselectMultiple(value as Date[]); + break; + case 'range': + this.deselectRange(value as Date[]); + break; + } + } + + /** + * @hidden + */ + public isCurrentMonth(value: Date): boolean { + return this.viewDate.getMonth() === value.getMonth(); + } + + /** + * @hidden + */ + public isCurrentYear(value: Date): boolean { + return this.viewDate.getFullYear() === value.getFullYear(); + } + + /** + * @hidden + */ + public selectDateFromClient(value: Date) { + switch (this.selection) { + case 'single': + case 'multi': + if (!this.isDateDisabled(value)) { + this.selectDate(value); + } + + break; + case 'range': + this.selectRange(value, true); + break; + } + } + + /** + *@hidden + */ + public focusActiveDate() { + let date = this.dates.find((d) => d.selected); + + if (!date) { + date = this.dates.find((d) => d.isToday); + } + + if (date) { + date.nativeElement.focus(); + } + } + + /** + * @hidden + */ + public generateWeekHeader(): string[] { + const dayNames = []; + const rv = this.calendarModel.monthdatescalendar(this.viewDate.getFullYear(), this.viewDate.getMonth())[0]; + for (const day of rv) { + dayNames.push(this.formatterWeekday.format(day.date)); + } + + return dayNames; + } + + /** + * Returns the locale representation of the date in the days view. + * + * @hidden + */ + public formattedDate(value: Date): string { + return this.formatterDay.format(value); + } + + /** + * @hidden + */ + public rowTracker(index, item): string { + return `${item[index].date.getMonth()}${item[index].date.getDate()}`; + } + + /** + * @hidden + */ + public dateTracker(index, item): string { + return `${item.date.getMonth()}--${item.date.getDate()}`; + } + + /** + * @hidden + */ + public selectDay(event) { + this.selectDateFromClient(event.date); + this.onDateSelection.emit(event); + + this.onSelection.emit(this.selectedDates); + } + + /** + * @hidden + */ + public animationDone(event, isLast: boolean) { + if (isLast) { + const date = this.dates.find((d) => d.selected); + if (date && !this.isKeydownTrigger) { + setTimeout(() => { + date.nativeElement.focus(); + }, parseInt(slideInRight.options.params.duration, 10)); + } else if (this.callback) { + setTimeout(() => { + this.callback(this.dates, this._nextDate); + }, parseInt(slideInRight.options.params.duration, 10)); + } + } + } + + /** + * @hidden + */ + @HostListener('keydown.arrowup', ['$event']) + public onKeydownArrowUp(event: KeyboardEvent) { + event.preventDefault(); + this.focusPreviousUpDate(event.target); + } + + /** + * @hidden + */ + @HostListener('keydown.arrowdown', ['$event']) + public onKeydownArrowDown(event: KeyboardEvent) { + event.preventDefault(); + this.focusNextDownDate(event.target); + } + + /** + * @hidden + */ + @HostListener('keydown.arrowleft', ['$event']) + public onKeydownArrowLeft(event: KeyboardEvent) { + event.preventDefault(); + this.focusPreviousDate(event.target); + } + + /** + * @hidden + */ + @HostListener('keydown.arrowright', ['$event']) + public onKeydownArrowRight(event: KeyboardEvent) { + event.preventDefault(); + this.focusNextDate(event.target); + } + + /** + * @hidden + */ + @HostListener('keydown.home', ['$event']) + public onKeydownHome(event: KeyboardEvent) { + event.preventDefault(); + + const dates = this.dates.filter(d => d.isCurrentMonth); + for (let i = 0; i < dates.length; i++) { + if (!dates[i].isDisabled) { + dates[i].nativeElement.focus(); + break; + } + } + } + + /** + * @hidden + */ + @HostListener('keydown.end', ['$event']) + public onKeydownEnd(event: KeyboardEvent) { + event.preventDefault(); + + const dates = this.dates.filter(d => d.isCurrentMonth); + for (let i = dates.length - 1; i >= 0; i--) { + if (!dates[i].isDisabled) { + dates[i].nativeElement.focus(); + break; + } + } + } +} diff --git a/projects/igniteui-angular/src/lib/calendar/index.ts b/projects/igniteui-angular/src/lib/calendar/index.ts index 1e7a72cf081..86e4e610112 100644 --- a/projects/igniteui-angular/src/lib/calendar/index.ts +++ b/projects/igniteui-angular/src/lib/calendar/index.ts @@ -1,4 +1,7 @@ export * from './calendar'; export * from './calendar.component'; +export * from './days-view/days-view.component'; +export * from './months-view/months-view.component'; +export * from './years-view/years-view.component'; export * from './calendar.directives'; export * from './calendar.module'; diff --git a/projects/igniteui-angular/src/lib/calendar/months-view/months-view.component.html b/projects/igniteui-angular/src/lib/calendar/months-view/months-view.component.html new file mode 100644 index 00000000000..dfc19af574d --- /dev/null +++ b/projects/igniteui-angular/src/lib/calendar/months-view/months-view.component.html @@ -0,0 +1,8 @@ +
+
+
+ {{ formattedMonth(month) | titlecase }} +
+
+
+ diff --git a/projects/igniteui-angular/src/lib/calendar/months-view/months-view.component.ts b/projects/igniteui-angular/src/lib/calendar/months-view/months-view.component.ts new file mode 100644 index 00000000000..eef7b559751 --- /dev/null +++ b/projects/igniteui-angular/src/lib/calendar/months-view/months-view.component.ts @@ -0,0 +1,372 @@ +import { + Component, + Output, + EventEmitter, + Input, + HostBinding, + HostListener, + ViewChildren, + QueryList, + ElementRef +} from '@angular/core'; +import { Calendar } from '../calendar'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { IgxCalendarMonthDirective } from '../calendar.directives'; + +let NEXT_ID = 0; + +@Component({ + providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: IgxMonthsViewComponent, multi: true }], + selector: 'igx-months-view', + templateUrl: 'months-view.component.html' +}) +export class IgxMonthsViewComponent implements ControlValueAccessor { + + /** + * Sets/gets the `id` of the months view. + * If not set, the `id` will have value `"igx-months-view-0"`. + * ```html + * + * ``` + * ```typescript + * let monthsViewId = this.monthsView.id; + * ``` + * @memberof IgxMonthsViewComponent + */ + @HostBinding('attr.id') + @Input() + public id = `igx-months-view-${NEXT_ID++}`; + + /** + * Gets/sets the selected date of the months view. + * By default it is the current date. + * ```html + * + * ``` + * ```typescript + * let date = this.monthsView.date; + * ``` + * @memberof IgxMonthsViewComponent + */ + @Input() + public date = new Date(); + + /** + * Gets the month format option of the months view. + * ```typescript + * let monthFormat = this.monthsView.monthFormat. + * ``` + */ + @Input() + public get monthFormat(): string { + return this._monthFormat; + } + + /** + * Sets the month format option of the months view. + * ```html + * [monthFormat] = "short'" + * ``` + * @memberof IgxMonthsViewComponent + */ + public set monthFormat(value: string) { + this._monthFormat = value; + this.initMonthFormatter(); + } + + /** + * Gets the `locale` of the months view. + * Default value is `"en"`. + * ```typescript + * let locale = this.monthsView.locale; + * ``` + * @memberof IgxMonthsViewComponent + */ + @Input() + public get locale(): string { + return this._locale; + } + + /** + * Sets the `locale` of the months view. + * Expects a valid BCP 47 language tag. + * Default value is `"en"`. + * ```html + * + * ``` + * @memberof IgxMonthsViewComponent + */ + public set locale(value: string) { + this._locale = value; + this.initMonthFormatter(); + } + + /** + * Emits an event when a selection is made in the months view. + * Provides reference the `date` property in the `IgxMonthsViewComponent`. + * ```html + * + * ``` + * @memberof IgxMonthsViewComponent + */ + @Output() + public onSelection = new EventEmitter(); + + /** + * The default css class applied to the component. + * + * @hidden + */ + @HostBinding('class.igx-calendar') + public styleClass = true; + + /** + * @hidden + */ + @ViewChildren(IgxCalendarMonthDirective, { read: IgxCalendarMonthDirective }) + public dates: QueryList; + + + /** + * The default `tabindex` attribute for the component. + * + * @hidden + */ + @HostBinding('attr.tabindex') + public tabindex = 0; + + /** + * Returns an array of date objects which are then used to + * properly render the month names. + * + * Used in the template of the component + * + * @hidden + */ + get months(): Date[] { + let start = new Date(this.date.getFullYear(), 0, 1); + const result = []; + + for (let i = 0; i < 12; i++) { + result.push(start); + start = this._calendarModel.timedelta(start, 'month', 1); + } + + return result; + } + + /** + *@hidden + */ + private _formatterMonth: any; + /** + *@hidden + */ + private _locale = 'en'; + /** + *@hidden + */ + private _monthFormat = 'short'; + /** + *@hidden + */ + private _calendarModel: Calendar; + + /** + *@hidden + */ + private _onTouchedCallback: () => void = () => { }; + /** + *@hidden + */ + private _onChangeCallback: (_: Date) => void = () => { }; + + constructor(public el: ElementRef) { + this.initMonthFormatter(); + this._calendarModel = new Calendar(); + } + + /** + * Returns the locale representation of the month in the months view. + * + * @hidden + */ + public formattedMonth(value: Date): string { + return this._formatterMonth.format(value); + } + + /** + *@hidden + */ + public selectMonth(event) { + this.onSelection.emit(event); + + this.date = event; + this._onChangeCallback(this.date); + } + + /** + * @hidden + */ + public registerOnChange(fn: (v: Date) => void) { + this._onChangeCallback = fn; + } + + /** + * @hidden + */ + public registerOnTouched(fn: () => void) { + this._onTouchedCallback = fn; + } + + /** + * @hidden + */ + public writeValue(value: Date) { + if (value) { + this.date = value; + } + } + + /** + *@hidden + */ + private initMonthFormatter() { + this._formatterMonth = new Intl.DateTimeFormat(this._locale, { month: this.monthFormat }); + } + + + /** + * @hidden + */ + @HostListener('keydown.arrowup', ['$event']) + public onKeydownArrowUp(event: KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + + const node = this.dates.find((date) => date.isCurrentMonth); + if (!node) { + return; + } + + const months = this.dates.toArray(); + if (months.indexOf(node) - 3 >= 0) { + const month = months[months.indexOf(node) - 3]; + + month.nativeElement.focus(); + + // TO DO: needs refactoring after styling!!!! + this.date = new Date(month.value.getFullYear(), month.value.getMonth(), this.date.getDate()); + } + } + + /** + * @hidden + */ + @HostListener('keydown.arrowdown', ['$event']) + public onKeydownArrowDown(event: KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + + const node = this.dates.find((date) => date.isCurrentMonth); + if (!node) { + return; + } + + const months = this.dates.toArray(); + if (months.indexOf(node) + 3 < months.length) { + const month = months[months.indexOf(node) + 3]; + + month.nativeElement.focus(); + + // TO DO: needs refactoring after styling!!!! + this.date = new Date(month.value.getFullYear(), month.value.getMonth(), this.date.getDate()); + } + } + + /** + * @hidden + */ + @HostListener('keydown.arrowright', ['$event']) + public onKeydownArrowRight(event: KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + + const node = this.dates.find((date) => date.isCurrentMonth); + if (!node) { return; } + + const months = this.dates.toArray(); + if (months.indexOf(node) + 1 < months.length) { + const month = months[months.indexOf(node) + 1]; + + month.nativeElement.focus(); + + // TO DO: needs refactoring after styling!!!! + this.date = new Date(month.value.getFullYear(), month.value.getMonth(), this.date.getDate()); + } + } + + /** + * @hidden + */ + @HostListener('keydown.arrowleft', ['$event']) + public onKeydownArrowLeft(event: KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + + const node = this.dates.find((date) => date.isCurrentMonth); + if (!node) { return; } + + const months = this.dates.toArray(); + if (months.indexOf(node) - 1 >= 0) { + const month = months[months.indexOf(node) - 1]; + + month.nativeElement.focus(); + + // TO DO: needs refactoring after styling!!!! + this.date = new Date(month.value.getFullYear(), month.value.getMonth(), this.date.getDate()); + } + } + + /** + * @hidden + */ + @HostListener('keydown.home', ['$event']) + public onKeydownHome(event: KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + + const month = this.dates.toArray()[0]; + + month.nativeElement.focus(); + + // TO DO: needs refactoring after styling!!!! + this.date = new Date(month.value.getFullYear(), month.value.getMonth(), this.date.getDate()); + } + + /** + * @hidden + */ + @HostListener('keydown.end', ['$event']) + public onKeydownEnd(event: KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + + const months = this.dates.toArray(); + const month = months[months.length - 1]; + + month.nativeElement.focus(); + + // TO DO: needs refactoring after styling!!!! + this.date = new Date(month.value.getFullYear(), month.value.getMonth(), this.date.getDate()); + } + + /** + * @hidden + */ + @HostListener('keydown.enter') + public onKeydownEnter() { + this.onSelection.emit(this.date); + this._onChangeCallback(this.date); + } +} diff --git a/projects/igniteui-angular/src/lib/calendar/years-view/years-view.component.html b/projects/igniteui-angular/src/lib/calendar/years-view/years-view.component.html new file mode 100644 index 00000000000..4f67b37afe9 --- /dev/null +++ b/projects/igniteui-angular/src/lib/calendar/years-view/years-view.component.html @@ -0,0 +1,7 @@ +
+
+ + {{ formattedYear(year) }} + +
+
diff --git a/projects/igniteui-angular/src/lib/calendar/years-view/years-view.component.ts b/projects/igniteui-angular/src/lib/calendar/years-view/years-view.component.ts new file mode 100644 index 00000000000..3180b841897 --- /dev/null +++ b/projects/igniteui-angular/src/lib/calendar/years-view/years-view.component.ts @@ -0,0 +1,300 @@ +import { Component, Output, EventEmitter, Input, HostBinding, HostListener, ElementRef, Injectable} from '@angular/core'; +import { range, Calendar } from '../../calendar'; +import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'; +import { HammerGestureConfig, HAMMER_GESTURE_CONFIG } from '@angular/platform-browser'; + +let NEXT_ID = 0; + +@Injectable() +export class CalendarHammerConfig extends HammerGestureConfig { + public overrides = { + pan: { direction: Hammer.DIRECTION_VERTICAL, threshold: 1 } + }; +} + +@Component({ + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: IgxYearsViewComponent, + multi: true + }, + { + provide: HAMMER_GESTURE_CONFIG, + useClass: CalendarHammerConfig + } + ], + selector: 'igx-years-view', + templateUrl: 'years-view.component.html' +}) +export class IgxYearsViewComponent implements ControlValueAccessor { + + /** + * Sets/gets the `id` of the years view. + * If not set, the `id` will have value `"igx-years-view-0"`. + * ```html + * + * ``` + * ```typescript + * let yearsViewId = this.yearsView.id; + * ``` + * @memberof IgxCalendarComponent + */ + @HostBinding('attr.id') + @Input() + public id = `igx-years-view-${NEXT_ID++}`; + + /** + * Gets/sets the selected date of the years view. + * By default it is the current date. + * ```html + * + * ``` + * ```typescript + * let date = this.yearsView.date; + * ``` + * @memberof IgxYearsViewComponent + */ + @Input() + public date = new Date(); + + /** + * Gets the year format option of the years view. + * ```typescript + * let yearFormat = this.yearsView.yearFormat. + * ``` + */ + @Input() + public get yearFormat(): string { + return this._yearFormat; + } + + /** + * Sets the year format option of the years view. + * ```html + * + * ``` + * @memberof IgxYearsViewComponent + */ + public set yearFormat(value: string) { + this._yearFormat = value; + this.initYearFormatter(); + } + + /** + * Gets the `locale` of the years view. + * Default value is `"en"`. + * ```typescript + * let locale = this.yearsView.locale; + * ``` + * @memberof IgxYearsViewComponent + */ + @Input() + public get locale(): string { + return this._locale; + } + + /** + * Sets the `locale` of the years view. + * Expects a valid BCP 47 language tag. + * Default value is `"en"`. + * ```html + * + * ``` + * @memberof IgxYearsViewComponent + */ + public set locale(value: string) { + this._locale = value; + this.initYearFormatter(); + } + + /** + * Emits an event when a selection is made in the years view. + * Provides reference the `date` property in the `IgxYearsViewComponent`. + * ```html + * + * ``` + * @memberof IgxYearsViewComponent + */ + @Output() + public onSelection = new EventEmitter(); + + /** + * The default css class applied to the component. + * + * @hidden + */ + @HostBinding('class.igx-calendar') + public styleClass = true; + + /** + * The default `tabindex` attribute for the component. + * + * @hidden + */ + @HostBinding('attr.tabindex') + public tabindex = 0; + + /** + * Returns an array of date objects which are then used to properly + * render the years. + * + * Used in the template of the component. + * + * @hidden + */ + get decade(): number[] { + const result = []; + const start = this.date.getFullYear() - 3; + const end = this.date.getFullYear() + 4; + + for (const year of range(start, end)) { + result.push(new Date(year, this.date.getMonth(), this.date.getDate())); + } + + return result; + } + + /** + *@hidden + */ + private _formatterYear: any; + /** + *@hidden + */ + private _locale = 'en'; + /** + *@hidden + */ + private _yearFormat = 'numeric'; + /** + *@hidden + */ + private _calendarModel: Calendar; + + /** + *@hidden + */ + private _onTouchedCallback: () => void = () => { }; + /** + *@hidden + */ + private _onChangeCallback: (_: Date) => void = () => { }; + + constructor(public el: ElementRef) { + this.initYearFormatter(); + this._calendarModel = new Calendar(); + } + + /** + * Returns the locale representation of the year in the years view. + * + * @hidden + */ + public formattedYear(value: Date): string { + return this._formatterYear.format(value); + } + + /** + *@hidden + */ + public selectYear(event) { + this.onSelection.emit(event); + + this.date = event; + this._onChangeCallback(this.date); + } + + /** + *@hidden + */ + public scroll(event) { + event.preventDefault(); + event.stopPropagation(); + + const delta = event.deltaY < 0 ? -1 : 1; + this.generateYearRange(delta); + } + + /** + *@hidden + */ + public pan(event) { + const delta = event.deltaY < 0 ? 1 : -1; + this.generateYearRange(delta); + } + + /** + * @hidden + */ + public registerOnChange(fn: (v: Date) => void) { + this._onChangeCallback = fn; + } + + /** + * @hidden + */ + public registerOnTouched(fn: () => void) { + this._onTouchedCallback = fn; + } + + /** + * @hidden + */ + public writeValue(value: Date) { + if (value) { + this.date = value; + } + } + + /** + * @hidden + */ + @HostListener('keydown.arrowdown', ['$event']) + public onKeydownArrowDown(event: KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + + this.generateYearRange(1); + } + + /** + * @hidden + */ + @HostListener('keydown.arrowup', ['$event']) + public onKeydownArrowUp(event: KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + + this.generateYearRange(-1); + } + + /** + * @hidden + */ + @HostListener('keydown.enter') + public onKeydownEnter() { + this.onSelection.emit(this.date); + this._onChangeCallback(this.date); + } + + /** + *@hidden + */ + private initYearFormatter() { + this._formatterYear = new Intl.DateTimeFormat(this._locale, { year: this.yearFormat }); + } + + /** + *@hidden + */ + private generateYearRange(delta: number) { + const currentYear = new Date().getFullYear(); + + if ((delta > 0 && this.date.getFullYear() - currentYear >= 95) || + (delta < 0 && currentYear - this.date.getFullYear() >= 95)) { + return; + } + this.date = this._calendarModel.timedelta(this.date, 'year', delta); + } +} diff --git a/projects/igniteui-angular/src/lib/date-picker/date-picker.component.spec.ts b/projects/igniteui-angular/src/lib/date-picker/date-picker.component.spec.ts index d9221e0758a..db43168481e 100644 --- a/projects/igniteui-angular/src/lib/date-picker/date-picker.component.spec.ts +++ b/projects/igniteui-angular/src/lib/date-picker/date-picker.component.spec.ts @@ -321,7 +321,7 @@ describe('IgxDatePicker', () => { fixture.detectChanges(); const targetDate = 15; - const fromDate = datePicker.calendar.dates.filter( + const fromDate = datePicker.calendar.daysView.dates.filter( d => d.date.date.getDate() === targetDate)[0]; fromDate.nativeElement.click(); fixture.detectChanges(); @@ -347,7 +347,7 @@ describe('IgxDatePicker', () => { fixture.detectChanges(); await wait(); - const todayDate = datePicker.calendar.dates.find(d => d.isToday); + const todayDate = datePicker.calendar.daysView.dates.find(d => d.isToday); expect(document.activeElement).toEqual(todayDate.nativeElement); }); diff --git a/projects/igniteui-angular/src/lib/date-picker/date-picker.component.ts b/projects/igniteui-angular/src/lib/date-picker/date-picker.component.ts index 5a1e8b2fa17..510999d75fb 100644 --- a/projects/igniteui-angular/src/lib/date-picker/date-picker.component.ts +++ b/projects/igniteui-angular/src/lib/date-picker/date-picker.component.ts @@ -673,7 +673,7 @@ export class IgxDatePickerComponent implements ControlValueAccessor, EditorProvi // Focus a date, after the celendar appearence into DOM. private _focusCalendarDate() { requestAnimationFrame(() => { - this.calendar.focusActiveDate(); + this.calendar.daysView.focusActiveDate(); }); } diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-filtering-ui.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/grid-filtering-ui.spec.ts index d3587faf64e..e3711629b83 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-filtering-ui.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-filtering-ui.spec.ts @@ -1135,7 +1135,7 @@ describe('IgxGrid - Filtering actions', () => { fix.detectChanges(); const calendar = fix.debugElement.query(By.css('igx-calendar')); - const currentDay = calendar.query(By.css('span.igx-calendar__date--current')); + const currentDay = calendar.query(By.css('igx-day-item.igx-calendar__date--current')); currentDay.nativeElement.click(); flush(); fix.detectChanges(); @@ -1173,7 +1173,7 @@ describe('IgxGrid - Filtering actions', () => { fix.detectChanges(); const calendar = fix.debugElement.query(By.css('igx-calendar')); - const currentDay = calendar.query(By.css('span.igx-calendar__date--current')); + const currentDay = calendar.query(By.css('igx-day-item.igx-calendar__date--current')); currentDay.nativeElement.click(); flush(); fix.detectChanges(); @@ -1258,7 +1258,7 @@ describe('IgxGrid - Filtering actions', () => { fix.detectChanges(); const calendar = fix.debugElement.query(By.css('igx-calendar')); - const currentDay = calendar.query(By.css('span.igx-calendar__date--current')); + const currentDay = calendar.query(By.css('igx-day-item.igx-calendar__date--current')); currentDay.nativeElement.click(); flush(); fix.detectChanges(); @@ -2773,7 +2773,7 @@ describe('IgxGrid - Filtering Row UI actions', () => { fix.detectChanges(); const calendar = fix.debugElement.query(By.css('igx-calendar')); - const sundayLabel = calendar.nativeElement.children[1].children[1].children[0].innerText; + const sundayLabel = calendar.nativeElement.children[1].children[1].children[0].children[0].innerText; expect(sundayLabel).toEqual('So'); })); diff --git a/projects/igniteui-angular/src/lib/month-picker/README.md b/projects/igniteui-angular/src/lib/month-picker/README.md new file mode 100644 index 00000000000..d6d3db63240 --- /dev/null +++ b/projects/igniteui-angular/src/lib/month-picker/README.md @@ -0,0 +1,124 @@ +# igxMonthPicker Component + +The **igxMonthPicker** provides a way for the user to select date(s). + + +## Dependencies +In order to be able to use **igxMonthPicker** you should keep in mind that it is dependent on **BrowserAnimationsModule**, +which must be imported **only once** in your application's AppModule, for example: +```typescript +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +@NgModule({ + imports: [ + BrowserAnimationsModule, + ... + ] +}) +export class AppModule { +} +``` +Also the **igxMonthPicker** uses the [Intl](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat) WebAPI for localization and formatting of dates. Consider using the [appropriate polyfills](https://github.com/andyearnshaw/Intl.js/) if your target platform does not support them. + + +## Usage + +Importing the month picker in your application +```typescript +import { IgxMonthPickerComponent } from "igniteui-angular"; +``` + +Instantiate a month picker component and pass a date object. +```html + +``` + +The **igxMonthPicker** implements the `ControlValueAccessor` interface, providing two-way data-binding +and the expected behavior when used both in Template-driven or Reactive Forms. +```html + +``` + +Customize the format and set the locale +```typescript + public formatOptions = { + month: 'long', + year: 'numeric' + }; + + public localeDe = 'de'; +``` + +```html + +``` + +### Keyboard navigation +When the **igxMonthPicker** component is focused: +- `PageUp` will move to the previous year. +- `PageDown` will move to the next year. +- `Home` will focus the first month of the current year. +- `End` will focus the last month of the current year. +- `Tab` will navigate through the subheader buttons; + +When `prev` or `next` year buttons (in the subheader) are focused: +- `Space` or `Enter` will scroll into view the next or previous year. + +When `years` button (in the subheader) is focused: +- `Space` or `Enter` will open the years view. + +When a month inside the months view is focused: +- Arrow keys will navigate through the months. +- `Home` will focus the first month inside the months view. +- `End` will focus the last month inside the months view. +- `Enter` will select the currently focused month and close the view. + + +## API Summary + +### Inputs + +- `id: string` + +Unique identifier of the component. If not provided it will be automatically generated. + +- `locale: string` + +Controls the locale used for formatting and displaying the dates in the month picker. +The expected string should be a [BCP 47 language tag](http://tools.ietf.org/html/rfc5646). +The default value is `en`. + +- `viewDate: Date` + +Controls the year/month that will be presented in the default view when the month picker renders. By default it is the current year/month. + +- `value: Date` + +Gets and sets the selected date in the month picker component. + +- `formatOptions: Object` + +Controls the date-time components to use in formatted output, and their desired representations. +Consult [this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat) +for additional information on the available options. + +The defaul values are listed below. +```typescript +{ day: 'numeric', month: 'short', weekday: 'short', year: 'numeric' } +``` + +- `formatViews: Object` + +Controls whether the date parts in the different month picker views should be formatted according to the provided +`locale` and `formatOptions`. + +The default values are listed below. +```typescript +{ day: false, month: true, year: false } +``` + +### Outputs + +- `onSelection(): Date | Date[]` + +Event fired when a value is selected through UI interaction. +Returns the selected value (depending on the type of selection). diff --git a/projects/igniteui-angular/src/lib/month-picker/month-picker.component.html b/projects/igniteui-angular/src/lib/month-picker/month-picker.component.html new file mode 100644 index 00000000000..e97cce97441 --- /dev/null +++ b/projects/igniteui-angular/src/lib/month-picker/month-picker.component.html @@ -0,0 +1,29 @@ +
+
+
+ keyboard_arrow_left +
+
+ + {{ formattedYear(viewDate) }} + +
+
+ keyboard_arrow_right +
+
+ + + +
+ + diff --git a/projects/igniteui-angular/src/lib/month-picker/month-picker.component.spec.ts b/projects/igniteui-angular/src/lib/month-picker/month-picker.component.spec.ts new file mode 100644 index 00000000000..96a9352921c --- /dev/null +++ b/projects/igniteui-angular/src/lib/month-picker/month-picker.component.spec.ts @@ -0,0 +1,368 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { UIInteractions } from '../test-utils/ui-interactions.spec'; +import { configureTestSuite } from '../test-utils/configure-suite'; +import { IgxMonthPickerComponent, IgxMonthPickerModule } from './month-picker.component'; + +describe('IgxMonthPicker', () => { + configureTestSuite(); + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [IgxMonthPickerSampleComponent], + imports: [IgxMonthPickerModule, FormsModule, NoopAnimationsModule] + }).compileComponents(); + }); + + it('should initialize a month picker component', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + expect(fixture.componentInstance).toBeDefined(); + }); + + it('should initialize a month picker component with `id` property', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const monthPicker = fixture.componentInstance.monthPicker; + + expect(monthPicker.id).toBe('igx-month-picker-1'); + + monthPicker.id = 'custom'; + fixture.detectChanges(); + + expect(monthPicker.id).toBe('custom'); + }); + + it('should properly render month picker DOM structure', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + + const months = dom.queryAll(By.css('.igx-calendar__month')); + const current = dom.query(By.css('.igx-calendar__month--current')); + + expect(months.length).toEqual(11); + expect(current.nativeElement.textContent.trim()).toMatch('Feb'); + + dom.queryAll(By.css('.igx-calendar-picker__date'))[0].nativeElement.click(); + fixture.detectChanges(); + + const years = dom.queryAll(By.css('.igx-calendar__year')); + const currentYear = dom.query(By.css('.igx-calendar__year--current')); + + expect(years.length).toEqual(6); + expect(currentYear.nativeElement.textContent.trim()).toMatch('2019'); + }); + + it('should properly set @Input properties and setters', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const instance = fixture.componentInstance; + const monthPicker = fixture.componentInstance.monthPicker; + + const format = { + day: '2-digit', + month: 'long', + weekday: 'long', + year: '2-digit' + }; + + expect(monthPicker.value).toBeUndefined(); + expect(monthPicker.viewDate.getDate()).toEqual(instance.viewDate.getDate()); + expect(monthPicker.locale).toEqual('en'); + + const today = new Date(Date.now()); + monthPicker.viewDate = today; + monthPicker.value = today; + instance.locale = 'fr'; + instance.formatOptions = format; + fixture.detectChanges(); + + expect(monthPicker.locale).toEqual('fr'); + expect(monthPicker.formatOptions.year).toEqual('2-digit'); + expect(monthPicker.value.getDate()).toEqual(today.getDate()); + expect(monthPicker.viewDate.getDate()).toEqual(today.getDate()); + }); + + it('should properly set formatOptions and formatViews', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + + const defaultOptions = { + day: 'numeric', + month: 'short', + weekday: 'short', + year: 'numeric' + }; + const defaultViews = { day: false, month: true, year: false }; + + const yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + const month = dom.queryAll(By.css('.igx-calendar__month'))[0]; + + expect(monthPicker.formatOptions).toEqual(jasmine.objectContaining(defaultOptions)); + expect(monthPicker.formatViews).toEqual(jasmine.objectContaining(defaultViews)); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2019'); + expect(month.nativeElement.textContent.trim()).toMatch('Jan'); + + const formatOptions: any = { month: 'long', year: '2-digit' }; + const formatViews: any = { month: true, year: true }; + + monthPicker.formatViews = formatViews; + monthPicker.formatOptions = formatOptions; + fixture.detectChanges(); + + const march = dom.queryAll(By.css('.igx-calendar__month'))[1]; + + expect(monthPicker.formatOptions).toEqual(jasmine.objectContaining(Object.assign(defaultOptions, formatOptions))); + expect(monthPicker.formatViews).toEqual(jasmine.objectContaining(Object.assign(defaultViews, formatViews))); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('19'); + expect(march.nativeElement.textContent.trim()).toMatch('March'); + + yearBtn.nativeElement.click(); + fixture.detectChanges(); + const year = dom.queryAll(By.css('.igx-calendar__year'))[0]; + + expect(year.nativeElement.textContent.trim()).toMatch('16'); + }); + + it('should properly set locale', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + + const locale = 'de'; + monthPicker.locale = locale; + fixture.detectChanges(); + + const yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + const month = dom.queryAll(By.css('.igx-calendar__month'))[1]; + + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2019'); + expect(month.nativeElement.textContent.trim()).toMatch('Mär'); + }); + + it('should select a month on click', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + + const months = dom.queryAll(By.css('.igx-calendar__month')); + + spyOn(monthPicker.onSelection, 'emit'); + + months[1].nativeElement.click(); + fixture.detectChanges(); + + const currentMonth = dom.query(By.css('.igx-calendar__month--current')); + + expect(monthPicker.onSelection.emit).toHaveBeenCalled(); + expect(currentMonth.nativeElement.textContent.trim()).toEqual('Mar'); + + const nextDay = new Date(2019, 2, 7); + expect(fixture.componentInstance.model.getDate()).toEqual(nextDay.getDate()); + }); + + it('should select a month through API', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + + const nextDay = new Date(2022, 3, 14); + + monthPicker.selectDate(nextDay); + fixture.detectChanges(); + + const currentMonth = dom.query(By.css('.igx-calendar__month--current')); + const yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + + expect(currentMonth.nativeElement.textContent.trim()).toEqual('Apr'); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2019'); + }); + + it('should navigate to the previous/next year.', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + + + const prev = dom.query(By.css('.igx-calendar-picker__prev')); + const next = dom.query(By.css('.igx-calendar-picker__next')); + const yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2019'); + + prev.nativeElement.click(); + fixture.detectChanges(); + + expect(monthPicker.viewDate.getFullYear()).toEqual(2018); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2018'); + + next.nativeElement.click(); + next.nativeElement.click(); + next.nativeElement.click(); + fixture.detectChanges(); + + expect(monthPicker.viewDate.getFullYear()).toEqual(2021); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2021'); + }); + + it('should navigate to the previous/next year via KB.', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + + const prev = dom.query(By.css('.igx-calendar-picker__prev')); + const next = dom.query(By.css('.igx-calendar-picker__next')); + const yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2019'); + + prev.nativeElement.focus(); + + expect(prev.nativeElement).toBe(document.activeElement); + + UIInteractions.simulateKeyDownEvent(prev.nativeElement, 'Enter'); + fixture.detectChanges(); + + expect(monthPicker.viewDate.getFullYear()).toEqual(2018); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2018'); + + next.nativeElement.focus(); + + expect(next.nativeElement).toBe(document.activeElement); + + UIInteractions.simulateKeyDownEvent(next.nativeElement, 'Enter'); + UIInteractions.simulateKeyDownEvent(next.nativeElement, 'Enter'); + UIInteractions.simulateKeyDownEvent(next.nativeElement, 'Enter'); + fixture.detectChanges(); + + expect(monthPicker.viewDate.getFullYear()).toEqual(2021); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2021'); + }); + + it('should open years view, navigate through and select an year via KB.', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + + let year = dom.query(By.css('.igx-calendar-picker__date')); + year.nativeElement.focus(); + + expect(year.nativeElement).toBe(document.activeElement); + + UIInteractions.simulateKeyDownEvent(document.activeElement, 'Enter'); + fixture.detectChanges(); + + let currentYear = dom.query(By.css('.igx-calendar__year--current')); + + UIInteractions.simulateKeyDownEvent(currentYear.nativeElement, 'ArrowDown'); + fixture.detectChanges(); + + currentYear = dom.query(By.css('.igx-calendar__year--current')); + expect(currentYear.nativeElement.textContent.trim()).toMatch('2020'); + + UIInteractions.simulateKeyDownEvent(currentYear.nativeElement, 'ArrowUp'); + UIInteractions.simulateKeyDownEvent(currentYear.nativeElement, 'ArrowUp'); + fixture.detectChanges(); + + currentYear = dom.query(By.css('.igx-calendar__year--current')); + expect(currentYear.nativeElement.textContent.trim()).toMatch('2018'); + + UIInteractions.simulateKeyDownEvent(currentYear.nativeElement, 'Enter'); + fixture.detectChanges(); + + year = dom.query(By.css('.igx-calendar-picker__date')); + + expect(monthPicker.viewDate.getFullYear()).toEqual(2018); + expect(year.nativeElement.textContent.trim()).toMatch('2018'); + }); + + it('should navigate through and select a month via KB.', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + + const months = dom.queryAll(By.css('.igx-calendar__month')); + let currentMonth = dom.query(By.css('.igx-calendar__month--current')); + + expect(months.length).toEqual(11); + expect(currentMonth.nativeElement.textContent.trim()).toMatch('Feb'); + + UIInteractions.simulateKeyDownEvent(currentMonth.nativeElement, 'Home'); + fixture.detectChanges(); + + currentMonth = dom.query(By.css('.igx-calendar__month--current')); + expect(currentMonth.nativeElement.textContent.trim()).toMatch('Jan'); + + UIInteractions.simulateKeyDownEvent(currentMonth.nativeElement, 'End'); + fixture.detectChanges(); + + currentMonth = dom.query(By.css('.igx-calendar__month--current')); + expect(currentMonth.nativeElement.textContent.trim()).toMatch('Dec'); + + UIInteractions.simulateKeyDownEvent(currentMonth.nativeElement, 'ArrowLeft'); + fixture.detectChanges(); + + currentMonth = dom.query(By.css('.igx-calendar__month--current')); + UIInteractions.simulateKeyDownEvent(currentMonth.nativeElement, 'ArrowUp'); + fixture.detectChanges(); + + currentMonth = dom.query(By.css('.igx-calendar__month--current')); + UIInteractions.simulateKeyDownEvent(currentMonth.nativeElement, 'ArrowRight'); + fixture.detectChanges(); + + currentMonth = dom.query(By.css('.igx-calendar__month--current')); + expect(currentMonth.nativeElement.textContent.trim()).toMatch('Sep'); + + UIInteractions.simulateKeyDownEvent(currentMonth.nativeElement, 'Enter'); + fixture.detectChanges(); + + expect(monthPicker.viewDate.getMonth()).toEqual(8); + }); +}); + +@Component({ + template: ` + ` +}) +export class IgxMonthPickerSampleComponent { + public model: Date = new Date(2019, 1, 7); + public viewDate = new Date(2019, 1, 7); + public locale = 'en'; + + formatOptions = { + day: 'numeric', + month: 'short', + weekday: 'short', + year: 'numeric' + }; + + @ViewChild(IgxMonthPickerComponent) public monthPicker: IgxMonthPickerComponent; +} diff --git a/projects/igniteui-angular/src/lib/month-picker/month-picker.component.ts b/projects/igniteui-angular/src/lib/month-picker/month-picker.component.ts new file mode 100644 index 00000000000..107fb9687cb --- /dev/null +++ b/projects/igniteui-angular/src/lib/month-picker/month-picker.component.ts @@ -0,0 +1,228 @@ +import { + Component, + NgModule, + HostListener, + ElementRef, + ViewChild, + HostBinding, + Input +} from '@angular/core'; +import { IgxCalendarModule, CalendarView, IgxCalendarComponent, IgxMonthsViewComponent } from '../calendar/index'; +import { IgxIconModule } from '../icon/index'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { trigger, transition, useAnimation } from '@angular/animations'; +import { fadeIn, scaleInCenter, slideInLeft, slideInRight } from '../animations/main'; +import { KEYS } from '../core/utils'; + +let NEXT_ID = 0; +@Component({ + providers: [ + { + multi: true, + provide: NG_VALUE_ACCESSOR, + useExisting: IgxMonthPickerComponent + } + ], + animations: [ + trigger('animateView', [ + transition('void => 0', useAnimation(fadeIn)), + transition('void => *', useAnimation(scaleInCenter, { + params: { + duration: '.2s', + fromScale: .9 + } + })) + ]), + trigger('animateChange', [ + transition('* => prev', useAnimation(slideInLeft, { + params: { + fromPosition: 'translateX(-30%)' + } + })), + transition('* => next', useAnimation(slideInRight, { + params: { + fromPosition: 'translateX(30%)' + } + })) + ]) + ], + selector: 'igx-month-picker', + templateUrl: 'month-picker.component.html' +}) +export class IgxMonthPickerComponent extends IgxCalendarComponent { + /** + * Sets/gets the `id` of the month picker. + * If not set, the `id` will have value `"igx-month-picker-0"`. + */ + @HostBinding('attr.id') + @Input() + public id = `igx-month-picker-${NEXT_ID++}`; + + /** + * @hidden + */ + public yearAction = ''; + + /** + * @hidden + */ + @ViewChild('yearsBtn') + public yearsBtn: ElementRef; + + /** + * @hidden + */ + @ViewChild('months', {read: IgxMonthsViewComponent}) + public monthsView: IgxMonthsViewComponent; + + /** + * @hidden + */ + public animationDone() { + this.yearAction = ''; + } + + /** + * @hidden + */ + public nextYear() { + this.yearAction = 'next'; + super.nextYear(); + } + + /** + * @hidden + */ + public nextYearKB(event) { + if (event.key === KEYS.SPACE || event.key === KEYS.SPACE_IE || event.key === KEYS.ENTER) { + event.preventDefault(); + event.stopPropagation(); + + this.nextYear(); + } + } + + /** + * @hidden + */ + public previousYear() { + this.yearAction = 'prev'; + super.previousYear(); + } + + /** + * @hidden + */ + public previousYearKB(event) { + if (event.key === KEYS.SPACE || event.key === KEYS.SPACE_IE || event.key === KEYS.ENTER) { + event.preventDefault(); + event.stopPropagation(); + + this.previousYear(); + } + } + + /** + * @hidden + */ + public selectMonth(event: Date) { + this.viewDate = new Date(event.getFullYear(), event.getMonth(), event.getDate()); + this._onChangeCallback(this.viewDate); + + this.onSelection.emit(this.viewDate); + } + + /** + * @hidden + */ + public selectYear(event: Date) { + this.viewDate = new Date(event.getFullYear(), event.getMonth(), event.getDate()); + this.activeView = CalendarView.DEFAULT; + + requestAnimationFrame(() => { + this.yearsBtn.nativeElement.focus(); + }); + } + + /** + * Selects a date. + *```typescript + * this.monPicker.selectDate(new Date(`2018-06-12`)); + *``` + */ + public selectDate(value: Date) { + if (!value) { + return new Date(); + } + + // TO DO: to be refactored after discussion on the desired behavior + super.selectDate(value); + this.viewDate.setMonth(value.getMonth()); + } + + /** + * @hidden + */ + public writeValue(value: Date) { + + // TO DO: to be refactored after discussion on the desired behavior + if (value) { + this.viewDate.setMonth(value.getMonth()); + } + } + + /** + * @hidden + */ + @HostListener('keydown.pageup', ['$event']) + public onKeydownPageUp(event: KeyboardEvent) { + super.onKeydownShiftPageUp(event); + } + + /** + * @hidden + */ + @HostListener('keydown.pagedown', ['$event']) + public onKeydownPageDown(event: KeyboardEvent) { + super.onKeydownShiftPageDown(event); + } + + /** + * @hidden + */ + @HostListener('keydown.shift.pageup', ['$event']) + @HostListener('keydown.shift.pagedown', ['$event']) + public onKeydownShiftPageDownUp(event: KeyboardEvent) { + event.stopPropagation(); + } + + /** + * @hidden + */ + @HostListener('keydown.home', ['$event']) + public onKeydownHome(event: KeyboardEvent) { + if (this.monthsView) { + this.monthsView.el.nativeElement.focus(); + this.monthsView.onKeydownHome(event); + } + } + + /** + * @hidden + */ + @HostListener('keydown.end', ['$event']) + public onKeydownEnd(event: KeyboardEvent) { + if (this.monthsView) { + this.monthsView.el.nativeElement.focus(); + this.monthsView.onKeydownEnd(event); + } + } +} + +@NgModule({ + declarations: [IgxMonthPickerComponent], + exports: [IgxMonthPickerComponent], + imports: [CommonModule, IgxIconModule, IgxCalendarModule] +}) +export class IgxMonthPickerModule { } diff --git a/projects/igniteui-angular/src/lib/services/overlay/overlay.spec.ts b/projects/igniteui-angular/src/lib/services/overlay/overlay.spec.ts index 6e516a23170..23f833cb9e1 100644 --- a/projects/igniteui-angular/src/lib/services/overlay/overlay.spec.ts +++ b/projects/igniteui-angular/src/lib/services/overlay/overlay.spec.ts @@ -3267,7 +3267,7 @@ describe('igxOverlay', () => { overlay.show(IgxCalendarComponent); // EXPECT fixture.detectChanges(); - expect(document.querySelectorAll((IGX_CALENDAR_CLASS)).length).toEqual(1); + expect(document.querySelectorAll((IGX_CALENDAR_CLASS)).length).toEqual(2); overlay.hideAll(); tick(); fixture.detectChanges(); diff --git a/projects/igniteui-angular/src/public_api.ts b/projects/igniteui-angular/src/public_api.ts index 49d3f7e4863..d4d33e1b715 100644 --- a/projects/igniteui-angular/src/public_api.ts +++ b/projects/igniteui-angular/src/public_api.ts @@ -60,6 +60,7 @@ export * from './lib/checkbox/checkbox.component'; export * from './lib/chips/index'; export * from './lib/combo/index'; export * from './lib/date-picker/date-picker.component'; +export * from './lib/month-picker/month-picker.component'; export * from './lib/dialog/dialog.component'; export * from './lib/drop-down/index'; export * from './lib/grids/grid/index'; diff --git a/src/app/app.component.ts b/src/app/app.component.ts index e8727d1d69a..a35539c325d 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -63,6 +63,11 @@ export class AppComponent implements OnInit { icon: 'event', name: 'Calendar' }, + { + link: '/calendar-views', + icon: 'event', + name: 'Calendar Views' + }, { link: '/card', icon: 'home', diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4e1e42d5fdf..255a06a1091 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -82,6 +82,7 @@ import { GridColumnPercentageWidthsSampleComponent } from './grid-percentage-col import { DisplayFormatPipe, InputFormatPipe } from './mask/mask.sample'; import { BannerSampleComponent } from './banner/banner.sample'; import { TreeGridWithTransactionsComponent } from './tree-grid/tree-grid-with-transactions.component'; +import { CalendarViewsSampleComponent } from './calendar-views/calendar-views.sample'; import { SelectSampleComponent } from './select/select.sample'; import { GridSearchBoxComponent } from './grid-search-box/grid-search-box.component'; import { GridSearchComponent } from './grid-search/grid-search.sample'; @@ -163,6 +164,7 @@ const components = [ DisplayFormatPipe, InputFormatPipe, GridColumnPercentageWidthsSampleComponent, + CalendarViewsSampleComponent, GridSearchBoxComponent, GridSearchComponent ]; diff --git a/src/app/app.routing.ts b/src/app/app.routing.ts index a6a3e7765d2..07434da00c5 100644 --- a/src/app/app.routing.ts +++ b/src/app/app.routing.ts @@ -49,6 +49,7 @@ import { HierarchicalGridRemoteSampleComponent } from './hierarchical-grid-remot import { HierarchicalGridUpdatingSampleComponent } from './hierarchical-grid-updating/hierarchical-grid-updating.sample'; import { GridColumnPercentageWidthsSampleComponent } from './grid-percentage-columns/grid-percantge-widths.sample'; import { BannerSampleComponent } from './banner/banner.sample'; +import { CalendarViewsSampleComponent } from './calendar-views/calendar-views.sample'; import { AutocompleteSampleComponent } from './autocomplete/autocomplete.sample'; import { SelectSampleComponent } from './select/select.sample'; @@ -86,6 +87,10 @@ const appRoutes = [ path: 'calendar', component: CalendarSampleComponent }, + { + path: 'calendar-views', + component: CalendarViewsSampleComponent + }, { path: 'card', component: CardSampleComponent diff --git a/src/app/calendar-views/calendar-views.sample.html b/src/app/calendar-views/calendar-views.sample.html new file mode 100644 index 00000000000..b76293f7631 --- /dev/null +++ b/src/app/calendar-views/calendar-views.sample.html @@ -0,0 +1,81 @@ +
+ Calendar Views +
+
+
+

Default Calendar

+
+ + +
+ + + + +
+ {{ dates }} +
+
+
+

Month Picker

+ + + + +
+ {{ date1 }} +
+
+
+
+
+
+ + + + +
+
+
+
+ + + + +
+
+
+
+ + +
+
+ + + + +
+
+
+ {{ date }} +
+
diff --git a/src/app/calendar-views/calendar-views.sample.scss b/src/app/calendar-views/calendar-views.sample.scss new file mode 100644 index 00000000000..b8d4888980f --- /dev/null +++ b/src/app/calendar-views/calendar-views.sample.scss @@ -0,0 +1,11 @@ +.calendar-sample + .calendar-sample { + margin-top: 48px; +} + +.calendar-sample-buttons { + margin-bottom: 16px; + + button { + margin-right: 8px; + } +} diff --git a/src/app/calendar-views/calendar-views.sample.ts b/src/app/calendar-views/calendar-views.sample.ts new file mode 100644 index 00000000000..341bec8c153 --- /dev/null +++ b/src/app/calendar-views/calendar-views.sample.ts @@ -0,0 +1,92 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { IgxCalendarComponent, DateRangeType, IgxMonthPickerComponent } from 'igniteui-angular'; +import { IgxDaysViewComponent } from 'projects/igniteui-angular/src/lib/calendar/days-view/days-view.component'; + +@Component({ + selector: 'app-calendar-views-sample', + templateUrl: 'calendar-views.sample.html', + styleUrls: ['calendar-views.sample.scss'] +}) +export class CalendarViewsSampleComponent implements OnInit { + @ViewChild('calendar') calendar: IgxCalendarComponent; + @ViewChild('daysView') daysView: IgxDaysViewComponent; + @ViewChild('mp') monthPicker: IgxMonthPickerComponent; + + dates: Date | Date[]; + date = new Date(2018, 8, 5); + date1 = new Date(2019, 1, 7); + + viewDate = new Date(2019, 1, 7); + selection = new Date(2018, 1, 13); + + locale = 'en'; + localeFr = 'fr'; + localeDe = 'de' + + formatOptions = { + day: '2-digit', + month: 'long', + weekday: 'long', + year: 'numeric' + } + + disabledDates = [{ + type: DateRangeType.Between, + dateRange: [ + new Date(2019, 0, 14), + new Date(2019, 0, 21) + ] + }]; + + specialDates = [{ + type: DateRangeType.Specific, + dateRange: [ + new Date(2019, 0, 7), + new Date(2019, 0, 9) + ] + }]; + + ngOnInit() { + this.dates = [ + new Date(2019, 1, 7), + new Date(2019, 1, 8) + ]; + + this.daysView.disabledDates = [{ + type: DateRangeType.Between, dateRange: [ + new Date(2019, 1, 22), + new Date(2019, 1, 25) + ] + }]; + this.daysView.specialDates = [{ + type: DateRangeType.Specific, dateRange: [ + new Date(2019, 0, 7), + new Date(2019, 1, 11) + ] + }]; + } + + onSelection(event) { + console.log(event); + } + + select() { + // this.calendar.selectDate(new Date(2019, 1, 13)); + this.calendar.selectDate([new Date(2019, 1, 13), new Date(2019, 1, 14)]); + } + + deselect() { + // this.calendar.deselectDate(new Date(2019, 1, 13)); + this.calendar.deselectDate([new Date(2019, 1, 7), new Date(2019, 1, 8), new Date(2019, 1, 13), new Date(2019, 1, 14)]); + } + + selectDV() { + this.daysView.selectDate(new Date(2019, 1, 13)); + // this.daysView.selectDate([new Date(2019, 1, 13), new Date(2019, 1, 14)]); + } + + deselectDV() { + this.daysView.deselectDate(new Date(2019, 1, 13)); + // this.daysView.deselectDate([new Date(2019, 1, 13), new Date(2019, 1, 14)]); + } +} diff --git a/src/app/routing.ts b/src/app/routing.ts index c18ee2d199e..3a47d048a81 100644 --- a/src/app/routing.ts +++ b/src/app/routing.ts @@ -61,6 +61,7 @@ import { HierarchicalGridRemoteSampleComponent } from './hierarchical-grid-remot import { HierarchicalGridUpdatingSampleComponent } from './hierarchical-grid-updating/hierarchical-grid-updating.sample'; import { GridColumnPercentageWidthsSampleComponent } from './grid-percentage-columns/grid-percantge-widths.sample'; import { BannerSampleComponent } from './banner/banner.sample'; +import { CalendarViewsSampleComponent } from './calendar-views/calendar-views.sample'; import { SelectSampleComponent } from './select/select.sample'; import { GridSearchComponent } from './grid-search/grid-search.sample'; import { AutocompleteSampleComponent } from './autocomplete/autocomplete.sample'; @@ -99,6 +100,10 @@ const appRoutes = [ path: 'calendar', component: CalendarSampleComponent }, + { + path: 'calendar-views', + component: CalendarViewsSampleComponent + }, { path: 'card', component: CardSampleComponent diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index abfa6badabb..371f5a06606 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -39,6 +39,7 @@ import { IgxTooltipModule, IgxSelectModule } from 'igniteui-angular'; +import { IgxMonthPickerModule } from 'projects/igniteui-angular/src/lib/month-picker/month-picker.component'; const igniteModules = [ @@ -79,6 +80,7 @@ const igniteModules = [ IgxToastModule, IgxToggleModule, IgxTooltipModule, + IgxMonthPickerModule, IgxSelectModule ];