import { signal, effect } from "../../signals"; import { emit } from "../../shared/helpers"; /** PettyCalendar — month grid with day selection and month navigation. */ export class PettyCalendar extends HTMLElement { static observedAttributes = ["value", "min", "max"]; readonly #month = signal(new Date().getMonth()); readonly #year = signal(new Date().getFullYear()); readonly #selected = signal(""); #stopEffect: (() => void) | null = null; get value(): string { return this.#selected.get(); } set value(v: string) { this.#selected.set(v); } connectedCallback(): void { const init = this.getAttribute("value") ?? ""; if (init) { this.#selected.set(init); this.#parseMonth(init); } this.#stopEffect = effect(() => this.#render()); this.querySelector("[data-part=prev-month]")?.addEventListener("click", this.#onPrev); this.querySelector("[data-part=next-month]")?.addEventListener("click", this.#onNext); this.addEventListener("click", this.#onDayClick); } disconnectedCallback(): void { this.#stopEffect = null; this.querySelector("[data-part=prev-month]")?.removeEventListener("click", this.#onPrev); this.querySelector("[data-part=next-month]")?.removeEventListener("click", this.#onNext); this.removeEventListener("click", this.#onDayClick); } attributeChangedCallback(name: string, _old: string | null, next: string | null): void { if (name === "value" && next) { this.#selected.set(next); this.#parseMonth(next); } } #parseMonth(dateStr: string): void { const d = new Date(dateStr); if (!Number.isNaN(d.getTime())) { this.#month.set(d.getMonth()); this.#year.set(d.getFullYear()); } } #onPrev = (): void => { if (this.#month.get() === 0) { this.#month.set(11); this.#year.set(this.#year.get() - 1); } else this.#month.set(this.#month.get() - 1); }; #onNext = (): void => { if (this.#month.get() === 11) { this.#month.set(0); this.#year.set(this.#year.get() + 1); } else this.#month.set(this.#month.get() + 1); }; #onDayClick = (e: Event): void => { const btn = (e.target as HTMLElement).closest("[data-date]"); if (!btn || btn.hasAttribute("data-disabled")) return; const date = (btn as HTMLElement).dataset.date ?? ""; this.#selected.set(date); emit(this, "change", { value: date }); }; #createDayCell(day: number, iso: string, sel: string, today: string): HTMLTableCellElement { const td = document.createElement("td"); const btn = document.createElement("button"); btn.dataset.date = iso; btn.dataset.part = "day"; btn.setAttribute("role", "gridcell"); btn.textContent = String(day); if (iso === sel) btn.dataset.state = "selected"; if (iso === today) btn.dataset.state = btn.dataset.state ? `${btn.dataset.state} today` : "today"; td.appendChild(btn); return td; } #render(): void { const m = this.#month.get(); const y = this.#year.get(); const sel = this.#selected.get(); const title = this.querySelector("[data-part=title]"); if (title) title.textContent = `${new Date(y, m).toLocaleString("default", { month: "long" })} ${y}`; const body = this.querySelector("[data-part=body]"); if (!body) return; const today = new Date().toISOString().slice(0, 10); const firstDay = new Date(y, m, 1).getDay(); const daysInMonth = new Date(y, m + 1, 0).getDate(); body.replaceChildren(); let row = document.createElement("tr"); for (let i = 0; i < firstDay; i++) row.appendChild(document.createElement("td")); for (let d = 1; d <= daysInMonth; d++) { const iso = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; row.appendChild(this.#createDayCell(d, iso, sel, today)); if ((firstDay + d) % 7 === 0) { body.appendChild(row); row = document.createElement("tr"); } } if (row.children.length > 0) body.appendChild(row); } }