diff --git a/CHANGELOG.md b/CHANGELOG.md index fcec5bb04c2..0b098243cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes for each version of this project will be documented in this ## Unreleased +### New Features + +- `IgxCalendarComponent` + - Added the `igxCalendarDay` template directive and the `dayTemplate` input to customize the content of the day cells, e.g. to show event indicators, availability or prices. The template receives the date and the day states (`selected`, `disabled`, `special`, `today`, `weekend`, `currentMonth`, `active` and the range states) through the `IgxCalendarDayTemplateContext`. The day cells keep their selection, keyboard navigation, ARIA attributes and theme styling. `IgxDatePickerComponent` and `IgxDateRangePickerComponent` pass an `igxCalendarDay` template to their calendar. + ### Behavioral Changes - `IgxCarouselComponent` diff --git a/projects/igniteui-angular/calendar/README.md b/projects/igniteui-angular/calendar/README.md index 800a9aa9e80..6ac1dbb0a30 100644 --- a/projects/igniteui-angular/calendar/README.md +++ b/projects/igniteui-angular/calendar/README.md @@ -167,6 +167,9 @@ Controls the number of month views displayed. Default is 1. - `hideOusideDays: boolean` Controls the visibility of the dates that do not belong to the current month. +- `dayTemplate: TemplateRef | null` +Sets a template for the content of the day cells. When not set, the template marked with the `igxCalendarDay` directive is used, if any. See [Day template](#day-template). + ### Outputs @@ -259,3 +262,55 @@ In the `igxCalendarSubheader` context this is the same as the `viewDate` (offset ``` where `value` is the locale string representation of the date component, `literal` is the locale string separator (if any), and `combined` is as the name suggests the combined output of the two. + +#### Day template + +Decorate a `ng-template` inside the calendar with the `igxCalendarDay` directive to customize the content of the day cells, +for example to show event indicators, availability or prices. The template can also be set through the `dayTemplate` input. + +```html + + + + {{ date.getDate() }} + @if (hasEvents(date)) { + + } + + + +``` + +```scss +.day__dot { + // `currentColor` follows the day cell foreground, so the dot stays visible on the selected background. + background: currentColor; +} +``` + +The template replaces only the default day number. The day cell keeps the selection, the keyboard navigation, +the ARIA attributes and the state styling (selected, range, current, special, disabled, etc.), so selecting a +templated day works as before and the theme still draws its selected state. The template is not rendered for hidden days +(see `hideOutsideDays`) and is used in every month view when `monthsViewNumber` is greater than `1`. +The `igx-date-picker` and `igx-date-range-picker` pass a template marked with `igxCalendarDay` to their calendar. +The directive is included in `IGX_DATE_PICKER_DIRECTIVES` and `IGX_DATE_RANGE_PICKER_DIRECTIVES`. + +**NOTE:** The template content is hidden from assistive technologies, as the day cell is announced through its own +`aria-label`, and it should not contain focusable or interactive elements, since a click anywhere in the cell selects the date. +Use `currentColor` or the `selected` context property to keep the content readable on the selected background. + +| Name | Type | Description | +| :----------------- | :-----: | :---------------------------------------------------------------------------------- | +| $implicit, date | Date | The date of the day cell. | +| formattedDate | string | The default text of the day cell, formatted with `formatOptions` and `formatViews`. | +| selected | boolean | Whether the day is rendered as selected. | +| disabled | boolean | Whether the day is disabled through `disabledDates`. | +| special | boolean | Whether the day is marked as special through `specialDates`. | +| today | boolean | Whether the day is the current date. | +| weekend | boolean | Whether the day is a weekend day. | +| currentMonth | boolean | Whether the day belongs to the displayed month. | +| active | boolean | Whether the day is the active (keyboard focused) day. | +| firstInRange | boolean | Whether the day is the first day of the selected or previewed range. | +| lastInRange | boolean | Whether the day is the last day of the selected or previewed range. | +| withinRange | boolean | Whether the day is within the selected range. Always `false` unless `selection` is `range`. | +| withinPreviewRange | boolean | Whether the day is within the previewed range while a range is being selected. | diff --git a/projects/igniteui-angular/calendar/src/calendar/calendar.component.html b/projects/igniteui-angular/calendar/src/calendar/calendar.component.html index cca6233920a..fd3eef60eed 100644 --- a/projects/igniteui-angular/calendar/src/calendar/calendar.component.html +++ b/projects/igniteui-angular/calendar/src/calendar/calendar.component.html @@ -254,6 +254,7 @@

