303 lines
14 KiB
Text
303 lines
14 KiB
Text
// templui component calendar - version: main installed by templui v0.71.0
|
|
package calendar
|
|
|
|
import (
|
|
"git.jmbit.de/jmb/scanfile/server/web/templui/components/icon"
|
|
"git.jmbit.de/jmb/scanfile/server/web/templui/utils"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
type LocaleTag string
|
|
|
|
var (
|
|
LocaleDefaultTag = LocaleTag("en-US")
|
|
LocaleTagChinese = LocaleTag("zh-CN")
|
|
LocaleTagFrench = LocaleTag("fr-FR")
|
|
LocaleTagGerman = LocaleTag("de-DE")
|
|
LocaleTagItalian = LocaleTag("it-IT")
|
|
LocaleTagJapanese = LocaleTag("ja-JP")
|
|
LocaleTagPortuguese = LocaleTag("pt-PT")
|
|
LocaleTagSpanish = LocaleTag("es-ES")
|
|
)
|
|
|
|
type Props struct {
|
|
ID string
|
|
Class string
|
|
LocaleTag LocaleTag
|
|
Value *time.Time
|
|
Name string
|
|
InitialMonth int // Optional: 0-11 (Default: current or from Value). Controls the initially displayed month view.
|
|
InitialYear int // Optional: (Default: current or from Value). Controls the initially displayed year view.
|
|
}
|
|
|
|
templ Calendar(props ...Props) {
|
|
@Script()
|
|
{{
|
|
var p Props
|
|
if len(props) > 0 {
|
|
p = props[0]
|
|
}
|
|
if p.ID == "" {
|
|
p.ID = utils.RandomID() + "-calendar"
|
|
}
|
|
if p.Name == "" {
|
|
// Should be provided by parent (e.g., DatePicker or in standalone usage)
|
|
p.Name = p.ID + "-value" // Fallback name
|
|
}
|
|
if p.LocaleTag == "" {
|
|
p.LocaleTag = LocaleDefaultTag
|
|
}
|
|
|
|
initialView := time.Now()
|
|
if p.Value != nil {
|
|
initialView = *p.Value
|
|
}
|
|
|
|
initialMonth := p.InitialMonth
|
|
initialYear := p.InitialYear
|
|
|
|
// Use year from initialView if InitialYear prop is invalid/unset (<= 0)
|
|
if initialYear <= 0 {
|
|
initialYear = initialView.Year()
|
|
}
|
|
|
|
// Use month from initialView if InitialMonth prop is invalid OR
|
|
// if InitialMonth is default 0 AND InitialYear was also defaulted (meaning neither was likely set explicitly)
|
|
if (initialMonth < 0 || initialMonth > 11) || (initialMonth == 0 && p.InitialYear <= 0) {
|
|
initialMonth = int(initialView.Month()) - 1 // time.Month is 1-12
|
|
}
|
|
|
|
initialSelectedISO := ""
|
|
if p.Value != nil {
|
|
initialSelectedISO = p.Value.Format("2006-01-02")
|
|
}
|
|
}}
|
|
<div class={ p.Class } id={ p.ID + "-wrapper" } data-calendar-wrapper="true">
|
|
<input
|
|
type="hidden"
|
|
name={ p.Name }
|
|
value={ initialSelectedISO }
|
|
id={ p.ID + "-hidden" }
|
|
data-calendar-hidden-input
|
|
/>
|
|
<div
|
|
id={ p.ID }
|
|
data-calendar-container="true"
|
|
data-locale-tag={ string(p.LocaleTag) }
|
|
data-initial-month={ strconv.Itoa(initialMonth) }
|
|
data-initial-year={ strconv.Itoa(initialYear) }
|
|
data-selected-date={ initialSelectedISO }
|
|
>
|
|
<!-- Calendar Header -->
|
|
<div class="flex items-center justify-between mb-4">
|
|
<span data-calendar-month-display class="text-sm font-medium"></span>
|
|
<div class="flex gap-1">
|
|
<button type="button" data-calendar-prev class="inline-flex items-center justify-center rounded-md text-sm font-medium h-7 w-7 hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50">
|
|
@icon.ChevronLeft()
|
|
</button>
|
|
<button type="button" data-calendar-next class="inline-flex items-center justify-center rounded-md text-sm font-medium h-7 w-7 hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50">
|
|
@icon.ChevronRight()
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<!-- Weekday Headers -->
|
|
<div data-calendar-weekdays class="grid grid-cols-7 gap-1 mb-1 place-items-center"></div>
|
|
<!-- Calendar Day Grid -->
|
|
<div data-calendar-days class="grid grid-cols-7 gap-1 place-items-center"></div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
var handle = templ.NewOnceHandle()
|
|
|
|
templ Script() {
|
|
@handle.Once() {
|
|
<script defer nonce={ templ.GetNonce(ctx) }>
|
|
(function() {
|
|
function initCalendar(container) {
|
|
if (!container || container._calendarInitialized) return;
|
|
|
|
const monthDisplay = container.querySelector('[data-calendar-month-display]');
|
|
const weekdaysContainer = container.querySelector('[data-calendar-weekdays]');
|
|
const daysContainer = container.querySelector('[data-calendar-days]');
|
|
const prevButton = container.querySelector('[data-calendar-prev]');
|
|
const nextButton = container.querySelector('[data-calendar-next]');
|
|
const wrapper = container.closest('[data-calendar-wrapper]');
|
|
const hiddenInput = wrapper ? wrapper.querySelector('[data-calendar-hidden-input]') : null;
|
|
|
|
if (!monthDisplay || !weekdaysContainer || !daysContainer || !prevButton || !nextButton || !hiddenInput) {
|
|
console.error('Calendar init error: Missing required elements (or hidden input relative to wrapper).', container);
|
|
return;
|
|
}
|
|
|
|
const localeTag = container.dataset.localeTag || 'en-US';
|
|
let monthNames;
|
|
try {
|
|
monthNames = Array.from({ length: 12 }, (_, i) =>
|
|
new Intl.DateTimeFormat(localeTag, { month: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2000, i, 1)))
|
|
);
|
|
} catch (e) {
|
|
console.error(`Calendar: Error generating month names via Intl (locale: "${localeTag}"). Falling back to English.`, e);
|
|
// Fallback to English names if Intl fails for any reason
|
|
monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
}
|
|
let dayNames = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; // Default fallback
|
|
|
|
try {
|
|
// Use days 0-6 (Sun-Sat standard). Intl provides names in the locale's typical order.
|
|
dayNames = Array.from({ length: 7 }, (_, i) =>
|
|
new Intl.DateTimeFormat(localeTag, { weekday: 'short' }).format(new Date(Date.UTC(2000, 0, i)))
|
|
);
|
|
} catch (e) {
|
|
console.error('Error generating calendar day names via Intl:', e);
|
|
// Keep default dayNames on error
|
|
}
|
|
|
|
let currentMonth = parseInt(container.dataset.initialMonth);
|
|
let currentYear = parseInt(container.dataset.initialYear);
|
|
let selectedDate = null; // Stored as JS Date object (UTC midnight)
|
|
|
|
if (container.dataset.selectedDate) {
|
|
selectedDate = parseISODate(container.dataset.selectedDate);
|
|
}
|
|
|
|
function parseISODate(isoStr) {
|
|
if (!isoStr) return null;
|
|
try {
|
|
const parts = isoStr.split('-');
|
|
const year = parseInt(parts[0], 10);
|
|
const month = parseInt(parts[1], 10) - 1; // JS month is 0-indexed
|
|
const day = parseInt(parts[2], 10);
|
|
const date = new Date(Date.UTC(year, month, day));
|
|
if (!isNaN(date) && date.getUTCFullYear() === year && date.getUTCMonth() === month && date.getUTCDate() === day) {
|
|
return date;
|
|
}
|
|
} catch {}
|
|
return null;
|
|
}
|
|
|
|
function updateMonthDisplay() {
|
|
// Always use the fallback month name combined with the current year
|
|
// Ensure month index is within bounds (0-11)
|
|
const monthIndex = Math.max(0, Math.min(11, currentMonth));
|
|
const monthName = monthNames[monthIndex];
|
|
const displayString = `${monthName} ${currentYear}`;
|
|
monthDisplay.textContent = displayString;
|
|
}
|
|
|
|
function renderWeekdays() {
|
|
weekdaysContainer.innerHTML = '';
|
|
dayNames.forEach(day => {
|
|
const el = document.createElement('div');
|
|
el.className = 'text-center text-xs text-muted-foreground font-medium';
|
|
el.textContent = day;
|
|
weekdaysContainer.appendChild(el);
|
|
});
|
|
}
|
|
|
|
function renderCalendar() {
|
|
daysContainer.innerHTML = '';
|
|
const firstDayOfMonth = new Date(Date.UTC(currentYear, currentMonth, 1));
|
|
const firstDayUTCDay = firstDayOfMonth.getUTCDay(); // 0=Sun
|
|
let startOffset = firstDayUTCDay; // Simple Sunday start offset
|
|
// NOTE: A robust implementation might need to adjust offset based on locale's actual first day of week.
|
|
// Intl doesn't directly provide this easily yet. Keep Sunday start for simplicity.
|
|
|
|
const daysInMonth = new Date(Date.UTC(currentYear, currentMonth + 1, 0)).getUTCDate();
|
|
// Calculate 'today' based on the browser's local date for correct highlighting
|
|
const now = new Date();
|
|
const today = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
|
|
|
for (let i = 0; i < startOffset; i++) {
|
|
const blank = document.createElement('div');
|
|
blank.className = 'h-8 w-8';
|
|
daysContainer.appendChild(blank);
|
|
}
|
|
|
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium focus:outline-none focus:ring-1 focus:ring-ring';
|
|
button.textContent = day;
|
|
button.dataset.day = day;
|
|
const currentDate = new Date(Date.UTC(currentYear, currentMonth, day));
|
|
const isSelected = selectedDate && currentDate.getTime() === selectedDate.getTime();
|
|
const isToday = currentDate.getTime() === today.getTime();
|
|
|
|
if (isSelected) button.classList.add('bg-primary', 'text-primary-foreground', 'hover:bg-primary/90');
|
|
else if (isToday) button.classList.add('bg-accent', 'text-accent-foreground');
|
|
else button.classList.add('hover:bg-accent', 'hover:text-accent-foreground');
|
|
|
|
button.addEventListener('click', handleDayClick);
|
|
daysContainer.appendChild(button);
|
|
}
|
|
}
|
|
|
|
function handlePrevMonthClick() {
|
|
currentMonth--;
|
|
if (currentMonth < 0) { currentMonth = 11; currentYear--; }
|
|
updateMonthDisplay(); renderCalendar();
|
|
}
|
|
|
|
function handleNextMonthClick() {
|
|
currentMonth++;
|
|
if (currentMonth > 11) { currentMonth = 0; currentYear++; }
|
|
updateMonthDisplay(); renderCalendar();
|
|
}
|
|
|
|
function handleDayClick(event) {
|
|
const day = parseInt(event.target.dataset.day);
|
|
if (!day) return;
|
|
const newlySelectedDate = new Date(Date.UTC(currentYear, currentMonth, day));
|
|
|
|
selectedDate = newlySelectedDate;
|
|
|
|
const isoFormattedValue = newlySelectedDate.toISOString().split('T')[0];
|
|
hiddenInput.value = isoFormattedValue;
|
|
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
|
container.dispatchEvent(new CustomEvent('calendar-date-selected', {
|
|
bubbles: true,
|
|
detail: { date: newlySelectedDate }
|
|
}));
|
|
|
|
renderCalendar();
|
|
}
|
|
|
|
// Initialization
|
|
prevButton.addEventListener('click', handlePrevMonthClick);
|
|
nextButton.addEventListener('click', handleNextMonthClick);
|
|
|
|
updateMonthDisplay();
|
|
renderWeekdays();
|
|
renderCalendar();
|
|
|
|
container._calendarInitialized = true;
|
|
}
|
|
|
|
function initAllComponents(root = document) {
|
|
if (root instanceof Element && root.matches('[data-calendar-container]')) {
|
|
initCalendar(root);
|
|
}
|
|
|
|
for (const calendar of root.querySelectorAll('[data-calendar-container]')) {
|
|
initCalendar(calendar);
|
|
}
|
|
}
|
|
|
|
const handleHtmxSwap = (event) => {
|
|
const target = event.detail.target || event.detail.elt;
|
|
if (target instanceof Element) {
|
|
requestAnimationFrame(() => initAllComponents(target));
|
|
}
|
|
};
|
|
|
|
initAllComponents();
|
|
document.addEventListener('DOMContentLoaded', () => initAllComponents());
|
|
document.body.addEventListener('htmx:afterSwap', handleHtmxSwap);
|
|
document.body.addEventListener('htmx:oobAfterSwap', handleHtmxSwap);
|
|
})();
|
|
</script>
|
|
}
|
|
}
|