diff --git a/src/htmx.js b/src/htmx.js index 4c8203ef2..73484e88f 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1455,19 +1455,24 @@ var htmx = (function() { function oobSwap(oobValue, oobElement, settleInfo, rootNode) { rootNode = rootNode || getDocument() let selector = '#' + CSS.escape(getRawAttribute(oobElement, 'id')) - /** @type HtmxSwapStyle */ - let swapStyle = 'outerHTML' - if (oobValue === 'true') { - // do nothing - } else if (oobValue.indexOf(':') > 0) { - swapStyle = oobValue.substring(0, oobValue.indexOf(':')) - selector = oobValue.substring(oobValue.indexOf(':') + 1) + var swapSpec = getSwapSpecification(oobElement, oobValue, {}) + if (swapSpec && Object.keys(swapSpec).length > 1) { + selector = swapSpec.target || selector // if it parses as a full swapSpec and is not a single value then use it } else { - swapStyle = oobValue + const split = oobValue.indexOf(':') // otherwise split as style:selector for legacy support + if (split !== -1) { + swapSpec = { swapStyle: oobValue.substring(0, split) } + selector = oobValue.substring(split + 1) + } else { + swapSpec = { swapStyle: oobValue === 'true' ? 'outerHTML' : oobValue } + } + } + if (WHITESPACE.test(swapSpec.swapStyle)) { + logError('invalid modifier in hx-swap-oob: ' + oobValue) } + oobElement.removeAttribute('hx-swap-oob') oobElement.removeAttribute('data-hx-swap-oob') - const targets = querySelectorAllExt(rootNode, selector, false) if (targets.length) { forEach( @@ -1475,31 +1480,38 @@ var htmx = (function() { function(target) { let fragment const oobElementClone = oobElement.cloneNode(true) - fragment = getDocument().createDocumentFragment() - fragment.appendChild(oobElementClone) - if (!isInlineSwap(swapStyle, target)) { - fragment = asParentNode(oobElementClone) // if this is not an inline swap, we use the content of the node, not the node itself + if (swapSpec.strip === undefined && !isInlineSwap(swapSpec.swapStyle, target)) { + swapSpec.strip = true + } + if (swapSpec.strip) { + // @ts-ignore if elt is template, content will be valid so use as inner content + fragment = oobElementClone.content || asParentNode(oobElementClone) // if this is not an inline swap, we use the content of the node, not the node itself + swapSpec.strip = undefined + } else { + fragment = getDocument().createDocumentFragment() + fragment.appendChild(oobElementClone) } - const beforeSwapDetails = { shouldSwap: true, target, fragment } + const beforeSwapDetails = { shouldSwap: true, target, fragment, swapSpec } if (!triggerEvent(target, 'htmx:oobBeforeSwap', beforeSwapDetails)) return target = beforeSwapDetails.target // allow re-targeting if (beforeSwapDetails.shouldSwap) { - handlePreservedElements(fragment) - swapWithStyle(swapStyle, target, target, fragment, settleInfo) - restorePreservedElements() + swap(target, fragment, beforeSwapDetails.swapSpec, { + contextElement: target, + afterSwapCallback: function(settleInfo) { + forEach(settleInfo.elts, function(elt) { + triggerEvent(elt, 'htmx:oobAfterSwap', beforeSwapDetails) + }) + } + }, settleInfo) } - forEach(settleInfo.elts, function(elt) { - triggerEvent(elt, 'htmx:oobAfterSwap', beforeSwapDetails) - }) } ) - oobElement.parentNode.removeChild(oobElement) } else { - oobElement.parentNode.removeChild(oobElement) triggerErrorEvent(getDocument().body, 'htmx:oobErrorNoTarget', { content: oobElement }) } + oobElement.parentNode.removeChild(oobElement) return oobValue } @@ -1840,7 +1852,7 @@ var htmx = (function() { } /** - * @param {DocumentFragment} fragment + * @param {DocumentFragment|ParentNode} fragment * @param {HtmxSettleInfo} settleInfo * @param {Node|Document} [rootNode] */ @@ -1864,18 +1876,24 @@ var htmx = (function() { * Implements complete swapping pipeline, including: delay, view transitions, focus and selection preservation, * title updates, scroll, OOB swapping, normal swapping and settling * @param {string|Element} target - * @param {string} content + * @param {string|ParentNode} content * @param {HtmxSwapSpecification} swapSpec * @param {SwapOptions} [swapOptions] + * @param {HtmxSettleInfo} [oobSettleInfo] */ - function swap(target, content, swapSpec, swapOptions) { + function swap(target, content, swapSpec, swapOptions, oobSettleInfo) { if (!swapOptions) { swapOptions = {} } + + addClassToElement(target, htmx.config.swappingClass) + // optional transition API promise callbacks let settleResolve = null let settleReject = null + const isOOBSwap = !!oobSettleInfo // calls passing oobSettleInfo are from oobSwap function and can skip some logic + let doSwap = function() { maybeCall(swapOptions.beforeSwapCallback) @@ -1892,45 +1910,54 @@ var htmx = (function() { // @ts-ignore end: activeElt ? activeElt.selectionEnd : null } - const settleInfo = makeSettleInfo(target) + if (swapSpec.settleDelay !== undefined || swapSpec.swapDelay != undefined) { + oobSettleInfo = undefined // for oobSwaps with swap or settle modifier make and perform own settleInfo + } + const settleInfo = oobSettleInfo || makeSettleInfo(target) // For text content swaps, don't parse the response as HTML, just insert it if (swapSpec.swapStyle === 'textContent') { - target.textContent = content + target.textContent = typeof content === 'string' ? content : content.textContent // Otherwise, make the fragment and process it } else { - let fragment = makeFragment(content) + /** @type DocumentFragment|ParentNode */ + let fragment = typeof content === 'string' ? makeFragment(content) : content + // @ts-ignore if fragment is a ParentNode title will be undefined which is fine settleInfo.title = swapOptions.title || fragment.title if (swapOptions.historyRequest) { - // @ts-ignore fragment can be a parentNode Element fragment = fragment.querySelector('[hx-history-elt],[data-hx-history-elt]') || fragment } - // select-oob swaps - if (swapOptions.selectOOB) { - const oobSelectValues = swapOptions.selectOOB.split(',') - for (let i = 0; i < oobSelectValues.length; i++) { - const oobSelectValue = oobSelectValues[i].split(':', 2) - let id = oobSelectValue[0].trim() - if (id.indexOf('#') === 0) { - id = id.substring(1) - } - const oobValue = oobSelectValue[1] || 'true' - const oobElement = fragment.querySelector('#' + id) - if (oobElement) { - oobSwap(oobValue, oobElement, settleInfo, rootNode) + if (!isOOBSwap) { + // select-oob swaps + if (swapOptions.selectOOB) { + const oobSelectValues = swapOptions.selectOOB.split(',') + for (let i = 0; i < oobSelectValues.length; i++) { + const oobSelectValue = oobSelectValues[i].split(':') + const selector = oobSelectValue.shift().trim() + const oobValue = oobSelectValue.join(':') || 'true' + let oobElements + if (selector.indexOf('#') !== 0 && /^[a-z\-_](\\\.|[a-z0-9\-_])*$/i.test(selector)) { + oobElements = fragment.querySelectorAll('#' + selector) // check if selector is an id first + } + if (!oobElements || !oobElements.length) { + oobElements = fragment.querySelectorAll(selector) // then support any full selector + } + forEach(oobElements, function(elt) { + oobSwap(oobValue, elt, settleInfo, rootNode) + }) } } + // oob swaps + findAndSwapOobElements(fragment, settleInfo, rootNode) + forEach(findAll(fragment, 'template'), /** @param {HTMLTemplateElement} template */function(template) { + if (template.content && findAndSwapOobElements(template.content, settleInfo, rootNode)) { + // Avoid polluting the DOM with empty templates that were only used to encapsulate oob swap + template.remove() + } + }) } - // oob swaps - findAndSwapOobElements(fragment, settleInfo, rootNode) - forEach(findAll(fragment, 'template'), /** @param {HTMLTemplateElement} template */function(template) { - if (template.content && findAndSwapOobElements(template.content, settleInfo, rootNode)) { - // Avoid polluting the DOM with empty templates that were only used to encapsulate oob swap - template.remove() - } - }) // normal swap if (swapOptions.select) { @@ -1940,6 +1967,9 @@ var htmx = (function() { }) fragment = newFragment } + if (swapSpec.strip) { + fragment = fragment.firstElementChild + } handlePreservedElements(fragment) swapWithStyle(swapSpec.swapStyle, swapOptions.contextElement, target, fragment, settleInfo) restorePreservedElements() @@ -1965,51 +1995,55 @@ var htmx = (function() { } } - target.classList.remove(htmx.config.swappingClass) + removeClassFromElement(target, htmx.config.swappingClass) forEach(settleInfo.elts, function(elt) { if (elt.classList) { elt.classList.add(htmx.config.settlingClass) } - triggerEvent(elt, 'htmx:afterSwap', swapOptions.eventInfo) + if (!isOOBSwap) { + triggerEvent(elt, 'htmx:afterSwap', swapOptions.eventInfo) + } }) - maybeCall(swapOptions.afterSwapCallback) + if (swapOptions.afterSwapCallback) { + swapOptions.afterSwapCallback(settleInfo) + } // merge in new title after swap but before settle if (!swapSpec.ignoreTitle) { handleTitle(settleInfo.title) } - // settle - const doSettle = function() { - forEach(settleInfo.tasks, function(task) { - task.call() - }) - forEach(settleInfo.elts, function(elt) { - if (elt.classList) { - elt.classList.remove(htmx.config.settlingClass) - } - triggerEvent(elt, 'htmx:afterSettle', swapOptions.eventInfo) - }) + // settle unless this is a oobSwap that settles at the end of its normal swap + if (!oobSettleInfo) { + const doSettle = function() { + forEach(settleInfo.tasks, function(task) { + task.call() + }) + forEach(settleInfo.elts, function(elt) { + removeClassFromElement(elt, htmx.config.settlingClass) + triggerEvent(elt, 'htmx:afterSettle', swapOptions.eventInfo) + }) - if (swapOptions.anchor) { - const anchorTarget = asElement(resolveTarget('#' + swapOptions.anchor)) - if (anchorTarget) { - anchorTarget.scrollIntoView({ block: 'start', behavior: 'auto' }) + if (swapOptions.anchor) { + const anchorTarget = asElement(resolveTarget('#' + swapOptions.anchor)) + if (anchorTarget) { + anchorTarget.scrollIntoView({ block: 'start', behavior: 'auto' }) + } } - } - updateScrollState(settleInfo.elts, swapSpec) - maybeCall(swapOptions.afterSettleCallback) - maybeCall(settleResolve) - } + updateScrollState(settleInfo.elts, swapSpec) + maybeCall(swapOptions.afterSettleCallback) + maybeCall(settleResolve) + } - if (swapSpec.settleDelay > 0) { - getWindow().setTimeout(doSettle, swapSpec.settleDelay) - } else { - doSettle() + if (swapSpec.settleDelay > 0) { + getWindow().setTimeout(doSettle, swapSpec.settleDelay) + } else { + doSettle() + } } } - let shouldTransition = htmx.config.globalViewTransitions + let shouldTransition = !isOOBSwap && htmx.config.globalViewTransitions if (swapSpec.hasOwnProperty('transition')) { shouldTransition = swapSpec.transition } @@ -3733,17 +3767,18 @@ var htmx = (function() { /** * @param {Element} elt * @param {HtmxSwapStyle} [swapInfoOverride] + * @param {Object} [defaults] * @returns {HtmxSwapSpecification} */ - function getSwapSpecification(elt, swapInfoOverride) { + function getSwapSpecification(elt, swapInfoOverride, defaults) { const swapInfo = swapInfoOverride || getClosestAttributeValue(elt, 'hx-swap') /** @type HtmxSwapSpecification */ - const swapSpec = { + const swapSpec = defaults || { swapStyle: getInternalData(elt).boosted ? 'innerHTML' : htmx.config.defaultSwapStyle, swapDelay: htmx.config.defaultSwapDelay, settleDelay: htmx.config.defaultSettleDelay } - if (htmx.config.scrollIntoViewOnBoost && getInternalData(elt).boosted && !isAnchorLink(elt)) { + if (!defaults && htmx.config.scrollIntoViewOnBoost && getInternalData(elt).boosted && !isAnchorLink(elt)) { swapSpec.show = 'top' } if (swapInfo) { @@ -3777,9 +3812,18 @@ var htmx = (function() { } else if (value.indexOf('focus-scroll:') === 0) { const focusScrollVal = value.slice('focus-scroll:'.length) swapSpec.focusScroll = focusScrollVal == 'true' + } else if (value.indexOf('strip:') === 0) { + swapSpec.strip = value.slice(6) === 'true' + } else if (value.indexOf('target:') === 0) { + swapSpec.target = value.slice(7) } else if (i == 0) { swapSpec.swapStyle = value + } else if (swapSpec.target) { + swapSpec.target += (' ' + value) // unfound modifers must be part of target selector } else { + if (defaults) { + return // on invalid modifers allow oob swap to fall back to old style + } logError('Unknown modifier in hx-swap: ' + value) } } @@ -4889,8 +4933,6 @@ var htmx = (function() { swapSpec.ignoreTitle = ignoreTitle } - target.classList.add(htmx.config.swappingClass) - if (responseInfoSelect) { selectOverride = responseInfoSelect } @@ -5138,7 +5180,7 @@ var htmx = (function() { * @property {*} [eventInfo] * @property {string} [anchor] * @property {Element} [contextElement] - * @property {swapCallback} [afterSwapCallback] + * @property {afterSwapCallback} [afterSwapCallback] * @property {swapCallback} [afterSettleCallback] * @property {swapCallback} [beforeSwapCallback] * @property {string} [title] @@ -5149,6 +5191,11 @@ var htmx = (function() { * @callback swapCallback */ +/** + * @callback afterSwapCallback + * @param {HtmxSettleInfo} settleInfo + */ + /** * @typedef {'innerHTML' | 'outerHTML' | 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend' | 'delete' | 'none' | string} HtmxSwapStyle */ @@ -5156,8 +5203,8 @@ var htmx = (function() { /** * @typedef HtmxSwapSpecification * @property {HtmxSwapStyle} swapStyle - * @property {number} swapDelay - * @property {number} settleDelay + * @property {number} [swapDelay] + * @property {number} [settleDelay] * @property {boolean} [transition] * @property {boolean} [ignoreTitle] * @property {string} [head] @@ -5166,6 +5213,8 @@ var htmx = (function() { * @property {string} [show] * @property {string} [showTarget] * @property {boolean} [focusScroll] + * @property {boolean} [strip] + * @property {string} [target] */ /** diff --git a/test/attributes/hx-ext.js b/test/attributes/hx-ext.js index adc047fe5..e0214bd89 100644 --- a/test/attributes/hx-ext.js +++ b/test/attributes/hx-ext.js @@ -56,6 +56,17 @@ describe('hx-ext attribute', function() { } } }) + htmx.defineExtension('ext-6', { + isInlineSwap: function(swapStyle) { + return swapStyle === 'morph:outerHTML' + }, + handleSwap: function(swapStyle, target, fragment, settleInfo) { + if (swapStyle === 'morph:outerHTML') { + const swapOuterHTML = htmx._('swapOuterHTML') + swapOuterHTML(target, fragment, settleInfo) + } + } + }) }) afterEach(function() { @@ -66,6 +77,7 @@ describe('hx-ext attribute', function() { htmx.removeExtension('ext-3') htmx.removeExtension('ext-4') htmx.removeExtension('ext-5') + htmx.removeExtension('ext-6') }) it('A simple extension is invoked properly', function() { @@ -195,4 +207,16 @@ describe('hx-ext attribute', function() { this.server.respond() byId('b1').innerHTML.should.equal('Bar') }) + + it('oob swap via swap extension can accept custom swap styles with ":" if it is a complex style', function() { + this.server.respondWith( + 'GET', + '/test', + '
Swapped16
!
` can be encapsulated in `
...
``` +You can also now use `template` tag as a universal tag that can encapsulate all tag types: +```html + +