Date Picker
Calendar interface for selecting and formatting dates. Uses Popover for the popup.
TailwindCSS
Vanilla JS
package showcase
import "github.com/axzilla/templui/internal/components/datepicker"
templ DatePickerDefault() {
<div class="w-full max-w-sm">
@datepicker.DatePicker()
</div>
}
Installation
templui add datepicker
Copy and paste the following code into your project:
package datepicker import ( "github.com/axzilla/templui/internal/components/button" "github.com/axzilla/templui/internal/components/calendar" "github.com/axzilla/templui/internal/components/icon" "github.com/axzilla/templui/internal/components/popover" "github.com/axzilla/templui/internal/utils" "time" ) type Format string type LocaleTag string const ( FormatLOCALE_SHORT Format = "locale-short" // Locale-specific short format (e.g., MM/DD/YY or DD.MM.YY) FormatLOCALE_MEDIUM Format = "locale-medium" // Locale-specific medium format (e.g., Jan 5, 2024 or 5. Jan. 2024) FormatLOCALE_LONG Format = "locale-long" // Locale-specific long format (e.g., January 5, 2024 or 5. Januar 2024) FormatLOCALE_FULL Format = "locale-full" // Locale-specific full format (e.g., Monday, January 5, 2024 or Montag, 5. Januar 2024) ) // Common Locale (BCP 47) 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 Attributes templ.Attributes Value time.Time Format Format // Controls the display format using Intl dateStyle options. LocaleTag LocaleTag // BCP 47 Locale Tag (e.g., "en-US", "es-ES"). Determines language and regional format defaults. Placeholder string Disabled bool Required bool HasError bool Name string } templ DatePicker(props ...Props) { @Script() {{ var p Props if len(props) > 0 { p = props[0] } if p.ID == "" { p.ID = utils.RandomID() } if p.Name == "" { p.Name = p.ID } if p.Placeholder == "" { p.Placeholder = "Select a date" } if p.LocaleTag == "" { p.LocaleTag = LocaleDefaultTag } if p.Format == "" { p.Format = FormatLOCALE_MEDIUM } var contentID = p.ID + "-content" var valuePtr *time.Time if !p.Value.IsZero() { valuePtr = &p.Value } }} @popover.Popover() { @popover.Trigger(popover.TriggerProps{For: contentID}) { @button.Button(button.Props{ ID: p.ID, Variant: button.VariantOutline, Class: utils.TwMerge( "w-full select-trigger flex items-center justify-between", utils.If(p.HasError, "border-destructive ring-destructive"), p.Class, ), Disabled: p.Disabled, Attributes: utils.MergeAttributes(p.Attributes, templ.Attributes{ "data-datepicker": "true", "data-display-format": string(p.Format), "data-locale-tag": string(p.LocaleTag), "data-placeholder": p.Placeholder, }), }) { if p.Placeholder != "" { <span data-datepicker-display class={ "text-left grow text-muted-foreground" }> { p.Placeholder } </span> } <span class="text-muted-foreground flex items-center ml-2"> @icon.Calendar(icon.Props{Size: 16}) </span> } } @popover.Content(popover.ContentProps{ ID: contentID, Placement: popover.PlacementBottomStart, Class: "p-3", }) { @calendar.Calendar(calendar.Props{ ID: p.ID + "-calendar-instance", // Pass ID for calendar instance Name: p.Name, // Pass Name for hidden input LocaleTag: calendar.LocaleTag(p.LocaleTag), // Pass locale tag to calendar Value: valuePtr, // Pass pointer to value }) } } } var handle = templ.NewOnceHandle() templ Script() { @handle.Once() { <script defer nonce={ templ.GetNonce(ctx) }> (function() { // IIFE Start function parseISODate(isoString) { if (!isoString || typeof isoString !== 'string') return null; const parts = isoString.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!parts) return null; const year = parseInt(parts[1], 10); const month = parseInt(parts[2], 10) - 1; // JS month is 0-indexed const day = parseInt(parts[3], 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 formatDateWithIntl(date, format, localeTag) { if (!date || isNaN(date.getTime())) return ''; // Always use UTC for formatting to avoid timezone shifts let options = { timeZone: 'UTC' }; switch(format) { case 'locale-short': options.dateStyle = 'short'; break; case 'locale-long': options.dateStyle = 'long'; break; case 'locale-full': options.dateStyle = 'full'; break; case 'locale-medium': // Default to medium default: options.dateStyle = 'medium'; break; } try { // Explicitly pass the options object with timeZone: 'UTC' return new Intl.DateTimeFormat(localeTag, options).format(date); } catch (e) { console.error(`Error formatting date with Intl (locale: ${localeTag}, format: ${format}, timezone: UTC):`, e); // Fallback to locale default medium on error, still using UTC try { const fallbackOptions = { dateStyle: 'medium', timeZone: 'UTC' }; return new Intl.DateTimeFormat(localeTag, fallbackOptions).format(date); } catch (fallbackError) { console.error(`Error formatting date with fallback Intl (locale: ${localeTag}, timezone: UTC):`, fallbackError); // Absolute fallback: Format the UTC date parts manually if Intl fails completely const year = date.getUTCFullYear(); // getUTCMonth is 0-indexed, add 1 for display const month = (date.getUTCMonth() + 1).toString().padStart(2, '0'); const day = date.getUTCDate().toString().padStart(2, '0'); return `${year}-${month}-${day}`; // Simple ISO format as absolute fallback } } } function initDatePicker(triggerButton) { if (!triggerButton || triggerButton._datePickerInitialized) return; const datePickerID = triggerButton.id; const displaySpan = triggerButton.querySelector('[data-datepicker-display]'); const calendarInstanceId = datePickerID + '-calendar-instance'; const calendarInstance = document.getElementById(calendarInstanceId); const calendarHiddenInputId = calendarInstanceId + '-hidden'; const calendarHiddenInput = document.getElementById(calendarHiddenInputId); // Fallback to find calendar relatively let calendar = calendarInstance; let hiddenInput = calendarHiddenInput; if (!calendarInstance || !calendarHiddenInput) { const popoverContentId = triggerButton.getAttribute('aria-controls'); const popoverContent = popoverContentId ? document.getElementById(popoverContentId) : null; if (popoverContent) { if (!calendar) calendar = popoverContent.querySelector('[data-calendar-container]'); if (!hiddenInput) { const wrapper = popoverContent.querySelector('[data-calendar-wrapper]'); hiddenInput = wrapper ? wrapper.querySelector('[data-calendar-hidden-input]') : null; } } } if (!displaySpan || !calendar || !hiddenInput) { console.error("DatePicker init error: Missing required elements.", { datePickerID, displaySpan, calendar, hiddenInput }); return; } const displayFormat = triggerButton.dataset.displayFormat || 'locale-medium'; const localeTag = triggerButton.dataset.localeTag || 'en-US'; const placeholder = triggerButton.dataset.placeholder || 'Select a date'; const onCalendarSelect = (event) => { if (!event.detail || !event.detail.date || !(event.detail.date instanceof Date)) return; const selectedDate = event.detail.date; const displayFormattedValue = formatDateWithIntl(selectedDate, displayFormat, localeTag); displaySpan.textContent = displayFormattedValue; displaySpan.classList.remove('text-muted-foreground'); // Find and click the popover trigger to close it const popoverTrigger = triggerButton.closest('[data-popover]')?.querySelector('[data-popover-trigger]'); if (popoverTrigger instanceof HTMLElement) { popoverTrigger.click(); } else { triggerButton.click(); // Fallback: click the button itself (might not work if inside popover) } }; const updateDisplay = () => { if (hiddenInput && hiddenInput.value) { const initialDate = parseISODate(hiddenInput.value); if (initialDate) { const correctlyFormatted = formatDateWithIntl(initialDate, displayFormat, localeTag); if (displaySpan.textContent.trim() !== correctlyFormatted) { displaySpan.textContent = correctlyFormatted; displaySpan.classList.remove('text-muted-foreground'); } } else { // Handle case where hidden input has invalid value displaySpan.textContent = placeholder; displaySpan.classList.add('text-muted-foreground'); } } else { // Ensure placeholder is shown if no value displaySpan.textContent = placeholder; displaySpan.classList.add('text-muted-foreground'); } }; // Attach listener to the specific calendar instance calendar.addEventListener('calendar-date-selected', onCalendarSelect); updateDisplay(); // Initial display update triggerButton._datePickerInitialized = true; // Store cleanup function on the button itself triggerButton._datePickerCleanup = () => { if (calendar) { calendar.removeEventListener('calendar-date-selected', onCalendarSelect); } }; } function initAllComponents(root = document) { if (root instanceof Element && root.matches('[data-datepicker="true"]')) { initDatePicker(root); } root.querySelectorAll('[data-datepicker="true"]').forEach(triggerButton => { initDatePicker(triggerButton); }); } const handleHtmxSwap = (event) => { const target = event.detail.elt if (target instanceof Element) { requestAnimationFrame(() => initAllComponents(target)); } }; initAllComponents(); document.addEventListener('DOMContentLoaded', () => initAllComponents()); document.body.addEventListener('htmx:beforeSwap', (event) => { let target = event.detail.elt; if (target instanceof Element) { const cleanup = (button) => { if (button.matches && button.matches('[data-datepicker="true"]')) { if (button._datePickerCleanup) { button._datePickerCleanup(); delete button._datePickerCleanup; delete button._datePickerInitialized; } } }; // Cleanup the target itself if it's a trigger button if (target.matches && target.matches('[data-datepicker="true"]')) { cleanup(target); } // Cleanup trigger buttons within the target if (target.querySelectorAll) { target.querySelectorAll('[data-datepicker="true"]').forEach(cleanup); } } }); document.body.addEventListener('htmx:afterSwap', handleHtmxSwap); document.body.addEventListener('htmx:oobAfterSwap', handleHtmxSwap); })(); // End of IIFE </script> } }
Update the import paths to match your project setup.
Examples
With Label
package showcase
import "github.com/axzilla/templui/internal/components/datepicker"
templ DatePickerWithLabel() {
<div class="w-full max-w-sm space-y-2">
// @label.Label(label.Props{
// For: "date-picker-with-label",
// }) {
// Pick a date
// }
@datepicker.DatePicker(datepicker.Props{
ID: "xxxx",
})
</div>
}
Custom Placeholder
package showcase
import "github.com/axzilla/templui/internal/components/datepicker"
templ DatePickerCustomPlaceholder() {
<div class="w-full max-w-sm">
@datepicker.DatePicker(datepicker.Props{
Placeholder: "When is your birthday?",
})
</div>
}
Selected Date
package showcase
import (
"github.com/axzilla/templui/internal/components/datepicker"
"time"
)
templ DatePickerSelectedDate() {
<div class="w-full max-w-sm">
@datepicker.DatePicker(datepicker.Props{
Value: time.Now(),
})
</div>
}
Disabled
package showcase
import "github.com/axzilla/templui/internal/components/datepicker"
templ DatePickerDisabled() {
<div class="w-full max-w-sm">
@datepicker.DatePicker(datepicker.Props{
Disabled: true,
})
</div>
}
Formats
package showcase
import (
"github.com/axzilla/templui/internal/components/datepicker"
"github.com/axzilla/templui/internal/components/label"
"time"
)
templ DatePickerFormats() {
<div class="w-full max-w-sm flex flex-col gap-4">
<div class="space-y-2">
@label.Label(label.Props{
For: "dp-default",
}) {
Default (Medium, en-US)
}
@datepicker.DatePicker(datepicker.Props{
ID: "dp-default",
Value: time.Now(),
})
</div>
<div class="space-y-2">
@label.Label(label.Props{
For: "dp-de-short",
}) {
Short Format (German)
}
@datepicker.DatePicker(datepicker.Props{
ID: "dp-de-short",
LocaleTag: datepicker.LocaleTagGerman,
Format: datepicker.FormatLOCALE_SHORT,
Value: time.Now(),
})
</div>
<div class="space-y-2">
@label.Label(label.Props{
For: "dp-es-long",
}) {
Long Format (Spanish)
}
@datepicker.DatePicker(datepicker.Props{
ID: "dp-es-long",
LocaleTag: datepicker.LocaleTagSpanish,
Format: datepicker.FormatLOCALE_LONG,
Value: time.Now().AddDate(0, 0, -30), // 30 days ago
Placeholder: "Seleccionar fecha",
})
</div>
<div class="space-y-2">
@label.Label(label.Props{
For: "dp-fr-full",
}) {
Full Format (French)
}
@datepicker.DatePicker(datepicker.Props{
ID: "dp-fr-full",
LocaleTag: datepicker.LocaleTagFrench,
Format: datepicker.FormatLOCALE_FULL,
Value: time.Now(),
})
</div>
<div class="space-y-2">
@label.Label(label.Props{
For: "dp-ja-medium",
}) {
Medium Format (Japanese)
}
@datepicker.DatePicker(datepicker.Props{
ID: "dp-ja-medium",
LocaleTag: datepicker.LocaleTagJapanese,
Format: datepicker.FormatLOCALE_MEDIUM,
Value: time.Now(),
})
</div>
<div class="space-y-2">
@label.Label(label.Props{
For: "dp-ar-short",
}) {
Short Format (Arabic - ar-SA)
}
@datepicker.DatePicker(datepicker.Props{
ID: "dp-ar-short",
LocaleTag: "ar-SA", // Using string literal for locale
Format: datepicker.FormatLOCALE_SHORT,
Value: time.Now(),
})
</div>
</div>
}
Form
Select a date from the calendar.
Select a valid date
package showcase
import (
"github.com/axzilla/templui/internal/components/datepicker"
"github.com/axzilla/templui/internal/components/form"
)
templ DatePickerForm() {
<div class="w-full max-w-sm">
@form.Item() {
@form.Label(form.LabelProps{
For: "date-picker-form",
}) {
Select a date
}
@datepicker.DatePicker(datepicker.Props{
ID: "date-picker-form",
HasError: true,
})
@form.Description() {
Select a date from the calendar.
}
@form.Message(form.MessageProps{
Variant: form.MessageVariantError,
}) {
Select a valid date
}
}
</div>
}