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
|
Placeholder string
|
||||||
Disabled bool
|
Disabled bool
|
||||||
HasError bool
|
HasError bool
|
||||||
|
Required bool
|
||||||
|
Clearable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
templ DatePicker(props ...Props) {
|
templ DatePicker(props ...Props) {
|
||||||
|
|
@ -78,6 +80,11 @@ templ DatePicker(props ...Props) {
|
||||||
valuePtr = &p.Value
|
valuePtr = &p.Value
|
||||||
initialSelectedISO = p.Value.Format("2006-01-02")
|
initialSelectedISO = p.Value.Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var required = "false"
|
||||||
|
if p.Required {
|
||||||
|
required = "true"
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
<div class="relative inline-block w-full">
|
<div class="relative inline-block w-full">
|
||||||
<input
|
<input
|
||||||
|
|
@ -116,6 +123,7 @@ templ DatePicker(props ...Props) {
|
||||||
"data-tui-datepicker-display-format": string(p.Format),
|
"data-tui-datepicker-display-format": string(p.Format),
|
||||||
"data-tui-datepicker-locale-tag": string(p.LocaleTag),
|
"data-tui-datepicker-locale-tag": string(p.LocaleTag),
|
||||||
"data-tui-datepicker-placeholder": p.Placeholder,
|
"data-tui-datepicker-placeholder": p.Placeholder,
|
||||||
|
"data-tui-datepicker-required": required,
|
||||||
"aria-invalid": utils.If(p.HasError, "true"),
|
"aria-invalid": utils.If(p.HasError, "true"),
|
||||||
}),
|
}),
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -147,6 +155,18 @@ templ DatePicker(props ...Props) {
|
||||||
Value: valuePtr, // Pass pointer to value
|
Value: valuePtr, // Pass pointer to value
|
||||||
RenderHiddenInput: false, // Don't render hidden input inside popover
|
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() {
|
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