From f2a0395b9837dc77860bf8170735e2a183b2af9f Mon Sep 17 00:00:00 2001 From: Alex Lion Date: Thu, 6 Aug 2026 16:16:33 +0700 Subject: [PATCH] Mirror datetime inputs to value attribute --- assets/js/date_time_local.mjs | 22 +++++-- assets/test/date_time_local_hook_test.mjs | 70 ++++++++++++++++++++++- 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/assets/js/date_time_local.mjs b/assets/js/date_time_local.mjs index 521f98c..a200ef5 100644 --- a/assets/js/date_time_local.mjs +++ b/assets/js/date_time_local.mjs @@ -17,7 +17,7 @@ const DateTimeLocal = { }, syncLocalTime() { if (!this.utcTime.value) { - this.localTime.value = ""; + this.setValue(this.localTime, ""); return; } @@ -26,21 +26,33 @@ const DateTimeLocal = { if (!Number.isNaN(date.getTime())) { const pad = (part) => String(part).padStart(2, "0"); - this.localTime.value = + this.setValue( + this.localTime, `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + - `T${pad(date.getHours())}:${pad(date.getMinutes())}`; + `T${pad(date.getHours())}:${pad(date.getMinutes())}`, + ); } }, syncUtcTime() { if (!this.localTime.value) { - this.utcTime.value = ""; + this.setValue(this.utcTime, ""); return; } const date = new Date(this.localTime.value); if (!Number.isNaN(date.getTime())) { - this.utcTime.value = date.toISOString().slice(0, 19); + this.setValue(this.utcTime, date.toISOString().slice(0, 19)); + this.setValue(this.localTime, this.localTime.value); + } + }, + + setValue(input, value) { + input.value = value; + if (value) { + input.setAttribute("value", value); + } else { + input.removeAttribute("value"); } }, destroyed() { diff --git a/assets/test/date_time_local_hook_test.mjs b/assets/test/date_time_local_hook_test.mjs index 87d5f2c..766d3d9 100644 --- a/assets/test/date_time_local_hook_test.mjs +++ b/assets/test/date_time_local_hook_test.mjs @@ -3,9 +3,27 @@ import test from "node:test"; import DateTimeLocal from "../js/date_time_local.mjs"; -function mountHook() { - const localTime = { value: "" }; - const utcTime = { value: "2026-08-04T12:00:00" }; +function makeInput(value = "") { + const attributes = new Map(); + if (value) attributes.set("value", value); + + return { + value, + setAttribute(name, val) { + attributes.set(name, String(val)); + }, + removeAttribute(name) { + attributes.delete(name); + }, + getAttribute(name) { + return attributes.has(name) ? attributes.get(name) : null; + }, + }; +} + +function mountHook(utcValue = "2026-08-04T12:00:00") { + const localTime = makeInput(); + const utcTime = makeInput(utcValue); const listeners = new Map(); const el = { @@ -29,6 +47,15 @@ function mountHook() { return { el, hook, localTime, utcTime }; } +function toLocalString(utcValue) { + const date = new Date(`${utcValue}Z`); + const pad = (part) => String(part).padStart(2, "0"); + return ( + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + + `T${pad(date.getHours())}:${pad(date.getMinutes())}` + ); +} + test("syncs the hidden UTC value when the datetime control only emits change", () => { const { el, localTime, utcTime } = mountHook(); localTime.value = "2030-01-02T10:30"; @@ -37,3 +64,40 @@ test("syncs the hidden UTC value when the datetime control only emits change", ( assert.equal(utcTime.value, new Date(localTime.value).toISOString().slice(0, 19)); }); + +test("mirrors synced values into the value attribute so LiveView sees a DOM diff", () => { + const { el, localTime, utcTime } = mountHook(); + + assert.equal(localTime.getAttribute("value"), localTime.value); + + localTime.value = "2030-01-02T10:30"; + el.dispatch("input", localTime); + + assert.equal(utcTime.getAttribute("value"), utcTime.value); + assert.equal(localTime.getAttribute("value"), "2030-01-02T10:30"); +}); + +test("restores the local value after a patch outside the form wipes the input", () => { + const utcValue = "2026-08-04T12:00:00"; + const { hook, localTime } = mountHook(utcValue); + + // Simulate morphdom patching against server HTML that has no value + // attribute (e.g. re-render caused by an upload finishing): the input + // property is reset and the attribute removed, then `updated` fires. + localTime.value = ""; + localTime.removeAttribute("value"); + hook.updated(); + + assert.equal(localTime.value, toLocalString(utcValue)); + assert.equal(localTime.getAttribute("value"), toLocalString(utcValue)); +}); + +test("clears the local value and attribute when the UTC value is empty", () => { + const { hook, localTime, utcTime } = mountHook(); + + utcTime.value = ""; + hook.updated(); + + assert.equal(localTime.value, ""); + assert.equal(localTime.getAttribute("value"), null); +});