Precise DOM morphing
morphing typescript dom

Defer active-element direct morph updates until blur

Queue direct morphs for the focused element and flush them when focus moves away, while preserving user-typed input value by updating only default value/attribute during the deferred pass.

+82 -4
+1 -1
README.md
··· 59 59 60 60 - **`preserveChanges`**: When `true`, preserves modified form inputs during morphing. This prevents user-entered data from being overwritten. Default: `false` 61 61 62 - - **`preserveActiveElement`**: When `true`, preserves the current `document.activeElement` during morphing. This prevents the focused element itself from being replaced or updated. Default: `false` 62 + - **`preserveActiveElement`**: When `true`, preserves the current `document.activeElement` during morphing. Direct updates and replacement of the focused element are deferred until it blurs. Default: `false` 63 63 64 64 - **`beforeNodeVisited`**: Called before a node is visited during morphing. Return `false` to skip morphing this node. 65 65
+51 -3
src/morphlex.ts
··· 29 29 type IdSetMap = WeakMap<Node, Set<string>> 30 30 type IdArrayMap = WeakMap<Node, Array<string>> 31 31 32 + const queuedActiveElementTargets: WeakMap<Element, ChildNode> = new WeakMap() 33 + const queuedActiveElementOptions: WeakMap<Element, Options> = new WeakMap() 34 + const queuedActiveElementListeners: WeakSet<Element> = new WeakSet() 35 + 32 36 /** 33 37 * Configuration options for morphing operations. 34 38 */ ··· 42 46 43 47 /** 44 48 * When `true`, preserves the active element during morphing. 45 - * This prevents the focused element itself from being replaced or updated. 49 + * This defers direct updates/replacement of the focused element until blur. 46 50 * @default false 47 51 */ 48 52 preserveActiveElement?: boolean ··· 253 257 readonly #idSetMap: IdSetMap = new WeakMap() 254 258 readonly #activeElement: Element | null 255 259 readonly #options: Options 260 + readonly #skipValuePropertyUpdateFor: Element | null 256 261 257 - constructor(options: Options = {}, activeElement: Element | null = null) { 262 + constructor(options: Options = {}, activeElement: Element | null = null, skipValuePropertyUpdateFor: Element | null = null) { 258 263 this.#options = options 259 264 this.#activeElement = activeElement 265 + this.#skipValuePropertyUpdateFor = skipValuePropertyUpdateFor 260 266 } 261 267 262 268 #isPinnedActiveElement(node: Node): boolean { ··· 305 311 if (from === to) return 306 312 if (from.isEqualNode(to)) return 307 313 314 + if (this.#isPinnedActiveElement(from)) { 315 + queueActiveElementMorph(from as Element, to, this.#options) 316 + return 317 + } 318 + 308 319 if (from.nodeType === ELEMENT_NODE_TYPE && to.nodeType === ELEMENT_NODE_TYPE) { 309 320 if ((from as Element).localName === (to as Element).localName) { 310 321 this.#morphMatchingElements(from as Element, to as Element) ··· 319 330 #morphMatchingElements(from: Element, to: Element): void { 320 331 if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 321 332 if (this.#isPinnedActiveElement(from)) { 333 + queueActiveElementMorph(from, to, this.#options) 322 334 this.#options.afterNodeVisited?.(from, to) 323 335 return 324 336 } ··· 365 377 for (const { name, value } of to.attributes) { 366 378 if (name === "value") { 367 379 if (isInputElement(from) && from.value !== value) { 368 - if (!this.#options.preserveChanges || from.value === from.defaultValue) { 380 + if (from !== this.#skipValuePropertyUpdateFor && (!this.#options.preserveChanges || from.value === from.defaultValue)) { 369 381 from.value = value 370 382 } 371 383 } ··· 835 847 } 836 848 } 837 849 } 850 + } 851 + 852 + function queueActiveElementMorph(from: Element, to: ChildNode, options: Options): void { 853 + queuedActiveElementTargets.set(from, to.cloneNode(true) as ChildNode) 854 + queuedActiveElementOptions.set(from, options) 855 + 856 + if (queuedActiveElementListeners.has(from)) return 857 + queuedActiveElementListeners.add(from) 858 + 859 + from.addEventListener( 860 + "blur", 861 + () => { 862 + queuedActiveElementListeners.delete(from) 863 + flushQueuedActiveElementMorph(from) 864 + }, 865 + { once: true }, 866 + ) 867 + } 868 + 869 + function flushQueuedActiveElementMorph(from: Element): void { 870 + const to = queuedActiveElementTargets.get(from) 871 + if (!to) return 872 + 873 + const options = queuedActiveElementOptions.get(from) ?? {} 874 + 875 + queuedActiveElementTargets.delete(from) 876 + queuedActiveElementOptions.delete(from) 877 + 878 + if (!from.isConnected) return 879 + 880 + const flushOptions: Options = { 881 + ...options, 882 + preserveActiveElement: false, 883 + } 884 + 885 + new Morph(flushOptions, null, from).morph(from as ChildNode, to) 838 886 } 839 887 840 888 function nodeListToArray(nodeList: NodeListOf<ChildNode>): Array<ChildNode>
+30
test/new/active-element.browser.test.ts
··· 26 26 input.remove() 27 27 }) 28 28 29 + test("applies queued active element updates on blur without changing input value", () => { 30 + const wrapper = document.createElement("div") 31 + wrapper.innerHTML = '<input id="name" value="hello" class="old"><button id="next">next</button>' 32 + document.body.appendChild(wrapper) 33 + 34 + const input = wrapper.querySelector("#name") as HTMLInputElement 35 + const nextButton = wrapper.querySelector("#next") as HTMLButtonElement 36 + 37 + input.value = "user typed" 38 + input.focus() 39 + 40 + const targetWrapper = document.createElement("div") 41 + targetWrapper.innerHTML = '<input id="name" value="server" class="new"><button id="next">next</button>' 42 + 43 + morph(wrapper, targetWrapper, { preserveActiveElement: true, preserveChanges: false }) 44 + 45 + expect(input.value).toBe("user typed") 46 + expect(input.defaultValue).toBe("hello") 47 + expect(input.className).toBe("old") 48 + 49 + nextButton.focus() 50 + 51 + expect(input.value).toBe("user typed") 52 + expect(input.defaultValue).toBe("server") 53 + expect(input.getAttribute("value")).toBe("server") 54 + expect(input.className).toBe("new") 55 + 56 + wrapper.remove() 57 + }) 58 + 29 59 test("updates focused input when preserveActiveElement is disabled", () => { 30 60 const input = dom('<input type="text" value="hello world">') as HTMLInputElement 31 61 document.body.appendChild(input)