feat: datepicker clear selected date

This commit is contained in:
juancwu 2026-02-24 15:03:24 +00:00
commit b33a9bc670
2 changed files with 270 additions and 1 deletions

249
assets/js/datepicker.js Normal file
View file

@ -0,0 +1,249 @@
(function () {
"use strict";
/**
* Reactive Binding for hidden inputs
*
* Problem: Setting input.value programmatically (e.g., via Datastar/Alpine)
* does NOT fire 'input' events - this is standard browser behavior since the 90s.
*
* Solution: Override the value setter to dispatch 'input' events on change.
* This is the same pattern used by Vue.js, MobX, and other reactive frameworks.
*/
function enableReactiveBinding(input) {
if (input._tui) return;
input._tui = true;
const desc = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
);
if (!desc?.set) return;
Object.defineProperty(input, "value", {
get: desc.get,
set(v) {
const old = this.value;
desc.set.call(this, v);
if (old !== v) {
this.dispatchEvent(new Event("input", { bubbles: true }));
}
},
configurable: true,
});
}
function todayISO() {
var d = new Date();
return (
d.getFullYear() +
"-" +
String(d.getMonth() + 1).padStart(2, "0") +
"-" +
String(d.getDate()).padStart(2, "0")
);
}
function setDefaultToday(input) {
input.value = todayISO();
const form = input.closest("form");
if (form) {
form.addEventListener("reset", function () {
setTimeout(() => {
input.value = todayISO();
}, 0);
});
}
}
// Utility functions
function parseISODate(isoString) {
if (!isoString) 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;
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 formatDate(date, format, locale) {
if (!date || isNaN(date.getTime())) return "";
const options = { timeZone: "UTC" };
const formatMap = {
"locale-short": "short",
"locale-long": "long",
"locale-full": "full",
"locale-medium": "medium",
};
options.dateStyle = formatMap[format] || "medium";
try {
return new Intl.DateTimeFormat(locale, options).format(date);
} catch (e) {
// Fallback to ISO format
const year = date.getUTCFullYear();
const month = (date.getUTCMonth() + 1).toString().padStart(2, "0");
const day = date.getUTCDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
}
}
// Find elements
function findElements(trigger) {
const calendarId = trigger.id + "-calendar-instance";
const calendar = document.getElementById(calendarId);
const hiddenInput =
document.getElementById(trigger.id + "-hidden") ||
trigger.parentElement?.querySelector(
"[data-tui-datepicker-hidden-input]",
);
const display = trigger.querySelector("[data-tui-datepicker-display]");
return { calendar, hiddenInput, display };
}
// Update display
function updateDisplay(trigger) {
const elements = findElements(trigger);
if (!elements.display || !elements.hiddenInput) return;
const format =
trigger.getAttribute("data-tui-datepicker-display-format") ||
"locale-medium";
const locale =
trigger.getAttribute("data-tui-datepicker-locale-tag") || "en-US";
const placeholder =
trigger.getAttribute("data-tui-datepicker-placeholder") ||
"Select a date";
if (elements.hiddenInput.value) {
const date = parseISODate(elements.hiddenInput.value);
if (date) {
elements.display.textContent = formatDate(date, format, locale);
elements.display.classList.remove("text-muted-foreground");
return;
}
}
elements.display.textContent = placeholder;
elements.display.classList.add("text-muted-foreground");
}
// Handle calendar date selection
document.addEventListener("calendar-date-selected", (e) => {
// Find the datepicker trigger associated with this calendar
const calendar = e.target;
if (!calendar || !calendar.id.endsWith("-calendar-instance")) return;
const triggerId = calendar.id.replace("-calendar-instance", "");
const trigger = document.getElementById(triggerId);
if (!trigger || !trigger.hasAttribute("data-tui-datepicker")) return;
const elements = findElements(trigger);
if (!elements.display || !e.detail?.date) return;
const format =
trigger.getAttribute("data-tui-datepicker-display-format") ||
"locale-medium";
const locale =
trigger.getAttribute("data-tui-datepicker-locale-tag") || "en-US";
elements.display.textContent = formatDate(e.detail.date, format, locale);
elements.display.classList.remove("text-muted-foreground");
// Close the popover
if (window.closePopover) {
const popoverId =
trigger.getAttribute("aria-controls") || trigger.id + "-content";
window.closePopover(popoverId);
}
});
// Handle hidden input value changes (for reactive frameworks)
document.addEventListener("input", (e) => {
if (!e.target.matches("[data-tui-datepicker-hidden-input]")) return;
const trigger = document.getElementById(e.target.id.replace("-hidden", ""));
if (trigger) {
updateDisplay(trigger);
}
});
// Form reset handling
document.addEventListener("reset", (e) => {
if (!e.target.matches("form")) return;
e.target
.querySelectorAll('[data-tui-datepicker="true"]')
.forEach((trigger) => {
const elements = findElements(trigger);
if (elements.hiddenInput) {
elements.hiddenInput.value = "";
}
updateDisplay(trigger);
});
});
// Clear button handling
document.addEventListener("click", (e) => {
const trigger = e.target.closest('[data-tui-datepicker-clear="true"]');
if (!trigger) return;
const triggerID = trigger.id;
const baseID = triggerID.replace(new RegExp("-clear-button" + "$"), "");
const datepicker = document.getElementById(baseID);
if (!datepicker) return;
const elements = findElements(datepicker);
if (elements.hiddenInput) {
elements.hiddenInput.value = "";
}
updateDisplay(datepicker);
});
// Initialize datepickers
function initializeDatePickers() {
document
.querySelectorAll('[data-tui-datepicker="true"]')
.forEach((trigger) => {
const elements = findElements(trigger);
if (!elements.hiddenInput || elements.hiddenInput._tui) return;
// Enable reactive binding for hidden input
enableReactiveBinding(elements.hiddenInput);
// If required, set default date to today
if (trigger.dataset.tuiDatepickerRequired === "true") {
setDefaultToday(elements.hiddenInput);
}
updateDisplay(trigger);
});
}
// Initialize on DOM ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeDatePickers);
} else {
initializeDatePickers();
}
// MutationObserver for dynamically added elements
new MutationObserver(initializeDatePickers).observe(document.body, {
childList: true,
subtree: true,
});
})();

