Files
ISA-Frontend/libs/ui/datepicker/README.md
Lorenz Hilpert 2b5da00249 feat(checkout): add reward order confirmation feature with schema migrations
- 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
2025-10-21 14:28:52 +02:00

35 KiB

@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

  • 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:

  1. If both dates are selected, clears range and starts new selection
  2. If only end date exists, sorts new selection with existing date
  3. If only start date exists, sorts new selection with existing date
  4. Uses date-fns isBefore() 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 grid
  • DatepickerView.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 maximum
  • null - 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 date
  • stopKey: string - Form control key for stop date

Returns: ValidationErrors | null

  • { startAfterStop: true } - If start date is after stop date
  • null - 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

  1. OnPush Change Detection: All components use ChangeDetectionStrategy.OnPush
  2. Signal-Based Reactivity: Computed properties only recalculate when dependencies change
  3. date-fns Tree Shaking: Only import needed date functions
  4. 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 SingleDatepickerComponent extending DatepickerBase<Date>
  • Share calendar view components

Impact: Medium - common use case currently requires workaround

2. Validator Utilities Location (Low Priority)

Current State:

  • Validators have TODO comments about extracting to utils
  • Currently in library but could be shared

Consideration:

  • Move to @isa/utils/validators for 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 DateTimeRangePicker variant

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 formatting
  • zod - 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.