Count a form's accepted resets, so a blur's commit yields to any of them in any order
lastReset was one shared slot: a cancelled reset replaced an accepted one's event, and the first of two resets cleared the slot before the blur's commit ran. In both orderings the commit found no accepted reset, committed the blank the reset had left in the box, and the reset's own repaint then painted an empty field. The page half now keeps two counts per form in WeakMaps. One document listener, registered before any field's, raises the pending count as each reset is dispatched and, a task later, lowers it and raises the accepted count if no listener cancelled the reset; nothing ever lowers the accepted count, so a later reset cannot erase an earlier one's protection. A blur records the accepted count when it queues its commit; when the timer runs, it re-queues itself while any reset for the form is still deciding, then commits only if the accepted count is unchanged. A cancelled reset raises nothing, so the commit goes through as it would have. node/reset_node.mjs is a stub page with queued timers, enough DOM for enhance to build a field, and a reset that does what a browser's does: dispatch, then restore every control unless cancelled. It lays out the single accepted reset, the single cancelled reset, blur then an accepted then a cancelled reset, and a cancelled reset then blur then an accepted one, and runs the timers in order. The Go test wants the default back in the field and the box after every accepted reset, and the typed text committed after the cancelled one. datetime.js is 84,708 bytes; the cap moves to 83KB with the arithmetic.
Human
Prompt
The third review's one finding: two same-task orderings of blur, accepted reset and cancelled reset still ended with the field and the box empty, because every reset overwrote the shared lastReset and every reset timer cleared it.
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
js/datetime.js: one document listener counts, per form, the resets still deciding and the resets accepted, the second raised only in the timer where defaultPrevented is final and never lowered; a blur's queued commit waits out pending resets and runs only if the accepted count is what it was when queued. lastReset is gone. check/node/reset_node.mjs lays out four orderings on a stub page with queued timers; TestAResetOutranksAPendingBlurCommitInEveryOrder wants the default after an accepted reset in every ordering and the typed text after a single cancelled one. The cap moves to 83KB; the 1.0.0 candidate is rebuilt.
Agent notes
The design is the controller's: a WeakMap from form to a count only accepted resets raise, read when the blur commit is queued and again when it runs. The pending count is what lets the commit wait for a reset dispatched after it to decide; timers run in order, and the reset's decision comes after the blur's timer without it. Red against 2cb368c: the two overlapping orderings reported native "" and shown "" on the harness.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+324
−20
check/datetime_test.go+61 −2check/node/reset_node.mjs+210 −0js/datetime.js+34 −11releases/1.0.0/SHA256SUMS+1 −1releases/1.0.0/datetime.js+18 −6
CI passed — run details
CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok amadan.net/rastrillo/fiesta 0.100s
=== size ===
go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$' -count=1 -v
=== RUN TestTheOneLinkIsUnder25KiBGzipped
build_test.go:138: fiesta-plain.css: 152856 bytes, 22303 bytes gzipped (21.8 KiB of the 25.0 KiB limit)
build_test.go:138: fiesta-signal.css: 152951 bytes, 22414 bytes gzipped (21.9 KiB of the 25.0 KiB limit)
build_test.go:138: fiesta.css: 155770 bytes, 23018 bytes gzipped (22.5 KiB of the 25.0 KiB limit)
--- PASS: TestTheOneLinkIsUnder25KiBGzipped (0.02s)
PASS
ok amadan.net/rastrillo/fiesta/internal/build 0.021s
=== suite ===
--- shard 0 of 10 ---
==> shard 0 of 10: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 go test -count=1 ./...
ok amadan.net/rastrillo/fiesta 0.745s
ok amadan.net/rastrillo/fiesta/check 4.978s
ok amadan.net/rastrillo/fiesta/cmd/fiesta 5.430s
ok amadan.net/rastrillo/fiesta/example 0.270s
ok amadan.net/rastrillo/fiesta/internal/axe 0.166s
ok amadan.net/rastrillo/fiesta/internal/build 1.449s
ok amadan.net/rastrillo/fiesta/internal/codeview 0.016s
ok amadan.net/rastrillo/fiesta/internal/gallery 2.703s
? amadan.net/rastrillo/fiesta/internal/harness [no test files]
ok amadan.net/rastrillo/fiesta/internal/markup 0.018s
ok amadan.net/rastrillo/fiesta/internal/nodetest 0.465s
ok amadan.net/rastrillo/fiesta/internal/site 3.562s
ok amadan.net/rastrillo/fiesta/internal/tokens 0.011s
--- shard 1 of 10 ---
==> shard 1 of 10: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
--- shard 2 of 10 ---
==> shard 2 of 10: make govulncheck
go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
=== Symbol Results ===
No vulnerabilities found.
Your code is affected by 0 vulnerabilities.
This scan also found 2 vulnerabilities in packages you import and 5
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.
--- shard 3 of 10 ---
==> shard 3 of 10: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
[90m12:52AM[0m [32mINF[0m [1mUnknown SCM platform. Use --platform to include links in findings.[0m [36mhost=[0mamadan.net
[90m12:52AM[0m [32mINF[0m [1m1 commits scanned.[0m
[90m12:52AM[0m [32mINF[0m [1mscanned ~5474190 bytes (5.47 MB) in 4.24s[0m
[90m12:52AM[0m [32mINF[0m [1mno leaks found[0m
--- shard 4 of 10 ---
==> shard 4 of 10: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
ok amadan.net/rastrillo/fiesta/internal/harness 11.242s
--- shard 5 of 10 ---
==> shard 5 of 10: make browser.check~1of3
./hack/gotest.sh browser 'check~1of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 3 of ./check/ runs 95 of 286 tests
ok amadan.net/rastrillo/fiesta/check 206.253s
--- shard 6 of 10 ---
==> shard 6 of 10: make browser.check~2of3
./hack/gotest.sh browser 'check~2of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 3 of ./check/ runs 95 of 286 tests
ok amadan.net/rastrillo/fiesta/check 224.429s
--- shard 7 of 10 ---
==> shard 7 of 10: make browser.check~3of3
./hack/gotest.sh browser 'check~3of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 3 of 3 of ./check/ runs 96 of 286 tests
ok amadan.net/rastrillo/fiesta/check 221.378s
--- shard 8 of 10 ---
==> shard 8 of 10: make browser.internal.gallery~1of2
./hack/gotest.sh browser 'internal.gallery~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/gallery/ runs 1 of 13 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 83.383s
--- shard 9 of 10 ---
==> shard 9 of 10: make browser.internal.gallery~2of2
./hack/gotest.sh browser 'internal.gallery~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/gallery/ runs 12 of 13 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 25.616s
diff --git a/check/datetime_test.go b/check/datetime_test.go| index 21b39d7..d06b9a3 100644 |
| --- a/check/datetime_test.go |
| +++ b/check/datetime_test.go |
| @@ -472,9 +472,21 @@ func TestDatetimeContract(t *testing.T) { |
| // page half with a comment saying why, a malformed attribute now |
| // falls to the table before English (NO_ATTRIBUTE and the catch), |
| // and a reset that lands in the tick before a blur's commit wins |
| - // (lastReset). 81KB (82,944) does not hold it; 82KB (83,968) does, |
| + // (lastReset). 81KB (82,944) did not hold it; 82KB (83,968) did, |
| // with 621 to spare. |
| - if n := len(raw); n > 82*1024 { |
| + // |
| + // 82KB → 83KB (measured 84,708 bytes, +1,361) when the third review |
| + // found two orderings lastReset still lost (an accepted reset |
| + // followed by a cancelled one, and the reverse around the blur). |
| + // The accounting is now per form at the top of the page half: a |
| + // pending count every reset raises and its timer lowers, an |
| + // accepted count only an uncancelled reset's timer raises and |
| + // nothing lowers, and a blur commit that waits out pending resets |
| + // and runs only if the accepted count is what it was when queued. |
| + // About 0.9KB is the comment saying why; lastReset's four lines |
| + // went. 82KB (83,968) does not hold it; 83KB (84,992) does, with |
| + // 284 to spare. |
| + if n := len(raw); n > 83*1024 { |
| t.Fatalf("datetime.js is %d bytes; split something out before it stops being readable", n) |
| } |
| } |
| @@ -580,3 +592,50 @@ func TestTheEnglishVocabularyIsTheFallback(t *testing.T) { |
| t.Errorf("a Spanish page with a malformed date-words attribute resolved to:\n %v\nwant the Spanish table:\n %v", got.Malformed, spanish.Words) |
| } |
| } |
| + |
| +// A form reset and the commit a blur queues can land in one task in |
| +// any order, and an accepted reset must win every time: the reset has |
| +// blanked the box, and the commit would empty the field the reset |
| +// restored. node/reset_node.mjs lays out four orderings on a stub page |
| +// with queued timers and runs the timers as a browser would. The two |
| +// overlapping ones are the review's: blur, an accepted reset, then a |
| +// cancelled one, where the cancelled event once replaced the accepted |
| +// one's guard; and a cancelled reset, blur, then an accepted one, where |
| +// the first reset's cleanup once erased the second's. Both ended with |
| +// the field and the box empty. The single accepted reset is the |
| +// original case, and a single cancelled reset must still commit the |
| +// text the blur was committing. |
| +func TestAResetOutranksAPendingBlurCommitInEveryOrder(t *testing.T) { |
| + nodetest.Node(t) |
| + out := nodetest.Run(t, nodetest.Cmd{Args: []string{"node/reset_node.mjs"}, Env: []string{"TZ=UTC"}}) |
| + type state struct { |
| + Native string `json:"native"` |
| + Shown string `json:"shown"` |
| + } |
| + var got struct { |
| + ShownLoad string `json:"shownLoad"` |
| + Changed string `json:"changed"` |
| + Single state `json:"single"` |
| + Cancelled state `json:"cancelled"` |
| + BlurAcceptedCancelled state `json:"blurAcceptedCancelled"` |
| + CancelledBlurAccepted state `json:"cancelledBlurAccepted"` |
| + } |
| + if err := json.Unmarshal(out, &got); err != nil { |
| + t.Fatalf("decode %q: %v", out, err) |
| + } |
| + if got.ShownLoad == "" || got.Changed != "2026-12-25" { |
| + t.Fatalf("setup: the box showed %q on load and the field is %q after 25 Dec 2026 was committed; the harness did not drive the field", got.ShownLoad, got.Changed) |
| + } |
| + for name, s := range map[string]state{ |
| + "blur then an accepted reset": got.Single, |
| + "blur, an accepted reset, then a cancelled reset": got.BlurAcceptedCancelled, |
| + "a cancelled reset, blur, then an accepted reset": got.CancelledBlurAccepted, |
| + } { |
| + if s.Native != "2026-10-20" || s.Shown != got.ShownLoad { |
| + t.Errorf("%s: the field is %q and the box shows %q; want the default 2026-10-20 and %q", name, s.Native, s.Shown, got.ShownLoad) |
| + } |
| + } |
| + if got.Cancelled.Native != "2026-12-31" { |
| + t.Errorf("text typed, blur, then a cancelled reset: the field is %q, want 2026-12-31, the text the blur was committing", got.Cancelled.Native) |
| + } |
| +} |
diff --git a/check/node/reset_node.mjs b/check/node/reset_node.mjs| new file mode 100644 |
| index 0000000..64e7981 |
| --- /dev/null |
| +++ b/check/node/reset_node.mjs |
| @@ -0,0 +1,210 @@ |
| +// reset_node drives the race between a date field's blur commit and a |
| +// form reset, in plain Node on a stub page with queued timers, so each |
| +// ordering of blur, accepted reset and cancelled reset within one task |
| +// can be laid out exactly and the timers run afterwards in the order |
| +// a browser would run them. |
| +// |
| +// node node/reset_node.mjs |
| +// |
| +// check/datetime_test.go is the only caller. It reads back one JSON |
| +// object with the field's wire value and the box's text after each |
| +// scenario, plus "shownLoad", what the box showed before anything |
| +// happened, which is what it must show again after an accepted reset: |
| +// |
| +// single blur, then an accepted reset |
| +// cancelled text typed, blur, then a cancelled reset |
| +// blurAcceptedCancelled blur, an accepted reset, a cancelled reset |
| +// cancelledBlurAccepted a cancelled reset, blur, an accepted reset |
| +// |
| +// The page is the least DOM enhance() touches: elements with |
| +// attributes, children, a class list, listeners and a form; a document |
| +// that creates them, holds the field, and dispatches a reset to the |
| +// listeners the script put on it; and setTimeout that queues. A reset |
| +// here does what a browser's does: the event is dispatched, and if no |
| +// listener cancelled it every control goes back to its default, the |
| +// field to its value attribute and the box to the empty string. |
| +import { readFileSync } from "node:fs"; |
| +import vm from "node:vm"; |
| + |
| +const src = readFileSync(new URL("../../js/datetime.js", import.meta.url), "utf8"); |
| + |
| +// page builds a fresh context holding one armed date field with a |
| +// default of 2026-10-20 inside a form, loads the script (which scans at |
| +// once, the document being complete), and returns the handles the |
| +// scenarios drive. |
| +function page() { |
| + const timers = []; |
| + const listenersOf = new WeakMap(); |
| + const element = (tag) => { |
| + const attrs = {}; |
| + const classes = new Set(); |
| + const el = { |
| + tagName: tag.toUpperCase(), |
| + children: [], |
| + parentNode: null, |
| + dataset: {}, |
| + hidden: false, |
| + value: "", |
| + id: "", |
| + type: "", |
| + disabled: false, |
| + readOnly: false, |
| + form: null, |
| + textContent: "", |
| + innerHTML: "", |
| + getAttribute: (k) => (k in attrs ? attrs[k] : null), |
| + hasAttribute: (k) => k in attrs, |
| + setAttribute(k, v) { attrs[k] = String(v); if (k === "id") el.id = String(v); }, |
| + removeAttribute(k) { delete attrs[k]; }, |
| + toggleAttribute(k, force) { const on = force === undefined ? !(k in attrs) : !!force; if (on) attrs[k] = ""; else delete attrs[k]; return on; }, |
| + classList: { add: (...c) => c.forEach((x) => classes.add(x)), remove: (...c) => c.forEach((x) => classes.delete(x)), toggle: (c, f) => (f ? classes.add(c) : classes.delete(c), !!f), contains: (c) => classes.has(c) }, |
| + appendChild(c) { if (c.parentNode) c.remove(); el.children.push(c); c.parentNode = el; return c; }, |
| + append(...cs) { cs.forEach((c) => el.appendChild(c)); }, |
| + insertBefore(c, ref) { const i = el.children.indexOf(ref); el.children.splice(i < 0 ? el.children.length : i, 0, c); c.parentNode = el; return c; }, |
| + remove() { if (el.parentNode) { const p = el.parentNode; p.children.splice(p.children.indexOf(el), 1); el.parentNode = null; } }, |
| + contains(other) { for (let n = other; n; n = n.parentNode) if (n === el) return true; return false; }, |
| + closest(sel) { for (let n = el; n; n = n.parentNode) if (n.matches && n.matches(sel)) return n; return null; }, |
| + matches(sel) { |
| + if (sel === ":disabled") { for (let n = el; n; n = n.parentNode) if (n.disabled && (n === el || n.tagName === "FIELDSET")) return true; return false; } |
| + if (sel.startsWith("[") && sel.endsWith("]")) return sel.slice(1, -1) in attrs; |
| + return false; |
| + }, |
| + querySelector: () => null, |
| + querySelectorAll: () => [], |
| + getBoundingClientRect: () => ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }), |
| + focus() { document.activeElement = el; }, |
| + blur() { if (document.activeElement === el) document.activeElement = document.body; }, |
| + select() {}, |
| + addEventListener(type, fn) { const m = listenersOf.get(el) || {}; (m[type] = m[type] || []).push(fn); listenersOf.set(el, m); }, |
| + removeEventListener() {}, |
| + // A real Event's target is read-only, so the script's own |
| + // new Event("change") is wrapped rather than written to. |
| + dispatchEvent(ev) { |
| + const e = ev instanceof Event ? { type: ev.type, bubbles: ev.bubbles, target: el, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } } : Object.assign({ target: el }, ev); |
| + for (let n = el; n; n = n.parentNode) { |
| + const fns = (listenersOf.get(n) || {})[e.type] || []; |
| + for (const fn of fns) fn.call(n, e); |
| + if (!e.bubbles) break; |
| + } |
| + return !e.defaultPrevented; |
| + }, |
| + get offsetTop() { return 0; }, |
| + get clientHeight() { return 0; }, |
| + scrollTop: 0, |
| + }; |
| + return el; |
| + }; |
| + |
| + const form = element("form"); |
| + const native = element("input"); |
| + native.type = "date"; |
| + native.setAttribute("type", "date"); |
| + native.setAttribute("id", "d"); |
| + native.setAttribute("name", "d"); |
| + native.setAttribute("value", "2026-10-20"); |
| + native.setAttribute("data-rst-date", ""); |
| + native.value = "2026-10-20"; |
| + native.form = form; |
| + const field = element("div"); |
| + field.appendChild(native); |
| + form.appendChild(field); |
| + |
| + const body = element("body"); |
| + body.appendChild(form); |
| + const root = element("html"); |
| + root.setAttribute("lang", "en"); |
| + root.lang = "en"; |
| + root.appendChild(body); |
| + const document = Object.assign(element("#document"), { |
| + readyState: "complete", |
| + documentElement: root, |
| + body, |
| + activeElement: body, |
| + createElement: (tag) => element(tag), |
| + querySelector: () => null, |
| + querySelectorAll: (sel) => (sel.includes("data-rst-date") ? [native] : []), |
| + }); |
| + root.parentNode = document; |
| + |
| + const window = { innerHeight: 600 }; |
| + const context = { |
| + window, self: window, document, |
| + Event, CustomEvent, Intl, console, |
| + MutationObserver: class { observe() {} disconnect() {} }, |
| + setTimeout: (fn) => { timers.push(fn); return timers.length; }, |
| + clearTimeout() {}, |
| + }; |
| + vm.runInNewContext(src, context, { filename: "datetime.js" }); |
| + |
| + const input = field.children.find((c) => c.tagName === "DIV").children.find((c) => c.tagName === "INPUT" && c !== native); |
| + if (!input) throw new Error("enhance did not put a box beside the field"); |
| + const fire = (el, type, extra) => el.dispatchEvent(Object.assign({ type, bubbles: true, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }, extra || {})); |
| + |
| + return { |
| + native, input, |
| + // Types text into the box and commits it with Enter. |
| + commit(text) { input.focus(); input.value = text; fire(input, "input"); fire(input, "keydown", { key: "Enter" }); }, |
| + // Types text into the box without committing it. |
| + type(text) { input.focus(); input.value = text; fire(input, "input"); }, |
| + // Focus leaves the box: focus goes to the body, and focusout bubbles |
| + // from the box through the wrap the script listens on. |
| + blur() { document.activeElement = body; fire(input, "focusout", { bubbles: true }); }, |
| + // A form reset, accepted or cancelled by a listener registered for the purpose. |
| + reset(cancel) { |
| + if (cancel) form.addEventListener("reset", function once(e) { e.preventDefault(); }); |
| + const ev = { type: "reset", bubbles: true, target: form, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; |
| + // The form's own listeners (the cancelling one), then the document's, as a bubbling event reaches them. |
| + for (const fn of (listenersOf.get(form) || {}).reset || []) fn.call(form, ev); |
| + for (const fn of (listenersOf.get(document) || {}).reset || []) fn.call(document, ev); |
| + listenersOf.set(form, {}); |
| + if (!ev.defaultPrevented) { native.value = native.getAttribute("value"); input.value = ""; } |
| + }, |
| + // Runs every queued timer in order, including ones queued while running. |
| + run() { while (timers.length) timers.shift()(); }, |
| + state() { return { native: native.value, shown: input.value }; }, |
| + }; |
| +} |
| + |
| +const out = {}; |
| +{ |
| + const p = page(); |
| + out.shownLoad = p.input.value; |
| + p.commit("25 Dec 2026"); |
| + p.run(); |
| + out.changed = p.native.value; |
| + p.blur(); |
| + p.reset(false); |
| + p.run(); |
| + out.single = p.state(); |
| +} |
| +{ |
| + const p = page(); |
| + p.commit("25 Dec 2026"); |
| + p.run(); |
| + p.type("31 Dec 2026"); |
| + p.blur(); |
| + p.reset(true); |
| + p.run(); |
| + out.cancelled = p.state(); |
| +} |
| +{ |
| + const p = page(); |
| + p.commit("25 Dec 2026"); |
| + p.run(); |
| + p.blur(); |
| + p.reset(false); |
| + p.reset(true); |
| + p.run(); |
| + out.blurAcceptedCancelled = p.state(); |
| +} |
| +{ |
| + const p = page(); |
| + p.commit("25 Dec 2026"); |
| + p.run(); |
| + p.reset(true); |
| + p.blur(); |
| + p.reset(false); |
| + p.run(); |
| + out.cancelledBlurAccepted = p.state(); |
| +} |
| +process.stdout.write(JSON.stringify(out)); |
diff --git a/js/datetime.js b/js/datetime.js| index 6ffe3f0..6c0e835 100644 |
| --- a/js/datetime.js |
| +++ b/js/datetime.js |
| @@ -1096,6 +1096,29 @@ |
| if (typeof document !== "undefined") { |
| + // Resets, per form, for the commit a blur queues. A reset that is |
| + // dispatched after the blur and before its timer blanks the box, |
| + // and the commit would empty the field the reset restored; so the |
| + // commit waits while any reset for its form is still deciding |
| + // (resetsPending), and then runs only if no reset was accepted |
| + // since it was queued (resetsAccepted). Whether a reset was |
| + // accepted is known only once it has been dispatched, so it is |
| + // counted in a timer, where defaultPrevented is final; a cancelled |
| + // reset is never counted; and nothing ever clears a count, so a |
| + // later reset cannot erase an earlier one's protection. One |
| + // listener for every field, registered before any field's own. |
| + var resetsPending = new WeakMap(), resetsAccepted = new WeakMap(); |
| + function tally(map, form) { return (form && map.get(form)) || 0; } |
| + function count(map, form, by) { if (form) map.set(form, tally(map, form) + by); } |
| + document.addEventListener("reset", function (e) { |
| + var form = e.target; |
| + count(resetsPending, form, 1); |
| + setTimeout(function () { |
| + count(resetsPending, form, -1); |
| + if (!e.defaultPrevented) count(resetsAccepted, form, 1); |
| + }, 0); |
| + }); |
| + |
| var CAL_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' + |
| 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + |
| '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>'; |
| @@ -1665,19 +1688,21 @@ |
| // say and slammed the panel shut under the cursor. A tick later |
| // the answer is not in doubt — document.activeElement is either |
| // back inside this field or it is not. |
| - // A reset that arrives in the tick between the blur and its |
| - // commit wins: the reset has blanked the box, and committing |
| - // that blank would empty the field the reset just restored. |
| - // The event is kept, not a flag, so a reset a listener cancelled |
| - // still lets the commit through. |
| - var lastReset = null; |
| + // A reset accepted after the blur wins over its commit: the reset |
| + // has blanked the box, and committing that blank would empty the |
| + // field the reset restored (the accounting is at the top of the |
| + // page half). A cancelled reset changes nothing, so the commit |
| + // goes through as it would have. |
| wrap.addEventListener("focusout", function () { |
| - setTimeout(function () { |
| + var form = native.form, at = tally(resetsAccepted, form); |
| + var settle = function () { |
| if (wrap.contains(document.activeElement)) return; |
| - if (lastReset && !lastReset.defaultPrevented) return; |
| + if (tally(resetsPending, form) > 0) { setTimeout(settle, 0); return; } |
| + if (tally(resetsAccepted, form) !== at) return; |
| commitText(); |
| closeAll(); |
| - }, 0); |
| + }; |
| + setTimeout(settle, 0); |
| }); |
| if (pick) { |
| @@ -1734,9 +1759,7 @@ |
| // reset happens, not when the field was enhanced. |
| document.addEventListener("reset", function (e) { |
| if (e.target !== native.form) return; |
| - lastReset = e; |
| setTimeout(function () { |
| - lastReset = null; |
| if (e.defaultPrevented) return; |
| closeAll(); |
| paint(); |
diff --git a/releases/1.0.0/SHA256SUMS b/releases/1.0.0/SHA256SUMS| index ece4b30..8a35605 100644 |
| --- a/releases/1.0.0/SHA256SUMS |
| +++ b/releases/1.0.0/SHA256SUMS |
| @@ -3,7 +3,7 @@ b495047bd93a9b06913511076f504daba17d5bbeb3e0650f3bb53a4220329c57 NOTICE |
| 40bb2c04c08f57539ee293e0e26d421c108425dfdd0da0401ee989157fe57bab anchor.js |
| f5a0406b7b4aca24539dd8ababe0fdc45111f346cce7667ed56d4ce4858e700e busy.js |
| 6db97e886b0cf78806e88ffd8b4893e185387acb6d4d33924b20331a5ab6ced3 calendar.js |
| -f53d2ab11686cddf05d2e0e2efa57de19c1edad708298890993bb63017fee2df datetime.js |
| +5f6aa4498aa9ad2b6122c40324ee88ed994f8e94310c40405fe53df6fffc0684 datetime.js |
| 369b0e6f349a8e40c80c0d510f0541e249f78aba5ffaf0362d9bc685dca3cfac day.css |
| 256285cd4933c9ec094c4a02c68c97cea6381b30add06885ff2d806768cf5aed fiesta-plain.css |
| eaf51f25dd3ad66d8f8e8511c005e19a70cc5306be6e125f046d2a5294256532 fiesta-signal.css |
diff --git a/releases/1.0.0/datetime.js b/releases/1.0.0/datetime.js| index 7aa1aec..99e0a62 100644 |
| --- a/releases/1.0.0/datetime.js |
| +++ b/releases/1.0.0/datetime.js |
| @@ -696,6 +696,18 @@ |
| if (typeof document !== "undefined") { |
| + var resetsPending = new WeakMap(), resetsAccepted = new WeakMap(); |
| + function tally(map, form) { return (form && map.get(form)) || 0; } |
| + function count(map, form, by) { if (form) map.set(form, tally(map, form) + by); } |
| + document.addEventListener("reset", function (e) { |
| + var form = e.target; |
| + count(resetsPending, form, 1); |
| + setTimeout(function () { |
| + count(resetsPending, form, -1); |
| + if (!e.defaultPrevented) count(resetsAccepted, form, 1); |
| + }, 0); |
| + }); |
| + |
| var CAL_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' + |
| 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + |
| '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>'; |
| @@ -1132,14 +1144,16 @@ |
| if (e.key === "Enter") e.preventDefault(); |
| }); |
| - var lastReset = null; |
| wrap.addEventListener("focusout", function () { |
| - setTimeout(function () { |
| + var form = native.form, at = tally(resetsAccepted, form); |
| + var settle = function () { |
| if (wrap.contains(document.activeElement)) return; |
| - if (lastReset && !lastReset.defaultPrevented) return; |
| + if (tally(resetsPending, form) > 0) { setTimeout(settle, 0); return; } |
| + if (tally(resetsAccepted, form) !== at) return; |
| commitText(); |
| closeAll(); |
| - }, 0); |
| + }; |
| + setTimeout(settle, 0); |
| }); |
| if (pick) { |
| @@ -1169,9 +1183,7 @@ |
| document.addEventListener("reset", function (e) { |
| if (e.target !== native.form) return; |
| - lastReset = e; |
| setTimeout(function () { |
| - lastReset = null; |
| if (e.defaultPrevented) return; |
| closeAll(); |
| paint(); |