rspace-online/lib/folk-calendar.ts

450 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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`
<div class="header">
<span class="header-title">
<span>📅</span>
<span>Calendar</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="calendar-container">
<div class="calendar-nav">
<button class="prev-btn"></button>
<span class="month-year"></span>
<button class="next-btn"></button>
</div>
<div class="weekdays">
<span class="weekday">Sun</span>
<span class="weekday">Mon</span>
<span class="weekday">Tue</span>
<span class="weekday">Wed</span>
<span class="weekday">Thu</span>
<span class="weekday">Fri</span>
<span class="weekday">Sat</span>
</div>
<div class="days"></div>
<div class="events-list"></div>
</div>
`;
// 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 += `<div class="day other-month" data-date="${year}-${month - 1}-${day}">${day}</div>`;
}
// 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 += `<div class="${classes.join(" ")}" data-date="${year}-${month}-${day}">${day}</div>`;
}
// Next month padding
const totalCells = startPadding + daysInMonth;
const nextPadding = totalCells <= 35 ? 35 - totalCells : 42 - totalCells;
for (let day = 1; day <= nextPadding; day++) {
html += `<div class="day other-month" data-date="${year}-${month + 1}-${day}">${day}</div>`;
}
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 = '<div class="event-item">No events</div>';
} else {
this.#eventsListEl.innerHTML = dayEvents
.map(
(event) => `
<div class="event-item">
<span class="event-dot" style="background: ${event.color || "#8b5cf6"}"></span>
<span>${this.#escapeHtml(event.title)}</span>
</div>
`
)
.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(),
})),
};
}
}