View file

@ -47,6 +47,8 @@ type Props struct {
Placeholder string
Disabled bool
HasError bool
Required bool
Clearable bool
}
templ DatePicker(props ...Props) {
@ -78,6 +80,11 @@ templ DatePicker(props ...Props) {
valuePtr = &p.Value
initialSelectedISO = p.Value.Format("2006-01-02")
}
var required = "false"
if p.Required {
required = "true"
}
}}
<div class="relative inline-block w-full">
<input
@ -116,6 +123,7 @@ templ DatePicker(props ...Props) {
"data-tui-datepicker-display-format": string(p.Format),
"data-tui-datepicker-locale-tag": string(p.LocaleTag),
"data-tui-datepicker-placeholder": p.Placeholder,
"data-tui-datepicker-required": required,
"aria-invalid": utils.If(p.HasError, "true"),
}),
}) {
@ -147,6 +155,18 @@ templ DatePicker(props ...Props) {
Value: valuePtr, // Pass pointer to value
RenderHiddenInput: false, // Don't render hidden input inside popover
})
if p.Clearable {
@button.Button(button.Props{
ID: p.ID + "-clear-button",
Class: "mt-4 w-full",
Variant: button.VariantOutline,
Attributes: templ.Attributes{
"data-tui-datepicker-clear": "true",
},
}) {
Clear
}
}
}
}
}
@ -154,5 +174,5 @@ templ DatePicker(props ...Props) {
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ utils.ScriptURL("/assets/js/datepicker.min.js") }></script>
<script defer nonce={ templ.GetNonce(ctx) } src={ utils.ScriptURL("/assets/js/datepicker.js") }></script>
}