budgit/assets/js/calendar.js
2026-04-12 16:07:06 +00:00

414 lines
13 KiB
JavaScript

(function () {
"use strict";
// Utility functions
function parseISODate(isoStr) {
if (!isoStr) return null;
const parts = isoStr.split("-");
if (parts.length !== 3) return null;
const year = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10) - 1;
const day = parseInt(parts[2], 10);
const date = new Date(Date.UTC(year, month, day));
if (
date.getUTCFullYear() === year &&
date.getUTCMonth() === month &&
date.getUTCDate() === day
) {
return date;
}
return null;
}
function getMonthNames(locale) {
try {
return Array.from({ length: 12 }, (_, i) =>
new Intl.DateTimeFormat(locale, {
month: "short",
timeZone: "UTC",
}).format(new Date(Date.UTC(2000, i, 1))),
);
} catch {
return [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
}
}
function getDayNames(locale, startOfWeek) {
try {
return Array.from({ length: 7 }, (_, i) =>
new Intl.DateTimeFormat(locale, {
weekday: "short",
timeZone: "UTC",
}).format(new Date(Date.UTC(2000, 0, i + 2 + startOfWeek))),
);
} catch {
return ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
}
}
function findHiddenInput(container) {
const wrapper = container.closest("[data-tui-calendar-wrapper]");
return wrapper?.querySelector("[data-tui-calendar-hidden-input]") || null;
}
function renderCalendar(container) {
const weekdaysContainer = container.querySelector(
"[data-tui-calendar-weekdays]",
);
const daysContainer = container.querySelector("[data-tui-calendar-days]");
if (!weekdaysContainer || !daysContainer) return;
// Get current viewing month/year (or use initial/defaults)
let currentMonth = parseInt(container.dataset.tuiCalendarCurrentMonth);
let currentYear = parseInt(container.dataset.tuiCalendarCurrentYear);
// If not set, use initial values or current date
if (isNaN(currentMonth) || isNaN(currentYear)) {
const initialMonth = parseInt(
container.getAttribute("data-tui-calendar-initial-month"),
);
const initialYear = parseInt(
container.getAttribute("data-tui-calendar-initial-year"),
);
const selectedDate = container.getAttribute(
"data-tui-calendar-selected-date",
);
if (selectedDate) {
const parsed = parseISODate(selectedDate);
if (parsed) {
currentMonth = parsed.getUTCMonth();
currentYear = parsed.getUTCFullYear();
}
}
if (isNaN(currentMonth)) {
currentMonth = !isNaN(initialMonth)
? initialMonth
: new Date().getMonth();
}
if (isNaN(currentYear)) {
currentYear =
!isNaN(initialYear) && initialYear > 0
? initialYear
: new Date().getFullYear();
}
// Store for navigation
container.dataset.tuiCalendarCurrentMonth = currentMonth;
container.dataset.tuiCalendarCurrentYear = currentYear;
}
// Get other settings
const locale =
container.getAttribute("data-tui-calendar-locale-tag") || "en-US";
let startOfWeek = parseInt(
container.getAttribute("data-tui-calendar-start-of-week"),
10,
);
if (isNaN(startOfWeek)) startOfWeek = 1;
const selectedDateStr = container.getAttribute(
"data-tui-calendar-selected-date",
);
const selectedDate = selectedDateStr ? parseISODate(selectedDateStr) : null;
// Update SelectBox values
const monthNames = getMonthNames(locale);
const monthValue = container.querySelector(`#${container.id}-month-value`);
const yearValue = container.querySelector(`#${container.id}-year-value`);
if (monthValue) monthValue.textContent = monthNames[currentMonth];
if (yearValue) yearValue.textContent = currentYear;
// Render weekdays if empty
if (!weekdaysContainer.children.length) {
const dayNames = getDayNames(locale, startOfWeek);
weekdaysContainer.innerHTML = dayNames
.map(
(day) =>
`<div class="text-center text-xs text-muted-foreground font-medium">${day}</div>`,
)
.join("");
}
// Render days
daysContainer.innerHTML = "";
const firstDay = new Date(Date.UTC(currentYear, currentMonth, 1));
const startOffset = (((firstDay.getUTCDay() - startOfWeek) % 7) + 7) % 7;
const daysInMonth = new Date(
Date.UTC(currentYear, currentMonth + 1, 0),
).getUTCDate();
const today = new Date();
const todayUTC = new Date(
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
);
// Add empty cells for offset
for (let i = 0; i < startOffset; i++) {
daysContainer.innerHTML +=
'<div class="h-[var(--cell-size)] w-[var(--cell-size)]"></div>';
}
// Add day buttons
for (let day = 1; day <= daysInMonth; day++) {
const currentDate = new Date(Date.UTC(currentYear, currentMonth, day));
const isSelected =
selectedDate && currentDate.getTime() === selectedDate.getTime();
const isToday = currentDate.getTime() === todayUTC.getTime();
let classes =
"inline-flex h-[var(--cell-size)] w-[var(--cell-size)] items-center justify-center rounded-md text-sm font-medium focus:outline-none focus:ring-1 focus:ring-ring";
if (isSelected) {
classes += " bg-primary text-primary-foreground hover:bg-primary/90";
} else if (isToday) {
classes += " bg-accent text-accent-foreground";
} else {
classes += " hover:bg-accent hover:text-accent-foreground";
}
const attrs = [
`data-tui-calendar-day="${day}"`,
isToday ? 'data-tui-calendar-today="true"' : "",
isSelected ? 'data-tui-calendar-selected="true"' : "",
]
.filter(Boolean)
.join(" ");
daysContainer.innerHTML += `<button type="button" class="${classes}" ${attrs}>${day}</button>`;
}
}
// Handle month/year selection from native selects
document.addEventListener("change", (e) => {
// Month select
if (e.target.matches("[data-tui-calendar-month-select]")) {
const container = e.target.closest("[data-tui-calendar-container]");
if (!container) return;
const newMonth = parseInt(e.target.value, 10);
if (isNaN(newMonth)) return;
container.dataset.tuiCalendarCurrentMonth = newMonth;
renderCalendar(container);
return;
}
// Year select
if (e.target.matches("[data-tui-calendar-year-select]")) {
const container = e.target.closest("[data-tui-calendar-container]");
if (!container) return;
const newYear = parseInt(e.target.value, 10);
if (isNaN(newYear)) return;
container.dataset.tuiCalendarCurrentYear = newYear;
renderCalendar(container);
return;
}
});
// Event delegation for calendar navigation and selection
document.addEventListener("click", (e) => {
// Previous month
const prevBtn = e.target.closest("[data-tui-calendar-prev]");
if (prevBtn) {
const container = prevBtn.closest("[data-tui-calendar-container]");
if (!container) return;
let month = parseInt(container.dataset.tuiCalendarCurrentMonth, 10);
let year = parseInt(container.dataset.tuiCalendarCurrentYear, 10);
// Only use fallback if truly not initialized (should not happen after init)
if (isNaN(month)) month = new Date().getMonth();
if (isNaN(year)) year = new Date().getFullYear();
month--;
if (month < 0) {
month = 11;
year--;
}
container.dataset.tuiCalendarCurrentMonth = month;
container.dataset.tuiCalendarCurrentYear = year;
renderCalendar(container);
updateNativeSelects(container);
return;
}
// Next month
const nextBtn = e.target.closest("[data-tui-calendar-next]");
if (nextBtn) {
const container = nextBtn.closest("[data-tui-calendar-container]");
if (!container) return;
let month = parseInt(container.dataset.tuiCalendarCurrentMonth, 10);
let year = parseInt(container.dataset.tuiCalendarCurrentYear, 10);
// Only use fallback if truly not initialized (should not happen after init)
if (isNaN(month)) month = new Date().getMonth();
if (isNaN(year)) year = new Date().getFullYear();
month++;
if (month > 11) {
month = 0;
year++;
}
container.dataset.tuiCalendarCurrentMonth = month;
container.dataset.tuiCalendarCurrentYear = year;
renderCalendar(container);
updateNativeSelects(container);
return;
}
// Day selection
if (e.target.matches("[data-tui-calendar-day]")) {
const container = e.target.closest("[data-tui-calendar-container]");
if (!container) return;
const day = parseInt(e.target.dataset.tuiCalendarDay);
let month = parseInt(container.dataset.tuiCalendarCurrentMonth, 10);
let year = parseInt(container.dataset.tuiCalendarCurrentYear, 10);
// Only use fallback if truly not initialized (should not happen after init)
if (isNaN(month)) month = new Date().getMonth();
if (isNaN(year)) year = new Date().getFullYear();
const selectedDate = new Date(Date.UTC(year, month, day));
// Update selected date attribute
container.setAttribute(
"data-tui-calendar-selected-date",
selectedDate.toISOString().split("T")[0],
);
// Update hidden input
const hiddenInput = findHiddenInput(container);
if (hiddenInput) {
hiddenInput.value = selectedDate.toISOString().split("T")[0];
}
// Dispatch custom event
container.dispatchEvent(
new CustomEvent("calendar-date-selected", {
bubbles: true,
detail: { date: selectedDate },
}),
);
renderCalendar(container);
}
});
// Update native selects when month/year changes via arrows
function updateNativeSelects(container) {
const month = parseInt(container.dataset.tuiCalendarCurrentMonth, 10);
const year = parseInt(container.dataset.tuiCalendarCurrentYear, 10);
if (isNaN(month) || isNaN(year)) return;
// Update month select
const monthSelect = container.querySelector(
"[data-tui-calendar-month-select]",
);
if (monthSelect) {
monthSelect.value = month.toString();
}
// Update year select
const yearSelect = container.querySelector(
"[data-tui-calendar-year-select]",
);
if (yearSelect) {
yearSelect.value = year.toString();
}
}
// Form reset handling
document.addEventListener("reset", (e) => {
if (!e.target.matches("form")) return;
e.target
.querySelectorAll("[data-tui-calendar-container]")
.forEach((container) => {
const hiddenInput = findHiddenInput(container);
if (hiddenInput) {
hiddenInput.value = "";
}
// Clear selected date and reset to current month
container.removeAttribute("data-tui-calendar-selected-date");
const today = new Date();
container.dataset.tuiCalendarCurrentMonth = today.getMonth();
container.dataset.tuiCalendarCurrentYear = today.getFullYear();
renderCalendar(container);
});
});
// MutationObserver for dynamic content (framework-agnostic)
const observer = new MutationObserver(() => {
document
.querySelectorAll("[data-tui-calendar-container]")
.forEach((container) => {
const daysContainer = container.querySelector(
"[data-tui-calendar-days]",
);
// Only render if not already rendered
if (daysContainer && !daysContainer.children.length) {
renderCalendar(container);
}
});
});
observer.observe(document.body, { childList: true, subtree: true });
// Initialize calendars on page load
function initCalendars() {
document
.querySelectorAll("[data-tui-calendar-container]")
.forEach((container) => {
// Localize month names in native select options
const locale =
container.getAttribute("data-tui-calendar-locale-tag") || "en-US";
const monthNames = getMonthNames(locale);
const monthSelect = container.querySelector(
"[data-tui-calendar-month-select]",
);
if (monthSelect) {
const options = monthSelect.querySelectorAll("option");
options.forEach((option, index) => {
if (monthNames[index]) {
option.textContent = monthNames[index];
}
});
}
renderCalendar(container);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initCalendars);
} else {
initCalendars();
}
})();