import { uid } from '../shared/dom';
import {
CALENDAR_EVENT_CLICK_EVENT,
CALENDAR_EVENT_MOVE_EVENT,
CALENDAR_NAVIGATE_EVENT,
CALENDAR_SELECT_EVENT,
CALENDAR_VIEW_CHANGE_EVENT,
type CalendarEvent,
type CalendarEventInput,
type CalendarOptions,
type CalendarView,
type WeekDay,
} from './calendar.types';
import {
addDays,
addMonths,
dateKey,
dayFraction,
isSameDay,
isSameMonth,
startOfDay,
startOfMonth,
startOfWeek,
toDate,
} from './calendar.utils';
const SELECTORS = {
grid: '[data-c42-calendar-grid]',
title: '[data-c42-calendar-title]',
prev: '[data-c42-calendar-prev]',
next: '[data-c42-calendar-next]',
today: '[data-c42-calendar-today]',
view: '[data-c42-calendar-view]',
day: '[data-c42-calendar-day]',
event: '[data-c42-calendar-event]',
} as const;
interface DragState {
id: string;
}
/**
* Headless calendar controller. Renders month/week/day grids into a provided
* container, places events into their day, supports keyboard date navigation,
* view switching, prev/next/today navigation and pointer drag to move events.
* It reflects state via `data-*` attributes and CSS custom properties — it
* applies no visual styling.
*
* Markup:
* ```html
*
*
*
*
*
*
*
*
*
*
*
*
* ```
*/
export class Calendar {
private readonly root: HTMLElement;
private readonly grid: HTMLElement;
private readonly titleEl: HTMLElement | null;
private readonly weekStartsOn: WeekDay;
private readonly locale: string | undefined;
private current: Date;
private view: CalendarView;
private focused: Date;
private selected: Date | null = null;
private readonly events = new Map();
private drag: DragState | null = null;
private cleanups: Array<() => void> = [];
constructor(root: HTMLElement, options: CalendarOptions = {}) {
const grid = root.querySelector(SELECTORS.grid);
if (!grid) {
throw new Error('[42/calendar] Needs a [data-c42-calendar-grid] element.');
}
this.root = root;
this.grid = grid;
this.titleEl = root.querySelector(SELECTORS.title);
this.weekStartsOn = options.weekStartsOn ?? 0;
this.locale = options.locale;
this.view = options.view ?? 'month';
this.current = startOfDay(options.defaultDate ? toDate(options.defaultDate) : new Date());
this.focused = this.current;
(options.events ?? []).forEach((event) => this.normalizeInto(event));
this.init();
}
private normalizeInto(input: CalendarEventInput): CalendarEvent {
const start = toDate(input.start);
const end = input.end ? toDate(input.end) : new Date(start.getTime() + 60 * 60 * 1000);
const event: CalendarEvent = {
id: input.id ?? uid('cal-event'),
start,
end,
title: input.title ?? '',
allDay: input.allDay ?? false,
};
this.events.set(event.id, event);
return event;
}
private init(): void {
this.grid.setAttribute('role', 'grid');
if (!this.grid.hasAttribute('aria-label')) {
this.grid.setAttribute('aria-label', 'Calendar');
}
if (!this.grid.hasAttribute('tabindex')) {
this.grid.setAttribute('tabindex', '0');
}
this.bindControl(SELECTORS.prev, () => this.navigate(-1));
this.bindControl(SELECTORS.next, () => this.navigate(1));
this.bindControl(SELECTORS.today, () => this.goToToday());
this.root.querySelectorAll(SELECTORS.view).forEach((button) => {
if (button instanceof HTMLButtonElement && !button.hasAttribute('type')) {
button.type = 'button';
}
const onClick = (): void => {
const next = button.dataset.c42CalendarView as CalendarView | undefined;
if (next) {
this.setView(next);
}
};
button.addEventListener('click', onClick);
this.cleanups.push(() => button.removeEventListener('click', onClick));
});
const onKeydown = (event: KeyboardEvent): void => this.onKeydown(event);
this.grid.addEventListener('keydown', onKeydown);
this.cleanups.push(() => this.grid.removeEventListener('keydown', onKeydown));
const onGridClick = (event: Event): void => this.onGridClick(event);
this.grid.addEventListener('click', onGridClick);
this.cleanups.push(() => this.grid.removeEventListener('click', onGridClick));
const onPointerDown = (event: Event): void => this.onPointerDown(event as PointerEvent);
this.grid.addEventListener('pointerdown', onPointerDown);
this.cleanups.push(() => this.grid.removeEventListener('pointerdown', onPointerDown));
this.render();
}
private bindControl(selector: string, handler: () => void): void {
const control = this.root.querySelector(selector);
if (!control) {
return;
}
if (control instanceof HTMLButtonElement && !control.hasAttribute('type')) {
control.type = 'button';
}
const onClick = (): void => handler();
control.addEventListener('click', onClick);
this.cleanups.push(() => control.removeEventListener('click', onClick));
}
// ─── Range helpers ───────────────────────────────────────────────────────
private rangeStart(): Date {
if (this.view === 'month') {
return startOfWeek(startOfMonth(this.current), this.weekStartsOn);
}
if (this.view === 'week') {
return startOfWeek(this.current, this.weekStartsOn);
}
return startOfDay(this.current);
}
private dayCount(): number {
if (this.view === 'month') {
return 42;
}
return this.view === 'week' ? 7 : 1;
}
private days(): Date[] {
const start = this.rangeStart();
return Array.from({ length: this.dayCount() }, (_, i) => addDays(start, i));
}
private eventsOn(day: Date): CalendarEvent[] {
return Array.from(this.events.values())
.filter((event) => isSameDay(event.start, day))
.sort((a, b) => a.start.getTime() - b.start.getTime());
}
private inPeriod(date: Date): boolean {
const start = this.rangeStart();
const end = addDays(start, this.dayCount());
return date >= start && date < end;
}
// ─── Rendering ───────────────────────────────────────────────────────────
private render(): void {
this.root.dataset.view = this.view;
this.renderTitle();
this.renderViewButtons();
const today = new Date();
this.grid.replaceChildren();
if (this.view !== 'day') {
this.renderWeekdayHeader();
}
this.days().forEach((day) => {
const cell = document.createElement('div');
cell.dataset.c42CalendarDay = '';
cell.dataset.date = dateKey(day);
cell.setAttribute('role', 'gridcell');
cell.tabIndex = isSameDay(day, this.focused) ? 0 : -1;
if (isSameDay(day, today)) {
cell.dataset.today = 'true';
}
if (this.selected && isSameDay(day, this.selected)) {
cell.dataset.selected = 'true';
cell.setAttribute('aria-selected', 'true');
}
if (this.view === 'month' && !isSameMonth(day, this.current)) {
cell.dataset.outside = 'true';
}
if (isSameDay(day, this.focused)) {
cell.dataset.focused = 'true';
}
const number = document.createElement('div');
number.dataset.c42CalendarDaynumber = '';
number.textContent = String(day.getDate());
cell.appendChild(number);
const eventsBox = document.createElement('div');
eventsBox.dataset.c42CalendarEvents = '';
this.eventsOn(day).forEach((event) => eventsBox.appendChild(this.renderEvent(event)));
cell.appendChild(eventsBox);
this.grid.appendChild(cell);
});
}
private renderEvent(event: CalendarEvent): HTMLElement {
const el = document.createElement('button');
el.type = 'button';
el.dataset.c42CalendarEvent = '';
el.dataset.eventId = event.id;
el.textContent = event.title;
if (event.allDay) {
el.dataset.allDay = 'true';
}
// Layout-agnostic time hints for week/day positioning.
el.style.setProperty('--c42-calendar-event-start', String(dayFraction(event.start)));
el.style.setProperty('--c42-calendar-event-end', String(dayFraction(event.end)));
return el;
}
private renderTitle(): void {
if (!this.titleEl) {
return;
}
this.titleEl.textContent = this.formatTitle();
}
private formatTitle(): string {
if (this.view === 'day') {
return new Intl.DateTimeFormat(this.locale, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(this.current);
}
if (this.view === 'week') {
const start = startOfWeek(this.current, this.weekStartsOn);
const end = addDays(start, 6);
const startStr = new Intl.DateTimeFormat(this.locale, {
month: 'short',
day: 'numeric',
}).format(start);
const endStr = new Intl.DateTimeFormat(this.locale, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(end);
return `${startStr} – ${endStr}`;
}
return new Intl.DateTimeFormat(this.locale, { month: 'long', year: 'numeric' }).format(
this.current,
);
}
private renderViewButtons(): void {
this.root.querySelectorAll(SELECTORS.view).forEach((button) => {
const active = button.dataset.c42CalendarView === this.view;
button.setAttribute('aria-pressed', String(active));
button.dataset.active = String(active);
});
}
private renderWeekdayHeader(): void {
const start = startOfWeek(this.current, this.weekStartsOn);
const fmt = new Intl.DateTimeFormat(this.locale, { weekday: 'short' });
for (let i = 0; i < 7; i += 1) {
const label = document.createElement('div');
label.dataset.c42CalendarWeekday = '';
label.setAttribute('role', 'columnheader');
label.textContent = fmt.format(addDays(start, i));
this.grid.appendChild(label);
}
}
// ─── Keyboard ──────────────────────────────────────────────────────────
private onKeydown(event: KeyboardEvent): void {
const step = this.view === 'day' ? 1 : 7;
let handled = true;
switch (event.key) {
case 'ArrowLeft':
this.moveFocus(-1);
break;
case 'ArrowRight':
this.moveFocus(1);
break;
case 'ArrowUp':
this.moveFocus(-step);
break;
case 'ArrowDown':
this.moveFocus(step);
break;
case 'Home':
this.moveFocusTo(startOfWeek(this.focused, this.weekStartsOn));
break;
case 'End':
this.moveFocusTo(addDays(startOfWeek(this.focused, this.weekStartsOn), 6));
break;
case 'PageUp':
this.navigate(-1);
break;
case 'PageDown':
this.navigate(1);
break;
case 'Enter':
case ' ':
this.selectDate(this.focused);
break;
default:
handled = false;
break;
}
if (handled) {
event.preventDefault();
}
}
private moveFocus(deltaDays: number): void {
this.moveFocusTo(addDays(this.focused, deltaDays));
}
private moveFocusTo(date: Date): void {
this.focused = startOfDay(date);
if (!this.inPeriod(this.focused)) {
this.current = this.focused;
}
this.render();
this.focusCell(this.focused);
}
private focusCell(date: Date): void {
const cell = this.grid.querySelector(
`${SELECTORS.day}[data-date="${dateKey(date)}"]`,
);
cell?.focus?.();
}
// ─── Pointer ───────────────────────────────────────────────────────────
private onGridClick(event: Event): void {
const target = event.target as HTMLElement;
const eventEl = target.closest(SELECTORS.event);
if (eventEl) {
const id = eventEl.dataset.eventId;
const found = id ? this.events.get(id) : undefined;
if (found) {
this.root.dispatchEvent(
new CustomEvent(CALENDAR_EVENT_CLICK_EVENT, {
detail: { id: found.id, event: found },
bubbles: true,
}),
);
}
return;
}
const cell = target.closest(SELECTORS.day);
if (cell?.dataset.date) {
this.selectDate(toDate(cell.dataset.date));
}
}
private onPointerDown(event: PointerEvent): void {
const target = event.target as HTMLElement;
const eventEl = target.closest(SELECTORS.event);
const id = eventEl?.dataset.eventId;
if (!eventEl || !id || !this.events.has(id)) {
return;
}
eventEl.dataset.dragging = 'true';
this.drag = { id };
document.addEventListener('pointermove', this.onPointerMove);
document.addEventListener('pointerup', this.onPointerUp);
}
private readonly onPointerMove = (event: PointerEvent): void => {
if (!this.drag) {
return;
}
const el = document.elementFromPoint?.(event.clientX, event.clientY) as HTMLElement | null;
const cell = el?.closest?.(SELECTORS.day);
if (cell?.dataset.date) {
this.root.querySelectorAll(`${SELECTORS.day}[data-dragover]`).forEach((node) =>
(node as HTMLElement).removeAttribute('data-dragover'),
);
cell.dataset.dragover = 'true';
}
};
private readonly onPointerUp = (event: PointerEvent): void => {
if (this.drag) {
const el = document.elementFromPoint?.(event.clientX, event.clientY) as HTMLElement | null;
const cell = el?.closest?.(SELECTORS.day);
if (cell?.dataset.date) {
this.moveEvent(this.drag.id, toDate(cell.dataset.date));
} else {
this.render();
}
this.drag = null;
}
document.removeEventListener('pointermove', this.onPointerMove);
document.removeEventListener('pointerup', this.onPointerUp);
};
// ─── Public API ──────────────────────────────────────────────────────────
/** Switch the view (month/week/day). */
setView(view: CalendarView): void {
if (view === this.view) {
return;
}
this.view = view;
this.render();
this.root.dispatchEvent(
new CustomEvent(CALENDAR_VIEW_CHANGE_EVENT, { detail: { view }, bubbles: true }),
);
}
/** Move to the previous/next period (sign of `direction`). */
navigate(direction: number): void {
const step = direction < 0 ? -1 : 1;
if (this.view === 'month') {
this.current = addMonths(this.current, step);
} else {
this.current = addDays(this.current, step * (this.view === 'week' ? 7 : 1));
}
this.focused = this.current;
this.render();
this.focusCell(this.focused);
this.emitNavigate();
}
/** Jump to today's period. */
goToToday(): void {
this.current = startOfDay(new Date());
this.focused = this.current;
this.render();
this.emitNavigate();
}
/** Programmatically navigate to a given date (keeps the current view). */
goToDate(date: Date | string): void {
this.current = startOfDay(toDate(date));
this.focused = this.current;
this.render();
this.emitNavigate();
}
/** Select a date, reflect it and emit `calendar:select`. */
selectDate(date: Date | string): void {
const day = startOfDay(toDate(date));
this.selected = day;
this.focused = day;
if (!this.inPeriod(day)) {
this.current = day;
}
this.render();
this.root.dispatchEvent(
new CustomEvent(CALENDAR_SELECT_EVENT, {
detail: { date: day, dateKey: dateKey(day) },
bubbles: true,
}),
);
}
/** Add an event and re-render. Returns the normalized event. */
addEvent(input: CalendarEventInput): CalendarEvent {
const event = this.normalizeInto(input);
this.render();
return event;
}
/** Remove an event by id. Returns true if it existed. */
removeEvent(id: string): boolean {
const existed = this.events.delete(id);
if (existed) {
this.render();
}
return existed;
}
/**
* Move an event to a new day (preserving its time-of-day and duration) and
* emit `calendar:eventmove`.
*/
moveEvent(id: string, toDay: Date | string): boolean {
const event = this.events.get(id);
if (!event) {
return false;
}
const target = startOfDay(toDate(toDay));
if (isSameDay(event.start, target)) {
this.render();
return false;
}
const duration = event.end.getTime() - event.start.getTime();
const newStart = new Date(target.getTime());
newStart.setHours(event.start.getHours(), event.start.getMinutes(), 0, 0);
const newEnd = new Date(newStart.getTime() + duration);
event.start = newStart;
event.end = newEnd;
this.render();
this.root.dispatchEvent(
new CustomEvent(CALENDAR_EVENT_MOVE_EVENT, {
detail: { id, event, start: newStart, end: newEnd },
bubbles: true,
}),
);
return true;
}
/** All events, sorted by start. */
getEvents(): CalendarEvent[] {
return Array.from(this.events.values()).sort((a, b) => a.start.getTime() - b.start.getTime());
}
get currentView(): CalendarView {
return this.view;
}
get currentDate(): Date {
return new Date(this.current.getTime());
}
get selectedDate(): Date | null {
return this.selected ? new Date(this.selected.getTime()) : null;
}
private emitNavigate(): void {
this.root.dispatchEvent(
new CustomEvent(CALENDAR_NAVIGATE_EVENT, {
detail: { date: this.currentDate, view: this.view },
bubbles: true,
}),
);
}
/** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */
on(event: string, handler: (event: E) => void): () => void {
const listener = handler as EventListener;
this.root.addEventListener(event, listener);
const off = (): void => this.root.removeEventListener(event, listener);
this.cleanups.push(off);
return off;
}
destroy(): void {
document.removeEventListener('pointermove', this.onPointerMove);
document.removeEventListener('pointerup', this.onPointerUp);
this.cleanups.forEach((fn) => fn());
this.cleanups = [];
}
}