diff --git a/CHANGELOG.md b/CHANGELOG.md index b34431f83c1..412ccd981e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes for each version of this project will be documented in this file. +## Unreleased +### New Features + +- `IgxDateRangePicker` + - Now has a complete set of properties to customize the calendar: + - `headerOrientation` + - `orientation` + - `hideHeader` + - `activeDate` + - `disabledDates` + - `specialDates` + + - As well as the following templates, available to customize the contents of the calendar header in `dialog` mode: + + - `igxCalendarHeader` + - `igxCalendarHeaderTitle` + - `igxCalendarSubheader` + - **Behavioral Changes** + - The calendar would be displayed with header in `dialog` mode by default. + - The picker would remain open when typing (in two-inputs and `dropdown` mode). + - The calendar selection would get updated with the typed value. + - The calendar view would be updated as per the typed value. + - The picker would display a clear icon by default in single input mode. +- `IgxDatePicker` + - Similar to the `IgxDateRangePicker`, also completes the ability to customize the calendar by introducing the following + properties in addition to the existing ones: + - `hideHeader` + - `orientation` + - `activeDate` + - **Behavioral Changes** + - The calendar selection would get updated with the typed value. + - The calendar view would be updated as per the typed date value. + + ## 20.1.0 ### New Features - `IgxCarousel` diff --git a/projects/igniteui-angular/src/lib/calendar/calendar-base.ts b/projects/igniteui-angular/src/lib/calendar/calendar-base.ts index 94f0a85d2a0..f1df4db3341 100644 --- a/projects/igniteui-angular/src/lib/calendar/calendar-base.ts +++ b/projects/igniteui-angular/src/lib/calendar/calendar-base.ts @@ -707,7 +707,7 @@ export class IgxCalendarBaseDirective implements ControlValueAccessor { switch (this.selection) { case CalendarSelection.SINGLE: - if (isDate(value) && !this.isDateDisabled(value as Date)) { + if (isDate(value)) { this.selectSingle(value as Date); } break; @@ -715,7 +715,7 @@ export class IgxCalendarBaseDirective implements ControlValueAccessor { this.selectMultiple(value); break; case CalendarSelection.RANGE: - this.selectRange(value, true); + this.selectRange(value); break; } } @@ -807,9 +807,7 @@ export class IgxCalendarBaseDirective implements ControlValueAccessor { : [value, this.lastSelectedDate]; const unselectedDates = [this._startDate, ...this.generateDateRange(this._startDate, this._endDate)] - .filter(date => !this.isDateDisabled(date) - && this.selectedDates.every((d: Date) => d.getTime() !== date.getTime()) - ); + .filter(date => this.selectedDates.every((d: Date) => d.getTime() !== date.getTime())); // select all dates from last selected to shift clicked date if (this.selectedDates.some((date: Date) => date.getTime() === this.lastSelectedDate.getTime()) 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 9da0f57cb38..2e9696dac8a 100644 --- a/projects/igniteui-angular/src/lib/calendar/calendar.component.spec.ts +++ b/projects/igniteui-angular/src/lib/calendar/calendar.component.spec.ts @@ -1767,7 +1767,8 @@ describe("IgxCalendar - ", () => { }); it("Should not select date from model, if it is part of disabled dates", () => { - expect(calendar.value).toBeFalsy(); + // Changed per WC alignment task #16131 - calendar should not block selection of dates through API/model + expect(calendar.value).toBeTruthy(); }); it("Should not select date from model in range selection, if model passes null", () => { diff --git a/projects/igniteui-angular/src/lib/calendar/common/helpers.ts b/projects/igniteui-angular/src/lib/calendar/common/helpers.ts index 4d4d0c1a30a..8971144bb59 100644 --- a/projects/igniteui-angular/src/lib/calendar/common/helpers.ts +++ b/projects/igniteui-angular/src/lib/calendar/common/helpers.ts @@ -88,20 +88,34 @@ export function getClosestActiveDate( * @remarks * By default, `unit` is set to 'day'. */ -export function* calendarRange(options: CalendarRangeParams) { - let low = toCalendarDay(options.start); - const unit = options.unit ?? "day"; - const high = - typeof options.end === "number" - ? low.add(unit, options.end) - : toCalendarDay(options.end); - - const reverse = high.lessThan(low); - const step = reverse ? -1 : 1; - - while (!reverse ? low.lessThan(high) : low.greaterThan(high)) { - yield low; - low = low.add(unit, step); +export function* calendarRange( + options: CalendarRangeParams +): Generator { + const { start, end, unit = 'day', inclusive = false } = options; + + let currentDate = toCalendarDay(start); + const endDate = + typeof end === 'number' + ? toCalendarDay(start).add(unit, end) + : toCalendarDay(end); + + const isReversed = endDate.lessThan(currentDate); + const step = isReversed ? -1 : 1; + + const shouldContinue = () => { + if (inclusive) { + return isReversed + ? currentDate.greaterThanOrEqual(endDate) + : currentDate.lessThanOrEqual(endDate); + } + return isReversed + ? currentDate.greaterThan(endDate) + : currentDate.lessThan(endDate); + }; + + while (shouldContinue()) { + yield currentDate; + currentDate = currentDate.add(unit, step); } } diff --git a/projects/igniteui-angular/src/lib/calendar/common/model.ts b/projects/igniteui-angular/src/lib/calendar/common/model.ts index b91c878d303..54f471dd479 100644 --- a/projects/igniteui-angular/src/lib/calendar/common/model.ts +++ b/projects/igniteui-angular/src/lib/calendar/common/model.ts @@ -7,6 +7,7 @@ export type CalendarRangeParams = { start: DayParameter; end: DayParameter | number; unit?: DayInterval; + inclusive?: boolean; }; type CalendarDayParams = { @@ -237,11 +238,18 @@ export class CalendarDay { public greaterThan(value: DayParameter) { return this.timestamp > toCalendarDay(value).timestamp; } + public greaterThanOrEqual(value: DayParameter) { + return this.timestamp >= toCalendarDay(value).timestamp; + } public lessThan(value: DayParameter) { return this.timestamp < toCalendarDay(value).timestamp; } + public lessThanOrEqual(value: DayParameter) { + return this.timestamp <= toCalendarDay(value).timestamp; + } + public toString() { return `${this.native}`; } diff --git a/projects/igniteui-angular/src/lib/core/utils.ts b/projects/igniteui-angular/src/lib/core/utils.ts index a787a9cdcd5..749b85832e5 100644 --- a/projects/igniteui-angular/src/lib/core/utils.ts +++ b/projects/igniteui-angular/src/lib/core/utils.ts @@ -225,6 +225,19 @@ export const isEqual = (obj1, obj2): boolean => { return obj1 === obj2; }; +/** + * Limits a number to a range between a minimum and a maximum value. + * + * @param number + * @param min + * @param max + * @returns: `number` + * @hidden + */ +export const clamp = (number: number, min: number, max: number) => + Math.max(min, Math.min(number, max)); + + /** * Utility service taking care of various utility functions such as * detecting browser features, general cross browser DOM manipulation, etc. diff --git a/projects/igniteui-angular/src/lib/date-common/picker-base.directive.ts b/projects/igniteui-angular/src/lib/date-common/picker-base.directive.ts index e32b343d088..9d3a4db6214 100644 --- a/projects/igniteui-angular/src/lib/date-common/picker-base.directive.ts +++ b/projects/igniteui-angular/src/lib/date-common/picker-base.directive.ts @@ -14,7 +14,7 @@ import { IBaseCancelableBrowserEventArgs, IBaseEventArgs } from '../core/utils'; import { IgxOverlayOutletDirective } from '../directives/toggle/toggle.directive'; import { OverlaySettings } from '../services/overlay/utilities'; import { IgxPickerToggleComponent } from './picker-icons.common'; -import { PickerInteractionMode } from './types'; +import { PickerHeaderOrientation, PickerInteractionMode } from './types'; import { WEEKDAYS } from '../calendar/calendar'; import { DateRange } from '../date-range-picker/date-range-picker-inputs.common'; import { IGX_INPUT_GROUP_TYPE, IgxInputGroupType } from '../input-group/inputGroupType'; @@ -78,6 +78,28 @@ export abstract class PickerBaseDirective implements IToggleView, EditorProvider @Input() public mode: PickerInteractionMode = PickerInteractionMode.DropDown; + /** + * Gets/Sets the orientation of the `IgxDatePickerComponent` header. + * + * @example + * ```html + * + * ``` + */ + @Input() + public headerOrientation: PickerHeaderOrientation = PickerHeaderOrientation.Horizontal; + + /** + * Gets/Sets whether the header is hidden in dialog mode. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public hideHeader = false; + /** * Overlay settings used to display the pop-up element. * diff --git a/projects/igniteui-angular/src/lib/date-common/types.ts b/projects/igniteui-angular/src/lib/date-common/types.ts index 1814dbeac77..29b8f505d6f 100644 --- a/projects/igniteui-angular/src/lib/date-common/types.ts +++ b/projects/igniteui-angular/src/lib/date-common/types.ts @@ -5,6 +5,13 @@ export const PickerHeaderOrientation = { } as const; export type PickerHeaderOrientation = (typeof PickerHeaderOrientation)[keyof typeof PickerHeaderOrientation]; +/** Calendar orientation. */ +export const PickerCalendarOrientation = { + Horizontal: 'horizontal', + Vertical: 'vertical' +} as const; +export type PickerCalendarOrientation = (typeof PickerCalendarOrientation)[keyof typeof PickerCalendarOrientation]; + /** * This enumeration is used to configure whether the date/time picker has an editable input with drop down * or is readonly - the date/time is selected only through a dialog. 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 d208aed8cd2..76226569d2f 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 @@ -14,7 +14,7 @@ import { } from '../services/public_api'; import { Component, DebugElement, ElementRef, EventEmitter, QueryList, Renderer2, ViewChild } from '@angular/core'; import { By } from '@angular/platform-browser'; -import { PickerHeaderOrientation, PickerInteractionMode } from '../date-common/types'; +import { PickerCalendarOrientation, PickerHeaderOrientation, PickerInteractionMode } from '../date-common/types'; import { DatePart } from '../directives/date-time-editor/date-time-editor.common'; import { DateRangeDescriptor, DateRangeType } from '../core/dates'; import { IgxOverlayOutletDirective } from '../directives/toggle/toggle.directive'; @@ -32,6 +32,8 @@ const DATE_PICKER_CLEAR_ICON = 'clear'; const CSS_CLASS_INPUT_GROUP_REQUIRED = 'igx-input-group--required'; const CSS_CLASS_INPUT_GROUP_INVALID = 'igx-input-group--invalid'; +const CSS_CLASS_CALENDAR_HEADER = '.igx-calendar__header'; +const CSS_CLASS_CALENDAR_WRAPPER_VERTICAL = 'igx-calendar__wrapper--vertical'; describe('IgxDatePicker', () => { describe('Integration tests', () => { @@ -66,6 +68,117 @@ describe('IgxDatePicker', () => { expect(suffix).toHaveSize(1); expect(suffix[0].nativeElement.innerText).toEqual(DATE_PICKER_CLEAR_ICON); }); + + it('should hide the calendar header if hideHeader is true in dialog mode', fakeAsync(() => { + const datePicker = fixture.componentInstance.datePicker; + datePicker.mode = 'dialog'; + datePicker.hideHeader = true; + datePicker.open(); + tick(); + fixture.detectChanges(); + + expect(datePicker['_calendar'].hasHeader).toBeFalse(); + const calendarHeader = fixture.debugElement.query(By.css(CSS_CLASS_CALENDAR_HEADER)); + expect(calendarHeader).toBeFalsy('Calendar header should not be present'); + })); + + it('should set calendar orientation property', fakeAsync(() => { + const datePicker = fixture.componentInstance.datePicker; + datePicker.orientation = PickerCalendarOrientation.Horizontal; + datePicker.open(); + tick(); + fixture.detectChanges(); + + expect(datePicker['_calendar'].orientation).toEqual(PickerCalendarOrientation.Horizontal.toString()); + expect(datePicker['_calendar'].wrapper.nativeElement).not.toHaveClass(CSS_CLASS_CALENDAR_WRAPPER_VERTICAL); + datePicker.close(); + tick(); + fixture.detectChanges(); + + datePicker.orientation = PickerCalendarOrientation.Vertical; + datePicker.open(); + tick(); + fixture.detectChanges(); + + expect(datePicker['_calendar'].orientation).toEqual(PickerCalendarOrientation.Vertical.toString()); + expect(datePicker['_calendar'].wrapper.nativeElement).toHaveClass(CSS_CLASS_CALENDAR_WRAPPER_VERTICAL); + })); + + it('should initialize activeDate with current date, when not set', fakeAsync(() => { + const datePicker = fixture.componentInstance.datePicker; + datePicker.value = null; + fixture.detectChanges(); + const todayDate = new Date(); + const today = new Date(todayDate.setHours(0, 0, 0, 0)).getTime().toString(); + + expect(datePicker.activeDate).toEqual(todayDate); + + datePicker.open(); + fixture.detectChanges(); + + expect(datePicker['_calendar'].activeDate).toEqual(todayDate); + expect(datePicker['_calendar'].value).toBeUndefined(); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(today); + })); + + it('should initialize activeDate = value when it is not set, but value is', fakeAsync(() => { + const datePicker = fixture.componentInstance.datePicker; + const date = fixture.componentInstance.date; + + expect(datePicker.activeDate).toEqual(date); + datePicker.open(); + fixture.detectChanges(); + + const activeDescendantDate = new Date(date.setHours(0, 0, 0, 0)).getTime().toString(); + expect(datePicker['_calendar'].activeDate).toEqual(date); + expect(datePicker['_calendar'].value).toEqual(date); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + })); + + it('should set activeDate correctly', fakeAsync(() => { + const datePicker = fixture.componentInstance.datePicker; + const targetDate = new Date(2025, 0, 1); + datePicker.activeDate = new Date(targetDate); + fixture.detectChanges(); + + expect(datePicker.activeDate).toEqual(targetDate); + expect(datePicker.value).toEqual(fixture.componentInstance.date); + + datePicker.open(); + fixture.detectChanges(); + + const activeDescendantDate = new Date(targetDate.setHours(0, 0, 0, 0)).getTime().toString(); + expect(datePicker['_calendar'].activeDate).toEqual(targetDate); + expect(datePicker['_calendar'].viewDate.getMonth()).toEqual(targetDate.getMonth()); + expect(datePicker['_calendar'].value).toEqual(fixture.componentInstance.date); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + })); + + it('should set activeDate of the calendar to value of picker even when it is outside the enabled range, i.e. > maxValue', fakeAsync(() => { + const datePicker = fixture.componentInstance.datePicker; + const maxDate = new Date(2025, 7, 1); + datePicker.maxValue = maxDate; + fixture.detectChanges(); + + const valueGreaterThanMax = new Date(2025, 10, 1); + datePicker.value = valueGreaterThanMax; + fixture.detectChanges(); + + expect(datePicker.activeDate).toEqual(valueGreaterThanMax); + + datePicker.open(); + fixture.detectChanges(); + + const activeDescendantDate = new Date(valueGreaterThanMax.setHours(0, 0, 0, 0)).getTime().toString(); + expect(datePicker['_calendar'].activeDate).toEqual(valueGreaterThanMax); + expect(datePicker['_calendar'].viewDate.getMonth()).toEqual(valueGreaterThanMax.getMonth()); + expect(datePicker['_calendar'].value).toEqual(valueGreaterThanMax); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + })); }); describe('Events', () => { @@ -212,6 +325,58 @@ describe('IgxDatePicker', () => { .toBeTrue(); expect(datePicker.isFocused).toBeTrue(); })); + + it('should update the calendar selection on typing', fakeAsync(() => { + const date = new Date(2025, 0, 1); + datePicker.value = date; + datePicker.open(); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('.igx-input-group__input')); + input.nativeElement.focus(); + tick(); + fixture.detectChanges(); + + fixture.detectChanges(); + UIInteractions.simulateTyping('02/01/2025', input); + + const expectedDate = new Date(2025, 0, 2); + expect(datePicker.value).toEqual(expectedDate); + expect(datePicker.activeDate).toEqual(expectedDate); + + const activeDescendantDate = new Date(expectedDate.setHours(0, 0, 0, 0)).getTime().toString(); + expect(datePicker['_calendar'].activeDate).toEqual(expectedDate); + expect(datePicker['_calendar'].viewDate.getMonth()).toEqual(expectedDate.getMonth()); + expect(datePicker['_calendar'].value).toEqual(expectedDate); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + })); + + it('should update the calendar view and active date on typing a date that is not in the current view', fakeAsync(() => { + const date = new Date(2025, 0, 1); + datePicker.value = date; + datePicker.open(); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('.igx-input-group__input')); + input.nativeElement.focus(); + tick(); + fixture.detectChanges(); + + fixture.detectChanges(); + UIInteractions.simulateTyping('02/11/2025', input); + + const expectedDate = new Date(2025, 10, 2); + expect(datePicker.value).toEqual(expectedDate); + expect(datePicker.activeDate).toEqual(expectedDate); + + const activeDescendantDate = new Date(expectedDate.setHours(0, 0, 0, 0)).getTime().toString(); + expect(datePicker['_calendar'].activeDate).toEqual(expectedDate); + expect(datePicker['_calendar'].viewDate.getMonth()).toEqual(expectedDate.getMonth()); + expect(datePicker['_calendar'].value).toEqual(expectedDate); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + })); }); describe('NgControl integration', () => { @@ -739,7 +904,7 @@ describe('IgxDatePicker', () => { mockCdr = jasmine.createSpyObj('ChangeDetectorRef', ['detectChanges']); - mockCalendar = { selected: new EventEmitter() }; + mockCalendar = { selected: new EventEmitter(), selectDate: () => {} }; const mockComponentInstance = { calendar: mockCalendar, todaySelection: new EventEmitter(), 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 a3a6935af7d..f24cbaf792d 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 @@ -51,7 +51,6 @@ import { IBaseCancelableBrowserEventArgs, isDate, PlatformUtil } from '../core/u import { IgxCalendarContainerComponent } from '../date-common/calendar-container/calendar-container.component'; import { PickerBaseDirective } from '../date-common/picker-base.directive'; import { IgxPickerActionsDirective, IgxPickerClearComponent } from '../date-common/public_api'; -import { PickerHeaderOrientation } from '../date-common/types'; import { DateTimeUtil } from '../date-common/util/date-time.util'; import { DatePart, DatePartDeltas, IgxDateTimeEditorDirective } from '../directives/date-time-editor/public_api'; import { IgxOverlayOutletDirective } from '../directives/toggle/toggle.directive'; @@ -68,6 +67,7 @@ import { IgxIconComponent } from '../icon/icon.component'; import { IgxTextSelectionDirective } from '../directives/text-selection/text-selection.directive'; import { getCurrentResourceStrings } from '../core/i18n/resources'; import { fadeIn, fadeOut } from 'igniteui-angular/animations'; +import { PickerCalendarOrientation } from '../date-common/types'; let NEXT_ID = 0; @@ -140,6 +140,15 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr @Input() public displayMonthsCount = 1; + /** + * Gets/Sets the orientation of the multiple months displayed in the picker's calendar's days view. + * + * @example + * + */ + @Input() + public orientation: PickerCalendarOrientation = PickerCalendarOrientation.Horizontal; + /** * Show/hide week numbers * @@ -151,27 +160,32 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr @Input({ transform: booleanAttribute }) public showWeekNumbers: boolean; + /** - * Gets/Sets a custom formatter function on the selected or passed date. - * - * @example - * ```html - * - * ``` + * Gets/Sets the date which is shown in the calendar picker and is highlighted. + * By default it is the current date, or the value of the picker, if set. */ @Input() - public formatter: (val: Date) => string; + public get activeDate(): Date { + const today = new Date(new Date().setHours(0, 0, 0, 0)); + const dateValue = DateTimeUtil.isValidDate(this._dateValue) ? new Date(this._dateValue.setHours(0, 0, 0, 0)) : null; + return this._activeDate ?? dateValue ?? this._calendar?.activeDate ?? today; + } + + public set activeDate(value: Date) { + this._activeDate = value; + } /** - * Gets/Sets the orientation of the `IgxDatePickerComponent` header. + * Gets/Sets a custom formatter function on the selected or passed date. * - * @example + * @example * ```html - * + * * ``` */ @Input() - public headerOrientation: PickerHeaderOrientation = PickerHeaderOrientation.Horizontal; + public formatter: (val: Date) => string; /** * Gets/Sets the today button's label. @@ -463,13 +477,13 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr private _dateValue: Date; private _overlayId: string; private _value: Date | string; - private _targetViewDate: Date; private _ngControl: NgControl = null; private _statusChanges$: Subscription; private _calendar: IgxCalendarComponent; private _calendarContainer?: HTMLElement; private _specialDates: DateRangeDescriptor[] = null; private _disabledDates: DateRangeDescriptor[] = null; + private _activeDate: Date = null; private _overlaySubFilter: [MonoTypeOperatorFunction, MonoTypeOperatorFunction] = [ @@ -835,6 +849,12 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr return; } this._dateValue = DateTimeUtil.isValidDate(value) ? value : DateTimeUtil.parseIsoDate(value); + if (this._calendar) { + this._calendar.selectDate(this._dateValue); + this._calendar.activeDate = this.activeDate; + this._calendar.viewDate = this.activeDate; + this.cdr.detectChanges(); + } } private updateValidity() { @@ -872,7 +892,10 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr date.setMilliseconds(this.dateValue.getMilliseconds()); } this.value = date; - this._calendar.viewDate = date; + if (this._calendar) { + this._calendar.activeDate = this.activeDate; + this._calendar.viewDate = this.activeDate; + } this.close(); } @@ -910,13 +933,6 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr this.opened.emit({ owner: this }); this._calendar.wrapper?.nativeElement?.focus(); - - if (this._targetViewDate) { - this._targetViewDate.setHours(0, 0, 0, 0); - // INFO: We need to set the active date to the target view date so there's something to - // navigate when the calendar is opened. - this._calendar.activeDate = this._targetViewDate; - } }); this._overlayService.closing.pipe(...this._overlaySubFilter).subscribe((e: OverlayCancelableEventArgs) => { @@ -963,10 +979,9 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr } } - private _initializeCalendarContainer(componentInstance: IgxCalendarContainerComponent) { this._calendar = componentInstance.calendar; - this._calendar.hasHeader = !this.isDropdown; + this._calendar.hasHeader = !this.isDropdown && !this.hideHeader; this._calendar.formatOptions = this.pickerCalendarFormat; this._calendar.formatViews = this.pickerFormatViews; this._calendar.locale = this.locale; @@ -979,6 +994,7 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr this._calendar.hideOutsideDays = this.hideOutsideDays; this._calendar.monthsViewNumber = this.displayMonthsCount; this._calendar.showWeekNumbers = this.showWeekNumbers; + this._calendar.orientation = this.orientation; this._calendar.selected.pipe(takeUntil(this._destroy$)).subscribe((ev: Date) => this.handleSelection(ev)); this.setDisabledDates(); @@ -986,10 +1002,10 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr // calendar will throw if the picker's value is InvalidDate #9208 this._calendar.value = this.dateValue; } - this.setCalendarViewDate(); + this._calendar.activeDate = this.activeDate; + this._calendar.viewDate = this.activeDate; componentInstance.mode = this.mode; - // componentInstance.headerOrientation = this.headerOrientation; componentInstance.closeButtonLabel = this.cancelButtonLabel; componentInstance.todayButtonLabel = this.todayButtonLabel; componentInstance.pickerActions = this.pickerActions; @@ -997,18 +1013,4 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr componentInstance.calendarClose.pipe(takeUntil(this._destroy$)).subscribe(() => this.close()); componentInstance.todaySelection.pipe(takeUntil(this._destroy$)).subscribe(() => this.selectToday()); } - - private setCalendarViewDate() { - const { minValue, maxValue } = this.getMinMaxDates(); - const dateValue = DateTimeUtil.isValidDate(this.dateValue) ? this.dateValue : new Date(); - if (minValue && DateTimeUtil.lessThanMinValue(dateValue, minValue)) { - this._calendar.viewDate = this._targetViewDate = minValue; - return; - } - if (maxValue && DateTimeUtil.greaterThanMaxValue(dateValue, maxValue)) { - this._calendar.viewDate = this._targetViewDate = maxValue; - return; - } - this._calendar.viewDate = this._targetViewDate = dateValue; - } } diff --git a/projects/igniteui-angular/src/lib/date-picker/public_api.ts b/projects/igniteui-angular/src/lib/date-picker/public_api.ts index ce993452a99..f6d632c3cce 100644 --- a/projects/igniteui-angular/src/lib/date-picker/public_api.ts +++ b/projects/igniteui-angular/src/lib/date-picker/public_api.ts @@ -1,3 +1,4 @@ +import { IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarSubheaderTemplateDirective } from '../calendar/calendar.directives'; import { IgxPickerActionsDirective, IgxPickerClearComponent, IgxPickerToggleComponent } from '../date-common/picker-icons.common'; import { IgxHintDirective } from '../directives/hint/hint.directive'; import { IgxLabelDirective } from '../directives/label/label.directive'; @@ -16,5 +17,8 @@ export const IGX_DATE_PICKER_DIRECTIVES = [ IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective, - IgxHintDirective + IgxHintDirective, + IgxCalendarHeaderTemplateDirective, + IgxCalendarSubheaderTemplateDirective, + IgxCalendarHeaderTitleTemplateDirective ] as const; diff --git a/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.spec.ts b/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.spec.ts index 01d03dc791d..6ea553ea318 100644 --- a/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.spec.ts +++ b/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync, flush } from '@angular/core/testing'; import { Component, OnInit, ViewChild, DebugElement, ChangeDetectionStrategy } from '@angular/core'; import { IgxInputDirective, IgxInputState, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from '../input-group/public_api'; -import { PickerInteractionMode } from '../date-common/types'; +import { PickerCalendarOrientation, PickerHeaderOrientation, PickerInteractionMode } from '../date-common/types'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { FormsModule, ReactiveFormsModule, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms'; import { By } from '@angular/platform-browser'; @@ -15,7 +15,7 @@ import { DateRangeType } from '../core/dates'; import { IgxDateRangePickerComponent, IgxDateRangeEndComponent } from './public_api'; import { AutoPositionStrategy, IgxOverlayService } from '../services/public_api'; import { AnimationMetadata, AnimationOptions } from '@angular/animations'; -import { IgxCalendarComponent, WEEKDAYS } from '../calendar/public_api'; +import { IgxCalendarComponent, IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarSubheaderTemplateDirective, WEEKDAYS } from '../calendar/public_api'; import { Subject } from 'rxjs'; import { AsyncPipe } from '@angular/common'; import { AnimationService } from '../services/animation/animation'; @@ -44,6 +44,11 @@ const CSS_CLASS_OVERLAY_CONTENT = 'igx-overlay__content'; const CSS_CLASS_DATE_RANGE = 'igx-date-range-picker'; const CSS_CLASS_CALENDAR_DATE = 'igx-days-view__date'; const CSS_CLASS_INACTIVE_DATE = 'igx-days-view__date--inactive'; +const CSS_CLASS_CALENDAR_HEADER_TEMPLATE = '.igx-calendar__header-date'; +const CSS_CLASS_CALENDAR_HEADER_TITLE = '.igx-calendar__header-year'; +const CSS_CLASS_CALENDAR_SUBHEADER = '.igx-calendar-picker__dates'; +const CSS_CLASS_CALENDAR_HEADER = '.igx-calendar__header'; +const CSS_CLASS_CALENDAR_WRAPPER_VERTICAL = 'igx-calendar__wrapper--vertical'; describe('IgxDateRangePicker', () => { describe('Unit tests: ', () => { @@ -58,6 +63,7 @@ describe('IgxDateRangePicker', () => { let mockCalendar: IgxCalendarComponent; let mockDaysView: any; let mockAnimationService: AnimationService; + let mockCdr: any; const elementRef = { nativeElement: null }; const platform = {} as any; const mockNgControl = jasmine.createSpyObj('NgControl', @@ -83,6 +89,9 @@ describe('IgxDateRangePicker', () => { mockInjector = jasmine.createSpyObj('Injector', { get: mockNgControl }); + mockCdr = jasmine.createSpyObj('ChangeDetectorRef', { + detectChanges: () => { } + }); mockAnimationBuilder = { build: (a: AnimationMetadata | AnimationMetadata[]) => ({ create: (e: any, opt?: AnimationOptions) => ({ @@ -239,7 +248,7 @@ describe('IgxDateRangePicker', () => { }); it('should disable calendar dates when min and/or max values as dates are provided', () => { - const dateRange = new IgxDateRangePickerComponent(elementRef, 'en-US', platform, mockInjector, null, overlay); + const dateRange = new IgxDateRangePickerComponent(elementRef, 'en-US', platform, mockInjector, mockCdr, overlay); dateRange.ngOnInit(); spyOnProperty((dateRange as any), 'calendar').and.returnValue(mockCalendar); @@ -255,7 +264,7 @@ describe('IgxDateRangePicker', () => { }); it('should disable calendar dates when min and/or max values as strings are provided', fakeAsync(() => { - const dateRange = new IgxDateRangePickerComponent(elementRef, 'en', platform, mockInjector, null, null, null); + const dateRange = new IgxDateRangePickerComponent(elementRef, 'en', platform, mockInjector, mockCdr, null, null); dateRange.ngOnInit(); spyOnProperty((dateRange as any), 'calendar').and.returnValue(mockCalendar); @@ -270,6 +279,42 @@ describe('IgxDateRangePicker', () => { expect((dateRange as any).calendar.disabledDates[1].type).toEqual(DateRangeType.After); expect((dateRange as any).calendar.disabledDates[1].dateRange[0]).toEqual(new Date(dateRange.maxValue)); })); + + it('should validate correctly when disabledDates are set', () => { + const dateRange = new IgxDateRangePickerComponent(elementRef, 'en-US', platform, mockInjector, mockCdr, null, null); + dateRange.ngOnInit(); + + dateRange.registerOnChange(mockNgControl.registerOnChangeCb); + dateRange.registerOnValidatorChange(mockNgControl.registerOnValidatorChangeCb); + mockNgControl.registerOnValidatorChangeCb.calls.reset(); + spyOnProperty((dateRange as any), 'calendar').and.returnValue(mockCalendar); + + const start = new Date(new Date().getFullYear(), new Date().getMonth(), 10); + const end = new Date(new Date().getFullYear(), new Date().getMonth(), 18); + + const disabledDates = [{ + type: DateRangeType.Between, dateRange: [ start, end ] + }]; + dateRange.disabledDates = disabledDates; + expect(mockNgControl.registerOnValidatorChangeCb).toHaveBeenCalledTimes(1); + + + const validRange = { + start: new Date(new Date().getFullYear(), new Date().getMonth(), 2), + end: new Date(new Date().getFullYear(), new Date().getMonth(), 5), + }; + dateRange.writeValue(validRange); + const mockFormControl = new UntypedFormControl(dateRange.value); + expect(dateRange.validate(mockFormControl)).toBeNull(); + + (dateRange as any).updateCalendar(); + expect((dateRange as any).calendar.disabledDates.length).toEqual(1); + expect((dateRange as any).calendar.disabledDates[0].type).toEqual(DateRangeType.Between); + + start.setDate(start.getDate() - 2); + dateRange.writeValue({ start, end }); + expect(dateRange.validate(mockFormControl)).toEqual({ dateIsDisabled: true }); + }); }); describe('Integration tests', () => { @@ -1084,7 +1129,8 @@ describe('IgxDateRangePicker', () => { expect(endInput.nativeElement.value).toEqual(inputEndDate); }); - it('should select a range from the calendar only when the two inputs are filled in', fakeAsync(() => { + it('should select a range from the calendar only when any of the two inputs are filled in', fakeAsync(() => { + // refactored to any of the two inputs, instead of both, to match the behavior in WC - #16131 startInput.triggerEventHandler('focus', {}); fixture.detectChanges(); UIInteractions.simulateTyping('11/10/2015', startInput); @@ -1093,7 +1139,7 @@ describe('IgxDateRangePicker', () => { tick(DEBOUNCE_TIME); fixture.detectChanges(); const rangePicker = fixture.componentInstance.dateRange; - expect((rangePicker as any).calendar.selectedDates.length).toBe(0); + expect((rangePicker as any).calendar.selectedDates.length).toBe(1); calendar = document.getElementsByClassName(CSS_CLASS_CALENDAR)[0]; UIInteractions.triggerKeyDownEvtUponElem('Escape', calendar); @@ -1276,6 +1322,60 @@ describe('IgxDateRangePicker', () => { expect(dateRange.opening.emit).toHaveBeenCalledTimes(0); expect(dateRange.opened.emit).toHaveBeenCalledTimes(0); })); + + it('should update the calendar selection on typing', fakeAsync(() => { + const range = { start: new Date(2025, 0, 16), end: new Date(2025, 0, 20) }; + dateRange.value = range; + fixture.detectChanges(); + dateRange.open(); + fixture.detectChanges(); + + expect((dateRange['_calendar'].value as Date[]).length).toBe(5); + + startInput.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('01/18/2025', startInput); + + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + expect((dateRange['_calendar'].value as Date[]).length).toBe(3); + + startDate = new Date(2025, 0, 18); + const expectedRange = { start: startDate, end: new Date(2025, 0, 20) }; + expect(dateRange.value).toEqual(expectedRange); + expect(dateRange.activeDate).toEqual(expectedRange.start); + + const activeDescendantDate = new Date(startDate.setHours(0, 0, 0, 0)).getTime().toString(); + expect(dateRange['_calendar'].activeDate).toEqual(startDate); + expect(dateRange['_calendar'].viewDate.getMonth()).toEqual(startDate.getMonth()); + expect(dateRange['_calendar'].value[0]).toEqual(startDate); + expect(dateRange['_calendar'].wrapper.nativeElement.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + })); + + it('should update the calendar view and active date on typing a date that is not in the current view', fakeAsync(() => { + const range = { start: new Date(2025, 0, 16), end: new Date(2025, 0, 20) }; + dateRange.value = range; + fixture.detectChanges(); + dateRange.open(); + fixture.detectChanges(); + + expect((dateRange['_calendar'].value as Date[]).length).toBe(5); + + startInput.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('11/18/2025', startInput); + + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + startDate = new Date(2025, 10, 18); + + const activeDescendantDate = new Date(startDate.setHours(0, 0, 0, 0)).getTime().toString(); + expect(dateRange['_calendar'].activeDate).toEqual(startDate); + expect(dateRange['_calendar'].viewDate.getMonth()).toEqual(startDate.getMonth()); + expect(dateRange['_calendar'].wrapper.nativeElement.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + })); }); it('should focus the last focused input after the calendar closes - dropdown', fakeAsync(() => { @@ -1562,6 +1662,249 @@ describe('IgxDateRangePicker', () => { expect(dateRange.locale).toEqual('en-US'); expect(dateRange.weekStart).toEqual(WEEKDAYS.FRIDAY); })); + + it('Should render calendar with header in dialog mode by default', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + dateRange.mode = 'dialog'; + dateRange.open(); + tick(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].hasHeader).toBeTrue(); + const calendarHeader = fixture.debugElement.query(By.css(CSS_CLASS_CALENDAR_HEADER_TEMPLATE)); + expect(calendarHeader).toBeTruthy('Calendar header should be present'); + })); + + it('should set calendar headerOrientation prop in dialog mode', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + + dateRange.mode = 'dialog'; + dateRange.open(); + tick(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].headerOrientation).toBe(PickerHeaderOrientation.Horizontal); + + dateRange.close(); + tick(); + fixture.detectChanges(); + + dateRange.headerOrientation = PickerHeaderOrientation.Vertical; + dateRange.open(); + tick(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].headerOrientation).toBe(PickerHeaderOrientation.Vertical); + })); + + it('should hide the calendar header if hideHeader is true in dialog mode', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + + dateRange.mode = 'dialog'; + dateRange.hideHeader = true; + dateRange.open(); + tick(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].hasHeader).toBeFalse(); + const calendarHeader = fixture.debugElement.query(By.css(CSS_CLASS_CALENDAR_HEADER)); + expect(calendarHeader).toBeFalsy('Calendar header should not be present'); + })); + + it('should set calendar orientation property', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + dateRange.open(); + tick(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].orientation).toEqual(PickerCalendarOrientation.Horizontal.toString()); + expect(dateRange['_calendar'].wrapper.nativeElement).not.toHaveClass(CSS_CLASS_CALENDAR_WRAPPER_VERTICAL); + dateRange.close(); + tick(); + fixture.detectChanges(); + + dateRange.orientation = PickerCalendarOrientation.Vertical; + dateRange.open(); + tick(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].orientation).toEqual(PickerCalendarOrientation.Vertical.toString()); + expect(dateRange['_calendar'].wrapper.nativeElement).toHaveClass(CSS_CLASS_CALENDAR_WRAPPER_VERTICAL); + })); + + it('should limit the displayMonthsCount property between 1 and 2', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + dateRange.open(); + tick(); + + dateRange.displayMonthsCount = 3; + fixture.detectChanges(); + + expect(dateRange.displayMonthsCount).toBe(2); + + dateRange.displayMonthsCount = -1; + fixture.detectChanges(); + + expect(dateRange.displayMonthsCount).toBe(1); + })); + + it('should set the specialDates of the calendar', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + + const specialDates = [{ + type: DateRangeType.Between, dateRange: [ + new Date(new Date().getFullYear(), new Date().getMonth(), 3), + new Date(new Date().getFullYear(), new Date().getMonth(), 8) + ] + }]; + dateRange.specialDates = specialDates; + fixture.detectChanges(); + + dateRange.open(); + tick(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].specialDates).toEqual(specialDates); + })); + + it('should set the disabledDates of the calendar', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + + const disabledDates = [{ + type: DateRangeType.Between, dateRange: [ + new Date(new Date().getFullYear(), new Date().getMonth(), 3), + new Date(new Date().getFullYear(), new Date().getMonth(), 8) + ] + }]; + dateRange.disabledDates = disabledDates; + fixture.detectChanges(); + + dateRange.open(); + tick(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].disabledDates).toEqual(disabledDates); + })); + + it('should initialize activeDate with current date, when not set', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + const todayDate = new Date(); + const today = new Date(todayDate.setHours(0, 0, 0, 0)).getTime().toString(); + + expect(dateRange.activeDate).toEqual(todayDate); + + dateRange.open(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].activeDate).toEqual(todayDate); + expect(dateRange['_calendar'].value).toEqual([]); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(today); + })); + + it('should initialize activeDate = first defined in value (start/end) when it is not set, but value is', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + let range = { start: new Date(2025, 0, 1), end: new Date(2025, 0, 5) }; + dateRange.value = range; + fixture.detectChanges(); + + expect(dateRange.activeDate).toEqual(range.start); + dateRange.open(); + fixture.detectChanges(); + + const activeDescendantDate = new Date(range.start.setHours(0, 0, 0, 0)).getTime().toString(); + expect(dateRange['_calendar'].activeDate).toEqual(range.start); + expect(dateRange['_calendar'].value[0]).toEqual(range.start); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + + range = { ...range, start: null}; + dateRange.value = range; + fixture.detectChanges(); + + expect(dateRange.activeDate).toEqual(range.end); + })); + + it('should set activeDate correctly', fakeAsync(() => { + const targetDate = new Date(2025, 11, 1); + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + const range = { start: new Date(2025, 0, 1), end: new Date(2025, 0, 5) }; + dateRange.value = range; + dateRange.activeDate = targetDate; + fixture.detectChanges(); + + expect(dateRange.activeDate).toEqual(targetDate); + dateRange.open(); + fixture.detectChanges(); + + const activeDescendantDate = new Date(targetDate.setHours(0, 0, 0, 0)).getTime().toString(); + expect(dateRange['_calendar'].activeDate).toEqual(targetDate); + expect(dateRange['_calendar'].value[0]).toEqual(range.start); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + })); + + describe('Templated Calendar Header', () => { + let dateRangeDebugEl: DebugElement; + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [DateRangeTemplatesComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(DateRangeTemplatesComponent); + fixture.detectChanges(); + dateRangeDebugEl = fixture.debugElement.queryAll(By.directive(IgxDateRangePickerComponent))[0]; + dateRange = dateRangeDebugEl.componentInstance; + dateRange.mode = 'dialog'; + dateRange.open(); + tick(); + fixture.detectChanges(); + })); + + it('Should use the custom template for header title', fakeAsync(() => { + const headerTitleElement = dateRangeDebugEl.query(By.css(CSS_CLASS_CALENDAR_HEADER_TITLE)); + expect(headerTitleElement).toBeTruthy('Header title element should be present'); + if (headerTitleElement) { + expect(headerTitleElement.nativeElement.textContent.trim()).toBe('Test header title'); + } + })); + + it('Should use the custom template for header', fakeAsync(() => { + const headerElement = dateRangeDebugEl.query(By.css(CSS_CLASS_CALENDAR_HEADER_TEMPLATE)); + expect(headerElement).toBeTruthy('Header element should be present'); + if (headerElement) { + expect(headerElement.nativeElement.textContent.trim()).toBe('Test header'); + } + })); + + it('Should use the custom template for subheader', fakeAsync(() => { + const headerElement = dateRangeDebugEl.query(By.css(CSS_CLASS_CALENDAR_SUBHEADER)); + expect(headerElement).toBeTruthy('Subheader element should be present'); + if (headerElement) { + expect(headerElement.nativeElement.textContent.trim()).toBe('Test subheader'); + } + })); + }); }); }); }); @@ -1684,6 +2027,9 @@ export class DateRangeCustomComponent extends DateRangeTestComponent { flight_takeoff + Test header + Test header title + Test subheader @@ -1727,7 +2073,10 @@ export class DateRangeCustomComponent extends DateRangeTestComponent { IgxInputDirective, IgxDateTimeEditorDirective, IgxPrefixDirective, - IgxSuffixDirective + IgxSuffixDirective, + IgxCalendarHeaderTemplateDirective, + IgxCalendarHeaderTitleTemplateDirective, + IgxCalendarSubheaderTemplateDirective ] }) export class DateRangeTemplatesComponent extends DateRangeTestComponent { diff --git a/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.ts b/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.ts index e6b5edca0ba..c1dcdfc3076 100644 --- a/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.ts +++ b/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.ts @@ -13,10 +13,10 @@ import { import { fromEvent, merge, MonoTypeOperatorFunction, noop, Subscription } from 'rxjs'; import { filter, takeUntil } from 'rxjs/operators'; -import { CalendarSelection, IgxCalendarComponent } from '../calendar/public_api'; -import { DateRangeType } from '../core/dates'; +import { CalendarSelection, IgxCalendarComponent, IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarSubheaderTemplateDirective } from '../calendar/public_api'; +import { DateRangeDescriptor, DateRangeType } from '../core/dates'; import { DateRangePickerResourceStringsEN, IDateRangePickerResourceStrings } from '../core/i18n/date-range-picker-resources'; -import { IBaseCancelableBrowserEventArgs, isDate, parseDate, PlatformUtil } from '../core/utils'; +import { clamp, IBaseCancelableBrowserEventArgs, isDate, parseDate, PlatformUtil } from '../core/utils'; import { IgxCalendarContainerComponent } from '../date-common/calendar-container/calendar-container.component'; import { PickerBaseDirective } from '../date-common/picker-base.directive'; import { IgxPickerActionsDirective } from '../date-common/picker-icons.common'; @@ -35,6 +35,8 @@ import { IgxPrefixDirective } from '../directives/prefix/prefix.directive'; import { IgxIconComponent } from '../icon/icon.component'; import { getCurrentResourceStrings } from '../core/i18n/resources'; import { fadeIn, fadeOut } from 'igniteui-angular/animations'; +import { PickerCalendarOrientation } from '../date-common/types'; +import { calendarRange, isDateInRanges } from '../calendar/common/helpers'; const SingleInputDatesConcatenationString = ' - '; @@ -91,7 +93,22 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective * ``` */ @Input() - public displayMonthsCount = 2; + public get displayMonthsCount(): number { + return this._displayMonthsCount; + } + + public set displayMonthsCount(value: number) { + this._displayMonthsCount = clamp(value, 1, 2); + } + + /** + * Gets/Sets the orientation of the multiple months displayed in the picker's calendar's days view. + * + * @example + * + */ + @Input() + public orientation: PickerCalendarOrientation = PickerCalendarOrientation.Horizontal; /** * Gets/Sets whether dates that are not part of the current month will be displayed. @@ -218,6 +235,41 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective return this._maxValue; } + /** + * Gets/Sets the disabled dates descriptors. + * + * @example + * ```typescript + * let disabledDates = this.dateRangePicker.disabledDates; + * this.dateRangePicker.disabledDates = [ {type: DateRangeType.Weekends}, ...]; + * ``` + */ + @Input() + public get disabledDates(): DateRangeDescriptor[] { + return this._disabledDates; + } + public set disabledDates(value: DateRangeDescriptor[]) { + this._disabledDates = value; + this.onValidatorChange(); + } + + /** + * Gets/Sets the special dates descriptors. + * + * @example + * ```typescript + * let specialDates = this.dateRangePicker.specialDates; + * this.dateRangePicker.specialDates = [ {type: DateRangeType.Weekends}, ... ]; + * ``` + */ + @Input() + public get specialDates(): DateRangeDescriptor[] { + return this._specialDates; + } + public set specialDates(value: DateRangeDescriptor[]) { + this._specialDates = value; + } + /** * An accessor that sets the resource strings. * By default it uses EN resources. @@ -311,6 +363,16 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective @ContentChild(IgxDateRangeSeparatorDirective, { read: TemplateRef }) public dateSeparatorTemplate: TemplateRef; + + @ContentChild(IgxCalendarHeaderTitleTemplateDirective) + private headerTitleTemplate: IgxCalendarHeaderTitleTemplateDirective; + + @ContentChild(IgxCalendarHeaderTemplateDirective) + private headerTemplate: IgxCalendarHeaderTemplateDirective; + + @ContentChild(IgxCalendarSubheaderTemplateDirective) + private subheaderTemplate: IgxCalendarSubheaderTemplateDirective; + /** @hidden @internal */ public get dateSeparator(): string { if (this._dateSeparator === null) { @@ -325,6 +387,21 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective || DateTimeUtil.DEFAULT_INPUT_FORMAT; } + /** + * Gets/Sets the date which is shown in the calendar picker and is highlighted. + * By default it is the current date, or the value of the picker, if set. + */ + @Input() + public get activeDate(): Date { + const today = new Date(new Date().setHours(0, 0, 0, 0)); + const dateValue = DateTimeUtil.isValidDate(this._firstDefinedInRange) ? new Date(this._firstDefinedInRange.setHours(0, 0, 0, 0)) : null; + return this._activeDate ?? dateValue ?? this._calendar?.activeDate ?? today; + } + + public set activeDate(value: Date) { + this._activeDate = value; + } + /** * @example * ```html @@ -437,6 +514,14 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective return Object.assign({}, this._dialogOverlaySettings, this.overlaySettings); } + private get _firstDefinedInRange(): Date | null { + if (!this.value) { + return null; + } + const range = this.toRangeOfDates(this.value); + return range?.start ?? range?.end ?? null; + } + private _resourceStrings = getCurrentResourceStrings(DateRangePickerResourceStringsEN); private _doneButtonText = null; private _dateSeparator = null; @@ -448,6 +533,10 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective private _calendarContainer?: HTMLElement; private _positionSettings: PositionSettings; private _focusedInput: IgxDateRangeInputsBaseComponent; + private _displayMonthsCount = 2; + private _specialDates: DateRangeDescriptor[] = null; + private _disabledDates: DateRangeDescriptor[] = null; + private _activeDate: Date | null = null; private _overlaySubFilter: [MonoTypeOperatorFunction, MonoTypeOperatorFunction] = [ filter(x => x.id === this._overlayId), @@ -605,16 +694,19 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective } } - const min = parseDate(this.minValue); - const max = parseDate(this.maxValue); + if (this._isValueInDisabledRange(value)) { + Object.assign(errors, { dateIsDisabled: true }); + } + + const { minValue, maxValue } = this._getMinMaxDates(); const start = parseDate(value.start); const end = parseDate(value.end); - if ((min && start && DateTimeUtil.lessThanMinValue(start, min, false)) - || (min && end && DateTimeUtil.lessThanMinValue(end, min, false))) { + if ((minValue && start && DateTimeUtil.lessThanMinValue(start, minValue, false)) + || (minValue && end && DateTimeUtil.lessThanMinValue(end, minValue, false))) { Object.assign(errors, { minValue: true }); } - if ((max && start && DateTimeUtil.greaterThanMaxValue(start, max, false)) - || (max && end && DateTimeUtil.greaterThanMaxValue(end, max, false))) { + if ((maxValue && start && DateTimeUtil.greaterThanMaxValue(start, maxValue, false)) + || (maxValue && end && DateTimeUtil.greaterThanMaxValue(end, maxValue, false))) { Object.assign(errors, { maxValue: true }); } } @@ -742,6 +834,7 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective if (this.isDropdown && selectionData?.length > 1) { this.close(); } + this._setCalendarActiveDate(); } private handleClosing(e: IBaseCancelableBrowserEventArgs): void { @@ -883,24 +976,21 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective if (!this.calendar) { return; } - this.calendar.disabledDates = []; - const minValue = this.parseMinValue(this.minValue); - if (minValue) { - this.calendar.disabledDates.push({ type: DateRangeType.Before, dateRange: [minValue] }); - } - const maxValue = this.parseMaxValue(this.maxValue); - if (maxValue) { - this.calendar.disabledDates.push({ type: DateRangeType.After, dateRange: [maxValue] }); - } + this._setDisabledDates(); const range: Date[] = []; - if (this.value?.start && this.value?.end) { + if (this.value) { const _value = this.toRangeOfDates(this.value); - if (DateTimeUtil.greaterThanMaxValue(_value.start, _value.end)) { - this.swapEditorDates(); + if (_value.start && _value.end) { + if (DateTimeUtil.greaterThanMaxValue(_value.start, _value.end)) { + this.swapEditorDates(); + } + } + if (_value.start) { + range.push(_value.start); } - if (this.valueInRange(this.value, minValue, maxValue)) { - range.push(_value.start, _value.end); + if (_value.end) { + range.push(_value.end); } } @@ -909,7 +999,8 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective } else if (range.length === 0 && this.calendar.monthViews) { this.calendar.deselectDate(); } - this.calendar.viewDate = range[0] || new Date(); + this._setCalendarActiveDate(); + this._cdr.detectChanges(); } private swapEditorDates(): void { @@ -921,18 +1012,6 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective } } - private valueInRange(value: DateRange, minValue?: Date, maxValue?: Date): boolean { - const _value = this.toRangeOfDates(value); - if (minValue && DateTimeUtil.lessThanMinValue(_value.start, minValue, false)) { - return false; - } - if (maxValue && DateTimeUtil.greaterThanMaxValue(_value.end, maxValue, false)) { - return false; - } - - return true; - } - private extractRange(selection: Date[]): DateRange { return { start: selection[0] || null, @@ -970,6 +1049,10 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective } else { this.value = { start: value, end: null }; } + if (this.calendar) { + this._setCalendarActiveDate(parseDate(value)); + this._cdr.detectChanges(); + } }); end.dateTimeEditor.valueChange .pipe(takeUntil(this._destroy$)) @@ -979,6 +1062,10 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective } else { this.value = { start: null, end: value as Date }; } + if (this.calendar) { + this._setCalendarActiveDate(parseDate(value)); + this._cdr.detectChanges(); + } }); } } @@ -1080,18 +1167,67 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective private _initializeCalendarContainer(componentInstance: IgxCalendarContainerComponent) { this._calendar = componentInstance.calendar; - this.calendar.hasHeader = false; - this.calendar.locale = this.locale; - this.calendar.selection = CalendarSelection.RANGE; - this.calendar.weekStart = this.weekStart; - this.calendar.hideOutsideDays = this.hideOutsideDays; - this.calendar.monthsViewNumber = this.displayMonthsCount; - this.calendar.showWeekNumbers = this.showWeekNumbers; - this.calendar.selected.pipe(takeUntil(this._destroy$)).subscribe((ev: Date[]) => this.handleSelection(ev)); + this._calendar.hasHeader = !this.isDropdown && !this.hideHeader; + this._calendar.locale = this.locale; + this._calendar.selection = CalendarSelection.RANGE; + this._calendar.weekStart = this.weekStart; + this._calendar.hideOutsideDays = this.hideOutsideDays; + this._calendar.monthsViewNumber = this._displayMonthsCount; + this._calendar.showWeekNumbers = this.showWeekNumbers; + this._calendar.headerTitleTemplate = this.headerTitleTemplate; + this._calendar.headerTemplate = this.headerTemplate; + this._calendar.subheaderTemplate = this.subheaderTemplate; + this._calendar.headerOrientation = this.headerOrientation; + this._calendar.orientation = this.orientation; + this._calendar.specialDates = this.specialDates; + this._calendar.selected.pipe(takeUntil(this._destroy$)).subscribe((ev: Date[]) => this.handleSelection(ev)); + + this._setDisabledDates(); + this._setCalendarActiveDate(); componentInstance.mode = this.mode; componentInstance.closeButtonLabel = !this.isDropdown ? this.doneButtonText : null; componentInstance.pickerActions = this.pickerActions; componentInstance.calendarClose.pipe(takeUntil(this._destroy$)).subscribe(() => this.close()); } + + private _setDisabledDates(): void { + if (!this.calendar) { + return; + } + this.calendar.disabledDates = this.disabledDates ? [...this.disabledDates] : []; + const { minValue, maxValue } = this._getMinMaxDates(); + if (minValue) { + this.calendar.disabledDates.push({ type: DateRangeType.Before, dateRange: [minValue] }); + } + if (maxValue) { + this.calendar.disabledDates.push({ type: DateRangeType.After, dateRange: [maxValue] }); + } + } + + private _getMinMaxDates() { + const minValue = this.parseMinValue(this.minValue); + const maxValue = this.parseMaxValue(this.maxValue); + return { minValue, maxValue }; + } + + private _isValueInDisabledRange(value: DateRange) { + if (value && value.start && value.end && this.disabledDates) { + const isOutsideDisabledRange = Array.from( + calendarRange({ + start: parseDate(this.value.start), + end: parseDate(this.value.end), + inclusive: true + })).every((date) => !isDateInRanges(date, this.disabledDates)); + return !isOutsideDisabledRange; + } + return false; + } + + private _setCalendarActiveDate(value = null): void { + if (this._calendar) { + this._calendar.activeDate = value ?? this.activeDate; + this._calendar.viewDate = value ?? this.activeDate; + } + } } diff --git a/projects/igniteui-angular/src/lib/date-range-picker/public_api.ts b/projects/igniteui-angular/src/lib/date-range-picker/public_api.ts index 2b0e4fa2367..f25a96a786a 100644 --- a/projects/igniteui-angular/src/lib/date-range-picker/public_api.ts +++ b/projects/igniteui-angular/src/lib/date-range-picker/public_api.ts @@ -1,3 +1,4 @@ +import { IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarSubheaderTemplateDirective } from '../calendar/calendar.directives'; import { IgxPickerToggleComponent } from '../date-common/picker-icons.common'; import { IgxHintDirective } from '../directives/hint/hint.directive'; import { IgxLabelDirective } from '../directives/label/label.directive'; @@ -19,5 +20,8 @@ export const IGX_DATE_RANGE_PICKER_DIRECTIVES = [ IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective, - IgxHintDirective + IgxHintDirective, + IgxCalendarHeaderTemplateDirective, + IgxCalendarSubheaderTemplateDirective, + IgxCalendarHeaderTitleTemplateDirective ] as const; diff --git a/projects/igniteui-angular/src/lib/time-picker/time-picker.component.html b/projects/igniteui-angular/src/lib/time-picker/time-picker.component.html index 79cd4288ccd..c0358f97a63 100644 --- a/projects/igniteui-angular/src/lib/time-picker/time-picker.component.html +++ b/projects/igniteui-angular/src/lib/time-picker/time-picker.component.html @@ -62,7 +62,7 @@