import { ChangeDetectorRef, Component, EventEmitter, inject, Input, OnChanges, Output, } from '@angular/core'; import { VspSelPanel } from './select.component'; const DOW = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; const MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; /** Format a date like "Jan 5, 2026". */ export const fmtDate = (d: Date | null | undefined): string => d ? `${MONTHS[d.getMonth()].slice(0, 3)} ${d.getDate()}, ${d.getFullYear()}` : ''; const sameDay = (a: Date | null | undefined, b: Date | null | undefined): boolean => !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); const stripTime = (d: Date): Date => new Date(d.getFullYear(), d.getMonth(), d.getDate()); const nightsBetween = (a: Date, b: Date): number => Math.abs(Math.round((stripTime(b).getTime() - stripTime(a).getTime()) / 86400000)); /** * Flexible date matcher used by the pickers' `disabled` input. Accepts a `Date`, * `Date[]`, `{ from, to }`, `{ before }`, `{ after }`, `{ dayOfWeek: [0,6] }`, a * `(date) => boolean` predicate, or an array of any of these (any match wins). */ export type DateMatcher = | Date | Date[] | { from: Date; to: Date } | { before: Date } | { after: Date } | { dayOfWeek: number[] } | ((date: Date) => boolean); /** True when `d` satisfies the matcher (or any matcher in the array). */ export function matchesDate(d: Date, matcher?: DateMatcher | DateMatcher[]): boolean { if (!matcher) return false; if (Array.isArray(matcher)) return matcher.some((m) => matchesDate(d, m as DateMatcher)); if (matcher instanceof Date) return sameDay(d, matcher); if (typeof matcher === 'function') return matcher(d); const x = stripTime(d); if ('from' in matcher && 'to' in matcher) return x >= stripTime(matcher.from) && x <= stripTime(matcher.to); if ('before' in matcher) return x < stripTime(matcher.before); if ('after' in matcher) return x > stripTime(matcher.after); if ('dayOfWeek' in matcher) return matcher.dayOfWeek.includes(d.getDay()); return false; } interface GridDay { dt: Date; muted?: boolean; } function monthGrid(year: number, month: number): GridDay[] { const first = new Date(year, month, 1); const days: GridDay[] = []; for (let i = first.getDay(); i > 0; i--) days.push({ dt: new Date(year, month, 1 - i), muted: true }); const dim = new Date(year, month + 1, 0).getDate(); for (let d = 1; d <= dim; d++) days.push({ dt: new Date(year, month, d) }); while (days.length < 42) { const last = days[days.length - 1].dt; const nd = new Date(last); nd.setDate(nd.getDate() + 1); days.push({ dt: nd, muted: true }); } return days; } export interface MonthView { m: number; y: number; } export type RangeEdge = 'start' | 'end' | false; export interface DateRange { start: Date | null; end: Date | null; } /** A named shortcut shown in the range picker's preset rail. */ export interface RangePreset { label: string; range: DateRange; } /* ---------------- Calendar (month grid) ---------------- */ @Component({ selector: 'vsp-calendar', template: `
{{ months[view.m] }} {{ view.y }}
@for (d of dow; track d) {
{{ d }}
} @for (g of days; track i; let i = $index) { }
`, }) export class VspCalendar { private readonly cdr = inject(ChangeDetectorRef); @Input() view: MonthView = { m: 0, y: 0 }; @Output() viewChange = new EventEmitter(); @Input() isSelected: (d: Date) => boolean = () => false; @Input() isInRange?: (d: Date) => boolean; @Input() isRangeEnd?: (d: Date) => RangeEdge; @Input() isDisabled?: (d: Date) => boolean; @Input() min?: Date; @Input() max?: Date; @Output() pick = new EventEmitter(); dow = DOW; months = MONTHS; today = stripTime(new Date()); strip = stripTime; get days(): GridDay[] { return monthGrid(this.view.y, this.view.m); } private get minD(): Date | null { return this.min ? stripTime(this.min) : null; } private get maxD(): Date | null { return this.max ? stripTime(this.max) : null; } get prevDisabled(): boolean { return !!this.minD && new Date(this.view.y, this.view.m, 1) <= this.minD; } get nextDisabled(): boolean { return !!this.maxD && new Date(this.view.y, this.view.m + 1, 0) >= this.maxD; } dayDisabled = (dt: Date): boolean => !!this.isDisabled?.(dt) || (!!this.minD && stripTime(dt) < this.minD) || (!!this.maxD && stripTime(dt) > this.maxD); nav(delta: number): void { let m = this.view.m + delta; let y = this.view.y; if (m < 0) { m = 11; y--; } if (m > 11) { m = 0; y++; } // Drive our own `view` rather than waiting for the parent to push it back via // `[view]`. In a zoneless app that @Output() round-trip doesn't refresh the open // calendar, so the month label/grid would appear frozen. Self-updating + markForCheck // keeps the calendar correct under both zoned and zoneless change detection. this.view = { m, y }; this.viewChange.emit(this.view); this.cdr.markForCheck(); } dayClass(dt: Date, muted?: boolean): string { const sel = this.isSelected(dt); const range = this.isInRange?.(dt); const rEdge = this.isRangeEnd?.(dt); return [ 'ui-cal-day', muted && 'muted', sameDay(dt, this.today) && 'today', sel && 'sel', range && !sel && 'inrange', rEdge === 'start' && 'rstart', rEdge === 'end' && 'rend', this.dayDisabled(dt) && 'disabled', ] .filter(Boolean) .join(' '); } } /* ---------------- DatePicker ---------------- */ @Component({ selector: 'vsp-date-picker', imports: [VspSelPanel, VspCalendar], template: ` @if (open) { @if (multiple) {
{{ values.length ? values.length + ' selected' : 'Select dates' }}
} }
`, }) export class VspDatePicker implements OnChanges { @Input() value: Date | null = null; @Output() valueChange = new EventEmitter(); @Input() placeholder = 'Pick a date'; @Input() min?: Date; @Input() max?: Date; @Input() disabled?: DateMatcher | DateMatcher[]; @Input() multiple = false; @Input() values: Date[] = []; @Output() valuesChange = new EventEmitter(); open = false; view: MonthView; fmt = fmtDate; private syncedTime?: number; constructor() { const base = this.value ?? new Date(); this.view = { m: base.getMonth(), y: base.getFullYear() }; } ngOnChanges(): void { const v = this.multiple ? this.values[this.values.length - 1] : this.value; if (!v) return; // Re-center the calendar on the selected date only when that date's value actually // changes — not on every new `[value]`/`[values]` object reference. A parent that // binds a freshly-built `Date` each change-detection pass would otherwise reset the // month every cycle and clobber the user's month navigation. const t = v.getTime(); if (t !== this.syncedTime) { this.syncedTime = t; this.view = { m: v.getMonth(), y: v.getFullYear() }; } } get label(): string { if (this.multiple) return this.values.length ? this.values.length === 1 ? fmtDate(this.values[0]) : `${this.values.length} dates` : this.placeholder; return this.value ? fmtDate(this.value) : this.placeholder; } get empty(): boolean { return this.multiple ? !this.values.length : !this.value; } isDisabledFn = (d: Date): boolean => matchesDate(d, this.disabled); isSelectedFn = (d: Date): boolean => this.multiple ? this.values.some((x) => sameDay(x, d)) : sameDay(d, this.value); choose(d: Date): void { if (this.multiple) { const exists = this.values.some((x) => sameDay(x, d)); this.values = exists ? this.values.filter((x) => !sameDay(x, d)) : [...this.values, d].sort((a, b) => a.getTime() - b.getTime()); this.valuesChange.emit(this.values); } else { this.value = d; this.valueChange.emit(d); this.open = false; } } clearAll(): void { this.values = []; this.valuesChange.emit(this.values); } } /* ---------------- DateRangePicker ---------------- */ @Component({ selector: 'vsp-date-range-picker', imports: [VspSelPanel, VspCalendar], template: ` @if (open) {
@if (presets?.length) {
@for (p of presets; track p.label) { }
}
{{ footLabel }}
}
`, }) export class VspDateRangePicker { @Input() value: DateRange = { start: null, end: null }; @Output() valueChange = new EventEmitter(); @Input() placeholder = 'Pick a range'; @Input() min?: Date; @Input() max?: Date; @Input() disabled?: DateMatcher | DateMatcher[]; @Input() minNights?: number; @Input() maxNights?: number; @Input() presets?: RangePreset[]; open = false; view: MonthView; constructor() { const base = this.value.start ?? new Date(); this.view = { m: base.getMonth(), y: base.getFullYear() }; } set(r: DateRange): void { this.value = r; this.valueChange.emit(r); } pick(d: Date): void { const v = this.value; if (!v.start || v.end) this.set({ start: d, end: null }); else if (d < v.start) this.set({ start: d, end: v.start }); else this.set({ start: v.start, end: d }); } isDisabledFn = (d: Date): boolean => { if (matchesDate(d, this.disabled)) return true; const v = this.value; if (v.start && !v.end && !sameDay(d, v.start)) { const n = nightsBetween(v.start, d); if (this.minNights != null && n < this.minNights) return true; if (this.maxNights != null && n > this.maxNights) return true; } return false; }; isSelectedFn = (d: Date): boolean => sameDay(d, this.value.start) || sameDay(d, this.value.end); isInRangeFn = (d: Date): boolean => !!this.value.start && !!this.value.end && d > this.value.start && d < this.value.end; isRangeEndFn = (d: Date): RangeEdge => { if (sameDay(d, this.value.start) && this.value.end) return 'start'; if (sameDay(d, this.value.end)) return 'end'; return false; }; isPresetActive(p: RangePreset): boolean { return sameDay(p.range.start, this.value.start) && sameDay(p.range.end, this.value.end); } get label(): string { const v = this.value; return v.start ? v.end ? `${fmtDate(v.start)} – ${fmtDate(v.end)}` : `${fmtDate(v.start)} – …` : this.placeholder; } get footLabel(): string { const v = this.value; return v.start && v.end ? `${nightsBetween(v.start, v.end) + 1} days` : 'Select start & end'; } }