Calendar
Month grids to choose a date, a range or several dates.
- v1.0.0
- WAI-ARIA: Date grid
- SSR
- Zoneless
- 5.7 kB gzip
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
import { Component, signal } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Calendar,
CalendarCaption,
CalendarCell,
CalendarDay,
CalendarGrid,
CalendarHeading,
CalendarMonth,
CalendarMonths,
CalendarNav,
CalendarNext,
CalendarPrevious,
CalendarWeek,
CalendarWeekday,
CalendarWeekdays,
} from '@/ui/calendar/calendar';
/**
* A calendar to choose one date. Today is marked; the arrow keys move between
* days and Page Up and Page Down between months.
*/
@Component({
selector: 'example-calendar-demo',
imports: [
Calendar,
CalendarCaption,
CalendarCell,
CalendarDay,
CalendarGrid,
CalendarHeading,
CalendarMonth,
CalendarMonths,
CalendarNav,
CalendarNext,
CalendarPrevious,
CalendarWeek,
CalendarWeekday,
CalendarWeekdays,
NdIcon,
],
template: `
<div
ndCalendar
#calendar="ndCalendar"
[(value)]="date"
class="rounded-md border shadow-sm"
>
<div ndCalendarMonths>
<div ndCalendarNav>
<button ndCalendarPrevious aria-label="Previous month">
<svg ndIcon="chevron-left"></svg>
</button>
<button ndCalendarNext aria-label="Next month">
<svg ndIcon="chevron-right"></svg>
</button>
</div>
@for (month of calendar.months(); track month.key) {
<div ndCalendarMonth [month]="month">
<div ndCalendarCaption>
<div ndCalendarHeading>{{ month.label }}</div>
</div>
<table ndCalendarGrid>
<thead>
<tr ndCalendarWeekdays>
@for (weekday of calendar.weekdays(); track weekday.long) {
<th ndCalendarWeekday [weekday]="weekday">
{{ weekday.short }}
</th>
}
</tr>
</thead>
<tbody>
@for (week of month.weeks; track week.key) {
<tr ndCalendarWeek>
@for (day of week.days; track day.key) {
<td ndCalendarCell [day]="day">
<button ndCalendarDay [day]="day">
{{ day.label }}
</button>
</td>
}
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
`,
})
export class CalendarDemoExample {
protected readonly date = signal<Date | null>(new Date());
}
A calendar shows month grids to choose a date, a range of days or several
dates. Only one day is in the tab order; the arrow keys move between days,
Page Up and Page Down between months, and Enter or Space chooses a day.
Month names, weekdays and day labels come from Intl in the app's
locale, and weeks start on the locale's first day. It binds forms like any
control.
Installation
npx @ndop/cli add calendarpnpm dlx @ndop/cli add calendaryarn dlx @ndop/cli add calendarbunx @ndop/cli add calendarInstall the dependencies
npm install @ndop/primitives @ndop/corepnpm add @ndop/primitives @ndop/coreyarn add @ndop/primitives @ndop/corebun add @ndop/primitives @ndop/coreCopy the code into your project
At the paths your
ndop.jsonaliases point to; these use the defaults.src/app/ui/calendar/calendar.tsimport { computed, Directive, inject, input } from '@angular/core'; import { NdCalendar, NdCalendarCell, NdCalendarDay, NdCalendarGrid, NdCalendarHeading, NdCalendarMonth, NdCalendarNext, NdCalendarPrevious, NdCalendarWeekday, } from '@ndop/primitives/calendar'; import { buttonVariants } from '@/ui/button/button'; import { cn } from '@/lib/utils'; export type { NdCalendarDayView as CalendarDayView, NdCalendarMode as CalendarMode, NdCalendarMonthView as CalendarMonthView, NdCalendarWeekdayView as CalendarWeekdayView, NdCalendarWeekView as CalendarWeekView, NdDateRange as DateRange, } from '@ndop/primitives/calendar'; /** * A calendar (`@ndop/primitives/calendar`): month grids to choose a date, * a range or several dates, with the arrow keys moving between days. Its * structure is your template: loop over `months()` and `weekdays()`. * * @example * ```html * <div ndCalendar #calendar="ndCalendar" [(value)]="date"> * <div ndCalendarMonths> * <div ndCalendarNav>…</div> * @for (month of calendar.months(); track month.key) { * <div ndCalendarMonth [month]="month">…</div> * } * </div> * </div> * ``` */ @Directive({ selector: '[ndCalendar]', exportAs: 'ndCalendar', hostDirectives: [ { directive: NdCalendar, inputs: [ 'mode', 'value', 'range', 'values', 'month', 'defaultMonth', 'numberOfMonths', 'min', 'max', 'disabledDates', 'weekStartsOn', 'locale', 'showOutsideDays', ], outputs: ['valueChange', 'rangeChange', 'valuesChange', 'monthChange'], }, ], host: { 'data-slot': 'calendar', '[class]': 'classes()' }, }) export class Calendar { /** Extra classes, merged last so they win. */ readonly class = input(''); private readonly calendar = inject(NdCalendar); /** The shown months, with their weeks and days. */ readonly months = this.calendar.months; /** The weekdays, in the order the columns show them. */ readonly weekdays = this.calendar.weekdays; protected readonly classes = computed(() => cn( 'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent', 'w-fit rtl:**:[[data-slot=calendar-previous]>svg]:rotate-180 rtl:**:[[data-slot=calendar-next]>svg]:rotate-180', this.class(), ), ); } /** * The row of shown months, which the navigation buttons sit above. * * @example * ```html * <div ndCalendarMonths>…</div> * ``` */ @Directive({ selector: '[ndCalendarMonths]', host: { 'data-slot': 'calendar-months', '[class]': 'classes()' }, }) export class CalendarMonths { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('relative flex flex-col gap-4 md:flex-row', this.class()), ); } /** * The bar that holds the previous and next buttons, across the top of the * months. * * @example * ```html * <div ndCalendarNav>…</div> * ``` */ @Directive({ selector: '[ndCalendarNav]', host: { 'data-slot': 'calendar-nav', '[class]': 'classes()' }, }) export class CalendarNav { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', this.class(), ), ); } /** Classes of the previous and next buttons. */ const navButton = 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50'; /** * The button that shows the previous month. Name it with `aria-label` and * put the chevron icon in it. * * @example * ```html * <button ndCalendarPrevious aria-label="Previous month"> * <svg ndIcon="chevron-left"></svg> * </button> * ``` */ @Directive({ selector: 'button[ndCalendarPrevious]', hostDirectives: [NdCalendarPrevious], host: { 'data-slot': 'calendar-previous', '[class]': 'classes()' }, }) export class CalendarPrevious { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn(buttonVariants({ variant: 'ghost' }), navButton, this.class()), ); } /** * The button that shows the next month. Name it with `aria-label` and put * the chevron icon in it. * * @example * ```html * <button ndCalendarNext aria-label="Next month"> * <svg ndIcon="chevron-right"></svg> * </button> * ``` */ @Directive({ selector: 'button[ndCalendarNext]', hostDirectives: [NdCalendarNext], host: { 'data-slot': 'calendar-next', '[class]': 'classes()' }, }) export class CalendarNext { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn(buttonVariants({ variant: 'ghost' }), navButton, this.class()), ); } /** * One shown month: its caption and grid. * * @example * ```html * <div ndCalendarMonth [month]="month">…</div> * ``` */ @Directive({ selector: '[ndCalendarMonth]', hostDirectives: [{ directive: NdCalendarMonth, inputs: ['month'] }], host: { 'data-slot': 'calendar-month', '[class]': 'classes()' }, }) export class CalendarMonth { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex w-full flex-col gap-4', this.class()), ); } /** * The row of a month's name, between the navigation buttons. * * @example * ```html * <div ndCalendarCaption><div ndCalendarHeading>…</div></div> * ``` */ @Directive({ selector: '[ndCalendarCaption]', host: { 'data-slot': 'calendar-caption', '[class]': 'classes()' }, }) export class CalendarCaption { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)', this.class(), ), ); } /** * A month's name and year, which labels its grid and is announced when the * month changes. * * @example * ```html * <div ndCalendarHeading>{{ month.label }}</div> * ``` */ @Directive({ selector: '[ndCalendarHeading]', hostDirectives: [NdCalendarHeading], host: { 'data-slot': 'calendar-heading', '[class]': 'classes()' }, }) export class CalendarHeading { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('font-medium select-none', 'text-sm', this.class()), ); } /** * A month's grid of days, on a `table`. * * @example * ```html * <table ndCalendarGrid>…</table> * ``` */ @Directive({ selector: 'table[ndCalendarGrid]', hostDirectives: [NdCalendarGrid], host: { 'data-slot': 'calendar-grid', '[class]': 'classes()' }, }) export class CalendarGrid { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('w-full border-collapse', this.class()), ); } /** * The row of weekday headers, on a `tr` in the grid's `thead`. * * @example * ```html * <tr ndCalendarWeekdays>…</tr> * ``` */ @Directive({ selector: 'tr[ndCalendarWeekdays]', host: { 'data-slot': 'calendar-weekdays', '[class]': 'classes()' }, }) export class CalendarWeekdays { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex', this.class())); } /** * A weekday's column header, with its full name for screen readers. * * @example * ```html * <th ndCalendarWeekday [weekday]="weekday">{{ weekday.short }}</th> * ``` */ @Directive({ selector: 'th[ndCalendarWeekday]', hostDirectives: [{ directive: NdCalendarWeekday, inputs: ['weekday'] }], host: { 'data-slot': 'calendar-weekday', '[class]': 'classes()' }, }) export class CalendarWeekday { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none', this.class(), ), ); } /** * A week's row of days, on a `tr` in the grid's `tbody`. * * @example * ```html * <tr ndCalendarWeek>…</tr> * ``` */ @Directive({ selector: 'tr[ndCalendarWeek]', host: { 'data-slot': 'calendar-week', '[class]': 'classes()' }, }) export class CalendarWeek { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('mt-2 flex w-full', this.class()), ); } /** * A day's cell, which shapes the band of a chosen range. * * @example * ```html * <td ndCalendarCell [day]="day">…</td> * ``` */ @Directive({ selector: 'td[ndCalendarCell]', hostDirectives: [{ directive: NdCalendarCell, inputs: ['day'] }], host: { 'data-slot': 'calendar-cell', '[class]': 'classes()' }, }) export class CalendarCell { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-e-md', '[&:first-child[data-selected=true]_button]:rounded-s-md', 'data-range-start:rounded-s-md data-range-start:bg-accent', 'data-range-middle:rounded-none', 'data-range-end:rounded-e-md data-range-end:bg-accent', 'data-today:rounded-md data-today:bg-accent data-today:text-accent-foreground data-today:data-[selected=true]:rounded-none', 'data-outside:text-muted-foreground data-outside:aria-selected:text-muted-foreground', 'data-disabled:text-muted-foreground data-disabled:opacity-50', 'data-hidden:invisible', this.class(), ), ); } /** * A day's button: chosen days fill with the primary color, and a range's * ends round off its band. * * @example * ```html * <button ndCalendarDay [day]="day">{{ day.label }}</button> * ``` */ @Directive({ selector: 'button[ndCalendarDay]', hostDirectives: [{ directive: NdCalendarDay, inputs: ['day'] }], host: { 'data-slot': 'calendar-day', '[class]': 'classes()' }, }) export class CalendarDay { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: 'ghost', size: 'icon' }), 'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal focus-visible:relative focus-visible:z-10 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-e-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-s-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70', this.class(), ), ); }src/app/ui/button/button.tsimport { computed, Directive, input } from '@angular/core'; import { NdButton } from '@ndop/primitives/button'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * Class table for {@link Button}; also usable to style other elements like * a button. */ export const buttonVariants = variants({ base: "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-disabled:pointer-events-none data-disabled:opacity-50", variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', icon: 'size-9', 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-8', 'icon-lg': 'size-10', }, }, defaults: { variant: 'default', size: 'default' }, }); /** Visual style of a {@link Button}. */ export type ButtonVariant = VariantOf<typeof buttonVariants, 'variant'>; /** Size of a {@link Button}. */ export type ButtonSize = VariantOf<typeof buttonVariants, 'size'>; /** * Styled button for a native `button` or an `a`, with the * `@ndop/primitives/button` behavior (safe default type, disabled links). * * @example * ```html * <button ndButton variant="outline" size="sm">Cancel</button> * <a ndButton variant="link" href="/docs">Docs</a> * ``` */ @Directive({ selector: 'button[ndButton], a[ndButton]', hostDirectives: [{ directive: NdButton, inputs: ['disabled'] }], host: { 'data-slot': 'button', '[attr.data-variant]': 'variant()', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class Button { /** Visual style. */ readonly variant = input<ButtonVariant>('default'); /** Height, padding and icon size. */ readonly size = input<ButtonSize>('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: this.variant(), size: this.size() }), this.class(), ), ); }src/app/lib/utils.ts/** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core';Update the import paths to match your project
The files import each other through the
@/uiand@/libaliases.
Usage
import {
Calendar,
CalendarCaption,
CalendarCell,
CalendarDay,
CalendarGrid,
CalendarHeading,
CalendarMonth,
CalendarMonths,
CalendarNav,
CalendarNext,
CalendarPrevious,
CalendarWeek,
CalendarWeekday,
CalendarWeekdays,
} from '@/ui/calendar/calendar';<div
ndCalendar
#calendar="ndCalendar"
[(value)]="date"
class="rounded-md border shadow-sm"
>
<div ndCalendarMonths>
<div ndCalendarNav>
<button ndCalendarPrevious aria-label="Previous month">
<svg ndIcon="chevron-left"></svg>
</button>
<button ndCalendarNext aria-label="Next month">
<svg ndIcon="chevron-right"></svg>
</button>
</div>
@for (month of calendar.months(); track month.key) {
<div ndCalendarMonth [month]="month">
<div ndCalendarCaption>
<div ndCalendarHeading>{{ month.label }}</div>
</div>
<table ndCalendarGrid>
<thead>
<tr ndCalendarWeekdays>
@for (weekday of calendar.weekdays(); track weekday.long) {
<th ndCalendarWeekday [weekday]="weekday">
{{ weekday.short }}
</th>
}
</tr>
</thead>
<tbody>
@for (week of month.weeks; track week.key) {
<tr ndCalendarWeek>
@for (day of week.days; track day.key) {
<td ndCalendarCell [day]="day">
<button ndCalendarDay [day]="day">
{{ day.label }}
</button>
</td>
}
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>Examples
Calendar range
Two months side by side to choose a range of days.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
import { Component, signal } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Calendar,
CalendarCaption,
CalendarCell,
CalendarDay,
CalendarGrid,
CalendarHeading,
CalendarMonth,
CalendarMonths,
CalendarNav,
CalendarNext,
CalendarPrevious,
CalendarWeek,
CalendarWeekday,
CalendarWeekdays,
type DateRange,
} from '@/ui/calendar/calendar';
/**
* Two months side by side to choose a range of days: the first click
* starts it, the second ends it.
*/
@Component({
selector: 'example-calendar-range',
imports: [
Calendar,
CalendarCaption,
CalendarCell,
CalendarDay,
CalendarGrid,
CalendarHeading,
CalendarMonth,
CalendarMonths,
CalendarNav,
CalendarNext,
CalendarPrevious,
CalendarWeek,
CalendarWeekday,
CalendarWeekdays,
NdIcon,
],
template: `
<div
ndCalendar
#calendar="ndCalendar"
mode="range"
[numberOfMonths]="2"
[(range)]="range"
class="rounded-md border shadow-sm"
>
<div ndCalendarMonths>
<div ndCalendarNav>
<button ndCalendarPrevious aria-label="Previous month">
<svg ndIcon="chevron-left"></svg>
</button>
<button ndCalendarNext aria-label="Next month">
<svg ndIcon="chevron-right"></svg>
</button>
</div>
@for (month of calendar.months(); track month.key) {
<div ndCalendarMonth [month]="month">
<div ndCalendarCaption>
<div ndCalendarHeading>{{ month.label }}</div>
</div>
<table ndCalendarGrid>
<thead>
<tr ndCalendarWeekdays>
@for (weekday of calendar.weekdays(); track weekday.long) {
<th ndCalendarWeekday [weekday]="weekday">
{{ weekday.short }}
</th>
}
</tr>
</thead>
<tbody>
@for (week of month.weeks; track week.key) {
<tr ndCalendarWeek>
@for (day of week.days; track day.key) {
<td ndCalendarCell [day]="day">
<button ndCalendarDay [day]="day">
{{ day.label }}
</button>
</td>
}
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
`,
})
export class CalendarRangeExample {
protected readonly range = signal<DateRange>({
from: new Date(),
to: new Date(Date.now() + 6 * 24 * 60 * 60 * 1000),
});
}
Calendar multiple
Several dates chosen at once.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
0 dates chosen.
import { Component, signal } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Calendar,
CalendarCaption,
CalendarCell,
CalendarDay,
CalendarGrid,
CalendarHeading,
CalendarMonth,
CalendarMonths,
CalendarNav,
CalendarNext,
CalendarPrevious,
CalendarWeek,
CalendarWeekday,
CalendarWeekdays,
} from '@/ui/calendar/calendar';
/**
* Several dates chosen at once; choosing a date again removes it.
*/
@Component({
selector: 'example-calendar-multiple',
imports: [
Calendar,
CalendarCaption,
CalendarCell,
CalendarDay,
CalendarGrid,
CalendarHeading,
CalendarMonth,
CalendarMonths,
CalendarNav,
CalendarNext,
CalendarPrevious,
CalendarWeek,
CalendarWeekday,
CalendarWeekdays,
NdIcon,
],
template: `
<div class="grid justify-items-center gap-3">
<div
ndCalendar
#calendar="ndCalendar"
mode="multiple"
[(values)]="dates"
class="rounded-md border shadow-sm"
>
<div ndCalendarMonths>
<div ndCalendarNav>
<button ndCalendarPrevious aria-label="Previous month">
<svg ndIcon="chevron-left"></svg>
</button>
<button ndCalendarNext aria-label="Next month">
<svg ndIcon="chevron-right"></svg>
</button>
</div>
@for (month of calendar.months(); track month.key) {
<div ndCalendarMonth [month]="month">
<div ndCalendarCaption>
<div ndCalendarHeading>{{ month.label }}</div>
</div>
<table ndCalendarGrid>
<thead>
<tr ndCalendarWeekdays>
@for (weekday of calendar.weekdays(); track weekday.long) {
<th ndCalendarWeekday [weekday]="weekday">
{{ weekday.short }}
</th>
}
</tr>
</thead>
<tbody>
@for (week of month.weeks; track week.key) {
<tr ndCalendarWeek>
@for (day of week.days; track day.key) {
<td ndCalendarCell [day]="day">
<button ndCalendarDay [day]="day">
{{ day.label }}
</button>
</td>
}
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
<p class="text-sm text-muted-foreground">
{{ dates().length }} dates chosen.
</p>
</div>
`,
})
export class CalendarMultipleExample {
protected readonly dates = signal<readonly Date[]>([]);
}
Calendar disabled
Weekends and past days that can't be chosen.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Calendar,
CalendarCaption,
CalendarCell,
CalendarDay,
CalendarGrid,
CalendarHeading,
CalendarMonth,
CalendarMonths,
CalendarNav,
CalendarNext,
CalendarPrevious,
CalendarWeek,
CalendarWeekday,
CalendarWeekdays,
} from '@/ui/calendar/calendar';
/**
* Weekends and past days that can't be chosen. They stay focusable, so
* the arrow keys move across them.
*/
@Component({
selector: 'example-calendar-disabled',
imports: [
Calendar,
CalendarCaption,
CalendarCell,
CalendarDay,
CalendarGrid,
CalendarHeading,
CalendarMonth,
CalendarMonths,
CalendarNav,
CalendarNext,
CalendarPrevious,
CalendarWeek,
CalendarWeekday,
CalendarWeekdays,
NdIcon,
],
template: `
<div
ndCalendar
#calendar="ndCalendar"
[min]="today"
[disabledDates]="weekends"
class="rounded-md border shadow-sm"
>
<div ndCalendarMonths>
<div ndCalendarNav>
<button ndCalendarPrevious aria-label="Previous month">
<svg ndIcon="chevron-left"></svg>
</button>
<button ndCalendarNext aria-label="Next month">
<svg ndIcon="chevron-right"></svg>
</button>
</div>
@for (month of calendar.months(); track month.key) {
<div ndCalendarMonth [month]="month">
<div ndCalendarCaption>
<div ndCalendarHeading>{{ month.label }}</div>
</div>
<table ndCalendarGrid>
<thead>
<tr ndCalendarWeekdays>
@for (weekday of calendar.weekdays(); track weekday.long) {
<th ndCalendarWeekday [weekday]="weekday">
{{ weekday.short }}
</th>
}
</tr>
</thead>
<tbody>
@for (week of month.weeks; track week.key) {
<tr ndCalendarWeek>
@for (day of week.days; track day.key) {
<td ndCalendarCell [day]="day">
<button ndCalendarDay [day]="day">
{{ day.label }}
</button>
</td>
}
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
`,
})
export class CalendarDisabledExample {
protected readonly today = new Date();
protected readonly weekends = (date: Date) =>
date.getDay() === 0 || date.getDay() === 6;
}
Calendar locale
Month and weekday names in French, with weeks from Monday.
| lun. | mar. | mer. | jeu. | ven. | sam. | dim. |
|---|---|---|---|---|---|---|
import { Component, signal } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Calendar,
CalendarCaption,
CalendarCell,
CalendarDay,
CalendarGrid,
CalendarHeading,
CalendarMonth,
CalendarMonths,
CalendarNav,
CalendarNext,
CalendarPrevious,
CalendarWeek,
CalendarWeekday,
CalendarWeekdays,
} from '@/ui/calendar/calendar';
/**
* Month and weekday names in French, with weeks from Monday, as French
* calendars start them. Names and labels come from Intl.
*/
@Component({
selector: 'example-calendar-locale',
imports: [
Calendar,
CalendarCaption,
CalendarCell,
CalendarDay,
CalendarGrid,
CalendarHeading,
CalendarMonth,
CalendarMonths,
CalendarNav,
CalendarNext,
CalendarPrevious,
CalendarWeek,
CalendarWeekday,
CalendarWeekdays,
NdIcon,
],
template: `
<div
ndCalendar
#calendar="ndCalendar"
locale="fr-FR"
[(value)]="date"
class="rounded-md border shadow-sm"
>
<div ndCalendarMonths>
<div ndCalendarNav>
<button ndCalendarPrevious aria-label="Previous month">
<svg ndIcon="chevron-left"></svg>
</button>
<button ndCalendarNext aria-label="Next month">
<svg ndIcon="chevron-right"></svg>
</button>
</div>
@for (month of calendar.months(); track month.key) {
<div ndCalendarMonth [month]="month">
<div ndCalendarCaption>
<div ndCalendarHeading>{{ month.label }}</div>
</div>
<table ndCalendarGrid>
<thead>
<tr ndCalendarWeekdays>
@for (weekday of calendar.weekdays(); track weekday.long) {
<th ndCalendarWeekday [weekday]="weekday">
{{ weekday.short }}
</th>
}
</tr>
</thead>
<tbody>
@for (week of month.weeks; track week.key) {
<tr ndCalendarWeek>
@for (day of week.days; track day.key) {
<td ndCalendarCell [day]="day">
<button ndCalendarDay [day]="day">
{{ day.label }}
</button>
</td>
}
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
`,
})
export class CalendarLocaleExample {
protected readonly date = signal<Date | null>(null);
}
Accessibility
Implements the Date grid pattern of the WAI-ARIA Authoring Practices.
Keyboard
Keys the component handles.
| Key | Action |
|---|---|
| Tab | Moves focus to the day in the tab order, then out of the grid. |
| ArrowRightorArrowLeft | Moves focus to the next or previous day, changing month when needed. |
| ArrowDownorArrowUp | Moves focus to the same day of the next or previous week. |
| HomeorEnd | Moves focus to the first or last day of the week. |
| PageDownorPageUp | Moves focus to the same day of the next or previous month. |
| Shift+PageDownorShift+PageUp | Moves focus to the same day of the next or previous year. |
| EnterorSpace | Chooses the focused day, unless it's disabled. |
Focus
- Focus never leaves the shown months on its own: moving past their edge shows the next or previous month and focuses the day there.
minandmaxstop the keys at their bounds.- Choosing a day keeps focus on it.
Right-to-left
In right-to-left layouts, the days run from right to left, so ArrowLeft moves to the next day and ArrowRight to the previous one. Direction comes from the nearest dir attribute.
API reference
Calendar
[ndCalendar]A calendar (@ndop/primitives/calendar): month grids to choose a date,
a range or several dates, with the arrow keys moving between days. Its
structure is your template: loop over months() and weekdays().
| Calendar binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[mode]How it chooses dates. | 'single' | 'multiple' | 'range' | 'single' |
[defaultMonth]The first month shown when month isn't bound. | Date | null | null |
[numberOfMonths]How many months show side by side. | number | 1 |
[min]The earliest day that can be chosen. | Date | null | null |
[max]The latest day that can be chosen. | Date | null | null |
[disabledDates]Disables days it returns true for, such as weekends. | ((date: Date) => boolean) | null | null |
[weekStartsOn]The first weekday, 0 for Sunday; defaults to the locale's. | number | null | null |
[locale]The locale for names and labels; defaults to the app's LOCALE_ID. | string | inject(LOCALE_ID) |
[showOutsideDays]Shows the neighboring months' days that fill the first and last weeks. | boolean | true |
[(value)]The chosen date in single mode. Two-way bindable with [(value)]. | Date | null | null |
[(range)]The chosen days in range mode. Two-way bindable with [(range)]. | NdDateRange | { from: null, to: null } |
[(values)]The chosen dates in multiple mode. Two-way bindable with [(values)]. | readonly Date[] | [] |
[(month)]The first month shown. Two-way bindable with [(month)]. | Date | null | null |
| Data attribute | Values |
|---|---|
data-slot | calendar |
data-mode | — |
CalendarMonths
[ndCalendarMonths]The row of shown months, which the navigation buttons sit above.
| CalendarMonths binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | calendar-months |
CalendarNav
[ndCalendarNav]The bar that holds the previous and next buttons, across the top of the months.
| CalendarNav binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | calendar-nav |
CalendarPrevious
button[ndCalendarPrevious]The button that shows the previous month. Name it with aria-label and
put the chevron icon in it.
Manages aria-disabled.
| CalendarPrevious binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | calendar-previous |
data-disabled | — |
CalendarNext
button[ndCalendarNext]The button that shows the next month. Name it with aria-label and put
the chevron icon in it.
Manages aria-disabled.
| CalendarNext binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | calendar-next |
data-disabled | — |
CalendarMonth
[ndCalendarMonth]One shown month: its caption and grid.
| CalendarMonth binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[month]requiredThe month, from NdCalendar.months. | NdCalendarMonthView | — |
| Data attribute | Values |
|---|---|
data-slot | calendar-month |
CalendarCaption
[ndCalendarCaption]The row of a month's name, between the navigation buttons.
| CalendarCaption binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | calendar-caption |
CalendarHeading
[ndCalendarHeading]A month's name and year, which labels its grid and is announced when the month changes.
Manages id, aria-live.
| CalendarHeading binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | calendar-heading |
CalendarGrid
table[ndCalendarGrid]role gridA month's grid of days, on a table.
Manages aria-labelledby, aria-multiselectable.
| CalendarGrid binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | calendar-grid |
CalendarWeekdays
tr[ndCalendarWeekdays]The row of weekday headers, on a tr in the grid's thead.
| CalendarWeekdays binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | calendar-weekdays |
CalendarWeekday
th[ndCalendarWeekday]A weekday's column header, with its full name for screen readers.
Manages abbr, scope.
| CalendarWeekday binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[weekday]requiredThe weekday, from NdCalendar.weekdays. | NdCalendarWeekdayView | — |
| Data attribute | Values |
|---|---|
data-slot | calendar-weekday |
CalendarWeek
tr[ndCalendarWeek]A week's row of days, on a tr in the grid's tbody.
| CalendarWeek binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | calendar-week |
CalendarCell
td[ndCalendarCell]role gridcellA day's cell, which shapes the band of a chosen range.
Manages aria-selected.
| CalendarCell binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[day]requiredThe day, from a week of NdCalendar.months. | NdCalendarDayView | — |
| Data attribute | Values |
|---|---|
data-slot | calendar-cell |
data-selected | — |
data-today | — |
data-outside | — |
data-disabled | — |
data-range-start | — |
data-range-middle | — |
data-range-end | — |
CalendarDay
button[ndCalendarDay]A day's button: chosen days fill with the primary color, and a range's ends round off its band.
Manages aria-label, aria-current, aria-disabled, tabindex.
| CalendarDay binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[day]requiredThe day, from a week of NdCalendar.months. | NdCalendarDayView | — |
| Data attribute | Values |
|---|---|
data-slot | calendar-day |
data-selected-single | — |
data-range-start | — |
data-range-middle | — |
data-range-end | — |
data-today | — |
data-outside | — |
data-disabled | — |
Agent notes
Do
- Name the previous and next buttons with
aria-label. - Use
min,maxanddisabledDatesfor days that can't be chosen; they stay reachable with the keyboard. - Show two months with
numberOfMonths="2"for ranges.
Don't
- Don't use it for dates far from today, such as a birth date; use a text field.
- Don't put it in a narrow space; put it in a Date Picker's popover.