Run the CLI
Use the CLI to add the component to your project.
npx @ngzard/ui add calendarA flexible and accessible calendar component for selecting dates with three selection modes: single, multiple, and range. Built with modern Angular patterns and full keyboard navigation support.
import { Component } from '@angular/core';
import { ZardCalendarComponent } from '../calendar.component';
@Component({
selector: 'z-demo-calendar-default',
standalone: true,
imports: [ZardCalendarComponent],
template: ` <z-calendar (dateChange)="onDateChange($event)" /> `,
})
export class ZardDemoCalendarDefaultComponent {
onDateChange(date: Date | Date[]) {
console.log('Selected date:', date);
}
}
Use the CLI to add the component to your project.
npx @ngzard/ui add calendarpnpm dlx @ngzard/ui add calendaryarn dlx @ngzard/ui add calendarbunx @ngzard/ui add calendarCreate the component directory structure and add the following files to your project.
import { ChangeDetectionStrategy, Component, computed, input, linkedSignal, model, viewChild, ViewEncapsulation } from '@angular/core';
import { outputFromObservable, outputToObservable } from '@angular/core/rxjs-interop';
import type { ClassValue } from 'clsx';
import { filter } from 'rxjs';
import { ZardCalendarGridComponent } from './calendar-grid.component';
import { ZardCalendarNavigationComponent } from './calendar-navigation.component';
import type { CalendarMode, CalendarValue } from './calendar.types';
import { generateCalendarDays, getSelectedDatesArray, isSameDay } from './calendar.utils';
import { calendarVariants } from './calendar.variants';
import { mergeClasses } from '../../shared/utils/utils';
export type { CalendarDay, CalendarMode, CalendarValue } from './calendar.types';
@Component({
selector: 'z-calendar, [z-calendar]',
exportAs: 'zCalendar',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
imports: [ZardCalendarNavigationComponent, ZardCalendarGridComponent],
host: {
'[attr.tabindex]': '0',
},
template: `
<div [class]="classes()">
<z-calendar-navigation
[currentMonth]="currentMonthValue()"
[currentYear]="currentYearValue()"
[minDate]="minDate()"
[maxDate]="maxDate()"
[disabled]="disabled()"
(monthChange)="onMonthChange($event)"
(yearChange)="onYearChange($event)"
(previousMonth)="previousMonth()"
(nextMonth)="nextMonth()"
/>
<z-calendar-grid
[calendarDays]="calendarDays()"
[disabled]="disabled()"
(dateSelect)="onDateSelect($event)"
(previousMonth)="onGridPreviousMonth($event)"
(nextMonth)="onGridNextMonth($event)"
(previousYear)="navigateYear(-1)"
(nextYear)="navigateYear(1)"
/>
</div>
`,
})
export class ZardCalendarComponent {
private readonly gridRef = viewChild.required(ZardCalendarGridComponent);
// Public method to reset navigation (useful for date-picker)
resetNavigation(): void {
const value = this.currentDate();
this.currentMonthValue.set(value.getMonth().toString());
this.currentYearValue.set(value.getFullYear().toString());
this.gridRef().setFocusedDayIndex(-1);
}
// Public inputs
readonly class = input<ClassValue>('');
readonly zMode = input<CalendarMode>('single');
readonly value = model<CalendarValue>(null);
readonly minDate = input<Date | null>(null);
readonly maxDate = input<Date | null>(null);
readonly disabled = input<boolean>(false);
// Public outputs
readonly dateChange = outputFromObservable(outputToObservable(this.value).pipe(filter(v => v !== null)));
// Internal state
private readonly currentDate = computed(() => {
const val = this.value();
const mode = this.zMode();
if (!val) return new Date();
// For single mode, val is Date | null
if (mode === 'single') return val as Date;
// For multiple/range mode, val is Date[]
if (Array.isArray(val) && val.length > 0) return val[0];
return new Date();
});
protected readonly currentMonthValue = linkedSignal(() => this.currentDate().getMonth().toString());
protected readonly currentYearValue = linkedSignal(() => this.currentDate().getFullYear().toString());
protected readonly classes = computed(() => mergeClasses(calendarVariants(), this.class()));
protected readonly calendarDays = computed(() => {
const currentDate = this.currentDate();
const navigationDate = new Date(Number.parseInt(this.currentYearValue()), Number.parseInt(this.currentMonthValue()), currentDate.getDate());
const selectedDate = Number.isNaN(navigationDate.getTime()) ? currentDate : navigationDate;
return generateCalendarDays({
year: selectedDate.getFullYear(),
month: selectedDate.getMonth(),
mode: this.zMode(),
selectedDates: getSelectedDatesArray(this.value(), this.zMode()),
minDate: this.minDate(),
maxDate: this.maxDate(),
disabled: this.disabled(),
});
});
protected onMonthChange(monthIndex: string | string[]): void {
if (Array.isArray(monthIndex)) {
console.warn('Calendar received array for month selection, expected single value. Ignoring:', monthIndex);
return;
}
if (!monthIndex || monthIndex.trim() === '') {
console.warn('Invalid month index received:', monthIndex);
return;
}
const parsedMonth = Number.parseInt(monthIndex, 10);
if (Number.isNaN(parsedMonth) || parsedMonth < 0 || parsedMonth > 11) {
console.warn('Invalid month value:', monthIndex, 'parsed as:', parsedMonth);
return;
}
const currentDate = this.currentDate();
const selectedYear = Number.parseInt(this.currentYearValue());
const newDate = new Date(Number.isNaN(selectedYear) ? currentDate.getFullYear() : selectedYear, parsedMonth, 1);
this.currentMonthValue.set(newDate.getMonth().toString());
this.gridRef().setFocusedDayIndex(-1);
}
protected onYearChange(year: string | string[]): void {
if (Array.isArray(year)) {
console.warn('Calendar received array for year selection, expected single value. Ignoring:', year);
return;
}
if (!year || year.trim() === '') {
console.warn('Invalid year received:', year);
return;
}
const parsedYear = Number.parseInt(year, 10);
if (Number.isNaN(parsedYear) || parsedYear < 1900 || parsedYear > 2100) {
console.warn('Invalid year value:', year, 'parsed as:', parsedYear);
return;
}
const currentDate = this.currentDate();
const selectedMonth = Number.parseInt(this.currentMonthValue());
const newDate = new Date(parsedYear, Number.isNaN(selectedMonth) ? currentDate.getMonth() : selectedMonth, 1);
this.currentYearValue.set(newDate.getFullYear().toString());
this.gridRef().setFocusedDayIndex(-1);
}
protected previousMonth(): void {
const currentDate = this.currentDate();
const currentMonth = Number.parseInt(this.currentMonthValue());
const previous = new Date(currentDate.getFullYear(), (Number.isNaN(currentMonth) ? currentDate.getMonth() : currentMonth) - 1, 1);
this.currentMonthValue.set(previous.getMonth().toString());
this.gridRef().setFocusedDayIndex(-1);
}
protected nextMonth(): void {
const currentDate = this.currentDate();
const currentMonth = Number.parseInt(this.currentMonthValue());
const next = new Date(currentDate.getFullYear(), (Number.isNaN(currentMonth) ? currentDate.getMonth() : currentMonth) + 1, 1);
this.currentMonthValue.set(next.getMonth().toString());
this.gridRef().setFocusedDayIndex(-1);
}
protected navigateYear(direction: number): void {
const current = this.currentDate();
const newDate = new Date(current.getFullYear() + direction, current.getMonth(), 1);
this.currentYearValue.set(newDate.getFullYear().toString());
setTimeout(() => this.gridRef().resetFocus(), 0);
}
protected onGridPreviousMonth(event: { position: string; dayOfWeek: number }): void {
this.previousMonth();
setTimeout(() => this.resetFocusAfterNavigation(event.position, event.dayOfWeek), 0);
}
protected onGridNextMonth(event: { position: string; dayOfWeek: number }): void {
this.nextMonth();
setTimeout(() => this.resetFocusAfterNavigation(event.position, event.dayOfWeek), 0);
}
protected onDateSelect(event: { date: Date; index: number }): void {
this.selectDate(event.date, event.index);
}
private selectDate(date: Date, index: number): void {
if (this.disabled()) return;
const mode = this.zMode();
const currentValue = this.value();
if (mode === 'single') {
this.value.set(date);
} else if (mode === 'multiple') {
const selectedDates = Array.isArray(currentValue) ? [...currentValue] : [];
const existingIndex = selectedDates.findIndex(d => isSameDay(d, date));
if (existingIndex >= 0) {
// Remove date if already selected
selectedDates.splice(existingIndex, 1);
} else {
// Add date
selectedDates.push(date);
}
this.value.set(selectedDates.length > 0 ? selectedDates : null);
} else if (mode === 'range') {
const selectedDates = Array.isArray(currentValue) ? [...currentValue] : [];
if (selectedDates.length === 0) {
// First date selected - set as range start
this.value.set([date]);
} else if (selectedDates.length === 1) {
// Second date selected - complete the range
const start = selectedDates[0];
if (date.getTime() < start.getTime()) {
// New date is before start, swap them
this.value.set([date, start]);
} else if (isSameDay(date, start)) {
// Same date clicked, reset
this.value.set(null);
} else {
// New date is after start
this.value.set([start, date]);
}
} else {
// Range already complete, start new range
this.value.set([date]);
}
}
}
private resetFocusAfterNavigation(position = 'default', dayOfWeek = -1): void {
const days = this.calendarDays();
let targetIndex = -1;
switch (position) {
case 'first':
// Focus first enabled day
targetIndex = days.findIndex(day => !day.isDisabled);
break;
case 'last':
// Focus last enabled day
for (let i = days.length - 1; i >= 0; i--) {
if (!days[i].isDisabled) {
targetIndex = i;
break;
}
}
break;
case 'firstWeek':
// Focus same day of week in first week
if (dayOfWeek >= 0 && dayOfWeek < 7) {
targetIndex = this.findEnabledInRange(dayOfWeek, 0, days);
}
break;
case 'lastWeek':
// Focus same day of week in last week
if (dayOfWeek >= 0) {
const lastWeekStart = Math.floor((days.length - 1) / 7) * 7;
const targetIdx = Math.min(lastWeekStart + dayOfWeek, days.length - 1);
targetIndex = this.findEnabledInRange(targetIdx, days.length - 1, days);
}
break;
default: {
// Default priority: selected > today > first enabled
const selectedIndex = days.findIndex(day => day.isSelected);
const todayIndex = days.findIndex(day => day.isToday && day.isCurrentMonth);
const firstEnabledIndex = days.findIndex(day => day.isCurrentMonth && !day.isDisabled);
targetIndex = selectedIndex >= 0 ? selectedIndex : todayIndex >= 0 ? todayIndex : Math.max(firstEnabledIndex, 0);
break;
}
}
if (targetIndex >= 0) {
this.gridRef().setFocusedDayIndex(targetIndex);
}
}
private findEnabledInRange(start: number, fallback: number, days: { isDisabled: boolean }[]): number {
const clampedStart = Math.max(0, Math.min(start, days.length - 1));
const clampedFallback = Math.max(0, Math.min(fallback, days.length - 1));
// Search forward from start
for (let i = clampedStart; i < days.length; i++) {
if (!days[i].isDisabled) return i;
}
// Search backward from start
for (let i = clampedStart - 1; i >= 0; i--) {
if (!days[i].isDisabled) return i;
}
return clampedFallback;
}
}
import { cva, type VariantProps } from 'class-variance-authority';
export const calendarVariants = cva('bg-background p-3 w-fit rounded-lg border');
export const calendarMonthVariants = cva('flex flex-col w-fit gap-4');
export const calendarNavVariants = cva('flex items-center justify-between gap-2 w-fit mb-4');
export const calendarNavButtonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
);
export const calendarWeekdaysVariants = cva('flex');
export const calendarWeekdayVariants = cva('text-muted-foreground font-normal text-center text-[0.8rem] w-8');
export const calendarWeekVariants = cva('flex w-full mt-2');
export const calendarDayVariants = cva('p-0 relative focus-within:relative focus-within:z-20 flex mt-1 h-8 w-8 text-sm');
export const calendarDayButtonVariants = cva(
'p-0 font-normal flex items-center justify-center whitespace-nowrap rounded-md ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground w-full h-full text-sm',
{
variants: {
selected: {
true: 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
false: '',
},
today: {
true: 'bg-accent text-accent-foreground',
false: '',
},
outside: {
true: 'text-muted-foreground opacity-50',
false: '',
},
disabled: {
true: 'text-muted-foreground opacity-50 cursor-not-allowed',
false: '',
},
rangeStart: {
true: 'rounded-r-none bg-primary text-primary-foreground',
false: '',
},
rangeEnd: {
true: 'rounded-l-none bg-primary text-primary-foreground',
false: '',
},
inRange: {
true: 'rounded-none bg-accent hover:bg-accent',
false: '',
},
},
compoundVariants: [
{
today: true,
selected: false,
rangeStart: false,
rangeEnd: false,
inRange: false,
className: 'bg-accent text-accent-foreground',
},
{
today: true,
selected: true,
className: 'bg-primary text-primary-foreground',
},
{
rangeStart: true,
rangeEnd: true,
className: 'rounded-md bg-primary text-primary-foreground',
},
],
defaultVariants: {
selected: false,
today: false,
outside: false,
disabled: false,
rangeStart: false,
rangeEnd: false,
inRange: false,
},
},
);
export type ZardCalendarVariants = VariantProps<typeof calendarVariants>;
export type ZardCalendarWeekdayVariants = VariantProps<typeof calendarWeekdayVariants>;
export type ZardCalendarDayVariants = VariantProps<typeof calendarDayVariants>;
export type ZardCalendarDayButtonVariants = VariantProps<typeof calendarDayButtonVariants>;
import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, input, output, signal, viewChild, ViewEncapsulation } from '@angular/core';
import { mergeClasses } from '../../shared/utils/utils';
import type { CalendarDay } from './calendar.types';
import { getDayAriaLabel, getDayId } from './calendar.utils';
import { calendarDayButtonVariants, calendarDayVariants, calendarWeekdayVariants } from './calendar.variants';
@Component({
selector: 'z-calendar-grid',
exportAs: 'zCalendarGrid',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: {
'[attr.role]': '"grid"',
},
template: `
<div #gridContainer>
<!-- Weekdays Header -->
<div class="grid grid-cols-7 text-center w-fit" role="row">
@for (weekday of weekdays; track $index) {
<div [class]="weekdayClasses()" role="columnheader">
{{ weekday }}
</div>
}
</div>
<!-- Calendar Days Grid -->
<div class="grid grid-cols-7 gap-0 mt-2 auto-rows-min w-fit" role="rowgroup">
@for (day of calendarDays(); track day.date.getTime(); let i = $index) {
<div [class]="dayContainerClasses()" role="gridcell">
<button
[id]="getDayId(i)"
[class]="dayButtonClasses(day)"
(click)="onDayClick(day.date, i)"
[disabled]="day.isDisabled"
[attr.aria-selected]="day.isSelected"
[attr.aria-label]="getDayAriaLabel(day)"
[attr.tabindex]="getFocusedDayIndex() === i ? 0 : -1"
role="button"
>
{{ day.date.getDate() }}
</button>
</div>
}
</div>
</div>
`,
})
export class ZardCalendarGridComponent {
private readonly gridContainer = viewChild.required<ElementRef<HTMLElement>>('gridContainer');
// Inputs
readonly calendarDays = input.required<CalendarDay[]>();
readonly disabled = input<boolean>(false);
// Outputs
readonly dateSelect = output<{ date: Date; index: number }>();
readonly previousMonth = output<{ position: string; dayOfWeek: number }>();
readonly nextMonth = output<{ position: string; dayOfWeek: number }>();
readonly previousYear = output<void>();
readonly nextYear = output<void>();
readonly weekdays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
private readonly focusedDayIndex = signal<number>(-1);
protected readonly weekdayClasses = computed(() => mergeClasses(calendarWeekdayVariants()));
protected readonly dayContainerClasses = computed(() => mergeClasses(calendarDayVariants()));
protected dayButtonClasses(day: CalendarDay): string {
return mergeClasses(
calendarDayButtonVariants({
selected: day.isSelected,
today: day.isToday,
outside: !day.isCurrentMonth,
disabled: day.isDisabled,
rangeStart: day.isRangeStart ?? false,
rangeEnd: day.isRangeEnd ?? false,
inRange: day.isInRange ?? false,
}),
);
}
protected onDayClick(date: Date, index: number): void {
if (this.disabled()) return;
this.focusedDayIndex.set(index);
this.dateSelect.emit({ date, index });
}
protected getDayId(index: number): string {
return getDayId(index);
}
protected getDayAriaLabel(day: CalendarDay): string {
return getDayAriaLabel(day);
}
protected getFocusedDayIndex(): number {
const focused = this.focusedDayIndex();
if (focused >= 0) return focused;
// Default focus to selected date or today
const days = this.calendarDays();
const selectedIndex = days.findIndex(day => day.isSelected);
if (selectedIndex >= 0) return selectedIndex;
const todayIndex = days.findIndex(day => day.isToday && day.isCurrentMonth);
if (todayIndex >= 0) return todayIndex;
// Fall back to first enabled day of current month
const firstCurrentMonthIndex = days.findIndex(day => day.isCurrentMonth && !day.isDisabled);
return firstCurrentMonthIndex >= 0 ? firstCurrentMonthIndex : 0;
}
/**
* Public method to set focus on a specific day index
*/
setFocusedDayIndex(index: number): void {
this.focusedDayIndex.set(index);
this.setFocus(index);
}
/**
* Public method to reset focus based on priority
*/
resetFocus(): void {
const targetIndex = this.getFocusedDayIndex();
this.setFocus(targetIndex);
}
@HostListener('keydown', ['$event'])
onKeyDown(event: KeyboardEvent): void {
if (this.disabled()) return;
const days = this.calendarDays();
if (days.length === 0) return;
const currentIndex = this.getFocusedDayIndex();
let newIndex: number | null = null;
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
newIndex = this.navigate(currentIndex, -1, days);
break;
case 'ArrowRight':
event.preventDefault();
newIndex = this.navigate(currentIndex, 1, days);
break;
case 'ArrowUp':
event.preventDefault();
newIndex = this.navigate(currentIndex, -7, days);
break;
case 'ArrowDown':
event.preventDefault();
newIndex = this.navigate(currentIndex, 7, days);
break;
case 'Home':
event.preventDefault();
newIndex = this.findEnabledInRange(Math.floor(currentIndex / 7) * 7, Math.floor(currentIndex / 7) * 7 + 6, days);
break;
case 'End':
event.preventDefault();
newIndex = this.findEnabledInRange(Math.floor(currentIndex / 7) * 7 + 6, Math.floor(currentIndex / 7) * 7, days, true);
break;
case 'PageUp':
event.preventDefault();
if (event.ctrlKey) {
this.previousYear.emit();
} else {
this.previousMonth.emit({ position: 'default', dayOfWeek: -1 });
}
return;
case 'PageDown':
event.preventDefault();
if (event.ctrlKey) {
this.nextYear.emit();
} else {
this.nextMonth.emit({ position: 'default', dayOfWeek: -1 });
}
return;
case 'Enter':
case ' ': {
event.preventDefault();
const focusedDay = days[currentIndex];
if (focusedDay && !focusedDay.isDisabled) {
this.dateSelect.emit({ date: focusedDay.date, index: currentIndex });
}
return;
}
default:
return;
}
if (newIndex !== null && newIndex !== currentIndex) {
this.setFocus(newIndex);
}
}
private navigate(currentIndex: number, step: number, days: CalendarDay[]): number | null {
const targetIndex = currentIndex + step;
// If within bounds, find enabled day
if (targetIndex >= 0 && targetIndex < days.length) {
return this.findEnabledInRange(targetIndex, currentIndex, days);
}
// Handle month boundaries
const dayOfWeek = currentIndex % 7;
if (step === -1) {
// Going left - navigate to previous month, focus last day
this.previousMonth.emit({ position: 'last', dayOfWeek: -1 });
} else if (step === 1) {
// Going right - navigate to next month, focus first day
this.nextMonth.emit({ position: 'first', dayOfWeek: -1 });
} else if (step === -7) {
// Going up - navigate to previous month, preserve column
this.previousMonth.emit({ position: 'lastWeek', dayOfWeek });
} else if (step === 7) {
// Going down - navigate to next month, preserve column
this.nextMonth.emit({ position: 'firstWeek', dayOfWeek });
}
return null;
}
private findEnabledInRange(start: number, fallback: number, days: CalendarDay[], reverse = false): number {
const clampedStart = Math.max(0, Math.min(start, days.length - 1));
const clampedFallback = Math.max(0, Math.min(fallback, days.length - 1));
if (!reverse) {
// Search forward from start
for (let i = clampedStart; i < days.length; i++) {
if (!days[i].isDisabled) return i;
}
// Search backward from start
for (let i = clampedStart - 1; i >= 0; i--) {
if (!days[i].isDisabled) return i;
}
} else {
// Search backward from start
for (let i = clampedStart; i >= 0; i--) {
if (!days[i].isDisabled) return i;
}
// Search forward from start
for (let i = clampedStart + 1; i < days.length; i++) {
if (!days[i].isDisabled) return i;
}
}
return clampedFallback;
}
private setFocus(index: number): void {
this.focusedDayIndex.set(index);
setTimeout(() => {
const dayElement = this.gridContainer()?.nativeElement.querySelector(`#${getDayId(index)}`) as HTMLElement;
dayElement?.focus();
}, 0);
}
}
import { ChangeDetectionStrategy, Component, computed, input, output, ViewEncapsulation } from '@angular/core';
import { calendarNavVariants } from './calendar.variants';
import { mergeClasses } from '../../shared/utils/utils';
import { ZardButtonComponent } from '../button/button.component';
import { ZardIconComponent } from '../icon/icon.component';
import { ZardSelectItemComponent } from '../select/select-item.component';
import { ZardSelectComponent } from '../select/select.component';
@Component({
selector: 'z-calendar-navigation',
exportAs: 'zCalendarNavigation',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
imports: [ZardButtonComponent, ZardIconComponent, ZardSelectComponent, ZardSelectItemComponent],
template: `
<div [class]="navClasses()">
<button z-button zType="ghost" zSize="sm" (click)="onPreviousClick()" [disabled]="isPreviousDisabled()" aria-label="Previous month" class="h-7 w-7 p-0">
<z-icon zType="chevron-left"></z-icon>
</button>
<!-- Month and Year Selectors -->
<div class="flex items-center space-x-2">
<!-- Month Select -->
<z-select class="min-w-20" [zValue]="currentMonth()" [zLabel]="currentMonthName()" (zSelectionChange)="onMonthChange($event)">
@for (month of months; track $index) {
<z-select-item [zValue]="$index.toString()">{{ month }}</z-select-item>
}
</z-select>
<!-- Year Select -->
<z-select class="min-w-21" [zValue]="currentYear()" [zLabel]="currentYear()" (zSelectionChange)="onYearChange($event)">
@for (year of availableYears(); track year) {
<z-select-item [zValue]="year.toString()">{{ year }}</z-select-item>
}
</z-select>
</div>
<button z-button zType="ghost" zSize="sm" (click)="onNextClick()" [disabled]="isNextDisabled()" aria-label="Next month" class="h-7 w-7 p-0">
<z-icon zType="chevron-right"></z-icon>
</button>
</div>
`,
})
export class ZardCalendarNavigationComponent {
// Inputs
readonly currentMonth = input.required<string>();
readonly currentYear = input.required<string>();
readonly minDate = input<Date | null>(null);
readonly maxDate = input<Date | null>(null);
readonly disabled = input<boolean>(false);
// Outputs
readonly monthChange = output<string>();
readonly yearChange = output<string>();
readonly previousMonth = output<void>();
readonly nextMonth = output<void>();
readonly months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
protected readonly navClasses = computed(() => mergeClasses(calendarNavVariants()));
protected readonly availableYears = computed(() => {
const currentYear = new Date().getFullYear();
const years = [];
for (let i = currentYear - 10; i <= currentYear + 10; i++) {
years.push(i);
}
return years;
});
protected readonly currentMonthName = computed(() => {
const selectedMonth = Number.parseInt(this.currentMonth());
if (!Number.isNaN(selectedMonth) && this.months[selectedMonth]) return this.months[selectedMonth];
return this.months[new Date().getMonth()];
});
protected isPreviousDisabled(): boolean {
if (this.disabled()) return true;
const minDate = this.minDate();
if (!minDate) return false;
const currentMonth = Number.parseInt(this.currentMonth());
const currentYear = Number.parseInt(this.currentYear());
const lastDayOfPreviousMonth = new Date(currentYear, currentMonth, 0);
return lastDayOfPreviousMonth.getTime() < minDate.getTime();
}
protected isNextDisabled(): boolean {
if (this.disabled()) return true;
const maxDate = this.maxDate();
if (!maxDate) return false;
const currentMonth = Number.parseInt(this.currentMonth());
const currentYear = Number.parseInt(this.currentYear());
const nextMonth = new Date(currentYear, currentMonth + 1, 1);
return nextMonth.getTime() > maxDate.getTime();
}
protected onPreviousClick(): void {
this.previousMonth.emit();
}
protected onNextClick(): void {
this.nextMonth.emit();
}
protected onMonthChange(month: string | string[]): void {
if (Array.isArray(month)) {
console.warn('Calendar navigation received array for month selection, expected single value. Ignoring:', month);
return;
}
this.monthChange.emit(month);
}
protected onYearChange(year: string | string[]): void {
if (Array.isArray(year)) {
console.warn('Calendar navigation received array for year selection, expected single value. Ignoring:', year);
return;
}
this.yearChange.emit(year);
}
}
export type CalendarMode = 'single' | 'multiple' | 'range';
export type CalendarValue = Date | Date[] | null;
export interface CalendarDay {
date: Date;
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
isDisabled: boolean;
isRangeStart?: boolean;
isRangeEnd?: boolean;
isInRange?: boolean;
id?: string;
}
export interface CalendarDayConfig {
year: number;
month: number;
mode: CalendarMode;
selectedDates: Date[];
minDate: Date | null;
maxDate: Date | null;
disabled: boolean;
}
export { type ZardCalendarVariants } from './calendar.variants';
import type { CalendarDay, CalendarDayConfig, CalendarMode, CalendarValue } from './calendar.types';
/**
* Checks if two dates represent the same day (ignoring time)
*/
export function isSameDay(date1: Date, date2: Date): boolean {
return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate();
}
/**
* Checks if a date is disabled based on min/max constraints
*/
export function isDateDisabled(date: Date, minDate: Date | null, maxDate: Date | null): boolean {
if (minDate && date < minDate) return true;
if (maxDate && date > maxDate) return true;
return false;
}
/**
* Generates calendar days for a given month with all selection states
*/
export function generateCalendarDays(config: CalendarDayConfig): CalendarDay[] {
const { year, month, mode, selectedDates, minDate, maxDate, disabled } = config;
const today = new Date();
// Get first day of the month
const firstDay = new Date(year, month, 1);
// Get last day of the month
const lastDay = new Date(year, month + 1, 0);
// Get the first day of the week for the first day of the month
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - startDate.getDay());
// Get the last day of the week for the last day of the month
const endDate = new Date(lastDay);
endDate.setDate(endDate.getDate() + (6 - endDate.getDay()));
const days: CalendarDay[] = [];
const currentWeekDate = new Date(startDate);
// For range mode, determine range start and end
let rangeStart: Date | null = null;
let rangeEnd: Date | null = null;
if (mode === 'range' && selectedDates.length > 0) {
rangeStart = selectedDates[0];
rangeEnd = selectedDates.length > 1 ? selectedDates[1] : null;
}
while (currentWeekDate <= endDate) {
const date = new Date(currentWeekDate);
const isCurrentMonth = date.getMonth() === month;
const isToday = isSameDay(date, today);
const isDisabledDate = disabled || isDateDisabled(date, minDate, maxDate);
// Determine if date is selected
let isSelected = false;
let isRangeStart = false;
let isRangeEnd = false;
let isInRange = false;
if (mode === 'single') {
isSelected = selectedDates.length > 0 && isSameDay(date, selectedDates[0]);
} else if (mode === 'multiple') {
isSelected = selectedDates.some(d => isSameDay(date, d));
} else if (mode === 'range') {
if (rangeStart && isSameDay(date, rangeStart)) {
isRangeStart = true;
isSelected = true;
}
if (rangeEnd && isSameDay(date, rangeEnd)) {
isRangeEnd = true;
isSelected = true;
}
if (rangeStart && rangeEnd && !isRangeStart && !isRangeEnd) {
// Check if date is between start and end
const dateTime = date.getTime();
const startTime = rangeStart.getTime();
const endTime = rangeEnd.getTime();
isInRange = dateTime > startTime && dateTime < endTime;
}
}
days.push({
date,
isCurrentMonth,
isToday,
isSelected,
isDisabled: isDisabledDate,
isRangeStart,
isRangeEnd,
isInRange,
});
currentWeekDate.setDate(currentWeekDate.getDate() + 1);
}
return days;
}
/**
* Converts CalendarValue to array of Dates for easier processing
*/
export function getSelectedDatesArray(value: CalendarValue, mode: CalendarMode): Date[] {
if (!value) return [];
if (mode === 'single') {
return [value as Date];
}
if ((mode === 'multiple' || mode === 'range') && Array.isArray(value)) {
return value;
}
return [];
}
/**
* Generates a unique ID for a calendar day
*/
export function getDayId(index: number): string {
return `calendar-day-${index}`;
}
/**
* Generates an accessible ARIA label for a calendar day
*/
export function getDayAriaLabel(day: CalendarDay): string {
const dateStr = day.date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
const labels = [dateStr];
if (day.isToday) labels.push('Today');
if (day.isSelected) labels.push('Selected');
if (day.isRangeStart) labels.push('Range start');
if (day.isRangeEnd) labels.push('Range end');
if (day.isInRange) labels.push('In range');
if (!day.isCurrentMonth) labels.push('Outside month');
if (day.isDisabled) labels.push('Disabled');
return labels.join(', ');
}
import { Component } from '@angular/core';
import { ZardCalendarComponent } from '../calendar.component';
@Component({
selector: 'z-demo-calendar-default',
standalone: true,
imports: [ZardCalendarComponent],
template: ` <z-calendar (dateChange)="onDateChange($event)" /> `,
})
export class ZardDemoCalendarDefaultComponent {
onDateChange(date: Date | Date[]) {
console.log('Selected date:', date);
}
}
Selected (0) date(s).
import { Component, signal } from '@angular/core';
import { ZardCalendarComponent } from '../calendar.component';
@Component({
selector: 'z-demo-calendar-multiple',
standalone: true,
imports: [ZardCalendarComponent],
template: `
<div class="space-y-4">
<z-calendar zMode="multiple" [(value)]="selectedDates" (dateChange)="onDateChange($event)" />
<div class="text-muted-foreground mt-2 text-sm">
<p class="font-medium">Selected ({{ selectedDates()?.length ?? 0 }}) date(s).</p>
</div>
</div>
`,
})
export class ZardDemoCalendarMultipleComponent {
selectedDates = signal<Date[] | null>(null);
onDateChange(dates: Date | Date[]) {
console.log('Selected dates:', dates);
}
formatDate(date: Date): string {
return date.toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' });
}
}
import { Component, signal } from '@angular/core';
import { ZardCalendarComponent } from '../calendar.component';
@Component({
selector: 'z-demo-calendar-range',
standalone: true,
imports: [ZardCalendarComponent],
template: `
<div class="space-y-4">
<z-calendar zMode="range" [(value)]="dateRange" (dateChange)="onDateChange($event)" />
<div class="bg-muted/50 mt-4 rounded-lg border p-4">
<div class="space-y-1 text-sm">
<div class="flex items-center gap-2">
<span class="text-muted-foreground min-w-12">From:</span>
<span class="font-medium">{{ formatDate(dateRange(), 'start') }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted-foreground min-w-12">To:</span>
<span class="font-medium">{{ formatDate(dateRange(), 'end') }}</span>
</div>
</div>
</div>
</div>
`,
})
export class ZardDemoCalendarRangeComponent {
dateRange = signal<Date[] | null>(null);
onDateChange(dates: Date | Date[]) {
console.log('Selected range:', dates);
}
formatDate(date?: Date[] | null, label: 'start' | 'end' = 'start'): string {
if (!date || date?.length === 0) {
return 'N/A';
}
const targetDate = label === 'start' ? date[0] : date[1];
if (!targetDate) {
return 'N/A';
}
return targetDate.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
}
Available dates: 11/11/2025 - 12/11/2025
import { Component } from '@angular/core';
import { ZardCalendarComponent } from '../calendar.component';
const DAYS_IN_FUTURE = 30;
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
@Component({
selector: 'z-demo-calendar-with-constraints',
standalone: true,
imports: [ZardCalendarComponent],
template: `
<div class="space-y-8">
<div>
<h3 class="mb-3 text-sm font-medium">With Min/Max Date</h3>
<z-calendar [minDate]="minDate" [maxDate]="maxDate" (dateChange)="onDateChange($event)" />
<p class="text-muted-foreground mt-2 text-sm">Available dates: {{ minDate.toLocaleDateString() }} - {{ maxDate.toLocaleDateString() }}</p>
</div>
<div>
<h3 class="mb-3 text-sm font-medium">Disabled</h3>
<z-calendar [disabled]="true" />
</div>
</div>
`,
})
export class ZardDemoCalendarWithConstraintsComponent {
minDate = new Date();
maxDate = new Date(Date.now() + DAYS_IN_FUTURE * MILLISECONDS_PER_DAY);
constructor() {
// Set min date to today
this.minDate.setHours(0, 0, 0, 0);
// Set max date to 30 days from now
this.maxDate.setHours(23, 59, 59, 999);
}
onDateChange(date: Date | Date[]) {
console.log('Selected date:', date);
}
}