- Add new reward-order-confirmation feature library with components and store - Implement checkout completion orchestrator service for order finalization - Migrate checkout/oms/crm models to Zod schemas for better type safety - Add order creation facade and display order schemas - Update shopping cart facade with order completion flow - Add comprehensive tests for shopping cart facade - Update routing to include order confirmation page
@isa/ui/datepicker
A comprehensive date range picker component library for Angular applications with calendar and month/year selection views, form integration, and robust validation.
Overview
The UI Datepicker library provides a full-featured date range picker for the ISA application. It implements Angular's ControlValueAccessor interface for seamless form integration, uses signals for reactive state management, and provides calendar-based date selection with month/year navigation. The library includes dedicated validators for date bounds and range order validation, making it ideal for filtering, reporting, and data entry scenarios requiring date range inputs.
Table of Contents
- Features
- Quick Start
- Core Concepts
- API Reference
- Usage Examples
- Validation
- Form Integration
- Testing
- Architecture Notes
Features
- Date range selection - Select start and end dates with automatic ordering
- ControlValueAccessor integration - Full Angular forms compatibility (reactive and template-driven)
- Multiple views - Calendar view for day selection, month/year view for navigation
- Signal-based reactivity - Modern Angular signals for state management
- Configurable bounds - Min/max date constraints with injection tokens
- Auto-sorting - Automatically orders start/end dates when user selects them
- Zod schema validation - Runtime type safety with Zod schemas
- Custom validators - Date bounds and range order validators for forms
- date-fns integration - Comprehensive date manipulation and formatting
- Keyboard navigation - Full keyboard support for accessibility
- OnPush change detection - Optimized performance
- Extensible architecture - Abstract base class for custom datepicker implementations
Quick Start
1. Import Component
import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { RangeDatepickerComponent } from '@isa/ui/datepicker';
@Component({
selector: 'app-date-filter',
standalone: true,
imports: [ReactiveFormsModule, RangeDatepickerComponent],
template: `
<ui-range-datepicker [formControl]="dateRangeControl" />
`
})
export class DateFilterComponent {
dateRangeControl = new FormControl<[Date?, Date?]>([undefined, undefined]);
}
2. Use with Reactive Forms
import { Component } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RangeDatepickerComponent, dateBoundsValidator, dateRangeStartStopOrderValidator } from '@isa/ui/datepicker';
import { subMonths } from 'date-fns';
@Component({
selector: 'app-report-filter',
standalone: true,
imports: [ReactiveFormsModule, RangeDatepickerComponent],
template: `
<form [formGroup]="filterForm">
<ui-range-datepicker formControlName="dateRange" />
</form>
`
})
export class ReportFilterComponent {
filterForm = this.fb.group({
dateRange: [
[undefined, undefined] as [Date?, Date?],
[
Validators.required,
dateRangeStartStopOrderValidator('start', 'stop')
]
]
}, {
validators: [dateRangeStartStopOrderValidator('dateRange', 'dateRange')]
});
constructor(private fb: FormBuilder) {}
}
3. Configure Min/Max Bounds
import { Component, Provider } from '@angular/core';
import { RangeDatepickerComponent, UI_DATEPICKER_DEFAULT_MIN, UI_DATEPICKER_DEFAULT_MAX } from '@isa/ui/datepicker';
import { subYears, addYears } from 'date-fns';
const CUSTOM_DATEPICKER_MIN: Provider = {
provide: UI_DATEPICKER_DEFAULT_MIN,
useValue: subYears(new Date(), 10) // 10 years in the past
};
const CUSTOM_DATEPICKER_MAX: Provider = {
provide: UI_DATEPICKER_DEFAULT_MAX,
useValue: addYears(new Date(), 2) // 2 years in the future
};
@Component({
selector: 'app-custom-datepicker',
standalone: true,
imports: [RangeDatepickerComponent],
providers: [CUSTOM_DATEPICKER_MIN, CUSTOM_DATEPICKER_MAX],
template: `<ui-range-datepicker />`
})
export class CustomDatepickerComponent {}
4. Access Selected Dates
import { Component, signal } from '@angular/core';
import { RangeDatepickerComponent } from '@isa/ui/datepicker';
@Component({
selector: 'app-date-display',
standalone: true,
imports: [RangeDatepickerComponent],
template: `
<ui-range-datepicker [(ngModel)]="selectedRange" />
@if (selectedRange()[0] && selectedRange()[1]) {
<p>Start: {{ selectedRange()[0] | date }}</p>
<p>End: {{ selectedRange()[1] | date }}</p>
}
`
})
export class DateDisplayComponent {
selectedRange = signal<[Date?, Date?]>([undefined, undefined]);
}
Core Concepts
Component Architecture
The datepicker library uses a multi-layer architecture:
RangeDatepickerComponent (User-facing)
├─→ Extends RangeDatepicker (Business logic)
│ └─→ Extends DatepickerBase (Forms integration)
├─→ DatepickerViewState (View state management)
├─→ CalendarBodyComponent (Day selection view)
├─→ MonthYearBodyComponent (Month/year selection view)
├─→ SelectedRangeComponent (Header with date inputs)
└─→ SelectedMonthYearComponent (Month/year display)
Date Range Value Type
The library uses a tuple type for date ranges:
export const DateRangeSchema = z.tuple([
z.coerce.date().optional(), // start date
z.coerce.date().optional(), // end date
]);
export type DateRangeValue = z.infer<typeof DateRangeSchema>;
// Type: [Date?, Date?]
Auto-Sorting Behavior
When users select dates, the range automatically sorts them in ascending order:
// User selects end date first
datepicker.setDateRange(new Date('2024-12-31'));
// Value: [2024-12-31, undefined]
// User selects start date second
datepicker.setDateRange(new Date('2024-01-01'));
// Value: [2024-01-01, 2024-12-31] (automatically sorted!)
Sorting Logic:
- If both dates are selected, clears range and starts new selection
- If only end date exists, sorts new selection with existing date
- If only start date exists, sorts new selection with existing date
- Uses
date-fnsisBefore()for comparison
View State Management
The datepicker maintains separate view state from selected value:
export class DatepickerViewState {
// Currently displayed month/year in calendar
displayedDate = signal<Date>(new Date());
// Which view is active
view = signal<DatepickerView>(DatepickerView.Calendar);
}
View Types:
DatepickerView.Calendar- Day selection gridDatepickerView.MonthYear- Month and year selection
ControlValueAccessor Implementation
The datepicker implements Angular's ControlValueAccessor interface:
export abstract class DatepickerBase<TValue> implements ControlValueAccessor {
// Form control callbacks
onChange?: (value: TValue | undefined) => void;
onTouched?: () => void;
// Current value signal
value = model<TValue | undefined>();
// Form control methods
writeValue(obj: unknown) { /* ... */ }
registerOnChange(fn: typeof this.onChange) { /* ... */ }
registerOnTouched(fn: typeof this.onTouched) { /* ... */ }
// Abstract method for type parsing
abstract parseValue(value: unknown): TValue | undefined;
}
Dependency Injection Pattern
Min/max bounds are configurable via injection tokens:
// Default configuration (4 years past, 1 year future)
export const UI_DATEPICKER_DEFAULT_MIN = new InjectionToken<DateValue | undefined>(
'UI_DATEPICKER_DEFAULT_MIN',
{
providedIn: 'root',
factory: () => subYears(new Date(), 4)
}
);
export const UI_DATEPICKER_DEFAULT_MAX = new InjectionToken<DateValue | undefined>(
'UI_DATEPICKER_DEFAULT_MAX',
{
providedIn: 'root',
factory: () => addYears(new Date(), 1)
}
);
// Usage in component
min = input<DateValue | undefined>(inject(UI_DATEPICKER_DEFAULT_MIN));
max = input<DateValue | undefined>(inject(UI_DATEPICKER_DEFAULT_MAX));
API Reference
RangeDatepickerComponent
Main component for date range selection with calendar interface.
Selector
ui-range-datepicker
Inputs (Inherited from DatepickerBase)
| Input | Type | Default | Description |
|---|---|---|---|
min |
DateValue | undefined |
subYears(today, 4) |
Minimum selectable date |
max |
DateValue | undefined |
addYears(today, 1) |
Maximum selectable date |
Outputs (ControlValueAccessor)
Works with Angular forms - use formControl, formControlName, or [(ngModel)] bindings.
Public Properties
| Property | Type | Description |
|---|---|---|
value |
Signal<[Date?, Date?]> |
Current selected date range |
viewState |
DatepickerViewState |
View state management service |
Methods
| Method | Signature | Description |
|---|---|---|
setValue |
(value?: [Date?, Date?]) => void |
Programmatically set the date range |
setDateRange |
(value: Date) => void |
Add a date to the range (auto-sorts) |
parseValue |
(value: unknown) => [Date?, Date?] | undefined |
Parse unknown value to date range |
sortRangeValuesAsc |
(range: [Date?, Date?]) => [Date?, Date?] |
Sort dates in ascending order |
Example
import { Component, viewChild } from '@angular/core';
import { RangeDatepickerComponent } from '@isa/ui/datepicker';
@Component({
selector: 'app-datepicker-example',
standalone: true,
imports: [RangeDatepickerComponent],
template: `
<ui-range-datepicker #picker />
<button (click)="setLastMonth()">Last Month</button>
<button (click)="clearDates()">Clear</button>
`
})
export class DatepickerExampleComponent {
picker = viewChild.required<RangeDatepickerComponent>('picker');
setLastMonth(): void {
const today = new Date();
const lastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const endOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
this.picker().setValue([lastMonth, endOfLastMonth]);
}
clearDates(): void {
this.picker().setValue([undefined, undefined]);
}
}
DatepickerBase
Abstract base class for creating custom datepicker implementations.
Type Parameters
TValue- The type of the datepicker value (e.g.,[Date?, Date?]for range picker)
Abstract Methods
abstract parseValue(value: unknown): TValue | undefined;
Implement this method to define how raw values are parsed into your datepicker's value type.
Concrete Methods
| Method | Description |
|---|---|
setValue(value?: TValue) |
Set value with parsing and form control notification |
writeValue(obj: unknown) |
ControlValueAccessor method - write value from form |
registerOnChange(fn) |
ControlValueAccessor method - register change callback |
registerOnTouched(fn) |
ControlValueAccessor method - register touched callback |
Example: Custom Single Date Picker
import { Directive } from '@angular/core';
import { DatepickerBase } from '@isa/ui/datepicker';
import { z } from 'zod';
const SingleDateSchema = z.coerce.date().optional();
type SingleDateValue = z.infer<typeof SingleDateSchema>;
@Directive()
export class SingleDatepicker extends DatepickerBase<SingleDateValue> {
parseValue(value: unknown): SingleDateValue {
return SingleDateSchema.parse(value);
}
setSingleDate(value: Date): void {
this.setValue(value);
}
}
DatepickerViewState
Service managing the datepicker's view state (displayed month/year and active view).
Properties
| Property | Type | Description |
|---|---|---|
displayedDate |
WritableSignal<Date> |
Currently displayed month/year in calendar |
view |
WritableSignal<DatepickerView> |
Active view (Calendar or MonthYear) |
Example
import { Component, inject } from '@angular/core';
import { DatepickerViewState, DatepickerView } from '@isa/ui/datepicker';
@Component({
selector: 'app-custom-controls',
template: `
<button (click)="showCalendar()">Calendar View</button>
<button (click)="showMonthYear()">Month/Year View</button>
<button (click)="goToToday()">Today</button>
`
})
export class CustomControlsComponent {
viewState = inject(DatepickerViewState);
showCalendar(): void {
this.viewState.view.set(DatepickerView.Calendar);
}
showMonthYear(): void {
this.viewState.view.set(DatepickerView.MonthYear);
}
goToToday(): void {
this.viewState.displayedDate.set(new Date());
}
}
Validators
dateBoundsValidator(min?, max?): ValidatorFn
Validates that a date falls within specified bounds.
Parameters:
min?: Date- Minimum allowed date (inclusive)max?: Date- Maximum allowed date (inclusive)
Returns: ValidationErrors | null
{ invalidDate: true }- If date is invalid{ minDate: true }- If date is before minimum{ maxDate: true }- If date is after maximumnull- If validation passes
Example:
import { FormControl } from '@angular/forms';
import { dateBoundsValidator } from '@isa/ui/datepicker';
import { subYears, addYears } from 'date-fns';
const dateControl = new FormControl<Date>(
new Date(),
[dateBoundsValidator(subYears(new Date(), 5), addYears(new Date(), 1))]
);
// Test validation
dateControl.setValue(subYears(new Date(), 10)); // Invalid - before min
console.log(dateControl.errors); // { minDate: true }
dateRangeStartStopOrderValidator(startKey, stopKey): ValidatorFn
Validates that a start date is not after a stop date in a form group.
Parameters:
startKey: string- Form control key for start datestopKey: string- Form control key for stop date
Returns: ValidationErrors | null
{ startAfterStop: true }- If start date is after stop datenull- If validation passes (or if either date is missing/invalid)
Example:
import { FormBuilder, Validators } from '@angular/forms';
import { dateRangeStartStopOrderValidator } from '@isa/ui/datepicker';
const form = this.fb.group({
startDate: [null, Validators.required],
endDate: [null, Validators.required]
}, {
validators: [dateRangeStartStopOrderValidator('startDate', 'endDate')]
});
// Test validation
form.patchValue({
startDate: new Date('2024-12-31'),
endDate: new Date('2024-01-01')
});
console.log(form.errors); // { startAfterStop: true }
Usage Examples
Basic Date Range Selection
import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { RangeDatepickerComponent } from '@isa/ui/datepicker';
@Component({
selector: 'app-basic-range',
standalone: true,
imports: [ReactiveFormsModule, RangeDatepickerComponent],
template: `
<div class="date-filter">
<label>Select Date Range:</label>
<ui-range-datepicker [formControl]="rangeControl" />
@if (rangeControl.value?.[0] && rangeControl.value?.[1]) {
<p>
Selected: {{ rangeControl.value[0] | date }} to {{ rangeControl.value[1] | date }}
</p>
}
</div>
`
})
export class BasicRangeComponent {
rangeControl = new FormControl<[Date?, Date?]>([undefined, undefined]);
}
Form with Validation
import { Component } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RangeDatepickerComponent, dateBoundsValidator, dateRangeStartStopOrderValidator } from '@isa/ui/datepicker';
import { subMonths, addMonths } from 'date-fns';
@Component({
selector: 'app-validated-form',
standalone: true,
imports: [ReactiveFormsModule, RangeDatepickerComponent],
template: `
<form [formGroup]="reportForm" (ngSubmit)="generateReport()">
<div class="form-field">
<label>Report Period:</label>
<ui-range-datepicker formControlName="dateRange" />
@if (reportForm.get('dateRange')?.hasError('required')) {
<span class="error">Date range is required</span>
}
@if (reportForm.get('dateRange')?.hasError('startAfterStop')) {
<span class="error">Start date must be before end date</span>
}
@if (reportForm.get('dateRange')?.hasError('minDate')) {
<span class="error">Date must be within the last 6 months</span>
}
@if (reportForm.get('dateRange')?.hasError('maxDate')) {
<span class="error">Date cannot be in the future</span>
}
</div>
<button type="submit" [disabled]="!reportForm.valid">
Generate Report
</button>
</form>
`
})
export class ValidatedFormComponent {
reportForm = this.fb.group({
dateRange: [
[undefined, undefined] as [Date?, Date?],
[
Validators.required,
dateBoundsValidator(subMonths(new Date(), 6), new Date())
]
]
}, {
validators: [dateRangeStartStopOrderValidator('dateRange', 'dateRange')]
});
constructor(private fb: FormBuilder) {}
generateReport(): void {
if (this.reportForm.valid) {
const [start, end] = this.reportForm.value.dateRange!;
console.log('Generating report from', start, 'to', end);
}
}
}
Custom Min/Max Bounds
import { Component, Provider } from '@angular/core';
import { RangeDatepickerComponent, UI_DATEPICKER_DEFAULT_MIN, UI_DATEPICKER_DEFAULT_MAX } from '@isa/ui/datepicker';
import { subYears, endOfYear } from 'date-fns';
// Custom providers for component-specific bounds
const FISCAL_YEAR_MIN: Provider = {
provide: UI_DATEPICKER_DEFAULT_MIN,
useValue: new Date(2020, 0, 1) // January 1, 2020
};
const FISCAL_YEAR_MAX: Provider = {
provide: UI_DATEPICKER_DEFAULT_MAX,
useValue: endOfYear(new Date()) // End of current year
};
@Component({
selector: 'app-fiscal-report',
standalone: true,
imports: [RangeDatepickerComponent],
providers: [FISCAL_YEAR_MIN, FISCAL_YEAR_MAX],
template: `
<div class="fiscal-report">
<h2>Fiscal Year Report</h2>
<p>Select dates between 2020 and end of current year</p>
<ui-range-datepicker />
</div>
`
})
export class FiscalReportComponent {}
Programmatic Date Control
import { Component, signal, viewChild } from '@angular/core';
import { RangeDatepickerComponent } from '@isa/ui/datepicker';
import { startOfMonth, endOfMonth, subMonths, startOfYear, endOfYear } from 'date-fns';
@Component({
selector: 'app-quick-filters',
standalone: true,
imports: [RangeDatepickerComponent],
template: `
<div class="quick-filters">
<button (click)="setThisMonth()">This Month</button>
<button (click)="setLastMonth()">Last Month</button>
<button (click)="setThisYear()">This Year</button>
<button (click)="clearRange()">Clear</button>
</div>
<ui-range-datepicker #picker />
@if (selectedRange()[0] && selectedRange()[1]) {
<div class="selected-info">
<p>From: {{ selectedRange()[0] | date:'fullDate' }}</p>
<p>To: {{ selectedRange()[1] | date:'fullDate' }}</p>
</div>
}
`
})
export class QuickFiltersComponent {
picker = viewChild.required<RangeDatepickerComponent>('picker');
selectedRange = signal<[Date?, Date?]>([undefined, undefined]);
setThisMonth(): void {
const today = new Date();
const start = startOfMonth(today);
const end = endOfMonth(today);
this.updateRange([start, end]);
}
setLastMonth(): void {
const lastMonth = subMonths(new Date(), 1);
const start = startOfMonth(lastMonth);
const end = endOfMonth(lastMonth);
this.updateRange([start, end]);
}
setThisYear(): void {
const today = new Date();
const start = startOfYear(today);
const end = endOfYear(today);
this.updateRange([start, end]);
}
clearRange(): void {
this.updateRange([undefined, undefined]);
}
private updateRange(range: [Date?, Date?]): void {
this.picker().setValue(range);
this.selectedRange.set(range);
}
}
Integration with Backend API
import { Component, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { RangeDatepickerComponent } from '@isa/ui/datepicker';
import { format } from 'date-fns';
import { HttpClient } from '@angular/common/http';
interface ReportFilters {
startDate: string; // ISO format
endDate: string; // ISO format
}
@Component({
selector: 'app-api-integration',
standalone: true,
imports: [ReactiveFormsModule, RangeDatepickerComponent],
template: `
<form [formGroup]="filterForm" (ngSubmit)="loadData()">
<ui-range-datepicker formControlName="dateRange" />
<button type="submit" [disabled]="!filterForm.valid || isLoading()">
{{ isLoading() ? 'Loading...' : 'Load Data' }}
</button>
</form>
@if (data()) {
<div class="results">
<p>Found {{ data()?.length }} results</p>
<!-- Display results... -->
</div>
}
`
})
export class ApiIntegrationComponent {
filterForm = this.fb.group({
dateRange: [[undefined, undefined] as [Date?, Date?]]
});
isLoading = signal(false);
data = signal<unknown[] | null>(null);
constructor(
private fb: FormBuilder,
private http: HttpClient
) {}
loadData(): void {
const [start, end] = this.filterForm.value.dateRange!;
if (!start || !end) return;
const filters: ReportFilters = {
startDate: format(start, 'yyyy-MM-dd'),
endDate: format(end, 'yyyy-MM-dd')
};
this.isLoading.set(true);
this.http.get<unknown[]>('/api/reports', { params: filters })
.subscribe({
next: (results) => {
this.data.set(results);
this.isLoading.set(false);
},
error: (error) => {
console.error('Failed to load data:', error);
this.isLoading.set(false);
}
});
}
}
Validation
Built-in Validators
The library provides two validators for common date validation scenarios:
1. Date Bounds Validator
Ensures a date falls within a specified range:
import { dateBoundsValidator } from '@isa/ui/datepicker';
import { subYears, addYears } from 'date-fns';
// Allow dates within 5 years past and 2 years future
const control = new FormControl(
new Date(),
[dateBoundsValidator(subYears(new Date(), 5), addYears(new Date(), 2))]
);
// Validation errors
control.setValue(subYears(new Date(), 10));
console.log(control.errors); // { minDate: true }
control.setValue(addYears(new Date(), 5));
console.log(control.errors); // { maxDate: true }
control.setValue(new Date('invalid'));
console.log(control.errors); // { invalidDate: true }
2. Date Range Order Validator
Ensures start date is not after end date:
import { dateRangeStartStopOrderValidator } from '@isa/ui/datepicker';
const form = this.fb.group({
startDate: [null],
endDate: [null]
}, {
validators: [dateRangeStartStopOrderValidator('startDate', 'endDate')]
});
// Valid - start before end
form.patchValue({
startDate: new Date('2024-01-01'),
endDate: new Date('2024-12-31')
});
console.log(form.errors); // null
// Invalid - start after end
form.patchValue({
startDate: new Date('2024-12-31'),
endDate: new Date('2024-01-01')
});
console.log(form.errors); // { startAfterStop: true }
Custom Validators
Create custom validators for specific business rules:
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
import { isWeekend } from 'date-fns';
// Validator: No weekend dates
export function noWeekendsValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const date: Date | null = control.value;
if (!date) return null;
if (isWeekend(date)) {
return { weekend: true };
}
return null;
};
}
// Validator: Date range must span at least N days
export function minRangeDaysValidator(minDays: number): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const start = group.get('startDate')?.value as Date | null;
const end = group.get('endDate')?.value as Date | null;
if (!start || !end) return null;
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff < minDays) {
return { minRangeDays: { required: minDays, actual: daysDiff } };
}
return null;
};
}
// Usage
const form = this.fb.group({
startDate: [null, [Validators.required, noWeekendsValidator()]],
endDate: [null, [Validators.required, noWeekendsValidator()]]
}, {
validators: [
dateRangeStartStopOrderValidator('startDate', 'endDate'),
minRangeDaysValidator(7) // Minimum 7 days
]
});
Error Display
Display validation errors in the template:
<form [formGroup]="form">
<ui-range-datepicker formControlName="dateRange" />
@if (form.get('dateRange')?.touched && form.get('dateRange')?.errors) {
<div class="validation-errors">
@if (form.get('dateRange')?.hasError('required')) {
<span class="error">Date range is required</span>
}
@if (form.get('dateRange')?.hasError('minDate')) {
<span class="error">Date is before minimum allowed date</span>
}
@if (form.get('dateRange')?.hasError('maxDate')) {
<span class="error">Date is after maximum allowed date</span>
}
@if (form.get('dateRange')?.hasError('startAfterStop')) {
<span class="error">Start date must be before end date</span>
}
@if (form.get('dateRange')?.hasError('invalidDate')) {
<span class="error">Invalid date format</span>
}
</div>
}
</form>
Form Integration
Reactive Forms
Full integration with Angular reactive forms:
import { Component } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RangeDatepickerComponent, dateRangeStartStopOrderValidator } from '@isa/ui/datepicker';
@Component({
selector: 'app-reactive-form',
standalone: true,
imports: [ReactiveFormsModule, RangeDatepickerComponent],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<ui-range-datepicker formControlName="dateRange" />
<button type="submit">Submit</button>
</form>
`
})
export class ReactiveFormComponent {
form = this.fb.group({
dateRange: [
[undefined, undefined] as [Date?, Date?],
[Validators.required]
]
}, {
validators: [dateRangeStartStopOrderValidator('dateRange', 'dateRange')]
});
constructor(private fb: FormBuilder) {}
onSubmit(): void {
if (this.form.valid) {
console.log('Selected range:', this.form.value.dateRange);
}
}
}
Template-Driven Forms
Works with template-driven forms via ngModel:
<form #form="ngForm" (ngSubmit)="onSubmit(form)">
<ui-range-datepicker
name="dateRange"
[(ngModel)]="selectedRange"
required>
</ui-range-datepicker>
<button type="submit" [disabled]="!form.valid">Submit</button>
</form>
Form Control State
Access form control state and errors:
// Get form control
const dateRangeControl = this.form.get('dateRange');
// Check validity
console.log(dateRangeControl?.valid);
console.log(dateRangeControl?.invalid);
console.log(dateRangeControl?.errors);
// Check state
console.log(dateRangeControl?.touched);
console.log(dateRangeControl?.dirty);
console.log(dateRangeControl?.pristine);
// Manually update
dateRangeControl?.setValue([new Date(), new Date()]);
dateRangeControl?.patchValue([new Date(), undefined]);
dateRangeControl?.markAsTouched();
dateRangeControl?.markAsDirty();
Testing
The library uses Jest for testing.
Running Tests
# Run tests for this library
npx nx test ui-datepicker --skip-nx-cache
# Run tests with coverage
npx nx test ui-datepicker --code-coverage --skip-nx-cache
# Run tests in watch mode
npx nx test ui-datepicker --watch
Test Coverage
The library includes comprehensive unit tests covering:
- Component rendering - Calendar and month/year views
- Date selection - Single date, range selection, auto-sorting
- Form integration - ControlValueAccessor implementation
- Validators - Date bounds and range order validation
- State management - View state transitions
- Date bounds - Min/max constraint enforcement
- Edge cases - Invalid dates, empty values, out-of-bounds dates
Example Tests
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from '@jest/globals';
import { RangeDatepickerComponent } from './range-datepicker.component';
describe('RangeDatepickerComponent', () => {
let component: RangeDatepickerComponent;
let fixture: ComponentFixture<RangeDatepickerComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RangeDatepickerComponent]
}).compileComponents();
fixture = TestBed.createComponent(RangeDatepickerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should auto-sort date range', () => {
const startDate = new Date('2024-01-01');
const endDate = new Date('2024-12-31');
// Select end date first
component.setDateRange(endDate);
// Select start date second
component.setDateRange(startDate);
const value = component.value();
expect(value?.[0]).toEqual(startDate);
expect(value?.[1]).toEqual(endDate);
});
it('should parse date range from unknown value', () => {
const dateStrings = ['2024-01-01', '2024-12-31'];
const parsed = component.parseValue(dateStrings);
expect(parsed).toBeDefined();
expect(parsed?.[0]).toBeInstanceOf(Date);
expect(parsed?.[1]).toBeInstanceOf(Date);
});
});
Architecture Notes
Component Hierarchy
RangeDatepickerComponent
├─→ RangeDatepicker (extends DatepickerBase<DateRangeValue>)
│ ├─→ Implements date range logic
│ ├─→ Auto-sorting behavior
│ └─→ Zod schema parsing
├─→ DatepickerBase<TValue>
│ ├─→ ControlValueAccessor implementation
│ ├─→ Form integration
│ └─→ Min/max bounds management
├─→ DatepickerViewState (DI service)
│ ├─→ Displayed date signal
│ └─→ Active view signal
├─→ CalendarBodyComponent
│ ├─→ Day grid rendering
│ ├─→ Month navigation
│ └─→ Date selection handling
├─→ MonthYearBodyComponent
│ ├─→ Month selection grid
│ ├─→ Year selection grid
│ └─→ Bounds filtering
├─→ SelectedRangeComponent (header)
│ └─→ Date input fields
└─→ SelectedMonthYearComponent (header)
└─→ Month/year display
Design Patterns
1. Abstract Base Class
DatepickerBase provides extensibility for custom implementations:
@Directive()
export abstract class DatepickerBase<TValue> implements ControlValueAccessor {
// Common form integration
// Min/max bounds handling
// Abstract parsing method
abstract parseValue(value: unknown): TValue | undefined;
}
// Implementations
export class RangeDatepicker extends DatepickerBase<DateRangeValue> { /* ... */ }
export class SingleDatepicker extends DatepickerBase<Date | undefined> { /* ... */ }
Benefits:
- Code reuse for form integration
- Consistent API across datepicker types
- Easy to create custom datepicker variants
2. Dependency Injection for Configuration
Injection tokens allow flexible configuration:
export const UI_DATEPICKER_DEFAULT_MIN = new InjectionToken<DateValue>(
'UI_DATEPICKER_DEFAULT_MIN',
{ providedIn: 'root', factory: () => subYears(new Date(), 4) }
);
// Override per component
providers: [
{ provide: UI_DATEPICKER_DEFAULT_MIN, useValue: customMin }
]
Benefits:
- Global defaults with local overrides
- Type-safe configuration
- Testability (easy to mock)
3. Signal-Based State Management
Uses Angular signals for reactive state:
// View state
displayedDate = signal<Date>(new Date());
view = signal<DatepickerView>(DatepickerView.Calendar);
// Component value
value = model<DateRangeValue | undefined>();
// Computed properties
calendarDays = computed(() => {
const month = this.viewState.displayedDate();
return eachDayOfInterval(/* ... */);
});
Benefits:
- Fine-grained reactivity
- Automatic dependency tracking
- Minimal re-renders with OnPush
4. Zod Schema Validation
Runtime type safety with Zod:
export const DateRangeSchema = z.tuple([
z.coerce.date().optional(),
z.coerce.date().optional(),
]);
parseValue(value: unknown): DateRangeValue | undefined {
return DateRangeSchema.optional().parse(value);
}
Benefits:
- Runtime type validation
- Type coercion (strings → dates)
- Clear validation errors
Performance Considerations
- OnPush Change Detection: All components use
ChangeDetectionStrategy.OnPush - Signal-Based Reactivity: Computed properties only recalculate when dependencies change
- date-fns Tree Shaking: Only import needed date functions
- Lazy View Rendering: Calendar/month-year views render only when active
Known Architectural Considerations
1. Single Date Picker Support (Medium Priority)
Current State:
- Library only provides
RangeDatepicker - No built-in single date picker component
Consideration:
- Add
SingleDatepickerComponentextendingDatepickerBase<Date> - Share calendar view components
Impact: Medium - common use case currently requires workaround
2. Validator Utilities Location (Low Priority)
Current State:
- Validators have
TODOcomments about extracting to utils - Currently in library but could be shared
Consideration:
- Move to
@isa/utils/validatorsfor broader reuse - Keep datepicker-specific validators in library
Impact: Low - current location is acceptable
3. Time Selection Support (Future Enhancement)
Current State:
- Date-only selection
- No time picker functionality
Consideration:
- Add time selection component
- Create
DateTimeRangePickervariant
Impact: Future - no current requirement
Dependencies
Required Libraries
@angular/core- Angular framework@angular/common- Angular common utilities (DatePipe)@angular/forms- Forms integration (ControlValueAccessor)date-fns- Date manipulation and formattingzod- Runtime type validation@ng-icons/core- Icon rendering@isa/icons- ISA icon library (chevrons, check icon)@isa/ui/buttons- Button components (month/year selection)
Path Alias
Import from: @isa/ui/datepicker
Project Configuration
- Project Name:
ui-datepicker - Prefix:
ui - Testing: Jest
- Source Root:
libs/ui/datepicker/src
License
Internal ISA Frontend library - not for external distribution.