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 = []; } }