import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; const styles = css` :host { background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); min-width: 280px; min-height: 320px; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #8b5cf6; color: white; border-radius: 8px 8px 0 0; font-size: 12px; font-weight: 600; cursor: move; } .header-title { display: flex; align-items: center; gap: 6px; } .header-actions { display: flex; gap: 4px; } .header-actions button { background: transparent; border: none; color: white; cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 14px; } .header-actions button:hover { background: rgba(255, 255, 255, 0.2); } .calendar-container { padding: 12px; } .calendar-nav { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } .calendar-nav button { background: #f1f5f9; border: none; border-radius: 4px; padding: 4px 8px; cursor: pointer; font-size: 16px; } .calendar-nav button:hover { background: #e2e8f0; } .month-year { font-weight: 600; font-size: 14px; color: #1e293b; } .weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; margin-bottom: 4px; } .weekday { text-align: center; font-size: 11px; font-weight: 600; color: #64748b; padding: 4px; } .days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; } .day { aspect-ratio: 1; display: flex; align-items: center; justify-content: center; font-size: 12px; border-radius: 4px; cursor: pointer; color: #374151; } .day:hover { background: #f1f5f9; } .day.other-month { color: #94a3b8; } .day.today { background: #8b5cf6; color: white; font-weight: 600; } .day.selected { background: #ddd6fe; color: #5b21b6; font-weight: 600; } .day.has-event { position: relative; } .day.has-event::after { content: ""; position: absolute; bottom: 2px; width: 4px; height: 4px; background: #8b5cf6; border-radius: 50%; } .day.has-event.today::after { background: white; } .events-list { margin-top: 12px; padding-top: 12px; border-top: 1px solid #e2e8f0; max-height: 120px; overflow-y: auto; } .event-item { display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 12px; border-radius: 4px; margin-bottom: 4px; background: #f8fafc; } .event-dot { width: 6px; height: 6px; border-radius: 50%; background: #8b5cf6; } `; export interface CalendarEvent { id: string; title: string; date: Date; color?: string; } declare global { interface HTMLElementTagNameMap { "folk-calendar": FolkCalendar; } } export class FolkCalendar extends FolkShape { static override tagName = "folk-calendar"; static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules) .map((r) => r.cssText) .join("\n"); const childRules = Array.from(styles.cssRules) .map((r) => r.cssText) .join("\n"); sheet.replaceSync(`${parentRules}\n${childRules}`); this.styles = sheet; } #currentMonth = new Date(); #selectedDate: Date | null = null; #events: CalendarEvent[] = []; #daysContainer: HTMLElement | null = null; #monthYearEl: HTMLElement | null = null; #eventsListEl: HTMLElement | null = null; get selectedDate() { return this.#selectedDate; } set selectedDate(date: Date | null) { this.#selectedDate = date; this.#render(); this.dispatchEvent(new CustomEvent("date-select", { detail: { date } })); } get events() { return this.#events; } set events(events: CalendarEvent[]) { this.#events = events; this.#render(); } addEvent(event: CalendarEvent) { this.#events.push(event); this.#render(); this.dispatchEvent(new CustomEvent("event-add", { detail: { event } })); } override createRenderRoot() { const root = super.createRenderRoot(); const wrapper = document.createElement("div"); wrapper.innerHTML = html`
\u{1F4C5} Calendar
Sun Mon Tue Wed Thu Fri Sat
`; // Replace the container div (slot's parent) with our wrapper const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) { containerDiv.replaceWith(wrapper); } // Get element references this.#daysContainer = wrapper.querySelector(".days"); this.#monthYearEl = wrapper.querySelector(".month-year"); this.#eventsListEl = wrapper.querySelector(".events-list"); const prevBtn = wrapper.querySelector(".prev-btn") as HTMLButtonElement; const nextBtn = wrapper.querySelector(".next-btn") as HTMLButtonElement; const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; // Navigation prevBtn.addEventListener("click", (e) => { e.stopPropagation(); this.#currentMonth = new Date( this.#currentMonth.getFullYear(), this.#currentMonth.getMonth() - 1, 1 ); this.#render(); }); nextBtn.addEventListener("click", (e) => { e.stopPropagation(); this.#currentMonth = new Date( this.#currentMonth.getFullYear(), this.#currentMonth.getMonth() + 1, 1 ); this.#render(); }); closeBtn.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); // Initial render this.#render(); return root; } #render() { if (!this.#daysContainer || !this.#monthYearEl || !this.#eventsListEl) return; const year = this.#currentMonth.getFullYear(); const month = this.#currentMonth.getMonth(); // Update month/year display const monthNames = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ]; this.#monthYearEl.textContent = `${monthNames[month]} ${year}`; // Calculate days const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); const startPadding = firstDay.getDay(); const daysInMonth = lastDay.getDate(); const today = new Date(); today.setHours(0, 0, 0, 0); // Generate day cells let html = ""; // Previous month padding const prevMonthLastDay = new Date(year, month, 0).getDate(); for (let i = startPadding - 1; i >= 0; i--) { const day = prevMonthLastDay - i; html += `
${day}
`; } // Current month days for (let day = 1; day <= daysInMonth; day++) { const date = new Date(year, month, day); date.setHours(0, 0, 0, 0); const classes = ["day"]; if (date.getTime() === today.getTime()) classes.push("today"); if (this.#selectedDate && date.getTime() === this.#selectedDate.getTime()) { classes.push("selected"); } if (this.#hasEventOnDate(date)) classes.push("has-event"); html += `
${day}
`; } // Next month padding const totalCells = startPadding + daysInMonth; const nextPadding = totalCells <= 35 ? 35 - totalCells : 42 - totalCells; for (let day = 1; day <= nextPadding; day++) { html += `
${day}
`; } this.#daysContainer.innerHTML = html; // Add click handlers this.#daysContainer.querySelectorAll(".day").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); const dateStr = (el as HTMLElement).dataset.date; if (dateStr) { const [y, m, d] = dateStr.split("-").map(Number); this.selectedDate = new Date(y, m, d); } }); }); // Render events for selected date this.#renderEvents(); } #hasEventOnDate(date: Date): boolean { return this.#events.some((event) => { const eventDate = new Date(event.date); eventDate.setHours(0, 0, 0, 0); return eventDate.getTime() === date.getTime(); }); } #renderEvents() { if (!this.#eventsListEl) return; if (!this.#selectedDate) { this.#eventsListEl.style.display = "none"; return; } const dayEvents = this.#events.filter((event) => { const eventDate = new Date(event.date); eventDate.setHours(0, 0, 0, 0); const selected = new Date(this.#selectedDate!); selected.setHours(0, 0, 0, 0); return eventDate.getTime() === selected.getTime(); }); if (dayEvents.length === 0) { this.#eventsListEl.innerHTML = '
No events
'; } else { this.#eventsListEl.innerHTML = dayEvents .map( (event) => `
${this.#escapeHtml(event.title)}
` ) .join(""); } this.#eventsListEl.style.display = "block"; } #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } override toJSON() { return { ...super.toJSON(), type: "folk-calendar", selectedDate: this.selectedDate?.toISOString() || null, events: this.events.map((e) => ({ ...e, date: e.date.toISOString(), })), }; } }