{ IgxCalendarValueComponent, IgxCalendarTemplatesComponent, IgxCalendarRangeValueComponent, + IgxCalendarDayTemplateComponent, + IgxCalendarDayTemplateInputComponent, ], }).compileComponents(); }); @@ -2695,6 +2697,230 @@ describe("IgxCalendar - ", () => { } }); }); + + describe("Day template - ", () => { + let fixture: ComponentFixture; + let dom: DebugElement; + let calendar: IgxCalendarComponent; + + const createFixture = async (setup?: (instance: IgxCalendarDayTemplateComponent) => void) => { + fixture = TestBed.createComponent(IgxCalendarDayTemplateComponent); + setup?.(fixture.componentInstance); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + dom = fixture.debugElement; + calendar = fixture.componentInstance.calendar; + }; + + const customDay = (date: Date): HTMLElement => + dom.query(By.css(`.custom-day[data-date="${date.toDateString()}"]`))?.nativeElement; + + const dayItem = (date: Date): HTMLElement => customDay(date).closest(HelperTestFunctions.DAY_CSSCLASS); + + it("Should render the day template inside every day cell with the date and formatted date", async () => { + await createFixture(); + + expect(calendar.dayTemplate).not.toBeNull(); + + const days = dom.queryAll(By.css(HelperTestFunctions.DAY_CSSCLASS)); + const customDays = dom.queryAll(By.css(".custom-day")); + expect(customDays.length).toBe(days.length); + + const day = customDay(new Date(2017, 5, 13)); + expect(day.textContent.trim()).toBe("13!"); + expect(day.parentElement.classList).toContain("igx-day-item__inner"); + expect(dayItem(new Date(2017, 5, 13)).getAttribute("aria-label")).toBe(new Date(2017, 5, 13).toDateString()); + }); + + it("Should not render the day template for hidden days", async () => { + await createFixture((instance) => instance.hideOutsideDays = true); + + const customDays = dom.queryAll(By.css(".custom-day")); + expect(customDays.length).toBe(30); + expect(dom.queryAll(By.css(".custom-day--inactive")).length).toBe(0); + }); + + it("Should pass the day states to the day template", async () => { + await createFixture(); + + expect(customDay(new Date(2017, 5, 13)).classList).toContain("custom-day--selected"); + expect(customDay(new Date(2017, 5, 14)).classList).not.toContain("custom-day--selected"); + expect(customDay(new Date(2017, 5, 21)).classList).toContain("custom-day--disabled"); + expect(customDay(new Date(2017, 5, 2)).classList).toContain("custom-day--special"); + expect(customDay(new Date(2017, 5, 17)).classList).toContain("custom-day--weekend"); + expect(customDay(new Date(2017, 4, 28)).classList).toContain("custom-day--inactive"); + expect(customDay(new Date(2017, 5, 14)).classList).not.toContain("custom-day--inactive"); + }); + + it("Should select a templated day on click and update the template state", async () => { + await createFixture(); + vi.spyOn(calendar.selected, "emit"); + + customDay(new Date(2017, 5, 14)).click(); + fixture.detectChanges(); + + expect(calendar.selected.emit).toHaveBeenCalledWith(new Date(2017, 5, 14)); + expect(calendar.value).toEqual(new Date(2017, 5, 14)); + expect(customDay(new Date(2017, 5, 14)).classList).toContain("custom-day--selected"); + expect(customDay(new Date(2017, 5, 13)).classList).not.toContain("custom-day--selected"); + + const cell = dayItem(new Date(2017, 5, 14)); + expect(cell.getAttribute("aria-selected")).toBe("true"); + expect(cell.classList).toContain(HelperTestFunctions.SELECTED_DATE); + }); + + it("Should keep keyboard navigation and selection working with a day template", async () => { + await createFixture(); + const wrapper = dom.query(By.css(HelperTestFunctions.CALENDAR_WRAPPER_CLASS)).nativeElement; + wrapper.focus(); + calendar.activeDate = new Date(2017, 5, 13); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem("ArrowRight", wrapper); + fixture.detectChanges(); + + expect(calendar.activeDate).toEqual(new Date(2017, 5, 14)); + expect(customDay(new Date(2017, 5, 14)).classList).toContain("custom-day--active"); + + UIInteractions.triggerKeyDownEvtUponElem("Enter", wrapper); + fixture.detectChanges(); + + expect(calendar.value).toEqual(new Date(2017, 5, 14)); + expect(customDay(new Date(2017, 5, 14)).classList).toContain("custom-day--selected"); + }); + + it("Should update the template state when the value is changed through the API", async () => { + await createFixture(); + + calendar.selectDate(new Date(2017, 5, 26)); + fixture.detectChanges(); + + expect(customDay(new Date(2017, 5, 26)).classList).toContain("custom-day--selected"); + expect(customDay(new Date(2017, 5, 13)).classList).not.toContain("custom-day--selected"); + }); + + it("Should pass the range states to the day template", async () => { + await createFixture((instance) => { + instance.selection = "range"; + instance.model = [new Date(2017, 5, 5), new Date(2017, 5, 8)]; + }); + + const first = customDay(new Date(2017, 5, 5)).classList; + const middle = customDay(new Date(2017, 5, 6)).classList; + const last = customDay(new Date(2017, 5, 8)).classList; + + expect(first).toContain("custom-day--first"); + expect(first).toContain("custom-day--range"); + expect(middle).toContain("custom-day--range"); + expect(middle).not.toContain("custom-day--first"); + expect(middle).not.toContain("custom-day--last"); + expect(last).toContain("custom-day--last"); + expect(customDay(new Date(2017, 5, 9)).classList).not.toContain("custom-day--range"); + }); + + it("Should not pass the range state to the day template for a multi selection", async () => { + await createFixture((instance) => { + instance.selection = "multi"; + instance.model = [new Date(2017, 5, 5), new Date(2017, 5, 8)]; + }); + + expect(customDay(new Date(2017, 5, 5)).classList).toContain("custom-day--selected"); + expect(customDay(new Date(2017, 5, 8)).classList).toContain("custom-day--selected"); + + for (const date of [6, 7]) { + const day = customDay(new Date(2017, 5, date)); + expect(day.classList).not.toContain("custom-day--range"); + expect(day.classList).not.toContain("custom-day--selected"); + expect(dayItem(new Date(2017, 5, date)).classList).not.toContain("igx-day-item--range"); + } + }); + + it("Should pass a boolean selected state to the day template when the selection is empty", async () => { + for (const selection of ["multi", "range"] as const) { + await createFixture((instance) => { + instance.selection = selection; + instance.model = []; + }); + + expect(customDay(new Date(2017, 5, 14)).getAttribute("data-selected")).toBe("false"); + expect(customDay(new Date(2017, 4, 28)).getAttribute("data-selected")).toBe("false"); + } + }); + + it("Should pass the today state to the day template", async () => { + const today = new Date(); + await createFixture((instance) => { + instance.viewDate = today; + instance.model = today; + }); + + const todayDays = dom.queryAll(By.css(".custom-day--today")); + expect(todayDays.length).toBe(1); + expect(todayDays[0].nativeElement.getAttribute("data-date")).toBe(today.toDateString()); + }); + + it("Should pass the preview range state to the day template while a range is being selected", async () => { + await createFixture((instance) => { + instance.selection = "range"; + instance.model = [new Date(2017, 5, 5)]; + }); + + expect(dom.queryAll(By.css(".custom-day--preview")).length).toBe(0); + + UIInteractions.simulateMouseEvent("mouseenter", customDay(new Date(2017, 5, 8)).parentElement, 0, 0); + fixture.detectChanges(); + + for (const date of [5, 6, 7, 8]) { + expect(customDay(new Date(2017, 5, date)).classList).toContain("custom-day--preview"); + } + expect(customDay(new Date(2017, 5, 9)).classList).not.toContain("custom-day--preview"); + + UIInteractions.simulateMouseEvent("mouseleave", customDay(new Date(2017, 5, 8)).parentElement, 0, 0); + fixture.detectChanges(); + + expect(dom.queryAll(By.css(".custom-day--preview")).length).toBe(0); + }); + + it("Should give the dayTemplate input priority over the igxCalendarDay template", async () => { + await createFixture(); + expect(dom.queryAll(By.css(".override-day")).length).toBe(0); + + fixture.componentInstance.customTemplate = fixture.componentInstance.overrideTemplate; + fixture.detectChanges(); + + expect(calendar.dayTemplate).toBe(fixture.componentInstance.overrideTemplate); + expect(dom.queryAll(By.css(".custom-day")).length).toBe(0); + expect(dom.queryAll(By.css(".override-day")).length) + .toBe(dom.queryAll(By.css(HelperTestFunctions.DAY_CSSCLASS)).length); + + fixture.componentInstance.customTemplate = null; + fixture.detectChanges(); + + expect(dom.queryAll(By.css(".override-day")).length).toBe(0); + expect(dom.queryAll(By.css(".custom-day")).length).toBeGreaterThan(0); + }); + + it("Should render the day template in every month view", async () => { + await createFixture((instance) => instance.monthsViewNumber = 2); + + const views = dom.queryAll(By.css(HelperTestFunctions.DAYS_VIEW)); + expect(views.length).toBe(2); + views.forEach((view) => expect(view.queryAll(By.css(".custom-day")).length).toBeGreaterThan(0)); + expect(customDay(new Date(2017, 6, 20))).toBeTruthy(); + }); + + it("Should render a day template passed through the dayTemplate input", () => { + const inputFixture = TestBed.createComponent(IgxCalendarDayTemplateInputComponent); + inputFixture.detectChanges(); + + const days = inputFixture.debugElement.queryAll(By.css(HelperTestFunctions.DAY_CSSCLASS)); + const customDays = inputFixture.debugElement.queryAll(By.css(".input-day")); + + expect(inputFixture.componentInstance.calendar.dayTemplate).toBe(inputFixture.componentInstance.template); + expect(customDays.length).toBe(days.length); + }); + }); }); }); @@ -2798,6 +3024,96 @@ export class IgxCalendarTemplatesComponent { public viewDate = new Date(2017, 5, 13); } +@Component({ + template: ` + + + {{ formattedDate }}! + + + + {{ date.getDate() }} + + `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxCalendarComponent, IgxCalendarDayTemplateDirective, FormsModule] +}) +export class IgxCalendarDayTemplateComponent { + @ViewChild(IgxCalendarComponent, { static: true }) + public calendar: IgxCalendarComponent; + @ViewChild("overrideTemplate", { static: true }) + public overrideTemplate: TemplateRef; + public customTemplate: TemplateRef | null = null; + public viewDate = new Date(2017, 5, 13); + public selection: "single" | "multi" | "range" = "single"; + public model: Date | Date[] = new Date(2017, 5, 13); + public monthsViewNumber = 1; + public hideOutsideDays = false; + public disabledDates: DateRangeDescriptor[] = [ + { type: DateRangeType.Between, dateRange: [new Date(2017, 5, 20), new Date(2017, 5, 22)] }, + ]; + public specialDates: DateRangeDescriptor[] = [ + { type: DateRangeType.Specific, dateRange: [new Date(2017, 5, 2)] }, + ]; +} + +@Component({ + template: ` + + + {{ date.getDate() }} + + `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [IgxCalendarComponent] +}) +export class IgxCalendarDayTemplateInputComponent { + @ViewChild(IgxCalendarComponent, { static: true }) + public calendar: IgxCalendarComponent; + @ViewChild("dayTemplate", { static: true }) + public template: TemplateRef; + public viewDate = new Date(2017, 5, 13); +} + @Component({ template: ``, changeDetection: ChangeDetectionStrategy.Eager, diff --git a/projects/igniteui-angular/calendar/src/calendar/calendar.component.ts b/projects/igniteui-angular/calendar/src/calendar/calendar.component.ts index 0a8b684db4a..d55d33dcac8 100644 --- a/projects/igniteui-angular/calendar/src/calendar/calendar.component.ts +++ b/projects/igniteui-angular/calendar/src/calendar/calendar.component.ts @@ -13,11 +13,14 @@ import { HostListener, ChangeDetectionStrategy, ViewEncapsulation, + TemplateRef, } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { + IgxCalendarDayTemplateContext, + IgxCalendarDayTemplateDirective, IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarSubheaderTemplateDirective, @@ -357,6 +360,29 @@ export class IgxCalendarComponent extends IgxCalendarBaseDirective implements Af this.subheaderTemplateDirective = directive; } + /** + * Sets/gets the template used for the content of the day cells. + * + * @remarks + * When not set, the template marked with the `igxCalendarDay` directive is used, if any. + * The template receives an `IgxCalendarDayTemplateContext` and replaces the default day number. + * Selection, keyboard navigation, ARIA attributes and state styling of the day cells are preserved. + * + * @example + * ```html + * + * {{ date.getDate() }} + * ``` + */ + @Input() + public get dayTemplate(): TemplateRef | null { + return this._dayTemplate ?? this.dayTemplateDirective ?? null; + } + + public set dayTemplate(value: TemplateRef | null) { + this._dayTemplate = value; + } + /** * Gets the context for the template marked with the `igxCalendarHeader` directive. * @@ -414,6 +440,15 @@ export class IgxCalendarComponent extends IgxCalendarBaseDirective implements Af @ContentChild(forwardRef(() => IgxCalendarSubheaderTemplateDirective), { read: IgxCalendarSubheaderTemplateDirective, static: true }) private subheaderTemplateDirective!: IgxCalendarSubheaderTemplateDirective; + /** + * @hidden + * @internal + */ + @ContentChild(forwardRef(() => IgxCalendarDayTemplateDirective), { read: TemplateRef }) + private dayTemplateDirective?: TemplateRef; + + private _dayTemplate: TemplateRef | null = null; + /** * @hidden * @internal diff --git a/projects/igniteui-angular/calendar/src/calendar/calendar.directives.ts b/projects/igniteui-angular/calendar/src/calendar/calendar.directives.ts index ff104af80ea..f827f8b49de 100644 --- a/projects/igniteui-angular/calendar/src/calendar/calendar.directives.ts +++ b/projects/igniteui-angular/calendar/src/calendar/calendar.directives.ts @@ -134,6 +134,83 @@ export class IgxCalendarSubheaderTemplateDirective { public template = inject>(TemplateRef); } +/** + * The context passed to a template marked with the `igxCalendarDay` directive. + */ +export interface IgxCalendarDayTemplateContext { + /** The date of the day cell. */ + $implicit: Date; + /** The date of the day cell. */ + date: Date; + /** The default text of the day cell, formatted with the calendar `formatOptions` and `formatViews`. */ + formattedDate: string; + /** Whether the day is rendered as selected. */ + selected: boolean; + /** Whether the day is disabled through `disabledDates`. */ + disabled: boolean; + /** Whether the day is marked as special through `specialDates`. */ + special: boolean; + /** Whether the day is the current date. */ + today: boolean; + /** Whether the day is a weekend day. */ + weekend: boolean; + /** Whether the day belongs to the displayed month. */ + currentMonth: boolean; + /** Whether the day is the active (keyboard focused) day. */ + active: boolean; + /** Whether the day is the first day of the selected or previewed range. */ + firstInRange: boolean; + /** Whether the day is the last day of the selected or previewed range. */ + lastInRange: boolean; + /** Whether the day is within the selected range. */ + withinRange: boolean; + /** Whether the day is within the previewed range while a range is being selected. */ + withinPreviewRange: boolean; +} + +/** + * Marks an `ng-template` as the content template of the calendar day cells. + * + * @remarks + * The template replaces the default content (the day number) of each visible day cell. + * Selection, keyboard navigation, ARIA attributes and state styling stay on the day cell. + * The template content is hidden from assistive technologies and should not contain focusable elements. + * + * @example + * ```html + * + * + * {{ date.getDate() }} + * + * + * ``` + */ +@Directive({ + selector: '[igxCalendarDay]', + standalone: true +}) +export class IgxCalendarDayTemplateDirective { + public template = inject>(TemplateRef); + + /** + * Narrows the type of the template context. + * + * @param _directive The directive instance. + * @param _context The template context. + * @returns Always `true`. + * @example + * ```html + * {{ date.getDate() }} + * ``` + */ + public static ngTemplateContextGuard( + _directive: IgxCalendarDayTemplateDirective, + _context: unknown + ): _context is IgxCalendarDayTemplateContext { + return true; + } +} + /** * @hidden */ diff --git a/projects/igniteui-angular/calendar/src/calendar/days-view/day-item.component.ts b/projects/igniteui-angular/calendar/src/calendar/days-view/day-item.component.ts index e68c6615ea0..f60729e5290 100644 --- a/projects/igniteui-angular/calendar/src/calendar/days-view/day-item.component.ts +++ b/projects/igniteui-angular/calendar/src/calendar/days-view/day-item.component.ts @@ -1,5 +1,6 @@ import { Component, Input, Output, EventEmitter, HostBinding, ElementRef, booleanAttribute, ChangeDetectionStrategy, inject } from '@angular/core'; import { CalendarSelection } from '../calendar'; +import { IgxCalendarDayTemplateContext } from '../calendar.directives'; import { areSameMonth, CalendarDay, DateRangeDescriptor, isDateInRanges, isNextMonth, isPreviousMonth } from 'igniteui-angular/core'; /** @@ -190,5 +191,32 @@ export class IgxDayItemComponent { return this.selection !== CalendarSelection.RANGE; } + /** + * Returns the context for the day template. + * The same object is updated and returned on every call to avoid re-creating it on each change detection. + * + * @hidden + * @internal + */ + public getTemplateContext(formattedDate: string): IgxCalendarDayTemplateContext { + return Object.assign(this._templateContext, { + $implicit: this.date.native, + date: this.date.native, + formattedDate, + selected: !!this.isSelectedCSS, + disabled: this.isDisabled, + special: this.isSpecial, + today: this.isToday, + weekend: this.isWeekend, + currentMonth: this.isCurrentMonth, + active: this.isActive, + firstInRange: this.isFirstInRange, + lastInRange: this.isLastInRange, + withinRange: this.isWithinRangeCSS, + withinPreviewRange: this.isWithinPreviewRange + }); + } + private _selected = false; + private _templateContext = {} as IgxCalendarDayTemplateContext; } diff --git a/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.html b/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.html index e1814966907..6e48871d8ae 100644 --- a/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.html +++ b/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.html @@ -94,7 +94,13 @@ (mouseEnter)="changePreviewRange(day.native)" (mouseLeave)="clearPreviewRange()" > - {{ formattedDate(day.native) | dayDigit:formatViews }} + @if (!dayTemplate) { + {{ formattedDate(day.native) | dayDigit:formatViews }} + } @else if (!item.isHidden) { + + } } diff --git a/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.ts b/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.ts index 31463d42cb0..ffa8238f55c 100644 --- a/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.ts +++ b/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.ts @@ -12,11 +12,13 @@ import { ChangeDetectorRef, ChangeDetectionStrategy, ViewEncapsulation, + TemplateRef, inject } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; -import { TitleCasePipe } from '@angular/common'; +import { NgTemplateOutlet, TitleCasePipe } from '@angular/common'; import { CalendarSelection, ScrollDirection } from '../../calendar/calendar'; +import { IgxCalendarDayTemplateContext } from '../calendar.directives'; import { IgxDayItemComponent } from './day-item.component'; import { CalendarDay, @@ -51,7 +53,7 @@ let NEXT_ID = 0; styleUrl: 'days-view.component.css', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [IgxDayItemComponent, TitleCasePipe, DayDigitPipe] + imports: [IgxDayItemComponent, NgTemplateOutlet, TitleCasePipe, DayDigitPipe] }) export class IgxDaysViewComponent extends IgxCalendarBaseDirective { protected el = inject(ElementRef); @@ -160,6 +162,19 @@ export class IgxDaysViewComponent extends IgxCalendarBaseDirective { return this._hideTrailingDays ?? this.hideOutsideDays; } + /** + * Sets/gets the template used for the content of the day cells. + * When not set, the day cells display the formatted day number. + * + * @example + * ```html + * + * {{ date.getDate() }} + * ``` + */ + @Input() + public dayTemplate: TemplateRef | null = null; + @Input({ transform: booleanAttribute }) public set showActiveDay(value: boolean) { this._showActiveDay = value; diff --git a/projects/igniteui-angular/calendar/src/calendar/public_api.ts b/projects/igniteui-angular/calendar/src/calendar/public_api.ts index 1de02a55b40..f7715a4f1eb 100644 --- a/projects/igniteui-angular/calendar/src/calendar/public_api.ts +++ b/projects/igniteui-angular/calendar/src/calendar/public_api.ts @@ -1,5 +1,5 @@ import { IgxCalendarComponent } from './calendar.component'; -import { IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarMonthDirective, IgxCalendarSubheaderTemplateDirective, IgxCalendarYearDirective } from './calendar.directives'; +import { IgxCalendarDayTemplateDirective, IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarMonthDirective, IgxCalendarSubheaderTemplateDirective, IgxCalendarYearDirective } from './calendar.directives'; import { IgxDaysViewComponent } from './days-view/days-view.component'; import { IgxMonthPickerComponent } from './month-picker/month-picker.component'; import { IgxMonthsViewComponent } from './months-view/months-view.component'; @@ -24,6 +24,7 @@ export const IGX_CALENDAR_DIRECTIVES = [ IgxCalendarHeaderTitleTemplateDirective, IgxCalendarMonthDirective, IgxCalendarYearDirective, - IgxCalendarSubheaderTemplateDirective + IgxCalendarSubheaderTemplateDirective, + IgxCalendarDayTemplateDirective ] as const; diff --git a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts index 2ddeaff3892..f795e8be7e2 100644 --- a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts +++ b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts @@ -4,9 +4,10 @@ import { FormField, disabled, form as signalForm, required } from '@angular/form import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; import { IgxHintDirective, IgxInputGroupComponent, IgxInputState, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from '../../../input-group/src/public_api'; -import { IgxCalendarComponent, IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective } from '../../../calendar/src/public_api'; +import { IgxCalendarComponent } from '../../../calendar/src/public_api'; import { IgxCalendarContainerComponent } from './calendar-container/calendar-container.component'; import { IgxDatePickerComponent } from './date-picker.component'; +import { IGX_DATE_PICKER_DIRECTIVES } from './public_api'; import { IgxOverlayService, OverlayCancelableEventArgs, OverlayClosingEventArgs, OverlayEventArgs, OverlaySettings, WEEKDAYS } from 'igniteui-angular/core'; import { ChangeDetectorRef, Component, DebugElement, ElementRef, EventEmitter, Injector, provideZonelessChangeDetection, QueryList, Renderer2, viewChild, ViewChild, ChangeDetectionStrategy, signal } from '@angular/core'; import { By } from '@angular/platform-browser'; @@ -723,6 +724,18 @@ describe('IgxDatePicker', () => { expect(headerElement.nativeElement.textContent.trim()).toBe('Nov'); } }); + + it('Should use the custom template for day cells', async () => { + fixture.componentInstance.datePicker.value = new Date(2024, 10, 11); + fixture.componentInstance.datePicker.open(); + await fixture.whenStable(); + fixture.detectChanges(); + + const days = fixture.debugElement.queryAll(By.css('.igx-day-item:not(.igx-day-item--hidden)')); + const customDays = fixture.debugElement.queryAll(By.css('.igx-day-item__inner .custom-day')); + expect(customDays.length).toBeGreaterThan(0); + expect(customDays.length).toBe(days.length); + }); }); describe('UI Interaction', () => { @@ -1747,9 +1760,10 @@ export class IgxDatePickerWithProjectionsComponent { {{ formatCalendar.year.value }} {{ formatCalendar.month.value }} + {{ date.getDate() }} `, changeDetection: ChangeDetectionStrategy.Eager, - imports: [IgxDatePickerComponent, IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective] + imports: [IGX_DATE_PICKER_DIRECTIVES] }) export class IgxDatePickerWithTemplatesComponent { @ViewChild(IgxDatePickerComponent) diff --git a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts index 3745e44fa7c..fda040d608f 100644 --- a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts +++ b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts @@ -15,6 +15,7 @@ import { Output, PipeTransform, Renderer2, + TemplateRef, ViewChild, ViewContainerRef, booleanAttribute, @@ -33,7 +34,7 @@ import { } from '@angular/forms'; import { IgxCalendarComponent, IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarSubheaderTemplateDirective, - IFormattingViews, IFormattingOptions + IFormattingViews, IFormattingOptions, IgxCalendarDayTemplateContext, IgxCalendarDayTemplateDirective } from 'igniteui-angular/calendar'; import { IgxLabelDirective, IgxInputState, toInputState, IgxInputGroupComponent, IgxPrefixDirective, IgxInputDirective, IgxSuffixDirective, @@ -437,6 +438,9 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr @ContentChild(IgxCalendarSubheaderTemplateDirective) private subheaderTemplate!: IgxCalendarSubheaderTemplateDirective; + @ContentChild(IgxCalendarDayTemplateDirective, { read: TemplateRef }) + private dayTemplate?: TemplateRef; + @ContentChild(IgxPickerActionsDirective) private pickerActions!: IgxPickerActionsDirective; @@ -957,6 +961,7 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr this._calendar.headerTitleTemplate = this.headerTitleTemplate; this._calendar.headerTemplate = this.headerTemplate; this._calendar.subheaderTemplate = this.subheaderTemplate; + this._calendar.dayTemplate = this.dayTemplate ?? null; this._calendar.headerOrientation = this.headerOrientation; this._calendar.hideOutsideDays = this.hideOutsideDays; this._calendar.monthsViewNumber = this.displayMonthsCount; diff --git a/projects/igniteui-angular/date-picker/src/date-picker/public_api.ts b/projects/igniteui-angular/date-picker/src/date-picker/public_api.ts index ac5f3883392..85cdaababad 100644 --- a/projects/igniteui-angular/date-picker/src/date-picker/public_api.ts +++ b/projects/igniteui-angular/date-picker/src/date-picker/public_api.ts @@ -1,7 +1,7 @@ import { IgxPickerActionsDirective, IgxPickerClearComponent, IgxPickerToggleComponent } from 'igniteui-angular/core'; import { IgxDatePickerComponent } from './date-picker.component'; import { IgxHintDirective, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; -import { IgxCalendarHeaderTemplateDirective, IgxCalendarSubheaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective } from 'igniteui-angular/calendar'; +import { IgxCalendarHeaderTemplateDirective, IgxCalendarSubheaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarDayTemplateDirective } from 'igniteui-angular/calendar'; export * from './date-picker.common'; export * from './date-picker.component'; @@ -20,5 +20,6 @@ export const IGX_DATE_PICKER_DIRECTIVES = [ IgxHintDirective, IgxCalendarHeaderTemplateDirective, IgxCalendarSubheaderTemplateDirective, - IgxCalendarHeaderTitleTemplateDirective + IgxCalendarHeaderTitleTemplateDirective, + IgxCalendarDayTemplateDirective ] as const; diff --git a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.spec.ts b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.spec.ts index ae2366edc11..0d2b87bd496 100644 --- a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.spec.ts +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.spec.ts @@ -13,7 +13,7 @@ import { CancelableEventArgs, WEEKDAYS, DateRangePickerResourceStringsEN, change import { IgxDateRangeSeparatorDirective, IgxDateRangeStartComponent } from './date-range-picker-inputs.common'; import { IgxDateTimeEditorDirective } from '../../../directives/src/directives/date-time-editor/date-time-editor.directive'; import { DateRangeType } from 'igniteui-angular/core'; -import { IgxDateRangePickerComponent, IgxDateRangeEndComponent } from './public_api'; +import { IgxDateRangePickerComponent, IgxDateRangeEndComponent, IGX_DATE_RANGE_PICKER_DIRECTIVES } from './public_api'; import { AutoPositionStrategy, IgxOverlayService } from 'igniteui-angular/core'; import { map, Subject, timer } from 'rxjs'; import { AsyncPipe } from '@angular/common'; @@ -24,7 +24,6 @@ import { registerLocaleData } from "@angular/common"; import localeJa from "@angular/common/locales/ja"; import localeBg from "@angular/common/locales/bg"; import { CalendarDay } from 'igniteui-angular/core'; -import { IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarSubheaderTemplateDirective } from 'igniteui-angular/calendar'; import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; // The number of milliseconds in one day @@ -2282,6 +2281,13 @@ describe('IgxDateRangePicker', () => { expect(headerElement.nativeElement.textContent.trim()).toBe('Test subheader'); } }); + + it('Should use the custom template for day cells', async () => { + const days = fixture.debugElement.queryAll(By.css('.igx-day-item:not(.igx-day-item--hidden)')); + const customDays = fixture.debugElement.queryAll(By.css('.igx-day-item__inner .custom-day')); + expect(customDays.length).toBeGreaterThan(0); + expect(customDays.length).toBe(days.length); + }); }); it('should render projected clear icons which clear the range on click', () => { @@ -2649,6 +2655,7 @@ export class DateRangeCustomComponent extends DateRangeTestComponent { Test header Test header title Test subheader + {{ date.getDate() }} @@ -2689,20 +2696,11 @@ export class DateRangeCustomComponent extends DateRangeTestComponent { `, changeDetection: ChangeDetectionStrategy.Eager, imports: [ - IgxDateRangePickerComponent, - IgxDateRangeStartComponent, - IgxDateRangeEndComponent, - IgxPickerToggleComponent, - IgxPickerClearComponent, + IGX_DATE_RANGE_PICKER_DIRECTIVES, IgxIconComponent, FormsModule, IgxInputDirective, - IgxDateTimeEditorDirective, - IgxPrefixDirective, - IgxSuffixDirective, - IgxCalendarHeaderTemplateDirective, - IgxCalendarHeaderTitleTemplateDirective, - IgxCalendarSubheaderTemplateDirective + IgxDateTimeEditorDirective ] }) export class DateRangeTemplatesComponent extends DateRangeTestComponent { diff --git a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts index 70db3ebb920..ad49c0dfc27 100644 --- a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts @@ -35,6 +35,8 @@ import { filter, takeUntil } from 'rxjs/operators'; import { CalendarSelection, IgxCalendarComponent, + IgxCalendarDayTemplateContext, + IgxCalendarDayTemplateDirective, IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarSubheaderTemplateDirective @@ -485,6 +487,9 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective @ContentChild(IgxCalendarSubheaderTemplateDirective) private subheaderTemplate!: IgxCalendarSubheaderTemplateDirective; + @ContentChild(IgxCalendarDayTemplateDirective, { read: TemplateRef }) + private dayTemplate?: TemplateRef; + /** @hidden @internal */ public get dateSeparator(): string { if (this._dateSeparator === null) { @@ -1352,6 +1357,7 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective this._calendar.headerTitleTemplate = this.headerTitleTemplate; this._calendar.headerTemplate = this.headerTemplate; this._calendar.subheaderTemplate = this.subheaderTemplate; + this._calendar.dayTemplate = this.dayTemplate ?? null; this._calendar.headerOrientation = this.headerOrientation; this._calendar.orientation = this.orientation; this._calendar.specialDates = this.specialDates; diff --git a/projects/igniteui-angular/date-picker/src/date-range-picker/public_api.ts b/projects/igniteui-angular/date-picker/src/date-range-picker/public_api.ts index 7997575389e..2b78f9258f9 100644 --- a/projects/igniteui-angular/date-picker/src/date-range-picker/public_api.ts +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/public_api.ts @@ -2,7 +2,7 @@ import { IgxPickerClearComponent, IgxPickerToggleComponent } from 'igniteui-angu import { IgxDateRangeEndComponent, IgxDateRangeSeparatorDirective, IgxDateRangeStartComponent } from './date-range-picker-inputs.common'; import { IgxDateRangePickerComponent } from './date-range-picker.component'; import { IgxHintDirective, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; -import { IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarSubheaderTemplateDirective } from 'igniteui-angular/calendar'; +import { IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarSubheaderTemplateDirective, IgxCalendarDayTemplateDirective } from 'igniteui-angular/calendar'; export * from './date-range-picker-inputs.common'; export * from './date-range-picker.component'; @@ -21,5 +21,6 @@ export const IGX_DATE_RANGE_PICKER_DIRECTIVES = [ IgxHintDirective, IgxCalendarHeaderTemplateDirective, IgxCalendarSubheaderTemplateDirective, - IgxCalendarHeaderTitleTemplateDirective + IgxCalendarHeaderTitleTemplateDirective, + IgxCalendarDayTemplateDirective ] as const; diff --git a/skills/igniteui-angular-components/references/form-controls.md b/skills/igniteui-angular-components/references/form-controls.md index d49d8f49828..b1233e73f0d 100644 --- a/skills/igniteui-angular-components/references/form-controls.md +++ b/skills/igniteui-angular-components/references/form-controls.md @@ -148,6 +148,19 @@ import { IgxCalendarComponent } from 'igniteui-angular/calendar'; Selection modes: `'single'`, `'multi'`, `'range'`. +Custom day cell content (event dots, prices, availability) — import `IgxCalendarDayTemplateDirective` from `igniteui-angular/calendar`. The template replaces only the day number; selection, keyboard navigation and ARIA stay on the cell. Also works inside `igx-date-picker` and `igx-date-range-picker`; the directive is included in `IGX_DATE_PICKER_DIRECTIVES` and `IGX_DATE_RANGE_PICKER_DIRECTIVES`. + +```html + + + {{ date.getDate() }} + @if (hasEvents(date)) { } + + +``` + +Context (`IgxCalendarDayTemplateContext`): `$implicit`/`date`, `formattedDate`, `selected`, `disabled`, `special`, `today`, `weekend`, `currentMonth`, `active`, `firstInRange`, `lastInRange`, `withinRange`, `withinPreviewRange`. Do not put focusable elements in the template (a click anywhere selects the date; the content is `aria-hidden`); style indicators with `currentColor` so they stay visible on the selected background. + ## Checkbox, Radio, Switch ```typescript diff --git a/src/app/calendar/calendar.sample.html b/src/app/calendar/calendar.sample.html index 78b01a126de..1467f48d56d 100644 --- a/src/app/calendar/calendar.sample.html +++ b/src/app/calendar/calendar.sample.html @@ -23,6 +23,69 @@ +
+ Angular Calendar - Day Template + + +
+ 1 event + 2+ events +
+ + Date Range Picker - igxCalendarDay + + + + {{ formattedDate }} + + {{ isSoldOut(date) ? 'full' : '$' + getPrice(date) }} + + + + +
+ + + + {{ formattedDate }} + + @for (event of getEvents(date); track $index) { + + } + + + +
WC Calendar i); + } + + protected getPrice(date: Date): number { + const weekend = date.getDay() === 0 || date.getDay() === 6; + return 80 + ((date.getDate() * 37) % 50) + (weekend ? 40 : 0); + } + + protected isSoldOut(date: Date): boolean { + return date.getDate() % 9 === 0; + } + protected onSelection(event: Date | Date[]) { console.log(`Selected Date(s): ${event}`); }