scanfile/server/web/templui/components/datepicker/date_picker.templ
2025-06-03 15:44:56 +02:00

319 lines
11 KiB
Text

// templui component datepicker - version: main installed by templui v0.71.0
package datepicker
import (
"git.jmbit.de/jmb/scanfile/server/web/templui/components/button"
"git.jmbit.de/jmb/scanfile/server/web/templui/components/calendar"
"git.jmbit.de/jmb/scanfile/server/web/templui/components/icon"
"git.jmbit.de/jmb/scanfile/server/web/templui/components/popover"
"git.jmbit.de/jmb/scanfile/server/web/templui/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.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.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>
}
}