- 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
- Dropdown Menu
- Empty
- Field
- Form Field
- Hover Card
- Icon
- 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 { HlmLabelImports } from '@spartan-ng/helm/label';
@Component({
selector: 'spartan-date-picker-preview',
imports: [HlmDatePickerImports, HlmLabelImports],
template: `
<div class="flex flex-col gap-3">
<label for="date" hlmLabel class="px-1">Date of birth</label>
<hlm-date-picker buttonId="date" [min]="minDate" [max]="maxDate">
<span>Select date</span>
</hlm-date-picker>
</div>
`,
})
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, runInInjectionContext } from '@angular/core';
import { clsx, type ClassValue } from 'clsx';
import { isPlatformBrowser } from '@angular/common';
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;
}import type, { BooleanInput, NumberInput } from '@angular/cdk/coercion';
import type, { BrnDialogState } from '@spartan-ng/brain/dialog';
import type, { ChangeFn, TouchFn } from '@spartan-ng/brain/forms';
import type, { ClassValue } from 'clsx';
import { ChangeDetectionStrategy, Component, InjectionToken, booleanAttribute, computed, forwardRef, inject, input, linkedSignal, numberAttribute, output, signal, untracked, type ValueProvider } from '@angular/core';
import { HlmCalendar, HlmCalendarMulti, HlmCalendarRange } from '@spartan-ng/helm/calendar';
import { HlmIconImports } from '@spartan-ng/helm/icon';
import { HlmPopoverImports } from '@spartan-ng/helm/popover';
import { NG_VALUE_ACCESSOR, type ControlValueAccessor } from '@angular/forms';
import { hlm } from '@spartan-ng/helm/utils';
import { lucideChevronDown } from '@ng-icons/lucide';
import { provideIcons } from '@ng-icons/core';
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,
};
let nextId = 0;
@Component({
selector: 'hlm-date-picker-multi',
imports: [HlmIconImports, HlmPopoverImports, HlmCalendarMulti],
providers: [HLM_DATE_PICKER_MUTLI_VALUE_ACCESSOR, provideIcons({ lucideChevronDown })],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'block',
},
template: `
<hlm-popover sideOffset="5" [state]="_popoverState()" (stateChanged)="_popoverState.set($event)">
<button
[id]="buttonId()"
type="button"
[class]="_computedClass()"
[disabled]="_mutableDisabled()"
hlmPopoverTrigger
>
<span class="truncate">
@if (_formattedDate(); as formattedDate) {
{{ formattedDate }}
} @else {
<ng-content />
}
</span>
<ng-icon hlm size="sm" name="lucideChevronDown" />
</button>
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<hlm-calendar-multi
calendarClass="border-0 rounded-none"
[date]="_mutableDate()"
[captionLayout]="captionLayout()"
[min]="min()"
[max]="max()"
[minSelection]="minSelection()"
[maxSelection]="maxSelection()"
[disabled]="_mutableDisabled()"
(dateChange)="_handleChange($event)"
/>
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDatePickerMulti<T> implements ControlValueAccessor {
private readonly _config = injectHlmDatePickerMultiConfig<T>();
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'ring-offset-background border-input bg-background hover:bg-accent dark:bg-input/30 dark:hover:bg-input/50 hover:text-accent-foreground inline-flex h-9 w-[280px] cursor-default items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm font-normal whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50',
'focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
'disabled:pointer-events-none disabled:opacity-50',
'[&_ng-icon]:pointer-events-none [&_ng-icon]:shrink-0',
this.userClass(),
),
);
/** The id of the button that opens the date picker. */
public readonly buttonId = input<string>(`hlm-date-picker-multi-${++nextId}`);
/** 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<BrnDialogState | null>(null);
protected readonly _mutableDisabled = linkedSignal(this.disabled);
protected readonly _formattedDate = computed(() => {
const dates = this._mutableDate();
return dates ? this.formatDates()(dates) : undefined;
});
public readonly dateChange = output<T[]>();
protected _onChange?: ChangeFn<T[]>;
protected _onTouched?: TouchFn;
protected _handleChange(value: T[] | undefined) {
if (value === undefined) return;
if (this._mutableDisabled()) 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._mutableDisabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
}
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;
}
function getDefaultConfig<T>(): HlmDatePickerConfig<T> {
return {
formatDate: (date) => (date instanceof Date ? date.toDateString() : `${date}`),
transformDate: (date) => date,
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,
};
let nextId = 0;
@Component({
selector: 'hlm-date-picker',
imports: [HlmIconImports, HlmPopoverImports, HlmCalendar],
providers: [HLM_DATE_PICKER_VALUE_ACCESSOR, provideIcons({ lucideChevronDown })],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'block',
},
template: `
<hlm-popover sideOffset="5" [state]="_popoverState()" (stateChanged)="_popoverState.set($event)">
<button
[id]="buttonId()"
type="button"
[class]="_computedClass()"
[disabled]="_mutableDisabled()"
hlmPopoverTrigger
>
<span class="truncate">
@if (_formattedDate(); as formattedDate) {
{{ formattedDate }}
} @else {
<ng-content />
}
</span>
<ng-icon hlm size="sm" name="lucideChevronDown" />
</button>
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<hlm-calendar
calendarClass="border-0 rounded-none"
[captionLayout]="captionLayout()"
[date]="_mutableDate()"
[min]="min()"
[max]="max()"
[disabled]="_mutableDisabled()"
(dateChange)="_handleChange($event)"
/>
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDatePicker<T> implements ControlValueAccessor {
private readonly _config = injectHlmDatePickerConfig<T>();
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'ring-offset-background border-input bg-background hover:bg-accent dark:bg-input/30 dark:hover:bg-input/50 hover:text-accent-foreground inline-flex h-9 w-[280px] cursor-default items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm font-normal whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50',
'focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
'disabled:pointer-events-none disabled:opacity-50',
'[&_ng-icon]:pointer-events-none [&_ng-icon]:shrink-0',
this.userClass(),
),
);
/** The id of the button that opens the date picker. */
public readonly buttonId = input<string>(`hlm-date-picker-${++nextId}`);
/** 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>();
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<BrnDialogState | null>(null);
protected readonly _mutableDisabled = linkedSignal(this.disabled);
protected readonly _formattedDate = computed(() => {
const date = this._mutableDate();
return date ? this.formatDate()(date) : undefined;
});
public readonly dateChange = output<T>();
protected _onChange?: ChangeFn<T>;
protected _onTouched?: TouchFn;
protected _handleChange(value: T) {
if (this._mutableDisabled()) return;
const transformedDate = value !== undefined ? this.transformDate()(value) : value;
this._mutableDate.set(transformedDate);
this._onChange?.(transformedDate);
this.dateChange.emit(transformedDate);
if (this.autoCloseOnSelect()) {
this._popoverState.set('closed');
}
}
/** 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 setDisabledState(isDisabled: boolean): void {
this._mutableDisabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
}
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,
};
let nextId = 0;
@Component({
selector: 'hlm-date-range-picker',
imports: [HlmIconImports, HlmPopoverImports, HlmCalendarRange],
providers: [HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR, provideIcons({ lucideChevronDown })],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'block',
},
template: `
<hlm-popover
sideOffset="5"
[state]="_popoverState()"
(stateChanged)="_popoverState.set($event)"
(closed)="_onClose()"
>
<button
[id]="buttonId()"
type="button"
[class]="_computedClass()"
[disabled]="_mutableDisabled()"
hlmPopoverTrigger
>
<span class="truncate">
@if (_formattedDate(); as formattedDate) {
{{ formattedDate }}
} @else {
<ng-content />
}
</span>
<ng-icon hlm size="sm" name="lucideChevronDown" />
</button>
<hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
<hlm-calendar-range
calendarClass="border-0 rounded-none"
[startDate]="_start()"
[captionLayout]="captionLayout()"
[endDate]="_end()"
[min]="min()"
[max]="max()"
[disabled]="_mutableDisabled()"
(startDateChange)="_handleStartDayChange($event)"
(endDateChange)="_handleEndDateChange($event)"
/>
</hlm-popover-content>
</hlm-popover>
`,
})
export class HlmDateRangePicker<T> implements ControlValueAccessor {
private readonly _config = injectHlmDateRangePickerConfig<T>();
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'ring-offset-background border-input bg-background hover:bg-accent dark:bg-input/30 dark:hover:bg-input/50 hover:text-accent-foreground inline-flex h-9 w-[280px] cursor-default items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm font-normal whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50',
'focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
'disabled:pointer-events-none disabled:opacity-50',
'[&_ng-icon]:pointer-events-none [&_ng-icon]:shrink-0',
this.userClass(),
),
);
/** The id of the button that opens the date picker. */
public readonly buttonId = input<string>(`hlm-date-picker-range-${++nextId}`);
/** 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<BrnDialogState | null>(null);
protected readonly _mutableDisabled = linkedSignal(this.disabled);
protected 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>();
protected _onChange?: ChangeFn<[T, T] | null>;
protected _onTouched?: TouchFn;
protected _handleStartDayChange(value: T) {
this._start.set(value);
}
protected _handleEndDateChange(value: T): void {
this._end.set(value);
if (this._mutableDisabled()) 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._mutableDisabled.set(isDisabled);
}
public open() {
this._popoverState.set('open');
}
public close() {
this._popoverState.set('closed');
}
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, HlmDatePickerMulti, HlmDateRangePicker] as const;Usage
import { HlmDatePickerImports } from '@spartan-ng/helm/date-picker';<hlm-date-picker [min]="minDate" [max]="maxDate">
<span>Pick a date</span>
</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 { HlmLabelImports } from '@spartan-ng/helm/label';
import { DateTime } from 'luxon';
@Component({
selector: 'spartan-date-picker-config',
imports: [HlmDatePickerImports, HlmLabelImports],
providers: [
provideHlmDatePickerConfig({
formatDate: (date: Date) => DateTime.fromJSDate(date).toFormat('dd.MM.yyyy'),
transformDate: (date: Date) => DateTime.fromJSDate(date).plus({ days: 1 }).toJSDate(),
}),
],
template: `
<div class="flex flex-col gap-3">
<label for="customConfig" hlmLabel class="px-1">Date Picker with Config</label>
<hlm-date-picker buttonId="customConfig" [min]="minDate" [max]="maxDate">
<span>Pick a date</span>
</hlm-date-picker>
</div>
`,
})
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 { HlmLabelImports } from '@spartan-ng/helm/label';
@Component({
selector: 'spartan-date-picker-multiple',
imports: [HlmDatePickerImports, HlmLabelImports],
template: `
<div class="flex flex-col gap-3">
<label for="datePickerMulti" hlmLabel class="px-1">Date Picker Multiple</label>
<hlm-date-picker-multi
buttonId="datePickerMulti"
[min]="minDate"
[max]="maxDate"
[autoCloseOnMaxSelection]="true"
[minSelection]="2"
[maxSelection]="6"
>
<span>Pick dates</span>
</hlm-date-picker-multi>
</div>
`,
})
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 { FormsModule } from '@angular/forms';
import { HlmDatePickerImports } from '@spartan-ng/helm/date-picker';
@Component({
selector: 'spartan-date-picker-range',
imports: [HlmDatePickerImports, FormsModule],
template: `
<hlm-date-range-picker [min]="minDate" [max]="maxDate" [autoCloseOnEndSelection]="true">
<span>Enter a date range</span>
</hlm-date-range-picker>
`,
})
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 { HlmLabelImports } from '@spartan-ng/helm/label';
import { DateTime } from 'luxon';
@Component({
selector: 'spartan-date-picker-format',
imports: [HlmDatePickerImports, HlmLabelImports],
providers: [
// Global formatDate config
provideHlmDatePickerConfig({ formatDate: (date: Date) => DateTime.fromJSDate(date).toFormat('dd.MM.yyyy') }),
],
template: `
<div class="flex flex-col gap-3">
<label for="datePickerFormat" hlmLabel class="px-1">Date Picker with Custom Format</label>
<hlm-date-picker buttonId="datePickerFormat" [min]="minDate" [max]="maxDate" [formatDate]="formatDate">
<span>Pick a date</span>
</hlm-date-picker>
</div>
`,
})
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');
}Date and Time picker
import { Component } from '@angular/core';
import { HlmDatePickerImports } from '@spartan-ng/helm/date-picker';
import { HlmInputImports } from '@spartan-ng/helm/input';
import { HlmLabelImports } from '@spartan-ng/helm/label';
@Component({
selector: 'spartan-date-and-time-picker',
imports: [HlmDatePickerImports, HlmLabelImports, HlmInputImports],
template: `
<div class="flex gap-4">
<div class="flex flex-col gap-3">
<label for="date-picker" hlmLabel class="px-1">Date</label>
<hlm-date-picker buttonId="date-picker" [min]="minDate" [max]="maxDate">
<span>Select date</span>
</hlm-date-picker>
</div>
<div class="flex flex-col gap-3">
<label for="time-picker" hlmLabel class="px-1">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"
/>
</div>
</div>
`,
})
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 { 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 { HlmLabelImports } from '@spartan-ng/helm/label';
@Component({
selector: 'spartan-date-picker-form',
imports: [HlmDatePickerImports, ReactiveFormsModule, HlmButtonImports, HlmLabelImports],
template: `
<form [formGroup]="form" (ngSubmit)="submit()" class="space-y-8">
<div class="flex flex-col gap-2">
<label for="birthday" hlmLabel class="px-1">Date of birth</label>
<hlm-date-picker
buttonId="birthday"
[min]="minDate"
[max]="maxDate"
formControlName="birthday"
[autoCloseOnSelect]="true"
>
<span>Pick a date</span>
</hlm-date-picker>
<div class="text-muted-foreground px-1 text-sm">Your date of birth is used to calculate your age.</div>
</div>
<button type="submit" hlmBtn [disabled]="form.invalid">Submit</button>
</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 Multiple Selection
Sync the dates to a form by adding formControlName to hlm-date-picker-multi .
import { 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 { HlmLabelImports } from '@spartan-ng/helm/label';
@Component({
selector: 'spartan-date-picker-form-multiple',
imports: [HlmDatePickerImports, ReactiveFormsModule, HlmButtonImports, HlmLabelImports],
template: `
<form [formGroup]="form" (ngSubmit)="submit()" class="space-y-8">
<div class="flex flex-col gap-2">
<label for="availableDates" hlmLabel class="px-1">Available dates</label>
<hlm-date-picker-multi
buttonId="availableDates"
[min]="minDate"
[max]="maxDate"
formControlName="availableDates"
[autoCloseOnMaxSelection]="true"
[minSelection]="2"
[maxSelection]="4"
>
<span>Pick dates</span>
</hlm-date-picker-multi>
</div>
<button type="submit" hlmBtn [disabled]="form.invalid">Submit</button>
</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 { 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 { HlmLabelImports } from '@spartan-ng/helm/label';
@Component({
selector: 'spartan-date-picker-form-range',
imports: [ReactiveFormsModule, HlmButtonImports, HlmLabelImports, HlmDatePickerImports],
template: `
<form [formGroup]="form" (ngSubmit)="submit()" class="space-y-8">
<div class="flex flex-col gap-2">
<label for="dateRange" hlmLabel class="px-1">Enter a date range</label>
<hlm-date-range-picker
buttonId="dateRange"
[min]="minDate"
[max]="maxDate"
formControlName="range"
[autoCloseOnEndSelection]="true"
>
<span>Enter a date range</span>
</hlm-date-range-picker>
</div>
<button type="submit" hlmBtn [disabled]="form.invalid">Submit</button>
</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);
}
}Helm API
HlmDatePickerMulti
Selector: hlm-date-picker-multi
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| class | ClassValue | - | - |
| buttonId | string | `hlm-date-picker-multi-${++nextId}` | The id of the button that opens the date picker. |
| 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[] | - | - |
HlmDatePicker
Selector: hlm-date-picker
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| class | ClassValue | - | - |
| buttonId | string | `hlm-date-picker-${++nextId}` | The id of the button that opens the date picker. |
| 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. |
| 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 |
|---|---|---|---|
| class | ClassValue | - | - |
| buttonId | string | `hlm-date-picker-range-${++nextId}` | The id of the button that opens the date picker. |
| 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