Precise DOM morphing
morphing typescript dom

Add optional active-element preservation during morphing

Introduce preserveActiveElement to protect the focused element from moves, replacement, and removal so typing state can stay stable when requested without changing default behavior.

+121 -6
+3
README.md
··· 46 46 47 47 ```javascript 48 48 morph(currentNode, newNode, { 49 + preserveActiveElement: false, 49 50 preserveChanges: true, 50 51 beforeNodeAdded: (parent, node, insertionPoint) => { 51 52 console.log("Adding node:", node) ··· 57 58 ### Available Options 58 59 59 60 - **`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. This prevents focused elements from being moved, replaced or updated. Default: `false` 60 63 61 64 - **`beforeNodeVisited`**: Called before a node is visited during morphing. Return `false` to skip morphing this node. 62 65
+36 -5
src/morphlex.ts
··· 48 48 preserveChanges?: boolean 49 49 50 50 /** 51 + * When `true`, preserves the active element during morphing. 52 + * This prevents focused elements from being moved, replaced or updated. 53 + * @default false 54 + */ 55 + preserveActiveElement?: boolean 56 + 57 + /** 51 58 * Called before a node is visited during morphing. 52 59 * @param fromNode The existing node in the DOM 53 60 * @param toNode The new node to morph to ··· 155 162 if (typeof to === "string") to = parseFragment(to).childNodes 156 163 157 164 if (!options.preserveChanges && isParentNode(from)) flagDirtyInputs(from) 158 - 159 - new Morph(options).morph(from, to) 165 + new Morph(options, getActiveElement(from, options)).morph(from, to) 160 166 } 161 167 162 168 /** ··· 187 193 (from as Element).localName === (to as Element).localName 188 194 ) { 189 195 if (isParentNode(from)) flagDirtyInputs(from) 190 - new Morph(options).visitChildNodes(from as Element, to as Element) 196 + new Morph(options, getActiveElement(from, options)).visitChildNodes(from as Element, to as Element) 191 197 } else { 192 198 throw new Error("[Morphlex] You can only do an inner morph with matching elements.") 193 199 } 194 200 } 195 201 202 + function getActiveElement(from: ChildNode, options: Options): Element | null { 203 + if (!options.preserveActiveElement) return null 204 + 205 + const activeElement = document.activeElement 206 + if (!(activeElement instanceof Element)) return null 207 + if (from === activeElement) return activeElement 208 + 209 + if (from.nodeType !== ELEMENT_NODE_TYPE) return null 210 + return (from as Element).contains(activeElement) ? activeElement : null 211 + } 212 + 196 213 function flagDirtyInputs(node: ParentNode): void { 197 214 for (const input of node.querySelectorAll("input")) { 198 215 if ((input.name && input.value !== input.defaultValue) || input.checked !== input.defaultChecked) { ··· 241 258 class Morph { 242 259 readonly #idArrayMap: IdArrayMap = new WeakMap() 243 260 readonly #idSetMap: IdSetMap = new WeakMap() 261 + readonly #activeElement: Element | null 244 262 readonly #options: Options 245 263 246 - constructor(options: Options = {}) { 264 + constructor(options: Options = {}, activeElement: Element | null = null) { 247 265 this.#options = options 266 + this.#activeElement = activeElement 267 + } 268 + 269 + #isPinnedActiveElement(node: Node): boolean { 270 + return !!this.#options.preserveActiveElement && node === this.#activeElement 248 271 } 249 272 250 273 morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void { ··· 302 325 303 326 #morphMatchingElements(from: Element, to: Element): void { 304 327 if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 328 + if (this.#isPinnedActiveElement(from)) { 329 + this.#options.afterNodeVisited?.(from, to) 330 + return 331 + } 305 332 306 333 if (from.hasAttributes() || to.hasAttributes()) { 307 334 this.#visitAttributes(from, to) ··· 650 677 const match = fromChildNodes[matchInd]! 651 678 const operation = op[i]! 652 679 653 - if (!shouldNotMove[matchInd]) { 680 + if (!shouldNotMove[matchInd] && !this.#isPinnedActiveElement(match)) { 654 681 moveBefore(parent, match, insertionPoint) 655 682 } 656 683 ··· 676 703 } 677 704 678 705 #replaceNode(node: ChildNode, newNode: ChildNode): void { 706 + if (this.#isPinnedActiveElement(node)) return 707 + 679 708 const parent = node.parentNode || document 680 709 const insertionPoint = node 681 710 // Check if both removal and addition are allowed before starting the replacement ··· 691 720 } 692 721 693 722 #removeNode(node: ChildNode): void { 723 + if (this.#isPinnedActiveElement(node)) return 724 + 694 725 if (this.#options.beforeNodeRemoved?.(node) ?? true) { 695 726 node.remove() 696 727 this.#options.afterNodeRemoved?.(node)
-1
test/ai-gen-coverage/input-type-mismatch.browser.test.ts
··· 35 35 const a = dom(`<div><input type="text" value="hello"></div>`) as HTMLElement 36 36 const b = dom(`<div><input type="number" value="123"></div>`) as HTMLElement 37 37 38 - const originalInput = a.firstElementChild 39 38 morph(a, b) 40 39 const newInput = a.firstElementChild as HTMLInputElement 41 40
+82
test/new/active-element.browser.test.ts
··· 1 + import { describe, expect, test } from "vitest" 2 + import { morph } from "../../src/morphlex" 3 + import { dom } from "./utils" 4 + 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" 10 + document.body.appendChild(input) 11 + 12 + input.focus() 13 + input.setSelectionRange(2, 5) 14 + 15 + const next = document.createElement("input") 16 + next.type = "text" 17 + next.value = "server value" 18 + 19 + morph(input, next, { preserveActiveElement: true, preserveChanges: false }) 20 + 21 + expect(document.activeElement).toBe(input) 22 + expect(input.value).toBe("hello world") 23 + expect(input.selectionStart).toBe(2) 24 + expect(input.selectionEnd).toBe(5) 25 + 26 + input.remove() 27 + }) 28 + 29 + test("updates focused input when preserveActiveElement is disabled", () => { 30 + const input = dom('<input type="text" value="hello world">') as HTMLInputElement 31 + document.body.appendChild(input) 32 + 33 + input.focus() 34 + 35 + const next = dom('<input type="text" value="server value">') as HTMLInputElement 36 + 37 + morph(input, next, { preserveActiveElement: false, preserveChanges: false }) 38 + 39 + expect(input.value).toBe("server value") 40 + 41 + input.remove() 42 + }) 43 + 44 + test("preserves active contenteditable element", () => { 45 + const from = document.createElement("div") 46 + from.contentEditable = "true" 47 + from.textContent = "user text" 48 + document.body.appendChild(from) 49 + 50 + from.focus() 51 + 52 + const to = document.createElement("div") 53 + to.contentEditable = "true" 54 + to.textContent = "server text" 55 + 56 + morph(from, to, { preserveActiveElement: true }) 57 + 58 + expect(document.activeElement).toBe(from) 59 + expect(from.textContent).toBe("user text") 60 + 61 + from.remove() 62 + }) 63 + 64 + test("does not move active element while reordering", () => { 65 + const from = document.createElement("div") 66 + from.innerHTML = '<input id="active"><p id="sibling">A</p>' 67 + document.body.appendChild(from) 68 + 69 + const active = from.querySelector("#active") as HTMLInputElement 70 + active.focus() 71 + 72 + const to = document.createElement("div") 73 + to.innerHTML = '<p id="sibling">A</p><input id="active">' 74 + 75 + morph(from, to, { preserveActiveElement: true }) 76 + 77 + expect(from.querySelector("#active")).toBe(active) 78 + expect(document.activeElement).toBe(active) 79 + 80 + from.remove() 81 + }) 82 + })