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.
···4647```javascript
48morph(currentNode, newNode, {
049 preserveChanges: true,
50 beforeNodeAdded: (parent, node, insertionPoint) => {
51 console.log("Adding node:", node)
···57### Available Options
5859- **`preserveChanges`**: When `true`, preserves modified form inputs during morphing. This prevents user-entered data from being overwritten. Default: `false`
006061- **`beforeNodeVisited`**: Called before a node is visited during morphing. Return `false` to skip morphing this node.
62
···4647```javascript
48morph(currentNode, newNode, {
49+ preserveActiveElement: false,
50 preserveChanges: true,
51 beforeNodeAdded: (parent, node, insertionPoint) => {
52 console.log("Adding node:", node)
···58### Available Options
5960- **`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`
6364- **`beforeNodeVisited`**: Called before a node is visited during morphing. Return `false` to skip morphing this node.
65
+36-5
src/morphlex.ts
···48 preserveChanges?: boolean
4950 /**
000000051 * Called before a node is visited during morphing.
52 * @param fromNode The existing node in the DOM
53 * @param toNode The new node to morph to
···155 if (typeof to === "string") to = parseFragment(to).childNodes
156157 if (!options.preserveChanges && isParentNode(from)) flagDirtyInputs(from)
158-159- new Morph(options).morph(from, to)
160}
161162/**
···187 (from as Element).localName === (to as Element).localName
188 ) {
189 if (isParentNode(from)) flagDirtyInputs(from)
190- new Morph(options).visitChildNodes(from as Element, to as Element)
191 } else {
192 throw new Error("[Morphlex] You can only do an inner morph with matching elements.")
193 }
194}
19500000000000196function flagDirtyInputs(node: ParentNode): void {
197 for (const input of node.querySelectorAll("input")) {
198 if ((input.name && input.value !== input.defaultValue) || input.checked !== input.defaultChecked) {
···241class Morph {
242 readonly #idArrayMap: IdArrayMap = new WeakMap()
243 readonly #idSetMap: IdSetMap = new WeakMap()
0244 readonly #options: Options
245246- constructor(options: Options = {}) {
247 this.#options = options
00000248 }
249250 morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void {
···302303 #morphMatchingElements(from: Element, to: Element): void {
304 if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return
0000305306 if (from.hasAttributes() || to.hasAttributes()) {
307 this.#visitAttributes(from, to)
···650 const match = fromChildNodes[matchInd]!
651 const operation = op[i]!
652653- if (!shouldNotMove[matchInd]) {
654 moveBefore(parent, match, insertionPoint)
655 }
656···676 }
677678 #replaceNode(node: ChildNode, newNode: ChildNode): void {
00679 const parent = node.parentNode || document
680 const insertionPoint = node
681 // Check if both removal and addition are allowed before starting the replacement
···691 }
692693 #removeNode(node: ChildNode): void {
00694 if (this.#options.beforeNodeRemoved?.(node) ?? true) {
695 node.remove()
696 this.#options.afterNodeRemoved?.(node)
···48 preserveChanges?: boolean
4950 /**
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+ /**
58 * Called before a node is visited during morphing.
59 * @param fromNode The existing node in the DOM
60 * @param toNode The new node to morph to
···162 if (typeof to === "string") to = parseFragment(to).childNodes
163164 if (!options.preserveChanges && isParentNode(from)) flagDirtyInputs(from)
165+ new Morph(options, getActiveElement(from, options)).morph(from, to)
0166}
167168/**
···193 (from as Element).localName === (to as Element).localName
194 ) {
195 if (isParentNode(from)) flagDirtyInputs(from)
196+ new Morph(options, getActiveElement(from, options)).visitChildNodes(from as Element, to as Element)
197 } else {
198 throw new Error("[Morphlex] You can only do an inner morph with matching elements.")
199 }
200}
201202+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+213function flagDirtyInputs(node: ParentNode): void {
214 for (const input of node.querySelectorAll("input")) {
215 if ((input.name && input.value !== input.defaultValue) || input.checked !== input.defaultChecked) {
···258class Morph {
259 readonly #idArrayMap: IdArrayMap = new WeakMap()
260 readonly #idSetMap: IdSetMap = new WeakMap()
261+ readonly #activeElement: Element | null
262 readonly #options: Options
263264+ constructor(options: Options = {}, activeElement: Element | null = null) {
265 this.#options = options
266+ this.#activeElement = activeElement
267+ }
268+269+ #isPinnedActiveElement(node: Node): boolean {
270+ return !!this.#options.preserveActiveElement && node === this.#activeElement
271 }
272273 morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void {
···325326 #morphMatchingElements(from: Element, to: Element): void {
327 if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return
328+ if (this.#isPinnedActiveElement(from)) {
329+ this.#options.afterNodeVisited?.(from, to)
330+ return
331+ }
332333 if (from.hasAttributes() || to.hasAttributes()) {
334 this.#visitAttributes(from, to)
···677 const match = fromChildNodes[matchInd]!
678 const operation = op[i]!
679680+ if (!shouldNotMove[matchInd] && !this.#isPinnedActiveElement(match)) {
681 moveBefore(parent, match, insertionPoint)
682 }
683···703 }
704705 #replaceNode(node: ChildNode, newNode: ChildNode): void {
706+ if (this.#isPinnedActiveElement(node)) return
707+708 const parent = node.parentNode || document
709 const insertionPoint = node
710 // Check if both removal and addition are allowed before starting the replacement
···720 }
721722 #removeNode(node: ChildNode): void {
723+ if (this.#isPinnedActiveElement(node)) return
724+725 if (this.#options.beforeNodeRemoved?.(node) ?? true) {
726 node.remove()
727 this.#options.afterNodeRemoved?.(node)
···35 const a = dom(`<div><input type="text" value="hello"></div>`) as HTMLElement
36 const b = dom(`<div><input type="number" value="123"></div>`) as HTMLElement
3738- const originalInput = a.firstElementChild
39 morph(a, b)
40 const newInput = a.firstElementChild as HTMLInputElement
41
···35 const a = dom(`<div><input type="text" value="hello"></div>`) as HTMLElement
36 const b = dom(`<div><input type="number" value="123"></div>`) as HTMLElement
37038 morph(a, b)
39 const newInput = a.firstElementChild as HTMLInputElement
40