- Accordion
- Alert
- Alert Dialog
- Aspect Ratio
- Autocomplete
- Avatar
- Badge
- Breadcrumb
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Collapsible
- Combobox
- Command
- Context Menu
- Data Table
- Date Picker
- Dialog
- Drawer
- Dropdown Menu
- Empty
- Field
- Hover Card
- Input Group
- Input OTP
- Input
- Item
- Kbd
- Label
- Menubar
- Native Select
- Navigation Menu
- Pagination
- Popover
- Progress
- Radio Group
- Resizable
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner (Toast)
- Spinner
- Switch
- Table
- Tabs
- Textarea
- Toggle
- Toggle Group
- Tooltip
Date Picker
A date picker component.
import { Component } from '@angular/core';
import { HlmDatePickerImports } from '@spartan-ng/helm/date-picker';
import { HlmFieldImports } from '@spartan-ng/helm/field';
@Component({
selector: 'spartan-date-picker-preview',
imports: [HlmDatePickerImports, HlmFieldImports],
template: `
<hlm-field>
<label hlmFieldLabel>Date of birth</label>
<hlm-date-picker [min]="minDate" [max]="maxDate">
<hlm-date-picker-trigger buttonId="date">Pick a date</hlm-date-picker-trigger>
</hlm-date-picker>
</hlm-field>
`,
})
export class DatePickerPreview {
/** The minimum date */
public minDate = new Date(2023, 0, 1);
/** The maximum date */
public maxDate = new Date(2030, 11, 31);
}Installation
The Date Picker component is built with the Popover and the Calendar components.
ng g @spartan-ng/cli:ui date-pickernx g @spartan-ng/cli:ui date-pickerimport { DestroyRef, ElementRef, HostAttributeToken, Injector, PLATFORM_ID, effect, inject, makeEnvironmentProviders, runInInjectionContext, type EnvironmentProviders } from '@angular/core';
import { OVERLAY_DEFAULT_CONFIG } from '@angular/cdk/overlay';
import { clsx, type ClassValue } from 'clsx';
import { isPlatformBrowser } from '@angular/common';
import { provideSpartanHlm } from '@spartan-ng/helm/utils';
import { twMerge } from 'tailwind-merge';
export function hlm(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Global map to track class managers per element
const elementClassManagers = new WeakMap<HTMLElement, ElementClassManager>();
// Global mutation observer for all elements
let globalObserver: MutationObserver | null = null;
const observedElements = new Set<HTMLElement>();
interface ElementClassManager {
element: HTMLElement;
sources: Map<number, { classes: Set<string>; order: number }>;
baseClasses: Set<string>;
isUpdating: boolean;
nextOrder: number;
hasInitialized: boolean;
restoreRafId: number | null;
/** Transitions are suppressed until the first effect writes correct classes */
transitionsSuppressed: boolean;
/** Original inline transition value to restore after suppression (empty string = none was set) */
previousTransition: string;
/** Original inline transition priority to preserve !important when restoring */
previousTransitionPriority: string;
}
let sourceCounter = 0;
/**
* This function dynamically adds and removes classes for a given element without requiring
* the a class binding (e.g. `[class]="..."`) which may interfere with other class bindings.
*
* 1. This will merge the existing classes on the element with the new classes.
* 2. It will also remove any classes that were previously added by this function but are no longer present in the new classes.
* 3. Multiple calls to this function on the same element will be merged efficiently.
*/
export function classes(computed: () => ClassValue[] | string, options: ClassesOptions = {}) {
runInInjectionContext(options.injector ?? inject(Injector), () => {
const elementRef = options.elementRef ?? inject(ElementRef);
const platformId = inject(PLATFORM_ID);
const destroyRef = inject(DestroyRef);
const baseClasses = inject(new HostAttributeToken('class'), { optional: true });
const element = elementRef.nativeElement;
// Create unique identifier for this source
const sourceId = sourceCounter++;
// Get or create the class manager for this element
let manager = elementClassManagers.get(element);
if (!manager) {
// Initialize base classes from variation (host attribute 'class')
const initialBaseClasses = new Set<string>();
if (baseClasses) {
toClassList(baseClasses).forEach((cls) => initialBaseClasses.add(cls));
}
manager = {
element,
sources: new Map(),
baseClasses: initialBaseClasses,
isUpdating: false,
nextOrder: 0,
hasInitialized: false,
restoreRafId: null,
transitionsSuppressed: false,
previousTransition: '',
previousTransitionPriority: '',
};
elementClassManagers.set(element, manager);
// Setup global observer if needed and register this element
setupGlobalObserver(platformId);
observedElements.add(element);
// Suppress transitions until the first effect writes correct classes and
// the browser has painted them. This prevents CSS transition animations
// during hydration when classes change from SSR state to client state.
if (isPlatformBrowser(platformId)) {
manager.previousTransition = element.style.getPropertyValue('transition');
manager.previousTransitionPriority = element.style.getPropertyPriority('transition');
element.style.setProperty('transition', 'none', 'important');
manager.transitionsSuppressed = true;
}
}
// Assign order once at registration time
const sourceOrder = manager.nextOrder++;
function updateClasses(): void {
// Get the new classes from the computed function
const newClasses = toClassList(computed());
// Update this source's classes, keeping the original order
manager!.sources.set(sourceId, {
classes: new Set(newClasses),
order: sourceOrder,
});
// Update the element
updateElement(manager!);
// Re-enable transitions after the first effect writes correct classes.
// Deferred to next animation frame so the browser paints the class change
// with transitions disabled first, then re-enables them.
if (manager!.transitionsSuppressed) {
manager!.transitionsSuppressed = false;
manager!.restoreRafId = requestAnimationFrame(() => {
manager!.restoreRafId = null;
restoreTransitionSuppression(manager!);
});
}
}
// Register cleanup with DestroyRef
destroyRef.onDestroy(() => {
if (manager!.restoreRafId !== null) {
cancelAnimationFrame(manager!.restoreRafId);
manager!.restoreRafId = null;
}
if (manager!.transitionsSuppressed) {
manager!.transitionsSuppressed = false;
restoreTransitionSuppression(manager!);
}
// Remove this source from the manager
manager!.sources.delete(sourceId);
// If no more sources, clean up the manager
if (manager!.sources.size === 0) {
cleanupManager(element);
} else {
// Update element without this source's classes
updateElement(manager!);
}
});
/**
* We need this effect to track changes to the computed classes. Ideally, we would use
* afterRenderEffect here, but that doesn't run in SSR contexts, so we use a standard
* effect which works in both browser and SSR.
*/
effect(updateClasses);
});
}
function restoreTransitionSuppression(manager: ElementClassManager): void {
const prev = manager.previousTransition;
if (prev) {
manager.element.style.setProperty('transition', prev, manager.previousTransitionPriority || undefined);
} else {
manager.element.style.removeProperty('transition');
}
}
// eslint-disable-next-line @typescript-eslint/no-wrapper-object-types
function setupGlobalObserver(platformId: Object): void {
if (isPlatformBrowser(platformId) && !globalObserver) {
// Create single global observer that watches the entire document
globalObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const element = mutation.target as HTMLElement;
const manager = elementClassManagers.get(element);
// Only process elements we're managing
if (manager && observedElements.has(element)) {
if (manager.isUpdating) continue; // Ignore changes we're making
// Update base classes to include any externally added classes
const currentClasses = toClassList(element.className);
const allSourceClasses = new Set<string>();
// Collect all classes from all sources
for (const source of manager.sources.values()) {
for (const className of source.classes) {
allSourceClasses.add(className);
}
}
// Any classes not from sources become new base classes
manager.baseClasses.clear();
for (const className of currentClasses) {
if (!allSourceClasses.has(className)) {
manager.baseClasses.add(className);
}
}
updateElement(manager);
}
}
}
});
// Start observing the entire document for class attribute changes
globalObserver.observe(document, {
attributes: true,
attributeFilter: ['class'],
subtree: true, // Watch all descendants
});
}
}
function updateElement(manager: ElementClassManager): void {
if (manager.isUpdating) return; // Prevent recursive updates
manager.isUpdating = true;
// Handle initialization: capture base classes after first source registration
if (!manager.hasInitialized && manager.sources.size > 0) {
// Get current classes on element (may include SSR classes)
const currentClasses = toClassList(manager.element.className);
// Get all classes that will be applied by sources
const allSourceClasses = new Set<string>();
for (const source of manager.sources.values()) {
source.classes.forEach((className) => allSourceClasses.add(className));
}
// Only consider classes as "base" if they're not produced by any source
// This prevents SSR-rendered classes from being preserved as base classes
currentClasses.forEach((className) => {
if (!allSourceClasses.has(className)) {
manager.baseClasses.add(className);
}
});
manager.hasInitialized = true;
}
// Get classes from all sources, sorted by registration order (later takes precedence)
const sortedSources = Array.from(manager.sources.entries()).sort(([, a], [, b]) => a.order - b.order);
const allSourceClasses: string[] = [];
for (const [, source] of sortedSources) {
allSourceClasses.push(...source.classes);
}
// Combine base classes with all source classes, ensuring base classes take precedence
const classesToApply =
allSourceClasses.length > 0 || manager.baseClasses.size > 0
? hlm([...allSourceClasses, ...manager.baseClasses])
: '';
// Apply the classes to the element
if (manager.element.className !== classesToApply) {
manager.element.className = classesToApply;
}
manager.isUpdating = false;
}
function cleanupManager(element: HTMLElement): void {
// Remove from global tracking
observedElements.delete(element);
elementClassManagers.delete(element);
// If no more elements being tracked, cleanup global observer
if (observedElements.size === 0 && globalObserver) {
globalObserver.disconnect();
globalObserver = null;
}
}
interface ClassesOptions {
elementRef?: ElementRef<HTMLElement>;
injector?: Injector;
}
// Cache for parsed class lists to avoid repeated string operations
const classListCache = new Map<string, string[]>();
function toClassList(className: string | ClassValue[]): string[] {
// For simple string inputs, use cache to avoid repeated parsing
if (typeof className === 'string' && classListCache.has(className)) {
return classListCache.get(className)!;
}
const result = clsx(className)
.split(' ')
.filter((c) => c.length > 0);
// Cache string results, but limit cache size to prevent memory growth
if (typeof className === 'string' && classListCache.size < 1000) {
classListCache.set(className, result);
}
return result;
}
/**
* Provides default configuration for Spartan Helm components.
*
* This utility configures the Angular CDK overlay to disable the `usePopover`
* behavior introduced in Angular 21, which causes CDK overlay-based components
* (sheets, dialogs, tooltips, etc.) to render above `position: fixed` elements
* like `<hlm-toaster>`.
*
* @returns {EnvironmentProviders} Environment providers to be added to the application config.
*
* @example
* ```ts
* // app.config.ts
*
*
* export const appConfig: ApplicationConfig = {
* providers: [
* provideSpartanHlm(),
* // ... other providers
* ],
* };
* ```
*/
export function provideSpartanHlm(): EnvironmentProviders {
return makeEnvironmentProviders([
{
provide: OVERLAY_DEFAULT_CONFIG,
useValue: { usePopover: false },
},
]);
}import { BooleanInput, type BooleanInput, type NumberInput } from '@angular/cdk/coercion';
import { BrnFieldControl, BrnFieldControlDescribedBy, provideBrnLabelable } from '@spartan-ng/brain/field';
import { BrnOverlay, type BrnOverlayState } from '@spartan-ng/brain/overlay';
import { BrnPopover, type BrnPopover } from '@spartan-ng/brain/popover';
import { ButtonVariants, HlmButtonImports } from '@spartan-ng/helm/button';
import { ChangeDetectionStrategy, Component, Directive, ElementRef, InjectionToken, booleanAttribute, computed, contentChild, effect, forwardRef, inject, input, linkedSignal, numberAttribute, output, signal, untracked, viewChild, type ExistingProvider, type Signal, type Type, type ValueProvider } from '@angular/core';
import { ClassValue } from 'clsx';
import { HlmCalendar, HlmCalendarMulti, HlmCalendarRange } from '@spartan-ng/helm/calendar';
import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
import { HlmPopoverImports, HlmPopoverTrigger } from '@spartan-ng/helm/popover';
import { NG_VALUE_ACCESSOR, type ControlValueAccessor } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { hlm } from '@spartan-ng/helm/utils';
import { lucideCalendar, lucideChevronDown, lucideX } from '@ng-icons/lucide';
import { type ChangeFn, type TouchFn } from '@spartan-ng/brain/forms';
@Directive({ selector: '[hlmDatePickerAnchor]' })
export class HlmDatePickerAnchor {
private readonly _host = inject(ElementRef, { host: true });
private readonly _brnOverlay = inject(BrnOverlay, { optional: true });
public readonly hlmDatePickerAnchorFor = input<BrnPopover | undefined>(undefined, {
alias: 'hlmDatePickerAnchorFor',
});
constructor() {
effect(() => {
this.hlmDatePickerAnchorFor()?.setOrigin(this._host.nativeElement);
});
this._brnOverlay?.setOrigin(this._host.nativeElement);
}
}
@Component({
selector: 'hlm-date-picker-input',
imports: [HlmInputGroupImports, HlmButtonImports, HlmDatePickerAnchor, NgIcon],
providers: [provideIcons({ lucideCalendar, lucideX }), provideHlmDatePickerTrigger(HlmDatePickerInput)],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-input-group hlmDatePickerAnchor [hlmDatePickerAnchorFor]="_popover()">
<input
hlmInputGroupInput
[value]="_inputValue()"
[id]="inputId()"
[placeholder]="placeholder()"
[disabled]="_disabled()"
[forceInvalid]="forceInvalid()"
(click)="_handleClick()"
(keydown.arrowDown)="_open()"
(keydown.enter)="_handleEnter($event)"
(input)="_handleInputChange($event)"
(blur)="_commitDate()"
/>
<hlm-input-group-addon align="inline-end">
@if (_showClearButton()) {
<button
hlmInputGroupButton
size="icon-xs"
variant="ghost"
[attr.aria-label]="clearAriaLabel()"
(click)="_clear()"
[disabled]="_disabled()"
>
<ng-icon name="lucideX" />
</button>
}
<button
hlmInputGroupButton
size="icon-xs"
[attr.aria-label]="calendarAriaLabel()"
(click)="_popover().open()"
[disabled]="_disabled()"
>
<ng-icon name="lucideCalendar" />
</button>
</hlm-input-group-addon>
</hlm-input-group>
`,
})
export class HlmDatePickerInput<T> implements HlmDatePickerTriggerBase {
private static _nextId = 0;
private readonly _datePicker = injectHlmDatePicker<T>();
private readonly _config = injectHlmDatePickerConfig<T>();
protected readonly _popover = this._datePicker.popover;
protected readonly _disabled = this._datePicker.disabledState;
public readonly inputId = input(`hlm-date-picker-input-${HlmDatePickerInput._nextId++}`);
public readonly placeholder = input('');
public readonly inputValue = input<string>('');
/**
* Parses input text into a date value. Return `undefined` for invalid
* input - the picker's date is cleared while the text is preserved so
* the user can fix it.
*
* Defaults to `parseDate` from `HlmDatePickerConfig`.
*/
public readonly parseDate = input<(value: string) => T | undefined>(this._config.parseDate);
public readonly forceInvalid = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
/** Show a clear button that resets the input and picker date. Hidden when empty. */
public readonly showClear = input<boolean, BooleanInput>(true, { transform: booleanAttribute });
/** Open the popover on input click. */
public readonly openOnClick = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
/** Accessible label for the clear button. */
public readonly clearAriaLabel = input<string>('Clear date');
/** Accessible label for the calendar trigger button. */
public readonly calendarAriaLabel = input<string>('Open calendar');
/** @internal Id used by the trigger contract for labeling. */
public readonly triggerId = this.inputId;
/**
* Text shown in the input. Mirrors the picker's `formattedDate` and the
* parent's `inputValue`, and accepts user writes via `_handleInputChange`.
* Commits only happen on blur / Enter, so in-progress text isn't clobbered.
*/
protected readonly _inputValue = linkedSignal<{ formatted: string | undefined; inputValue: string }, string>({
source: () => ({
formatted: this._datePicker.formattedDate(),
inputValue: this.inputValue(),
}),
computation: (source, previous) => {
// First render: prefer formatted, fall back to inputValue.
if (previous === undefined) {
return source.formatted ?? source.inputValue;
}
// Picker's formatted date changed - snap to canonical format.
if (source.formatted !== previous.source.formatted) {
if (source.formatted !== undefined) {
return source.formatted;
}
// Cleared externally vs. user has invalid text in flight: only
// mirror the clear when the displayed text was in sync.
return previous.value === previous.source.formatted ? '' : previous.value;
}
// Parent updated inputValue - reflect it.
if (source.inputValue !== previous.source.inputValue) {
return source.inputValue;
}
return previous.value;
},
});
protected _handleInputChange(event: Event) {
const text = (event.target as HTMLInputElement).value;
this._inputValue.set(text);
}
protected readonly _showClearButton = computed(() => this.showClear() && this._inputValue().length > 0);
protected _clear() {
this._inputValue.set('');
this._datePicker.updateDate?.(undefined);
this._datePicker.touched?.();
}
protected _handleEnter(event: Event) {
event.preventDefault();
this._commitDate();
this._popover().close();
}
protected _commitDate() {
const value = this._inputValue();
if (!value) {
this._datePicker.updateDate?.(undefined);
this._datePicker.touched?.();
return;
}
// Invalid parse: clear the picker date, keep the text so the user can fix it.
const parsed = this.parseDate()(value);
this._datePicker.updateDate?.(parsed ?? undefined);
this._datePicker.touched?.();
}
protected _open() {
this._popover().open();
}
protected _handleClick() {
if (this.openOnClick()) {
this._open();
}
}
}
export interface HlmDatePickerMultiConfig<T> {
/**
* If true, the date picker will close when the max selection of dates is reached.
*/
autoCloseOnMaxSelection: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param dates
* @returns formatted date
*/
formatDates: (dates: T[]) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param dates
* @returns transformed date
*/
transformDates: (dates: T[]) => T[];
}
function getDefaultConfig<T>(): HlmDatePickerMultiConfig<T> {
return {
formatDates: (dates) => dates.map((date) => (date instanceof Date ? date.toDateString() : `${date}`)).join(', '),
transformDates: (dates) => dates,
autoCloseOnMaxSelection: false,
};
}
const HlmDatePickerMultiConfigToken = new InjectionToken<HlmDatePickerMultiConfig<unknown>>('HlmDatePickerMultiConfig');
export function provideHlmDatePickerMultiConfig<T>(config: Partial<HlmDatePickerMultiConfig<T>>): ValueProvider {
return { provide: HlmDatePickerMultiConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDatePickerMultiConfig<T>(): HlmDatePickerMultiConfig<T> {
const injectedConfig = inject(HlmDatePickerMultiConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDatePickerMultiConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_PICKER_MUTLI_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDatePickerMulti),
multi: true,
};
@Component({
selector: 'hlm-date-picker-multi',
imports: [HlmPopoverImports, HlmCalendarMulti],
providers: [
HLM_DATE_PICKER_MUTLI_VALUE_ACCESSOR,
provideHlmDatePicker(HlmDatePickerMulti),
provideBrnLabelable(HlmDatePickerMulti),
],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar-multi
class="rounded-none border-0"
[date]="_mutableDate()"
[captionLayout]="captionLayout()"
[min]="min()"
[max]="max()"
[minSelection]="minSelection()"
[maxSelection]="maxSelection()"
[disabled]="_disabled()"
(dateChange)="_handleChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDatePickerMulti<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDatePickerMultiConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** The minimum selectable dates. */
public readonly minSelection = input<number, NumberInput>(undefined, {
transform: numberAttribute,
});
/** The maximum selectable dates. */
public readonly maxSelection = input<number, NumberInput>(undefined, {
transform: numberAttribute,
});
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<T[]>();
protected readonly _mutableDate = linkedSignal(this.date);
/** If true, the date picker will close when the max selection of dates is reached. */
public readonly autoCloseOnMaxSelection = input<boolean, BooleanInput>(this._config.autoCloseOnMaxSelection, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDates = input<(date: T[]) => string>(this._config.formatDates);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDates = input<(date: T[]) => T[]>(this._config.transformDates);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const dates = this._mutableDate();
return dates ? this.formatDates()(dates) : undefined;
});
public readonly dateChange = output<T[]>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._mutableDate()?.length);
protected _onChange?: ChangeFn<T[]>;
protected _onTouched?: TouchFn;
protected _handleChange(value: T[] | undefined) {
if (value === undefined) return;
if (this._disabled()) return;
const transformedDate = value !== undefined ? this.transformDates()(value) : value;
this._mutableDate.set(transformedDate);
this._onChange?.(transformedDate);
this.dateChange.emit(transformedDate);
if (this.autoCloseOnMaxSelection() && this._mutableDate()?.length === this.maxSelection()) {
this._popoverState.set('closed');
}
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: T[] | null): void {
this._mutableDate.set(value ? this.transformDates()(value) : undefined);
}
public registerOnChange(fn: ChangeFn<T[]>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._onChange?.([]);
this.dateChange.emit([]);
}
}
export interface HlmDatePickerTriggerBase {
triggerId: Signal<string>;
}
export const HlmDatePickerTriggerToken = new InjectionToken<HlmDatePickerTriggerBase>('HlmDatePickerTriggerToken');
export function provideHlmDatePickerTrigger(instance: Type<HlmDatePickerTriggerBase>): ExistingProvider {
return { provide: HlmDatePickerTriggerToken, useExisting: instance };
}
@Component({
selector: 'hlm-date-picker-trigger',
imports: [HlmButtonImports, HlmPopoverTrigger, NgIcon, BrnFieldControlDescribedBy],
providers: [provideIcons({ lucideChevronDown }), provideHlmDatePickerTrigger(HlmDatePickerTrigger)],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { 'data-slot': 'date-picker-trigger' },
template: `
<button
[id]="buttonId()"
type="button"
[class]="_computedClass()"
[disabled]="_disabled()"
[attr.aria-invalid]="_ariaInvalid()"
[attr.data-invalid]="_ariaInvalid()"
[attr.data-touched]="_touched?.() ? 'true' : null"
[attr.data-dirty]="_dirty?.() ? 'true' : null"
[attr.data-matches-spartan-invalid]="_spartanInvalid() ? 'true' : null"
hlmBtn
[variant]="variant()"
hlmPopoverTrigger
[hlmPopoverTriggerFor]="_popover()"
brnFieldControlDescribedBy
[attr.data-placeholder]="_isPlaceholder() ? '' : null"
>
<span class="truncate">
@if (_formattedDate(); as formattedDate) {
{{ formattedDate }}
} @else {
<ng-content />
}
</span>
@if (showTrigger()) {
<ng-icon name="lucideChevronDown" />
}
</button>
`,
})
export class HlmDatePickerTrigger implements HlmDatePickerTriggerBase {
private static _nextId = 0;
private readonly _fieldControl = inject(BrnFieldControl, { optional: true });
private readonly _datePicker = injectHlmDatePicker();
private readonly _invalid = this._fieldControl?.invalid;
protected readonly _spartanInvalid = computed(() => this.forceInvalid() || this._fieldControl?.spartanInvalid());
protected readonly _dirty = this._fieldControl?.dirty;
protected readonly _touched = this._fieldControl?.touched;
protected readonly _ariaInvalid = computed(() => (this._invalid?.() ? 'true' : null));
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm('data-placeholder:text-muted-foreground w-64 justify-between', this.userClass()),
);
protected readonly _isPlaceholder = computed(() => !this._datePicker.hasDate());
/** The id of the button that opens the date picker. */
public readonly buttonId = input<string>(`hlm-date-picker-${++HlmDatePickerTrigger._nextId}`);
/** @internal The id of the button that opens the date picker, used for labeling. */
public readonly triggerId = this.buttonId;
/** Forces the invalid state visually, regardless of form control state. */
public readonly forceInvalid = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly showTrigger = input<boolean, BooleanInput>(true, { transform: booleanAttribute });
protected readonly _popover = this._datePicker.popover;
protected readonly _disabled = this._datePicker.disabledState;
protected readonly _formattedDate = this._datePicker.formattedDate;
}
export interface HlmDatePickerBase<T> {
popover: Signal<BrnPopover>;
disabledState: Signal<boolean>;
formattedDate: Signal<string | undefined>;
hasDate: Signal<boolean>;
/** Commit a date to the picker (e.g. from a parsed input). Pass `undefined` to clear. Optional. */
updateDate?(value: T | undefined): void;
// used for ControlValueAccessor
touched?(): void;
}
export const HlmDatePickerToken = new InjectionToken<HlmDatePickerBase<unknown>>('HlmDatePickerToken');
export function provideHlmDatePicker(instance: Type<HlmDatePickerBase<unknown>>): ExistingProvider {
return { provide: HlmDatePickerToken, useExisting: instance };
}
/**
* Inject the date picker component.
*/
export function injectHlmDatePicker<T>(): HlmDatePickerBase<T> {
return inject(HlmDatePickerToken) as HlmDatePickerBase<T>;
}
export interface HlmDatePickerConfig<T> {
/**
* If true, the date picker will close when a date is selected.
*/
autoCloseOnSelect: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param date
* @returns formatted date
*/
formatDate: (date: T) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param date
* @returns transformed date
*/
transformDate: (date: T) => T;
/**
* Parse a user-entered string into a date.
*
* @param value the raw string from the input
* @returns the parsed date, or `undefined` when the value can't be parsed
*/
parseDate: (value: string) => T | undefined;
}
function getDefaultConfig<T>(): HlmDatePickerConfig<T> {
return {
formatDate: (date) => (date instanceof Date ? date.toDateString() : `${date}`),
transformDate: (date) => date,
parseDate: (value) => {
const date = new Date(value);
return isNaN(date.getTime()) ? undefined : (date as T);
},
autoCloseOnSelect: false,
};
}
const HlmDatePickerConfigToken = new InjectionToken<HlmDatePickerConfig<unknown>>('HlmDatePickerConfig');
export function provideHlmDatePickerConfig<T>(config: Partial<HlmDatePickerConfig<T>>): ValueProvider {
return { provide: HlmDatePickerConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDatePickerConfig<T>(): HlmDatePickerConfig<T> {
const injectedConfig = inject(HlmDatePickerConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDatePickerConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_PICKER_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDatePicker),
multi: true,
};
@Component({
selector: 'hlm-date-picker',
imports: [HlmPopoverImports, HlmCalendar],
providers: [HLM_DATE_PICKER_VALUE_ACCESSOR, provideHlmDatePicker(HlmDatePicker), provideBrnLabelable(HlmDatePicker)],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar
class="rounded-none border-0"
[captionLayout]="captionLayout()"
[date]="_mutableDate()"
[defaultFocusedDate]="_mutableDate() ?? defaultFocusedDate()"
[min]="min()"
[max]="max()"
[disabled]="_disabled()"
(dateChange)="_handleChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDatePicker<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDatePickerConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<T>();
/** The date the calendar focuses on first open when no date is selected. */
public readonly defaultFocusedDate = input<T>();
protected readonly _mutableDate = linkedSignal(this.date);
/** If true, the date picker will close when a date is selected. */
public readonly autoCloseOnSelect = input<boolean, BooleanInput>(this._config.autoCloseOnSelect, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDate = input<(date: T) => string>(this._config.formatDate);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDate = input<(date: T) => T>(this._config.transformDate);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const date = this._mutableDate();
return date ? this.formatDate()(date) : undefined;
});
public readonly dateChange = output<T>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._mutableDate());
protected _onChange?: ChangeFn<T>;
protected _onTouched?: TouchFn;
protected _handleChange(value: T | undefined) {
if (this._disabled()) return;
this.updateDate(value);
if (this.autoCloseOnSelect()) {
this._popoverState.set('closed');
}
}
/**
* Commit a date to the picker. Updates the internal model, notifies form
* controls, and emits `dateChange`. Unlike `_handleChange`, this does not
* close the popover - it's intended to be called from a text input that
* is parsing user-entered values while typing.
*/
public updateDate(value: T | undefined) {
if (this._disabled()) return;
const transformedDate = value !== undefined ? this.transformDate()(value) : undefined;
this._mutableDate.set(transformedDate);
this._onChange?.(transformedDate as T);
this.dateChange.emit(transformedDate as T);
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: T | null): void {
this._mutableDate.set(value ? this.transformDate()(value) : undefined);
}
public registerOnChange(fn: ChangeFn<T>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public touched(): void {
this._onTouched?.();
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._onChange?.(undefined as T);
this.dateChange.emit(undefined as T);
}
}
export interface HlmDateRangePickerConfig<T> {
/**
* If true, the date picker will close when the max selection of dates is reached.
*/
autoCloseOnEndSelection: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param dates
* @returns formatted date
*/
formatDates: (dates: [T | undefined, T | undefined]) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param dates
* @returns transformed date
*/
transformDates: (dates: [T, T]) => [T, T];
}
function getDefaultConfig<T>(): HlmDateRangePickerConfig<T> {
return {
formatDates: (dates) =>
dates
.filter(Boolean)
.map((date) => (date instanceof Date ? date.toDateString() : `${date}`))
.join(' - '),
transformDates: (dates) => dates,
autoCloseOnEndSelection: false,
};
}
const HlmDateRangePickerConfigToken = new InjectionToken<HlmDateRangePickerConfig<unknown>>('HlmDateRangePickerConfig');
export function provideHlmDateRangePickerConfig<T>(config: Partial<HlmDateRangePickerConfig<T>>): ValueProvider {
return { provide: HlmDateRangePickerConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDateRangePickerConfig<T>(): HlmDateRangePickerConfig<T> {
const injectedConfig = inject(HlmDateRangePickerConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDateRangePickerConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDateRangePicker),
multi: true,
};
@Component({
selector: 'hlm-date-range-picker',
imports: [HlmPopoverImports, HlmCalendarRange],
providers: [
HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR,
provideHlmDatePicker(HlmDateRangePicker),
provideBrnLabelable(HlmDateRangePicker),
],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onClose(); _onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar-range
class="rounded-none border-0"
[startDate]="_start()"
[captionLayout]="captionLayout()"
[endDate]="_end()"
[min]="min()"
[max]="max()"
[disabled]="_disabled()"
(startDateChange)="_handleStartDayChange($event)"
(endDateChange)="_handleEndDateChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDateRangePicker<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDateRangePickerConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<[T, T]>();
protected readonly _mutableDate = linkedSignal(this.date);
protected readonly _start = linkedSignal(() => this._mutableDate()?.[0]);
protected readonly _end = linkedSignal(() => this._mutableDate()?.[1]);
/** If true, the date picker will close when the end date is selected */
public readonly autoCloseOnEndSelection = input<boolean, BooleanInput>(this._config.autoCloseOnEndSelection, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDates = input<(dates: [T | undefined, T | undefined]) => string>(this._config.formatDates);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDates = input<(date: [T, T]) => [T, T]>(this._config.transformDates);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const start = this._start();
const end = this._end();
return start || end ? this.formatDates()([start, end]) : undefined;
});
public readonly dateChange = output<[T, T] | null>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._start() || !!this._end());
protected _onChange?: ChangeFn<[T, T] | null>;
protected _onTouched?: TouchFn;
protected _handleStartDayChange(value: T | undefined) {
this._start.set(value);
}
protected _handleEndDateChange(value: T | undefined): void {
this._end.set(value);
if (this._disabled()) return;
const start = this._start();
if (start && value) {
const transformedDates = this.transformDates()([start, value]);
this._mutableDate.set(transformedDates);
this.dateChange.emit(transformedDates);
this._onChange?.(transformedDates);
if (this.autoCloseOnEndSelection()) {
this._popoverState.set('closed');
}
}
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: [T, T] | null): void {
untracked(() => {
if (!value) {
this._mutableDate.set(undefined);
} else {
this._mutableDate.set(this.transformDates()(value));
}
});
}
public registerOnChange(fn: ChangeFn<[T, T] | null>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._start.set(undefined);
this._end.set(undefined);
this._onChange?.(null);
this.dateChange.emit(null);
}
protected _onClose(): void {
const dates = this._mutableDate();
if (this._start() && !this._end() && dates) {
this._start.set(dates[0]);
this._end.set(dates[1]);
}
}
}
export const HlmDatePickerImports = [
HlmDatePicker,
HlmDatePickerAnchor,
HlmDatePickerInput,
HlmDatePickerMulti,
HlmDateRangePicker,
HlmDatePickerTrigger,
] as const;import { BooleanInput, type BooleanInput, type NumberInput } from '@angular/cdk/coercion';
import { BrnFieldControl, BrnFieldControlDescribedBy, provideBrnLabelable } from '@spartan-ng/brain/field';
import { BrnOverlay, type BrnOverlayState } from '@spartan-ng/brain/overlay';
import { BrnPopover, type BrnPopover } from '@spartan-ng/brain/popover';
import { ButtonVariants, HlmButtonImports } from '@spartan-ng/helm/button';
import { ChangeDetectionStrategy, Component, Directive, ElementRef, InjectionToken, booleanAttribute, computed, contentChild, effect, forwardRef, inject, input, linkedSignal, numberAttribute, output, signal, untracked, viewChild, type ExistingProvider, type Signal, type Type, type ValueProvider } from '@angular/core';
import { ClassValue } from 'clsx';
import { HlmCalendar, HlmCalendarMulti, HlmCalendarRange } from '@spartan-ng/helm/calendar';
import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
import { HlmPopoverImports, HlmPopoverTrigger } from '@spartan-ng/helm/popover';
import { NG_VALUE_ACCESSOR, type ControlValueAccessor } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { hlm } from '@spartan-ng/helm/utils';
import { lucideCalendar, lucideChevronDown, lucideX } from '@ng-icons/lucide';
import { type ChangeFn, type TouchFn } from '@spartan-ng/brain/forms';
@Directive({ selector: '[hlmDatePickerAnchor]' })
export class HlmDatePickerAnchor {
private readonly _host = inject(ElementRef, { host: true });
private readonly _brnOverlay = inject(BrnOverlay, { optional: true });
public readonly hlmDatePickerAnchorFor = input<BrnPopover | undefined>(undefined, {
alias: 'hlmDatePickerAnchorFor',
});
constructor() {
effect(() => {
this.hlmDatePickerAnchorFor()?.setOrigin(this._host.nativeElement);
});
this._brnOverlay?.setOrigin(this._host.nativeElement);
}
}
@Component({
selector: 'hlm-date-picker-input',
imports: [HlmInputGroupImports, HlmButtonImports, HlmDatePickerAnchor, NgIcon],
providers: [provideIcons({ lucideCalendar, lucideX }), provideHlmDatePickerTrigger(HlmDatePickerInput)],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-input-group hlmDatePickerAnchor [hlmDatePickerAnchorFor]="_popover()">
<input
hlmInputGroupInput
[value]="_inputValue()"
[id]="inputId()"
[placeholder]="placeholder()"
[disabled]="_disabled()"
[forceInvalid]="forceInvalid()"
(click)="_handleClick()"
(keydown.arrowDown)="_open()"
(keydown.enter)="_handleEnter($event)"
(input)="_handleInputChange($event)"
(blur)="_commitDate()"
/>
<hlm-input-group-addon align="inline-end">
@if (_showClearButton()) {
<button
hlmInputGroupButton
size="icon-xs"
variant="ghost"
[attr.aria-label]="clearAriaLabel()"
(click)="_clear()"
[disabled]="_disabled()"
>
<ng-icon name="lucideX" />
</button>
}
<button
hlmInputGroupButton
size="icon-xs"
[attr.aria-label]="calendarAriaLabel()"
(click)="_popover().open()"
[disabled]="_disabled()"
>
<ng-icon name="lucideCalendar" />
</button>
</hlm-input-group-addon>
</hlm-input-group>
`,
})
export class HlmDatePickerInput<T> implements HlmDatePickerTriggerBase {
private static _nextId = 0;
private readonly _datePicker = injectHlmDatePicker<T>();
private readonly _config = injectHlmDatePickerConfig<T>();
protected readonly _popover = this._datePicker.popover;
protected readonly _disabled = this._datePicker.disabledState;
public readonly inputId = input(`hlm-date-picker-input-${HlmDatePickerInput._nextId++}`);
public readonly placeholder = input('');
public readonly inputValue = input<string>('');
/**
* Parses input text into a date value. Return `undefined` for invalid
* input - the picker's date is cleared while the text is preserved so
* the user can fix it.
*
* Defaults to `parseDate` from `HlmDatePickerConfig`.
*/
public readonly parseDate = input<(value: string) => T | undefined>(this._config.parseDate);
public readonly forceInvalid = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
/** Show a clear button that resets the input and picker date. Hidden when empty. */
public readonly showClear = input<boolean, BooleanInput>(true, { transform: booleanAttribute });
/** Open the popover on input click. */
public readonly openOnClick = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
/** Accessible label for the clear button. */
public readonly clearAriaLabel = input<string>('Clear date');
/** Accessible label for the calendar trigger button. */
public readonly calendarAriaLabel = input<string>('Open calendar');
/** @internal Id used by the trigger contract for labeling. */
public readonly triggerId = this.inputId;
/**
* Text shown in the input. Mirrors the picker's `formattedDate` and the
* parent's `inputValue`, and accepts user writes via `_handleInputChange`.
* Commits only happen on blur / Enter, so in-progress text isn't clobbered.
*/
protected readonly _inputValue = linkedSignal<{ formatted: string | undefined; inputValue: string }, string>({
source: () => ({
formatted: this._datePicker.formattedDate(),
inputValue: this.inputValue(),
}),
computation: (source, previous) => {
// First render: prefer formatted, fall back to inputValue.
if (previous === undefined) {
return source.formatted ?? source.inputValue;
}
// Picker's formatted date changed - snap to canonical format.
if (source.formatted !== previous.source.formatted) {
if (source.formatted !== undefined) {
return source.formatted;
}
// Cleared externally vs. user has invalid text in flight: only
// mirror the clear when the displayed text was in sync.
return previous.value === previous.source.formatted ? '' : previous.value;
}
// Parent updated inputValue - reflect it.
if (source.inputValue !== previous.source.inputValue) {
return source.inputValue;
}
return previous.value;
},
});
protected _handleInputChange(event: Event) {
const text = (event.target as HTMLInputElement).value;
this._inputValue.set(text);
}
protected readonly _showClearButton = computed(() => this.showClear() && this._inputValue().length > 0);
protected _clear() {
this._inputValue.set('');
this._datePicker.updateDate?.(undefined);
this._datePicker.touched?.();
}
protected _handleEnter(event: Event) {
event.preventDefault();
this._commitDate();
this._popover().close();
}
protected _commitDate() {
const value = this._inputValue();
if (!value) {
this._datePicker.updateDate?.(undefined);
this._datePicker.touched?.();
return;
}
// Invalid parse: clear the picker date, keep the text so the user can fix it.
const parsed = this.parseDate()(value);
this._datePicker.updateDate?.(parsed ?? undefined);
this._datePicker.touched?.();
}
protected _open() {
this._popover().open();
}
protected _handleClick() {
if (this.openOnClick()) {
this._open();
}
}
}
export interface HlmDatePickerMultiConfig<T> {
/**
* If true, the date picker will close when the max selection of dates is reached.
*/
autoCloseOnMaxSelection: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param dates
* @returns formatted date
*/
formatDates: (dates: T[]) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param dates
* @returns transformed date
*/
transformDates: (dates: T[]) => T[];
}
function getDefaultConfig<T>(): HlmDatePickerMultiConfig<T> {
return {
formatDates: (dates) => dates.map((date) => (date instanceof Date ? date.toDateString() : `${date}`)).join(', '),
transformDates: (dates) => dates,
autoCloseOnMaxSelection: false,
};
}
const HlmDatePickerMultiConfigToken = new InjectionToken<HlmDatePickerMultiConfig<unknown>>('HlmDatePickerMultiConfig');
export function provideHlmDatePickerMultiConfig<T>(config: Partial<HlmDatePickerMultiConfig<T>>): ValueProvider {
return { provide: HlmDatePickerMultiConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDatePickerMultiConfig<T>(): HlmDatePickerMultiConfig<T> {
const injectedConfig = inject(HlmDatePickerMultiConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDatePickerMultiConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_PICKER_MUTLI_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDatePickerMulti),
multi: true,
};
@Component({
selector: 'hlm-date-picker-multi',
imports: [HlmPopoverImports, HlmCalendarMulti],
providers: [
HLM_DATE_PICKER_MUTLI_VALUE_ACCESSOR,
provideHlmDatePicker(HlmDatePickerMulti),
provideBrnLabelable(HlmDatePickerMulti),
],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar-multi
class="rounded-none border-0"
[date]="_mutableDate()"
[captionLayout]="captionLayout()"
[min]="min()"
[max]="max()"
[minSelection]="minSelection()"
[maxSelection]="maxSelection()"
[disabled]="_disabled()"
(dateChange)="_handleChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDatePickerMulti<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDatePickerMultiConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** The minimum selectable dates. */
public readonly minSelection = input<number, NumberInput>(undefined, {
transform: numberAttribute,
});
/** The maximum selectable dates. */
public readonly maxSelection = input<number, NumberInput>(undefined, {
transform: numberAttribute,
});
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<T[]>();
protected readonly _mutableDate = linkedSignal(this.date);
/** If true, the date picker will close when the max selection of dates is reached. */
public readonly autoCloseOnMaxSelection = input<boolean, BooleanInput>(this._config.autoCloseOnMaxSelection, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDates = input<(date: T[]) => string>(this._config.formatDates);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDates = input<(date: T[]) => T[]>(this._config.transformDates);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const dates = this._mutableDate();
return dates ? this.formatDates()(dates) : undefined;
});
public readonly dateChange = output<T[]>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._mutableDate()?.length);
protected _onChange?: ChangeFn<T[]>;
protected _onTouched?: TouchFn;
protected _handleChange(value: T[] | undefined) {
if (value === undefined) return;
if (this._disabled()) return;
const transformedDate = value !== undefined ? this.transformDates()(value) : value;
this._mutableDate.set(transformedDate);
this._onChange?.(transformedDate);
this.dateChange.emit(transformedDate);
if (this.autoCloseOnMaxSelection() && this._mutableDate()?.length === this.maxSelection()) {
this._popoverState.set('closed');
}
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: T[] | null): void {
this._mutableDate.set(value ? this.transformDates()(value) : undefined);
}
public registerOnChange(fn: ChangeFn<T[]>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._onChange?.([]);
this.dateChange.emit([]);
}
}
export interface HlmDatePickerTriggerBase {
triggerId: Signal<string>;
}
export const HlmDatePickerTriggerToken = new InjectionToken<HlmDatePickerTriggerBase>('HlmDatePickerTriggerToken');
export function provideHlmDatePickerTrigger(instance: Type<HlmDatePickerTriggerBase>): ExistingProvider {
return { provide: HlmDatePickerTriggerToken, useExisting: instance };
}
@Component({
selector: 'hlm-date-picker-trigger',
imports: [HlmButtonImports, HlmPopoverTrigger, NgIcon, BrnFieldControlDescribedBy],
providers: [provideIcons({ lucideChevronDown }), provideHlmDatePickerTrigger(HlmDatePickerTrigger)],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { 'data-slot': 'date-picker-trigger' },
template: `
<button
[id]="buttonId()"
type="button"
[class]="_computedClass()"
[disabled]="_disabled()"
[attr.aria-invalid]="_ariaInvalid()"
[attr.data-invalid]="_ariaInvalid()"
[attr.data-touched]="_touched?.() ? 'true' : null"
[attr.data-dirty]="_dirty?.() ? 'true' : null"
[attr.data-matches-spartan-invalid]="_spartanInvalid() ? 'true' : null"
hlmBtn
[variant]="variant()"
hlmPopoverTrigger
[hlmPopoverTriggerFor]="_popover()"
brnFieldControlDescribedBy
[attr.data-placeholder]="_isPlaceholder() ? '' : null"
>
<span class="truncate">
@if (_formattedDate(); as formattedDate) {
{{ formattedDate }}
} @else {
<ng-content />
}
</span>
@if (showTrigger()) {
<ng-icon name="lucideChevronDown" />
}
</button>
`,
})
export class HlmDatePickerTrigger implements HlmDatePickerTriggerBase {
private static _nextId = 0;
private readonly _fieldControl = inject(BrnFieldControl, { optional: true });
private readonly _datePicker = injectHlmDatePicker();
private readonly _invalid = this._fieldControl?.invalid;
protected readonly _spartanInvalid = computed(() => this.forceInvalid() || this._fieldControl?.spartanInvalid());
protected readonly _dirty = this._fieldControl?.dirty;
protected readonly _touched = this._fieldControl?.touched;
protected readonly _ariaInvalid = computed(() => (this._invalid?.() ? 'true' : null));
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm('data-placeholder:text-muted-foreground w-64 justify-between', this.userClass()),
);
protected readonly _isPlaceholder = computed(() => !this._datePicker.hasDate());
/** The id of the button that opens the date picker. */
public readonly buttonId = input<string>(`hlm-date-picker-${++HlmDatePickerTrigger._nextId}`);
/** @internal The id of the button that opens the date picker, used for labeling. */
public readonly triggerId = this.buttonId;
/** Forces the invalid state visually, regardless of form control state. */
public readonly forceInvalid = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly showTrigger = input<boolean, BooleanInput>(true, { transform: booleanAttribute });
protected readonly _popover = this._datePicker.popover;
protected readonly _disabled = this._datePicker.disabledState;
protected readonly _formattedDate = this._datePicker.formattedDate;
}
export interface HlmDatePickerBase<T> {
popover: Signal<BrnPopover>;
disabledState: Signal<boolean>;
formattedDate: Signal<string | undefined>;
hasDate: Signal<boolean>;
/** Commit a date to the picker (e.g. from a parsed input). Pass `undefined` to clear. Optional. */
updateDate?(value: T | undefined): void;
// used for ControlValueAccessor
touched?(): void;
}
export const HlmDatePickerToken = new InjectionToken<HlmDatePickerBase<unknown>>('HlmDatePickerToken');
export function provideHlmDatePicker(instance: Type<HlmDatePickerBase<unknown>>): ExistingProvider {
return { provide: HlmDatePickerToken, useExisting: instance };
}
/**
* Inject the date picker component.
*/
export function injectHlmDatePicker<T>(): HlmDatePickerBase<T> {
return inject(HlmDatePickerToken) as HlmDatePickerBase<T>;
}
export interface HlmDatePickerConfig<T> {
/**
* If true, the date picker will close when a date is selected.
*/
autoCloseOnSelect: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param date
* @returns formatted date
*/
formatDate: (date: T) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param date
* @returns transformed date
*/
transformDate: (date: T) => T;
/**
* Parse a user-entered string into a date.
*
* @param value the raw string from the input
* @returns the parsed date, or `undefined` when the value can't be parsed
*/
parseDate: (value: string) => T | undefined;
}
function getDefaultConfig<T>(): HlmDatePickerConfig<T> {
return {
formatDate: (date) => (date instanceof Date ? date.toDateString() : `${date}`),
transformDate: (date) => date,
parseDate: (value) => {
const date = new Date(value);
return isNaN(date.getTime()) ? undefined : (date as T);
},
autoCloseOnSelect: false,
};
}
const HlmDatePickerConfigToken = new InjectionToken<HlmDatePickerConfig<unknown>>('HlmDatePickerConfig');
export function provideHlmDatePickerConfig<T>(config: Partial<HlmDatePickerConfig<T>>): ValueProvider {
return { provide: HlmDatePickerConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDatePickerConfig<T>(): HlmDatePickerConfig<T> {
const injectedConfig = inject(HlmDatePickerConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDatePickerConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_PICKER_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDatePicker),
multi: true,
};
@Component({
selector: 'hlm-date-picker',
imports: [HlmPopoverImports, HlmCalendar],
providers: [HLM_DATE_PICKER_VALUE_ACCESSOR, provideHlmDatePicker(HlmDatePicker), provideBrnLabelable(HlmDatePicker)],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar
class="rounded-none border-0"
[captionLayout]="captionLayout()"
[date]="_mutableDate()"
[defaultFocusedDate]="_mutableDate() ?? defaultFocusedDate()"
[min]="min()"
[max]="max()"
[disabled]="_disabled()"
(dateChange)="_handleChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDatePicker<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDatePickerConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<T>();
/** The date the calendar focuses on first open when no date is selected. */
public readonly defaultFocusedDate = input<T>();
protected readonly _mutableDate = linkedSignal(this.date);
/** If true, the date picker will close when a date is selected. */
public readonly autoCloseOnSelect = input<boolean, BooleanInput>(this._config.autoCloseOnSelect, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDate = input<(date: T) => string>(this._config.formatDate);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDate = input<(date: T) => T>(this._config.transformDate);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const date = this._mutableDate();
return date ? this.formatDate()(date) : undefined;
});
public readonly dateChange = output<T>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._mutableDate());
protected _onChange?: ChangeFn<T>;
protected _onTouched?: TouchFn;
protected _handleChange(value: T | undefined) {
if (this._disabled()) return;
this.updateDate(value);
if (this.autoCloseOnSelect()) {
this._popoverState.set('closed');
}
}
/**
* Commit a date to the picker. Updates the internal model, notifies form
* controls, and emits `dateChange`. Unlike `_handleChange`, this does not
* close the popover - it's intended to be called from a text input that
* is parsing user-entered values while typing.
*/
public updateDate(value: T | undefined) {
if (this._disabled()) return;
const transformedDate = value !== undefined ? this.transformDate()(value) : undefined;
this._mutableDate.set(transformedDate);
this._onChange?.(transformedDate as T);
this.dateChange.emit(transformedDate as T);
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: T | null): void {
this._mutableDate.set(value ? this.transformDate()(value) : undefined);
}
public registerOnChange(fn: ChangeFn<T>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public touched(): void {
this._onTouched?.();
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._onChange?.(undefined as T);
this.dateChange.emit(undefined as T);
}
}
export interface HlmDateRangePickerConfig<T> {
/**
* If true, the date picker will close when the max selection of dates is reached.
*/
autoCloseOnEndSelection: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param dates
* @returns formatted date
*/
formatDates: (dates: [T | undefined, T | undefined]) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param dates
* @returns transformed date
*/
transformDates: (dates: [T, T]) => [T, T];
}
function getDefaultConfig<T>(): HlmDateRangePickerConfig<T> {
return {
formatDates: (dates) =>
dates
.filter(Boolean)
.map((date) => (date instanceof Date ? date.toDateString() : `${date}`))
.join(' - '),
transformDates: (dates) => dates,
autoCloseOnEndSelection: false,
};
}
const HlmDateRangePickerConfigToken = new InjectionToken<HlmDateRangePickerConfig<unknown>>('HlmDateRangePickerConfig');
export function provideHlmDateRangePickerConfig<T>(config: Partial<HlmDateRangePickerConfig<T>>): ValueProvider {
return { provide: HlmDateRangePickerConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDateRangePickerConfig<T>(): HlmDateRangePickerConfig<T> {
const injectedConfig = inject(HlmDateRangePickerConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDateRangePickerConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDateRangePicker),
multi: true,
};
@Component({
selector: 'hlm-date-range-picker',
imports: [HlmPopoverImports, HlmCalendarRange],
providers: [
HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR,
provideHlmDatePicker(HlmDateRangePicker),
provideBrnLabelable(HlmDateRangePicker),
],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onClose(); _onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar-range
class="rounded-none border-0"
[startDate]="_start()"
[captionLayout]="captionLayout()"
[endDate]="_end()"
[min]="min()"
[max]="max()"
[disabled]="_disabled()"
(startDateChange)="_handleStartDayChange($event)"
(endDateChange)="_handleEndDateChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDateRangePicker<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDateRangePickerConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<[T, T]>();
protected readonly _mutableDate = linkedSignal(this.date);
protected readonly _start = linkedSignal(() => this._mutableDate()?.[0]);
protected readonly _end = linkedSignal(() => this._mutableDate()?.[1]);
/** If true, the date picker will close when the end date is selected */
public readonly autoCloseOnEndSelection = input<boolean, BooleanInput>(this._config.autoCloseOnEndSelection, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDates = input<(dates: [T | undefined, T | undefined]) => string>(this._config.formatDates);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDates = input<(date: [T, T]) => [T, T]>(this._config.transformDates);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const start = this._start();
const end = this._end();
return start || end ? this.formatDates()([start, end]) : undefined;
});
public readonly dateChange = output<[T, T] | null>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._start() || !!this._end());
protected _onChange?: ChangeFn<[T, T] | null>;
protected _onTouched?: TouchFn;
protected _handleStartDayChange(value: T | undefined) {
this._start.set(value);
}
protected _handleEndDateChange(value: T | undefined): void {
this._end.set(value);
if (this._disabled()) return;
const start = this._start();
if (start && value) {
const transformedDates = this.transformDates()([start, value]);
this._mutableDate.set(transformedDates);
this.dateChange.emit(transformedDates);
this._onChange?.(transformedDates);
if (this.autoCloseOnEndSelection()) {
this._popoverState.set('closed');
}
}
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: [T, T] | null): void {
untracked(() => {
if (!value) {
this._mutableDate.set(undefined);
} else {
this._mutableDate.set(this.transformDates()(value));
}
});
}
public registerOnChange(fn: ChangeFn<[T, T] | null>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._start.set(undefined);
this._end.set(undefined);
this._onChange?.(null);
this.dateChange.emit(null);
}
protected _onClose(): void {
const dates = this._mutableDate();
if (this._start() && !this._end() && dates) {
this._start.set(dates[0]);
this._end.set(dates[1]);
}
}
}
export const HlmDatePickerImports = [
HlmDatePicker,
HlmDatePickerAnchor,
HlmDatePickerInput,
HlmDatePickerMulti,
HlmDateRangePicker,
HlmDatePickerTrigger,
] as const;import { BooleanInput, type BooleanInput, type NumberInput } from '@angular/cdk/coercion';
import { BrnFieldControl, BrnFieldControlDescribedBy, provideBrnLabelable } from '@spartan-ng/brain/field';
import { BrnOverlay, type BrnOverlayState } from '@spartan-ng/brain/overlay';
import { BrnPopover, type BrnPopover } from '@spartan-ng/brain/popover';
import { ButtonVariants, HlmButtonImports } from '@spartan-ng/helm/button';
import { ChangeDetectionStrategy, Component, Directive, ElementRef, InjectionToken, booleanAttribute, computed, contentChild, effect, forwardRef, inject, input, linkedSignal, numberAttribute, output, signal, untracked, viewChild, type ExistingProvider, type Signal, type Type, type ValueProvider } from '@angular/core';
import { ClassValue } from 'clsx';
import { HlmCalendar, HlmCalendarMulti, HlmCalendarRange } from '@spartan-ng/helm/calendar';
import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
import { HlmPopoverImports, HlmPopoverTrigger } from '@spartan-ng/helm/popover';
import { NG_VALUE_ACCESSOR, type ControlValueAccessor } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { hlm } from '@spartan-ng/helm/utils';
import { lucideCalendar, lucideChevronDown, lucideX } from '@ng-icons/lucide';
import { type ChangeFn, type TouchFn } from '@spartan-ng/brain/forms';
@Directive({ selector: '[hlmDatePickerAnchor]' })
export class HlmDatePickerAnchor {
private readonly _host = inject(ElementRef, { host: true });
private readonly _brnOverlay = inject(BrnOverlay, { optional: true });
public readonly hlmDatePickerAnchorFor = input<BrnPopover | undefined>(undefined, {
alias: 'hlmDatePickerAnchorFor',
});
constructor() {
effect(() => {
this.hlmDatePickerAnchorFor()?.setOrigin(this._host.nativeElement);
});
this._brnOverlay?.setOrigin(this._host.nativeElement);
}
}
@Component({
selector: 'hlm-date-picker-input',
imports: [HlmInputGroupImports, HlmButtonImports, HlmDatePickerAnchor, NgIcon],
providers: [provideIcons({ lucideCalendar, lucideX }), provideHlmDatePickerTrigger(HlmDatePickerInput)],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-input-group hlmDatePickerAnchor [hlmDatePickerAnchorFor]="_popover()">
<input
hlmInputGroupInput
[value]="_inputValue()"
[id]="inputId()"
[placeholder]="placeholder()"
[disabled]="_disabled()"
[forceInvalid]="forceInvalid()"
(click)="_handleClick()"
(keydown.arrowDown)="_open()"
(keydown.enter)="_handleEnter($event)"
(input)="_handleInputChange($event)"
(blur)="_commitDate()"
/>
<hlm-input-group-addon align="inline-end">
@if (_showClearButton()) {
<button
hlmInputGroupButton
size="icon-xs"
variant="ghost"
[attr.aria-label]="clearAriaLabel()"
(click)="_clear()"
[disabled]="_disabled()"
>
<ng-icon name="lucideX" />
</button>
}
<button
hlmInputGroupButton
size="icon-xs"
[attr.aria-label]="calendarAriaLabel()"
(click)="_popover().open()"
[disabled]="_disabled()"
>
<ng-icon name="lucideCalendar" />
</button>
</hlm-input-group-addon>
</hlm-input-group>
`,
})
export class HlmDatePickerInput<T> implements HlmDatePickerTriggerBase {
private static _nextId = 0;
private readonly _datePicker = injectHlmDatePicker<T>();
private readonly _config = injectHlmDatePickerConfig<T>();
protected readonly _popover = this._datePicker.popover;
protected readonly _disabled = this._datePicker.disabledState;
public readonly inputId = input(`hlm-date-picker-input-${HlmDatePickerInput._nextId++}`);
public readonly placeholder = input('');
public readonly inputValue = input<string>('');
/**
* Parses input text into a date value. Return `undefined` for invalid
* input - the picker's date is cleared while the text is preserved so
* the user can fix it.
*
* Defaults to `parseDate` from `HlmDatePickerConfig`.
*/
public readonly parseDate = input<(value: string) => T | undefined>(this._config.parseDate);
public readonly forceInvalid = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
/** Show a clear button that resets the input and picker date. Hidden when empty. */
public readonly showClear = input<boolean, BooleanInput>(true, { transform: booleanAttribute });
/** Open the popover on input click. */
public readonly openOnClick = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
/** Accessible label for the clear button. */
public readonly clearAriaLabel = input<string>('Clear date');
/** Accessible label for the calendar trigger button. */
public readonly calendarAriaLabel = input<string>('Open calendar');
/** @internal Id used by the trigger contract for labeling. */
public readonly triggerId = this.inputId;
/**
* Text shown in the input. Mirrors the picker's `formattedDate` and the
* parent's `inputValue`, and accepts user writes via `_handleInputChange`.
* Commits only happen on blur / Enter, so in-progress text isn't clobbered.
*/
protected readonly _inputValue = linkedSignal<{ formatted: string | undefined; inputValue: string }, string>({
source: () => ({
formatted: this._datePicker.formattedDate(),
inputValue: this.inputValue(),
}),
computation: (source, previous) => {
// First render: prefer formatted, fall back to inputValue.
if (previous === undefined) {
return source.formatted ?? source.inputValue;
}
// Picker's formatted date changed - snap to canonical format.
if (source.formatted !== previous.source.formatted) {
if (source.formatted !== undefined) {
return source.formatted;
}
// Cleared externally vs. user has invalid text in flight: only
// mirror the clear when the displayed text was in sync.
return previous.value === previous.source.formatted ? '' : previous.value;
}
// Parent updated inputValue - reflect it.
if (source.inputValue !== previous.source.inputValue) {
return source.inputValue;
}
return previous.value;
},
});
protected _handleInputChange(event: Event) {
const text = (event.target as HTMLInputElement).value;
this._inputValue.set(text);
}
protected readonly _showClearButton = computed(() => this.showClear() && this._inputValue().length > 0);
protected _clear() {
this._inputValue.set('');
this._datePicker.updateDate?.(undefined);
this._datePicker.touched?.();
}
protected _handleEnter(event: Event) {
event.preventDefault();
this._commitDate();
this._popover().close();
}
protected _commitDate() {
const value = this._inputValue();
if (!value) {
this._datePicker.updateDate?.(undefined);
this._datePicker.touched?.();
return;
}
// Invalid parse: clear the picker date, keep the text so the user can fix it.
const parsed = this.parseDate()(value);
this._datePicker.updateDate?.(parsed ?? undefined);
this._datePicker.touched?.();
}
protected _open() {
this._popover().open();
}
protected _handleClick() {
if (this.openOnClick()) {
this._open();
}
}
}
export interface HlmDatePickerMultiConfig<T> {
/**
* If true, the date picker will close when the max selection of dates is reached.
*/
autoCloseOnMaxSelection: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param dates
* @returns formatted date
*/
formatDates: (dates: T[]) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param dates
* @returns transformed date
*/
transformDates: (dates: T[]) => T[];
}
function getDefaultConfig<T>(): HlmDatePickerMultiConfig<T> {
return {
formatDates: (dates) => dates.map((date) => (date instanceof Date ? date.toDateString() : `${date}`)).join(', '),
transformDates: (dates) => dates,
autoCloseOnMaxSelection: false,
};
}
const HlmDatePickerMultiConfigToken = new InjectionToken<HlmDatePickerMultiConfig<unknown>>('HlmDatePickerMultiConfig');
export function provideHlmDatePickerMultiConfig<T>(config: Partial<HlmDatePickerMultiConfig<T>>): ValueProvider {
return { provide: HlmDatePickerMultiConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDatePickerMultiConfig<T>(): HlmDatePickerMultiConfig<T> {
const injectedConfig = inject(HlmDatePickerMultiConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDatePickerMultiConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_PICKER_MUTLI_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDatePickerMulti),
multi: true,
};
@Component({
selector: 'hlm-date-picker-multi',
imports: [HlmPopoverImports, HlmCalendarMulti],
providers: [
HLM_DATE_PICKER_MUTLI_VALUE_ACCESSOR,
provideHlmDatePicker(HlmDatePickerMulti),
provideBrnLabelable(HlmDatePickerMulti),
],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar-multi
class="rounded-none border-0"
[date]="_mutableDate()"
[captionLayout]="captionLayout()"
[min]="min()"
[max]="max()"
[minSelection]="minSelection()"
[maxSelection]="maxSelection()"
[disabled]="_disabled()"
(dateChange)="_handleChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDatePickerMulti<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDatePickerMultiConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** The minimum selectable dates. */
public readonly minSelection = input<number, NumberInput>(undefined, {
transform: numberAttribute,
});
/** The maximum selectable dates. */
public readonly maxSelection = input<number, NumberInput>(undefined, {
transform: numberAttribute,
});
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<T[]>();
protected readonly _mutableDate = linkedSignal(this.date);
/** If true, the date picker will close when the max selection of dates is reached. */
public readonly autoCloseOnMaxSelection = input<boolean, BooleanInput>(this._config.autoCloseOnMaxSelection, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDates = input<(date: T[]) => string>(this._config.formatDates);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDates = input<(date: T[]) => T[]>(this._config.transformDates);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const dates = this._mutableDate();
return dates ? this.formatDates()(dates) : undefined;
});
public readonly dateChange = output<T[]>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._mutableDate()?.length);
protected _onChange?: ChangeFn<T[]>;
protected _onTouched?: TouchFn;
protected _handleChange(value: T[] | undefined) {
if (value === undefined) return;
if (this._disabled()) return;
const transformedDate = value !== undefined ? this.transformDates()(value) : value;
this._mutableDate.set(transformedDate);
this._onChange?.(transformedDate);
this.dateChange.emit(transformedDate);
if (this.autoCloseOnMaxSelection() && this._mutableDate()?.length === this.maxSelection()) {
this._popoverState.set('closed');
}
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: T[] | null): void {
this._mutableDate.set(value ? this.transformDates()(value) : undefined);
}
public registerOnChange(fn: ChangeFn<T[]>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._onChange?.([]);
this.dateChange.emit([]);
}
}
export interface HlmDatePickerTriggerBase {
triggerId: Signal<string>;
}
export const HlmDatePickerTriggerToken = new InjectionToken<HlmDatePickerTriggerBase>('HlmDatePickerTriggerToken');
export function provideHlmDatePickerTrigger(instance: Type<HlmDatePickerTriggerBase>): ExistingProvider {
return { provide: HlmDatePickerTriggerToken, useExisting: instance };
}
@Component({
selector: 'hlm-date-picker-trigger',
imports: [HlmButtonImports, HlmPopoverTrigger, NgIcon, BrnFieldControlDescribedBy],
providers: [provideIcons({ lucideChevronDown }), provideHlmDatePickerTrigger(HlmDatePickerTrigger)],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { 'data-slot': 'date-picker-trigger' },
template: `
<button
[id]="buttonId()"
type="button"
[class]="_computedClass()"
[disabled]="_disabled()"
[attr.aria-invalid]="_ariaInvalid()"
[attr.data-invalid]="_ariaInvalid()"
[attr.data-touched]="_touched?.() ? 'true' : null"
[attr.data-dirty]="_dirty?.() ? 'true' : null"
[attr.data-matches-spartan-invalid]="_spartanInvalid() ? 'true' : null"
hlmBtn
[variant]="variant()"
hlmPopoverTrigger
[hlmPopoverTriggerFor]="_popover()"
brnFieldControlDescribedBy
[attr.data-placeholder]="_isPlaceholder() ? '' : null"
>
<span class="truncate">
@if (_formattedDate(); as formattedDate) {
{{ formattedDate }}
} @else {
<ng-content />
}
</span>
@if (showTrigger()) {
<ng-icon name="lucideChevronDown" />
}
</button>
`,
})
export class HlmDatePickerTrigger implements HlmDatePickerTriggerBase {
private static _nextId = 0;
private readonly _fieldControl = inject(BrnFieldControl, { optional: true });
private readonly _datePicker = injectHlmDatePicker();
private readonly _invalid = this._fieldControl?.invalid;
protected readonly _spartanInvalid = computed(() => this.forceInvalid() || this._fieldControl?.spartanInvalid());
protected readonly _dirty = this._fieldControl?.dirty;
protected readonly _touched = this._fieldControl?.touched;
protected readonly _ariaInvalid = computed(() => (this._invalid?.() ? 'true' : null));
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm('data-placeholder:text-muted-foreground w-64 justify-between', this.userClass()),
);
protected readonly _isPlaceholder = computed(() => !this._datePicker.hasDate());
/** The id of the button that opens the date picker. */
public readonly buttonId = input<string>(`hlm-date-picker-${++HlmDatePickerTrigger._nextId}`);
/** @internal The id of the button that opens the date picker, used for labeling. */
public readonly triggerId = this.buttonId;
/** Forces the invalid state visually, regardless of form control state. */
public readonly forceInvalid = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly showTrigger = input<boolean, BooleanInput>(true, { transform: booleanAttribute });
protected readonly _popover = this._datePicker.popover;
protected readonly _disabled = this._datePicker.disabledState;
protected readonly _formattedDate = this._datePicker.formattedDate;
}
export interface HlmDatePickerBase<T> {
popover: Signal<BrnPopover>;
disabledState: Signal<boolean>;
formattedDate: Signal<string | undefined>;
hasDate: Signal<boolean>;
/** Commit a date to the picker (e.g. from a parsed input). Pass `undefined` to clear. Optional. */
updateDate?(value: T | undefined): void;
// used for ControlValueAccessor
touched?(): void;
}
export const HlmDatePickerToken = new InjectionToken<HlmDatePickerBase<unknown>>('HlmDatePickerToken');
export function provideHlmDatePicker(instance: Type<HlmDatePickerBase<unknown>>): ExistingProvider {
return { provide: HlmDatePickerToken, useExisting: instance };
}
/**
* Inject the date picker component.
*/
export function injectHlmDatePicker<T>(): HlmDatePickerBase<T> {
return inject(HlmDatePickerToken) as HlmDatePickerBase<T>;
}
export interface HlmDatePickerConfig<T> {
/**
* If true, the date picker will close when a date is selected.
*/
autoCloseOnSelect: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param date
* @returns formatted date
*/
formatDate: (date: T) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param date
* @returns transformed date
*/
transformDate: (date: T) => T;
/**
* Parse a user-entered string into a date.
*
* @param value the raw string from the input
* @returns the parsed date, or `undefined` when the value can't be parsed
*/
parseDate: (value: string) => T | undefined;
}
function getDefaultConfig<T>(): HlmDatePickerConfig<T> {
return {
formatDate: (date) => (date instanceof Date ? date.toDateString() : `${date}`),
transformDate: (date) => date,
parseDate: (value) => {
const date = new Date(value);
return isNaN(date.getTime()) ? undefined : (date as T);
},
autoCloseOnSelect: false,
};
}
const HlmDatePickerConfigToken = new InjectionToken<HlmDatePickerConfig<unknown>>('HlmDatePickerConfig');
export function provideHlmDatePickerConfig<T>(config: Partial<HlmDatePickerConfig<T>>): ValueProvider {
return { provide: HlmDatePickerConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDatePickerConfig<T>(): HlmDatePickerConfig<T> {
const injectedConfig = inject(HlmDatePickerConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDatePickerConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_PICKER_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDatePicker),
multi: true,
};
@Component({
selector: 'hlm-date-picker',
imports: [HlmPopoverImports, HlmCalendar],
providers: [HLM_DATE_PICKER_VALUE_ACCESSOR, provideHlmDatePicker(HlmDatePicker), provideBrnLabelable(HlmDatePicker)],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar
class="rounded-none border-0"
[captionLayout]="captionLayout()"
[date]="_mutableDate()"
[defaultFocusedDate]="_mutableDate() ?? defaultFocusedDate()"
[min]="min()"
[max]="max()"
[disabled]="_disabled()"
(dateChange)="_handleChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDatePicker<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDatePickerConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<T>();
/** The date the calendar focuses on first open when no date is selected. */
public readonly defaultFocusedDate = input<T>();
protected readonly _mutableDate = linkedSignal(this.date);
/** If true, the date picker will close when a date is selected. */
public readonly autoCloseOnSelect = input<boolean, BooleanInput>(this._config.autoCloseOnSelect, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDate = input<(date: T) => string>(this._config.formatDate);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDate = input<(date: T) => T>(this._config.transformDate);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const date = this._mutableDate();
return date ? this.formatDate()(date) : undefined;
});
public readonly dateChange = output<T>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._mutableDate());
protected _onChange?: ChangeFn<T>;
protected _onTouched?: TouchFn;
protected _handleChange(value: T | undefined) {
if (this._disabled()) return;
this.updateDate(value);
if (this.autoCloseOnSelect()) {
this._popoverState.set('closed');
}
}
/**
* Commit a date to the picker. Updates the internal model, notifies form
* controls, and emits `dateChange`. Unlike `_handleChange`, this does not
* close the popover - it's intended to be called from a text input that
* is parsing user-entered values while typing.
*/
public updateDate(value: T | undefined) {
if (this._disabled()) return;
const transformedDate = value !== undefined ? this.transformDate()(value) : undefined;
this._mutableDate.set(transformedDate);
this._onChange?.(transformedDate as T);
this.dateChange.emit(transformedDate as T);
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: T | null): void {
this._mutableDate.set(value ? this.transformDate()(value) : undefined);
}
public registerOnChange(fn: ChangeFn<T>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public touched(): void {
this._onTouched?.();
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._onChange?.(undefined as T);
this.dateChange.emit(undefined as T);
}
}
export interface HlmDateRangePickerConfig<T> {
/**
* If true, the date picker will close when the max selection of dates is reached.
*/
autoCloseOnEndSelection: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param dates
* @returns formatted date
*/
formatDates: (dates: [T | undefined, T | undefined]) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param dates
* @returns transformed date
*/
transformDates: (dates: [T, T]) => [T, T];
}
function getDefaultConfig<T>(): HlmDateRangePickerConfig<T> {
return {
formatDates: (dates) =>
dates
.filter(Boolean)
.map((date) => (date instanceof Date ? date.toDateString() : `${date}`))
.join(' - '),
transformDates: (dates) => dates,
autoCloseOnEndSelection: false,
};
}
const HlmDateRangePickerConfigToken = new InjectionToken<HlmDateRangePickerConfig<unknown>>('HlmDateRangePickerConfig');
export function provideHlmDateRangePickerConfig<T>(config: Partial<HlmDateRangePickerConfig<T>>): ValueProvider {
return { provide: HlmDateRangePickerConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDateRangePickerConfig<T>(): HlmDateRangePickerConfig<T> {
const injectedConfig = inject(HlmDateRangePickerConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDateRangePickerConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDateRangePicker),
multi: true,
};
@Component({
selector: 'hlm-date-range-picker',
imports: [HlmPopoverImports, HlmCalendarRange],
providers: [
HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR,
provideHlmDatePicker(HlmDateRangePicker),
provideBrnLabelable(HlmDateRangePicker),
],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onClose(); _onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar-range
class="rounded-none border-0"
[startDate]="_start()"
[captionLayout]="captionLayout()"
[endDate]="_end()"
[min]="min()"
[max]="max()"
[disabled]="_disabled()"
(startDateChange)="_handleStartDayChange($event)"
(endDateChange)="_handleEndDateChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDateRangePicker<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDateRangePickerConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<[T, T]>();
protected readonly _mutableDate = linkedSignal(this.date);
protected readonly _start = linkedSignal(() => this._mutableDate()?.[0]);
protected readonly _end = linkedSignal(() => this._mutableDate()?.[1]);
/** If true, the date picker will close when the end date is selected */
public readonly autoCloseOnEndSelection = input<boolean, BooleanInput>(this._config.autoCloseOnEndSelection, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDates = input<(dates: [T | undefined, T | undefined]) => string>(this._config.formatDates);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDates = input<(date: [T, T]) => [T, T]>(this._config.transformDates);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const start = this._start();
const end = this._end();
return start || end ? this.formatDates()([start, end]) : undefined;
});
public readonly dateChange = output<[T, T] | null>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._start() || !!this._end());
protected _onChange?: ChangeFn<[T, T] | null>;
protected _onTouched?: TouchFn;
protected _handleStartDayChange(value: T | undefined) {
this._start.set(value);
}
protected _handleEndDateChange(value: T | undefined): void {
this._end.set(value);
if (this._disabled()) return;
const start = this._start();
if (start && value) {
const transformedDates = this.transformDates()([start, value]);
this._mutableDate.set(transformedDates);
this.dateChange.emit(transformedDates);
this._onChange?.(transformedDates);
if (this.autoCloseOnEndSelection()) {
this._popoverState.set('closed');
}
}
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: [T, T] | null): void {
untracked(() => {
if (!value) {
this._mutableDate.set(undefined);
} else {
this._mutableDate.set(this.transformDates()(value));
}
});
}
public registerOnChange(fn: ChangeFn<[T, T] | null>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._start.set(undefined);
this._end.set(undefined);
this._onChange?.(null);
this.dateChange.emit(null);
}
protected _onClose(): void {
const dates = this._mutableDate();
if (this._start() && !this._end() && dates) {
this._start.set(dates[0]);
this._end.set(dates[1]);
}
}
}
export const HlmDatePickerImports = [
HlmDatePicker,
HlmDatePickerAnchor,
HlmDatePickerInput,
HlmDatePickerMulti,
HlmDateRangePicker,
HlmDatePickerTrigger,
] as const;import { BooleanInput, type BooleanInput, type NumberInput } from '@angular/cdk/coercion';
import { BrnFieldControl, BrnFieldControlDescribedBy, provideBrnLabelable } from '@spartan-ng/brain/field';
import { BrnOverlay, type BrnOverlayState } from '@spartan-ng/brain/overlay';
import { BrnPopover, type BrnPopover } from '@spartan-ng/brain/popover';
import { ButtonVariants, HlmButtonImports } from '@spartan-ng/helm/button';
import { ChangeDetectionStrategy, Component, Directive, ElementRef, InjectionToken, booleanAttribute, computed, contentChild, effect, forwardRef, inject, input, linkedSignal, numberAttribute, output, signal, untracked, viewChild, type ExistingProvider, type Signal, type Type, type ValueProvider } from '@angular/core';
import { ClassValue } from 'clsx';
import { HlmCalendar, HlmCalendarMulti, HlmCalendarRange } from '@spartan-ng/helm/calendar';
import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
import { HlmPopoverImports, HlmPopoverTrigger } from '@spartan-ng/helm/popover';
import { NG_VALUE_ACCESSOR, type ControlValueAccessor } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { hlm } from '@spartan-ng/helm/utils';
import { lucideCalendar, lucideChevronDown, lucideX } from '@ng-icons/lucide';
import { type ChangeFn, type TouchFn } from '@spartan-ng/brain/forms';
@Directive({ selector: '[hlmDatePickerAnchor]' })
export class HlmDatePickerAnchor {
private readonly _host = inject(ElementRef, { host: true });
private readonly _brnOverlay = inject(BrnOverlay, { optional: true });
public readonly hlmDatePickerAnchorFor = input<BrnPopover | undefined>(undefined, {
alias: 'hlmDatePickerAnchorFor',
});
constructor() {
effect(() => {
this.hlmDatePickerAnchorFor()?.setOrigin(this._host.nativeElement);
});
this._brnOverlay?.setOrigin(this._host.nativeElement);
}
}
@Component({
selector: 'hlm-date-picker-input',
imports: [HlmInputGroupImports, HlmButtonImports, HlmDatePickerAnchor, NgIcon],
providers: [provideIcons({ lucideCalendar, lucideX }), provideHlmDatePickerTrigger(HlmDatePickerInput)],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-input-group hlmDatePickerAnchor [hlmDatePickerAnchorFor]="_popover()">
<input
hlmInputGroupInput
[value]="_inputValue()"
[id]="inputId()"
[placeholder]="placeholder()"
[disabled]="_disabled()"
[forceInvalid]="forceInvalid()"
(click)="_handleClick()"
(keydown.arrowDown)="_open()"
(keydown.enter)="_handleEnter($event)"
(input)="_handleInputChange($event)"
(blur)="_commitDate()"
/>
<hlm-input-group-addon align="inline-end">
@if (_showClearButton()) {
<button
hlmInputGroupButton
size="icon-xs"
variant="ghost"
[attr.aria-label]="clearAriaLabel()"
(click)="_clear()"
[disabled]="_disabled()"
>
<ng-icon name="lucideX" />
</button>
}
<button
hlmInputGroupButton
size="icon-xs"
[attr.aria-label]="calendarAriaLabel()"
(click)="_popover().open()"
[disabled]="_disabled()"
>
<ng-icon name="lucideCalendar" />
</button>
</hlm-input-group-addon>
</hlm-input-group>
`,
})
export class HlmDatePickerInput<T> implements HlmDatePickerTriggerBase {
private static _nextId = 0;
private readonly _datePicker = injectHlmDatePicker<T>();
private readonly _config = injectHlmDatePickerConfig<T>();
protected readonly _popover = this._datePicker.popover;
protected readonly _disabled = this._datePicker.disabledState;
public readonly inputId = input(`hlm-date-picker-input-${HlmDatePickerInput._nextId++}`);
public readonly placeholder = input('');
public readonly inputValue = input<string>('');
/**
* Parses input text into a date value. Return `undefined` for invalid
* input - the picker's date is cleared while the text is preserved so
* the user can fix it.
*
* Defaults to `parseDate` from `HlmDatePickerConfig`.
*/
public readonly parseDate = input<(value: string) => T | undefined>(this._config.parseDate);
public readonly forceInvalid = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
/** Show a clear button that resets the input and picker date. Hidden when empty. */
public readonly showClear = input<boolean, BooleanInput>(true, { transform: booleanAttribute });
/** Open the popover on input click. */
public readonly openOnClick = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
/** Accessible label for the clear button. */
public readonly clearAriaLabel = input<string>('Clear date');
/** Accessible label for the calendar trigger button. */
public readonly calendarAriaLabel = input<string>('Open calendar');
/** @internal Id used by the trigger contract for labeling. */
public readonly triggerId = this.inputId;
/**
* Text shown in the input. Mirrors the picker's `formattedDate` and the
* parent's `inputValue`, and accepts user writes via `_handleInputChange`.
* Commits only happen on blur / Enter, so in-progress text isn't clobbered.
*/
protected readonly _inputValue = linkedSignal<{ formatted: string | undefined; inputValue: string }, string>({
source: () => ({
formatted: this._datePicker.formattedDate(),
inputValue: this.inputValue(),
}),
computation: (source, previous) => {
// First render: prefer formatted, fall back to inputValue.
if (previous === undefined) {
return source.formatted ?? source.inputValue;
}
// Picker's formatted date changed - snap to canonical format.
if (source.formatted !== previous.source.formatted) {
if (source.formatted !== undefined) {
return source.formatted;
}
// Cleared externally vs. user has invalid text in flight: only
// mirror the clear when the displayed text was in sync.
return previous.value === previous.source.formatted ? '' : previous.value;
}
// Parent updated inputValue - reflect it.
if (source.inputValue !== previous.source.inputValue) {
return source.inputValue;
}
return previous.value;
},
});
protected _handleInputChange(event: Event) {
const text = (event.target as HTMLInputElement).value;
this._inputValue.set(text);
}
protected readonly _showClearButton = computed(() => this.showClear() && this._inputValue().length > 0);
protected _clear() {
this._inputValue.set('');
this._datePicker.updateDate?.(undefined);
this._datePicker.touched?.();
}
protected _handleEnter(event: Event) {
event.preventDefault();
this._commitDate();
this._popover().close();
}
protected _commitDate() {
const value = this._inputValue();
if (!value) {
this._datePicker.updateDate?.(undefined);
this._datePicker.touched?.();
return;
}
// Invalid parse: clear the picker date, keep the text so the user can fix it.
const parsed = this.parseDate()(value);
this._datePicker.updateDate?.(parsed ?? undefined);
this._datePicker.touched?.();
}
protected _open() {
this._popover().open();
}
protected _handleClick() {
if (this.openOnClick()) {
this._open();
}
}
}
export interface HlmDatePickerMultiConfig<T> {
/**
* If true, the date picker will close when the max selection of dates is reached.
*/
autoCloseOnMaxSelection: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param dates
* @returns formatted date
*/
formatDates: (dates: T[]) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param dates
* @returns transformed date
*/
transformDates: (dates: T[]) => T[];
}
function getDefaultConfig<T>(): HlmDatePickerMultiConfig<T> {
return {
formatDates: (dates) => dates.map((date) => (date instanceof Date ? date.toDateString() : `${date}`)).join(', '),
transformDates: (dates) => dates,
autoCloseOnMaxSelection: false,
};
}
const HlmDatePickerMultiConfigToken = new InjectionToken<HlmDatePickerMultiConfig<unknown>>('HlmDatePickerMultiConfig');
export function provideHlmDatePickerMultiConfig<T>(config: Partial<HlmDatePickerMultiConfig<T>>): ValueProvider {
return { provide: HlmDatePickerMultiConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDatePickerMultiConfig<T>(): HlmDatePickerMultiConfig<T> {
const injectedConfig = inject(HlmDatePickerMultiConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDatePickerMultiConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_PICKER_MUTLI_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDatePickerMulti),
multi: true,
};
@Component({
selector: 'hlm-date-picker-multi',
imports: [HlmPopoverImports, HlmCalendarMulti],
providers: [
HLM_DATE_PICKER_MUTLI_VALUE_ACCESSOR,
provideHlmDatePicker(HlmDatePickerMulti),
provideBrnLabelable(HlmDatePickerMulti),
],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar-multi
class="rounded-none border-0"
[date]="_mutableDate()"
[captionLayout]="captionLayout()"
[min]="min()"
[max]="max()"
[minSelection]="minSelection()"
[maxSelection]="maxSelection()"
[disabled]="_disabled()"
(dateChange)="_handleChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDatePickerMulti<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDatePickerMultiConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** The minimum selectable dates. */
public readonly minSelection = input<number, NumberInput>(undefined, {
transform: numberAttribute,
});
/** The maximum selectable dates. */
public readonly maxSelection = input<number, NumberInput>(undefined, {
transform: numberAttribute,
});
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<T[]>();
protected readonly _mutableDate = linkedSignal(this.date);
/** If true, the date picker will close when the max selection of dates is reached. */
public readonly autoCloseOnMaxSelection = input<boolean, BooleanInput>(this._config.autoCloseOnMaxSelection, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDates = input<(date: T[]) => string>(this._config.formatDates);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDates = input<(date: T[]) => T[]>(this._config.transformDates);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const dates = this._mutableDate();
return dates ? this.formatDates()(dates) : undefined;
});
public readonly dateChange = output<T[]>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._mutableDate()?.length);
protected _onChange?: ChangeFn<T[]>;
protected _onTouched?: TouchFn;
protected _handleChange(value: T[] | undefined) {
if (value === undefined) return;
if (this._disabled()) return;
const transformedDate = value !== undefined ? this.transformDates()(value) : value;
this._mutableDate.set(transformedDate);
this._onChange?.(transformedDate);
this.dateChange.emit(transformedDate);
if (this.autoCloseOnMaxSelection() && this._mutableDate()?.length === this.maxSelection()) {
this._popoverState.set('closed');
}
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: T[] | null): void {
this._mutableDate.set(value ? this.transformDates()(value) : undefined);
}
public registerOnChange(fn: ChangeFn<T[]>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._onChange?.([]);
this.dateChange.emit([]);
}
}
export interface HlmDatePickerTriggerBase {
triggerId: Signal<string>;
}
export const HlmDatePickerTriggerToken = new InjectionToken<HlmDatePickerTriggerBase>('HlmDatePickerTriggerToken');
export function provideHlmDatePickerTrigger(instance: Type<HlmDatePickerTriggerBase>): ExistingProvider {
return { provide: HlmDatePickerTriggerToken, useExisting: instance };
}
@Component({
selector: 'hlm-date-picker-trigger',
imports: [HlmButtonImports, HlmPopoverTrigger, NgIcon, BrnFieldControlDescribedBy],
providers: [provideIcons({ lucideChevronDown }), provideHlmDatePickerTrigger(HlmDatePickerTrigger)],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { 'data-slot': 'date-picker-trigger' },
template: `
<button
[id]="buttonId()"
type="button"
[class]="_computedClass()"
[disabled]="_disabled()"
[attr.aria-invalid]="_ariaInvalid()"
[attr.data-invalid]="_ariaInvalid()"
[attr.data-touched]="_touched?.() ? 'true' : null"
[attr.data-dirty]="_dirty?.() ? 'true' : null"
[attr.data-matches-spartan-invalid]="_spartanInvalid() ? 'true' : null"
hlmBtn
[variant]="variant()"
hlmPopoverTrigger
[hlmPopoverTriggerFor]="_popover()"
brnFieldControlDescribedBy
[attr.data-placeholder]="_isPlaceholder() ? '' : null"
>
<span class="truncate">
@if (_formattedDate(); as formattedDate) {
{{ formattedDate }}
} @else {
<ng-content />
}
</span>
@if (showTrigger()) {
<ng-icon name="lucideChevronDown" />
}
</button>
`,
})
export class HlmDatePickerTrigger implements HlmDatePickerTriggerBase {
private static _nextId = 0;
private readonly _fieldControl = inject(BrnFieldControl, { optional: true });
private readonly _datePicker = injectHlmDatePicker();
private readonly _invalid = this._fieldControl?.invalid;
protected readonly _spartanInvalid = computed(() => this.forceInvalid() || this._fieldControl?.spartanInvalid());
protected readonly _dirty = this._fieldControl?.dirty;
protected readonly _touched = this._fieldControl?.touched;
protected readonly _ariaInvalid = computed(() => (this._invalid?.() ? 'true' : null));
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm('data-placeholder:text-muted-foreground w-64 justify-between', this.userClass()),
);
protected readonly _isPlaceholder = computed(() => !this._datePicker.hasDate());
/** The id of the button that opens the date picker. */
public readonly buttonId = input<string>(`hlm-date-picker-${++HlmDatePickerTrigger._nextId}`);
/** @internal The id of the button that opens the date picker, used for labeling. */
public readonly triggerId = this.buttonId;
/** Forces the invalid state visually, regardless of form control state. */
public readonly forceInvalid = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly showTrigger = input<boolean, BooleanInput>(true, { transform: booleanAttribute });
protected readonly _popover = this._datePicker.popover;
protected readonly _disabled = this._datePicker.disabledState;
protected readonly _formattedDate = this._datePicker.formattedDate;
}
export interface HlmDatePickerBase<T> {
popover: Signal<BrnPopover>;
disabledState: Signal<boolean>;
formattedDate: Signal<string | undefined>;
hasDate: Signal<boolean>;
/** Commit a date to the picker (e.g. from a parsed input). Pass `undefined` to clear. Optional. */
updateDate?(value: T | undefined): void;
// used for ControlValueAccessor
touched?(): void;
}
export const HlmDatePickerToken = new InjectionToken<HlmDatePickerBase<unknown>>('HlmDatePickerToken');
export function provideHlmDatePicker(instance: Type<HlmDatePickerBase<unknown>>): ExistingProvider {
return { provide: HlmDatePickerToken, useExisting: instance };
}
/**
* Inject the date picker component.
*/
export function injectHlmDatePicker<T>(): HlmDatePickerBase<T> {
return inject(HlmDatePickerToken) as HlmDatePickerBase<T>;
}
export interface HlmDatePickerConfig<T> {
/**
* If true, the date picker will close when a date is selected.
*/
autoCloseOnSelect: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param date
* @returns formatted date
*/
formatDate: (date: T) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param date
* @returns transformed date
*/
transformDate: (date: T) => T;
/**
* Parse a user-entered string into a date.
*
* @param value the raw string from the input
* @returns the parsed date, or `undefined` when the value can't be parsed
*/
parseDate: (value: string) => T | undefined;
}
function getDefaultConfig<T>(): HlmDatePickerConfig<T> {
return {
formatDate: (date) => (date instanceof Date ? date.toDateString() : `${date}`),
transformDate: (date) => date,
parseDate: (value) => {
const date = new Date(value);
return isNaN(date.getTime()) ? undefined : (date as T);
},
autoCloseOnSelect: false,
};
}
const HlmDatePickerConfigToken = new InjectionToken<HlmDatePickerConfig<unknown>>('HlmDatePickerConfig');
export function provideHlmDatePickerConfig<T>(config: Partial<HlmDatePickerConfig<T>>): ValueProvider {
return { provide: HlmDatePickerConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDatePickerConfig<T>(): HlmDatePickerConfig<T> {
const injectedConfig = inject(HlmDatePickerConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDatePickerConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_PICKER_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDatePicker),
multi: true,
};
@Component({
selector: 'hlm-date-picker',
imports: [HlmPopoverImports, HlmCalendar],
providers: [HLM_DATE_PICKER_VALUE_ACCESSOR, provideHlmDatePicker(HlmDatePicker), provideBrnLabelable(HlmDatePicker)],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar
class="rounded-none border-0"
[captionLayout]="captionLayout()"
[date]="_mutableDate()"
[defaultFocusedDate]="_mutableDate() ?? defaultFocusedDate()"
[min]="min()"
[max]="max()"
[disabled]="_disabled()"
(dateChange)="_handleChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDatePicker<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDatePickerConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<T>();
/** The date the calendar focuses on first open when no date is selected. */
public readonly defaultFocusedDate = input<T>();
protected readonly _mutableDate = linkedSignal(this.date);
/** If true, the date picker will close when a date is selected. */
public readonly autoCloseOnSelect = input<boolean, BooleanInput>(this._config.autoCloseOnSelect, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDate = input<(date: T) => string>(this._config.formatDate);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDate = input<(date: T) => T>(this._config.transformDate);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const date = this._mutableDate();
return date ? this.formatDate()(date) : undefined;
});
public readonly dateChange = output<T>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._mutableDate());
protected _onChange?: ChangeFn<T>;
protected _onTouched?: TouchFn;
protected _handleChange(value: T | undefined) {
if (this._disabled()) return;
this.updateDate(value);
if (this.autoCloseOnSelect()) {
this._popoverState.set('closed');
}
}
/**
* Commit a date to the picker. Updates the internal model, notifies form
* controls, and emits `dateChange`. Unlike `_handleChange`, this does not
* close the popover - it's intended to be called from a text input that
* is parsing user-entered values while typing.
*/
public updateDate(value: T | undefined) {
if (this._disabled()) return;
const transformedDate = value !== undefined ? this.transformDate()(value) : undefined;
this._mutableDate.set(transformedDate);
this._onChange?.(transformedDate as T);
this.dateChange.emit(transformedDate as T);
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: T | null): void {
this._mutableDate.set(value ? this.transformDate()(value) : undefined);
}
public registerOnChange(fn: ChangeFn<T>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public touched(): void {
this._onTouched?.();
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._onChange?.(undefined as T);
this.dateChange.emit(undefined as T);
}
}
export interface HlmDateRangePickerConfig<T> {
/**
* If true, the date picker will close when the max selection of dates is reached.
*/
autoCloseOnEndSelection: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param dates
* @returns formatted date
*/
formatDates: (dates: [T | undefined, T | undefined]) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param dates
* @returns transformed date
*/
transformDates: (dates: [T, T]) => [T, T];
}
function getDefaultConfig<T>(): HlmDateRangePickerConfig<T> {
return {
formatDates: (dates) =>
dates
.filter(Boolean)
.map((date) => (date instanceof Date ? date.toDateString() : `${date}`))
.join(' - '),
transformDates: (dates) => dates,
autoCloseOnEndSelection: false,
};
}
const HlmDateRangePickerConfigToken = new InjectionToken<HlmDateRangePickerConfig<unknown>>('HlmDateRangePickerConfig');
export function provideHlmDateRangePickerConfig<T>(config: Partial<HlmDateRangePickerConfig<T>>): ValueProvider {
return { provide: HlmDateRangePickerConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDateRangePickerConfig<T>(): HlmDateRangePickerConfig<T> {
const injectedConfig = inject(HlmDateRangePickerConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDateRangePickerConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDateRangePicker),
multi: true,
};
@Component({
selector: 'hlm-date-range-picker',
imports: [HlmPopoverImports, HlmCalendarRange],
providers: [
HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR,
provideHlmDatePicker(HlmDateRangePicker),
provideBrnLabelable(HlmDateRangePicker),
],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onClose(); _onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar-range
class="rounded-none border-0"
[startDate]="_start()"
[captionLayout]="captionLayout()"
[endDate]="_end()"
[min]="min()"
[max]="max()"
[disabled]="_disabled()"
(startDateChange)="_handleStartDayChange($event)"
(endDateChange)="_handleEndDateChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDateRangePicker<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDateRangePickerConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<[T, T]>();
protected readonly _mutableDate = linkedSignal(this.date);
protected readonly _start = linkedSignal(() => this._mutableDate()?.[0]);
protected readonly _end = linkedSignal(() => this._mutableDate()?.[1]);
/** If true, the date picker will close when the end date is selected */
public readonly autoCloseOnEndSelection = input<boolean, BooleanInput>(this._config.autoCloseOnEndSelection, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDates = input<(dates: [T | undefined, T | undefined]) => string>(this._config.formatDates);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDates = input<(date: [T, T]) => [T, T]>(this._config.transformDates);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const start = this._start();
const end = this._end();
return start || end ? this.formatDates()([start, end]) : undefined;
});
public readonly dateChange = output<[T, T] | null>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._start() || !!this._end());
protected _onChange?: ChangeFn<[T, T] | null>;
protected _onTouched?: TouchFn;
protected _handleStartDayChange(value: T | undefined) {
this._start.set(value);
}
protected _handleEndDateChange(value: T | undefined): void {
this._end.set(value);
if (this._disabled()) return;
const start = this._start();
if (start && value) {
const transformedDates = this.transformDates()([start, value]);
this._mutableDate.set(transformedDates);
this.dateChange.emit(transformedDates);
this._onChange?.(transformedDates);
if (this.autoCloseOnEndSelection()) {
this._popoverState.set('closed');
}
}
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: [T, T] | null): void {
untracked(() => {
if (!value) {
this._mutableDate.set(undefined);
} else {
this._mutableDate.set(this.transformDates()(value));
}
});
}
public registerOnChange(fn: ChangeFn<[T, T] | null>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._start.set(undefined);
this._end.set(undefined);
this._onChange?.(null);
this.dateChange.emit(null);
}
protected _onClose(): void {
const dates = this._mutableDate();
if (this._start() && !this._end() && dates) {
this._start.set(dates[0]);
this._end.set(dates[1]);
}
}
}
export const HlmDatePickerImports = [
HlmDatePicker,
HlmDatePickerAnchor,
HlmDatePickerInput,
HlmDatePickerMulti,
HlmDateRangePicker,
HlmDatePickerTrigger,
] as const;import { BooleanInput, type BooleanInput, type NumberInput } from '@angular/cdk/coercion';
import { BrnFieldControl, BrnFieldControlDescribedBy, provideBrnLabelable } from '@spartan-ng/brain/field';
import { BrnOverlay, type BrnOverlayState } from '@spartan-ng/brain/overlay';
import { BrnPopover, type BrnPopover } from '@spartan-ng/brain/popover';
import { ButtonVariants, HlmButtonImports } from '@spartan-ng/helm/button';
import { ChangeDetectionStrategy, Component, Directive, ElementRef, InjectionToken, booleanAttribute, computed, contentChild, effect, forwardRef, inject, input, linkedSignal, numberAttribute, output, signal, untracked, viewChild, type ExistingProvider, type Signal, type Type, type ValueProvider } from '@angular/core';
import { ClassValue } from 'clsx';
import { HlmCalendar, HlmCalendarMulti, HlmCalendarRange } from '@spartan-ng/helm/calendar';
import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
import { HlmPopoverImports, HlmPopoverTrigger } from '@spartan-ng/helm/popover';
import { NG_VALUE_ACCESSOR, type ControlValueAccessor } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { hlm } from '@spartan-ng/helm/utils';
import { lucideCalendar, lucideChevronDown, lucideX } from '@ng-icons/lucide';
import { type ChangeFn, type TouchFn } from '@spartan-ng/brain/forms';
@Directive({ selector: '[hlmDatePickerAnchor]' })
export class HlmDatePickerAnchor {
private readonly _host = inject(ElementRef, { host: true });
private readonly _brnOverlay = inject(BrnOverlay, { optional: true });
public readonly hlmDatePickerAnchorFor = input<BrnPopover | undefined>(undefined, {
alias: 'hlmDatePickerAnchorFor',
});
constructor() {
effect(() => {
this.hlmDatePickerAnchorFor()?.setOrigin(this._host.nativeElement);
});
this._brnOverlay?.setOrigin(this._host.nativeElement);
}
}
@Component({
selector: 'hlm-date-picker-input',
imports: [HlmInputGroupImports, HlmButtonImports, HlmDatePickerAnchor, NgIcon],
providers: [provideIcons({ lucideCalendar, lucideX }), provideHlmDatePickerTrigger(HlmDatePickerInput)],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-input-group hlmDatePickerAnchor [hlmDatePickerAnchorFor]="_popover()">
<input
hlmInputGroupInput
[value]="_inputValue()"
[id]="inputId()"
[placeholder]="placeholder()"
[disabled]="_disabled()"
[forceInvalid]="forceInvalid()"
(click)="_handleClick()"
(keydown.arrowDown)="_open()"
(keydown.enter)="_handleEnter($event)"
(input)="_handleInputChange($event)"
(blur)="_commitDate()"
/>
<hlm-input-group-addon align="inline-end">
@if (_showClearButton()) {
<button
hlmInputGroupButton
size="icon-xs"
variant="ghost"
[attr.aria-label]="clearAriaLabel()"
(click)="_clear()"
[disabled]="_disabled()"
>
<ng-icon name="lucideX" />
</button>
}
<button
hlmInputGroupButton
size="icon-xs"
[attr.aria-label]="calendarAriaLabel()"
(click)="_popover().open()"
[disabled]="_disabled()"
>
<ng-icon name="lucideCalendar" />
</button>
</hlm-input-group-addon>
</hlm-input-group>
`,
})
export class HlmDatePickerInput<T> implements HlmDatePickerTriggerBase {
private static _nextId = 0;
private readonly _datePicker = injectHlmDatePicker<T>();
private readonly _config = injectHlmDatePickerConfig<T>();
protected readonly _popover = this._datePicker.popover;
protected readonly _disabled = this._datePicker.disabledState;
public readonly inputId = input(`hlm-date-picker-input-${HlmDatePickerInput._nextId++}`);
public readonly placeholder = input('');
public readonly inputValue = input<string>('');
/**
* Parses input text into a date value. Return `undefined` for invalid
* input - the picker's date is cleared while the text is preserved so
* the user can fix it.
*
* Defaults to `parseDate` from `HlmDatePickerConfig`.
*/
public readonly parseDate = input<(value: string) => T | undefined>(this._config.parseDate);
public readonly forceInvalid = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
/** Show a clear button that resets the input and picker date. Hidden when empty. */
public readonly showClear = input<boolean, BooleanInput>(true, { transform: booleanAttribute });
/** Open the popover on input click. */
public readonly openOnClick = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
/** Accessible label for the clear button. */
public readonly clearAriaLabel = input<string>('Clear date');
/** Accessible label for the calendar trigger button. */
public readonly calendarAriaLabel = input<string>('Open calendar');
/** @internal Id used by the trigger contract for labeling. */
public readonly triggerId = this.inputId;
/**
* Text shown in the input. Mirrors the picker's `formattedDate` and the
* parent's `inputValue`, and accepts user writes via `_handleInputChange`.
* Commits only happen on blur / Enter, so in-progress text isn't clobbered.
*/
protected readonly _inputValue = linkedSignal<{ formatted: string | undefined; inputValue: string }, string>({
source: () => ({
formatted: this._datePicker.formattedDate(),
inputValue: this.inputValue(),
}),
computation: (source, previous) => {
// First render: prefer formatted, fall back to inputValue.
if (previous === undefined) {
return source.formatted ?? source.inputValue;
}
// Picker's formatted date changed - snap to canonical format.
if (source.formatted !== previous.source.formatted) {
if (source.formatted !== undefined) {
return source.formatted;
}
// Cleared externally vs. user has invalid text in flight: only
// mirror the clear when the displayed text was in sync.
return previous.value === previous.source.formatted ? '' : previous.value;
}
// Parent updated inputValue - reflect it.
if (source.inputValue !== previous.source.inputValue) {
return source.inputValue;
}
return previous.value;
},
});
protected _handleInputChange(event: Event) {
const text = (event.target as HTMLInputElement).value;
this._inputValue.set(text);
}
protected readonly _showClearButton = computed(() => this.showClear() && this._inputValue().length > 0);
protected _clear() {
this._inputValue.set('');
this._datePicker.updateDate?.(undefined);
this._datePicker.touched?.();
}
protected _handleEnter(event: Event) {
event.preventDefault();
this._commitDate();
this._popover().close();
}
protected _commitDate() {
const value = this._inputValue();
if (!value) {
this._datePicker.updateDate?.(undefined);
this._datePicker.touched?.();
return;
}
// Invalid parse: clear the picker date, keep the text so the user can fix it.
const parsed = this.parseDate()(value);
this._datePicker.updateDate?.(parsed ?? undefined);
this._datePicker.touched?.();
}
protected _open() {
this._popover().open();
}
protected _handleClick() {
if (this.openOnClick()) {
this._open();
}
}
}
export interface HlmDatePickerMultiConfig<T> {
/**
* If true, the date picker will close when the max selection of dates is reached.
*/
autoCloseOnMaxSelection: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param dates
* @returns formatted date
*/
formatDates: (dates: T[]) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param dates
* @returns transformed date
*/
transformDates: (dates: T[]) => T[];
}
function getDefaultConfig<T>(): HlmDatePickerMultiConfig<T> {
return {
formatDates: (dates) => dates.map((date) => (date instanceof Date ? date.toDateString() : `${date}`)).join(', '),
transformDates: (dates) => dates,
autoCloseOnMaxSelection: false,
};
}
const HlmDatePickerMultiConfigToken = new InjectionToken<HlmDatePickerMultiConfig<unknown>>('HlmDatePickerMultiConfig');
export function provideHlmDatePickerMultiConfig<T>(config: Partial<HlmDatePickerMultiConfig<T>>): ValueProvider {
return { provide: HlmDatePickerMultiConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDatePickerMultiConfig<T>(): HlmDatePickerMultiConfig<T> {
const injectedConfig = inject(HlmDatePickerMultiConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDatePickerMultiConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_PICKER_MUTLI_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDatePickerMulti),
multi: true,
};
@Component({
selector: 'hlm-date-picker-multi',
imports: [HlmPopoverImports, HlmCalendarMulti],
providers: [
HLM_DATE_PICKER_MUTLI_VALUE_ACCESSOR,
provideHlmDatePicker(HlmDatePickerMulti),
provideBrnLabelable(HlmDatePickerMulti),
],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar-multi
class="rounded-none border-0"
[date]="_mutableDate()"
[captionLayout]="captionLayout()"
[min]="min()"
[max]="max()"
[minSelection]="minSelection()"
[maxSelection]="maxSelection()"
[disabled]="_disabled()"
(dateChange)="_handleChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDatePickerMulti<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDatePickerMultiConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** The minimum selectable dates. */
public readonly minSelection = input<number, NumberInput>(undefined, {
transform: numberAttribute,
});
/** The maximum selectable dates. */
public readonly maxSelection = input<number, NumberInput>(undefined, {
transform: numberAttribute,
});
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<T[]>();
protected readonly _mutableDate = linkedSignal(this.date);
/** If true, the date picker will close when the max selection of dates is reached. */
public readonly autoCloseOnMaxSelection = input<boolean, BooleanInput>(this._config.autoCloseOnMaxSelection, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDates = input<(date: T[]) => string>(this._config.formatDates);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDates = input<(date: T[]) => T[]>(this._config.transformDates);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const dates = this._mutableDate();
return dates ? this.formatDates()(dates) : undefined;
});
public readonly dateChange = output<T[]>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._mutableDate()?.length);
protected _onChange?: ChangeFn<T[]>;
protected _onTouched?: TouchFn;
protected _handleChange(value: T[] | undefined) {
if (value === undefined) return;
if (this._disabled()) return;
const transformedDate = value !== undefined ? this.transformDates()(value) : value;
this._mutableDate.set(transformedDate);
this._onChange?.(transformedDate);
this.dateChange.emit(transformedDate);
if (this.autoCloseOnMaxSelection() && this._mutableDate()?.length === this.maxSelection()) {
this._popoverState.set('closed');
}
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: T[] | null): void {
this._mutableDate.set(value ? this.transformDates()(value) : undefined);
}
public registerOnChange(fn: ChangeFn<T[]>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._onChange?.([]);
this.dateChange.emit([]);
}
}
export interface HlmDatePickerTriggerBase {
triggerId: Signal<string>;
}
export const HlmDatePickerTriggerToken = new InjectionToken<HlmDatePickerTriggerBase>('HlmDatePickerTriggerToken');
export function provideHlmDatePickerTrigger(instance: Type<HlmDatePickerTriggerBase>): ExistingProvider {
return { provide: HlmDatePickerTriggerToken, useExisting: instance };
}
@Component({
selector: 'hlm-date-picker-trigger',
imports: [HlmButtonImports, HlmPopoverTrigger, NgIcon, BrnFieldControlDescribedBy],
providers: [provideIcons({ lucideChevronDown }), provideHlmDatePickerTrigger(HlmDatePickerTrigger)],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { 'data-slot': 'date-picker-trigger' },
template: `
<button
[id]="buttonId()"
type="button"
[class]="_computedClass()"
[disabled]="_disabled()"
[attr.aria-invalid]="_ariaInvalid()"
[attr.data-invalid]="_ariaInvalid()"
[attr.data-touched]="_touched?.() ? 'true' : null"
[attr.data-dirty]="_dirty?.() ? 'true' : null"
[attr.data-matches-spartan-invalid]="_spartanInvalid() ? 'true' : null"
hlmBtn
[variant]="variant()"
hlmPopoverTrigger
[hlmPopoverTriggerFor]="_popover()"
brnFieldControlDescribedBy
[attr.data-placeholder]="_isPlaceholder() ? '' : null"
>
<span class="truncate">
@if (_formattedDate(); as formattedDate) {
{{ formattedDate }}
} @else {
<ng-content />
}
</span>
@if (showTrigger()) {
<ng-icon name="lucideChevronDown" />
}
</button>
`,
})
export class HlmDatePickerTrigger implements HlmDatePickerTriggerBase {
private static _nextId = 0;
private readonly _fieldControl = inject(BrnFieldControl, { optional: true });
private readonly _datePicker = injectHlmDatePicker();
private readonly _invalid = this._fieldControl?.invalid;
protected readonly _spartanInvalid = computed(() => this.forceInvalid() || this._fieldControl?.spartanInvalid());
protected readonly _dirty = this._fieldControl?.dirty;
protected readonly _touched = this._fieldControl?.touched;
protected readonly _ariaInvalid = computed(() => (this._invalid?.() ? 'true' : null));
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm('data-placeholder:text-muted-foreground w-64 justify-between', this.userClass()),
);
protected readonly _isPlaceholder = computed(() => !this._datePicker.hasDate());
/** The id of the button that opens the date picker. */
public readonly buttonId = input<string>(`hlm-date-picker-${++HlmDatePickerTrigger._nextId}`);
/** @internal The id of the button that opens the date picker, used for labeling. */
public readonly triggerId = this.buttonId;
/** Forces the invalid state visually, regardless of form control state. */
public readonly forceInvalid = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly showTrigger = input<boolean, BooleanInput>(true, { transform: booleanAttribute });
protected readonly _popover = this._datePicker.popover;
protected readonly _disabled = this._datePicker.disabledState;
protected readonly _formattedDate = this._datePicker.formattedDate;
}
export interface HlmDatePickerBase<T> {
popover: Signal<BrnPopover>;
disabledState: Signal<boolean>;
formattedDate: Signal<string | undefined>;
hasDate: Signal<boolean>;
/** Commit a date to the picker (e.g. from a parsed input). Pass `undefined` to clear. Optional. */
updateDate?(value: T | undefined): void;
// used for ControlValueAccessor
touched?(): void;
}
export const HlmDatePickerToken = new InjectionToken<HlmDatePickerBase<unknown>>('HlmDatePickerToken');
export function provideHlmDatePicker(instance: Type<HlmDatePickerBase<unknown>>): ExistingProvider {
return { provide: HlmDatePickerToken, useExisting: instance };
}
/**
* Inject the date picker component.
*/
export function injectHlmDatePicker<T>(): HlmDatePickerBase<T> {
return inject(HlmDatePickerToken) as HlmDatePickerBase<T>;
}
export interface HlmDatePickerConfig<T> {
/**
* If true, the date picker will close when a date is selected.
*/
autoCloseOnSelect: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param date
* @returns formatted date
*/
formatDate: (date: T) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param date
* @returns transformed date
*/
transformDate: (date: T) => T;
/**
* Parse a user-entered string into a date.
*
* @param value the raw string from the input
* @returns the parsed date, or `undefined` when the value can't be parsed
*/
parseDate: (value: string) => T | undefined;
}
function getDefaultConfig<T>(): HlmDatePickerConfig<T> {
return {
formatDate: (date) => (date instanceof Date ? date.toDateString() : `${date}`),
transformDate: (date) => date,
parseDate: (value) => {
const date = new Date(value);
return isNaN(date.getTime()) ? undefined : (date as T);
},
autoCloseOnSelect: false,
};
}
const HlmDatePickerConfigToken = new InjectionToken<HlmDatePickerConfig<unknown>>('HlmDatePickerConfig');
export function provideHlmDatePickerConfig<T>(config: Partial<HlmDatePickerConfig<T>>): ValueProvider {
return { provide: HlmDatePickerConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDatePickerConfig<T>(): HlmDatePickerConfig<T> {
const injectedConfig = inject(HlmDatePickerConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDatePickerConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_PICKER_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDatePicker),
multi: true,
};
@Component({
selector: 'hlm-date-picker',
imports: [HlmPopoverImports, HlmCalendar],
providers: [HLM_DATE_PICKER_VALUE_ACCESSOR, provideHlmDatePicker(HlmDatePicker), provideBrnLabelable(HlmDatePicker)],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar
class="rounded-none border-0"
[captionLayout]="captionLayout()"
[date]="_mutableDate()"
[defaultFocusedDate]="_mutableDate() ?? defaultFocusedDate()"
[min]="min()"
[max]="max()"
[disabled]="_disabled()"
(dateChange)="_handleChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDatePicker<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDatePickerConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<T>();
/** The date the calendar focuses on first open when no date is selected. */
public readonly defaultFocusedDate = input<T>();
protected readonly _mutableDate = linkedSignal(this.date);
/** If true, the date picker will close when a date is selected. */
public readonly autoCloseOnSelect = input<boolean, BooleanInput>(this._config.autoCloseOnSelect, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDate = input<(date: T) => string>(this._config.formatDate);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDate = input<(date: T) => T>(this._config.transformDate);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const date = this._mutableDate();
return date ? this.formatDate()(date) : undefined;
});
public readonly dateChange = output<T>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._mutableDate());
protected _onChange?: ChangeFn<T>;
protected _onTouched?: TouchFn;
protected _handleChange(value: T | undefined) {
if (this._disabled()) return;
this.updateDate(value);
if (this.autoCloseOnSelect()) {
this._popoverState.set('closed');
}
}
/**
* Commit a date to the picker. Updates the internal model, notifies form
* controls, and emits `dateChange`. Unlike `_handleChange`, this does not
* close the popover - it's intended to be called from a text input that
* is parsing user-entered values while typing.
*/
public updateDate(value: T | undefined) {
if (this._disabled()) return;
const transformedDate = value !== undefined ? this.transformDate()(value) : undefined;
this._mutableDate.set(transformedDate);
this._onChange?.(transformedDate as T);
this.dateChange.emit(transformedDate as T);
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: T | null): void {
this._mutableDate.set(value ? this.transformDate()(value) : undefined);
}
public registerOnChange(fn: ChangeFn<T>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public touched(): void {
this._onTouched?.();
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._onChange?.(undefined as T);
this.dateChange.emit(undefined as T);
}
}
export interface HlmDateRangePickerConfig<T> {
/**
* If true, the date picker will close when the max selection of dates is reached.
*/
autoCloseOnEndSelection: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param dates
* @returns formatted date
*/
formatDates: (dates: [T | undefined, T | undefined]) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param dates
* @returns transformed date
*/
transformDates: (dates: [T, T]) => [T, T];
}
function getDefaultConfig<T>(): HlmDateRangePickerConfig<T> {
return {
formatDates: (dates) =>
dates
.filter(Boolean)
.map((date) => (date instanceof Date ? date.toDateString() : `${date}`))
.join(' - '),
transformDates: (dates) => dates,
autoCloseOnEndSelection: false,
};
}
const HlmDateRangePickerConfigToken = new InjectionToken<HlmDateRangePickerConfig<unknown>>('HlmDateRangePickerConfig');
export function provideHlmDateRangePickerConfig<T>(config: Partial<HlmDateRangePickerConfig<T>>): ValueProvider {
return { provide: HlmDateRangePickerConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDateRangePickerConfig<T>(): HlmDateRangePickerConfig<T> {
const injectedConfig = inject(HlmDateRangePickerConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDateRangePickerConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDateRangePicker),
multi: true,
};
@Component({
selector: 'hlm-date-range-picker',
imports: [HlmPopoverImports, HlmCalendarRange],
providers: [
HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR,
provideHlmDatePicker(HlmDateRangePicker),
provideBrnLabelable(HlmDateRangePicker),
],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onClose(); _onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar-range
class="rounded-none border-0"
[startDate]="_start()"
[captionLayout]="captionLayout()"
[endDate]="_end()"
[min]="min()"
[max]="max()"
[disabled]="_disabled()"
(startDateChange)="_handleStartDayChange($event)"
(endDateChange)="_handleEndDateChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDateRangePicker<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDateRangePickerConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<[T, T]>();
protected readonly _mutableDate = linkedSignal(this.date);
protected readonly _start = linkedSignal(() => this._mutableDate()?.[0]);
protected readonly _end = linkedSignal(() => this._mutableDate()?.[1]);
/** If true, the date picker will close when the end date is selected */
public readonly autoCloseOnEndSelection = input<boolean, BooleanInput>(this._config.autoCloseOnEndSelection, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDates = input<(dates: [T | undefined, T | undefined]) => string>(this._config.formatDates);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDates = input<(date: [T, T]) => [T, T]>(this._config.transformDates);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const start = this._start();
const end = this._end();
return start || end ? this.formatDates()([start, end]) : undefined;
});
public readonly dateChange = output<[T, T] | null>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._start() || !!this._end());
protected _onChange?: ChangeFn<[T, T] | null>;
protected _onTouched?: TouchFn;
protected _handleStartDayChange(value: T | undefined) {
this._start.set(value);
}
protected _handleEndDateChange(value: T | undefined): void {
this._end.set(value);
if (this._disabled()) return;
const start = this._start();
if (start && value) {
const transformedDates = this.transformDates()([start, value]);
this._mutableDate.set(transformedDates);
this.dateChange.emit(transformedDates);
this._onChange?.(transformedDates);
if (this.autoCloseOnEndSelection()) {
this._popoverState.set('closed');
}
}
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: [T, T] | null): void {
untracked(() => {
if (!value) {
this._mutableDate.set(undefined);
} else {
this._mutableDate.set(this.transformDates()(value));
}
});
}
public registerOnChange(fn: ChangeFn<[T, T] | null>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._start.set(undefined);
this._end.set(undefined);
this._onChange?.(null);
this.dateChange.emit(null);
}
protected _onClose(): void {
const dates = this._mutableDate();
if (this._start() && !this._end() && dates) {
this._start.set(dates[0]);
this._end.set(dates[1]);
}
}
}
export const HlmDatePickerImports = [
HlmDatePicker,
HlmDatePickerAnchor,
HlmDatePickerInput,
HlmDatePickerMulti,
HlmDateRangePicker,
HlmDatePickerTrigger,
] as const;import { BooleanInput, type BooleanInput, type NumberInput } from '@angular/cdk/coercion';
import { BrnFieldControl, BrnFieldControlDescribedBy, provideBrnLabelable } from '@spartan-ng/brain/field';
import { BrnOverlay, type BrnOverlayState } from '@spartan-ng/brain/overlay';
import { BrnPopover, type BrnPopover } from '@spartan-ng/brain/popover';
import { ButtonVariants, HlmButtonImports } from '@spartan-ng/helm/button';
import { ChangeDetectionStrategy, Component, Directive, ElementRef, InjectionToken, booleanAttribute, computed, contentChild, effect, forwardRef, inject, input, linkedSignal, numberAttribute, output, signal, untracked, viewChild, type ExistingProvider, type Signal, type Type, type ValueProvider } from '@angular/core';
import { ClassValue } from 'clsx';
import { HlmCalendar, HlmCalendarMulti, HlmCalendarRange } from '@spartan-ng/helm/calendar';
import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
import { HlmPopoverImports, HlmPopoverTrigger } from '@spartan-ng/helm/popover';
import { NG_VALUE_ACCESSOR, type ControlValueAccessor } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { hlm } from '@spartan-ng/helm/utils';
import { lucideCalendar, lucideChevronDown, lucideX } from '@ng-icons/lucide';
import { type ChangeFn, type TouchFn } from '@spartan-ng/brain/forms';
@Directive({ selector: '[hlmDatePickerAnchor]' })
export class HlmDatePickerAnchor {
private readonly _host = inject(ElementRef, { host: true });
private readonly _brnOverlay = inject(BrnOverlay, { optional: true });
public readonly hlmDatePickerAnchorFor = input<BrnPopover | undefined>(undefined, {
alias: 'hlmDatePickerAnchorFor',
});
constructor() {
effect(() => {
this.hlmDatePickerAnchorFor()?.setOrigin(this._host.nativeElement);
});
this._brnOverlay?.setOrigin(this._host.nativeElement);
}
}
@Component({
selector: 'hlm-date-picker-input',
imports: [HlmInputGroupImports, HlmButtonImports, HlmDatePickerAnchor, NgIcon],
providers: [provideIcons({ lucideCalendar, lucideX }), provideHlmDatePickerTrigger(HlmDatePickerInput)],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-input-group hlmDatePickerAnchor [hlmDatePickerAnchorFor]="_popover()">
<input
hlmInputGroupInput
[value]="_inputValue()"
[id]="inputId()"
[placeholder]="placeholder()"
[disabled]="_disabled()"
[forceInvalid]="forceInvalid()"
(click)="_handleClick()"
(keydown.arrowDown)="_open()"
(keydown.enter)="_handleEnter($event)"
(input)="_handleInputChange($event)"
(blur)="_commitDate()"
/>
<hlm-input-group-addon align="inline-end">
@if (_showClearButton()) {
<button
hlmInputGroupButton
size="icon-xs"
variant="ghost"
[attr.aria-label]="clearAriaLabel()"
(click)="_clear()"
[disabled]="_disabled()"
>
<ng-icon name="lucideX" />
</button>
}
<button
hlmInputGroupButton
size="icon-xs"
[attr.aria-label]="calendarAriaLabel()"
(click)="_popover().open()"
[disabled]="_disabled()"
>
<ng-icon name="lucideCalendar" />
</button>
</hlm-input-group-addon>
</hlm-input-group>
`,
})
export class HlmDatePickerInput<T> implements HlmDatePickerTriggerBase {
private static _nextId = 0;
private readonly _datePicker = injectHlmDatePicker<T>();
private readonly _config = injectHlmDatePickerConfig<T>();
protected readonly _popover = this._datePicker.popover;
protected readonly _disabled = this._datePicker.disabledState;
public readonly inputId = input(`hlm-date-picker-input-${HlmDatePickerInput._nextId++}`);
public readonly placeholder = input('');
public readonly inputValue = input<string>('');
/**
* Parses input text into a date value. Return `undefined` for invalid
* input - the picker's date is cleared while the text is preserved so
* the user can fix it.
*
* Defaults to `parseDate` from `HlmDatePickerConfig`.
*/
public readonly parseDate = input<(value: string) => T | undefined>(this._config.parseDate);
public readonly forceInvalid = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
/** Show a clear button that resets the input and picker date. Hidden when empty. */
public readonly showClear = input<boolean, BooleanInput>(true, { transform: booleanAttribute });
/** Open the popover on input click. */
public readonly openOnClick = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
/** Accessible label for the clear button. */
public readonly clearAriaLabel = input<string>('Clear date');
/** Accessible label for the calendar trigger button. */
public readonly calendarAriaLabel = input<string>('Open calendar');
/** @internal Id used by the trigger contract for labeling. */
public readonly triggerId = this.inputId;
/**
* Text shown in the input. Mirrors the picker's `formattedDate` and the
* parent's `inputValue`, and accepts user writes via `_handleInputChange`.
* Commits only happen on blur / Enter, so in-progress text isn't clobbered.
*/
protected readonly _inputValue = linkedSignal<{ formatted: string | undefined; inputValue: string }, string>({
source: () => ({
formatted: this._datePicker.formattedDate(),
inputValue: this.inputValue(),
}),
computation: (source, previous) => {
// First render: prefer formatted, fall back to inputValue.
if (previous === undefined) {
return source.formatted ?? source.inputValue;
}
// Picker's formatted date changed - snap to canonical format.
if (source.formatted !== previous.source.formatted) {
if (source.formatted !== undefined) {
return source.formatted;
}
// Cleared externally vs. user has invalid text in flight: only
// mirror the clear when the displayed text was in sync.
return previous.value === previous.source.formatted ? '' : previous.value;
}
// Parent updated inputValue - reflect it.
if (source.inputValue !== previous.source.inputValue) {
return source.inputValue;
}
return previous.value;
},
});
protected _handleInputChange(event: Event) {
const text = (event.target as HTMLInputElement).value;
this._inputValue.set(text);
}
protected readonly _showClearButton = computed(() => this.showClear() && this._inputValue().length > 0);
protected _clear() {
this._inputValue.set('');
this._datePicker.updateDate?.(undefined);
this._datePicker.touched?.();
}
protected _handleEnter(event: Event) {
event.preventDefault();
this._commitDate();
this._popover().close();
}
protected _commitDate() {
const value = this._inputValue();
if (!value) {
this._datePicker.updateDate?.(undefined);
this._datePicker.touched?.();
return;
}
// Invalid parse: clear the picker date, keep the text so the user can fix it.
const parsed = this.parseDate()(value);
this._datePicker.updateDate?.(parsed ?? undefined);
this._datePicker.touched?.();
}
protected _open() {
this._popover().open();
}
protected _handleClick() {
if (this.openOnClick()) {
this._open();
}
}
}
export interface HlmDatePickerMultiConfig<T> {
/**
* If true, the date picker will close when the max selection of dates is reached.
*/
autoCloseOnMaxSelection: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param dates
* @returns formatted date
*/
formatDates: (dates: T[]) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param dates
* @returns transformed date
*/
transformDates: (dates: T[]) => T[];
}
function getDefaultConfig<T>(): HlmDatePickerMultiConfig<T> {
return {
formatDates: (dates) => dates.map((date) => (date instanceof Date ? date.toDateString() : `${date}`)).join(', '),
transformDates: (dates) => dates,
autoCloseOnMaxSelection: false,
};
}
const HlmDatePickerMultiConfigToken = new InjectionToken<HlmDatePickerMultiConfig<unknown>>('HlmDatePickerMultiConfig');
export function provideHlmDatePickerMultiConfig<T>(config: Partial<HlmDatePickerMultiConfig<T>>): ValueProvider {
return { provide: HlmDatePickerMultiConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDatePickerMultiConfig<T>(): HlmDatePickerMultiConfig<T> {
const injectedConfig = inject(HlmDatePickerMultiConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDatePickerMultiConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_PICKER_MUTLI_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDatePickerMulti),
multi: true,
};
@Component({
selector: 'hlm-date-picker-multi',
imports: [HlmPopoverImports, HlmCalendarMulti],
providers: [
HLM_DATE_PICKER_MUTLI_VALUE_ACCESSOR,
provideHlmDatePicker(HlmDatePickerMulti),
provideBrnLabelable(HlmDatePickerMulti),
],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar-multi
class="rounded-none border-0"
[date]="_mutableDate()"
[captionLayout]="captionLayout()"
[min]="min()"
[max]="max()"
[minSelection]="minSelection()"
[maxSelection]="maxSelection()"
[disabled]="_disabled()"
(dateChange)="_handleChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDatePickerMulti<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDatePickerMultiConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** The minimum selectable dates. */
public readonly minSelection = input<number, NumberInput>(undefined, {
transform: numberAttribute,
});
/** The maximum selectable dates. */
public readonly maxSelection = input<number, NumberInput>(undefined, {
transform: numberAttribute,
});
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<T[]>();
protected readonly _mutableDate = linkedSignal(this.date);
/** If true, the date picker will close when the max selection of dates is reached. */
public readonly autoCloseOnMaxSelection = input<boolean, BooleanInput>(this._config.autoCloseOnMaxSelection, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDates = input<(date: T[]) => string>(this._config.formatDates);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDates = input<(date: T[]) => T[]>(this._config.transformDates);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const dates = this._mutableDate();
return dates ? this.formatDates()(dates) : undefined;
});
public readonly dateChange = output<T[]>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._mutableDate()?.length);
protected _onChange?: ChangeFn<T[]>;
protected _onTouched?: TouchFn;
protected _handleChange(value: T[] | undefined) {
if (value === undefined) return;
if (this._disabled()) return;
const transformedDate = value !== undefined ? this.transformDates()(value) : value;
this._mutableDate.set(transformedDate);
this._onChange?.(transformedDate);
this.dateChange.emit(transformedDate);
if (this.autoCloseOnMaxSelection() && this._mutableDate()?.length === this.maxSelection()) {
this._popoverState.set('closed');
}
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: T[] | null): void {
this._mutableDate.set(value ? this.transformDates()(value) : undefined);
}
public registerOnChange(fn: ChangeFn<T[]>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._onChange?.([]);
this.dateChange.emit([]);
}
}
export interface HlmDatePickerTriggerBase {
triggerId: Signal<string>;
}
export const HlmDatePickerTriggerToken = new InjectionToken<HlmDatePickerTriggerBase>('HlmDatePickerTriggerToken');
export function provideHlmDatePickerTrigger(instance: Type<HlmDatePickerTriggerBase>): ExistingProvider {
return { provide: HlmDatePickerTriggerToken, useExisting: instance };
}
@Component({
selector: 'hlm-date-picker-trigger',
imports: [HlmButtonImports, HlmPopoverTrigger, NgIcon, BrnFieldControlDescribedBy],
providers: [provideIcons({ lucideChevronDown }), provideHlmDatePickerTrigger(HlmDatePickerTrigger)],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { 'data-slot': 'date-picker-trigger' },
template: `
<button
[id]="buttonId()"
type="button"
[class]="_computedClass()"
[disabled]="_disabled()"
[attr.aria-invalid]="_ariaInvalid()"
[attr.data-invalid]="_ariaInvalid()"
[attr.data-touched]="_touched?.() ? 'true' : null"
[attr.data-dirty]="_dirty?.() ? 'true' : null"
[attr.data-matches-spartan-invalid]="_spartanInvalid() ? 'true' : null"
hlmBtn
[variant]="variant()"
hlmPopoverTrigger
[hlmPopoverTriggerFor]="_popover()"
brnFieldControlDescribedBy
[attr.data-placeholder]="_isPlaceholder() ? '' : null"
>
<span class="truncate">
@if (_formattedDate(); as formattedDate) {
{{ formattedDate }}
} @else {
<ng-content />
}
</span>
@if (showTrigger()) {
<ng-icon name="lucideChevronDown" />
}
</button>
`,
})
export class HlmDatePickerTrigger implements HlmDatePickerTriggerBase {
private static _nextId = 0;
private readonly _fieldControl = inject(BrnFieldControl, { optional: true });
private readonly _datePicker = injectHlmDatePicker();
private readonly _invalid = this._fieldControl?.invalid;
protected readonly _spartanInvalid = computed(() => this.forceInvalid() || this._fieldControl?.spartanInvalid());
protected readonly _dirty = this._fieldControl?.dirty;
protected readonly _touched = this._fieldControl?.touched;
protected readonly _ariaInvalid = computed(() => (this._invalid?.() ? 'true' : null));
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm('data-placeholder:text-muted-foreground w-64 justify-between', this.userClass()),
);
protected readonly _isPlaceholder = computed(() => !this._datePicker.hasDate());
/** The id of the button that opens the date picker. */
public readonly buttonId = input<string>(`hlm-date-picker-${++HlmDatePickerTrigger._nextId}`);
/** @internal The id of the button that opens the date picker, used for labeling. */
public readonly triggerId = this.buttonId;
/** Forces the invalid state visually, regardless of form control state. */
public readonly forceInvalid = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly showTrigger = input<boolean, BooleanInput>(true, { transform: booleanAttribute });
protected readonly _popover = this._datePicker.popover;
protected readonly _disabled = this._datePicker.disabledState;
protected readonly _formattedDate = this._datePicker.formattedDate;
}
export interface HlmDatePickerBase<T> {
popover: Signal<BrnPopover>;
disabledState: Signal<boolean>;
formattedDate: Signal<string | undefined>;
hasDate: Signal<boolean>;
/** Commit a date to the picker (e.g. from a parsed input). Pass `undefined` to clear. Optional. */
updateDate?(value: T | undefined): void;
// used for ControlValueAccessor
touched?(): void;
}
export const HlmDatePickerToken = new InjectionToken<HlmDatePickerBase<unknown>>('HlmDatePickerToken');
export function provideHlmDatePicker(instance: Type<HlmDatePickerBase<unknown>>): ExistingProvider {
return { provide: HlmDatePickerToken, useExisting: instance };
}
/**
* Inject the date picker component.
*/
export function injectHlmDatePicker<T>(): HlmDatePickerBase<T> {
return inject(HlmDatePickerToken) as HlmDatePickerBase<T>;
}
export interface HlmDatePickerConfig<T> {
/**
* If true, the date picker will close when a date is selected.
*/
autoCloseOnSelect: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param date
* @returns formatted date
*/
formatDate: (date: T) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param date
* @returns transformed date
*/
transformDate: (date: T) => T;
/**
* Parse a user-entered string into a date.
*
* @param value the raw string from the input
* @returns the parsed date, or `undefined` when the value can't be parsed
*/
parseDate: (value: string) => T | undefined;
}
function getDefaultConfig<T>(): HlmDatePickerConfig<T> {
return {
formatDate: (date) => (date instanceof Date ? date.toDateString() : `${date}`),
transformDate: (date) => date,
parseDate: (value) => {
const date = new Date(value);
return isNaN(date.getTime()) ? undefined : (date as T);
},
autoCloseOnSelect: false,
};
}
const HlmDatePickerConfigToken = new InjectionToken<HlmDatePickerConfig<unknown>>('HlmDatePickerConfig');
export function provideHlmDatePickerConfig<T>(config: Partial<HlmDatePickerConfig<T>>): ValueProvider {
return { provide: HlmDatePickerConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDatePickerConfig<T>(): HlmDatePickerConfig<T> {
const injectedConfig = inject(HlmDatePickerConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDatePickerConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_PICKER_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDatePicker),
multi: true,
};
@Component({
selector: 'hlm-date-picker',
imports: [HlmPopoverImports, HlmCalendar],
providers: [HLM_DATE_PICKER_VALUE_ACCESSOR, provideHlmDatePicker(HlmDatePicker), provideBrnLabelable(HlmDatePicker)],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar
class="rounded-none border-0"
[captionLayout]="captionLayout()"
[date]="_mutableDate()"
[defaultFocusedDate]="_mutableDate() ?? defaultFocusedDate()"
[min]="min()"
[max]="max()"
[disabled]="_disabled()"
(dateChange)="_handleChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDatePicker<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDatePickerConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<T>();
/** The date the calendar focuses on first open when no date is selected. */
public readonly defaultFocusedDate = input<T>();
protected readonly _mutableDate = linkedSignal(this.date);
/** If true, the date picker will close when a date is selected. */
public readonly autoCloseOnSelect = input<boolean, BooleanInput>(this._config.autoCloseOnSelect, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDate = input<(date: T) => string>(this._config.formatDate);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDate = input<(date: T) => T>(this._config.transformDate);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const date = this._mutableDate();
return date ? this.formatDate()(date) : undefined;
});
public readonly dateChange = output<T>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._mutableDate());
protected _onChange?: ChangeFn<T>;
protected _onTouched?: TouchFn;
protected _handleChange(value: T | undefined) {
if (this._disabled()) return;
this.updateDate(value);
if (this.autoCloseOnSelect()) {
this._popoverState.set('closed');
}
}
/**
* Commit a date to the picker. Updates the internal model, notifies form
* controls, and emits `dateChange`. Unlike `_handleChange`, this does not
* close the popover - it's intended to be called from a text input that
* is parsing user-entered values while typing.
*/
public updateDate(value: T | undefined) {
if (this._disabled()) return;
const transformedDate = value !== undefined ? this.transformDate()(value) : undefined;
this._mutableDate.set(transformedDate);
this._onChange?.(transformedDate as T);
this.dateChange.emit(transformedDate as T);
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: T | null): void {
this._mutableDate.set(value ? this.transformDate()(value) : undefined);
}
public registerOnChange(fn: ChangeFn<T>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public touched(): void {
this._onTouched?.();
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._onChange?.(undefined as T);
this.dateChange.emit(undefined as T);
}
}
export interface HlmDateRangePickerConfig<T> {
/**
* If true, the date picker will close when the max selection of dates is reached.
*/
autoCloseOnEndSelection: boolean;
/**
* Defines how the date should be displayed in the UI.
*
* @param dates
* @returns formatted date
*/
formatDates: (dates: [T | undefined, T | undefined]) => string;
/**
* Defines how the date should be transformed before saving to model/form.
*
* @param dates
* @returns transformed date
*/
transformDates: (dates: [T, T]) => [T, T];
}
function getDefaultConfig<T>(): HlmDateRangePickerConfig<T> {
return {
formatDates: (dates) =>
dates
.filter(Boolean)
.map((date) => (date instanceof Date ? date.toDateString() : `${date}`))
.join(' - '),
transformDates: (dates) => dates,
autoCloseOnEndSelection: false,
};
}
const HlmDateRangePickerConfigToken = new InjectionToken<HlmDateRangePickerConfig<unknown>>('HlmDateRangePickerConfig');
export function provideHlmDateRangePickerConfig<T>(config: Partial<HlmDateRangePickerConfig<T>>): ValueProvider {
return { provide: HlmDateRangePickerConfigToken, useValue: { ...getDefaultConfig(), ...config } };
}
export function injectHlmDateRangePickerConfig<T>(): HlmDateRangePickerConfig<T> {
const injectedConfig = inject(HlmDateRangePickerConfigToken, { optional: true });
return injectedConfig ? (injectedConfig as HlmDateRangePickerConfig<T>) : getDefaultConfig();
}
export const HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HlmDateRangePicker),
multi: true,
};
@Component({
selector: 'hlm-date-range-picker',
imports: [HlmPopoverImports, HlmCalendarRange],
providers: [
HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR,
provideHlmDatePicker(HlmDateRangePicker),
provideBrnLabelable(HlmDateRangePicker),
],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnFieldControl],
host: { class: 'block' },
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onClose(); _onTouched?.()"
>
<ng-content />
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<ng-content select="[hlmDatePickerHeader]" />
<hlm-calendar-range
class="rounded-none border-0"
[startDate]="_start()"
[captionLayout]="captionLayout()"
[endDate]="_end()"
[min]="min()"
[max]="max()"
[disabled]="_disabled()"
(startDateChange)="_handleStartDayChange($event)"
(endDateChange)="_handleEndDateChange($event)"
/>
<ng-content select="[hlmDatePickerFooter]" />
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDateRangePicker<T> implements HlmDatePickerBase<T>, ControlValueAccessor {
private readonly _config = injectHlmDateRangePickerConfig<T>();
public readonly popover = viewChild.required(BrnPopover);
private readonly _trigger = contentChild(HlmDatePickerTriggerToken);
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** The minimum date that can be selected.*/
public readonly min = input<T>();
/** The maximum date that can be selected. */
public readonly max = input<T>();
/** Determine if the date picker is disabled. */
public readonly disabled = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
});
/** The selected value. */
public readonly date = input<[T, T]>();
protected readonly _mutableDate = linkedSignal(this.date);
protected readonly _start = linkedSignal(() => this._mutableDate()?.[0]);
protected readonly _end = linkedSignal(() => this._mutableDate()?.[1]);
/** If true, the date picker will close when the end date is selected */
public readonly autoCloseOnEndSelection = input<boolean, BooleanInput>(this._config.autoCloseOnEndSelection, {
transform: booleanAttribute,
});
/** Defines how the date should be displayed in the UI. */
public readonly formatDates = input<(dates: [T | undefined, T | undefined]) => string>(this._config.formatDates);
/** Defines how the date should be transformed before saving to model/form. */
public readonly transformDates = input<(date: [T, T]) => [T, T]>(this._config.transformDates);
protected readonly _popoverState = signal<BrnOverlayState | null>(null);
protected readonly _disabled = linkedSignal(this.disabled);
/** @internal The disabled state as a readonly signal */
public readonly disabledState = this._disabled.asReadonly();
public readonly formattedDate = computed(() => {
const start = this._start();
const end = this._end();
return start || end ? this.formatDates()([start, end]) : undefined;
});
public readonly dateChange = output<[T, T] | null>();
public readonly labelableId = computed(() => this._trigger()?.triggerId());
public readonly hasDate = computed(() => !!this._start() || !!this._end());
protected _onChange?: ChangeFn<[T, T] | null>;
protected _onTouched?: TouchFn;
protected _handleStartDayChange(value: T | undefined) {
this._start.set(value);
}
protected _handleEndDateChange(value: T | undefined): void {
this._end.set(value);
if (this._disabled()) return;
const start = this._start();
if (start && value) {
const transformedDates = this.transformDates()([start, value]);
this._mutableDate.set(transformedDates);
this.dateChange.emit(transformedDates);
this._onChange?.(transformedDates);
if (this.autoCloseOnEndSelection()) {
this._popoverState.set('closed');
}
}
}
/** CONTROL VALUE ACCESSOR */
public writeValue(value: [T, T] | null): void {
untracked(() => {
if (!value) {
this._mutableDate.set(undefined);
} else {
this._mutableDate.set(this.transformDates()(value));
}
});
}
public registerOnChange(fn: ChangeFn<[T, T] | null>): void {
this._onChange = fn;
}
public registerOnTouched(fn: TouchFn): void {
this._onTouched = fn;
}
public setDisabledState(isDisabled: boolean): void {
this._disabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
public reset() {
this._mutableDate.set(undefined);
this._start.set(undefined);
this._end.set(undefined);
this._onChange?.(null);
this.dateChange.emit(null);
}
protected _onClose(): void {
const dates = this._mutableDate();
if (this._start() && !this._end() && dates) {
this._start.set(dates[0]);
this._end.set(dates[1]);
}
}
}
export const HlmDatePickerImports = [
HlmDatePicker,
HlmDatePickerAnchor,
HlmDatePickerInput,
HlmDatePickerMulti,
HlmDateRangePicker,
HlmDatePickerTrigger,
] as const;Usage
import { HlmDatePickerImports } from '@spartan-ng/helm/date-picker';<hlm-date-picker [min]="minDate" [max]="maxDate">
<hlm-date-picker-trigger buttonId="date">Pick a date</hlm-date-picker-trigger>
</hlm-date-picker>Examples
Custom Configs
Use provideHlmDatePickerConfig to provide custom configs for the date picker component throughout the application.
autoCloseOnSelect: boolean;iftrue, the date picker will close when a date is selected.formatDate: (date: T) => string;defines the default format how the date should be displayed in the UI.transformDate: (date: T) => T;defines the default format how the date should be transformed before saving to model/form.
import { Component } from '@angular/core';
import { HlmDatePickerImports, provideHlmDatePickerConfig } from '@spartan-ng/helm/date-picker';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { DateTime } from 'luxon';
@Component({
selector: 'spartan-date-picker-config',
imports: [HlmDatePickerImports, HlmFieldImports],
providers: [
provideHlmDatePickerConfig({
formatDate: (date: Date) => DateTime.fromJSDate(date).toFormat('dd.MM.yyyy'),
transformDate: (date: Date) => DateTime.fromJSDate(date).plus({ days: 1 }).toJSDate(),
}),
],
template: `
<hlm-field>
<label hlmFieldLabel for="date-custom-config">Date Picker with Config</label>
<hlm-date-picker [min]="minDate" [max]="maxDate">
<hlm-date-picker-trigger buttonId="date-custom-config">Pick a date</hlm-date-picker-trigger>
</hlm-date-picker>
</hlm-field>
`,
})
export class DatePickerConfigExample {
/** The minimum date */
public minDate = new Date(2023, 0, 1);
/** The maximum date */
public maxDate = new Date(2030, 11, 31);
}Multiple Selection
Use hlm-date-picker-multi for multiple date selection. Limit the selectable dates using minSelection and maxSelection inputs.
import { Component } from '@angular/core';
import { HlmDatePickerImports } from '@spartan-ng/helm/date-picker';
import { HlmFieldImports } from '@spartan-ng/helm/field';
@Component({
selector: 'spartan-date-picker-multiple',
imports: [HlmDatePickerImports, HlmFieldImports],
template: `
<hlm-field>
<label hlmFieldLabel for="date-multi">Date Picker Multiple</label>
<hlm-date-picker-multi
[min]="minDate"
[max]="maxDate"
[autoCloseOnMaxSelection]="true"
[minSelection]="2"
[maxSelection]="6"
>
<hlm-date-picker-trigger buttonId="date-multi">Pick dates</hlm-date-picker-trigger>
</hlm-date-picker-multi>
</hlm-field>
`,
})
export class DatePickerMultipleExample {
/** The minimum date */
public minDate = new Date(2023, 0, 1);
/** The maximum date */
public maxDate = new Date(2030, 11, 31);
}Range Picker
Use hlm-date-range-picker for range date selection. Set the range by using startDate and endDate inputs.
import { Component } from '@angular/core';
import { HlmDatePickerImports } from '@spartan-ng/helm/date-picker';
import { HlmFieldImports } from '@spartan-ng/helm/field';
@Component({
selector: 'spartan-date-picker-range',
imports: [HlmDatePickerImports, HlmFieldImports],
template: `
<hlm-field>
<label hlmFieldLabel for="date-range-picker">Date Picker Range</label>
<hlm-date-range-picker [min]="minDate" [max]="maxDate" [autoCloseOnEndSelection]="true">
<hlm-date-picker-trigger buttonId="date-range-picker">Enter a date range</hlm-date-picker-trigger>
</hlm-date-range-picker>
</hlm-field>
`,
})
export class DatePickerRangeExample {
/** The minimum date */
public minDate = new Date(2023, 0, 1);
/** The maximum date */
public maxDate = new Date(2030, 11, 31);
}Format Date
Use formatDate input to override the global date format for the date picker component.
import { Component } from '@angular/core';
import { HlmDatePickerImports, provideHlmDatePickerConfig } from '@spartan-ng/helm/date-picker';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { DateTime } from 'luxon';
@Component({
selector: 'spartan-date-picker-format',
imports: [HlmDatePickerImports, HlmFieldImports],
providers: [
// Global formatDate config
provideHlmDatePickerConfig({ formatDate: (date: Date) => DateTime.fromJSDate(date).toFormat('dd.MM.yyyy') }),
],
template: `
<hlm-field>
<label hlmFieldLabel for="date-format">Date Picker with Custom Format</label>
<hlm-date-picker [min]="minDate" [max]="maxDate" [formatDate]="formatDate">
<hlm-date-picker-trigger buttonId="date-format">Pick a date</hlm-date-picker-trigger>
</hlm-date-picker>
</hlm-field>
`,
})
export class DatePickerFormatExample {
/** The minimum date */
public minDate = new Date(2023, 0, 1);
/** The maximum date */
public maxDate = new Date(2030, 11, 31);
/** Overrides global formatDate */
public formatDate = (date: Date) => DateTime.fromJSDate(date).toFormat('MMMM dd, yyyy');
}Input Picker
Use hlm-date-picker-input instead of hlm-date-picker-trigger to let users type a date directly. Provide a parseDate input (or set it globally via provideHlmDatePickerConfig ) to control how typed strings are parsed into dates - typically pair it with a matching formatDate .
import { Component } from '@angular/core';
import { HlmDatePickerImports } from '@spartan-ng/helm/date-picker';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { DateTime } from 'luxon';
@Component({
selector: 'spartan-date-picker-input-example',
imports: [HlmDatePickerImports, HlmFieldImports],
template: `
<hlm-field>
<label hlmFieldLabel for="date-input">Date of birth</label>
<hlm-date-picker
captionLayout="dropdown"
[max]="maxDate"
[defaultFocusedDate]="defaultFocusedDate"
[formatDate]="formatDate"
>
<hlm-date-picker-input inputId="date-input" placeholder="dd.MM.yyyy" [parseDate]="parseDate" />
</hlm-date-picker>
</hlm-field>
`,
})
export class DatePickerInputExample {
/** The maximum date */
public maxDate = new Date();
/** Open the calendar at "today minus 18 years" - a reasonable anchor for a date-of-birth picker. */
public defaultFocusedDate = DateTime.now().minus({ years: 18 }).toJSDate();
/** Format dates as `dd.MM.yyyy` (e.g. `01.07.2026`). */
public formatDate = (date: Date): string => DateTime.fromJSDate(date).toFormat('dd.MM.yyyy');
/** Parse `dd.MM.yyyy` strings back into `Date` instances. */
public parseDate = (value: string): Date | undefined => {
const dt = DateTime.fromFormat(value, 'dd.MM.yyyy');
return dt.isValid ? dt.toJSDate() : undefined;
};
}Date and Time picker
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HlmDatePickerImports } from '@spartan-ng/helm/date-picker';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { HlmInputImports } from '@spartan-ng/helm/input';
@Component({
selector: 'spartan-date-and-time-picker',
imports: [HlmDatePickerImports, HlmFieldImports, HlmInputImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'w-full max-w-xs',
},
template: `
<hlm-field-group class="mx-auto max-w-xs flex-row">
<hlm-field>
<label hlmFieldLabel for="date-picker">Date</label>
<hlm-date-picker [min]="minDate" [max]="maxDate">
<hlm-date-picker-trigger buttonId="date-picker">Select date</hlm-date-picker-trigger>
</hlm-date-picker>
</hlm-field>
<hlm-field>
<label hlmFieldLabel for="time-picker">Time</label>
<input
hlmInput
id="time-picker"
type="time"
step="1"
[defaultValue]="'10:30:00'"
class="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</hlm-field>
</hlm-field-group>
`,
})
export class DateAndTimePickerExample {
/** The minimum date */
public minDate = new Date(2023, 0, 1);
/** The maximum date */
public maxDate = new Date(2030, 11, 31);
}Form
Sync the date to a form by adding formControlName to hlm-date-picker .
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmDatePickerImports } from '@spartan-ng/helm/date-picker';
import { HlmFieldImports } from '@spartan-ng/helm/field';
@Component({
selector: 'spartan-date-picker-form',
imports: [HlmDatePickerImports, ReactiveFormsModule, HlmButtonImports, HlmFieldImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'w-full max-w-xs',
},
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<hlm-field-group>
<hlm-field>
<label for="date-birthday" hlmFieldLabel>Date of birth</label>
<hlm-date-picker [min]="minDate" [max]="maxDate" formControlName="birthday" [autoCloseOnSelect]="true">
<hlm-date-picker-trigger buttonId="date-birthday">Pick a date</hlm-date-picker-trigger>
</hlm-date-picker>
<hlm-field-description>Your date of birth is used to calculate your age.</hlm-field-description>
</hlm-field>
<hlm-field orientation="horizontal">
<button type="submit" hlmBtn [disabled]="form.invalid">Submit</button>
</hlm-field>
</hlm-field-group>
</form>
`,
})
export class DatePickerFormExample {
private readonly _formBuilder = inject(FormBuilder);
public form = this._formBuilder.group({
birthday: [null, Validators.required],
});
/** The minimum date */
public minDate = new Date(2023, 0, 1);
/** The maximum date */
public maxDate = new Date(2030, 11, 31);
submit() {
console.log(this.form.value);
}
}Form with Input Picker
Combine formControlName with hlm-date-picker-input to let users type a date or pick one from the calendar while syncing the value to a reactive form.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmDatePickerImports } from '@spartan-ng/helm/date-picker';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { DateTime } from 'luxon';
@Component({
selector: 'spartan-date-picker-form-input',
imports: [HlmDatePickerImports, ReactiveFormsModule, HlmButtonImports, HlmFieldImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'w-full max-w-xs',
},
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<hlm-field-group>
<hlm-field>
<label for="date-birthday-input" hlmFieldLabel>Date of birth</label>
<hlm-date-picker
captionLayout="dropdown"
formControlName="birthday"
[max]="maxDate"
[defaultFocusedDate]="defaultFocusedDate"
[formatDate]="formatDate"
>
<hlm-date-picker-input
inputId="date-birthday-input"
placeholder="dd.MM.yyyy"
showClear
[parseDate]="parseDate"
/>
</hlm-date-picker>
<hlm-field-description>Type a date (dd.MM.yyyy) or pick one from the calendar.</hlm-field-description>
</hlm-field>
<hlm-field orientation="horizontal">
<button type="submit" hlmBtn [disabled]="form.invalid">Submit</button>
</hlm-field>
</hlm-field-group>
</form>
`,
})
export class DatePickerFormInputExample {
private readonly _formBuilder = inject(FormBuilder);
public form = this._formBuilder.group({
birthday: [null, Validators.required],
});
/** The maximum date */
public maxDate = new Date();
/** Open the calendar at "today minus 18 years" - a reasonable anchor for a date-of-birth picker. */
public defaultFocusedDate = DateTime.now().minus({ years: 18 }).toJSDate();
/** Format dates as `dd.MM.yyyy` (e.g. `01.07.2026`). */
public formatDate = (date: Date): string => DateTime.fromJSDate(date).toFormat('dd.MM.yyyy');
/** Parse `dd.MM.yyyy` strings back into `Date` instances. */
public parseDate = (value: string): Date | undefined => {
const dt = DateTime.fromFormat(value, 'dd.MM.yyyy');
return dt.isValid ? dt.toJSDate() : undefined;
};
submit() {
console.log(this.form.value);
}
}Form Multiple Selection
Sync the dates to a form by adding formControlName to hlm-date-picker-multi .
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmDatePickerImports } from '@spartan-ng/helm/date-picker';
import { HlmFieldImports } from '@spartan-ng/helm/field';
@Component({
selector: 'spartan-date-picker-form-multiple',
imports: [HlmDatePickerImports, ReactiveFormsModule, HlmButtonImports, HlmFieldImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'w-full max-w-xs',
},
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<hlm-field-group>
<hlm-field>
<label hlmFieldLabel for="date-available-dates">Available dates</label>
<hlm-date-picker-multi
[min]="minDate"
[max]="maxDate"
formControlName="availableDates"
[autoCloseOnMaxSelection]="true"
[minSelection]="2"
[maxSelection]="4"
>
<hlm-date-picker-trigger buttonId="date-available-dates">Pick dates</hlm-date-picker-trigger>
</hlm-date-picker-multi>
</hlm-field>
<hlm-field orientation="horizontal">
<button type="submit" hlmBtn [disabled]="form.invalid">Submit</button>
</hlm-field>
</hlm-field-group>
</form>
`,
})
export class DatePickerFormMultipleExample {
private readonly _formBuilder = inject(FormBuilder);
public form = this._formBuilder.group({
availableDates: [[], Validators.required],
});
/** The minimum date */
public minDate = new Date(2023, 0, 1);
/** The maximum date */
public maxDate = new Date(2030, 11, 31);
submit() {
console.log(this.form.value);
}
}Form Range Picker
Sync the dates to a form by adding formControlName to hlm-date-range-picker .
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmDatePickerImports } from '@spartan-ng/helm/date-picker';
import { HlmFieldImports } from '@spartan-ng/helm/field';
@Component({
selector: 'spartan-date-picker-form-range',
imports: [ReactiveFormsModule, HlmButtonImports, HlmDatePickerImports, HlmFieldImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'w-full max-w-xs',
},
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<hlm-field-group>
<hlm-field>
<label hlmFieldLabel for="date-range-form">Enter a date range</label>
<hlm-date-range-picker
[min]="minDate"
[max]="maxDate"
formControlName="range"
[autoCloseOnEndSelection]="true"
>
<hlm-date-picker-trigger buttonId="date-range-form">Pick date range</hlm-date-picker-trigger>
</hlm-date-range-picker>
</hlm-field>
<hlm-field orientation="horizontal">
<button type="submit" hlmBtn [disabled]="form.invalid">Submit</button>
</hlm-field>
</hlm-field-group>
</form>
`,
})
export class DatePickerFormRangeExample {
private readonly _formBuilder = inject(FormBuilder);
public form = this._formBuilder.group({
range: [[], [Validators.required]],
});
/** The minimum date */
public minDate = new Date(2023, 0, 1);
/** The maximum date */
public maxDate = new Date(2030, 11, 31);
submit() {
console.log(this.form.value);
}
constructor() {
this.form.get('range')?.valueChanges.subscribe(console.log);
}
}RTL
To enable RTL support in spartan-ng, see the RTL configuration guide.
import { Component, computed, effect, inject, untracked } from '@angular/core';
import { TranslateService, Translations } from '@spartan-ng/app/app/shared/translate.service';
import {
type BrnCalendarI18n,
injectBrnCalendarI18n,
type MonthLabels,
provideBrnCalendarI18n,
} from '@spartan-ng/brain/calendar';
import { HlmDatePickerImports } from '@spartan-ng/helm/date-picker';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { DateTime } from 'luxon';
export const CALENDAR_I18N: Record<'en' | 'ar' | 'he', BrnCalendarI18n> = {
en: {
formatWeekdayName: (i) => ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][i],
months: () => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] as MonthLabels,
years: (startYear = 1925, endYear = new Date().getFullYear() + 1) =>
Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i),
formatHeader: (month, year) => new Date(year, month).toLocaleDateString('en', { month: 'long', year: 'numeric' }),
formatMonth: (m) => new Date(2000, m).toLocaleDateString('en', { month: 'short' }),
formatYear: (y) => `${y}`,
labelPrevious: () => 'Previous month',
labelNext: () => 'Next month',
labelWeekday: (i) => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][i],
firstDayOfWeek: () => 0,
},
ar: {
formatWeekdayName: (i) => ['ح', 'ن', 'ث', 'ر', 'خ', 'ج', 'س'][i],
months: () =>
[
'يناير',
'فبراير',
'مارس',
'أبريل',
'مايو',
'يونيو',
'يوليو',
'أغسطس',
'سبتمبر',
'أكتوبر',
'نوفمبر',
'ديسمبر',
] as MonthLabels,
years: (startYear = 1925, endYear = new Date().getFullYear() + 1) =>
Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i),
formatHeader: (month, year) => new Date(year, month).toLocaleDateString('ar', { month: 'long', year: 'numeric' }),
formatMonth: (m) => new Date(2000, m).toLocaleDateString('ar', { month: 'short' }),
formatYear: (y) => `${y}`,
labelPrevious: () => 'الشهر السابق',
labelNext: () => 'الشهر التالي',
labelWeekday: (i) => ['الأحد', 'الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت'][i],
firstDayOfWeek: () => 0,
},
he: {
formatWeekdayName: (i) => ['א', 'ב', 'ג', 'ד', 'ה', 'ו', 'ש'][i],
months: () =>
[
'ינואר',
'פברואר',
'מרץ',
'אפריל',
'מאי',
'יוני',
'יולי',
'אוגוסט',
'ספטמבר',
'אוקטובר',
'נובמבר',
'דצמבר',
] as MonthLabels,
years: (startYear = 1925, endYear = new Date().getFullYear() + 1) =>
Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i),
formatHeader: (month, year) => new Date(year, month).toLocaleDateString('he', { month: 'long', year: 'numeric' }),
formatMonth: (m) => new Date(2000, m).toLocaleDateString('he', { month: 'short' }),
formatYear: (y) => `${y}`,
labelPrevious: () => 'החודש הקודם',
labelNext: () => 'החודש הבא',
labelWeekday: (i) => ['ראשון', 'שני', 'שלישי', 'רביעי', 'חמישי', 'שישי', 'שבת'][i],
firstDayOfWeek: () => 0,
},
} as const;
@Component({
selector: 'spartan-date-picker-rtl',
imports: [HlmDatePickerImports, HlmFieldImports],
providers: [provideBrnCalendarI18n()],
template: `
<hlm-field [dir]="_dir()">
<hlm-date-picker [min]="minDate" [max]="maxDate" [formatDate]="_formatDate()">
<hlm-date-picker-trigger buttonId="date">{{ _t()['placeholder'] }}</hlm-date-picker-trigger>
</hlm-date-picker>
</hlm-field>
`,
})
export class DatePickerRtl {
/** The minimum date */
public minDate = new Date(2023, 0, 1);
/** The maximum date */
public maxDate = new Date(2030, 11, 31);
private readonly _language = inject(TranslateService).language;
private readonly _calendarI18n = injectBrnCalendarI18n();
constructor() {
effect(() => {
const language = this._language();
untracked(() => this._calendarI18n.use(CALENDAR_I18N[language]));
});
}
protected readonly _formatDate = computed(() => {
const locale = this._language();
return (date: Date) => DateTime.fromJSDate(date).setLocale(locale).toLocaleString(DateTime.DATE_FULL);
});
private readonly _translations: Translations = {
en: {
dir: 'ltr',
values: {
placeholder: 'Pick a date',
},
},
ar: {
dir: 'rtl',
values: {
placeholder: 'اختر تاريخًا',
},
},
he: {
dir: 'rtl',
values: {
placeholder: 'בחר תאריך',
},
},
};
private readonly _translation = computed(() => this._translations[this._language()]);
protected readonly _t = computed(() => this._translation().values);
protected readonly _dir = computed(() => this._translation().dir);
}Helm API
HlmDatePickerAnchor
Selector: [hlmDatePickerAnchor]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| hlmDatePickerAnchorFor | BrnPopover | undefined | undefined | - |
HlmDatePickerInput
Selector: hlm-date-picker-input
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| inputId | unknown | `hlm-date-picker-input-${HlmDatePickerInput._nextId++}` | - |
| placeholder | unknown | - | - |
| inputValue | string | - | - |
| parseDate | (value: string) => T | undefined | this._config.parseDate | Parses input text into a date value. Return `undefined` for invalid input - the picker's date is cleared while the text is preserved so the user can fix it. Defaults to `parseDate` from `HlmDatePickerConfig`. |
| forceInvalid | boolean | false | - |
| showClear | boolean | true | Show a clear button that resets the input and picker date. Hidden when empty. |
| openOnClick | boolean | false | Open the popover on input click. |
| clearAriaLabel | string | Clear date | Accessible label for the clear button. |
| calendarAriaLabel | string | Open calendar | Accessible label for the calendar trigger button. |
HlmDatePickerMulti
Selector: hlm-date-picker-multi
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| captionLayout | 'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years' | label | Show dropdowns to navigate between months or years. |
| min | T | - | The minimum date that can be selected. |
| max | T | - | The maximum date that can be selected. |
| minSelection | number | undefined | The minimum selectable dates. |
| maxSelection | number | undefined | The maximum selectable dates. |
| disabled | boolean | false | Determine if the date picker is disabled. |
| date | T[] | - | The selected value. |
| autoCloseOnMaxSelection | boolean | this._config.autoCloseOnMaxSelection | If true, the date picker will close when the max selection of dates is reached. |
| formatDates | (date: T[]) => string | this._config.formatDates | Defines how the date should be displayed in the UI. |
| transformDates | (date: T[]) => T[] | this._config.transformDates | Defines how the date should be transformed before saving to model/form. |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| dateChange | T[] | - | - |
HlmDatePickerTrigger
Selector: hlm-date-picker-trigger
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| class | ClassValue | - | - |
| buttonId | string | `hlm-date-picker-${++HlmDatePickerTrigger._nextId}` | The id of the button that opens the date picker. |
| forceInvalid | boolean | false | Forces the invalid state visually, regardless of form control state. |
| variant | ButtonVariants['variant'] | outline | - |
| showTrigger | boolean | true | - |
HlmDatePicker
Selector: hlm-date-picker
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| captionLayout | 'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years' | label | Show dropdowns to navigate between months or years. |
| min | T | - | The minimum date that can be selected. |
| max | T | - | The maximum date that can be selected. |
| disabled | boolean | false | Determine if the date picker is disabled. |
| date | T | - | The selected value. |
| defaultFocusedDate | T | - | The date the calendar focuses on first open when no date is selected. |
| autoCloseOnSelect | boolean | this._config.autoCloseOnSelect | If true, the date picker will close when a date is selected. |
| formatDate | (date: T) => string | this._config.formatDate | Defines how the date should be displayed in the UI. |
| transformDate | (date: T) => T | this._config.transformDate | Defines how the date should be transformed before saving to model/form. |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| dateChange | T | - | - |
HlmDateRangePicker
Selector: hlm-date-range-picker
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| captionLayout | 'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years' | label | Show dropdowns to navigate between months or years. |
| min | T | - | The minimum date that can be selected. |
| max | T | - | The maximum date that can be selected. |
| disabled | boolean | false | Determine if the date picker is disabled. |
| date | [T, T] | - | The selected value. |
| autoCloseOnEndSelection | boolean | this._config.autoCloseOnEndSelection | If true, the date picker will close when the end date is selected |
| formatDates | (dates: [T | undefined, T | undefined]) => string | this._config.formatDates | Defines how the date should be displayed in the UI. |
| transformDates | (date: [T, T]) => [T, T] | this._config.transformDates | Defines how the date should be transformed before saving to model/form. |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| dateChange | [T, T] | null | - | - |
On This Page