feat: datepicker clear selected date
This commit is contained in:
parent
7a04d9dcbc
commit
b33a9bc670
2 changed files with 270 additions and 1 deletions
249
assets/js/datepicker.js
Normal file
249
assets/js/datepicker.js
Normal 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,
|
||||
});
|
||||
})();
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue