Calendar
A date field component that allows users to enter and edit date.
TailwindCSS
Vanilla JS
package showcase
import (
"github.com/axzilla/templui/component/calendar"
"github.com/axzilla/templui/component/card"
)
templ CalendarDefault() {
<div class="w-full max-w-sm">
@card.Card() {
@card.Content() {
@calendar.Calendar()
}
}
</div>
}
package calendar
import (
"github.com/axzilla/templui/icon"
"github.com/axzilla/templui/util"
"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) {
{{
var p Props
if len(props) > 0 {
p = props[0]
}
if p.ID == "" {
p.ID = util.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")
}
}}
@Script()
<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 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 monthNamesFallback = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; // Fallback if Intl fails
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() {
try {
const dateForDisplay = new Date(Date.UTC(currentYear, currentMonth));
monthDisplay.textContent = new Intl.DateTimeFormat(localeTag, {
month: 'long', year: 'numeric'
}).format(dateForDisplay);
} catch (e) {
console.error("Error formatting month display with Intl:", e);
monthDisplay.textContent = `${monthNamesFallback[currentMonth]} ${currentYear}`; // Use fallback month name if Intl fails
}
}
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();
const today = new Date(); today.setUTCHours(0,0,0,0);
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;
}
// Global Init & HTMX Hook
function initCalendarsInScope(scopeElement) {
scopeElement.querySelectorAll('[data-calendar-container]').forEach(initCalendar);
}
document.addEventListener('DOMContentLoaded', () => {
initCalendarsInScope(document.body);
});
document.body.addEventListener('htmx:afterSwap', (event) => {
const target = event.detail.target || event.target;
if (target instanceof Element) {
if (target.matches('[data-calendar-container]')) {
initCalendar(target);
}
initCalendarsInScope(target);
}
});
</script>
}
}