Precise DOM morphing
morphing typescript dom

Remove `preserveActiveElement` option

I don’t think this is very useful and it makes the code more complex.

+41 -127
-3
README.md
··· 46 46 47 47 ```javascript 48 48 morph(currentNode, newNode, { 49 - preserveActiveElement: false, 50 49 preserveChanges: true, 51 50 beforeNodeAdded: (parent, node, insertionPoint) => { 52 51 console.log("Adding node:", node) ··· 58 57 ### Available Options 59 58 60 59 - **`preserveChanges`**: When `true`, preserves modified form inputs during morphing. This prevents user-entered data from being overwritten. Default: `false` 61 - 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 60 64 61 - **`beforeNodeVisited`**: Called before a node is visited during morphing. Return `false` to skip morphing this node. 65 62
+4 -84
src/morphlex.ts
··· 31 31 type IdArrayMap = WeakMap<Node, Array<string>> 32 32 type CandidateIdBucket = number | Array<number> 33 33 34 - const queuedActiveElementTargets: WeakMap<Element, ChildNode> = new WeakMap() 35 - const queuedActiveElementOptions: WeakMap<Element, Options> = new WeakMap() 36 - const queuedActiveElementListeners: WeakSet<Element> = new WeakSet() 37 - 38 34 /** 39 35 * Configuration options for morphing operations. 40 36 */ ··· 45 41 * @default false 46 42 */ 47 43 preserveChanges?: boolean 48 - 49 - /** 50 - * When `true`, preserves the active element during morphing. 51 - * This defers direct updates/replacement of the focused element until blur. 52 - * @default false 53 - */ 54 - preserveActiveElement?: boolean 55 44 56 45 /** 57 46 * Called before a node is visited during morphing. ··· 161 150 if (typeof to === "string") to = parseFragment(to).childNodes 162 151 163 152 if (isParentNode(from)) flagDirtyInputs(from) 164 - new Morph(options, getActiveElement(from, options)).morph(from, to) 153 + new Morph(options).morph(from, to) 165 154 } 166 155 167 156 /** ··· 192 181 (from as Element).localName === (to as Element).localName 193 182 ) { 194 183 if (isParentNode(from)) flagDirtyInputs(from) 195 - new Morph(options, getActiveElement(from, options)).visitChildNodes(from as Element, to as Element) 184 + new Morph(options).visitChildNodes(from as Element, to as Element) 196 185 } else { 197 186 throw new Error("[Morphlex] You can only do an inner morph with matching elements.") 198 187 } 199 188 } 200 189 201 - function getActiveElement(from: ChildNode, options: Options): Element | null { 202 - if (!options.preserveActiveElement) return null 203 - 204 - const activeElement = document.activeElement 205 - if (!(activeElement instanceof Element)) return null 206 - if (from === activeElement) return activeElement 207 - 208 - if (from.nodeType !== ELEMENT_NODE_TYPE) return null 209 - return (from as Element).contains(activeElement) ? activeElement : null 210 - } 211 - 212 190 function flagDirtyInputs(node: ParentNode): void { 213 191 if (node.nodeType === ELEMENT_NODE_TYPE) { 214 192 const element = node as Element ··· 275 253 class Morph { 276 254 readonly #idArrayMap: IdArrayMap = new WeakMap() 277 255 readonly #idSetMap: IdSetMap = new WeakMap() 278 - readonly #activeElement: Element | null 279 256 readonly #options: Options 280 - readonly #skipValuePropertyUpdateFor: Element | null 281 257 282 - constructor(options: Options = {}, activeElement: Element | null = null, skipValuePropertyUpdateFor: Element | null = null) { 258 + constructor(options: Options = {}) { 283 259 this.#options = options 284 - this.#activeElement = activeElement 285 - this.#skipValuePropertyUpdateFor = skipValuePropertyUpdateFor 286 - } 287 - 288 - #isPinnedActiveElement(node: Node): boolean { 289 - return !!this.#options.preserveActiveElement && node === this.#activeElement 290 260 } 291 261 292 262 morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void { ··· 331 301 if (from === to) return 332 302 if (from.isEqualNode(to)) return 333 303 334 - if (this.#isPinnedActiveElement(from)) { 335 - queueActiveElementMorph(from as Element, to, this.#options) 336 - return 337 - } 338 - 339 304 if (from.nodeType === ELEMENT_NODE_TYPE && to.nodeType === ELEMENT_NODE_TYPE) { 340 305 if ((from as Element).localName === (to as Element).localName) { 341 306 this.#morphMatchingElements(from as Element, to as Element) ··· 349 314 350 315 #morphMatchingElements(from: Element, to: Element): void { 351 316 if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 352 - if (this.#isPinnedActiveElement(from)) { 353 - queueActiveElementMorph(from, to, this.#options) 354 - this.#options.afterNodeVisited?.(from, to) 355 - return 356 - } 357 317 358 318 if (from.hasAttributes() || to.hasAttributes()) { 359 319 this.#visitAttributes(from, to) ··· 399 359 for (const { name, value } of to.attributes) { 400 360 if (name === "value") { 401 361 if (isInputElement(from) && from.value !== value) { 402 - if (from !== this.#skipValuePropertyUpdateFor && !this.#options.preserveChanges) { 362 + if (!this.#options.preserveChanges) { 403 363 from.value = value 404 364 } 405 365 } ··· 813 773 } 814 774 815 775 #replaceNode(node: ChildNode, newNode: ChildNode): void { 816 - if (this.#isPinnedActiveElement(node)) return 817 - 818 776 const parent = node.parentNode || document 819 777 const insertionPoint = node 820 778 // Check if both removal and addition are allowed before starting the replacement ··· 830 788 } 831 789 832 790 #removeNode(node: ChildNode): void { 833 - if (this.#isPinnedActiveElement(node)) return 834 - 835 791 if (this.#options.beforeNodeRemoved?.(node) ?? true) { 836 792 node.remove() 837 793 this.#options.afterNodeRemoved?.(node) ··· 893 849 } 894 850 }) 895 851 } 896 - } 897 - 898 - function queueActiveElementMorph(from: Element, to: ChildNode, options: Options): void { 899 - queuedActiveElementTargets.set(from, to.cloneNode(true) as ChildNode) 900 - queuedActiveElementOptions.set(from, options) 901 - 902 - if (queuedActiveElementListeners.has(from)) return 903 - queuedActiveElementListeners.add(from) 904 - 905 - from.addEventListener( 906 - "blur", 907 - () => { 908 - queuedActiveElementListeners.delete(from) 909 - flushQueuedActiveElementMorph(from) 910 - }, 911 - { once: true }, 912 - ) 913 - } 914 - 915 - function flushQueuedActiveElementMorph(from: Element): void { 916 - const to = queuedActiveElementTargets.get(from) 917 - if (!to) return 918 - 919 - const options = queuedActiveElementOptions.get(from) ?? {} 920 - 921 - queuedActiveElementTargets.delete(from) 922 - queuedActiveElementOptions.delete(from) 923 - 924 - if (!from.isConnected) return 925 - 926 - const flushOptions: Options = { 927 - ...options, 928 - preserveActiveElement: false, 929 - } 930 - 931 - new Morph(flushOptions, null, from).morph(from as ChildNode, to) 932 852 } 933 853 934 854 function forEachDescendantElementWithId(node: ParentNode, callback: (element: Element) => void): void {
+37 -40
test/new/active-element.browser.test.ts
··· 3 3 import { dom } from "./utils" 4 4 5 5 describe("active element preservation", () => { 6 - test("preserves focus and caret when preserveActiveElement is enabled", () => { 7 - const input = document.createElement("input") 8 - input.type = "text" 9 - input.value = "hello world" 6 + test("applies focused input attribute updates immediately", () => { 7 + const input = dom('<input type="text" value="hello world">') as HTMLInputElement 10 8 document.body.appendChild(input) 11 9 12 10 input.focus() 13 11 input.setSelectionRange(2, 5) 14 12 15 - const next = document.createElement("input") 16 - next.type = "text" 17 - next.value = "server value" 13 + const next = dom('<input type="text" value="server value" class="new">') as HTMLInputElement 18 14 19 - morph(input, next, { preserveActiveElement: true, preserveChanges: false }) 15 + morph(input, next, { preserveChanges: false }) 20 16 21 17 expect(document.activeElement).toBe(input) 22 - expect(input.value).toBe("hello world") 23 - expect(input.selectionStart).toBe(2) 24 - expect(input.selectionEnd).toBe(5) 18 + expect(input.value).toBe("server value") 19 + expect(input.getAttribute("value")).toBe("server value") 20 + expect(input.className).toBe("new") 25 21 26 22 input.remove() 27 23 }) 28 24 29 - test("applies queued active element updates on blur without changing input value", () => { 25 + test("does not defer focused descendant updates until blur", () => { 30 26 const wrapper = document.createElement("div") 31 27 wrapper.innerHTML = '<input id="name" value="hello" class="old"><button id="next">next</button>' 32 28 document.body.appendChild(wrapper) ··· 40 36 const targetWrapper = document.createElement("div") 41 37 targetWrapper.innerHTML = '<input id="name" value="server" class="new"><button id="next">next</button>' 42 38 43 - morph(wrapper, targetWrapper, { preserveActiveElement: true, preserveChanges: false }) 39 + morph(wrapper, targetWrapper, { preserveChanges: false }) 44 40 45 - expect(input.value).toBe("user typed") 46 - expect(input.defaultValue).toBe("hello") 47 - expect(input.className).toBe("old") 41 + expect(input.value).toBe("server") 42 + expect(input.defaultValue).toBe("server") 43 + expect(input.className).toBe("new") 48 44 49 45 nextButton.focus() 50 46 51 - expect(input.value).toBe("user typed") 47 + expect(input.value).toBe("server") 52 48 expect(input.defaultValue).toBe("server") 53 49 expect(input.getAttribute("value")).toBe("server") 54 50 expect(input.className).toBe("new") ··· 56 52 wrapper.remove() 57 53 }) 58 54 59 - test("updates focused input when preserveActiveElement is disabled", () => { 60 - const input = dom('<input type="text" value="hello world">') as HTMLInputElement 61 - document.body.appendChild(input) 55 + test("replaces active contenteditable element", () => { 56 + const parent = document.createElement("div") 57 + const from = document.createElement("div") 58 + from.contentEditable = "true" 59 + from.textContent = "user text" 60 + parent.appendChild(from) 61 + document.body.appendChild(parent) 62 62 63 - input.focus() 63 + from.focus() 64 64 65 - const next = dom('<input type="text" value="server value">') as HTMLInputElement 65 + const to = document.createElement("p") 66 + to.textContent = "server text" 66 67 67 - morph(input, next, { preserveActiveElement: false, preserveChanges: false }) 68 + morph(from, to) 68 69 69 - expect(input.value).toBe("server value") 70 + expect(parent.firstElementChild).toBe(to) 71 + expect(to.textContent).toBe("server text") 70 72 71 - input.remove() 73 + parent.remove() 72 74 }) 73 75 74 - test("preserves active contenteditable element", () => { 75 - const from = document.createElement("div") 76 - from.contentEditable = "true" 77 - from.textContent = "user text" 78 - document.body.appendChild(from) 76 + test("updates focused input when preserveChanges is disabled", () => { 77 + const input = dom('<input type="text" value="hello world">') as HTMLInputElement 78 + document.body.appendChild(input) 79 79 80 - from.focus() 80 + input.focus() 81 81 82 - const to = document.createElement("div") 83 - to.contentEditable = "true" 84 - to.textContent = "server text" 82 + const next = dom('<input type="text" value="server value">') as HTMLInputElement 85 83 86 - morph(from, to, { preserveActiveElement: true }) 84 + morph(input, next, { preserveChanges: false }) 87 85 88 - expect(document.activeElement).toBe(from) 89 - expect(from.textContent).toBe("user text") 86 + expect(input.value).toBe("server value") 90 87 91 - from.remove() 88 + input.remove() 92 89 }) 93 90 94 91 test("allows moving active element while reordering", () => { ··· 102 99 const to = document.createElement("div") 103 100 to.innerHTML = '<p id="sibling">A</p><input id="active">' 104 101 105 - morph(from, to, { preserveActiveElement: true }) 102 + morph(from, to) 106 103 107 104 expect(from.querySelector("#active")).toBe(active) 108 105 expect(from.firstElementChild?.id).toBe("sibling") ··· 125 122 const to = document.createElement("section") 126 123 to.innerHTML = '<input id="active" value="server"><span>new</span>' 127 124 128 - morph(from, to, { preserveActiveElement: true, preserveChanges: false }) 125 + morph(from, to, { preserveChanges: false }) 129 126 130 127 expect(host.firstElementChild?.tagName).toBe("SECTION") 131 128 expect((host.querySelector("#active") as HTMLInputElement).value).toBe("server")