diff --git a/CHANGELOG.md b/CHANGELOG.md index d0123c323d5..13f46f84476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ All notable changes for each version of this project will be documented in this ### 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. - `IgxGridComponent`, `IgxTreeGridComponent`, `IgxHierarchicalGridComponent` - Added the `validationTooltipTrigger` input, which controls when the validation error tooltip of an invalid cell is displayed. With the default `active` value the tooltip is shown when an invalid cell becomes active and on error icon hover, as before. With `hover` it is shown only while hovering the error icon, while the invalid state, the error icon and the invalid styles are kept. diff --git a/projects/igniteui-angular/calendar/README.md b/projects/igniteui-angular/calendar/README.md index 800a9aa9e80..73d4c866b3a 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,67 @@ 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. +Import `IgxCalendarDayTemplateDirective` (or `IGX_CALENDAR_DIRECTIVES`) together with the calendar, otherwise the +`igxCalendarDay` attribute is ignored and the default day content is rendered. + +```typescript +import { IgxCalendarComponent, IgxCalendarDayTemplateDirective } from 'igniteui-angular/calendar'; + +@Component({ + imports: [IgxCalendarComponent, IgxCalendarDayTemplateDirective], + ... +}) +``` + +```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 day cell is announced through its own `aria-label`, and the text rendered by the template is announced as +the cell description, so render readable text (e.g. "3 events") rather than symbols only. The template 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,268 @@ 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 describe visible templated day cells with their template content", async () => { + await createFixture((instance) => instance.hideOutsideDays = true); + + const cell = dayItem(new Date(2017, 5, 13)); + const description = document.getElementById(cell.getAttribute("aria-describedby")!); + expect(description).toBe(customDay(new Date(2017, 5, 13)).parentElement); + expect(cell.getAttribute("aria-label")).toBe(new Date(2017, 5, 13).toDateString()); + + dom.queryAll(By.css(HelperTestFunctions.HIDDEN_DAYS_CSSCLASS)) + .forEach((day) => expect(day.nativeElement.hasAttribute("aria-describedby")).toBe(false)); + }); + + it("Should not hide the templated content, so hidden template parts stay out of the description", async () => { + await createFixture(); + + const description = customDay(new Date(2017, 5, 13)).parentElement; + expect(description.id).toBe(dayItem(new Date(2017, 5, 13)).getAttribute("aria-describedby")); + expect(description.hasAttribute("aria-hidden")).toBe(false); + + const defaultFixture = TestBed.createComponent(IgxCalendarRangeValueComponent); + defaultFixture.detectChanges(); + defaultFixture.debugElement.queryAll(By.css(".igx-day-item__inner")) + .forEach((inner) => expect(inner.nativeElement.getAttribute("aria-hidden")).toBe("true")); + }); + + 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 keep the same date reference in the day template context until the day changes", async () => { + await createFixture(); + const day: IgxDayItemComponent = dom.query(By.directive(IgxDayItemComponent)).componentInstance; + const date = day.getTemplateContext("").date; + + expect(day.getTemplateContext("").date).toBe(date); + + // June 2023 has the same layout as June 2017, so the first cell (May 28) is reused with the new year. + calendar.viewDate = new Date(2023, 5, 13); + fixture.detectChanges(); + expect(day.getTemplateContext("").date).toEqual(new Date(2023, 4, 28)); + }); + + 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 +3062,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..49b8d180116 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.contentDayTemplate ?? null; + } + + public set dayTemplate(value: TemplateRef | null) { + this._dayTemplate = value; + } + /** * Gets the context for the template marked with the `igxCalendarHeader` directive. * @@ -414,6 +440,11 @@ export class IgxCalendarComponent extends IgxCalendarBaseDirective implements Af @ContentChild(forwardRef(() => IgxCalendarSubheaderTemplateDirective), { read: IgxCalendarSubheaderTemplateDirective, static: true }) private subheaderTemplateDirective!: IgxCalendarSubheaderTemplateDirective; + @ContentChild(IgxCalendarDayTemplateDirective, { read: TemplateRef }) + private contentDayTemplate?: 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..b85d9b73e54 100644 --- a/projects/igniteui-angular/calendar/src/calendar/calendar.directives.ts +++ b/projects/igniteui-angular/calendar/src/calendar/calendar.directives.ts @@ -134,6 +134,82 @@ 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 text is announced as the day cell description and the template should not contain focusable elements. + * + * @example + * ```html + * + * + * {{ date.getDate() }} + * + * + * ``` + */ +@Directive({ + selector: '[igxCalendarDay]' +}) +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.html b/projects/igniteui-angular/calendar/src/calendar/days-view/day-item.component.html index 3fc372d64a9..8fa791e0304 100644 --- a/projects/igniteui-angular/calendar/src/calendar/days-view/day-item.component.html +++ b/projects/igniteui-angular/calendar/src/calendar/days-view/day-item.component.html @@ -1,6 +1,7 @@