diff --git a/packages/docs/src/en/plugins/morph.md b/packages/docs/src/en/plugins/morph.md index cc60d4a79..108158e65 100644 --- a/packages/docs/src/en/plugins/morph.md +++ b/packages/docs/src/en/plugins/morph.md @@ -250,3 +250,15 @@ By adding keys to each node, we can accomplish this like so: Now that there are "keys" on the `
  • `s, Morph will match them in both trees and move them accordingly. You can configure what Morph considers a "key" with the `key:` configuration option. [More on that here](#lifecycle-hooks) + + +## Alpine.morphBetween() + +The `Alpine.morphBetween(startMarker, endMarker, newHtml, options)` method allows you to morph a range of DOM nodes between two marker elements based on passed in HTML. This is useful when you want to update only a specific section of the DOM without providing a single root node. + +| Parameter | Description | +| --- | --- | +| `startMarker` | A DOM node (typically a comment node) that marks the beginning of the range to morph | +| `endMarker` | A DOM node (typically a comment node) that marks the end of the range to morph | +| `newHtml` | A string of HTML or a DOM element to replace the content between the markers | +| `options` | An object of options (same as `Alpine.morph()`) | diff --git a/packages/morph/src/index.js b/packages/morph/src/index.js index 93c46b206..e159f9017 100644 --- a/packages/morph/src/index.js +++ b/packages/morph/src/index.js @@ -1,7 +1,8 @@ -import { morph } from './morph' +import { morph, morphBetween } from './morph' export default function (Alpine) { Alpine.morph = morph + Alpine.morphBetween = morphBetween } -export { morph } +export { morph, morphBetween } diff --git a/packages/morph/src/morph.js b/packages/morph/src/morph.js index 8afd35d10..0a2eef861 100644 --- a/packages/morph/src/morph.js +++ b/packages/morph/src/morph.js @@ -1,4 +1,3 @@ - let resolveStep = () => {} let logger = () => {} @@ -10,27 +9,80 @@ export function morph(from, toHtml, options) { // because it's an async function and if run twice, they would overwrite // each other. - let fromEl - let toEl - let key, lookahead, updating, updated, removing, removed, adding, added - - function assignOptions(options = {}) { - let defaultGetKey = el => el.getAttribute('key') - let noop = () => {} - - updating = options.updating || noop - updated = options.updated || noop - removing = options.removing || noop - removed = options.removed || noop - adding = options.adding || noop - added = options.added || noop - key = options.key || defaultGetKey - lookahead = options.lookahead || false + let context = createMorphContext(options) + + // Finally we morph the element + + let toEl = typeof toHtml === 'string' ? createElement(toHtml) : toHtml + + if (window.Alpine && window.Alpine.closestDataStack && ! from._x_dataStack) { + // Just in case a part of this template uses Alpine scope from somewhere + // higher in the DOM tree, we'll find that state and replace it on the root + // element so everything is synced up accurately. + toEl._x_dataStack = window.Alpine.closestDataStack(from) + + // We will kick off a clone on the root element. + toEl._x_dataStack && window.Alpine.cloneNode(from, toEl) + } + + context.patch(from, toEl) + + return from +} + +export function morphBetween(startMarker, endMarker, toHtml, options = {}) { + monkeyPatchDomSetAttributeToAllowAtSymbols() + + let context = createMorphContext(options) + + // Setup from block... + let fromContainer = startMarker.parentNode + let fromBlock = new Block(startMarker, endMarker) + + // Setup to block... + let toContainer = typeof toHtml === 'string' + ? (() => { + let container = document.createElement('div') + container.insertAdjacentHTML('beforeend', toHtml) + return container + })() + : toHtml + + let toStartMarker = document.createComment('[morph-start]') + let toEndMarker = document.createComment('[morph-end]') + + toContainer.insertBefore(toStartMarker, toContainer.firstChild) + toContainer.appendChild(toEndMarker) + + let toBlock = new Block(toStartMarker, toEndMarker) + + if (window.Alpine && window.Alpine.closestDataStack) { + toContainer._x_dataStack = window.Alpine.closestDataStack(fromContainer) + toContainer._x_dataStack && window.Alpine.cloneNode(fromContainer, toContainer) + } + + // Run the patch + context.patchChildren(fromBlock, toBlock) +} + +function createMorphContext(options = {}) { + let defaultGetKey = el => el.getAttribute('key') + let noop = () => {} + + let context = { + key: options.key || defaultGetKey, + lookahead: options.lookahead || false, + updating: options.updating || noop, + updated: options.updated || noop, + removing: options.removing || noop, + removed: options.removed || noop, + adding: options.adding || noop, + added: options.added || noop } - function patch(from, to) { - if (differentElementNamesTypesOrKeys(from, to)) { - return swapElements(from, to) + context.patch = function(from, to) { + if (context.differentElementNamesTypesOrKeys(from, to)) { + return context.swapElements(from, to) } let updateChildrenOnly = false @@ -40,60 +92,60 @@ export function morph(from, toHtml, options) { // hook to change. For example, when it was `shouldSkip()` the signature was `updating: (el, toEl, childrenOnly, skip)`. But if // we append `skipChildren()`, it would make the signature `updating: (el, toEl, childrenOnly, skipChildren, skip)`. This is // a breaking change due to how the `shouldSkip()` function is structured. - // - // So we're using `shouldSkipChildren()` instead which doesn't have this problem as it allows us to pass in the `skipChildren()` + // + // So we're using `shouldSkipChildren()` instead which doesn't have this problem as it allows us to pass in the `skipChildren()` // function as an earlier parameter and then append it to the `updating` hook signature manually. The signature of `updating` // hook is now `updating: (el, toEl, childrenOnly, skip, skipChildren)`. - if (shouldSkipChildren(updating, () => skipChildren = true, from, to, () => updateChildrenOnly = true)) return + if (shouldSkipChildren(context.updating, () => skipChildren = true, from, to, () => updateChildrenOnly = true)) return // Initialize the server-side HTML element with Alpine... if (from.nodeType === 1 && window.Alpine) { window.Alpine.cloneNode(from, to) if (from._x_teleport && to._x_teleport) { - patch(from._x_teleport, to._x_teleport) + context.patch(from._x_teleport, to._x_teleport) } } if (textOrComment(to)) { - patchNodeValue(from, to) + context.patchNodeValue(from, to) - updated(from, to) + context.updated(from, to) return } if (! updateChildrenOnly) { - patchAttributes(from, to) + context.patchAttributes(from, to) } - updated(from, to) + context.updated(from, to) if (! skipChildren) { - patchChildren(from, to) + context.patchChildren(from, to) } } - function differentElementNamesTypesOrKeys(from, to) { + context.differentElementNamesTypesOrKeys = function(from, to) { return from.nodeType != to.nodeType || from.nodeName != to.nodeName - || getKey(from) != getKey(to) + || context.getKey(from) != context.getKey(to) } - function swapElements(from, to) { - if (shouldSkip(removing, from)) return + context.swapElements = function(from, to) { + if (shouldSkip(context.removing, from)) return let toCloned = to.cloneNode(true) - if (shouldSkip(adding, toCloned)) return + if (shouldSkip(context.adding, toCloned)) return from.replaceWith(toCloned) - removed(from) - added(toCloned) + context.removed(from) + context.added(toCloned) } - function patchNodeValue(from, to) { + context.patchNodeValue = function(from, to) { let value = to.nodeValue if (from.nodeValue !== value) { @@ -102,7 +154,7 @@ export function morph(from, toHtml, options) { } } - function patchAttributes(from, to) { + context.patchAttributes = function(from, to) { if (from._x_transitioning) return if (from._x_isShown && ! to._x_isShown) { @@ -134,8 +186,8 @@ export function morph(from, toHtml, options) { } } - function patchChildren(from, to) { - let fromKeys = keyToMap(from.children) + context.patchChildren = function(from, to) { + let fromKeys = context.keyToMap(from.children) let fromKeyHoldovers = {} let currentTo = getFirstNode(to) @@ -146,8 +198,8 @@ export function morph(from, toHtml, options) { // Let's transfer it to the "to" element so that there isn't a key mismatch... seedingMatchingId(currentTo, currentFrom) - let toKey = getKey(currentTo) - let fromKey = getKey(currentFrom) + let toKey = context.getKey(currentTo) + let fromKey = context.getKey(currentFrom) // Add new elements... if (! currentFrom) { @@ -158,15 +210,15 @@ export function morph(from, toHtml, options) { from.appendChild(holdover) currentFrom = holdover - fromKey = getKey(currentFrom) + fromKey = context.getKey(currentFrom) } else { - if(! shouldSkip(adding, currentTo)) { + if(! shouldSkip(context.adding, currentTo)) { // Add element... let clone = currentTo.cloneNode(true) from.appendChild(clone) - added(clone) + context.added(clone) } currentTo = getNextSibling(to, currentTo) @@ -227,13 +279,13 @@ export function morph(from, toHtml, options) { let fromBlock = new Block(fromBlockStart, fromBlockEnd) let toBlock = new Block(toBlockStart, toBlockEnd) - patchChildren(fromBlock, toBlock) + context.patchChildren(fromBlock, toBlock) continue } // Lookaheads should only apply to non-text-or-comment elements... - if (currentFrom.nodeType === 1 && lookahead && ! currentFrom.isEqualNode(currentTo)) { + if (currentFrom.nodeType === 1 && context.lookahead && ! currentFrom.isEqualNode(currentTo)) { let nextToElementSibling = getNextSibling(to, currentTo) let found = false @@ -242,9 +294,9 @@ export function morph(from, toHtml, options) { if (nextToElementSibling.nodeType === 1 && currentFrom.isEqualNode(nextToElementSibling)) { found = true; // This ";" needs to be here... - currentFrom = addNodeBefore(from, currentTo, currentFrom) + currentFrom = context.addNodeBefore(from, currentTo, currentFrom) - fromKey = getKey(currentFrom) + fromKey = context.getKey(currentFrom) } nextToElementSibling = getNextSibling(to, nextToElementSibling) @@ -255,7 +307,7 @@ export function morph(from, toHtml, options) { if (! toKey && fromKey) { // No "to" key... fromKeyHoldovers[fromKey] = currentFrom; // This ";" needs to be here... - currentFrom = addNodeBefore(from, currentTo, currentFrom) + currentFrom = context.addNodeBefore(from, currentTo, currentFrom) fromKeyHoldovers[fromKey].remove() currentFrom = getNextSibling(from, currentFrom) currentTo = getNextSibling(to, currentTo) @@ -268,7 +320,7 @@ export function morph(from, toHtml, options) { // No "from" key... currentFrom.replaceWith(fromKeys[toKey]) currentFrom = fromKeys[toKey] - fromKey = getKey(currentFrom) + fromKey = context.getKey(currentFrom) } } @@ -280,11 +332,11 @@ export function morph(from, toHtml, options) { fromKeyHoldovers[fromKey] = currentFrom currentFrom.replaceWith(fromKeyNode) currentFrom = fromKeyNode - fromKey = getKey(currentFrom) + fromKey = context.getKey(currentFrom) } else { // Swap elements with keys... fromKeyHoldovers[fromKey] = currentFrom; // This ";" needs to be here... - currentFrom = addNodeBefore(from, currentTo, currentFrom) + currentFrom = context.addNodeBefore(from, currentTo, currentFrom) fromKeyHoldovers[fromKey].remove() currentFrom = getNextSibling(from, currentFrom) currentTo = getNextSibling(to, currentTo) @@ -298,7 +350,7 @@ export function morph(from, toHtml, options) { let currentFromNext = currentFrom && getNextSibling(from, currentFrom) //dom.next(from, fromChildren, currentFrom)) // Patch elements - patch(currentFrom, currentTo) + context.patch(currentFrom, currentTo) currentTo = currentTo && getNextSibling(to, currentTo) // dom.next(from, toChildren, currentTo)) @@ -311,7 +363,7 @@ export function morph(from, toHtml, options) { // We need to collect the "removals" first before actually // removing them so we don't mess with the order of things. while (currentFrom) { - if (! shouldSkip(removing, currentFrom)) removals.push(currentFrom) + if (! shouldSkip(context.removing, currentFrom)) removals.push(currentFrom) // currentFrom = dom.next(fromChildren, currentFrom) currentFrom = getNextSibling(from, currentFrom) @@ -323,19 +375,19 @@ export function morph(from, toHtml, options) { domForRemoval.remove() - removed(domForRemoval) + context.removed(domForRemoval) } } - function getKey(el) { - return el && el.nodeType === 1 && key(el) + context.getKey = function(el) { + return el && el.nodeType === 1 && context.key(el) } - function keyToMap(els) { + context.keyToMap = function(els) { let map = {} for (let el of els) { - let theKey = getKey(el) + let theKey = context.getKey(el) if (theKey) { map[theKey] = el @@ -345,13 +397,13 @@ export function morph(from, toHtml, options) { return map } - function addNodeBefore(parent, node, beforeMe) { - if(! shouldSkip(adding, node)) { + context.addNodeBefore = function(parent, node, beforeMe) { + if(! shouldSkip(context.adding, node)) { let clone = node.cloneNode(true) parent.insertBefore(clone, beforeMe) - added(clone) + context.added(clone) return clone } @@ -359,30 +411,7 @@ export function morph(from, toHtml, options) { return node } - // Finally we morph the element - - assignOptions(options) - - fromEl = from - toEl = typeof toHtml === 'string' ? createElement(toHtml) : toHtml - - if (window.Alpine && window.Alpine.closestDataStack && ! from._x_dataStack) { - // Just in case a part of this template uses Alpine scope from somewhere - // higher in the DOM tree, we'll find that state and replace it on the root - // element so everything is synced up accurately. - toEl._x_dataStack = window.Alpine.closestDataStack(from) - - // We will kick off a clone on the root element. - toEl._x_dataStack && window.Alpine.cloneNode(from, toEl) - } - - patch(from, toEl) - - // Release these for the garbage collector. - fromEl = undefined - toEl = undefined - - return from + return context } // These are legacy holdovers that don't do anything anymore... @@ -399,7 +428,7 @@ function shouldSkip(hook, ...args) { // Due to the structure of the `shouldSkip()` function, we can't pass in the `skipChildren` // function as an argument as it would change the signature of the existing hooks. So we -// are using this function instead which doesn't have this problem as we can pass the +// are using this function instead which doesn't have this problem as we can pass the // `skipChildren` function in as an earlier argument and then append it to the end // of the hook signature manually. function shouldSkipChildren(hook, skipChildren, ...args) { diff --git a/tests/cypress/integration/plugins/morph.spec.js b/tests/cypress/integration/plugins/morph.spec.js index af16a5b9c..7cf0501f0 100644 --- a/tests/cypress/integration/plugins/morph.spec.js +++ b/tests/cypress/integration/plugins/morph.spec.js @@ -652,3 +652,365 @@ test('can morph teleports with root-level state', get('h1').should(haveText('bar')); }, ) + +test('can use morphBetween with comment markers', + [html` +
    +

    Header

    + +

    Original content

    + +

    Footer

    +
    + `], + ({ get }, reload, window, document) => { + // Find the comment markers + let startMarker, endMarker; + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_COMMENT, + null, + false + ); + + let node; + while (node = walker.nextNode()) { + if (node.textContent === 'start') startMarker = node; + if (node.textContent === 'end') endMarker = node; + } + + window.Alpine.morphBetween(startMarker, endMarker, '

    New content

    More content

    ') + + get('h2:nth-of-type(1)').should(haveText('Header')) + get('h2:nth-of-type(2)').should(haveText('Footer')) + get('p').should(haveLength(2)) + get('p:nth-of-type(1)').should(haveText('New content')) + get('p:nth-of-type(2)').should(haveText('More content')) + }, +) + +test('morphBetween preserves Alpine state', + [html` +
    + + +

    + + + Static content +
    + `], + ({ get }, reload, window, document) => { + // Find markers + let startMarker, endMarker; + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_COMMENT, + null, + false + ); + + let node; + while (node = walker.nextNode()) { + if (node.textContent === 'morph-start') startMarker = node; + if (node.textContent === 'morph-end') endMarker = node; + } + + get('p').should(haveText('1')) + get('button').click() + get('p').should(haveText('2')) + + window.Alpine.morphBetween(startMarker, endMarker, ` +

    +
    New element
    + + `) + + get('p').should(haveText('2')) + get('article').should(haveText('New element')) + get('input').should(haveValue('2')) + get('input').clear().type('5') + get('p').should(haveText('5')) + }, +) + +test('morphBetween with keyed elements', + [html` + + `], + ({ get }, reload, window, document) => { + // Find markers + let startMarker, endMarker; + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_COMMENT, + null, + false + ); + + let node; + while (node = walker.nextNode()) { + if (node.textContent === 'items-start') startMarker = node; + if (node.textContent === 'items-end') endMarker = node; + } + + get('li:nth-of-type(1) input').type('first') + get('li:nth-of-type(2) input').type('second') + + get('ul').then(([el]) => window.Alpine.morphBetween(startMarker, endMarker, ` +
  • baz
  • +
  • foo
  • +
  • bar
  • + `, { key(el) { return el.getAttribute('key') } })) + + get('li').should(haveLength(3)) + get('li:nth-of-type(1)').should(haveText('baz')) + get('li:nth-of-type(2)').should(haveText('foo')) + get('li:nth-of-type(3)').should(haveText('bar')) + // Need to verify by the key attribute since the elements have been reordered + get('li[key="1"] input').should(haveValue('first')) + get('li[key="2"] input').should(haveValue('second')) + get('li[key="3"] input').should(haveValue('')) + }, +) + +test('morphBetween with custom key function', + [html` +
    + +
    Item A
    +
    Item B
    + +
    + `], + ({ get }, reload, window, document) => { + // Find markers + let startMarker, endMarker; + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_COMMENT, + null, + false + ); + + let node; + while (node = walker.nextNode()) { + if (node.textContent === 'start') startMarker = node; + if (node.textContent === 'end') endMarker = node; + } + + get('div[data-id="a"] input').type('aaa') + get('div[data-id="b"] input').type('bbb') + + window.Alpine.morphBetween(startMarker, endMarker, ` +
    Item B Updated
    +
    Item C
    +
    Item A Updated
    + `, { + key(el) { return el.dataset.id } + }) + + get('div[data-id]').should(haveLength(3)) + get('div[data-id="b"]').should(haveText('Item B Updated')) + get('div[data-id="a"]').should(haveText('Item A Updated')) + get('div[data-id="a"] input').should(haveValue('aaa')) + get('div[data-id="b"] input').should(haveValue('bbb')) + get('div[data-id="c"] input').should(haveValue('')) + }, +) + +test('morphBetween with hooks', + [html` +
    + +

    Old paragraph

    + Old span + +
    + `], + ({ get }, reload, window, document) => { + // Find markers + let startMarker, endMarker; + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_COMMENT, + null, + false + ); + + let node; + while (node = walker.nextNode()) { + if (node.textContent === 'region-start') startMarker = node; + if (node.textContent === 'region-end') endMarker = node; + } + + let removedElements = [] + let addedElements = [] + + window.Alpine.morphBetween(startMarker, endMarker, ` +

    New paragraph

    +
    New article
    + `, { + removing(el) { + if (el.nodeType === 1) removedElements.push(el.tagName) + }, + adding(el) { + if (el.nodeType === 1) addedElements.push(el.tagName) + } + }) + + get('p').should(haveText('New paragraph')) + get('article').should(haveText('New article')) + + // Check hooks were called + cy.wrap(removedElements).should('deep.equal', ['SPAN']) + cy.wrap(addedElements).should('deep.equal', ['ARTICLE']) + }, +) + +test('morphBetween with empty content', + [html` +
    +

    Title

    + +

    Content 1

    +

    Content 2

    + +

    End

    +
    + `], + ({ get }, reload, window, document) => { + // Find markers + let startMarker, endMarker; + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_COMMENT, + null, + false + ); + + let node; + while (node = walker.nextNode()) { + if (node.textContent === 'content-start') startMarker = node; + if (node.textContent === 'content-end') endMarker = node; + } + + window.Alpine.morphBetween(startMarker, endMarker, '') + + get('h3').should(haveLength(2)) + get('p').should(haveLength(0)) + + // Verify markers are still there + let found = false; + const walker2 = document.createTreeWalker( + document.body, + NodeFilter.SHOW_COMMENT, + null, + false + ); + while (node = walker2.nextNode()) { + if (node.textContent === 'content-start' || node.textContent === 'content-end') { + found = true; + } + } + cy.wrap(found).should('be.true') + }, +) + +test('morphBetween with nested Alpine components', + [html` +
    + +
    + + + +
    + +
    + `], + ({ get }, reload, window, document) => { + // Find markers + let startMarker, endMarker; + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_COMMENT, + null, + false + ); + + let node; + while (node = walker.nextNode()) { + if (node.textContent === 'nested-start') startMarker = node; + if (node.textContent === 'nested-end') endMarker = node; + } + + get('span:nth-of-type(1)').should(haveText('foo')) + get('span:nth-of-type(2)').should(haveText('bar')) + get('input').clear().type('baz') + get('span:nth-of-type(2)').should(haveText('baz')) + + window.Alpine.morphBetween(startMarker, endMarker, ` +
    +

    New heading

    + + + +
    + `) + + get('h4').should(haveText('New heading')) + get('span:nth-of-type(1)').should(haveText('foo')) + get('span:nth-of-type(2)').should(haveText('baz')) + get('input').should(haveValue('baz')) + }, +) + +test('morphBetween with conditional blocks', + [html` +
    + + +
    conditional content
    + +

    regular content

    + +
    + `], + ({ get }, reload, window, document) => { + // Find markers + let startMarker, endMarker; + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_COMMENT, + null, + false + ); + + let node; + while (node = walker.nextNode()) { + if (node.textContent === 'section-start') startMarker = node; + if (node.textContent === 'section-end') endMarker = node; + } + + get('div input').type('div-value') + get('p input').type('p-value') + + window.Alpine.morphBetween(startMarker, endMarker, ` + +
    conditional content
    + new conditional + +

    regular content

    + `) + + get('div input').should(haveValue('div-value')) + get('span input').should(haveValue('')) + get('p input').should(haveValue('p-value')) + }, +)