Date Picker
A button that opens a calendar in a popover to pick a date or a range.
- v1.0.0
- SSR
- Zoneless
- 1.1 kB gzip
import { DatePipe } from '@angular/common';
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';
import { DatePickerTrigger } from '@/ui/date-picker/date-picker';
import {
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from '@/ui/popover/popover';
/**
* A button that opens a calendar to pick a date. Opening focuses the chosen
* day; picking one closes the popover and returns focus to the button.
*/
@Component({
selector: 'example-date-picker-demo',
imports: [
Calendar,
CalendarCaption,
CalendarCell,
CalendarDay,
CalendarGrid,
CalendarHeading,
CalendarMonth,
CalendarMonths,
CalendarNav,
CalendarNext,
CalendarPrevious,
CalendarWeek,
CalendarWeekday,
CalendarWeekdays,
DatePickerTrigger,
DatePipe,
NdIcon,
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
],
template: `
<ng-container ndPopover [(open)]="open">
<button ndPopoverTrigger ndDatePickerTrigger [empty]="!date()">
<svg ndIcon="calendar"></svg>
{{ date() ? (date() | date: 'longDate') : 'Pick a date' }}
</button>
<ng-template ndPopoverPortal>
<div
ndPopoverContent
align="start"
aria-label="Choose a date"
class="w-auto p-0"
>
<div
ndCalendar
#calendar="ndCalendar"
[(value)]="date"
(valueChange)="open.set(false)"
>
<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>
</div>
</ng-template>
</ng-container>
`,
})
export class DatePickerDemoExample {
protected readonly open = signal(false);
protected readonly date = signal<Date | null>(null);
}
A date picker is a button that opens a calendar in a popover, to pick a date or a range of days. It composes Popover and Calendar: opening focuses the chosen day, the arrow keys move between days, and choosing one can close the popover and return focus to the button, which shows the date formatted for the locale.
Installation
npx @ndop/cli add date-pickerpnpm dlx @ndop/cli add date-pickeryarn dlx @ndop/cli add date-pickerbunx @ndop/cli add date-pickerInstall 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/date-picker/date-picker.tsimport { booleanAttribute, computed, Directive, input } from '@angular/core'; import { buttonVariants } from '@/ui/button/button'; import { cn } from '@/lib/utils'; /** * The button of a date picker: an outline button that shows the chosen * date, or a muted placeholder while `empty`. Put it on the trigger of a * Popover whose content holds a Calendar; opening focuses the chosen day. * * @example * ```html * <ng-container ndPopover> * <button ndPopoverTrigger ndDatePickerTrigger [empty]="!date()"> * <svg ndIcon="calendar"></svg> * {{ date() ? (date() | date: 'longDate') : 'Pick a date' }} * </button> * <ng-template ndPopoverPortal> * <div ndPopoverContent align="start" class="w-auto p-0">…</div> * </ng-template> * </ng-container> * ``` */ @Directive({ selector: 'button[ndDatePickerTrigger]', host: { type: 'button', 'data-slot': 'date-picker-trigger', '[attr.data-empty]': 'empty()', '[class]': 'classes()', }, }) export class DatePickerTrigger { /** Whether no date is chosen yet, which mutes the placeholder text. */ readonly empty = input(false, { transform: booleanAttribute }); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: 'outline' }), 'w-[240px] justify-start text-start font-normal data-[empty=true]:text-muted-foreground', 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';
import { DatePickerTrigger } from '@/ui/date-picker/date-picker';
import {
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from '@/ui/popover/popover';<ng-container ndPopover [(open)]="open">
<button ndPopoverTrigger ndDatePickerTrigger [empty]="!date()">
<svg ndIcon="calendar"></svg>
{{ date() ? (date() | date: 'longDate') : 'Pick a date' }}
</button>
<ng-template ndPopoverPortal>
<div
ndPopoverContent
align="start"
aria-label="Choose a date"
class="w-auto p-0"
>
<div
ndCalendar
#calendar="ndCalendar"
[(value)]="date"
(valueChange)="open.set(false)"
>
<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>
</div>
</ng-template>
</ng-container>Examples
Date Picker range
A button that opens two months to pick a range of days.
import { DatePipe } from '@angular/common';
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';
import { DatePickerTrigger } from '@/ui/date-picker/date-picker';
import {
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from '@/ui/popover/popover';
/**
* A button that opens two months to pick a range of days; it shows both
* ends once the range is complete.
*/
@Component({
selector: 'example-date-picker-range',
imports: [
Calendar,
CalendarCaption,
CalendarCell,
CalendarDay,
CalendarGrid,
CalendarHeading,
CalendarMonth,
CalendarMonths,
CalendarNav,
CalendarNext,
CalendarPrevious,
CalendarWeek,
CalendarWeekday,
CalendarWeekdays,
DatePickerTrigger,
DatePipe,
NdIcon,
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
],
template: `
<ng-container ndPopover [(open)]="open">
<button
ndPopoverTrigger
ndDatePickerTrigger
[empty]="!range().from"
class="w-[300px]"
>
<svg ndIcon="calendar"></svg>
@if (range().from; as from) {
{{ from | date: 'mediumDate' }}
@if (range().to; as to) {
– {{ to | date: 'mediumDate' }}
}
} @else {
Pick a date range
}
</button>
<ng-template ndPopoverPortal>
<div
ndPopoverContent
align="start"
aria-label="Choose a date range"
class="w-auto p-0"
>
<div
ndCalendar
#calendar="ndCalendar"
mode="range"
[numberOfMonths]="2"
[(range)]="range"
>
<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>
</div>
</ng-template>
</ng-container>
`,
})
export class DatePickerRangeExample {
protected readonly open = signal(false);
protected readonly range = signal<DateRange>({ from: null, to: null });
}
Accessibility
Date Picker has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on.
API reference
DatePickerTrigger
button[ndDatePickerTrigger]The button of a date picker: an outline button that shows the chosen
date, or a muted placeholder while empty. Put it on the trigger of a
Popover whose content holds a Calendar; opening focuses the chosen day.
| DatePickerTrigger binding | Type | Default |
|---|---|---|
[empty]Whether no date is chosen yet, which mutes the placeholder text. | boolean | false |
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | date-picker-trigger |
data-empty | — |
Agent notes
Do
- Show the chosen date on the button, formatted with Angular's
DatePipe, and a placeholder until then. - Name the popover's content with
aria-label, such as "Choose a date". - Use
min,maxanddisabledDatesfor dates that can't be chosen.
Don't
- Don't use it for dates people know by heart, such as a birth date; separate fields for day, month and year are quicker to type.
- Don't hide the calendar's keyboard: keep it in a popover, not on hover.