assets_js_controllers_timezone-input-controller.js
import { Controller } from "@hotwired/stimulus";
/**
* Timezone Input Controller
*
* Automatically converts datetime-local inputs between user's local timezone
* and UTC storage. Converts UTC values to local time on page load, and converts
* back to UTC before form submission.
*
* See /docs/10.3.2-timezone-input-controller.md for complete documentation.
*
* @example
* <form data-controller="timezone-input">
* <input type="datetime-local"
* name="start_date"
* data-timezone-input-target="datetimeInput"
* data-utc-value="2025-03-15T14:30:00Z">
* </form>
*/
class TimezoneInputController extends Controller {
static targets = ["datetimeInput", "notice"]
static values = {
timezone: String,
showNotice: { type: Boolean, default: true }
}
/**
* Initialize controller - detect timezone and convert UTC to local time
*/
connect() {
// Get or detect timezone
this.timezone = this.hasTimezoneValue ?
this.timezoneValue :
KMP_Timezone.detectTimezone();
// Convert all UTC values to local time for display
this.convertUtcToLocal();
// Show timezone notice if requested
if (this.showNoticeValue && this.hasNoticeTarget) {
this.updateNotice();
}
// Cache bound event handlers for proper cleanup
this._handleSubmit = this.handleSubmit.bind(this);
this._handleReset = this.handleReset.bind(this);
// Attach submit handler
this.element.addEventListener('submit', this._handleSubmit);
// Attach reset handler
this.element.addEventListener('reset', this._handleReset);
}
/**
* Convert UTC values to local timezone for input display
* Stores original and local values in data attributes for reset
*/
convertUtcToLocal() {
this.datetimeInputTargets.forEach(input => {
const utcValue = input.dataset.utcValue;
if (utcValue) {
// Convert UTC to local time for input
const localValue = KMP_Timezone.toLocalInput(utcValue, this.timezone);
input.value = localValue;
// Store original UTC value for reference
input.dataset.originalUtc = utcValue;
// Store converted local value for reset
input.dataset.localValue = localValue;
}
});
}
/**
* Update timezone notice elements
*/
updateNotice() {
const abbr = KMP_Timezone.getAbbreviation(this.timezone);
const noticeText = `Times shown in ${this.timezone} (${abbr})`;
this.noticeTargets.forEach(notice => {
while (notice.firstChild) {
notice.removeChild(notice.firstChild);
}
const icon = document.createElement('i');
icon.classList.add('bi', 'bi-clock');
notice.appendChild(icon);
notice.appendChild(document.createTextNode(` ${noticeText}`));
});
}
/**
* Handle form submission - convert local times to UTC and create hidden inputs
* @param {Event} event
*/
handleSubmit(event) {
this.datetimeInputTargets.forEach(input => {
if (input.value) {
// Convert local time to UTC
const utcValue = KMP_Timezone.toUTC(input.value, this.timezone);
// Store original local value for potential reset
input.dataset.submittedLocal = input.value;
// Only proceed when conversion succeeds
if (utcValue) {
delete input.dataset.timezoneConversionFailed;
// If the original input is already disabled from a previous submit, skip
if (input.disabled) {
return;
}
// Remove any prior hidden UTC inputs for this field
const existingHidden = this.element.querySelectorAll(
`input[name="${CSS.escape(input.name)}"][data-timezone-converted="true"]`
);
existingHidden.forEach(el => el.remove());
// Create hidden input with UTC value
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = input.name;
hiddenInput.value = utcValue;
hiddenInput.dataset.timezoneConverted = 'true';
// Disable original input so it doesn't submit
input.disabled = true;
// Add hidden input to form
this.element.appendChild(hiddenInput);
} else {
input.dataset.timezoneConversionFailed = 'true';
}
}
});
}
/**
* Handle form reset - remove hidden inputs and restore original local values
* @param {Event} event
*/
handleReset(event) {
// Remove any hidden UTC inputs
const hiddenInputs = this.element.querySelectorAll('input[data-timezone-converted="true"]');
hiddenInputs.forEach(input => input.remove());
// Re-enable and restore datetime inputs
this.datetimeInputTargets.forEach(input => {
input.disabled = false;
delete input.dataset.timezoneConversionFailed;
// Restore to original local value
if (input.dataset.localValue) {
setTimeout(() => {
input.value = input.dataset.localValue;
}, 0);
}
});
}
/**
* Manually update timezone and re-convert all values
* @param {string} newTimezone - IANA timezone identifier
*/
updateTimezone(newTimezone) {
this.timezone = newTimezone;
// Re-convert all values with new timezone
this.convertUtcToLocal();
// Update notice if shown
if (this.showNoticeValue && this.hasNoticeTarget) {
this.updateNotice();
}
}
/**
* Get current timezone being used
* @returns {string} Current IANA timezone identifier
*/
getTimezone() {
return this.timezone;
}
/**
* Cleanup on disconnect - remove event listeners and prevent memory leaks
*/
disconnect() {
// Remove event listeners using cached references
if (this._handleSubmit) {
this.element.removeEventListener('submit', this._handleSubmit);
this._handleSubmit = null;
}
if (this._handleReset) {
this.element.removeEventListener('reset', this._handleReset);
this._handleReset = null;
}
}
}
// Add to global controllers registry
if (!window.Controllers) {
window.Controllers = {};
}
window.Controllers["timezone-input"] = TimezoneInputController;