- Accordion
- Alert
- Alert Dialog
- Aspect Ratio
- Attachment
- Autocomplete
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Chart
- 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
- Marker
- Menubar
- Message
- Native Select
- Navigation Menu
- Pagination
- Popover
- Progress
- Questionnaire
- Radio Group
- Resizable
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner (Toast)
- Spinner
- Switch
- Table
- Tabs
- Textarea
- Toggle
- Toggle Group
- Tooltip
Calendar
A date field component that allows users to enter and edit date.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HlmCalendar } from '@spartan-ng/helm/calendar';
import { HlmCard, HlmCardImports } from '@spartan-ng/helm/card';
@Component({
selector: 'spartan-calendar-preview',
imports: [HlmCalendar, HlmCardImports],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [HlmCard],
host: {
class: 'p-0 w-fit mx-auto',
},
template: `
<div hlmCardContent class="p-0">
<hlm-calendar [(date)]="selectedDate" [min]="minDate" [max]="maxDate" />
</div>
`,
})
export class CalendarPreview {
/** The selected date */
public selectedDate = new Date();
/** The minimum date */
public minDate = new Date(new Date().setMonth(new Date().getMonth() - 2));
/** The maximum date */
public maxDate = new Date(new Date().setMonth(new Date().getMonth() + 2));
}
export const i18nRuntimeChange = `
import { injectBrnCalendarI18n } from '@spartan-ng/brain/calendar';
@Component({...})
export class CalendarPage {
private readonly _i18n = injectBrnCalendarI18n();
switchToFrench() {
this._i18n.use({
...,
labelNext: () => 'Mois suivant',
labelPrevious: () => 'Mois précédent',
...
});
}
}
`;
export const i18nProviders = `
import { bootstrapApplication } from '@angular/platform-browser';
import { provideBrnCalendarI18n } from '@spartan-ng/brain/calendar';
bootstrapApplication(App, {
providers: [
provideBrnCalendarI18n({
formatWeekdayName: (i) => ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][i],
formatHeader: (m, y) =>
new Date(y, m).toLocaleDateString('de-DE', {
month: 'long',
year: 'numeric',
}),
labelPrevious: () => 'Vorheriger Monat',
labelNext: () => 'Nächster Monat',
labelWeekday: (i) => ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][i],
firstDayOfWeek: () => 1,
}),
],
});
`;Installation
ng g @spartan-ng/cli:ui calendarnx g @spartan-ng/cli:ui calendarimport { 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 { BrnCalendar, BrnCalendarImports, BrnCalendarMulti, BrnCalendarRange, BrnMonthYearCalendar, injectBrnCalendarI18n } from '@spartan-ng/brain/calendar';
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { HlmButtonImports, buttonVariants } from '@spartan-ng/helm/button';
import { HlmSelectImports } from '@spartan-ng/helm/select';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { NgTemplateOutlet } from '@angular/common';
import { classes, hlm } from '@spartan-ng/helm/utils';
import { injectDateAdapter } from '@spartan-ng/brain/date-time';
import { lucideChevronLeft, lucideChevronRight } from '@ng-icons/lucide';
@Component({
selector: 'hlm-calendar-multi',
imports: [BrnCalendarImports, NgIcon, NgTemplateOutlet, HlmSelectImports, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendarMulti,
inputs: [
'min',
'max',
'minSelection',
'maxSelection',
'disabled',
'date',
'dateDisabled',
'weekStartsOn',
'highlightDays',
'defaultFocusedDate',
],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col space-y-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendarMulti<T> {
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendarMulti);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-calendar-range',
imports: [BrnCalendarImports, NgIcon, HlmSelectImports, NgTemplateOutlet, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendarRange,
inputs: [
'min',
'max',
'disabled',
'startDate',
'endDate',
'dateDisabled',
'weekStartsOn',
'highlightDays',
'defaultFocusedDate',
],
outputs: ['endDateChange', 'startDateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col space-y-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse space-y-1" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day has-[button[data-range-start=true]]:bg-muted has-[button[data-range-start=true]]:after:bg-muted has-[button[data-range-end=true]]:bg-muted has-[button[data-range-end=true]]:after:bg-muted relative isolate z-0 aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none has-[button[data-range-end=true]]:rounded-e-(--cell-radius) has-[button[data-range-end=true]]:after:absolute has-[button[data-range-end=true]]:after:inset-y-0 has-[button[data-range-end=true]]:after:start-0 has-[button[data-range-end=true]]:after:w-4 has-[button[data-range-start=true]]:rounded-s-(--cell-radius) has-[button[data-range-start=true]]:after:absolute has-[button[data-range-start=true]]:after:inset-y-0 has-[button[data-range-start=true]]:after:end-0 has-[button[data-range-start=true]]:after:w-4 [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendarRange<T> {
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendarRange);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-calendar',
imports: [BrnCalendarImports, NgIcon, HlmSelectImports, NgTemplateOutlet, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendar,
inputs: ['min', 'max', 'disabled', 'date', 'dateDisabled', 'weekStartsOn', 'highlightDays', 'defaultFocusedDate'],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col gap-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse space-y-1" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendar<T> {
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendar);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-month-year-calendar',
imports: [BrnCalendarImports, NgIcon, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnMonthYearCalendar,
inputs: ['min', 'max', 'disabled', 'date', 'defaultFocusedDate', 'view'],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'month-year-calendar' },
template: `
<div class="flex flex-col gap-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<button
brnMonthYearCalendarPreviousButton
hlmBtn
variant="ghost"
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
<button
hlmBtn
variant="ghost"
class="h-(--cell-size) py-0 select-none aria-disabled:opacity-50"
brnMonthYearCalendarHeader
>
{{ _heading() }}
</button>
<button
brnMonthYearCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<!-- Grid -->
@switch (_picker.view()) {
@case ('year') {
<div brnMonthYearCalendarGrid class="grid grid-cols-4 gap-2">
@for (year of _picker.years(); track _dateAdapter.getYear(year)) {
<button brnMonthYearCalendarYearButton [date]="year" [class]="_btnClass">
{{ _i18n.config().formatYear(_dateAdapter.getYear(year)) }}
</button>
}
</div>
}
@case ('month') {
<div brnMonthYearCalendarGrid class="grid grid-cols-4 gap-2">
@for (month of _picker.months(); track _dateAdapter.getMonth(month)) {
<button brnMonthYearCalendarMonthButton [date]="month" [class]="_btnClass">
{{ _i18n.config().months()[_dateAdapter.getMonth(month)] }}
</button>
}
</div>
}
}
</div>
`,
})
export class HlmMonthYearCalendar<T> {
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the picker directive */
protected readonly _picker = inject(BrnMonthYearCalendar<T>);
/** The heading for the current view. */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
if (this._picker.view() === 'month') {
return config.formatYear(this._dateAdapter.getYear(this._picker.focusedDate()));
}
const { start, end } = this._picker.yearRange();
return `${config.formatYear(start)} – ${config.formatYear(end)}`;
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost' }),
'data-[today=true]:bg-muted',
'data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground data-[selected=true]:hover:bg-primary data-[selected=true]:hover:text-primary-foreground',
'data-[focused=true]:border-ring data-[focused=true]:ring-ring/50 data-[focused=true]:ring-[3px]',
'aria-disabled:pointer-events-none aria-disabled:opacity-50',
'h-(--cell-size)',
);
constructor() {
classes(
() =>
'p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
export const HlmCalendarImports = [HlmCalendar, HlmCalendarMulti, HlmCalendarRange, HlmMonthYearCalendar] as const;import { BrnCalendar, BrnCalendarImports, BrnCalendarMulti, BrnCalendarRange, BrnMonthYearCalendar, injectBrnCalendarI18n } from '@spartan-ng/brain/calendar';
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { HlmButtonImports, buttonVariants } from '@spartan-ng/helm/button';
import { HlmSelectImports } from '@spartan-ng/helm/select';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { NgTemplateOutlet } from '@angular/common';
import { classes, hlm } from '@spartan-ng/helm/utils';
import { injectDateAdapter } from '@spartan-ng/brain/date-time';
import { lucideChevronLeft, lucideChevronRight } from '@ng-icons/lucide';
@Component({
selector: 'hlm-calendar-multi',
imports: [BrnCalendarImports, NgIcon, NgTemplateOutlet, HlmSelectImports, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendarMulti,
inputs: [
'min',
'max',
'minSelection',
'maxSelection',
'disabled',
'date',
'dateDisabled',
'weekStartsOn',
'highlightDays',
'defaultFocusedDate',
],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col space-y-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendarMulti<T> {
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendarMulti);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-3 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(8)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-calendar-range',
imports: [BrnCalendarImports, NgIcon, HlmSelectImports, NgTemplateOutlet, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendarRange,
inputs: [
'min',
'max',
'disabled',
'startDate',
'endDate',
'dateDisabled',
'weekStartsOn',
'highlightDays',
'defaultFocusedDate',
],
outputs: ['endDateChange', 'startDateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col space-y-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse space-y-1" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day has-[button[data-range-start=true]]:bg-muted has-[button[data-range-start=true]]:after:bg-muted has-[button[data-range-end=true]]:bg-muted has-[button[data-range-end=true]]:after:bg-muted relative isolate z-0 aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none has-[button[data-range-end=true]]:rounded-e-(--cell-radius) has-[button[data-range-end=true]]:after:absolute has-[button[data-range-end=true]]:after:inset-y-0 has-[button[data-range-end=true]]:after:start-0 has-[button[data-range-end=true]]:after:w-4 has-[button[data-range-start=true]]:rounded-s-(--cell-radius) has-[button[data-range-start=true]]:after:absolute has-[button[data-range-start=true]]:after:inset-y-0 has-[button[data-range-start=true]]:after:end-0 has-[button[data-range-start=true]]:after:w-4 [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendarRange<T> {
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendarRange);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-3 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(8)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-calendar',
imports: [BrnCalendarImports, NgIcon, HlmSelectImports, NgTemplateOutlet, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendar,
inputs: ['min', 'max', 'disabled', 'date', 'dateDisabled', 'weekStartsOn', 'highlightDays', 'defaultFocusedDate'],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col gap-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse space-y-1" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendar<T> {
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendar);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-3 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(8)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-month-year-calendar',
imports: [BrnCalendarImports, NgIcon, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnMonthYearCalendar,
inputs: ['min', 'max', 'disabled', 'date', 'defaultFocusedDate', 'view'],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'month-year-calendar' },
template: `
<div class="flex flex-col gap-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<button
brnMonthYearCalendarPreviousButton
hlmBtn
variant="ghost"
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
<button
hlmBtn
variant="ghost"
class="h-(--cell-size) py-0 select-none aria-disabled:opacity-50"
brnMonthYearCalendarHeader
>
{{ _heading() }}
</button>
<button
brnMonthYearCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<!-- Grid -->
@switch (_picker.view()) {
@case ('year') {
<div brnMonthYearCalendarGrid class="grid grid-cols-4 gap-2">
@for (year of _picker.years(); track _dateAdapter.getYear(year)) {
<button brnMonthYearCalendarYearButton [date]="year" [class]="_btnClass">
{{ _i18n.config().formatYear(_dateAdapter.getYear(year)) }}
</button>
}
</div>
}
@case ('month') {
<div brnMonthYearCalendarGrid class="grid grid-cols-4 gap-2">
@for (month of _picker.months(); track _dateAdapter.getMonth(month)) {
<button brnMonthYearCalendarMonthButton [date]="month" [class]="_btnClass">
{{ _i18n.config().months()[_dateAdapter.getMonth(month)] }}
</button>
}
</div>
}
}
</div>
`,
})
export class HlmMonthYearCalendar<T> {
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the picker directive */
protected readonly _picker = inject(BrnMonthYearCalendar<T>);
/** The heading for the current view. */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
if (this._picker.view() === 'month') {
return config.formatYear(this._dateAdapter.getYear(this._picker.focusedDate()));
}
const { start, end } = this._picker.yearRange();
return `${config.formatYear(start)} – ${config.formatYear(end)}`;
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost' }),
'data-[today=true]:bg-muted',
'data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground data-[selected=true]:hover:bg-primary data-[selected=true]:hover:text-primary-foreground',
'data-[focused=true]:border-ring data-[focused=true]:ring-ring/50 data-[focused=true]:ring-[3px]',
'aria-disabled:pointer-events-none aria-disabled:opacity-50',
'h-(--cell-size)',
);
constructor() {
classes(
() =>
'p-3 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(8)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
export const HlmCalendarImports = [HlmCalendar, HlmCalendarMulti, HlmCalendarRange, HlmMonthYearCalendar] as const;import { BrnCalendar, BrnCalendarImports, BrnCalendarMulti, BrnCalendarRange, BrnMonthYearCalendar, injectBrnCalendarI18n } from '@spartan-ng/brain/calendar';
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { HlmButtonImports, buttonVariants } from '@spartan-ng/helm/button';
import { HlmSelectImports } from '@spartan-ng/helm/select';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { NgTemplateOutlet } from '@angular/common';
import { classes, hlm } from '@spartan-ng/helm/utils';
import { injectDateAdapter } from '@spartan-ng/brain/date-time';
import { lucideChevronLeft, lucideChevronRight } from '@ng-icons/lucide';
@Component({
selector: 'hlm-calendar-multi',
imports: [BrnCalendarImports, NgIcon, NgTemplateOutlet, HlmSelectImports, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendarMulti,
inputs: [
'min',
'max',
'minSelection',
'maxSelection',
'disabled',
'date',
'dateDisabled',
'weekStartsOn',
'highlightDays',
'defaultFocusedDate',
],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col space-y-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendarMulti<T> {
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendarMulti);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-2 [--cell-size:--spacing(7)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-calendar-range',
imports: [BrnCalendarImports, NgIcon, HlmSelectImports, NgTemplateOutlet, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendarRange,
inputs: [
'min',
'max',
'disabled',
'startDate',
'endDate',
'dateDisabled',
'weekStartsOn',
'highlightDays',
'defaultFocusedDate',
],
outputs: ['endDateChange', 'startDateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col space-y-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse space-y-1" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day has-[button[data-range-start=true]]:bg-muted has-[button[data-range-start=true]]:after:bg-muted has-[button[data-range-end=true]]:bg-muted has-[button[data-range-end=true]]:after:bg-muted relative isolate z-0 aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none has-[button[data-range-end=true]]:rounded-e-(--cell-radius) has-[button[data-range-end=true]]:after:absolute has-[button[data-range-end=true]]:after:inset-y-0 has-[button[data-range-end=true]]:after:start-0 has-[button[data-range-end=true]]:after:w-4 has-[button[data-range-start=true]]:rounded-s-(--cell-radius) has-[button[data-range-start=true]]:after:absolute has-[button[data-range-start=true]]:after:inset-y-0 has-[button[data-range-start=true]]:after:end-0 has-[button[data-range-start=true]]:after:w-4 [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendarRange<T> {
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendarRange);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-2 [--cell-size:--spacing(7)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-calendar',
imports: [BrnCalendarImports, NgIcon, HlmSelectImports, NgTemplateOutlet, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendar,
inputs: ['min', 'max', 'disabled', 'date', 'dateDisabled', 'weekStartsOn', 'highlightDays', 'defaultFocusedDate'],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col gap-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse space-y-1" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendar<T> {
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendar);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-2 [--cell-size:--spacing(7)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-month-year-calendar',
imports: [BrnCalendarImports, NgIcon, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnMonthYearCalendar,
inputs: ['min', 'max', 'disabled', 'date', 'defaultFocusedDate', 'view'],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'month-year-calendar' },
template: `
<div class="flex flex-col gap-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<button
brnMonthYearCalendarPreviousButton
hlmBtn
variant="ghost"
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
<button
hlmBtn
variant="ghost"
class="h-(--cell-size) py-0 select-none aria-disabled:opacity-50"
brnMonthYearCalendarHeader
>
{{ _heading() }}
</button>
<button
brnMonthYearCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<!-- Grid -->
@switch (_picker.view()) {
@case ('year') {
<div brnMonthYearCalendarGrid class="grid grid-cols-4 gap-2">
@for (year of _picker.years(); track _dateAdapter.getYear(year)) {
<button brnMonthYearCalendarYearButton [date]="year" [class]="_btnClass">
{{ _i18n.config().formatYear(_dateAdapter.getYear(year)) }}
</button>
}
</div>
}
@case ('month') {
<div brnMonthYearCalendarGrid class="grid grid-cols-4 gap-2">
@for (month of _picker.months(); track _dateAdapter.getMonth(month)) {
<button brnMonthYearCalendarMonthButton [date]="month" [class]="_btnClass">
{{ _i18n.config().months()[_dateAdapter.getMonth(month)] }}
</button>
}
</div>
}
}
</div>
`,
})
export class HlmMonthYearCalendar<T> {
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the picker directive */
protected readonly _picker = inject(BrnMonthYearCalendar<T>);
/** The heading for the current view. */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
if (this._picker.view() === 'month') {
return config.formatYear(this._dateAdapter.getYear(this._picker.focusedDate()));
}
const { start, end } = this._picker.yearRange();
return `${config.formatYear(start)} – ${config.formatYear(end)}`;
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost' }),
'data-[today=true]:bg-muted',
'data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground data-[selected=true]:hover:bg-primary data-[selected=true]:hover:text-primary-foreground',
'data-[focused=true]:border-ring data-[focused=true]:ring-ring/50 data-[focused=true]:ring-[3px]',
'aria-disabled:pointer-events-none aria-disabled:opacity-50',
'h-(--cell-size)',
);
constructor() {
classes(
() =>
'p-2 [--cell-size:--spacing(7)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
export const HlmCalendarImports = [HlmCalendar, HlmCalendarMulti, HlmCalendarRange, HlmMonthYearCalendar] as const;import { BrnCalendar, BrnCalendarImports, BrnCalendarMulti, BrnCalendarRange, BrnMonthYearCalendar, injectBrnCalendarI18n } from '@spartan-ng/brain/calendar';
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { HlmButtonImports, buttonVariants } from '@spartan-ng/helm/button';
import { HlmSelectImports } from '@spartan-ng/helm/select';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { NgTemplateOutlet } from '@angular/common';
import { classes, hlm } from '@spartan-ng/helm/utils';
import { injectDateAdapter } from '@spartan-ng/brain/date-time';
import { lucideChevronLeft, lucideChevronRight } from '@ng-icons/lucide';
@Component({
selector: 'hlm-calendar-multi',
imports: [BrnCalendarImports, NgIcon, NgTemplateOutlet, HlmSelectImports, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendarMulti,
inputs: [
'min',
'max',
'minSelection',
'maxSelection',
'disabled',
'date',
'dateDisabled',
'weekStartsOn',
'highlightDays',
'defaultFocusedDate',
],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col space-y-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendarMulti<T> {
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendarMulti);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-3 [--cell-radius:var(--radius-4xl)] [--cell-size:--spacing(8)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-calendar-range',
imports: [BrnCalendarImports, NgIcon, HlmSelectImports, NgTemplateOutlet, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendarRange,
inputs: [
'min',
'max',
'disabled',
'startDate',
'endDate',
'dateDisabled',
'weekStartsOn',
'highlightDays',
'defaultFocusedDate',
],
outputs: ['endDateChange', 'startDateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col space-y-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse space-y-1" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day has-[button[data-range-start=true]]:bg-muted has-[button[data-range-start=true]]:after:bg-muted has-[button[data-range-end=true]]:bg-muted has-[button[data-range-end=true]]:after:bg-muted relative isolate z-0 aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none has-[button[data-range-end=true]]:rounded-e-(--cell-radius) has-[button[data-range-end=true]]:after:absolute has-[button[data-range-end=true]]:after:inset-y-0 has-[button[data-range-end=true]]:after:start-0 has-[button[data-range-end=true]]:after:w-4 has-[button[data-range-start=true]]:rounded-s-(--cell-radius) has-[button[data-range-start=true]]:after:absolute has-[button[data-range-start=true]]:after:inset-y-0 has-[button[data-range-start=true]]:after:end-0 has-[button[data-range-start=true]]:after:w-4 [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendarRange<T> {
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendarRange);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-3 [--cell-radius:var(--radius-4xl)] [--cell-size:--spacing(8)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-calendar',
imports: [BrnCalendarImports, NgIcon, HlmSelectImports, NgTemplateOutlet, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendar,
inputs: ['min', 'max', 'disabled', 'date', 'dateDisabled', 'weekStartsOn', 'highlightDays', 'defaultFocusedDate'],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col gap-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse space-y-1" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendar<T> {
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendar);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-3 [--cell-radius:var(--radius-4xl)] [--cell-size:--spacing(8)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-month-year-calendar',
imports: [BrnCalendarImports, NgIcon, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnMonthYearCalendar,
inputs: ['min', 'max', 'disabled', 'date', 'defaultFocusedDate', 'view'],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'month-year-calendar' },
template: `
<div class="flex flex-col gap-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<button
brnMonthYearCalendarPreviousButton
hlmBtn
variant="ghost"
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
<button
hlmBtn
variant="ghost"
class="h-(--cell-size) py-0 select-none aria-disabled:opacity-50"
brnMonthYearCalendarHeader
>
{{ _heading() }}
</button>
<button
brnMonthYearCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<!-- Grid -->
@switch (_picker.view()) {
@case ('year') {
<div brnMonthYearCalendarGrid class="grid grid-cols-4 gap-2">
@for (year of _picker.years(); track _dateAdapter.getYear(year)) {
<button brnMonthYearCalendarYearButton [date]="year" [class]="_btnClass">
{{ _i18n.config().formatYear(_dateAdapter.getYear(year)) }}
</button>
}
</div>
}
@case ('month') {
<div brnMonthYearCalendarGrid class="grid grid-cols-4 gap-2">
@for (month of _picker.months(); track _dateAdapter.getMonth(month)) {
<button brnMonthYearCalendarMonthButton [date]="month" [class]="_btnClass">
{{ _i18n.config().months()[_dateAdapter.getMonth(month)] }}
</button>
}
</div>
}
}
</div>
`,
})
export class HlmMonthYearCalendar<T> {
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the picker directive */
protected readonly _picker = inject(BrnMonthYearCalendar<T>);
/** The heading for the current view. */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
if (this._picker.view() === 'month') {
return config.formatYear(this._dateAdapter.getYear(this._picker.focusedDate()));
}
const { start, end } = this._picker.yearRange();
return `${config.formatYear(start)} – ${config.formatYear(end)}`;
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost' }),
'data-[today=true]:bg-muted',
'data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground data-[selected=true]:hover:bg-primary data-[selected=true]:hover:text-primary-foreground',
'data-[focused=true]:border-ring data-[focused=true]:ring-ring/50 data-[focused=true]:ring-[3px]',
'aria-disabled:pointer-events-none aria-disabled:opacity-50',
'h-(--cell-size)',
);
constructor() {
classes(
() =>
'p-3 [--cell-radius:var(--radius-4xl)] [--cell-size:--spacing(8)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
export const HlmCalendarImports = [HlmCalendar, HlmCalendarMulti, HlmCalendarRange, HlmMonthYearCalendar] as const;import { BrnCalendar, BrnCalendarImports, BrnCalendarMulti, BrnCalendarRange, BrnMonthYearCalendar, injectBrnCalendarI18n } from '@spartan-ng/brain/calendar';
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { HlmButtonImports, buttonVariants } from '@spartan-ng/helm/button';
import { HlmSelectImports } from '@spartan-ng/helm/select';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { NgTemplateOutlet } from '@angular/common';
import { classes, hlm } from '@spartan-ng/helm/utils';
import { injectDateAdapter } from '@spartan-ng/brain/date-time';
import { lucideChevronLeft, lucideChevronRight } from '@ng-icons/lucide';
@Component({
selector: 'hlm-calendar-multi',
imports: [BrnCalendarImports, NgIcon, NgTemplateOutlet, HlmSelectImports, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendarMulti,
inputs: [
'min',
'max',
'minSelection',
'maxSelection',
'disabled',
'date',
'dateDisabled',
'weekStartsOn',
'highlightDays',
'defaultFocusedDate',
],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col space-y-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendarMulti<T> {
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendarMulti);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-3 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(6)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-calendar-range',
imports: [BrnCalendarImports, NgIcon, HlmSelectImports, NgTemplateOutlet, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendarRange,
inputs: [
'min',
'max',
'disabled',
'startDate',
'endDate',
'dateDisabled',
'weekStartsOn',
'highlightDays',
'defaultFocusedDate',
],
outputs: ['endDateChange', 'startDateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col space-y-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse space-y-1" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day has-[button[data-range-start=true]]:bg-muted has-[button[data-range-start=true]]:after:bg-muted has-[button[data-range-end=true]]:bg-muted has-[button[data-range-end=true]]:after:bg-muted relative isolate z-0 aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none has-[button[data-range-end=true]]:rounded-e-(--cell-radius) has-[button[data-range-end=true]]:after:absolute has-[button[data-range-end=true]]:after:inset-y-0 has-[button[data-range-end=true]]:after:start-0 has-[button[data-range-end=true]]:after:w-4 has-[button[data-range-start=true]]:rounded-s-(--cell-radius) has-[button[data-range-start=true]]:after:absolute has-[button[data-range-start=true]]:after:inset-y-0 has-[button[data-range-start=true]]:after:end-0 has-[button[data-range-start=true]]:after:w-4 [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendarRange<T> {
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendarRange);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-3 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(6)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-calendar',
imports: [BrnCalendarImports, NgIcon, HlmSelectImports, NgTemplateOutlet, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendar,
inputs: ['min', 'max', 'disabled', 'date', 'dateDisabled', 'weekStartsOn', 'highlightDays', 'defaultFocusedDate'],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col gap-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse space-y-1" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendar<T> {
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendar);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-3 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(6)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-month-year-calendar',
imports: [BrnCalendarImports, NgIcon, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnMonthYearCalendar,
inputs: ['min', 'max', 'disabled', 'date', 'defaultFocusedDate', 'view'],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'month-year-calendar' },
template: `
<div class="flex flex-col gap-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<button
brnMonthYearCalendarPreviousButton
hlmBtn
variant="ghost"
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
<button
hlmBtn
variant="ghost"
class="h-(--cell-size) py-0 select-none aria-disabled:opacity-50"
brnMonthYearCalendarHeader
>
{{ _heading() }}
</button>
<button
brnMonthYearCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<!-- Grid -->
@switch (_picker.view()) {
@case ('year') {
<div brnMonthYearCalendarGrid class="grid grid-cols-4 gap-2">
@for (year of _picker.years(); track _dateAdapter.getYear(year)) {
<button brnMonthYearCalendarYearButton [date]="year" [class]="_btnClass">
{{ _i18n.config().formatYear(_dateAdapter.getYear(year)) }}
</button>
}
</div>
}
@case ('month') {
<div brnMonthYearCalendarGrid class="grid grid-cols-4 gap-2">
@for (month of _picker.months(); track _dateAdapter.getMonth(month)) {
<button brnMonthYearCalendarMonthButton [date]="month" [class]="_btnClass">
{{ _i18n.config().months()[_dateAdapter.getMonth(month)] }}
</button>
}
</div>
}
}
</div>
`,
})
export class HlmMonthYearCalendar<T> {
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the picker directive */
protected readonly _picker = inject(BrnMonthYearCalendar<T>);
/** The heading for the current view. */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
if (this._picker.view() === 'month') {
return config.formatYear(this._dateAdapter.getYear(this._picker.focusedDate()));
}
const { start, end } = this._picker.yearRange();
return `${config.formatYear(start)} – ${config.formatYear(end)}`;
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost' }),
'data-[today=true]:bg-muted',
'data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground data-[selected=true]:hover:bg-primary data-[selected=true]:hover:text-primary-foreground',
'data-[focused=true]:border-ring data-[focused=true]:ring-ring/50 data-[focused=true]:ring-[3px]',
'aria-disabled:pointer-events-none aria-disabled:opacity-50',
'h-(--cell-size)',
);
constructor() {
classes(
() =>
'p-3 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(6)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
export const HlmCalendarImports = [HlmCalendar, HlmCalendarMulti, HlmCalendarRange, HlmMonthYearCalendar] as const;import { BrnCalendar, BrnCalendarImports, BrnCalendarMulti, BrnCalendarRange, BrnMonthYearCalendar, injectBrnCalendarI18n } from '@spartan-ng/brain/calendar';
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { HlmButtonImports, buttonVariants } from '@spartan-ng/helm/button';
import { HlmSelectImports } from '@spartan-ng/helm/select';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { NgTemplateOutlet } from '@angular/common';
import { classes, hlm } from '@spartan-ng/helm/utils';
import { injectDateAdapter } from '@spartan-ng/brain/date-time';
import { lucideChevronLeft, lucideChevronRight } from '@ng-icons/lucide';
@Component({
selector: 'hlm-calendar-multi',
imports: [BrnCalendarImports, NgIcon, NgTemplateOutlet, HlmSelectImports, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendarMulti,
inputs: [
'min',
'max',
'minSelection',
'maxSelection',
'disabled',
'date',
'dateDisabled',
'weekStartsOn',
'highlightDays',
'defaultFocusedDate',
],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col space-y-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendarMulti<T> {
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendarMulti);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-3 [--cell-radius:var(--radius-4xl)] [--cell-size:--spacing(8)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-calendar-range',
imports: [BrnCalendarImports, NgIcon, HlmSelectImports, NgTemplateOutlet, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendarRange,
inputs: [
'min',
'max',
'disabled',
'startDate',
'endDate',
'dateDisabled',
'weekStartsOn',
'highlightDays',
'defaultFocusedDate',
],
outputs: ['endDateChange', 'startDateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col space-y-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse space-y-1" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day has-[button[data-range-start=true]]:bg-muted has-[button[data-range-start=true]]:after:bg-muted has-[button[data-range-end=true]]:bg-muted has-[button[data-range-end=true]]:after:bg-muted relative isolate z-0 aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none has-[button[data-range-end=true]]:rounded-e-(--cell-radius) has-[button[data-range-end=true]]:after:absolute has-[button[data-range-end=true]]:after:inset-y-0 has-[button[data-range-end=true]]:after:start-0 has-[button[data-range-end=true]]:after:w-4 has-[button[data-range-start=true]]:rounded-s-(--cell-radius) has-[button[data-range-start=true]]:after:absolute has-[button[data-range-start=true]]:after:inset-y-0 has-[button[data-range-start=true]]:after:end-0 has-[button[data-range-start=true]]:after:w-4 [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendarRange<T> {
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendarRange);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-3 [--cell-radius:var(--radius-4xl)] [--cell-size:--spacing(8)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-calendar',
imports: [BrnCalendarImports, NgIcon, HlmSelectImports, NgTemplateOutlet, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnCalendar,
inputs: ['min', 'max', 'disabled', 'date', 'dateDisabled', 'weekStartsOn', 'highlightDays', 'defaultFocusedDate'],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'calendar' },
template: `
<div class="inline-flex flex-col gap-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<ng-template #month>
<hlm-select brnCalendarMonthSelect class="order-1">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (month of _i18n.config().months(); track month) {
<hlm-select-item [value]="month">{{ month }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
<ng-template #year>
<hlm-select brnCalendarYearSelect class="order-3">
<hlm-select-trigger size="sm" [class]="_selectClass">
<hlm-select-value />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal class="max-h-80">
<hlm-select-group>
@for (year of _i18n.config().years(); track year) {
<hlm-select-item [value]="year">{{ year }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
</ng-template>
@let heading = _heading();
<button
brnCalendarPreviousButton
variant="ghost"
hlmBtn
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
@switch (captionLayout()) {
@case ('dropdown') {
<ng-container [ngTemplateOutlet]="month" />
<ng-container [ngTemplateOutlet]="year" />
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="order-4 text-sm font-medium">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="order-2 text-sm font-medium">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="order-5 text-sm font-medium">{{ heading.header }}</div>
}
}
<button
brnCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<table class="w-full border-collapse space-y-1" brnCalendarGrid>
<thead aria-hidden="true">
<tr class="flex">
<th
*brnCalendarWeekday="let weekday"
scope="col"
class="text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none"
[attr.aria-label]="_i18n.config().labelWeekday(weekday)"
>
{{ _i18n.config().formatWeekdayName(weekday) }}
</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr *brnCalendarWeek="let week" class="mt-2 flex w-full">
@for (date of week; track _dateAdapter.getTime(date)) {
<td
brnCalendarCell
class="group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius) [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)"
>
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
{{ _dateAdapter.getDate(date) }}
</button>
</td>
}
</tr>
</tbody>
</table>
</div>
`,
})
export class HlmCalendar<T> {
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date time adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Show dropdowns to navigate between months or years. */
public readonly captionLayout = input<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('label');
/** Access the calendar directive */
private readonly _calendar = inject(BrnCalendar);
/** Get the heading for the current month and year */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
const date = this._calendar.focusedDate();
return {
header: config.formatHeader(this._dateAdapter.getMonth(date), this._dateAdapter.getYear(date)),
month: config.formatMonth(this._dateAdapter.getMonth(date)),
year: config.formatYear(this._dateAdapter.getYear(date)),
};
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost', size: 'icon' }),
'data-[today=true]:bg-muted group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:bg-muted/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
'data-[outside=true]:opacity-50',
"data-[highlighted]:before:content-['']",
'data-[highlighted]:before:absolute',
'data-[highlighted]:before:bottom-1',
'data-[highlighted]:before:start-1/2',
'data-[highlighted]:before:h-1',
'data-[highlighted]:before:w-1',
'data-[highlighted]:before:-translate-x-1/2',
'data-[highlighted]:before:rounded-full',
'data-[highlighted]:before:bg-destructive',
);
protected readonly _selectClass = 'gap-0 px-1.5 py-2 [&>ng-icon]:ms-1';
constructor() {
classes(
() =>
'p-3 [--cell-radius:var(--radius-4xl)] [--cell-size:--spacing(8)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
@Component({
selector: 'hlm-month-year-calendar',
imports: [BrnCalendarImports, NgIcon, HlmButtonImports],
viewProviders: [provideIcons({ lucideChevronLeft, lucideChevronRight })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnMonthYearCalendar,
inputs: ['min', 'max', 'disabled', 'date', 'defaultFocusedDate', 'view'],
outputs: ['dateChange'],
},
],
host: { 'data-slot': 'month-year-calendar' },
template: `
<div class="flex flex-col gap-4">
<!-- Header -->
<div class="flex w-full items-center justify-between gap-1.5">
<button
brnMonthYearCalendarPreviousButton
hlmBtn
variant="ghost"
class="order-first size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronLeft" class="rtl:rotate-180" />
</button>
<button
hlmBtn
variant="ghost"
class="h-(--cell-size) py-0 select-none aria-disabled:opacity-50"
brnMonthYearCalendarHeader
>
{{ _heading() }}
</button>
<button
brnMonthYearCalendarNextButton
hlmBtn
variant="ghost"
class="order-last size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
>
<ng-icon name="lucideChevronRight" class="rtl:rotate-180" />
</button>
</div>
<!-- Grid -->
@switch (_picker.view()) {
@case ('year') {
<div brnMonthYearCalendarGrid class="grid grid-cols-4 gap-2">
@for (year of _picker.years(); track _dateAdapter.getYear(year)) {
<button brnMonthYearCalendarYearButton [date]="year" [class]="_btnClass">
{{ _i18n.config().formatYear(_dateAdapter.getYear(year)) }}
</button>
}
</div>
}
@case ('month') {
<div brnMonthYearCalendarGrid class="grid grid-cols-4 gap-2">
@for (month of _picker.months(); track _dateAdapter.getMonth(month)) {
<button brnMonthYearCalendarMonthButton [date]="month" [class]="_btnClass">
{{ _i18n.config().months()[_dateAdapter.getMonth(month)] }}
</button>
}
</div>
}
}
</div>
`,
})
export class HlmMonthYearCalendar<T> {
/** Access the calendar i18n */
protected readonly _i18n = injectBrnCalendarI18n();
/** Access the date adapter */
protected readonly _dateAdapter = injectDateAdapter<T>();
/** Access the picker directive */
protected readonly _picker = inject(BrnMonthYearCalendar<T>);
/** The heading for the current view. */
protected readonly _heading = computed(() => {
const config = this._i18n.config();
if (this._picker.view() === 'month') {
return config.formatYear(this._dateAdapter.getYear(this._picker.focusedDate()));
}
const { start, end } = this._picker.yearRange();
return `${config.formatYear(start)} – ${config.formatYear(end)}`;
});
protected readonly _btnClass = hlm(
buttonVariants({ variant: 'ghost' }),
'data-[today=true]:bg-muted',
'data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground data-[selected=true]:hover:bg-primary data-[selected=true]:hover:text-primary-foreground',
'data-[focused=true]:border-ring data-[focused=true]:ring-ring/50 data-[focused=true]:ring-[3px]',
'aria-disabled:pointer-events-none aria-disabled:opacity-50',
'h-(--cell-size)',
);
constructor() {
classes(
() =>
'p-3 [--cell-radius:var(--radius-4xl)] [--cell-size:--spacing(8)] group/calendar bg-background block in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
);
}
}
export const HlmCalendarImports = [HlmCalendar, HlmCalendarMulti, HlmCalendarRange, HlmMonthYearCalendar] as const;Usage
import { HlmCalendarImports } from '@spartan-ng/helm/calendar';<hlm-calendar [(date)]="selectedDate" [min]="minDate" [max]="maxDate" />Internationalization
The calendar supports internationalization (i18n) via the BrnCalendarI18nService . By default, weekday names and month headers are rendered in English. You can provide a custom configuration globally or swap it at runtime to support multiple locales.
Global Configuration
Use provideBrnCalendarI18n in your app bootstrap to configure labels and formats globally:
import { bootstrapApplication } from '@angular/platform-browser';
import { provideBrnCalendarI18n } from '@spartan-ng/brain/calendar';
bootstrapApplication(App, {
providers: [
provideBrnCalendarI18n({
formatWeekdayName: (i) => ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][i],
formatHeader: (m, y) =>
new Date(y, m).toLocaleDateString('de-DE', {
month: 'long',
year: 'numeric',
}),
labelPrevious: () => 'Vorheriger Monat',
labelNext: () => 'Nächster Monat',
labelWeekday: (i) => ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][i],
firstDayOfWeek: () => 1,
}),
],
});Runtime Configuration
You can dynamically switch calendar language at runtime by injecting BrnCalendarI18nService and calling use() :
import { injectBrnCalendarI18n } from '@spartan-ng/brain/calendar';
@Component({...})
export class CalendarPage {
private readonly _i18n = injectBrnCalendarI18n();
switchToFrench() {
this._i18n.use({
...,
labelNext: () => 'Mois suivant',
labelPrevious: () => 'Mois précédent',
...
});
}
}Examples
Multiple Selection
Use hlm-calendar-multi for multiple date selection. Limit the selectable dates using minSelection and maxSelection inputs.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HlmCalendarImports } from '@spartan-ng/helm/calendar';
import { HlmCard, HlmCardImports } from '@spartan-ng/helm/card';
@Component({
selector: 'spartan-calendar-multiple',
imports: [HlmCalendarImports, HlmCardImports],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [HlmCard],
host: {
class: 'p-0 w-fit mx-auto',
},
template: `
<div hlmCardContent class="p-0">
<hlm-calendar-multi
[(date)]="selectedDates"
[min]="minDate"
[max]="maxDate"
[minSelection]="2"
[maxSelection]="6"
/>
</div>
`,
})
export class CalendarMultipleExample {
/** The selected date */
public selectedDates = [new Date()];
/** The minimum date */
public minDate = new Date(2023, 0, 1);
/** The maximum date */
public maxDate = new Date(2030, 11, 31);
}Range Selection
Use hlm-calendar-range for range date selection. Set the range by using startDate and endDate inputs.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HlmCalendarImports } from '@spartan-ng/helm/calendar';
import { HlmCard, HlmCardImports } from '@spartan-ng/helm/card';
@Component({
selector: 'spartan-calendar-range',
imports: [HlmCalendarImports, HlmCardImports],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [HlmCard],
host: {
class: 'p-0 w-fit mx-auto',
},
template: `
<div hlmCardContent class="p-0">
<hlm-calendar-range [(startDate)]="start" [(endDate)]="end" [min]="minDate" [max]="maxDate" />
</div>
`,
})
export class CalendarRangeExample {
/** The selected date */
public start = new Date();
public end = new Date(this.start.getTime() + 5 * 24 * 60 * 60 * 1000);
/** The minimum date */
public minDate = new Date(2023, 0, 1);
/** The maximum date */
public maxDate = new Date(2030, 11, 31);
}Month and Year Selector
Caption layouts with a year dropdown use the years function from the calendar's i18n configuration. By default, the dropdown includes an inclusive range from 100 years in the past through 10 years in the future. Override years with provideBrnCalendarI18n if you need a different range.
See the Internationalization section for configuration examples.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
import { Component, model } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HlmCalendar } from '@spartan-ng/helm/calendar';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmSelectImports } from '@spartan-ng/helm/select';
@Component({
selector: 'spartan-calendar-year-and-month-dropdown',
imports: [HlmCalendar, HlmSelectImports, FormsModule, HlmCardImports],
host: {
class: 'flex flex-col gap-4',
},
template: `
<div hlmCard class="mx-auto w-fit p-0">
<div hlmCardContent class="p-0">
<hlm-calendar [captionLayout]="_captionLayout()" />
</div>
</div>
<hlm-select class="inline-block" [(ngModel)]="_captionLayout" [itemToString]="itemToString">
<hlm-select-trigger class="w-full">
<hlm-select-value placeholder="Select an option" />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal>
<hlm-select-group>
<hlm-select-item value="dropdown">Month and Year</hlm-select-item>
<hlm-select-item value="dropdown-months">Only Month</hlm-select-item>
<hlm-select-item value="dropdown-years">Only Year</hlm-select-item>
</hlm-select-group>
</hlm-select-content>
</hlm-select>
`,
})
export class CalendarYearAndMonthDropdownsExample {
protected readonly _captionLayout = model<'dropdown' | 'label' | 'dropdown-months' | 'dropdown-years'>('dropdown');
public readonly options = [
{ value: 'dropdown', label: 'Month and Year' },
{ value: 'dropdown-months', label: 'Only Month' },
{ value: 'dropdown-years', label: 'Only Year' },
];
public readonly itemToString = (value: string) => this.options.find((option) => option.value === value)?.label || '';
}Month and Year
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HlmCalendarImports } from '@spartan-ng/helm/calendar';
import { HlmCard, HlmCardImports } from '@spartan-ng/helm/card';
@Component({
selector: 'spartan-month-year-example',
imports: [HlmCardImports, HlmCalendarImports],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [HlmCard],
host: {
class: 'p-0 w-fit mx-auto',
},
template: `
<div hlmCardContent class="p-0">
<hlm-month-year-calendar [(date)]="selectedDate" />
</div>
`,
})
export class CalendarMonthAndYearExample {
/** The selected date */
public selectedDate = new Date();
}RTL
To enable RTL support in spartan-ng, see the RTL configuration guide.
| ح | ن | ث | ر | خ | ج | س |
|---|---|---|---|---|---|---|
import { ChangeDetectionStrategy, Component, computed, effect, inject, untracked } from '@angular/core';
import { TranslateService, Translations } from '@spartan-ng/app/app/shared/translate.service';
import { injectBrnCalendarI18n, provideBrnCalendarI18n } from '@spartan-ng/brain/calendar';
import { HlmCalendar } from '@spartan-ng/helm/calendar';
import { HlmCard, HlmCardImports } from '@spartan-ng/helm/card';
import { DateTime } from 'luxon';
import { CALENDAR_I18N } from '../(date-picker)/date-picker--rtl.preview';
@Component({
selector: 'spartan-calendar-rtl',
imports: [HlmCalendar, HlmCardImports],
providers: [provideBrnCalendarI18n()],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [HlmCard],
host: {
'[dir]': '_dir()',
class: 'p-0 w-fit mx-auto',
},
template: `
<div hlmCardContent class="p-0">
<hlm-calendar [(date)]="selectedDate" [min]="minDate" [max]="maxDate" captionLayout="dropdown" />
</div>
`,
})
export class CalendarRtl {
/** The selected date */
public selectedDate = new Date();
/** The minimum date */
public minDate = new Date(new Date().setMonth(new Date().getMonth() - 2));
/** The maximum date */
public maxDate = new Date(new Date().setMonth(new Date().getMonth() + 2));
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: {},
},
ar: {
dir: 'rtl',
values: {},
},
he: {
dir: 'rtl',
values: {},
},
};
private readonly _translation = computed(() => this._translations[this._language()]);
protected readonly _t = computed(() => this._translation().values);
protected readonly _dir = computed(() => this._translation().dir);
}For another RTL example please look at the Jalali (Persian) calendar.
Brain API
BrnCalendarCellButton
Selector: button[brnCalendarCellButton]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| date* (required) | T | - | The date this cell represents |
BrnCalendarCell
Selector: [brnCalendarCell]
BrnCalendarGrid
Selector: [brnCalendarGrid]
BrnCalendarHeader
Selector: [brnCalendarHeader]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | `brn-calendar-header-${++uniqueId}` | The unique id for the header |
BrnCalendarMonthSelect
Selector: brnSelect[brnCalendarMonthSelect],hlm-select[brnCalendarMonthSelect]
BrnCalendarNextButton
Selector: button[brnCalendarNextButton]
BrnCalendarPreviousButton
Selector: button[brnCalendarPreviousButton]
BrnCalendarWeek
Selector: [brnCalendarWeek]
BrnCalendarWeekday
Selector: [brnCalendarWeekday]
BrnCalendarYearSelect
Selector: brnSelect[brnCalendarYearSelect],hlm-select[brnCalendarYearSelect]
BrnCalendar
Selector: [brnCalendar]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| highlightDays | T[] | [] | The days to highlight. |
| 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. |
| dateDisabled | (date: T) => boolean | () => false | Whether a specific date is disabled. |
| weekStartsOn | Weekday | undefined | The day the week starts on |
| defaultFocusedDate | T | - | The default focused date. |
| date | T | - | The selected value. |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| focusedDateChange | T | - | Emits whenever the focused date changes. |
| dateChange | T | - | The selected value. |
BrnCalendarMulti
Selector: [brnCalendarMulti]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| highlightDays | T[] | [] | The days to highlight. |
| 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. |
| dateDisabled | (date: T) => boolean | () => false | Whether a specific date is disabled. |
| weekStartsOn | Weekday | undefined | The day the week starts on |
| defaultFocusedDate | T | - | The default focused date. |
| date | T[] | - | The selected value. |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| focusedDateChange | T | - | Emits whenever the focused date changes. |
| dateChange | T[] | - | The selected value. |
BrnCalendarRange
Selector: [brnCalendarRange]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| highlightDays | T[] | [] | The days to highlight. |
| 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. |
| dateDisabled | (date: T) => boolean | () => false | Whether a specific date is disabled. |
| weekStartsOn | Weekday | undefined | The day the week starts on |
| defaultFocusedDate | T | - | The default focused date. |
| startDate | T | - | The selected start date |
| endDate | T | - | The selected end date |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| focusedDateChange | T | - | Emits whenever the focused date changes. |
| startDateChange | T | - | The selected start date |
| endDateChange | T | - | The selected end date |
BrnMonthYearCalendarGrid
Selector: [brnMonthYearCalendarGrid]
BrnMonthYearCalendarHeader
Selector: button[brnMonthYearCalendarHeader]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | `brn-month-year-header-${++uniqueId}` | The unique id for the header |
BrnMonthYearCalendarMonthButton
Selector: button[brnMonthYearCalendarMonthButton]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| date* (required) | T | - | The month this cell represents. |
BrnMonthYearCalendarNextButton
Selector: button[brnMonthYearCalendarNextButton]
BrnMonthYearCalendarPreviousButton
Selector: button[brnMonthYearCalendarPreviousButton]
BrnMonthYearCalendarYearButton
Selector: button[brnMonthYearCalendarYearButton]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| date* (required) | T | - | The year this cell represents. |
BrnMonthYearCalendar
Selector: [brnMonthYearCalendar]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| min | T | - | The minimum date that can be selected. |
| max | T | - | The maximum date that can be selected. |
| disabled | boolean | false | Whether the month/year selector is disabled. |
| defaultFocusedDate | T | - | The default focused date. |
| view | BrnMonthYearCalendarView | 'year' | The current view. The year view is shown first. |
| date | T | - | The selected month. Represented by the first day of the month. |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| focusedDateChange | T | - | Emits whenever the focused date changes. |
| dateChange | T | - | The selected month. Represented by the first day of the month. |
Helm API
HlmCalendarMulti
Selector: hlm-calendar-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. |
| dateDisabled | (date: T) => boolean | () => false | Whether a specific date is disabled. |
| weekStartsOn | Weekday | undefined | The day the week starts on |
| highlightDays | T[] | [] | The days to highlight. |
| defaultFocusedDate | T | - | The default focused date. |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| dateChange | T[] | - | The selected value. |
HlmCalendarRange
Selector: hlm-calendar-range
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. |
| startDate | T | - | The selected start date |
| endDate | T | - | The selected end date |
| dateDisabled | (date: T) => boolean | () => false | Whether a specific date is disabled. |
| weekStartsOn | Weekday | undefined | The day the week starts on |
| highlightDays | T[] | [] | The days to highlight. |
| defaultFocusedDate | T | - | The default focused date. |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| endDateChange | T | - | The selected end date |
| startDateChange | T | - | The selected start date |
HlmCalendar
Selector: hlm-calendar
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. |
| dateDisabled | (date: T) => boolean | () => false | Whether a specific date is disabled. |
| weekStartsOn | Weekday | undefined | The day the week starts on |
| highlightDays | T[] | [] | The days to highlight. |
| defaultFocusedDate | T | - | The default focused date. |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| dateChange | T | - | The selected value. |
HlmMonthYearCalendar
Selector: hlm-month-year-calendar
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| min | T | - | The minimum date that can be selected. |
| max | T | - | The maximum date that can be selected. |
| disabled | boolean | false | Whether the month/year selector is disabled. |
| date | T | - | The selected month. Represented by the first day of the month. |
| defaultFocusedDate | T | - | The default focused date. |
| view | BrnMonthYearCalendarView | 'year' | The current view. The year view is shown first. |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| dateChange | T | - | The selected month. Represented by the first day of the month. |
On This Page