at master 154 kB view raw
1var htmx = (function() { 2 'use strict' 3 4 // Public API 5 const htmx = { 6 // Tsc madness here, assigning the functions directly results in an invalid TypeScript output, but reassigning is fine 7 /* Event processing */ 8 /** @type {typeof onLoadHelper} */ 9 onLoad: null, 10 /** @type {typeof processNode} */ 11 process: null, 12 /** @type {typeof addEventListenerImpl} */ 13 on: null, 14 /** @type {typeof removeEventListenerImpl} */ 15 off: null, 16 /** @type {typeof triggerEvent} */ 17 trigger: null, 18 /** @type {typeof ajaxHelper} */ 19 ajax: null, 20 /* DOM querying helpers */ 21 /** @type {typeof find} */ 22 find: null, 23 /** @type {typeof findAll} */ 24 findAll: null, 25 /** @type {typeof closest} */ 26 closest: null, 27 /** 28 * Returns the input values that would resolve for a given element via the htmx value resolution mechanism 29 * 30 * @see https://htmx.org/api/#values 31 * 32 * @param {Element} elt the element to resolve values on 33 * @param {HttpVerb} type the request type (e.g. **get** or **post**) non-GET's will include the enclosing form of the element. Defaults to **post** 34 * @returns {Object} 35 */ 36 values: function(elt, type) { 37 const inputValues = getInputValues(elt, type || 'post') 38 return inputValues.values 39 }, 40 /* DOM manipulation helpers */ 41 /** @type {typeof removeElement} */ 42 remove: null, 43 /** @type {typeof addClassToElement} */ 44 addClass: null, 45 /** @type {typeof removeClassFromElement} */ 46 removeClass: null, 47 /** @type {typeof toggleClassOnElement} */ 48 toggleClass: null, 49 /** @type {typeof takeClassForElement} */ 50 takeClass: null, 51 /** @type {typeof swap} */ 52 swap: null, 53 /* Extension entrypoints */ 54 /** @type {typeof defineExtension} */ 55 defineExtension: null, 56 /** @type {typeof removeExtension} */ 57 removeExtension: null, 58 /* Debugging */ 59 /** @type {typeof logAll} */ 60 logAll: null, 61 /** @type {typeof logNone} */ 62 logNone: null, 63 /* Debugging */ 64 /** 65 * The logger htmx uses to log with 66 * 67 * @see https://htmx.org/api/#logger 68 */ 69 logger: null, 70 /** 71 * A property holding the configuration htmx uses at runtime. 72 * 73 * Note that using a [meta tag](https://htmx.org/docs/#config) is the preferred mechanism for setting these properties. 74 * 75 * @see https://htmx.org/api/#config 76 */ 77 config: { 78 /** 79 * Whether to use history. 80 * @type boolean 81 * @default true 82 */ 83 historyEnabled: true, 84 /** 85 * The number of pages to keep in **localStorage** for history support. 86 * @type number 87 * @default 10 88 */ 89 historyCacheSize: 10, 90 /** 91 * @type boolean 92 * @default false 93 */ 94 refreshOnHistoryMiss: false, 95 /** 96 * The default swap style to use if **[hx-swap](https://htmx.org/attributes/hx-swap)** is omitted. 97 * @type HtmxSwapStyle 98 * @default 'innerHTML' 99 */ 100 defaultSwapStyle: 'innerHTML', 101 /** 102 * The default delay between receiving a response from the server and doing the swap. 103 * @type number 104 * @default 0 105 */ 106 defaultSwapDelay: 0, 107 /** 108 * The default delay between completing the content swap and settling attributes. 109 * @type number 110 * @default 20 111 */ 112 defaultSettleDelay: 20, 113 /** 114 * If true, htmx will inject a small amount of CSS into the page to make indicators invisible unless the **htmx-indicator** class is present. 115 * @type boolean 116 * @default true 117 */ 118 includeIndicatorStyles: true, 119 /** 120 * The class to place on indicators when a request is in flight. 121 * @type string 122 * @default 'htmx-indicator' 123 */ 124 indicatorClass: 'htmx-indicator', 125 /** 126 * The class to place on triggering elements when a request is in flight. 127 * @type string 128 * @default 'htmx-request' 129 */ 130 requestClass: 'htmx-request', 131 /** 132 * The class to temporarily place on elements that htmx has added to the DOM. 133 * @type string 134 * @default 'htmx-added' 135 */ 136 addedClass: 'htmx-added', 137 /** 138 * The class to place on target elements when htmx is in the settling phase. 139 * @type string 140 * @default 'htmx-settling' 141 */ 142 settlingClass: 'htmx-settling', 143 /** 144 * The class to place on target elements when htmx is in the swapping phase. 145 * @type string 146 * @default 'htmx-swapping' 147 */ 148 swappingClass: 'htmx-swapping', 149 /** 150 * Allows the use of eval-like functionality in htmx, to enable **hx-vars**, trigger conditions & script tag evaluation. Can be set to **false** for CSP compatibility. 151 * @type boolean 152 * @default true 153 */ 154 allowEval: true, 155 /** 156 * If set to false, disables the interpretation of script tags. 157 * @type boolean 158 * @default true 159 */ 160 allowScriptTags: true, 161 /** 162 * If set, the nonce will be added to inline scripts. 163 * @type string 164 * @default '' 165 */ 166 inlineScriptNonce: '', 167 /** 168 * If set, the nonce will be added to inline styles. 169 * @type string 170 * @default '' 171 */ 172 inlineStyleNonce: '', 173 /** 174 * The attributes to settle during the settling phase. 175 * @type string[] 176 * @default ['class', 'style', 'width', 'height'] 177 */ 178 attributesToSettle: ['class', 'style', 'width', 'height'], 179 /** 180 * Allow cross-site Access-Control requests using credentials such as cookies, authorization headers or TLS client certificates. 181 * @type boolean 182 * @default false 183 */ 184 withCredentials: false, 185 /** 186 * @type number 187 * @default 0 188 */ 189 timeout: 0, 190 /** 191 * The default implementation of **getWebSocketReconnectDelay** for reconnecting after unexpected connection loss by the event code **Abnormal Closure**, **Service Restart** or **Try Again Later**. 192 * @type {'full-jitter' | ((retryCount:number) => number)} 193 * @default "full-jitter" 194 */ 195 wsReconnectDelay: 'full-jitter', 196 /** 197 * The type of binary data being received over the WebSocket connection 198 * @type BinaryType 199 * @default 'blob' 200 */ 201 wsBinaryType: 'blob', 202 /** 203 * @type string 204 * @default '[hx-disable], [data-hx-disable]' 205 */ 206 disableSelector: '[hx-disable], [data-hx-disable]', 207 /** 208 * @type {'auto' | 'instant' | 'smooth'} 209 * @default 'instant' 210 */ 211 scrollBehavior: 'instant', 212 /** 213 * If the focused element should be scrolled into view. 214 * @type boolean 215 * @default false 216 */ 217 defaultFocusScroll: false, 218 /** 219 * If set to true htmx will include a cache-busting parameter in GET requests to avoid caching partial responses by the browser 220 * @type boolean 221 * @default false 222 */ 223 getCacheBusterParam: false, 224 /** 225 * If set to true, htmx will use the View Transition API when swapping in new content. 226 * @type boolean 227 * @default false 228 */ 229 globalViewTransitions: false, 230 /** 231 * htmx will format requests with these methods by encoding their parameters in the URL, not the request body 232 * @type {(HttpVerb)[]} 233 * @default ['get', 'delete'] 234 */ 235 methodsThatUseUrlParams: ['get', 'delete'], 236 /** 237 * If set to true, disables htmx-based requests to non-origin hosts. 238 * @type boolean 239 * @default false 240 */ 241 selfRequestsOnly: true, 242 /** 243 * If set to true htmx will not update the title of the document when a title tag is found in new content 244 * @type boolean 245 * @default false 246 */ 247 ignoreTitle: false, 248 /** 249 * Whether the target of a boosted element is scrolled into the viewport. 250 * @type boolean 251 * @default true 252 */ 253 scrollIntoViewOnBoost: true, 254 /** 255 * The cache to store evaluated trigger specifications into. 256 * You may define a simple object to use a never-clearing cache, or implement your own system using a [proxy object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Proxy) 257 * @type {Object|null} 258 * @default null 259 */ 260 triggerSpecsCache: null, 261 /** @type boolean */ 262 disableInheritance: false, 263 /** @type HtmxResponseHandlingConfig[] */ 264 responseHandling: [ 265 { code: '204', swap: false }, 266 { code: '[23]..', swap: true }, 267 { code: '[45]..', swap: false, error: true } 268 ], 269 /** 270 * Whether to process OOB swaps on elements that are nested within the main response element. 271 * @type boolean 272 * @default true 273 */ 274 allowNestedOobSwaps: true 275 }, 276 /** @type {typeof parseInterval} */ 277 parseInterval: null, 278 /** @type {typeof internalEval} */ 279 _: null, 280 version: '2.0.4' 281 } 282 // Tsc madness part 2 283 htmx.onLoad = onLoadHelper 284 htmx.process = processNode 285 htmx.on = addEventListenerImpl 286 htmx.off = removeEventListenerImpl 287 htmx.trigger = triggerEvent 288 htmx.ajax = ajaxHelper 289 htmx.find = find 290 htmx.findAll = findAll 291 htmx.closest = closest 292 htmx.remove = removeElement 293 htmx.addClass = addClassToElement 294 htmx.removeClass = removeClassFromElement 295 htmx.toggleClass = toggleClassOnElement 296 htmx.takeClass = takeClassForElement 297 htmx.swap = swap 298 htmx.defineExtension = defineExtension 299 htmx.removeExtension = removeExtension 300 htmx.logAll = logAll 301 htmx.logNone = logNone 302 htmx.parseInterval = parseInterval 303 htmx._ = internalEval 304 305 const internalAPI = { 306 addTriggerHandler, 307 bodyContains, 308 canAccessLocalStorage, 309 findThisElement, 310 filterValues, 311 swap, 312 hasAttribute, 313 getAttributeValue, 314 getClosestAttributeValue, 315 getClosestMatch, 316 getExpressionVars, 317 getHeaders, 318 getInputValues, 319 getInternalData, 320 getSwapSpecification, 321 getTriggerSpecs, 322 getTarget, 323 makeFragment, 324 mergeObjects, 325 makeSettleInfo, 326 oobSwap, 327 querySelectorExt, 328 settleImmediately, 329 shouldCancel, 330 triggerEvent, 331 triggerErrorEvent, 332 withExtensions 333 } 334 335 const VERBS = ['get', 'post', 'put', 'delete', 'patch'] 336 const VERB_SELECTOR = VERBS.map(function(verb) { 337 return '[hx-' + verb + '], [data-hx-' + verb + ']' 338 }).join(', ') 339 340 //= =================================================================== 341 // Utilities 342 //= =================================================================== 343 344 /** 345 * Parses an interval string consistent with the way htmx does. Useful for plugins that have timing-related attributes. 346 * 347 * Caution: Accepts an int followed by either **s** or **ms**. All other values use **parseFloat** 348 * 349 * @see https://htmx.org/api/#parseInterval 350 * 351 * @param {string} str timing string 352 * @returns {number|undefined} 353 */ 354 function parseInterval(str) { 355 if (str == undefined) { 356 return undefined 357 } 358 359 let interval = NaN 360 if (str.slice(-2) == 'ms') { 361 interval = parseFloat(str.slice(0, -2)) 362 } else if (str.slice(-1) == 's') { 363 interval = parseFloat(str.slice(0, -1)) * 1000 364 } else if (str.slice(-1) == 'm') { 365 interval = parseFloat(str.slice(0, -1)) * 1000 * 60 366 } else { 367 interval = parseFloat(str) 368 } 369 return isNaN(interval) ? undefined : interval 370 } 371 372 /** 373 * @param {Node} elt 374 * @param {string} name 375 * @returns {(string | null)} 376 */ 377 function getRawAttribute(elt, name) { 378 return elt instanceof Element && elt.getAttribute(name) 379 } 380 381 /** 382 * @param {Element} elt 383 * @param {string} qualifiedName 384 * @returns {boolean} 385 */ 386 // resolve with both hx and data-hx prefixes 387 function hasAttribute(elt, qualifiedName) { 388 return !!elt.hasAttribute && (elt.hasAttribute(qualifiedName) || 389 elt.hasAttribute('data-' + qualifiedName)) 390 } 391 392 /** 393 * 394 * @param {Node} elt 395 * @param {string} qualifiedName 396 * @returns {(string | null)} 397 */ 398 function getAttributeValue(elt, qualifiedName) { 399 return getRawAttribute(elt, qualifiedName) || getRawAttribute(elt, 'data-' + qualifiedName) 400 } 401 402 /** 403 * @param {Node} elt 404 * @returns {Node | null} 405 */ 406 function parentElt(elt) { 407 const parent = elt.parentElement 408 if (!parent && elt.parentNode instanceof ShadowRoot) return elt.parentNode 409 return parent 410 } 411 412 /** 413 * @returns {Document} 414 */ 415 function getDocument() { 416 return document 417 } 418 419 /** 420 * @param {Node} elt 421 * @param {boolean} global 422 * @returns {Node|Document} 423 */ 424 function getRootNode(elt, global) { 425 return elt.getRootNode ? elt.getRootNode({ composed: global }) : getDocument() 426 } 427 428 /** 429 * @param {Node} elt 430 * @param {(e:Node) => boolean} condition 431 * @returns {Node | null} 432 */ 433 function getClosestMatch(elt, condition) { 434 while (elt && !condition(elt)) { 435 elt = parentElt(elt) 436 } 437 438 return elt || null 439 } 440 441 /** 442 * @param {Element} initialElement 443 * @param {Element} ancestor 444 * @param {string} attributeName 445 * @returns {string|null} 446 */ 447 function getAttributeValueWithDisinheritance(initialElement, ancestor, attributeName) { 448 const attributeValue = getAttributeValue(ancestor, attributeName) 449 const disinherit = getAttributeValue(ancestor, 'hx-disinherit') 450 var inherit = getAttributeValue(ancestor, 'hx-inherit') 451 if (initialElement !== ancestor) { 452 if (htmx.config.disableInheritance) { 453 if (inherit && (inherit === '*' || inherit.split(' ').indexOf(attributeName) >= 0)) { 454 return attributeValue 455 } else { 456 return null 457 } 458 } 459 if (disinherit && (disinherit === '*' || disinherit.split(' ').indexOf(attributeName) >= 0)) { 460 return 'unset' 461 } 462 } 463 return attributeValue 464 } 465 466 /** 467 * @param {Element} elt 468 * @param {string} attributeName 469 * @returns {string | null} 470 */ 471 function getClosestAttributeValue(elt, attributeName) { 472 let closestAttr = null 473 getClosestMatch(elt, function(e) { 474 return !!(closestAttr = getAttributeValueWithDisinheritance(elt, asElement(e), attributeName)) 475 }) 476 if (closestAttr !== 'unset') { 477 return closestAttr 478 } 479 } 480 481 /** 482 * @param {Node} elt 483 * @param {string} selector 484 * @returns {boolean} 485 */ 486 function matches(elt, selector) { 487 // @ts-ignore: non-standard properties for browser compatibility 488 // noinspection JSUnresolvedVariable 489 const matchesFunction = elt instanceof Element && (elt.matches || elt.matchesSelector || elt.msMatchesSelector || elt.mozMatchesSelector || elt.webkitMatchesSelector || elt.oMatchesSelector) 490 return !!matchesFunction && matchesFunction.call(elt, selector) 491 } 492 493 /** 494 * @param {string} str 495 * @returns {string} 496 */ 497 function getStartTag(str) { 498 const tagMatcher = /<([a-z][^\/\0>\x20\t\r\n\f]*)/i 499 const match = tagMatcher.exec(str) 500 if (match) { 501 return match[1].toLowerCase() 502 } else { 503 return '' 504 } 505 } 506 507 /** 508 * @param {string} resp 509 * @returns {Document} 510 */ 511 function parseHTML(resp) { 512 const parser = new DOMParser() 513 return parser.parseFromString(resp, 'text/html') 514 } 515 516 /** 517 * @param {DocumentFragment} fragment 518 * @param {Node} elt 519 */ 520 function takeChildrenFor(fragment, elt) { 521 while (elt.childNodes.length > 0) { 522 fragment.append(elt.childNodes[0]) 523 } 524 } 525 526 /** 527 * @param {HTMLScriptElement} script 528 * @returns {HTMLScriptElement} 529 */ 530 function duplicateScript(script) { 531 const newScript = getDocument().createElement('script') 532 forEach(script.attributes, function(attr) { 533 newScript.setAttribute(attr.name, attr.value) 534 }) 535 newScript.textContent = script.textContent 536 newScript.async = false 537 if (htmx.config.inlineScriptNonce) { 538 newScript.nonce = htmx.config.inlineScriptNonce 539 } 540 return newScript 541 } 542 543 /** 544 * @param {HTMLScriptElement} script 545 * @returns {boolean} 546 */ 547 function isJavaScriptScriptNode(script) { 548 return script.matches('script') && (script.type === 'text/javascript' || script.type === 'module' || script.type === '') 549 } 550 551 /** 552 * we have to make new copies of script tags that we are going to insert because 553 * SOME browsers (not saying who, but it involves an element and an animal) don't 554 * execute scripts created in <template> tags when they are inserted into the DOM 555 * and all the others do lmao 556 * @param {DocumentFragment} fragment 557 */ 558 function normalizeScriptTags(fragment) { 559 Array.from(fragment.querySelectorAll('script')).forEach(/** @param {HTMLScriptElement} script */ (script) => { 560 if (isJavaScriptScriptNode(script)) { 561 const newScript = duplicateScript(script) 562 const parent = script.parentNode 563 try { 564 parent.insertBefore(newScript, script) 565 } catch (e) { 566 logError(e) 567 } finally { 568 script.remove() 569 } 570 } 571 }) 572 } 573 574 /** 575 * @typedef {DocumentFragment & {title?: string}} DocumentFragmentWithTitle 576 * @description a document fragment representing the response HTML, including 577 * a `title` property for any title information found 578 */ 579 580 /** 581 * @param {string} response HTML 582 * @returns {DocumentFragmentWithTitle} 583 */ 584 function makeFragment(response) { 585 // strip head tag to determine shape of response we are dealing with 586 const responseWithNoHead = response.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i, '') 587 const startTag = getStartTag(responseWithNoHead) 588 /** @type DocumentFragmentWithTitle */ 589 let fragment 590 if (startTag === 'html') { 591 // if it is a full document, parse it and return the body 592 fragment = /** @type DocumentFragmentWithTitle */ (new DocumentFragment()) 593 const doc = parseHTML(response) 594 takeChildrenFor(fragment, doc.body) 595 fragment.title = doc.title 596 } else if (startTag === 'body') { 597 // parse body w/o wrapping in template 598 fragment = /** @type DocumentFragmentWithTitle */ (new DocumentFragment()) 599 const doc = parseHTML(responseWithNoHead) 600 takeChildrenFor(fragment, doc.body) 601 fragment.title = doc.title 602 } else { 603 // otherwise we have non-body partial HTML content, so wrap it in a template to maximize parsing flexibility 604 const doc = parseHTML('<body><template class="internal-htmx-wrapper">' + responseWithNoHead + '</template></body>') 605 fragment = /** @type DocumentFragmentWithTitle */ (doc.querySelector('template').content) 606 // extract title into fragment for later processing 607 fragment.title = doc.title 608 609 // for legacy reasons we support a title tag at the root level of non-body responses, so we need to handle it 610 var titleElement = fragment.querySelector('title') 611 if (titleElement && titleElement.parentNode === fragment) { 612 titleElement.remove() 613 fragment.title = titleElement.innerText 614 } 615 } 616 if (fragment) { 617 if (htmx.config.allowScriptTags) { 618 normalizeScriptTags(fragment) 619 } else { 620 // remove all script tags if scripts are disabled 621 fragment.querySelectorAll('script').forEach((script) => script.remove()) 622 } 623 } 624 return fragment 625 } 626 627 /** 628 * @param {Function} func 629 */ 630 function maybeCall(func) { 631 if (func) { 632 func() 633 } 634 } 635 636 /** 637 * @param {any} o 638 * @param {string} type 639 * @returns 640 */ 641 function isType(o, type) { 642 return Object.prototype.toString.call(o) === '[object ' + type + ']' 643 } 644 645 /** 646 * @param {*} o 647 * @returns {o is Function} 648 */ 649 function isFunction(o) { 650 return typeof o === 'function' 651 } 652 653 /** 654 * @param {*} o 655 * @returns {o is Object} 656 */ 657 function isRawObject(o) { 658 return isType(o, 'Object') 659 } 660 661 /** 662 * @typedef {Object} OnHandler 663 * @property {(keyof HTMLElementEventMap)|string} event 664 * @property {EventListener} listener 665 */ 666 667 /** 668 * @typedef {Object} ListenerInfo 669 * @property {string} trigger 670 * @property {EventListener} listener 671 * @property {EventTarget} on 672 */ 673 674 /** 675 * @typedef {Object} HtmxNodeInternalData 676 * Element data 677 * @property {number} [initHash] 678 * @property {boolean} [boosted] 679 * @property {OnHandler[]} [onHandlers] 680 * @property {number} [timeout] 681 * @property {ListenerInfo[]} [listenerInfos] 682 * @property {boolean} [cancelled] 683 * @property {boolean} [triggeredOnce] 684 * @property {number} [delayed] 685 * @property {number|null} [throttle] 686 * @property {WeakMap<HtmxTriggerSpecification,WeakMap<EventTarget,string>>} [lastValue] 687 * @property {boolean} [loaded] 688 * @property {string} [path] 689 * @property {string} [verb] 690 * @property {boolean} [polling] 691 * @property {HTMLButtonElement|HTMLInputElement|null} [lastButtonClicked] 692 * @property {number} [requestCount] 693 * @property {XMLHttpRequest} [xhr] 694 * @property {(() => void)[]} [queuedRequests] 695 * @property {boolean} [abortable] 696 * @property {boolean} [firstInitCompleted] 697 * 698 * Event data 699 * @property {HtmxTriggerSpecification} [triggerSpec] 700 * @property {EventTarget[]} [handledFor] 701 */ 702 703 /** 704 * getInternalData retrieves "private" data stored by htmx within an element 705 * @param {EventTarget|Event} elt 706 * @returns {HtmxNodeInternalData} 707 */ 708 function getInternalData(elt) { 709 const dataProp = 'htmx-internal-data' 710 let data = elt[dataProp] 711 if (!data) { 712 data = elt[dataProp] = {} 713 } 714 return data 715 } 716 717 /** 718 * toArray converts an ArrayLike object into a real array. 719 * @template T 720 * @param {ArrayLike<T>} arr 721 * @returns {T[]} 722 */ 723 function toArray(arr) { 724 const returnArr = [] 725 if (arr) { 726 for (let i = 0; i < arr.length; i++) { 727 returnArr.push(arr[i]) 728 } 729 } 730 return returnArr 731 } 732 733 /** 734 * @template T 735 * @param {T[]|NamedNodeMap|HTMLCollection|HTMLFormControlsCollection|ArrayLike<T>} arr 736 * @param {(T) => void} func 737 */ 738 function forEach(arr, func) { 739 if (arr) { 740 for (let i = 0; i < arr.length; i++) { 741 func(arr[i]) 742 } 743 } 744 } 745 746 /** 747 * @param {Element} el 748 * @returns {boolean} 749 */ 750 function isScrolledIntoView(el) { 751 const rect = el.getBoundingClientRect() 752 const elemTop = rect.top 753 const elemBottom = rect.bottom 754 return elemTop < window.innerHeight && elemBottom >= 0 755 } 756 757 /** 758 * Checks whether the element is in the document (includes shadow roots). 759 * This function this is a slight misnomer; it will return true even for elements in the head. 760 * 761 * @param {Node} elt 762 * @returns {boolean} 763 */ 764 function bodyContains(elt) { 765 return elt.getRootNode({ composed: true }) === document 766 } 767 768 /** 769 * @param {string} trigger 770 * @returns {string[]} 771 */ 772 function splitOnWhitespace(trigger) { 773 return trigger.trim().split(/\s+/) 774 } 775 776 /** 777 * mergeObjects takes all the keys from 778 * obj2 and duplicates them into obj1 779 * @template T1 780 * @template T2 781 * @param {T1} obj1 782 * @param {T2} obj2 783 * @returns {T1 & T2} 784 */ 785 function mergeObjects(obj1, obj2) { 786 for (const key in obj2) { 787 if (obj2.hasOwnProperty(key)) { 788 // @ts-ignore tsc doesn't seem to properly handle types merging 789 obj1[key] = obj2[key] 790 } 791 } 792 // @ts-ignore tsc doesn't seem to properly handle types merging 793 return obj1 794 } 795 796 /** 797 * @param {string} jString 798 * @returns {any|null} 799 */ 800 function parseJSON(jString) { 801 try { 802 return JSON.parse(jString) 803 } catch (error) { 804 logError(error) 805 return null 806 } 807 } 808 809 /** 810 * @returns {boolean} 811 */ 812 function canAccessLocalStorage() { 813 const test = 'htmx:localStorageTest' 814 try { 815 localStorage.setItem(test, test) 816 localStorage.removeItem(test) 817 return true 818 } catch (e) { 819 return false 820 } 821 } 822 823 /** 824 * @param {string} path 825 * @returns {string} 826 */ 827 function normalizePath(path) { 828 try { 829 const url = new URL(path) 830 if (url) { 831 path = url.pathname + url.search 832 } 833 // remove trailing slash, unless index page 834 if (!(/^\/$/.test(path))) { 835 path = path.replace(/\/+$/, '') 836 } 837 return path 838 } catch (e) { 839 // be kind to IE11, which doesn't support URL() 840 return path 841 } 842 } 843 844 //= ========================================================================================= 845 // public API 846 //= ========================================================================================= 847 848 /** 849 * @param {string} str 850 * @returns {any} 851 */ 852 function internalEval(str) { 853 return maybeEval(getDocument().body, function() { 854 return eval(str) 855 }) 856 } 857 858 /** 859 * Adds a callback for the **htmx:load** event. This can be used to process new content, for example initializing the content with a javascript library 860 * 861 * @see https://htmx.org/api/#onLoad 862 * 863 * @param {(elt: Node) => void} callback the callback to call on newly loaded content 864 * @returns {EventListener} 865 */ 866 function onLoadHelper(callback) { 867 const value = htmx.on('htmx:load', /** @param {CustomEvent} evt */ function(evt) { 868 callback(evt.detail.elt) 869 }) 870 return value 871 } 872 873 /** 874 * Log all htmx events, useful for debugging. 875 * 876 * @see https://htmx.org/api/#logAll 877 */ 878 function logAll() { 879 htmx.logger = function(elt, event, data) { 880 if (console) { 881 console.log(event, elt, data) 882 } 883 } 884 } 885 886 function logNone() { 887 htmx.logger = null 888 } 889 890 /** 891 * Finds an element matching the selector 892 * 893 * @see https://htmx.org/api/#find 894 * 895 * @param {ParentNode|string} eltOrSelector the root element to find the matching element in, inclusive | the selector to match 896 * @param {string} [selector] the selector to match 897 * @returns {Element|null} 898 */ 899 function find(eltOrSelector, selector) { 900 if (typeof eltOrSelector !== 'string') { 901 return eltOrSelector.querySelector(selector) 902 } else { 903 return find(getDocument(), eltOrSelector) 904 } 905 } 906 907 /** 908 * Finds all elements matching the selector 909 * 910 * @see https://htmx.org/api/#findAll 911 * 912 * @param {ParentNode|string} eltOrSelector the root element to find the matching elements in, inclusive | the selector to match 913 * @param {string} [selector] the selector to match 914 * @returns {NodeListOf<Element>} 915 */ 916 function findAll(eltOrSelector, selector) { 917 if (typeof eltOrSelector !== 'string') { 918 return eltOrSelector.querySelectorAll(selector) 919 } else { 920 return findAll(getDocument(), eltOrSelector) 921 } 922 } 923 924 /** 925 * @returns Window 926 */ 927 function getWindow() { 928 return window 929 } 930 931 /** 932 * Removes an element from the DOM 933 * 934 * @see https://htmx.org/api/#remove 935 * 936 * @param {Node} elt 937 * @param {number} [delay] 938 */ 939 function removeElement(elt, delay) { 940 elt = resolveTarget(elt) 941 if (delay) { 942 getWindow().setTimeout(function() { 943 removeElement(elt) 944 elt = null 945 }, delay) 946 } else { 947 parentElt(elt).removeChild(elt) 948 } 949 } 950 951 /** 952 * @param {any} elt 953 * @return {Element|null} 954 */ 955 function asElement(elt) { 956 return elt instanceof Element ? elt : null 957 } 958 959 /** 960 * @param {any} elt 961 * @return {HTMLElement|null} 962 */ 963 function asHtmlElement(elt) { 964 return elt instanceof HTMLElement ? elt : null 965 } 966 967 /** 968 * @param {any} value 969 * @return {string|null} 970 */ 971 function asString(value) { 972 return typeof value === 'string' ? value : null 973 } 974 975 /** 976 * @param {EventTarget} elt 977 * @return {ParentNode|null} 978 */ 979 function asParentNode(elt) { 980 return elt instanceof Element || elt instanceof Document || elt instanceof DocumentFragment ? elt : null 981 } 982 983 /** 984 * This method adds a class to the given element. 985 * 986 * @see https://htmx.org/api/#addClass 987 * 988 * @param {Element|string} elt the element to add the class to 989 * @param {string} clazz the class to add 990 * @param {number} [delay] the delay (in milliseconds) before class is added 991 */ 992 function addClassToElement(elt, clazz, delay) { 993 elt = asElement(resolveTarget(elt)) 994 if (!elt) { 995 return 996 } 997 if (delay) { 998 getWindow().setTimeout(function() { 999 addClassToElement(elt, clazz) 1000 elt = null 1001 }, delay) 1002 } else { 1003 elt.classList && elt.classList.add(clazz) 1004 } 1005 } 1006 1007 /** 1008 * Removes a class from the given element 1009 * 1010 * @see https://htmx.org/api/#removeClass 1011 * 1012 * @param {Node|string} node element to remove the class from 1013 * @param {string} clazz the class to remove 1014 * @param {number} [delay] the delay (in milliseconds before class is removed) 1015 */ 1016 function removeClassFromElement(node, clazz, delay) { 1017 let elt = asElement(resolveTarget(node)) 1018 if (!elt) { 1019 return 1020 } 1021 if (delay) { 1022 getWindow().setTimeout(function() { 1023 removeClassFromElement(elt, clazz) 1024 elt = null 1025 }, delay) 1026 } else { 1027 if (elt.classList) { 1028 elt.classList.remove(clazz) 1029 // if there are no classes left, remove the class attribute 1030 if (elt.classList.length === 0) { 1031 elt.removeAttribute('class') 1032 } 1033 } 1034 } 1035 } 1036 1037 /** 1038 * Toggles the given class on an element 1039 * 1040 * @see https://htmx.org/api/#toggleClass 1041 * 1042 * @param {Element|string} elt the element to toggle the class on 1043 * @param {string} clazz the class to toggle 1044 */ 1045 function toggleClassOnElement(elt, clazz) { 1046 elt = resolveTarget(elt) 1047 elt.classList.toggle(clazz) 1048 } 1049 1050 /** 1051 * Takes the given class from its siblings, so that among its siblings, only the given element will have the class. 1052 * 1053 * @see https://htmx.org/api/#takeClass 1054 * 1055 * @param {Node|string} elt the element that will take the class 1056 * @param {string} clazz the class to take 1057 */ 1058 function takeClassForElement(elt, clazz) { 1059 elt = resolveTarget(elt) 1060 forEach(elt.parentElement.children, function(child) { 1061 removeClassFromElement(child, clazz) 1062 }) 1063 addClassToElement(asElement(elt), clazz) 1064 } 1065 1066 /** 1067 * Finds the closest matching element in the given elements parentage, inclusive of the element 1068 * 1069 * @see https://htmx.org/api/#closest 1070 * 1071 * @param {Element|string} elt the element to find the selector from 1072 * @param {string} selector the selector to find 1073 * @returns {Element|null} 1074 */ 1075 function closest(elt, selector) { 1076 elt = asElement(resolveTarget(elt)) 1077 if (elt && elt.closest) { 1078 return elt.closest(selector) 1079 } else { 1080 // TODO remove when IE goes away 1081 do { 1082 if (elt == null || matches(elt, selector)) { 1083 return elt 1084 } 1085 } 1086 while (elt = elt && asElement(parentElt(elt))) 1087 return null 1088 } 1089 } 1090 1091 /** 1092 * @param {string} str 1093 * @param {string} prefix 1094 * @returns {boolean} 1095 */ 1096 function startsWith(str, prefix) { 1097 return str.substring(0, prefix.length) === prefix 1098 } 1099 1100 /** 1101 * @param {string} str 1102 * @param {string} suffix 1103 * @returns {boolean} 1104 */ 1105 function endsWith(str, suffix) { 1106 return str.substring(str.length - suffix.length) === suffix 1107 } 1108 1109 /** 1110 * @param {string} selector 1111 * @returns {string} 1112 */ 1113 function normalizeSelector(selector) { 1114 const trimmedSelector = selector.trim() 1115 if (startsWith(trimmedSelector, '<') && endsWith(trimmedSelector, '/>')) { 1116 return trimmedSelector.substring(1, trimmedSelector.length - 2) 1117 } else { 1118 return trimmedSelector 1119 } 1120 } 1121 1122 /** 1123 * @param {Node|Element|Document|string} elt 1124 * @param {string} selector 1125 * @param {boolean=} global 1126 * @returns {(Node|Window)[]} 1127 */ 1128 function querySelectorAllExt(elt, selector, global) { 1129 if (selector.indexOf('global ') === 0) { 1130 return querySelectorAllExt(elt, selector.slice(7), true) 1131 } 1132 1133 elt = resolveTarget(elt) 1134 1135 const parts = [] 1136 { 1137 let chevronsCount = 0 1138 let offset = 0 1139 for (let i = 0; i < selector.length; i++) { 1140 const char = selector[i] 1141 if (char === ',' && chevronsCount === 0) { 1142 parts.push(selector.substring(offset, i)) 1143 offset = i + 1 1144 continue 1145 } 1146 if (char === '<') { 1147 chevronsCount++ 1148 } else if (char === '/' && i < selector.length - 1 && selector[i + 1] === '>') { 1149 chevronsCount-- 1150 } 1151 } 1152 if (offset < selector.length) { 1153 parts.push(selector.substring(offset)) 1154 } 1155 } 1156 1157 const result = [] 1158 const unprocessedParts = [] 1159 while (parts.length > 0) { 1160 const selector = normalizeSelector(parts.shift()) 1161 let item 1162 if (selector.indexOf('closest ') === 0) { 1163 item = closest(asElement(elt), normalizeSelector(selector.substr(8))) 1164 } else if (selector.indexOf('find ') === 0) { 1165 item = find(asParentNode(elt), normalizeSelector(selector.substr(5))) 1166 } else if (selector === 'next' || selector === 'nextElementSibling') { 1167 item = asElement(elt).nextElementSibling 1168 } else if (selector.indexOf('next ') === 0) { 1169 item = scanForwardQuery(elt, normalizeSelector(selector.substr(5)), !!global) 1170 } else if (selector === 'previous' || selector === 'previousElementSibling') { 1171 item = asElement(elt).previousElementSibling 1172 } else if (selector.indexOf('previous ') === 0) { 1173 item = scanBackwardsQuery(elt, normalizeSelector(selector.substr(9)), !!global) 1174 } else if (selector === 'document') { 1175 item = document 1176 } else if (selector === 'window') { 1177 item = window 1178 } else if (selector === 'body') { 1179 item = document.body 1180 } else if (selector === 'root') { 1181 item = getRootNode(elt, !!global) 1182 } else if (selector === 'host') { 1183 item = (/** @type ShadowRoot */(elt.getRootNode())).host 1184 } else { 1185 unprocessedParts.push(selector) 1186 } 1187 1188 if (item) { 1189 result.push(item) 1190 } 1191 } 1192 1193 if (unprocessedParts.length > 0) { 1194 const standardSelector = unprocessedParts.join(',') 1195 const rootNode = asParentNode(getRootNode(elt, !!global)) 1196 result.push(...toArray(rootNode.querySelectorAll(standardSelector))) 1197 } 1198 1199 return result 1200 } 1201 1202 /** 1203 * @param {Node} start 1204 * @param {string} match 1205 * @param {boolean} global 1206 * @returns {Element} 1207 */ 1208 var scanForwardQuery = function(start, match, global) { 1209 const results = asParentNode(getRootNode(start, global)).querySelectorAll(match) 1210 for (let i = 0; i < results.length; i++) { 1211 const elt = results[i] 1212 if (elt.compareDocumentPosition(start) === Node.DOCUMENT_POSITION_PRECEDING) { 1213 return elt 1214 } 1215 } 1216 } 1217 1218 /** 1219 * @param {Node} start 1220 * @param {string} match 1221 * @param {boolean} global 1222 * @returns {Element} 1223 */ 1224 var scanBackwardsQuery = function(start, match, global) { 1225 const results = asParentNode(getRootNode(start, global)).querySelectorAll(match) 1226 for (let i = results.length - 1; i >= 0; i--) { 1227 const elt = results[i] 1228 if (elt.compareDocumentPosition(start) === Node.DOCUMENT_POSITION_FOLLOWING) { 1229 return elt 1230 } 1231 } 1232 } 1233 1234 /** 1235 * @param {Node|string} eltOrSelector 1236 * @param {string=} selector 1237 * @returns {Node|Window} 1238 */ 1239 function querySelectorExt(eltOrSelector, selector) { 1240 if (typeof eltOrSelector !== 'string') { 1241 return querySelectorAllExt(eltOrSelector, selector)[0] 1242 } else { 1243 return querySelectorAllExt(getDocument().body, eltOrSelector)[0] 1244 } 1245 } 1246 1247 /** 1248 * @template {EventTarget} T 1249 * @param {T|string} eltOrSelector 1250 * @param {T} [context] 1251 * @returns {Element|T|null} 1252 */ 1253 function resolveTarget(eltOrSelector, context) { 1254 if (typeof eltOrSelector === 'string') { 1255 return find(asParentNode(context) || document, eltOrSelector) 1256 } else { 1257 return eltOrSelector 1258 } 1259 } 1260 1261 /** 1262 * @typedef {keyof HTMLElementEventMap|string} AnyEventName 1263 */ 1264 1265 /** 1266 * @typedef {Object} EventArgs 1267 * @property {EventTarget} target 1268 * @property {AnyEventName} event 1269 * @property {EventListener} listener 1270 * @property {Object|boolean} options 1271 */ 1272 1273 /** 1274 * @param {EventTarget|AnyEventName} arg1 1275 * @param {AnyEventName|EventListener} arg2 1276 * @param {EventListener|Object|boolean} [arg3] 1277 * @param {Object|boolean} [arg4] 1278 * @returns {EventArgs} 1279 */ 1280 function processEventArgs(arg1, arg2, arg3, arg4) { 1281 if (isFunction(arg2)) { 1282 return { 1283 target: getDocument().body, 1284 event: asString(arg1), 1285 listener: arg2, 1286 options: arg3 1287 } 1288 } else { 1289 return { 1290 target: resolveTarget(arg1), 1291 event: asString(arg2), 1292 listener: arg3, 1293 options: arg4 1294 } 1295 } 1296 } 1297 1298 /** 1299 * Adds an event listener to an element 1300 * 1301 * @see https://htmx.org/api/#on 1302 * 1303 * @param {EventTarget|string} arg1 the element to add the listener to | the event name to add the listener for 1304 * @param {string|EventListener} arg2 the event name to add the listener for | the listener to add 1305 * @param {EventListener|Object|boolean} [arg3] the listener to add | options to add 1306 * @param {Object|boolean} [arg4] options to add 1307 * @returns {EventListener} 1308 */ 1309 function addEventListenerImpl(arg1, arg2, arg3, arg4) { 1310 ready(function() { 1311 const eventArgs = processEventArgs(arg1, arg2, arg3, arg4) 1312 eventArgs.target.addEventListener(eventArgs.event, eventArgs.listener, eventArgs.options) 1313 }) 1314 const b = isFunction(arg2) 1315 return b ? arg2 : arg3 1316 } 1317 1318 /** 1319 * Removes an event listener from an element 1320 * 1321 * @see https://htmx.org/api/#off 1322 * 1323 * @param {EventTarget|string} arg1 the element to remove the listener from | the event name to remove the listener from 1324 * @param {string|EventListener} arg2 the event name to remove the listener from | the listener to remove 1325 * @param {EventListener} [arg3] the listener to remove 1326 * @returns {EventListener} 1327 */ 1328 function removeEventListenerImpl(arg1, arg2, arg3) { 1329 ready(function() { 1330 const eventArgs = processEventArgs(arg1, arg2, arg3) 1331 eventArgs.target.removeEventListener(eventArgs.event, eventArgs.listener) 1332 }) 1333 return isFunction(arg2) ? arg2 : arg3 1334 } 1335 1336 //= =================================================================== 1337 // Node processing 1338 //= =================================================================== 1339 1340 const DUMMY_ELT = getDocument().createElement('output') // dummy element for bad selectors 1341 /** 1342 * @param {Element} elt 1343 * @param {string} attrName 1344 * @returns {(Node|Window)[]} 1345 */ 1346 function findAttributeTargets(elt, attrName) { 1347 const attrTarget = getClosestAttributeValue(elt, attrName) 1348 if (attrTarget) { 1349 if (attrTarget === 'this') { 1350 return [findThisElement(elt, attrName)] 1351 } else { 1352 const result = querySelectorAllExt(elt, attrTarget) 1353 if (result.length === 0) { 1354 logError('The selector "' + attrTarget + '" on ' + attrName + ' returned no matches!') 1355 return [DUMMY_ELT] 1356 } else { 1357 return result 1358 } 1359 } 1360 } 1361 } 1362 1363 /** 1364 * @param {Element} elt 1365 * @param {string} attribute 1366 * @returns {Element|null} 1367 */ 1368 function findThisElement(elt, attribute) { 1369 return asElement(getClosestMatch(elt, function(elt) { 1370 return getAttributeValue(asElement(elt), attribute) != null 1371 })) 1372 } 1373 1374 /** 1375 * @param {Element} elt 1376 * @returns {Node|Window|null} 1377 */ 1378 function getTarget(elt) { 1379 const targetStr = getClosestAttributeValue(elt, 'hx-target') 1380 if (targetStr) { 1381 if (targetStr === 'this') { 1382 return findThisElement(elt, 'hx-target') 1383 } else { 1384 return querySelectorExt(elt, targetStr) 1385 } 1386 } else { 1387 const data = getInternalData(elt) 1388 if (data.boosted) { 1389 return getDocument().body 1390 } else { 1391 return elt 1392 } 1393 } 1394 } 1395 1396 /** 1397 * @param {string} name 1398 * @returns {boolean} 1399 */ 1400 function shouldSettleAttribute(name) { 1401 const attributesToSettle = htmx.config.attributesToSettle 1402 for (let i = 0; i < attributesToSettle.length; i++) { 1403 if (name === attributesToSettle[i]) { 1404 return true 1405 } 1406 } 1407 return false 1408 } 1409 1410 /** 1411 * @param {Element} mergeTo 1412 * @param {Element} mergeFrom 1413 */ 1414 function cloneAttributes(mergeTo, mergeFrom) { 1415 forEach(mergeTo.attributes, function(attr) { 1416 if (!mergeFrom.hasAttribute(attr.name) && shouldSettleAttribute(attr.name)) { 1417 mergeTo.removeAttribute(attr.name) 1418 } 1419 }) 1420 forEach(mergeFrom.attributes, function(attr) { 1421 if (shouldSettleAttribute(attr.name)) { 1422 mergeTo.setAttribute(attr.name, attr.value) 1423 } 1424 }) 1425 } 1426 1427 /** 1428 * @param {HtmxSwapStyle} swapStyle 1429 * @param {Element} target 1430 * @returns {boolean} 1431 */ 1432 function isInlineSwap(swapStyle, target) { 1433 const extensions = getExtensions(target) 1434 for (let i = 0; i < extensions.length; i++) { 1435 const extension = extensions[i] 1436 try { 1437 if (extension.isInlineSwap(swapStyle)) { 1438 return true 1439 } 1440 } catch (e) { 1441 logError(e) 1442 } 1443 } 1444 return swapStyle === 'outerHTML' 1445 } 1446 1447 /** 1448 * @param {string} oobValue 1449 * @param {Element} oobElement 1450 * @param {HtmxSettleInfo} settleInfo 1451 * @param {Node|Document} [rootNode] 1452 * @returns 1453 */ 1454 function oobSwap(oobValue, oobElement, settleInfo, rootNode) { 1455 rootNode = rootNode || getDocument() 1456 let selector = '#' + getRawAttribute(oobElement, 'id') 1457 /** @type HtmxSwapStyle */ 1458 let swapStyle = 'outerHTML' 1459 if (oobValue === 'true') { 1460 // do nothing 1461 } else if (oobValue.indexOf(':') > 0) { 1462 swapStyle = oobValue.substring(0, oobValue.indexOf(':')) 1463 selector = oobValue.substring(oobValue.indexOf(':') + 1) 1464 } else { 1465 swapStyle = oobValue 1466 } 1467 oobElement.removeAttribute('hx-swap-oob') 1468 oobElement.removeAttribute('data-hx-swap-oob') 1469 1470 const targets = querySelectorAllExt(rootNode, selector, false) 1471 if (targets) { 1472 forEach( 1473 targets, 1474 function(target) { 1475 let fragment 1476 const oobElementClone = oobElement.cloneNode( true) 1477 fragment = getDocument().createDocumentFragment() 1478 fragment.appendChild(oobElementClone) 1479 if (!isInlineSwap(swapStyle, target)) { 1480 fragment = asParentNode(oobElementClone) // if this is not an inline swap, we use the content of the node, not the node itself 1481 } 1482 1483 const beforeSwapDetails = { shouldSwap: true, target, fragment } 1484 if (!triggerEvent(target, 'htmx:oobBeforeSwap', beforeSwapDetails)) return 1485 1486 target = beforeSwapDetails.target // allow re-targeting 1487 if (beforeSwapDetails.shouldSwap) { 1488 handlePreservedElements(fragment) 1489 swapWithStyle(swapStyle, target, target, fragment, settleInfo) 1490 restorePreservedElements() 1491 } 1492 forEach(settleInfo.elts, function(elt) { 1493 triggerEvent(elt, 'htmx:oobAfterSwap', beforeSwapDetails) 1494 }) 1495 } 1496 ) 1497 oobElement.parentNode.removeChild(oobElement) 1498 } else { 1499 oobElement.parentNode.removeChild(oobElement) 1500 triggerErrorEvent(getDocument().body, 'htmx:oobErrorNoTarget', { content: oobElement }) 1501 } 1502 return oobValue 1503 } 1504 1505 function restorePreservedElements() { 1506 const pantry = find('#--htmx-preserve-pantry--') 1507 if (pantry) { 1508 for (const preservedElt of [...pantry.children]) { 1509 const existingElement = find('#' + preservedElt.id) 1510 // @ts-ignore - use proposed moveBefore feature 1511 existingElement.parentNode.moveBefore(preservedElt, existingElement) 1512 existingElement.remove() 1513 } 1514 pantry.remove() 1515 } 1516 } 1517 1518 /** 1519 * @param {DocumentFragment|ParentNode} fragment 1520 */ 1521 function handlePreservedElements(fragment) { 1522 forEach(findAll(fragment, '[hx-preserve], [data-hx-preserve]'), function(preservedElt) { 1523 const id = getAttributeValue(preservedElt, 'id') 1524 const existingElement = getDocument().getElementById(id) 1525 if (existingElement != null) { 1526 if (preservedElt.moveBefore) { // if the moveBefore API exists, use it 1527 // get or create a storage spot for stuff 1528 let pantry = find('#--htmx-preserve-pantry--') 1529 if (pantry == null) { 1530 getDocument().body.insertAdjacentHTML('afterend', "<div id='--htmx-preserve-pantry--'></div>") 1531 pantry = find('#--htmx-preserve-pantry--') 1532 } 1533 // @ts-ignore - use proposed moveBefore feature 1534 pantry.moveBefore(existingElement, null) 1535 } else { 1536 preservedElt.parentNode.replaceChild(existingElement, preservedElt) 1537 } 1538 } 1539 }) 1540 } 1541 1542 /** 1543 * @param {Node} parentNode 1544 * @param {ParentNode} fragment 1545 * @param {HtmxSettleInfo} settleInfo 1546 */ 1547 function handleAttributes(parentNode, fragment, settleInfo) { 1548 forEach(fragment.querySelectorAll('[id]'), function(newNode) { 1549 const id = getRawAttribute(newNode, 'id') 1550 if (id && id.length > 0) { 1551 const normalizedId = id.replace("'", "\\'") 1552 const normalizedTag = newNode.tagName.replace(':', '\\:') 1553 const parentElt = asParentNode(parentNode) 1554 const oldNode = parentElt && parentElt.querySelector(normalizedTag + "[id='" + normalizedId + "']") 1555 if (oldNode && oldNode !== parentElt) { 1556 const newAttributes = newNode.cloneNode() 1557 cloneAttributes(newNode, oldNode) 1558 settleInfo.tasks.push(function() { 1559 cloneAttributes(newNode, newAttributes) 1560 }) 1561 } 1562 } 1563 }) 1564 } 1565 1566 /** 1567 * @param {Node} child 1568 * @returns {HtmxSettleTask} 1569 */ 1570 function makeAjaxLoadTask(child) { 1571 return function() { 1572 removeClassFromElement(child, htmx.config.addedClass) 1573 processNode(asElement(child)) 1574 processFocus(asParentNode(child)) 1575 triggerEvent(child, 'htmx:load') 1576 } 1577 } 1578 1579 /** 1580 * @param {ParentNode} child 1581 */ 1582 function processFocus(child) { 1583 const autofocus = '[autofocus]' 1584 const autoFocusedElt = asHtmlElement(matches(child, autofocus) ? child : child.querySelector(autofocus)) 1585 if (autoFocusedElt != null) { 1586 autoFocusedElt.focus() 1587 } 1588 } 1589 1590 /** 1591 * @param {Node} parentNode 1592 * @param {Node} insertBefore 1593 * @param {ParentNode} fragment 1594 * @param {HtmxSettleInfo} settleInfo 1595 */ 1596 function insertNodesBefore(parentNode, insertBefore, fragment, settleInfo) { 1597 handleAttributes(parentNode, fragment, settleInfo) 1598 while (fragment.childNodes.length > 0) { 1599 const child = fragment.firstChild 1600 addClassToElement(asElement(child), htmx.config.addedClass) 1601 parentNode.insertBefore(child, insertBefore) 1602 if (child.nodeType !== Node.TEXT_NODE && child.nodeType !== Node.COMMENT_NODE) { 1603 settleInfo.tasks.push(makeAjaxLoadTask(child)) 1604 } 1605 } 1606 } 1607 1608 /** 1609 * based on https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0, 1610 * derived from Java's string hashcode implementation 1611 * @param {string} string 1612 * @param {number} hash 1613 * @returns {number} 1614 */ 1615 function stringHash(string, hash) { 1616 let char = 0 1617 while (char < string.length) { 1618 hash = (hash << 5) - hash + string.charCodeAt(char++) | 0 // bitwise or ensures we have a 32-bit int 1619 } 1620 return hash 1621 } 1622 1623 /** 1624 * @param {Element} elt 1625 * @returns {number} 1626 */ 1627 function attributeHash(elt) { 1628 let hash = 0 1629 // IE fix 1630 if (elt.attributes) { 1631 for (let i = 0; i < elt.attributes.length; i++) { 1632 const attribute = elt.attributes[i] 1633 if (attribute.value) { // only include attributes w/ actual values (empty is same as non-existent) 1634 hash = stringHash(attribute.name, hash) 1635 hash = stringHash(attribute.value, hash) 1636 } 1637 } 1638 } 1639 return hash 1640 } 1641 1642 /** 1643 * @param {EventTarget} elt 1644 */ 1645 function deInitOnHandlers(elt) { 1646 const internalData = getInternalData(elt) 1647 if (internalData.onHandlers) { 1648 for (let i = 0; i < internalData.onHandlers.length; i++) { 1649 const handlerInfo = internalData.onHandlers[i] 1650 removeEventListenerImpl(elt, handlerInfo.event, handlerInfo.listener) 1651 } 1652 delete internalData.onHandlers 1653 } 1654 } 1655 1656 /** 1657 * @param {Node} element 1658 */ 1659 function deInitNode(element) { 1660 const internalData = getInternalData(element) 1661 if (internalData.timeout) { 1662 clearTimeout(internalData.timeout) 1663 } 1664 if (internalData.listenerInfos) { 1665 forEach(internalData.listenerInfos, function(info) { 1666 if (info.on) { 1667 removeEventListenerImpl(info.on, info.trigger, info.listener) 1668 } 1669 }) 1670 } 1671 deInitOnHandlers(element) 1672 forEach(Object.keys(internalData), function(key) { if (key !== 'firstInitCompleted') delete internalData[key] }) 1673 } 1674 1675 /** 1676 * @param {Node} element 1677 */ 1678 function cleanUpElement(element) { 1679 triggerEvent(element, 'htmx:beforeCleanupElement') 1680 deInitNode(element) 1681 // @ts-ignore IE11 code 1682 // noinspection JSUnresolvedReference 1683 if (element.children) { // IE 1684 // @ts-ignore 1685 forEach(element.children, function(child) { cleanUpElement(child) }) 1686 } 1687 } 1688 1689 /** 1690 * @param {Node} target 1691 * @param {ParentNode} fragment 1692 * @param {HtmxSettleInfo} settleInfo 1693 */ 1694 function swapOuterHTML(target, fragment, settleInfo) { 1695 if (target instanceof Element && target.tagName === 'BODY') { // special case the body to innerHTML because DocumentFragments can't contain a body elt unfortunately 1696 return swapInnerHTML(target, fragment, settleInfo) 1697 } 1698 /** @type {Node} */ 1699 let newElt 1700 const eltBeforeNewContent = target.previousSibling 1701 const parentNode = parentElt(target) 1702 if (!parentNode) { // when parent node disappears, we can't do anything 1703 return 1704 } 1705 insertNodesBefore(parentNode, target, fragment, settleInfo) 1706 if (eltBeforeNewContent == null) { 1707 newElt = parentNode.firstChild 1708 } else { 1709 newElt = eltBeforeNewContent.nextSibling 1710 } 1711 settleInfo.elts = settleInfo.elts.filter(function(e) { return e !== target }) 1712 // scan through all newly added content and add all elements to the settle info so we trigger 1713 // events properly on them 1714 while (newElt && newElt !== target) { 1715 if (newElt instanceof Element) { 1716 settleInfo.elts.push(newElt) 1717 } 1718 newElt = newElt.nextSibling 1719 } 1720 cleanUpElement(target) 1721 if (target instanceof Element) { 1722 target.remove() 1723 } else { 1724 target.parentNode.removeChild(target) 1725 } 1726 } 1727 1728 /** 1729 * @param {Node} target 1730 * @param {ParentNode} fragment 1731 * @param {HtmxSettleInfo} settleInfo 1732 */ 1733 function swapAfterBegin(target, fragment, settleInfo) { 1734 return insertNodesBefore(target, target.firstChild, fragment, settleInfo) 1735 } 1736 1737 /** 1738 * @param {Node} target 1739 * @param {ParentNode} fragment 1740 * @param {HtmxSettleInfo} settleInfo 1741 */ 1742 function swapBeforeBegin(target, fragment, settleInfo) { 1743 return insertNodesBefore(parentElt(target), target, fragment, settleInfo) 1744 } 1745 1746 /** 1747 * @param {Node} target 1748 * @param {ParentNode} fragment 1749 * @param {HtmxSettleInfo} settleInfo 1750 */ 1751 function swapBeforeEnd(target, fragment, settleInfo) { 1752 return insertNodesBefore(target, null, fragment, settleInfo) 1753 } 1754 1755 /** 1756 * @param {Node} target 1757 * @param {ParentNode} fragment 1758 * @param {HtmxSettleInfo} settleInfo 1759 */ 1760 function swapAfterEnd(target, fragment, settleInfo) { 1761 return insertNodesBefore(parentElt(target), target.nextSibling, fragment, settleInfo) 1762 } 1763 1764 /** 1765 * @param {Node} target 1766 */ 1767 function swapDelete(target) { 1768 cleanUpElement(target) 1769 const parent = parentElt(target) 1770 if (parent) { 1771 return parent.removeChild(target) 1772 } 1773 } 1774 1775 /** 1776 * @param {Node} target 1777 * @param {ParentNode} fragment 1778 * @param {HtmxSettleInfo} settleInfo 1779 */ 1780 function swapInnerHTML(target, fragment, settleInfo) { 1781 const firstChild = target.firstChild 1782 insertNodesBefore(target, firstChild, fragment, settleInfo) 1783 if (firstChild) { 1784 while (firstChild.nextSibling) { 1785 cleanUpElement(firstChild.nextSibling) 1786 target.removeChild(firstChild.nextSibling) 1787 } 1788 cleanUpElement(firstChild) 1789 target.removeChild(firstChild) 1790 } 1791 } 1792 1793 /** 1794 * @param {HtmxSwapStyle} swapStyle 1795 * @param {Element} elt 1796 * @param {Node} target 1797 * @param {ParentNode} fragment 1798 * @param {HtmxSettleInfo} settleInfo 1799 */ 1800 function swapWithStyle(swapStyle, elt, target, fragment, settleInfo) { 1801 switch (swapStyle) { 1802 case 'none': 1803 return 1804 case 'outerHTML': 1805 swapOuterHTML(target, fragment, settleInfo) 1806 return 1807 case 'afterbegin': 1808 swapAfterBegin(target, fragment, settleInfo) 1809 return 1810 case 'beforebegin': 1811 swapBeforeBegin(target, fragment, settleInfo) 1812 return 1813 case 'beforeend': 1814 swapBeforeEnd(target, fragment, settleInfo) 1815 return 1816 case 'afterend': 1817 swapAfterEnd(target, fragment, settleInfo) 1818 return 1819 case 'delete': 1820 swapDelete(target) 1821 return 1822 default: 1823 var extensions = getExtensions(elt) 1824 for (let i = 0; i < extensions.length; i++) { 1825 const ext = extensions[i] 1826 try { 1827 const newElements = ext.handleSwap(swapStyle, target, fragment, settleInfo) 1828 if (newElements) { 1829 if (Array.isArray(newElements)) { 1830 // if handleSwap returns an array (like) of elements, we handle them 1831 for (let j = 0; j < newElements.length; j++) { 1832 const child = newElements[j] 1833 if (child.nodeType !== Node.TEXT_NODE && child.nodeType !== Node.COMMENT_NODE) { 1834 settleInfo.tasks.push(makeAjaxLoadTask(child)) 1835 } 1836 } 1837 } 1838 return 1839 } 1840 } catch (e) { 1841 logError(e) 1842 } 1843 } 1844 if (swapStyle === 'innerHTML') { 1845 swapInnerHTML(target, fragment, settleInfo) 1846 } else { 1847 swapWithStyle(htmx.config.defaultSwapStyle, elt, target, fragment, settleInfo) 1848 } 1849 } 1850 } 1851 1852 /** 1853 * @param {DocumentFragment} fragment 1854 * @param {HtmxSettleInfo} settleInfo 1855 * @param {Node|Document} [rootNode] 1856 */ 1857 function findAndSwapOobElements(fragment, settleInfo, rootNode) { 1858 var oobElts = findAll(fragment, '[hx-swap-oob], [data-hx-swap-oob]') 1859 forEach(oobElts, function(oobElement) { 1860 if (htmx.config.allowNestedOobSwaps || oobElement.parentElement === null) { 1861 const oobValue = getAttributeValue(oobElement, 'hx-swap-oob') 1862 if (oobValue != null) { 1863 oobSwap(oobValue, oobElement, settleInfo, rootNode) 1864 } 1865 } else { 1866 oobElement.removeAttribute('hx-swap-oob') 1867 oobElement.removeAttribute('data-hx-swap-oob') 1868 } 1869 }) 1870 return oobElts.length > 0 1871 } 1872 1873 /** 1874 * Implements complete swapping pipeline, including: focus and selection preservation, 1875 * title updates, scroll, OOB swapping, normal swapping and settling 1876 * @param {string|Element} target 1877 * @param {string} content 1878 * @param {HtmxSwapSpecification} swapSpec 1879 * @param {SwapOptions} [swapOptions] 1880 */ 1881 function swap(target, content, swapSpec, swapOptions) { 1882 if (!swapOptions) { 1883 swapOptions = {} 1884 } 1885 1886 target = resolveTarget(target) 1887 const rootNode = swapOptions.contextElement ? getRootNode(swapOptions.contextElement, false) : getDocument() 1888 1889 // preserve focus and selection 1890 const activeElt = document.activeElement 1891 let selectionInfo = {} 1892 try { 1893 selectionInfo = { 1894 elt: activeElt, 1895 // @ts-ignore 1896 start: activeElt ? activeElt.selectionStart : null, 1897 // @ts-ignore 1898 end: activeElt ? activeElt.selectionEnd : null 1899 } 1900 } catch (e) { 1901 // safari issue - see https://github.com/microsoft/playwright/issues/5894 1902 } 1903 const settleInfo = makeSettleInfo(target) 1904 1905 // For text content swaps, don't parse the response as HTML, just insert it 1906 if (swapSpec.swapStyle === 'textContent') { 1907 target.textContent = content 1908 // Otherwise, make the fragment and process it 1909 } else { 1910 let fragment = makeFragment(content) 1911 1912 settleInfo.title = fragment.title 1913 1914 // select-oob swaps 1915 if (swapOptions.selectOOB) { 1916 const oobSelectValues = swapOptions.selectOOB.split(',') 1917 for (let i = 0; i < oobSelectValues.length; i++) { 1918 const oobSelectValue = oobSelectValues[i].split(':', 2) 1919 let id = oobSelectValue[0].trim() 1920 if (id.indexOf('#') === 0) { 1921 id = id.substring(1) 1922 } 1923 const oobValue = oobSelectValue[1] || 'true' 1924 const oobElement = fragment.querySelector('#' + id) 1925 if (oobElement) { 1926 oobSwap(oobValue, oobElement, settleInfo, rootNode) 1927 } 1928 } 1929 } 1930 // oob swaps 1931 findAndSwapOobElements(fragment, settleInfo, rootNode) 1932 forEach(findAll(fragment, 'template'), /** @param {HTMLTemplateElement} template */function(template) { 1933 if (template.content && findAndSwapOobElements(template.content, settleInfo, rootNode)) { 1934 // Avoid polluting the DOM with empty templates that were only used to encapsulate oob swap 1935 template.remove() 1936 } 1937 }) 1938 1939 // normal swap 1940 if (swapOptions.select) { 1941 const newFragment = getDocument().createDocumentFragment() 1942 forEach(fragment.querySelectorAll(swapOptions.select), function(node) { 1943 newFragment.appendChild(node) 1944 }) 1945 fragment = newFragment 1946 } 1947 handlePreservedElements(fragment) 1948 swapWithStyle(swapSpec.swapStyle, swapOptions.contextElement, target, fragment, settleInfo) 1949 restorePreservedElements() 1950 } 1951 1952 // apply saved focus and selection information to swapped content 1953 if (selectionInfo.elt && 1954 !bodyContains(selectionInfo.elt) && 1955 getRawAttribute(selectionInfo.elt, 'id')) { 1956 const newActiveElt = document.getElementById(getRawAttribute(selectionInfo.elt, 'id')) 1957 const focusOptions = { preventScroll: swapSpec.focusScroll !== undefined ? !swapSpec.focusScroll : !htmx.config.defaultFocusScroll } 1958 if (newActiveElt) { 1959 // @ts-ignore 1960 if (selectionInfo.start && newActiveElt.setSelectionRange) { 1961 try { 1962 // @ts-ignore 1963 newActiveElt.setSelectionRange(selectionInfo.start, selectionInfo.end) 1964 } catch (e) { 1965 // the setSelectionRange method is present on fields that don't support it, so just let this fail 1966 } 1967 } 1968 newActiveElt.focus(focusOptions) 1969 } 1970 } 1971 1972 target.classList.remove(htmx.config.swappingClass) 1973 forEach(settleInfo.elts, function(elt) { 1974 if (elt.classList) { 1975 elt.classList.add(htmx.config.settlingClass) 1976 } 1977 triggerEvent(elt, 'htmx:afterSwap', swapOptions.eventInfo) 1978 }) 1979 if (swapOptions.afterSwapCallback) { 1980 swapOptions.afterSwapCallback() 1981 } 1982 1983 // merge in new title after swap but before settle 1984 if (!swapSpec.ignoreTitle) { 1985 handleTitle(settleInfo.title) 1986 } 1987 1988 // settle 1989 const doSettle = function() { 1990 forEach(settleInfo.tasks, function(task) { 1991 task.call() 1992 }) 1993 forEach(settleInfo.elts, function(elt) { 1994 if (elt.classList) { 1995 elt.classList.remove(htmx.config.settlingClass) 1996 } 1997 triggerEvent(elt, 'htmx:afterSettle', swapOptions.eventInfo) 1998 }) 1999 2000 if (swapOptions.anchor) { 2001 const anchorTarget = asElement(resolveTarget('#' + swapOptions.anchor)) 2002 if (anchorTarget) { 2003 anchorTarget.scrollIntoView({ block: 'start', behavior: 'auto' }) 2004 } 2005 } 2006 2007 updateScrollState(settleInfo.elts, swapSpec) 2008 if (swapOptions.afterSettleCallback) { 2009 swapOptions.afterSettleCallback() 2010 } 2011 } 2012 2013 if (swapSpec.settleDelay > 0) { 2014 getWindow().setTimeout(doSettle, swapSpec.settleDelay) 2015 } else { 2016 doSettle() 2017 } 2018 } 2019 2020 /** 2021 * @param {XMLHttpRequest} xhr 2022 * @param {string} header 2023 * @param {EventTarget} elt 2024 */ 2025 function handleTriggerHeader(xhr, header, elt) { 2026 const triggerBody = xhr.getResponseHeader(header) 2027 if (triggerBody.indexOf('{') === 0) { 2028 const triggers = parseJSON(triggerBody) 2029 for (const eventName in triggers) { 2030 if (triggers.hasOwnProperty(eventName)) { 2031 let detail = triggers[eventName] 2032 if (isRawObject(detail)) { 2033 // @ts-ignore 2034 elt = detail.target !== undefined ? detail.target : elt 2035 } else { 2036 detail = { value: detail } 2037 } 2038 triggerEvent(elt, eventName, detail) 2039 } 2040 } 2041 } else { 2042 const eventNames = triggerBody.split(',') 2043 for (let i = 0; i < eventNames.length; i++) { 2044 triggerEvent(elt, eventNames[i].trim(), []) 2045 } 2046 } 2047 } 2048 2049 const WHITESPACE = /\s/ 2050 const WHITESPACE_OR_COMMA = /[\s,]/ 2051 const SYMBOL_START = /[_$a-zA-Z]/ 2052 const SYMBOL_CONT = /[_$a-zA-Z0-9]/ 2053 const STRINGISH_START = ['"', "'", '/'] 2054 const NOT_WHITESPACE = /[^\s]/ 2055 const COMBINED_SELECTOR_START = /[{(]/ 2056 const COMBINED_SELECTOR_END = /[})]/ 2057 2058 /** 2059 * @param {string} str 2060 * @returns {string[]} 2061 */ 2062 function tokenizeString(str) { 2063 /** @type string[] */ 2064 const tokens = [] 2065 let position = 0 2066 while (position < str.length) { 2067 if (SYMBOL_START.exec(str.charAt(position))) { 2068 var startPosition = position 2069 while (SYMBOL_CONT.exec(str.charAt(position + 1))) { 2070 position++ 2071 } 2072 tokens.push(str.substring(startPosition, position + 1)) 2073 } else if (STRINGISH_START.indexOf(str.charAt(position)) !== -1) { 2074 const startChar = str.charAt(position) 2075 var startPosition = position 2076 position++ 2077 while (position < str.length && str.charAt(position) !== startChar) { 2078 if (str.charAt(position) === '\\') { 2079 position++ 2080 } 2081 position++ 2082 } 2083 tokens.push(str.substring(startPosition, position + 1)) 2084 } else { 2085 const symbol = str.charAt(position) 2086 tokens.push(symbol) 2087 } 2088 position++ 2089 } 2090 return tokens 2091 } 2092 2093 /** 2094 * @param {string} token 2095 * @param {string|null} last 2096 * @param {string} paramName 2097 * @returns {boolean} 2098 */ 2099 function isPossibleRelativeReference(token, last, paramName) { 2100 return SYMBOL_START.exec(token.charAt(0)) && 2101 token !== 'true' && 2102 token !== 'false' && 2103 token !== 'this' && 2104 token !== paramName && 2105 last !== '.' 2106 } 2107 2108 /** 2109 * @param {EventTarget|string} elt 2110 * @param {string[]} tokens 2111 * @param {string} paramName 2112 * @returns {ConditionalFunction|null} 2113 */ 2114 function maybeGenerateConditional(elt, tokens, paramName) { 2115 if (tokens[0] === '[') { 2116 tokens.shift() 2117 let bracketCount = 1 2118 let conditionalSource = ' return (function(' + paramName + '){ return (' 2119 let last = null 2120 while (tokens.length > 0) { 2121 const token = tokens[0] 2122 // @ts-ignore For some reason tsc doesn't understand the shift call, and thinks we're comparing the same value here, i.e. '[' vs ']' 2123 if (token === ']') { 2124 bracketCount-- 2125 if (bracketCount === 0) { 2126 if (last === null) { 2127 conditionalSource = conditionalSource + 'true' 2128 } 2129 tokens.shift() 2130 conditionalSource += ')})' 2131 try { 2132 const conditionFunction = maybeEval(elt, function() { 2133 return Function(conditionalSource)() 2134 }, 2135 function() { return true }) 2136 conditionFunction.source = conditionalSource 2137 return conditionFunction 2138 } catch (e) { 2139 triggerErrorEvent(getDocument().body, 'htmx:syntax:error', { error: e, source: conditionalSource }) 2140 return null 2141 } 2142 } 2143 } else if (token === '[') { 2144 bracketCount++ 2145 } 2146 if (isPossibleRelativeReference(token, last, paramName)) { 2147 conditionalSource += '((' + paramName + '.' + token + ') ? (' + paramName + '.' + token + ') : (window.' + token + '))' 2148 } else { 2149 conditionalSource = conditionalSource + token 2150 } 2151 last = tokens.shift() 2152 } 2153 } 2154 } 2155 2156 /** 2157 * @param {string[]} tokens 2158 * @param {RegExp} match 2159 * @returns {string} 2160 */ 2161 function consumeUntil(tokens, match) { 2162 let result = '' 2163 while (tokens.length > 0 && !match.test(tokens[0])) { 2164 result += tokens.shift() 2165 } 2166 return result 2167 } 2168 2169 /** 2170 * @param {string[]} tokens 2171 * @returns {string} 2172 */ 2173 function consumeCSSSelector(tokens) { 2174 let result 2175 if (tokens.length > 0 && COMBINED_SELECTOR_START.test(tokens[0])) { 2176 tokens.shift() 2177 result = consumeUntil(tokens, COMBINED_SELECTOR_END).trim() 2178 tokens.shift() 2179 } else { 2180 result = consumeUntil(tokens, WHITESPACE_OR_COMMA) 2181 } 2182 return result 2183 } 2184 2185 const INPUT_SELECTOR = 'input, textarea, select' 2186 2187 /** 2188 * @param {Element} elt 2189 * @param {string} explicitTrigger 2190 * @param {Object} cache for trigger specs 2191 * @returns {HtmxTriggerSpecification[]} 2192 */ 2193 function parseAndCacheTrigger(elt, explicitTrigger, cache) { 2194 /** @type HtmxTriggerSpecification[] */ 2195 const triggerSpecs = [] 2196 const tokens = tokenizeString(explicitTrigger) 2197 do { 2198 consumeUntil(tokens, NOT_WHITESPACE) 2199 const initialLength = tokens.length 2200 const trigger = consumeUntil(tokens, /[,\[\s]/) 2201 if (trigger !== '') { 2202 if (trigger === 'every') { 2203 /** @type HtmxTriggerSpecification */ 2204 const every = { trigger: 'every' } 2205 consumeUntil(tokens, NOT_WHITESPACE) 2206 every.pollInterval = parseInterval(consumeUntil(tokens, /[,\[\s]/)) 2207 consumeUntil(tokens, NOT_WHITESPACE) 2208 var eventFilter = maybeGenerateConditional(elt, tokens, 'event') 2209 if (eventFilter) { 2210 every.eventFilter = eventFilter 2211 } 2212 triggerSpecs.push(every) 2213 } else { 2214 /** @type HtmxTriggerSpecification */ 2215 const triggerSpec = { trigger } 2216 var eventFilter = maybeGenerateConditional(elt, tokens, 'event') 2217 if (eventFilter) { 2218 triggerSpec.eventFilter = eventFilter 2219 } 2220 consumeUntil(tokens, NOT_WHITESPACE) 2221 while (tokens.length > 0 && tokens[0] !== ',') { 2222 const token = tokens.shift() 2223 if (token === 'changed') { 2224 triggerSpec.changed = true 2225 } else if (token === 'once') { 2226 triggerSpec.once = true 2227 } else if (token === 'consume') { 2228 triggerSpec.consume = true 2229 } else if (token === 'delay' && tokens[0] === ':') { 2230 tokens.shift() 2231 triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)) 2232 } else if (token === 'from' && tokens[0] === ':') { 2233 tokens.shift() 2234 if (COMBINED_SELECTOR_START.test(tokens[0])) { 2235 var from_arg = consumeCSSSelector(tokens) 2236 } else { 2237 var from_arg = consumeUntil(tokens, WHITESPACE_OR_COMMA) 2238 if (from_arg === 'closest' || from_arg === 'find' || from_arg === 'next' || from_arg === 'previous') { 2239 tokens.shift() 2240 const selector = consumeCSSSelector(tokens) 2241 // `next` and `previous` allow a selector-less syntax 2242 if (selector.length > 0) { 2243 from_arg += ' ' + selector 2244 } 2245 } 2246 } 2247 triggerSpec.from = from_arg 2248 } else if (token === 'target' && tokens[0] === ':') { 2249 tokens.shift() 2250 triggerSpec.target = consumeCSSSelector(tokens) 2251 } else if (token === 'throttle' && tokens[0] === ':') { 2252 tokens.shift() 2253 triggerSpec.throttle = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)) 2254 } else if (token === 'queue' && tokens[0] === ':') { 2255 tokens.shift() 2256 triggerSpec.queue = consumeUntil(tokens, WHITESPACE_OR_COMMA) 2257 } else if (token === 'root' && tokens[0] === ':') { 2258 tokens.shift() 2259 triggerSpec[token] = consumeCSSSelector(tokens) 2260 } else if (token === 'threshold' && tokens[0] === ':') { 2261 tokens.shift() 2262 triggerSpec[token] = consumeUntil(tokens, WHITESPACE_OR_COMMA) 2263 } else { 2264 triggerErrorEvent(elt, 'htmx:syntax:error', { token: tokens.shift() }) 2265 } 2266 consumeUntil(tokens, NOT_WHITESPACE) 2267 } 2268 triggerSpecs.push(triggerSpec) 2269 } 2270 } 2271 if (tokens.length === initialLength) { 2272 triggerErrorEvent(elt, 'htmx:syntax:error', { token: tokens.shift() }) 2273 } 2274 consumeUntil(tokens, NOT_WHITESPACE) 2275 } while (tokens[0] === ',' && tokens.shift()) 2276 if (cache) { 2277 cache[explicitTrigger] = triggerSpecs 2278 } 2279 return triggerSpecs 2280 } 2281 2282 /** 2283 * @param {Element} elt 2284 * @returns {HtmxTriggerSpecification[]} 2285 */ 2286 function getTriggerSpecs(elt) { 2287 const explicitTrigger = getAttributeValue(elt, 'hx-trigger') 2288 let triggerSpecs = [] 2289 if (explicitTrigger) { 2290 const cache = htmx.config.triggerSpecsCache 2291 triggerSpecs = (cache && cache[explicitTrigger]) || parseAndCacheTrigger(elt, explicitTrigger, cache) 2292 } 2293 2294 if (triggerSpecs.length > 0) { 2295 return triggerSpecs 2296 } else if (matches(elt, 'form')) { 2297 return [{ trigger: 'submit' }] 2298 } else if (matches(elt, 'input[type="button"], input[type="submit"]')) { 2299 return [{ trigger: 'click' }] 2300 } else if (matches(elt, INPUT_SELECTOR)) { 2301 return [{ trigger: 'change' }] 2302 } else { 2303 return [{ trigger: 'click' }] 2304 } 2305 } 2306 2307 /** 2308 * @param {Element} elt 2309 */ 2310 function cancelPolling(elt) { 2311 getInternalData(elt).cancelled = true 2312 } 2313 2314 /** 2315 * @param {Element} elt 2316 * @param {TriggerHandler} handler 2317 * @param {HtmxTriggerSpecification} spec 2318 */ 2319 function processPolling(elt, handler, spec) { 2320 const nodeData = getInternalData(elt) 2321 nodeData.timeout = getWindow().setTimeout(function() { 2322 if (bodyContains(elt) && nodeData.cancelled !== true) { 2323 if (!maybeFilterEvent(spec, elt, makeEvent('hx:poll:trigger', { 2324 triggerSpec: spec, 2325 target: elt 2326 }))) { 2327 handler(elt) 2328 } 2329 processPolling(elt, handler, spec) 2330 } 2331 }, spec.pollInterval) 2332 } 2333 2334 /** 2335 * @param {HTMLAnchorElement} elt 2336 * @returns {boolean} 2337 */ 2338 function isLocalLink(elt) { 2339 return location.hostname === elt.hostname && 2340 getRawAttribute(elt, 'href') && 2341 getRawAttribute(elt, 'href').indexOf('#') !== 0 2342 } 2343 2344 /** 2345 * @param {Element} elt 2346 */ 2347 function eltIsDisabled(elt) { 2348 return closest(elt, htmx.config.disableSelector) 2349 } 2350 2351 /** 2352 * @param {Element} elt 2353 * @param {HtmxNodeInternalData} nodeData 2354 * @param {HtmxTriggerSpecification[]} triggerSpecs 2355 */ 2356 function boostElement(elt, nodeData, triggerSpecs) { 2357 if ((elt instanceof HTMLAnchorElement && isLocalLink(elt) && (elt.target === '' || elt.target === '_self')) || (elt.tagName === 'FORM' && String(getRawAttribute(elt, 'method')).toLowerCase() !== 'dialog')) { 2358 nodeData.boosted = true 2359 let verb, path 2360 if (elt.tagName === 'A') { 2361 verb = (/** @type HttpVerb */('get')) 2362 path = getRawAttribute(elt, 'href') 2363 } else { 2364 const rawAttribute = getRawAttribute(elt, 'method') 2365 verb = (/** @type HttpVerb */(rawAttribute ? rawAttribute.toLowerCase() : 'get')) 2366 path = getRawAttribute(elt, 'action') 2367 if (path == null || path === '') { 2368 // if there is no action attribute on the form set path to current href before the 2369 // following logic to properly clear parameters on a GET (not on a POST!) 2370 path = getDocument().location.href 2371 } 2372 if (verb === 'get' && path.includes('?')) { 2373 path = path.replace(/\?[^#]+/, '') 2374 } 2375 } 2376 triggerSpecs.forEach(function(triggerSpec) { 2377 addEventListener(elt, function(node, evt) { 2378 const elt = asElement(node) 2379 if (eltIsDisabled(elt)) { 2380 cleanUpElement(elt) 2381 return 2382 } 2383 issueAjaxRequest(verb, path, elt, evt) 2384 }, nodeData, triggerSpec, true) 2385 }) 2386 } 2387 } 2388 2389 /** 2390 * @param {Event} evt 2391 * @param {Node} node 2392 * @returns {boolean} 2393 */ 2394 function shouldCancel(evt, node) { 2395 const elt = asElement(node) 2396 if (!elt) { 2397 return false 2398 } 2399 if (evt.type === 'submit' || evt.type === 'click') { 2400 if (elt.tagName === 'FORM') { 2401 return true 2402 } 2403 if (matches(elt, 'input[type="submit"], button') && 2404 (matches(elt, '[form]') || closest(elt, 'form') !== null)) { 2405 return true 2406 } 2407 if (elt instanceof HTMLAnchorElement && elt.href && 2408 (elt.getAttribute('href') === '#' || elt.getAttribute('href').indexOf('#') !== 0)) { 2409 return true 2410 } 2411 } 2412 return false 2413 } 2414 2415 /** 2416 * @param {Node} elt 2417 * @param {Event|MouseEvent|KeyboardEvent|TouchEvent} evt 2418 * @returns {boolean} 2419 */ 2420 function ignoreBoostedAnchorCtrlClick(elt, evt) { 2421 return getInternalData(elt).boosted && elt instanceof HTMLAnchorElement && evt.type === 'click' && 2422 // @ts-ignore this will resolve to undefined for events that don't define those properties, which is fine 2423 (evt.ctrlKey || evt.metaKey) 2424 } 2425 2426 /** 2427 * @param {HtmxTriggerSpecification} triggerSpec 2428 * @param {Node} elt 2429 * @param {Event} evt 2430 * @returns {boolean} 2431 */ 2432 function maybeFilterEvent(triggerSpec, elt, evt) { 2433 const eventFilter = triggerSpec.eventFilter 2434 if (eventFilter) { 2435 try { 2436 return eventFilter.call(elt, evt) !== true 2437 } catch (e) { 2438 const source = eventFilter.source 2439 triggerErrorEvent(getDocument().body, 'htmx:eventFilter:error', { error: e, source }) 2440 return true 2441 } 2442 } 2443 return false 2444 } 2445 2446 /** 2447 * @param {Node} elt 2448 * @param {TriggerHandler} handler 2449 * @param {HtmxNodeInternalData} nodeData 2450 * @param {HtmxTriggerSpecification} triggerSpec 2451 * @param {boolean} [explicitCancel] 2452 */ 2453 function addEventListener(elt, handler, nodeData, triggerSpec, explicitCancel) { 2454 const elementData = getInternalData(elt) 2455 /** @type {(Node|Window)[]} */ 2456 let eltsToListenOn 2457 if (triggerSpec.from) { 2458 eltsToListenOn = querySelectorAllExt(elt, triggerSpec.from) 2459 } else { 2460 eltsToListenOn = [elt] 2461 } 2462 // store the initial values of the elements, so we can tell if they change 2463 if (triggerSpec.changed) { 2464 if (!('lastValue' in elementData)) { 2465 elementData.lastValue = new WeakMap() 2466 } 2467 eltsToListenOn.forEach(function(eltToListenOn) { 2468 if (!elementData.lastValue.has(triggerSpec)) { 2469 elementData.lastValue.set(triggerSpec, new WeakMap()) 2470 } 2471 // @ts-ignore value will be undefined for non-input elements, which is fine 2472 elementData.lastValue.get(triggerSpec).set(eltToListenOn, eltToListenOn.value) 2473 }) 2474 } 2475 forEach(eltsToListenOn, function(eltToListenOn) { 2476 /** @type EventListener */ 2477 const eventListener = function(evt) { 2478 if (!bodyContains(elt)) { 2479 eltToListenOn.removeEventListener(triggerSpec.trigger, eventListener) 2480 return 2481 } 2482 if (ignoreBoostedAnchorCtrlClick(elt, evt)) { 2483 return 2484 } 2485 if (explicitCancel || shouldCancel(evt, elt)) { 2486 evt.preventDefault() 2487 } 2488 if (maybeFilterEvent(triggerSpec, elt, evt)) { 2489 return 2490 } 2491 const eventData = getInternalData(evt) 2492 eventData.triggerSpec = triggerSpec 2493 if (eventData.handledFor == null) { 2494 eventData.handledFor = [] 2495 } 2496 if (eventData.handledFor.indexOf(elt) < 0) { 2497 eventData.handledFor.push(elt) 2498 if (triggerSpec.consume) { 2499 evt.stopPropagation() 2500 } 2501 if (triggerSpec.target && evt.target) { 2502 if (!matches(asElement(evt.target), triggerSpec.target)) { 2503 return 2504 } 2505 } 2506 if (triggerSpec.once) { 2507 if (elementData.triggeredOnce) { 2508 return 2509 } else { 2510 elementData.triggeredOnce = true 2511 } 2512 } 2513 if (triggerSpec.changed) { 2514 const node = event.target 2515 // @ts-ignore value will be undefined for non-input elements, which is fine 2516 const value = node.value 2517 const lastValue = elementData.lastValue.get(triggerSpec) 2518 if (lastValue.has(node) && lastValue.get(node) === value) { 2519 return 2520 } 2521 lastValue.set(node, value) 2522 } 2523 if (elementData.delayed) { 2524 clearTimeout(elementData.delayed) 2525 } 2526 if (elementData.throttle) { 2527 return 2528 } 2529 2530 if (triggerSpec.throttle > 0) { 2531 if (!elementData.throttle) { 2532 triggerEvent(elt, 'htmx:trigger') 2533 handler(elt, evt) 2534 elementData.throttle = getWindow().setTimeout(function() { 2535 elementData.throttle = null 2536 }, triggerSpec.throttle) 2537 } 2538 } else if (triggerSpec.delay > 0) { 2539 elementData.delayed = getWindow().setTimeout(function() { 2540 triggerEvent(elt, 'htmx:trigger') 2541 handler(elt, evt) 2542 }, triggerSpec.delay) 2543 } else { 2544 triggerEvent(elt, 'htmx:trigger') 2545 handler(elt, evt) 2546 } 2547 } 2548 } 2549 if (nodeData.listenerInfos == null) { 2550 nodeData.listenerInfos = [] 2551 } 2552 nodeData.listenerInfos.push({ 2553 trigger: triggerSpec.trigger, 2554 listener: eventListener, 2555 on: eltToListenOn 2556 }) 2557 eltToListenOn.addEventListener(triggerSpec.trigger, eventListener) 2558 }) 2559 } 2560 2561 let windowIsScrolling = false // used by initScrollHandler 2562 let scrollHandler = null 2563 function initScrollHandler() { 2564 if (!scrollHandler) { 2565 scrollHandler = function() { 2566 windowIsScrolling = true 2567 } 2568 window.addEventListener('scroll', scrollHandler) 2569 window.addEventListener('resize', scrollHandler) 2570 setInterval(function() { 2571 if (windowIsScrolling) { 2572 windowIsScrolling = false 2573 forEach(getDocument().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"), function(elt) { 2574 maybeReveal(elt) 2575 }) 2576 } 2577 }, 200) 2578 } 2579 } 2580 2581 /** 2582 * @param {Element} elt 2583 */ 2584 function maybeReveal(elt) { 2585 if (!hasAttribute(elt, 'data-hx-revealed') && isScrolledIntoView(elt)) { 2586 elt.setAttribute('data-hx-revealed', 'true') 2587 const nodeData = getInternalData(elt) 2588 if (nodeData.initHash) { 2589 triggerEvent(elt, 'revealed') 2590 } else { 2591 // if the node isn't initialized, wait for it before triggering the request 2592 elt.addEventListener('htmx:afterProcessNode', function() { triggerEvent(elt, 'revealed') }, { once: true }) 2593 } 2594 } 2595 } 2596 2597 //= =================================================================== 2598 2599 /** 2600 * @param {Element} elt 2601 * @param {TriggerHandler} handler 2602 * @param {HtmxNodeInternalData} nodeData 2603 * @param {number} delay 2604 */ 2605 function loadImmediately(elt, handler, nodeData, delay) { 2606 const load = function() { 2607 if (!nodeData.loaded) { 2608 nodeData.loaded = true 2609 triggerEvent(elt, 'htmx:trigger') 2610 handler(elt) 2611 } 2612 } 2613 if (delay > 0) { 2614 getWindow().setTimeout(load, delay) 2615 } else { 2616 load() 2617 } 2618 } 2619 2620 /** 2621 * @param {Element} elt 2622 * @param {HtmxNodeInternalData} nodeData 2623 * @param {HtmxTriggerSpecification[]} triggerSpecs 2624 * @returns {boolean} 2625 */ 2626 function processVerbs(elt, nodeData, triggerSpecs) { 2627 let explicitAction = false 2628 forEach(VERBS, function(verb) { 2629 if (hasAttribute(elt, 'hx-' + verb)) { 2630 const path = getAttributeValue(elt, 'hx-' + verb) 2631 explicitAction = true 2632 nodeData.path = path 2633 nodeData.verb = verb 2634 triggerSpecs.forEach(function(triggerSpec) { 2635 addTriggerHandler(elt, triggerSpec, nodeData, function(node, evt) { 2636 const elt = asElement(node) 2637 if (closest(elt, htmx.config.disableSelector)) { 2638 cleanUpElement(elt) 2639 return 2640 } 2641 issueAjaxRequest(verb, path, elt, evt) 2642 }) 2643 }) 2644 } 2645 }) 2646 return explicitAction 2647 } 2648 2649 /** 2650 * @callback TriggerHandler 2651 * @param {Node} elt 2652 * @param {Event} [evt] 2653 */ 2654 2655 /** 2656 * @param {Node} elt 2657 * @param {HtmxTriggerSpecification} triggerSpec 2658 * @param {HtmxNodeInternalData} nodeData 2659 * @param {TriggerHandler} handler 2660 */ 2661 function addTriggerHandler(elt, triggerSpec, nodeData, handler) { 2662 if (triggerSpec.trigger === 'revealed') { 2663 initScrollHandler() 2664 addEventListener(elt, handler, nodeData, triggerSpec) 2665 maybeReveal(asElement(elt)) 2666 } else if (triggerSpec.trigger === 'intersect') { 2667 const observerOptions = {} 2668 if (triggerSpec.root) { 2669 observerOptions.root = querySelectorExt(elt, triggerSpec.root) 2670 } 2671 if (triggerSpec.threshold) { 2672 observerOptions.threshold = parseFloat(triggerSpec.threshold) 2673 } 2674 const observer = new IntersectionObserver(function(entries) { 2675 for (let i = 0; i < entries.length; i++) { 2676 const entry = entries[i] 2677 if (entry.isIntersecting) { 2678 triggerEvent(elt, 'intersect') 2679 break 2680 } 2681 } 2682 }, observerOptions) 2683 observer.observe(asElement(elt)) 2684 addEventListener(asElement(elt), handler, nodeData, triggerSpec) 2685 } else if (!nodeData.firstInitCompleted && triggerSpec.trigger === 'load') { 2686 if (!maybeFilterEvent(triggerSpec, elt, makeEvent('load', { elt }))) { 2687 loadImmediately(asElement(elt), handler, nodeData, triggerSpec.delay) 2688 } 2689 } else if (triggerSpec.pollInterval > 0) { 2690 nodeData.polling = true 2691 processPolling(asElement(elt), handler, triggerSpec) 2692 } else { 2693 addEventListener(elt, handler, nodeData, triggerSpec) 2694 } 2695 } 2696 2697 /** 2698 * @param {Node} node 2699 * @returns {boolean} 2700 */ 2701 function shouldProcessHxOn(node) { 2702 const elt = asElement(node) 2703 if (!elt) { 2704 return false 2705 } 2706 const attributes = elt.attributes 2707 for (let j = 0; j < attributes.length; j++) { 2708 const attrName = attributes[j].name 2709 if (startsWith(attrName, 'hx-on:') || startsWith(attrName, 'data-hx-on:') || 2710 startsWith(attrName, 'hx-on-') || startsWith(attrName, 'data-hx-on-')) { 2711 return true 2712 } 2713 } 2714 return false 2715 } 2716 2717 /** 2718 * @param {Node} elt 2719 * @returns {Element[]} 2720 */ 2721 const HX_ON_QUERY = new XPathEvaluator() 2722 .createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or' + 2723 ' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]') 2724 2725 function processHXOnRoot(elt, elements) { 2726 if (shouldProcessHxOn(elt)) { 2727 elements.push(asElement(elt)) 2728 } 2729 const iter = HX_ON_QUERY.evaluate(elt) 2730 let node = null 2731 while (node = iter.iterateNext()) elements.push(asElement(node)) 2732 } 2733 2734 function findHxOnWildcardElements(elt) { 2735 /** @type {Element[]} */ 2736 const elements = [] 2737 if (elt instanceof DocumentFragment) { 2738 for (const child of elt.childNodes) { 2739 processHXOnRoot(child, elements) 2740 } 2741 } else { 2742 processHXOnRoot(elt, elements) 2743 } 2744 return elements 2745 } 2746 2747 /** 2748 * @param {Element} elt 2749 * @returns {NodeListOf<Element>|[]} 2750 */ 2751 function findElementsToProcess(elt) { 2752 if (elt.querySelectorAll) { 2753 const boostedSelector = ', [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]' 2754 2755 const extensionSelectors = [] 2756 for (const e in extensions) { 2757 const extension = extensions[e] 2758 if (extension.getSelectors) { 2759 var selectors = extension.getSelectors() 2760 if (selectors) { 2761 extensionSelectors.push(selectors) 2762 } 2763 } 2764 } 2765 2766 const results = elt.querySelectorAll(VERB_SELECTOR + boostedSelector + ", form, [type='submit']," + 2767 ' [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]' + extensionSelectors.flat().map(s => ', ' + s).join('')) 2768 2769 return results 2770 } else { 2771 return [] 2772 } 2773 } 2774 2775 /** 2776 * Handle submit buttons/inputs that have the form attribute set 2777 * see https://developer.mozilla.org/docs/Web/HTML/Element/button 2778 * @param {Event} evt 2779 */ 2780 function maybeSetLastButtonClicked(evt) { 2781 const elt = /** @type {HTMLButtonElement|HTMLInputElement} */ (closest(asElement(evt.target), "button, input[type='submit']")) 2782 const internalData = getRelatedFormData(evt) 2783 if (internalData) { 2784 internalData.lastButtonClicked = elt 2785 } 2786 } 2787 2788 /** 2789 * @param {Event} evt 2790 */ 2791 function maybeUnsetLastButtonClicked(evt) { 2792 const internalData = getRelatedFormData(evt) 2793 if (internalData) { 2794 internalData.lastButtonClicked = null 2795 } 2796 } 2797 2798 /** 2799 * @param {Event} evt 2800 * @returns {HtmxNodeInternalData|undefined} 2801 */ 2802 function getRelatedFormData(evt) { 2803 const elt = closest(asElement(evt.target), "button, input[type='submit']") 2804 if (!elt) { 2805 return 2806 } 2807 const form = resolveTarget('#' + getRawAttribute(elt, 'form'), elt.getRootNode()) || closest(elt, 'form') 2808 if (!form) { 2809 return 2810 } 2811 return getInternalData(form) 2812 } 2813 2814 /** 2815 * @param {EventTarget} elt 2816 */ 2817 function initButtonTracking(elt) { 2818 // need to handle both click and focus in: 2819 // focusin - in case someone tabs in to a button and hits the space bar 2820 // click - on OSX buttons do not focus on click see https://bugs.webkit.org/show_bug.cgi?id=13724 2821 elt.addEventListener('click', maybeSetLastButtonClicked) 2822 elt.addEventListener('focusin', maybeSetLastButtonClicked) 2823 elt.addEventListener('focusout', maybeUnsetLastButtonClicked) 2824 } 2825 2826 /** 2827 * @param {Element} elt 2828 * @param {string} eventName 2829 * @param {string} code 2830 */ 2831 function addHxOnEventHandler(elt, eventName, code) { 2832 const nodeData = getInternalData(elt) 2833 if (!Array.isArray(nodeData.onHandlers)) { 2834 nodeData.onHandlers = [] 2835 } 2836 let func 2837 /** @type EventListener */ 2838 const listener = function(e) { 2839 maybeEval(elt, function() { 2840 if (eltIsDisabled(elt)) { 2841 return 2842 } 2843 if (!func) { 2844 func = new Function('event', code) 2845 } 2846 func.call(elt, e) 2847 }) 2848 } 2849 elt.addEventListener(eventName, listener) 2850 nodeData.onHandlers.push({ event: eventName, listener }) 2851 } 2852 2853 /** 2854 * @param {Element} elt 2855 */ 2856 function processHxOnWildcard(elt) { 2857 // wipe any previous on handlers so that this function takes precedence 2858 deInitOnHandlers(elt) 2859 2860 for (let i = 0; i < elt.attributes.length; i++) { 2861 const name = elt.attributes[i].name 2862 const value = elt.attributes[i].value 2863 if (startsWith(name, 'hx-on') || startsWith(name, 'data-hx-on')) { 2864 const afterOnPosition = name.indexOf('-on') + 3 2865 const nextChar = name.slice(afterOnPosition, afterOnPosition + 1) 2866 if (nextChar === '-' || nextChar === ':') { 2867 let eventName = name.slice(afterOnPosition + 1) 2868 // if the eventName starts with a colon or dash, prepend "htmx" for shorthand support 2869 if (startsWith(eventName, ':')) { 2870 eventName = 'htmx' + eventName 2871 } else if (startsWith(eventName, '-')) { 2872 eventName = 'htmx:' + eventName.slice(1) 2873 } else if (startsWith(eventName, 'htmx-')) { 2874 eventName = 'htmx:' + eventName.slice(5) 2875 } 2876 2877 addHxOnEventHandler(elt, eventName, value) 2878 } 2879 } 2880 } 2881 } 2882 2883 /** 2884 * @param {Element|HTMLInputElement} elt 2885 */ 2886 function initNode(elt) { 2887 if (closest(elt, htmx.config.disableSelector)) { 2888 cleanUpElement(elt) 2889 return 2890 } 2891 const nodeData = getInternalData(elt) 2892 const attrHash = attributeHash(elt) 2893 if (nodeData.initHash !== attrHash) { 2894 // clean up any previously processed info 2895 deInitNode(elt) 2896 2897 nodeData.initHash = attrHash 2898 2899 triggerEvent(elt, 'htmx:beforeProcessNode') 2900 2901 const triggerSpecs = getTriggerSpecs(elt) 2902 const hasExplicitHttpAction = processVerbs(elt, nodeData, triggerSpecs) 2903 2904 if (!hasExplicitHttpAction) { 2905 if (getClosestAttributeValue(elt, 'hx-boost') === 'true') { 2906 boostElement(elt, nodeData, triggerSpecs) 2907 } else if (hasAttribute(elt, 'hx-trigger')) { 2908 triggerSpecs.forEach(function(triggerSpec) { 2909 // For "naked" triggers, don't do anything at all 2910 addTriggerHandler(elt, triggerSpec, nodeData, function() { 2911 }) 2912 }) 2913 } 2914 } 2915 2916 // Handle submit buttons/inputs that have the form attribute set 2917 // see https://developer.mozilla.org/docs/Web/HTML/Element/button 2918 if (elt.tagName === 'FORM' || (getRawAttribute(elt, 'type') === 'submit' && hasAttribute(elt, 'form'))) { 2919 initButtonTracking(elt) 2920 } 2921 2922 nodeData.firstInitCompleted = true 2923 triggerEvent(elt, 'htmx:afterProcessNode') 2924 } 2925 } 2926 2927 /** 2928 * Processes new content, enabling htmx behavior. This can be useful if you have content that is added to the DOM outside of the normal htmx request cycle but still want htmx attributes to work. 2929 * 2930 * @see https://htmx.org/api/#process 2931 * 2932 * @param {Element|string} elt element to process 2933 */ 2934 function processNode(elt) { 2935 elt = resolveTarget(elt) 2936 if (closest(elt, htmx.config.disableSelector)) { 2937 cleanUpElement(elt) 2938 return 2939 } 2940 initNode(elt) 2941 forEach(findElementsToProcess(elt), function(child) { initNode(child) }) 2942 forEach(findHxOnWildcardElements(elt), processHxOnWildcard) 2943 } 2944 2945 //= =================================================================== 2946 // Event/Log Support 2947 //= =================================================================== 2948 2949 /** 2950 * @param {string} str 2951 * @returns {string} 2952 */ 2953 function kebabEventName(str) { 2954 return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase() 2955 } 2956 2957 /** 2958 * @param {string} eventName 2959 * @param {any} detail 2960 * @returns {CustomEvent} 2961 */ 2962 function makeEvent(eventName, detail) { 2963 let evt 2964 if (window.CustomEvent && typeof window.CustomEvent === 'function') { 2965 // TODO: `composed: true` here is a hack to make global event handlers work with events in shadow DOM 2966 // This breaks expected encapsulation but needs to be here until decided otherwise by core devs 2967 evt = new CustomEvent(eventName, { bubbles: true, cancelable: true, composed: true, detail }) 2968 } else { 2969 evt = getDocument().createEvent('CustomEvent') 2970 evt.initCustomEvent(eventName, true, true, detail) 2971 } 2972 return evt 2973 } 2974 2975 /** 2976 * @param {EventTarget|string} elt 2977 * @param {string} eventName 2978 * @param {any=} detail 2979 */ 2980 function triggerErrorEvent(elt, eventName, detail) { 2981 triggerEvent(elt, eventName, mergeObjects({ error: eventName }, detail)) 2982 } 2983 2984 /** 2985 * @param {string} eventName 2986 * @returns {boolean} 2987 */ 2988 function ignoreEventForLogging(eventName) { 2989 return eventName === 'htmx:afterProcessNode' 2990 } 2991 2992 /** 2993 * `withExtensions` locates all active extensions for a provided element, then 2994 * executes the provided function using each of the active extensions. It should 2995 * be called internally at every extendable execution point in htmx. 2996 * 2997 * @param {Element} elt 2998 * @param {(extension:HtmxExtension) => void} toDo 2999 * @returns void 3000 */ 3001 function withExtensions(elt, toDo) { 3002 forEach(getExtensions(elt), function(extension) { 3003 try { 3004 toDo(extension) 3005 } catch (e) { 3006 logError(e) 3007 } 3008 }) 3009 } 3010 3011 function logError(msg) { 3012 if (console.error) { 3013 console.error(msg) 3014 } else if (console.log) { 3015 console.log('ERROR: ', msg) 3016 } 3017 } 3018 3019 /** 3020 * Triggers a given event on an element 3021 * 3022 * @see https://htmx.org/api/#trigger 3023 * 3024 * @param {EventTarget|string} elt the element to trigger the event on 3025 * @param {string} eventName the name of the event to trigger 3026 * @param {any=} detail details for the event 3027 * @returns {boolean} 3028 */ 3029 function triggerEvent(elt, eventName, detail) { 3030 elt = resolveTarget(elt) 3031 if (detail == null) { 3032 detail = {} 3033 } 3034 detail.elt = elt 3035 const event = makeEvent(eventName, detail) 3036 if (htmx.logger && !ignoreEventForLogging(eventName)) { 3037 htmx.logger(elt, eventName, detail) 3038 } 3039 if (detail.error) { 3040 logError(detail.error) 3041 triggerEvent(elt, 'htmx:error', { errorInfo: detail }) 3042 } 3043 let eventResult = elt.dispatchEvent(event) 3044 const kebabName = kebabEventName(eventName) 3045 if (eventResult && kebabName !== eventName) { 3046 const kebabedEvent = makeEvent(kebabName, event.detail) 3047 eventResult = eventResult && elt.dispatchEvent(kebabedEvent) 3048 } 3049 withExtensions(asElement(elt), function(extension) { 3050 eventResult = eventResult && (extension.onEvent(eventName, event) !== false && !event.defaultPrevented) 3051 }) 3052 return eventResult 3053 } 3054 3055 //= =================================================================== 3056 // History Support 3057 //= =================================================================== 3058 let currentPathForHistory = location.pathname + location.search 3059 3060 /** 3061 * @returns {Element} 3062 */ 3063 function getHistoryElement() { 3064 const historyElt = getDocument().querySelector('[hx-history-elt],[data-hx-history-elt]') 3065 return historyElt || getDocument().body 3066 } 3067 3068 /** 3069 * @param {string} url 3070 * @param {Element} rootElt 3071 */ 3072 function saveToHistoryCache(url, rootElt) { 3073 if (!canAccessLocalStorage()) { 3074 return 3075 } 3076 3077 // get state to save 3078 const innerHTML = cleanInnerHtmlForHistory(rootElt) 3079 const title = getDocument().title 3080 const scroll = window.scrollY 3081 3082 if (htmx.config.historyCacheSize <= 0) { 3083 // make sure that an eventually already existing cache is purged 3084 localStorage.removeItem('htmx-history-cache') 3085 return 3086 } 3087 3088 url = normalizePath(url) 3089 3090 const historyCache = parseJSON(localStorage.getItem('htmx-history-cache')) || [] 3091 for (let i = 0; i < historyCache.length; i++) { 3092 if (historyCache[i].url === url) { 3093 historyCache.splice(i, 1) 3094 break 3095 } 3096 } 3097 3098 /** @type HtmxHistoryItem */ 3099 const newHistoryItem = { url, content: innerHTML, title, scroll } 3100 3101 triggerEvent(getDocument().body, 'htmx:historyItemCreated', { item: newHistoryItem, cache: historyCache }) 3102 3103 historyCache.push(newHistoryItem) 3104 while (historyCache.length > htmx.config.historyCacheSize) { 3105 historyCache.shift() 3106 } 3107 3108 // keep trying to save the cache until it succeeds or is empty 3109 while (historyCache.length > 0) { 3110 try { 3111 localStorage.setItem('htmx-history-cache', JSON.stringify(historyCache)) 3112 break 3113 } catch (e) { 3114 triggerErrorEvent(getDocument().body, 'htmx:historyCacheError', { cause: e, cache: historyCache }) 3115 historyCache.shift() // shrink the cache and retry 3116 } 3117 } 3118 } 3119 3120 /** 3121 * @typedef {Object} HtmxHistoryItem 3122 * @property {string} url 3123 * @property {string} content 3124 * @property {string} title 3125 * @property {number} scroll 3126 */ 3127 3128 /** 3129 * @param {string} url 3130 * @returns {HtmxHistoryItem|null} 3131 */ 3132 function getCachedHistory(url) { 3133 if (!canAccessLocalStorage()) { 3134 return null 3135 } 3136 3137 url = normalizePath(url) 3138 3139 const historyCache = parseJSON(localStorage.getItem('htmx-history-cache')) || [] 3140 for (let i = 0; i < historyCache.length; i++) { 3141 if (historyCache[i].url === url) { 3142 return historyCache[i] 3143 } 3144 } 3145 return null 3146 } 3147 3148 /** 3149 * @param {Element} elt 3150 * @returns {string} 3151 */ 3152 function cleanInnerHtmlForHistory(elt) { 3153 const className = htmx.config.requestClass 3154 const clone = /** @type Element */ (elt.cloneNode(true)) 3155 forEach(findAll(clone, '.' + className), function(child) { 3156 removeClassFromElement(child, className) 3157 }) 3158 // remove the disabled attribute for any element disabled due to an htmx request 3159 forEach(findAll(clone, '[data-disabled-by-htmx]'), function(child) { 3160 child.removeAttribute('disabled') 3161 }) 3162 return clone.innerHTML 3163 } 3164 3165 function saveCurrentPageToHistory() { 3166 const elt = getHistoryElement() 3167 const path = currentPathForHistory || location.pathname + location.search 3168 3169 // Allow history snapshot feature to be disabled where hx-history="false" 3170 // is present *anywhere* in the current document we're about to save, 3171 // so we can prevent privileged data entering the cache. 3172 // The page will still be reachable as a history entry, but htmx will fetch it 3173 // live from the server onpopstate rather than look in the localStorage cache 3174 let disableHistoryCache 3175 try { 3176 disableHistoryCache = getDocument().querySelector('[hx-history="false" i],[data-hx-history="false" i]') 3177 } catch (e) { 3178 // IE11: insensitive modifier not supported so fallback to case sensitive selector 3179 disableHistoryCache = getDocument().querySelector('[hx-history="false"],[data-hx-history="false"]') 3180 } 3181 if (!disableHistoryCache) { 3182 triggerEvent(getDocument().body, 'htmx:beforeHistorySave', { path, historyElt: elt }) 3183 saveToHistoryCache(path, elt) 3184 } 3185 3186 if (htmx.config.historyEnabled) history.replaceState({ htmx: true }, getDocument().title, window.location.href) 3187 } 3188 3189 /** 3190 * @param {string} path 3191 */ 3192 function pushUrlIntoHistory(path) { 3193 // remove the cache buster parameter, if any 3194 if (htmx.config.getCacheBusterParam) { 3195 path = path.replace(/org\.htmx\.cache-buster=[^&]*&?/, '') 3196 if (endsWith(path, '&') || endsWith(path, '?')) { 3197 path = path.slice(0, -1) 3198 } 3199 } 3200 if (htmx.config.historyEnabled) { 3201 history.pushState({ htmx: true }, '', path) 3202 } 3203 currentPathForHistory = path 3204 } 3205 3206 /** 3207 * @param {string} path 3208 */ 3209 function replaceUrlInHistory(path) { 3210 if (htmx.config.historyEnabled) history.replaceState({ htmx: true }, '', path) 3211 currentPathForHistory = path 3212 } 3213 3214 /** 3215 * @param {HtmxSettleTask[]} tasks 3216 */ 3217 function settleImmediately(tasks) { 3218 forEach(tasks, function(task) { 3219 task.call(undefined) 3220 }) 3221 } 3222 3223 /** 3224 * @param {string} path 3225 */ 3226 function loadHistoryFromServer(path) { 3227 const request = new XMLHttpRequest() 3228 const details = { path, xhr: request } 3229 triggerEvent(getDocument().body, 'htmx:historyCacheMiss', details) 3230 request.open('GET', path, true) 3231 request.setRequestHeader('HX-Request', 'true') 3232 request.setRequestHeader('HX-History-Restore-Request', 'true') 3233 request.setRequestHeader('HX-Current-URL', getDocument().location.href) 3234 request.onload = function() { 3235 if (this.status >= 200 && this.status < 400) { 3236 triggerEvent(getDocument().body, 'htmx:historyCacheMissLoad', details) 3237 const fragment = makeFragment(this.response) 3238 /** @type ParentNode */ 3239 const content = fragment.querySelector('[hx-history-elt],[data-hx-history-elt]') || fragment 3240 const historyElement = getHistoryElement() 3241 const settleInfo = makeSettleInfo(historyElement) 3242 handleTitle(fragment.title) 3243 3244 handlePreservedElements(fragment) 3245 swapInnerHTML(historyElement, content, settleInfo) 3246 restorePreservedElements() 3247 settleImmediately(settleInfo.tasks) 3248 currentPathForHistory = path 3249 triggerEvent(getDocument().body, 'htmx:historyRestore', { path, cacheMiss: true, serverResponse: this.response }) 3250 } else { 3251 triggerErrorEvent(getDocument().body, 'htmx:historyCacheMissLoadError', details) 3252 } 3253 } 3254 request.send() 3255 } 3256 3257 /** 3258 * @param {string} [path] 3259 */ 3260 function restoreHistory(path) { 3261 saveCurrentPageToHistory() 3262 path = path || location.pathname + location.search 3263 const cached = getCachedHistory(path) 3264 if (cached) { 3265 const fragment = makeFragment(cached.content) 3266 const historyElement = getHistoryElement() 3267 const settleInfo = makeSettleInfo(historyElement) 3268 handleTitle(cached.title) 3269 handlePreservedElements(fragment) 3270 swapInnerHTML(historyElement, fragment, settleInfo) 3271 restorePreservedElements() 3272 settleImmediately(settleInfo.tasks) 3273 getWindow().setTimeout(function() { 3274 window.scrollTo(0, cached.scroll) 3275 }, 0) // next 'tick', so browser has time to render layout 3276 currentPathForHistory = path 3277 triggerEvent(getDocument().body, 'htmx:historyRestore', { path, item: cached }) 3278 } else { 3279 if (htmx.config.refreshOnHistoryMiss) { 3280 // @ts-ignore: optional parameter in reload() function throws error 3281 // noinspection JSUnresolvedReference 3282 window.location.reload(true) 3283 } else { 3284 loadHistoryFromServer(path) 3285 } 3286 } 3287 } 3288 3289 /** 3290 * @param {Element} elt 3291 * @returns {Element[]} 3292 */ 3293 function addRequestIndicatorClasses(elt) { 3294 let indicators = /** @type Element[] */ (findAttributeTargets(elt, 'hx-indicator')) 3295 if (indicators == null) { 3296 indicators = [elt] 3297 } 3298 forEach(indicators, function(ic) { 3299 const internalData = getInternalData(ic) 3300 internalData.requestCount = (internalData.requestCount || 0) + 1 3301 ic.classList.add.call(ic.classList, htmx.config.requestClass) 3302 }) 3303 return indicators 3304 } 3305 3306 /** 3307 * @param {Element} elt 3308 * @returns {Element[]} 3309 */ 3310 function disableElements(elt) { 3311 let disabledElts = /** @type Element[] */ (findAttributeTargets(elt, 'hx-disabled-elt')) 3312 if (disabledElts == null) { 3313 disabledElts = [] 3314 } 3315 forEach(disabledElts, function(disabledElement) { 3316 const internalData = getInternalData(disabledElement) 3317 internalData.requestCount = (internalData.requestCount || 0) + 1 3318 disabledElement.setAttribute('disabled', '') 3319 disabledElement.setAttribute('data-disabled-by-htmx', '') 3320 }) 3321 return disabledElts 3322 } 3323 3324 /** 3325 * @param {Element[]} indicators 3326 * @param {Element[]} disabled 3327 */ 3328 function removeRequestIndicators(indicators, disabled) { 3329 forEach(indicators.concat(disabled), function(ele) { 3330 const internalData = getInternalData(ele) 3331 internalData.requestCount = (internalData.requestCount || 1) - 1 3332 }) 3333 forEach(indicators, function(ic) { 3334 const internalData = getInternalData(ic) 3335 if (internalData.requestCount === 0) { 3336 ic.classList.remove.call(ic.classList, htmx.config.requestClass) 3337 } 3338 }) 3339 forEach(disabled, function(disabledElement) { 3340 const internalData = getInternalData(disabledElement) 3341 if (internalData.requestCount === 0) { 3342 disabledElement.removeAttribute('disabled') 3343 disabledElement.removeAttribute('data-disabled-by-htmx') 3344 } 3345 }) 3346 } 3347 3348 //= =================================================================== 3349 // Input Value Processing 3350 //= =================================================================== 3351 3352 /** 3353 * @param {Element[]} processed 3354 * @param {Element} elt 3355 * @returns {boolean} 3356 */ 3357 function haveSeenNode(processed, elt) { 3358 for (let i = 0; i < processed.length; i++) { 3359 const node = processed[i] 3360 if (node.isSameNode(elt)) { 3361 return true 3362 } 3363 } 3364 return false 3365 } 3366 3367 /** 3368 * @param {Element} element 3369 * @return {boolean} 3370 */ 3371 function shouldInclude(element) { 3372 // Cast to trick tsc, undefined values will work fine here 3373 const elt = /** @type {HTMLInputElement} */ (element) 3374 if (elt.name === '' || elt.name == null || elt.disabled || closest(elt, 'fieldset[disabled]')) { 3375 return false 3376 } 3377 // ignore "submitter" types (see jQuery src/serialize.js) 3378 if (elt.type === 'button' || elt.type === 'submit' || elt.tagName === 'image' || elt.tagName === 'reset' || elt.tagName === 'file') { 3379 return false 3380 } 3381 if (elt.type === 'checkbox' || elt.type === 'radio') { 3382 return elt.checked 3383 } 3384 return true 3385 } 3386 3387 /** @param {string} name 3388 * @param {string|Array|FormDataEntryValue} value 3389 * @param {FormData} formData */ 3390 function addValueToFormData(name, value, formData) { 3391 if (name != null && value != null) { 3392 if (Array.isArray(value)) { 3393 value.forEach(function(v) { formData.append(name, v) }) 3394 } else { 3395 formData.append(name, value) 3396 } 3397 } 3398 } 3399 3400 /** @param {string} name 3401 * @param {string|Array} value 3402 * @param {FormData} formData */ 3403 function removeValueFromFormData(name, value, formData) { 3404 if (name != null && value != null) { 3405 let values = formData.getAll(name) 3406 if (Array.isArray(value)) { 3407 values = values.filter(v => value.indexOf(v) < 0) 3408 } else { 3409 values = values.filter(v => v !== value) 3410 } 3411 formData.delete(name) 3412 forEach(values, v => formData.append(name, v)) 3413 } 3414 } 3415 3416 /** 3417 * @param {Element[]} processed 3418 * @param {FormData} formData 3419 * @param {HtmxElementValidationError[]} errors 3420 * @param {Element|HTMLInputElement|HTMLSelectElement|HTMLFormElement} elt 3421 * @param {boolean} validate 3422 */ 3423 function processInputValue(processed, formData, errors, elt, validate) { 3424 if (elt == null || haveSeenNode(processed, elt)) { 3425 return 3426 } else { 3427 processed.push(elt) 3428 } 3429 if (shouldInclude(elt)) { 3430 const name = getRawAttribute(elt, 'name') 3431 // @ts-ignore value will be undefined for non-input elements, which is fine 3432 let value = elt.value 3433 if (elt instanceof HTMLSelectElement && elt.multiple) { 3434 value = toArray(elt.querySelectorAll('option:checked')).map(function(e) { return (/** @type HTMLOptionElement */(e)).value }) 3435 } 3436 // include file inputs 3437 if (elt instanceof HTMLInputElement && elt.files) { 3438 value = toArray(elt.files) 3439 } 3440 addValueToFormData(name, value, formData) 3441 if (validate) { 3442 validateElement(elt, errors) 3443 } 3444 } 3445 if (elt instanceof HTMLFormElement) { 3446 forEach(elt.elements, function(input) { 3447 if (processed.indexOf(input) >= 0) { 3448 // The input has already been processed and added to the values, but the FormData that will be 3449 // constructed right after on the form, will include it once again. So remove that input's value 3450 // now to avoid duplicates 3451 removeValueFromFormData(input.name, input.value, formData) 3452 } else { 3453 processed.push(input) 3454 } 3455 if (validate) { 3456 validateElement(input, errors) 3457 } 3458 }) 3459 new FormData(elt).forEach(function(value, name) { 3460 if (value instanceof File && value.name === '') { 3461 return // ignore no-name files 3462 } 3463 addValueToFormData(name, value, formData) 3464 }) 3465 } 3466 } 3467 3468 /** 3469 * 3470 * @param {Element} elt 3471 * @param {HtmxElementValidationError[]} errors 3472 */ 3473 function validateElement(elt, errors) { 3474 const element = /** @type {HTMLElement & ElementInternals} */ (elt) 3475 if (element.willValidate) { 3476 triggerEvent(element, 'htmx:validation:validate') 3477 if (!element.checkValidity()) { 3478 errors.push({ elt: element, message: element.validationMessage, validity: element.validity }) 3479 triggerEvent(element, 'htmx:validation:failed', { message: element.validationMessage, validity: element.validity }) 3480 } 3481 } 3482 } 3483 3484 /** 3485 * Override values in the one FormData with those from another. 3486 * @param {FormData} receiver the formdata that will be mutated 3487 * @param {FormData} donor the formdata that will provide the overriding values 3488 * @returns {FormData} the {@linkcode receiver} 3489 */ 3490 function overrideFormData(receiver, donor) { 3491 for (const key of donor.keys()) { 3492 receiver.delete(key) 3493 } 3494 donor.forEach(function(value, key) { 3495 receiver.append(key, value) 3496 }) 3497 return receiver 3498 } 3499 3500 /** 3501 * @param {Element|HTMLFormElement} elt 3502 * @param {HttpVerb} verb 3503 * @returns {{errors: HtmxElementValidationError[], formData: FormData, values: Object}} 3504 */ 3505 function getInputValues(elt, verb) { 3506 /** @type Element[] */ 3507 const processed = [] 3508 const formData = new FormData() 3509 const priorityFormData = new FormData() 3510 /** @type HtmxElementValidationError[] */ 3511 const errors = [] 3512 const internalData = getInternalData(elt) 3513 if (internalData.lastButtonClicked && !bodyContains(internalData.lastButtonClicked)) { 3514 internalData.lastButtonClicked = null 3515 } 3516 3517 // only validate when form is directly submitted and novalidate or formnovalidate are not set 3518 // or if the element has an explicit hx-validate="true" on it 3519 let validate = (elt instanceof HTMLFormElement && elt.noValidate !== true) || getAttributeValue(elt, 'hx-validate') === 'true' 3520 if (internalData.lastButtonClicked) { 3521 validate = validate && internalData.lastButtonClicked.formNoValidate !== true 3522 } 3523 3524 // for a non-GET include the closest form 3525 if (verb !== 'get') { 3526 processInputValue(processed, priorityFormData, errors, closest(elt, 'form'), validate) 3527 } 3528 3529 // include the element itself 3530 processInputValue(processed, formData, errors, elt, validate) 3531 3532 // if a button or submit was clicked last, include its value 3533 if (internalData.lastButtonClicked || elt.tagName === 'BUTTON' || 3534 (elt.tagName === 'INPUT' && getRawAttribute(elt, 'type') === 'submit')) { 3535 const button = internalData.lastButtonClicked || (/** @type HTMLInputElement|HTMLButtonElement */(elt)) 3536 const name = getRawAttribute(button, 'name') 3537 addValueToFormData(name, button.value, priorityFormData) 3538 } 3539 3540 // include any explicit includes 3541 const includes = findAttributeTargets(elt, 'hx-include') 3542 forEach(includes, function(node) { 3543 processInputValue(processed, formData, errors, asElement(node), validate) 3544 // if a non-form is included, include any input values within it 3545 if (!matches(node, 'form')) { 3546 forEach(asParentNode(node).querySelectorAll(INPUT_SELECTOR), function(descendant) { 3547 processInputValue(processed, formData, errors, descendant, validate) 3548 }) 3549 } 3550 }) 3551 3552 // values from a <form> take precedence, overriding the regular values 3553 overrideFormData(formData, priorityFormData) 3554 3555 return { errors, formData, values: formDataProxy(formData) } 3556 } 3557 3558 /** 3559 * @param {string} returnStr 3560 * @param {string} name 3561 * @param {any} realValue 3562 * @returns {string} 3563 */ 3564 function appendParam(returnStr, name, realValue) { 3565 if (returnStr !== '') { 3566 returnStr += '&' 3567 } 3568 if (String(realValue) === '[object Object]') { 3569 realValue = JSON.stringify(realValue) 3570 } 3571 const s = encodeURIComponent(realValue) 3572 returnStr += encodeURIComponent(name) + '=' + s 3573 return returnStr 3574 } 3575 3576 /** 3577 * @param {FormData|Object} values 3578 * @returns string 3579 */ 3580 function urlEncode(values) { 3581 values = formDataFromObject(values) 3582 let returnStr = '' 3583 values.forEach(function(value, key) { 3584 returnStr = appendParam(returnStr, key, value) 3585 }) 3586 return returnStr 3587 } 3588 3589 //= =================================================================== 3590 // Ajax 3591 //= =================================================================== 3592 3593 /** 3594 * @param {Element} elt 3595 * @param {Element} target 3596 * @param {string} prompt 3597 * @returns {HtmxHeaderSpecification} 3598 */ 3599 function getHeaders(elt, target, prompt) { 3600 /** @type HtmxHeaderSpecification */ 3601 const headers = { 3602 'HX-Request': 'true', 3603 'HX-Trigger': getRawAttribute(elt, 'id'), 3604 'HX-Trigger-Name': getRawAttribute(elt, 'name'), 3605 'HX-Target': getAttributeValue(target, 'id'), 3606 'HX-Current-URL': getDocument().location.href 3607 } 3608 getValuesForElement(elt, 'hx-headers', false, headers) 3609 if (prompt !== undefined) { 3610 headers['HX-Prompt'] = prompt 3611 } 3612 if (getInternalData(elt).boosted) { 3613 headers['HX-Boosted'] = 'true' 3614 } 3615 return headers 3616 } 3617 3618 /** 3619 * filterValues takes an object containing form input values 3620 * and returns a new object that only contains keys that are 3621 * specified by the closest "hx-params" attribute 3622 * @param {FormData} inputValues 3623 * @param {Element} elt 3624 * @returns {FormData} 3625 */ 3626 function filterValues(inputValues, elt) { 3627 const paramsValue = getClosestAttributeValue(elt, 'hx-params') 3628 if (paramsValue) { 3629 if (paramsValue === 'none') { 3630 return new FormData() 3631 } else if (paramsValue === '*') { 3632 return inputValues 3633 } else if (paramsValue.indexOf('not ') === 0) { 3634 forEach(paramsValue.slice(4).split(','), function(name) { 3635 name = name.trim() 3636 inputValues.delete(name) 3637 }) 3638 return inputValues 3639 } else { 3640 const newValues = new FormData() 3641 forEach(paramsValue.split(','), function(name) { 3642 name = name.trim() 3643 if (inputValues.has(name)) { 3644 inputValues.getAll(name).forEach(function(value) { newValues.append(name, value) }) 3645 } 3646 }) 3647 return newValues 3648 } 3649 } else { 3650 return inputValues 3651 } 3652 } 3653 3654 /** 3655 * @param {Element} elt 3656 * @return {boolean} 3657 */ 3658 function isAnchorLink(elt) { 3659 return !!getRawAttribute(elt, 'href') && getRawAttribute(elt, 'href').indexOf('#') >= 0 3660 } 3661 3662 /** 3663 * @param {Element} elt 3664 * @param {HtmxSwapStyle} [swapInfoOverride] 3665 * @returns {HtmxSwapSpecification} 3666 */ 3667 function getSwapSpecification(elt, swapInfoOverride) { 3668 const swapInfo = swapInfoOverride || getClosestAttributeValue(elt, 'hx-swap') 3669 /** @type HtmxSwapSpecification */ 3670 const swapSpec = { 3671 swapStyle: getInternalData(elt).boosted ? 'innerHTML' : htmx.config.defaultSwapStyle, 3672 swapDelay: htmx.config.defaultSwapDelay, 3673 settleDelay: htmx.config.defaultSettleDelay 3674 } 3675 if (htmx.config.scrollIntoViewOnBoost && getInternalData(elt).boosted && !isAnchorLink(elt)) { 3676 swapSpec.show = 'top' 3677 } 3678 if (swapInfo) { 3679 const split = splitOnWhitespace(swapInfo) 3680 if (split.length > 0) { 3681 for (let i = 0; i < split.length; i++) { 3682 const value = split[i] 3683 if (value.indexOf('swap:') === 0) { 3684 swapSpec.swapDelay = parseInterval(value.slice(5)) 3685 } else if (value.indexOf('settle:') === 0) { 3686 swapSpec.settleDelay = parseInterval(value.slice(7)) 3687 } else if (value.indexOf('transition:') === 0) { 3688 swapSpec.transition = value.slice(11) === 'true' 3689 } else if (value.indexOf('ignoreTitle:') === 0) { 3690 swapSpec.ignoreTitle = value.slice(12) === 'true' 3691 } else if (value.indexOf('scroll:') === 0) { 3692 const scrollSpec = value.slice(7) 3693 var splitSpec = scrollSpec.split(':') 3694 const scrollVal = splitSpec.pop() 3695 var selectorVal = splitSpec.length > 0 ? splitSpec.join(':') : null 3696 // @ts-ignore 3697 swapSpec.scroll = scrollVal 3698 swapSpec.scrollTarget = selectorVal 3699 } else if (value.indexOf('show:') === 0) { 3700 const showSpec = value.slice(5) 3701 var splitSpec = showSpec.split(':') 3702 const showVal = splitSpec.pop() 3703 var selectorVal = splitSpec.length > 0 ? splitSpec.join(':') : null 3704 swapSpec.show = showVal 3705 swapSpec.showTarget = selectorVal 3706 } else if (value.indexOf('focus-scroll:') === 0) { 3707 const focusScrollVal = value.slice('focus-scroll:'.length) 3708 swapSpec.focusScroll = focusScrollVal == 'true' 3709 } else if (i == 0) { 3710 swapSpec.swapStyle = value 3711 } else { 3712 logError('Unknown modifier in hx-swap: ' + value) 3713 } 3714 } 3715 } 3716 } 3717 return swapSpec 3718 } 3719 3720 /** 3721 * @param {Element} elt 3722 * @return {boolean} 3723 */ 3724 function usesFormData(elt) { 3725 return getClosestAttributeValue(elt, 'hx-encoding') === 'multipart/form-data' || 3726 (matches(elt, 'form') && getRawAttribute(elt, 'enctype') === 'multipart/form-data') 3727 } 3728 3729 /** 3730 * @param {XMLHttpRequest} xhr 3731 * @param {Element} elt 3732 * @param {FormData} filteredParameters 3733 * @returns {*|string|null} 3734 */ 3735 function encodeParamsForBody(xhr, elt, filteredParameters) { 3736 let encodedParameters = null 3737 withExtensions(elt, function(extension) { 3738 if (encodedParameters == null) { 3739 encodedParameters = extension.encodeParameters(xhr, filteredParameters, elt) 3740 } 3741 }) 3742 if (encodedParameters != null) { 3743 return encodedParameters 3744 } else { 3745 if (usesFormData(elt)) { 3746 // Force conversion to an actual FormData object in case filteredParameters is a formDataProxy 3747 // See https://github.com/bigskysoftware/htmx/issues/2317 3748 return overrideFormData(new FormData(), formDataFromObject(filteredParameters)) 3749 } else { 3750 return urlEncode(filteredParameters) 3751 } 3752 } 3753 } 3754 3755 /** 3756 * 3757 * @param {Element} target 3758 * @returns {HtmxSettleInfo} 3759 */ 3760 function makeSettleInfo(target) { 3761 return { tasks: [], elts: [target] } 3762 } 3763 3764 /** 3765 * @param {Element[]} content 3766 * @param {HtmxSwapSpecification} swapSpec 3767 */ 3768 function updateScrollState(content, swapSpec) { 3769 const first = content[0] 3770 const last = content[content.length - 1] 3771 if (swapSpec.scroll) { 3772 var target = null 3773 if (swapSpec.scrollTarget) { 3774 target = asElement(querySelectorExt(first, swapSpec.scrollTarget)) 3775 } 3776 if (swapSpec.scroll === 'top' && (first || target)) { 3777 target = target || first 3778 target.scrollTop = 0 3779 } 3780 if (swapSpec.scroll === 'bottom' && (last || target)) { 3781 target = target || last 3782 target.scrollTop = target.scrollHeight 3783 } 3784 } 3785 if (swapSpec.show) { 3786 var target = null 3787 if (swapSpec.showTarget) { 3788 let targetStr = swapSpec.showTarget 3789 if (swapSpec.showTarget === 'window') { 3790 targetStr = 'body' 3791 } 3792 target = asElement(querySelectorExt(first, targetStr)) 3793 } 3794 if (swapSpec.show === 'top' && (first || target)) { 3795 target = target || first 3796 // @ts-ignore For some reason tsc doesn't recognize "instant" as a valid option for now 3797 target.scrollIntoView({ block: 'start', behavior: htmx.config.scrollBehavior }) 3798 } 3799 if (swapSpec.show === 'bottom' && (last || target)) { 3800 target = target || last 3801 // @ts-ignore For some reason tsc doesn't recognize "instant" as a valid option for now 3802 target.scrollIntoView({ block: 'end', behavior: htmx.config.scrollBehavior }) 3803 } 3804 } 3805 } 3806 3807 /** 3808 * @param {Element} elt 3809 * @param {string} attr 3810 * @param {boolean=} evalAsDefault 3811 * @param {Object=} values 3812 * @returns {Object} 3813 */ 3814 function getValuesForElement(elt, attr, evalAsDefault, values) { 3815 if (values == null) { 3816 values = {} 3817 } 3818 if (elt == null) { 3819 return values 3820 } 3821 const attributeValue = getAttributeValue(elt, attr) 3822 if (attributeValue) { 3823 let str = attributeValue.trim() 3824 let evaluateValue = evalAsDefault 3825 if (str === 'unset') { 3826 return null 3827 } 3828 if (str.indexOf('javascript:') === 0) { 3829 str = str.slice(11) 3830 evaluateValue = true 3831 } else if (str.indexOf('js:') === 0) { 3832 str = str.slice(3) 3833 evaluateValue = true 3834 } 3835 if (str.indexOf('{') !== 0) { 3836 str = '{' + str + '}' 3837 } 3838 let varsValues 3839 if (evaluateValue) { 3840 varsValues = maybeEval(elt, function() { return Function('return (' + str + ')')() }, {}) 3841 } else { 3842 varsValues = parseJSON(str) 3843 } 3844 for (const key in varsValues) { 3845 if (varsValues.hasOwnProperty(key)) { 3846 if (values[key] == null) { 3847 values[key] = varsValues[key] 3848 } 3849 } 3850 } 3851 } 3852 return getValuesForElement(asElement(parentElt(elt)), attr, evalAsDefault, values) 3853 } 3854 3855 /** 3856 * @param {EventTarget|string} elt 3857 * @param {() => any} toEval 3858 * @param {any=} defaultVal 3859 * @returns {any} 3860 */ 3861 function maybeEval(elt, toEval, defaultVal) { 3862 if (htmx.config.allowEval) { 3863 return toEval() 3864 } else { 3865 triggerErrorEvent(elt, 'htmx:evalDisallowedError') 3866 return defaultVal 3867 } 3868 } 3869 3870 /** 3871 * @param {Element} elt 3872 * @param {*?} expressionVars 3873 * @returns 3874 */ 3875 function getHXVarsForElement(elt, expressionVars) { 3876 return getValuesForElement(elt, 'hx-vars', true, expressionVars) 3877 } 3878 3879 /** 3880 * @param {Element} elt 3881 * @param {*?} expressionVars 3882 * @returns 3883 */ 3884 function getHXValsForElement(elt, expressionVars) { 3885 return getValuesForElement(elt, 'hx-vals', false, expressionVars) 3886 } 3887 3888 /** 3889 * @param {Element} elt 3890 * @returns {FormData} 3891 */ 3892 function getExpressionVars(elt) { 3893 return mergeObjects(getHXVarsForElement(elt), getHXValsForElement(elt)) 3894 } 3895 3896 /** 3897 * @param {XMLHttpRequest} xhr 3898 * @param {string} header 3899 * @param {string|null} headerValue 3900 */ 3901 function safelySetHeaderValue(xhr, header, headerValue) { 3902 if (headerValue !== null) { 3903 try { 3904 xhr.setRequestHeader(header, headerValue) 3905 } catch (e) { 3906 // On an exception, try to set the header URI encoded instead 3907 xhr.setRequestHeader(header, encodeURIComponent(headerValue)) 3908 xhr.setRequestHeader(header + '-URI-AutoEncoded', 'true') 3909 } 3910 } 3911 } 3912 3913 /** 3914 * @param {XMLHttpRequest} xhr 3915 * @return {string} 3916 */ 3917 function getPathFromResponse(xhr) { 3918 // NB: IE11 does not support this stuff 3919 if (xhr.responseURL && typeof (URL) !== 'undefined') { 3920 try { 3921 const url = new URL(xhr.responseURL) 3922 return url.pathname + url.search 3923 } catch (e) { 3924 triggerErrorEvent(getDocument().body, 'htmx:badResponseUrl', { url: xhr.responseURL }) 3925 } 3926 } 3927 } 3928 3929 /** 3930 * @param {XMLHttpRequest} xhr 3931 * @param {RegExp} regexp 3932 * @return {boolean} 3933 */ 3934 function hasHeader(xhr, regexp) { 3935 return regexp.test(xhr.getAllResponseHeaders()) 3936 } 3937 3938 /** 3939 * Issues an htmx-style AJAX request 3940 * 3941 * @see https://htmx.org/api/#ajax 3942 * 3943 * @param {HttpVerb} verb 3944 * @param {string} path the URL path to make the AJAX 3945 * @param {Element|string|HtmxAjaxHelperContext} context the element to target (defaults to the **body**) | a selector for the target | a context object that contains any of the following 3946 * @return {Promise<void>} Promise that resolves immediately if no request is sent, or when the request is complete 3947 */ 3948 function ajaxHelper(verb, path, context) { 3949 verb = (/** @type HttpVerb */(verb.toLowerCase())) 3950 if (context) { 3951 if (context instanceof Element || typeof context === 'string') { 3952 return issueAjaxRequest(verb, path, null, null, { 3953 targetOverride: resolveTarget(context) || DUMMY_ELT, 3954 returnPromise: true 3955 }) 3956 } else { 3957 let resolvedTarget = resolveTarget(context.target) 3958 // If target is supplied but can't resolve OR source is supplied but both target and source can't be resolved 3959 // then use DUMMY_ELT to abort the request with htmx:targetError to avoid it replacing body by mistake 3960 if ((context.target && !resolvedTarget) || (context.source && !resolvedTarget && !resolveTarget(context.source))) { 3961 resolvedTarget = DUMMY_ELT 3962 } 3963 return issueAjaxRequest(verb, path, resolveTarget(context.source), context.event, 3964 { 3965 handler: context.handler, 3966 headers: context.headers, 3967 values: context.values, 3968 targetOverride: resolvedTarget, 3969 swapOverride: context.swap, 3970 select: context.select, 3971 returnPromise: true 3972 }) 3973 } 3974 } else { 3975 return issueAjaxRequest(verb, path, null, null, { 3976 returnPromise: true 3977 }) 3978 } 3979 } 3980 3981 /** 3982 * @param {Element} elt 3983 * @return {Element[]} 3984 */ 3985 function hierarchyForElt(elt) { 3986 const arr = [] 3987 while (elt) { 3988 arr.push(elt) 3989 elt = elt.parentElement 3990 } 3991 return arr 3992 } 3993 3994 /** 3995 * @param {Element} elt 3996 * @param {string} path 3997 * @param {HtmxRequestConfig} requestConfig 3998 * @return {boolean} 3999 */ 4000 function verifyPath(elt, path, requestConfig) { 4001 let sameHost 4002 let url 4003 if (typeof URL === 'function') { 4004 url = new URL(path, document.location.href) 4005 const origin = document.location.origin 4006 sameHost = origin === url.origin 4007 } else { 4008 // IE11 doesn't support URL 4009 url = path 4010 sameHost = startsWith(path, document.location.origin) 4011 } 4012 4013 if (htmx.config.selfRequestsOnly) { 4014 if (!sameHost) { 4015 return false 4016 } 4017 } 4018 return triggerEvent(elt, 'htmx:validateUrl', mergeObjects({ url, sameHost }, requestConfig)) 4019 } 4020 4021 /** 4022 * @param {Object|FormData} obj 4023 * @return {FormData} 4024 */ 4025 function formDataFromObject(obj) { 4026 if (obj instanceof FormData) return obj 4027 const formData = new FormData() 4028 for (const key in obj) { 4029 if (obj.hasOwnProperty(key)) { 4030 if (obj[key] && typeof obj[key].forEach === 'function') { 4031 obj[key].forEach(function(v) { formData.append(key, v) }) 4032 } else if (typeof obj[key] === 'object' && !(obj[key] instanceof Blob)) { 4033 formData.append(key, JSON.stringify(obj[key])) 4034 } else { 4035 formData.append(key, obj[key]) 4036 } 4037 } 4038 } 4039 return formData 4040 } 4041 4042 /** 4043 * @param {FormData} formData 4044 * @param {string} name 4045 * @param {Array} array 4046 * @returns {Array} 4047 */ 4048 function formDataArrayProxy(formData, name, array) { 4049 // mutating the array should mutate the underlying form data 4050 return new Proxy(array, { 4051 get: function(target, key) { 4052 if (typeof key === 'number') return target[key] 4053 if (key === 'length') return target.length 4054 if (key === 'push') { 4055 return function(value) { 4056 target.push(value) 4057 formData.append(name, value) 4058 } 4059 } 4060 if (typeof target[key] === 'function') { 4061 return function() { 4062 target[key].apply(target, arguments) 4063 formData.delete(name) 4064 target.forEach(function(v) { formData.append(name, v) }) 4065 } 4066 } 4067 4068 if (target[key] && target[key].length === 1) { 4069 return target[key][0] 4070 } else { 4071 return target[key] 4072 } 4073 }, 4074 set: function(target, index, value) { 4075 target[index] = value 4076 formData.delete(name) 4077 target.forEach(function(v) { formData.append(name, v) }) 4078 return true 4079 } 4080 }) 4081 } 4082 4083 /** 4084 * @param {FormData} formData 4085 * @returns {Object} 4086 */ 4087 function formDataProxy(formData) { 4088 return new Proxy(formData, { 4089 get: function(target, name) { 4090 if (typeof name === 'symbol') { 4091 // Forward symbol calls to the FormData itself directly 4092 const result = Reflect.get(target, name) 4093 // Wrap in function with apply to correctly bind the FormData context, as a direct call would result in an illegal invocation error 4094 if (typeof result === 'function') { 4095 return function() { 4096 return result.apply(formData, arguments) 4097 } 4098 } else { 4099 return result 4100 } 4101 } 4102 if (name === 'toJSON') { 4103 // Support JSON.stringify call on proxy 4104 return () => Object.fromEntries(formData) 4105 } 4106 if (name in target) { 4107 // Wrap in function with apply to correctly bind the FormData context, as a direct call would result in an illegal invocation error 4108 if (typeof target[name] === 'function') { 4109 return function() { 4110 return formData[name].apply(formData, arguments) 4111 } 4112 } else { 4113 return target[name] 4114 } 4115 } 4116 const array = formData.getAll(name) 4117 // Those 2 undefined & single value returns are for retro-compatibility as we weren't using FormData before 4118 if (array.length === 0) { 4119 return undefined 4120 } else if (array.length === 1) { 4121 return array[0] 4122 } else { 4123 return formDataArrayProxy(target, name, array) 4124 } 4125 }, 4126 set: function(target, name, value) { 4127 if (typeof name !== 'string') { 4128 return false 4129 } 4130 target.delete(name) 4131 if (value && typeof value.forEach === 'function') { 4132 value.forEach(function(v) { target.append(name, v) }) 4133 } else if (typeof value === 'object' && !(value instanceof Blob)) { 4134 target.append(name, JSON.stringify(value)) 4135 } else { 4136 target.append(name, value) 4137 } 4138 return true 4139 }, 4140 deleteProperty: function(target, name) { 4141 if (typeof name === 'string') { 4142 target.delete(name) 4143 } 4144 return true 4145 }, 4146 // Support Object.assign call from proxy 4147 ownKeys: function(target) { 4148 return Reflect.ownKeys(Object.fromEntries(target)) 4149 }, 4150 getOwnPropertyDescriptor: function(target, prop) { 4151 return Reflect.getOwnPropertyDescriptor(Object.fromEntries(target), prop) 4152 } 4153 }) 4154 } 4155 4156 /** 4157 * @param {HttpVerb} verb 4158 * @param {string} path 4159 * @param {Element} elt 4160 * @param {Event} event 4161 * @param {HtmxAjaxEtc} [etc] 4162 * @param {boolean} [confirmed] 4163 * @return {Promise<void>} 4164 */ 4165 function issueAjaxRequest(verb, path, elt, event, etc, confirmed) { 4166 let resolve = null 4167 let reject = null 4168 etc = etc != null ? etc : {} 4169 if (etc.returnPromise && typeof Promise !== 'undefined') { 4170 var promise = new Promise(function(_resolve, _reject) { 4171 resolve = _resolve 4172 reject = _reject 4173 }) 4174 } 4175 if (elt == null) { 4176 elt = getDocument().body 4177 } 4178 const responseHandler = etc.handler || handleAjaxResponse 4179 const select = etc.select || null 4180 4181 if (!bodyContains(elt)) { 4182 // do not issue requests for elements removed from the DOM 4183 maybeCall(resolve) 4184 return promise 4185 } 4186 const target = etc.targetOverride || asElement(getTarget(elt)) 4187 if (target == null || target == DUMMY_ELT) { 4188 triggerErrorEvent(elt, 'htmx:targetError', { target: getAttributeValue(elt, 'hx-target') }) 4189 maybeCall(reject) 4190 return promise 4191 } 4192 4193 let eltData = getInternalData(elt) 4194 const submitter = eltData.lastButtonClicked 4195 4196 if (submitter) { 4197 const buttonPath = getRawAttribute(submitter, 'formaction') 4198 if (buttonPath != null) { 4199 path = buttonPath 4200 } 4201 4202 const buttonVerb = getRawAttribute(submitter, 'formmethod') 4203 if (buttonVerb != null) { 4204 // ignore buttons with formmethod="dialog" 4205 if (buttonVerb.toLowerCase() !== 'dialog') { 4206 verb = (/** @type HttpVerb */(buttonVerb)) 4207 } 4208 } 4209 } 4210 4211 const confirmQuestion = getClosestAttributeValue(elt, 'hx-confirm') 4212 // allow event-based confirmation w/ a callback 4213 if (confirmed === undefined) { 4214 const issueRequest = function(skipConfirmation) { 4215 return issueAjaxRequest(verb, path, elt, event, etc, !!skipConfirmation) 4216 } 4217 const confirmDetails = { target, elt, path, verb, triggeringEvent: event, etc, issueRequest, question: confirmQuestion } 4218 if (triggerEvent(elt, 'htmx:confirm', confirmDetails) === false) { 4219 maybeCall(resolve) 4220 return promise 4221 } 4222 } 4223 4224 let syncElt = elt 4225 let syncStrategy = getClosestAttributeValue(elt, 'hx-sync') 4226 let queueStrategy = null 4227 let abortable = false 4228 if (syncStrategy) { 4229 const syncStrings = syncStrategy.split(':') 4230 const selector = syncStrings[0].trim() 4231 if (selector === 'this') { 4232 syncElt = findThisElement(elt, 'hx-sync') 4233 } else { 4234 syncElt = asElement(querySelectorExt(elt, selector)) 4235 } 4236 // default to the drop strategy 4237 syncStrategy = (syncStrings[1] || 'drop').trim() 4238 eltData = getInternalData(syncElt) 4239 if (syncStrategy === 'drop' && eltData.xhr && eltData.abortable !== true) { 4240 maybeCall(resolve) 4241 return promise 4242 } else if (syncStrategy === 'abort') { 4243 if (eltData.xhr) { 4244 maybeCall(resolve) 4245 return promise 4246 } else { 4247 abortable = true 4248 } 4249 } else if (syncStrategy === 'replace') { 4250 triggerEvent(syncElt, 'htmx:abort') // abort the current request and continue 4251 } else if (syncStrategy.indexOf('queue') === 0) { 4252 const queueStrArray = syncStrategy.split(' ') 4253 queueStrategy = (queueStrArray[1] || 'last').trim() 4254 } 4255 } 4256 4257 if (eltData.xhr) { 4258 if (eltData.abortable) { 4259 triggerEvent(syncElt, 'htmx:abort') // abort the current request and continue 4260 } else { 4261 if (queueStrategy == null) { 4262 if (event) { 4263 const eventData = getInternalData(event) 4264 if (eventData && eventData.triggerSpec && eventData.triggerSpec.queue) { 4265 queueStrategy = eventData.triggerSpec.queue 4266 } 4267 } 4268 if (queueStrategy == null) { 4269 queueStrategy = 'last' 4270 } 4271 } 4272 if (eltData.queuedRequests == null) { 4273 eltData.queuedRequests = [] 4274 } 4275 if (queueStrategy === 'first' && eltData.queuedRequests.length === 0) { 4276 eltData.queuedRequests.push(function() { 4277 issueAjaxRequest(verb, path, elt, event, etc) 4278 }) 4279 } else if (queueStrategy === 'all') { 4280 eltData.queuedRequests.push(function() { 4281 issueAjaxRequest(verb, path, elt, event, etc) 4282 }) 4283 } else if (queueStrategy === 'last') { 4284 eltData.queuedRequests = [] // dump existing queue 4285 eltData.queuedRequests.push(function() { 4286 issueAjaxRequest(verb, path, elt, event, etc) 4287 }) 4288 } 4289 maybeCall(resolve) 4290 return promise 4291 } 4292 } 4293 4294 const xhr = new XMLHttpRequest() 4295 eltData.xhr = xhr 4296 eltData.abortable = abortable 4297 const endRequestLock = function() { 4298 eltData.xhr = null 4299 eltData.abortable = false 4300 if (eltData.queuedRequests != null && 4301 eltData.queuedRequests.length > 0) { 4302 const queuedRequest = eltData.queuedRequests.shift() 4303 queuedRequest() 4304 } 4305 } 4306 const promptQuestion = getClosestAttributeValue(elt, 'hx-prompt') 4307 if (promptQuestion) { 4308 var promptResponse = prompt(promptQuestion) 4309 // prompt returns null if cancelled and empty string if accepted with no entry 4310 if (promptResponse === null || 4311 !triggerEvent(elt, 'htmx:prompt', { prompt: promptResponse, target })) { 4312 maybeCall(resolve) 4313 endRequestLock() 4314 return promise 4315 } 4316 } 4317 4318 if (confirmQuestion && !confirmed) { 4319 if (!confirm(confirmQuestion)) { 4320 maybeCall(resolve) 4321 endRequestLock() 4322 return promise 4323 } 4324 } 4325 4326 let headers = getHeaders(elt, target, promptResponse) 4327 4328 if (verb !== 'get' && !usesFormData(elt)) { 4329 headers['Content-Type'] = 'application/x-www-form-urlencoded' 4330 } 4331 4332 if (etc.headers) { 4333 headers = mergeObjects(headers, etc.headers) 4334 } 4335 const results = getInputValues(elt, verb) 4336 let errors = results.errors 4337 const rawFormData = results.formData 4338 if (etc.values) { 4339 overrideFormData(rawFormData, formDataFromObject(etc.values)) 4340 } 4341 const expressionVars = formDataFromObject(getExpressionVars(elt)) 4342 const allFormData = overrideFormData(rawFormData, expressionVars) 4343 let filteredFormData = filterValues(allFormData, elt) 4344 4345 if (htmx.config.getCacheBusterParam && verb === 'get') { 4346 filteredFormData.set('org.htmx.cache-buster', getRawAttribute(target, 'id') || 'true') 4347 } 4348 4349 // behavior of anchors w/ empty href is to use the current URL 4350 if (path == null || path === '') { 4351 path = getDocument().location.href 4352 } 4353 4354 /** 4355 * @type {Object} 4356 * @property {boolean} [credentials] 4357 * @property {number} [timeout] 4358 * @property {boolean} [noHeaders] 4359 */ 4360 const requestAttrValues = getValuesForElement(elt, 'hx-request') 4361 4362 const eltIsBoosted = getInternalData(elt).boosted 4363 4364 let useUrlParams = htmx.config.methodsThatUseUrlParams.indexOf(verb) >= 0 4365 4366 /** @type HtmxRequestConfig */ 4367 const requestConfig = { 4368 boosted: eltIsBoosted, 4369 useUrlParams, 4370 formData: filteredFormData, 4371 parameters: formDataProxy(filteredFormData), 4372 unfilteredFormData: allFormData, 4373 unfilteredParameters: formDataProxy(allFormData), 4374 headers, 4375 target, 4376 verb, 4377 errors, 4378 withCredentials: etc.credentials || requestAttrValues.credentials || htmx.config.withCredentials, 4379 timeout: etc.timeout || requestAttrValues.timeout || htmx.config.timeout, 4380 path, 4381 triggeringEvent: event 4382 } 4383 4384 if (!triggerEvent(elt, 'htmx:configRequest', requestConfig)) { 4385 maybeCall(resolve) 4386 endRequestLock() 4387 return promise 4388 } 4389 4390 // copy out in case the object was overwritten 4391 path = requestConfig.path 4392 verb = requestConfig.verb 4393 headers = requestConfig.headers 4394 filteredFormData = formDataFromObject(requestConfig.parameters) 4395 errors = requestConfig.errors 4396 useUrlParams = requestConfig.useUrlParams 4397 4398 if (errors && errors.length > 0) { 4399 triggerEvent(elt, 'htmx:validation:halted', requestConfig) 4400 maybeCall(resolve) 4401 endRequestLock() 4402 return promise 4403 } 4404 4405 const splitPath = path.split('#') 4406 const pathNoAnchor = splitPath[0] 4407 const anchor = splitPath[1] 4408 4409 let finalPath = path 4410 if (useUrlParams) { 4411 finalPath = pathNoAnchor 4412 const hasValues = !filteredFormData.keys().next().done 4413 if (hasValues) { 4414 if (finalPath.indexOf('?') < 0) { 4415 finalPath += '?' 4416 } else { 4417 finalPath += '&' 4418 } 4419 finalPath += urlEncode(filteredFormData) 4420 if (anchor) { 4421 finalPath += '#' + anchor 4422 } 4423 } 4424 } 4425 4426 if (!verifyPath(elt, finalPath, requestConfig)) { 4427 triggerErrorEvent(elt, 'htmx:invalidPath', requestConfig) 4428 maybeCall(reject) 4429 return promise 4430 } 4431 4432 xhr.open(verb.toUpperCase(), finalPath, true) 4433 xhr.overrideMimeType('text/html') 4434 xhr.withCredentials = requestConfig.withCredentials 4435 xhr.timeout = requestConfig.timeout 4436 4437 // request headers 4438 if (requestAttrValues.noHeaders) { 4439 // ignore all headers 4440 } else { 4441 for (const header in headers) { 4442 if (headers.hasOwnProperty(header)) { 4443 const headerValue = headers[header] 4444 safelySetHeaderValue(xhr, header, headerValue) 4445 } 4446 } 4447 } 4448 4449 /** @type {HtmxResponseInfo} */ 4450 const responseInfo = { 4451 xhr, 4452 target, 4453 requestConfig, 4454 etc, 4455 boosted: eltIsBoosted, 4456 select, 4457 pathInfo: { 4458 requestPath: path, 4459 finalRequestPath: finalPath, 4460 responsePath: null, 4461 anchor 4462 } 4463 } 4464 4465 xhr.onload = function() { 4466 try { 4467 const hierarchy = hierarchyForElt(elt) 4468 responseInfo.pathInfo.responsePath = getPathFromResponse(xhr) 4469 responseHandler(elt, responseInfo) 4470 if (responseInfo.keepIndicators !== true) { 4471 removeRequestIndicators(indicators, disableElts) 4472 } 4473 triggerEvent(elt, 'htmx:afterRequest', responseInfo) 4474 triggerEvent(elt, 'htmx:afterOnLoad', responseInfo) 4475 // if the body no longer contains the element, trigger the event on the closest parent 4476 // remaining in the DOM 4477 if (!bodyContains(elt)) { 4478 let secondaryTriggerElt = null 4479 while (hierarchy.length > 0 && secondaryTriggerElt == null) { 4480 const parentEltInHierarchy = hierarchy.shift() 4481 if (bodyContains(parentEltInHierarchy)) { 4482 secondaryTriggerElt = parentEltInHierarchy 4483 } 4484 } 4485 if (secondaryTriggerElt) { 4486 triggerEvent(secondaryTriggerElt, 'htmx:afterRequest', responseInfo) 4487 triggerEvent(secondaryTriggerElt, 'htmx:afterOnLoad', responseInfo) 4488 } 4489 } 4490 maybeCall(resolve) 4491 endRequestLock() 4492 } catch (e) { 4493 triggerErrorEvent(elt, 'htmx:onLoadError', mergeObjects({ error: e }, responseInfo)) 4494 throw e 4495 } 4496 } 4497 xhr.onerror = function() { 4498 removeRequestIndicators(indicators, disableElts) 4499 triggerErrorEvent(elt, 'htmx:afterRequest', responseInfo) 4500 triggerErrorEvent(elt, 'htmx:sendError', responseInfo) 4501 maybeCall(reject) 4502 endRequestLock() 4503 } 4504 xhr.onabort = function() { 4505 removeRequestIndicators(indicators, disableElts) 4506 triggerErrorEvent(elt, 'htmx:afterRequest', responseInfo) 4507 triggerErrorEvent(elt, 'htmx:sendAbort', responseInfo) 4508 maybeCall(reject) 4509 endRequestLock() 4510 } 4511 xhr.ontimeout = function() { 4512 removeRequestIndicators(indicators, disableElts) 4513 triggerErrorEvent(elt, 'htmx:afterRequest', responseInfo) 4514 triggerErrorEvent(elt, 'htmx:timeout', responseInfo) 4515 maybeCall(reject) 4516 endRequestLock() 4517 } 4518 if (!triggerEvent(elt, 'htmx:beforeRequest', responseInfo)) { 4519 maybeCall(resolve) 4520 endRequestLock() 4521 return promise 4522 } 4523 var indicators = addRequestIndicatorClasses(elt) 4524 var disableElts = disableElements(elt) 4525 4526 forEach(['loadstart', 'loadend', 'progress', 'abort'], function(eventName) { 4527 forEach([xhr, xhr.upload], function(target) { 4528 target.addEventListener(eventName, function(event) { 4529 triggerEvent(elt, 'htmx:xhr:' + eventName, { 4530 lengthComputable: event.lengthComputable, 4531 loaded: event.loaded, 4532 total: event.total 4533 }) 4534 }) 4535 }) 4536 }) 4537 triggerEvent(elt, 'htmx:beforeSend', responseInfo) 4538 const params = useUrlParams ? null : encodeParamsForBody(xhr, elt, filteredFormData) 4539 xhr.send(params) 4540 return promise 4541 } 4542 4543 /** 4544 * @typedef {Object} HtmxHistoryUpdate 4545 * @property {string|null} [type] 4546 * @property {string|null} [path] 4547 */ 4548 4549 /** 4550 * @param {Element} elt 4551 * @param {HtmxResponseInfo} responseInfo 4552 * @return {HtmxHistoryUpdate} 4553 */ 4554 function determineHistoryUpdates(elt, responseInfo) { 4555 const xhr = responseInfo.xhr 4556 4557 //= ========================================== 4558 // First consult response headers 4559 //= ========================================== 4560 let pathFromHeaders = null 4561 let typeFromHeaders = null 4562 if (hasHeader(xhr, /HX-Push:/i)) { 4563 pathFromHeaders = xhr.getResponseHeader('HX-Push') 4564 typeFromHeaders = 'push' 4565 } else if (hasHeader(xhr, /HX-Push-Url:/i)) { 4566 pathFromHeaders = xhr.getResponseHeader('HX-Push-Url') 4567 typeFromHeaders = 'push' 4568 } else if (hasHeader(xhr, /HX-Replace-Url:/i)) { 4569 pathFromHeaders = xhr.getResponseHeader('HX-Replace-Url') 4570 typeFromHeaders = 'replace' 4571 } 4572 4573 // if there was a response header, that has priority 4574 if (pathFromHeaders) { 4575 if (pathFromHeaders === 'false') { 4576 return {} 4577 } else { 4578 return { 4579 type: typeFromHeaders, 4580 path: pathFromHeaders 4581 } 4582 } 4583 } 4584 4585 //= ========================================== 4586 // Next resolve via DOM values 4587 //= ========================================== 4588 const requestPath = responseInfo.pathInfo.finalRequestPath 4589 const responsePath = responseInfo.pathInfo.responsePath 4590 4591 const pushUrl = getClosestAttributeValue(elt, 'hx-push-url') 4592 const replaceUrl = getClosestAttributeValue(elt, 'hx-replace-url') 4593 const elementIsBoosted = getInternalData(elt).boosted 4594 4595 let saveType = null 4596 let path = null 4597 4598 if (pushUrl) { 4599 saveType = 'push' 4600 path = pushUrl 4601 } else if (replaceUrl) { 4602 saveType = 'replace' 4603 path = replaceUrl 4604 } else if (elementIsBoosted) { 4605 saveType = 'push' 4606 path = responsePath || requestPath // if there is no response path, go with the original request path 4607 } 4608 4609 if (path) { 4610 // false indicates no push, return empty object 4611 if (path === 'false') { 4612 return {} 4613 } 4614 4615 // true indicates we want to follow wherever the server ended up sending us 4616 if (path === 'true') { 4617 path = responsePath || requestPath // if there is no response path, go with the original request path 4618 } 4619 4620 // restore any anchor associated with the request 4621 if (responseInfo.pathInfo.anchor && path.indexOf('#') === -1) { 4622 path = path + '#' + responseInfo.pathInfo.anchor 4623 } 4624 4625 return { 4626 type: saveType, 4627 path 4628 } 4629 } else { 4630 return {} 4631 } 4632 } 4633 4634 /** 4635 * @param {HtmxResponseHandlingConfig} responseHandlingConfig 4636 * @param {number} status 4637 * @return {boolean} 4638 */ 4639 function codeMatches(responseHandlingConfig, status) { 4640 var regExp = new RegExp(responseHandlingConfig.code) 4641 return regExp.test(status.toString(10)) 4642 } 4643 4644 /** 4645 * @param {XMLHttpRequest} xhr 4646 * @return {HtmxResponseHandlingConfig} 4647 */ 4648 function resolveResponseHandling(xhr) { 4649 for (var i = 0; i < htmx.config.responseHandling.length; i++) { 4650 /** @type HtmxResponseHandlingConfig */ 4651 var responseHandlingElement = htmx.config.responseHandling[i] 4652 if (codeMatches(responseHandlingElement, xhr.status)) { 4653 return responseHandlingElement 4654 } 4655 } 4656 // no matches, return no swap 4657 return { 4658 swap: false 4659 } 4660 } 4661 4662 /** 4663 * @param {string} title 4664 */ 4665 function handleTitle(title) { 4666 if (title) { 4667 const titleElt = find('title') 4668 if (titleElt) { 4669 titleElt.innerHTML = title 4670 } else { 4671 window.document.title = title 4672 } 4673 } 4674 } 4675 4676 /** 4677 * @param {Element} elt 4678 * @param {HtmxResponseInfo} responseInfo 4679 */ 4680 function handleAjaxResponse(elt, responseInfo) { 4681 const xhr = responseInfo.xhr 4682 let target = responseInfo.target 4683 const etc = responseInfo.etc 4684 const responseInfoSelect = responseInfo.select 4685 4686 if (!triggerEvent(elt, 'htmx:beforeOnLoad', responseInfo)) return 4687 4688 if (hasHeader(xhr, /HX-Trigger:/i)) { 4689 handleTriggerHeader(xhr, 'HX-Trigger', elt) 4690 } 4691 4692 if (hasHeader(xhr, /HX-Location:/i)) { 4693 saveCurrentPageToHistory() 4694 let redirectPath = xhr.getResponseHeader('HX-Location') 4695 /** @type {HtmxAjaxHelperContext&{path:string}} */ 4696 var redirectSwapSpec 4697 if (redirectPath.indexOf('{') === 0) { 4698 redirectSwapSpec = parseJSON(redirectPath) 4699 // what's the best way to throw an error if the user didn't include this 4700 redirectPath = redirectSwapSpec.path 4701 delete redirectSwapSpec.path 4702 } 4703 ajaxHelper('get', redirectPath, redirectSwapSpec).then(function() { 4704 pushUrlIntoHistory(redirectPath) 4705 }) 4706 return 4707 } 4708 4709 const shouldRefresh = hasHeader(xhr, /HX-Refresh:/i) && xhr.getResponseHeader('HX-Refresh') === 'true' 4710 4711 if (hasHeader(xhr, /HX-Redirect:/i)) { 4712 responseInfo.keepIndicators = true 4713 location.href = xhr.getResponseHeader('HX-Redirect') 4714 shouldRefresh && location.reload() 4715 return 4716 } 4717 4718 if (shouldRefresh) { 4719 responseInfo.keepIndicators = true 4720 location.reload() 4721 return 4722 } 4723 4724 if (hasHeader(xhr, /HX-Retarget:/i)) { 4725 if (xhr.getResponseHeader('HX-Retarget') === 'this') { 4726 responseInfo.target = elt 4727 } else { 4728 responseInfo.target = asElement(querySelectorExt(elt, xhr.getResponseHeader('HX-Retarget'))) 4729 } 4730 } 4731 4732 const historyUpdate = determineHistoryUpdates(elt, responseInfo) 4733 4734 const responseHandling = resolveResponseHandling(xhr) 4735 const shouldSwap = responseHandling.swap 4736 let isError = !!responseHandling.error 4737 let ignoreTitle = htmx.config.ignoreTitle || responseHandling.ignoreTitle 4738 let selectOverride = responseHandling.select 4739 if (responseHandling.target) { 4740 responseInfo.target = asElement(querySelectorExt(elt, responseHandling.target)) 4741 } 4742 var swapOverride = etc.swapOverride 4743 if (swapOverride == null && responseHandling.swapOverride) { 4744 swapOverride = responseHandling.swapOverride 4745 } 4746 4747 // response headers override response handling config 4748 if (hasHeader(xhr, /HX-Retarget:/i)) { 4749 if (xhr.getResponseHeader('HX-Retarget') === 'this') { 4750 responseInfo.target = elt 4751 } else { 4752 responseInfo.target = asElement(querySelectorExt(elt, xhr.getResponseHeader('HX-Retarget'))) 4753 } 4754 } 4755 if (hasHeader(xhr, /HX-Reswap:/i)) { 4756 swapOverride = xhr.getResponseHeader('HX-Reswap') 4757 } 4758 4759 var serverResponse = xhr.response 4760 /** @type HtmxBeforeSwapDetails */ 4761 var beforeSwapDetails = mergeObjects({ 4762 shouldSwap, 4763 serverResponse, 4764 isError, 4765 ignoreTitle, 4766 selectOverride, 4767 swapOverride 4768 }, responseInfo) 4769 4770 if (responseHandling.event && !triggerEvent(target, responseHandling.event, beforeSwapDetails)) return 4771 4772 if (!triggerEvent(target, 'htmx:beforeSwap', beforeSwapDetails)) return 4773 4774 target = beforeSwapDetails.target // allow re-targeting 4775 serverResponse = beforeSwapDetails.serverResponse // allow updating content 4776 isError = beforeSwapDetails.isError // allow updating error 4777 ignoreTitle = beforeSwapDetails.ignoreTitle // allow updating ignoring title 4778 selectOverride = beforeSwapDetails.selectOverride // allow updating select override 4779 swapOverride = beforeSwapDetails.swapOverride // allow updating swap override 4780 4781 responseInfo.target = target // Make updated target available to response events 4782 responseInfo.failed = isError // Make failed property available to response events 4783 responseInfo.successful = !isError // Make successful property available to response events 4784 4785 if (beforeSwapDetails.shouldSwap) { 4786 if (xhr.status === 286) { 4787 cancelPolling(elt) 4788 } 4789 4790 withExtensions(elt, function(extension) { 4791 serverResponse = extension.transformResponse(serverResponse, xhr, elt) 4792 }) 4793 4794 // Save current page if there will be a history update 4795 if (historyUpdate.type) { 4796 saveCurrentPageToHistory() 4797 } 4798 4799 var swapSpec = getSwapSpecification(elt, swapOverride) 4800 4801 if (!swapSpec.hasOwnProperty('ignoreTitle')) { 4802 swapSpec.ignoreTitle = ignoreTitle 4803 } 4804 4805 target.classList.add(htmx.config.swappingClass) 4806 4807 // optional transition API promise callbacks 4808 let settleResolve = null 4809 let settleReject = null 4810 4811 if (responseInfoSelect) { 4812 selectOverride = responseInfoSelect 4813 } 4814 4815 if (hasHeader(xhr, /HX-Reselect:/i)) { 4816 selectOverride = xhr.getResponseHeader('HX-Reselect') 4817 } 4818 4819 const selectOOB = getClosestAttributeValue(elt, 'hx-select-oob') 4820 const select = getClosestAttributeValue(elt, 'hx-select') 4821 4822 let doSwap = function() { 4823 try { 4824 // if we need to save history, do so, before swapping so that relative resources have the correct base URL 4825 if (historyUpdate.type) { 4826 triggerEvent(getDocument().body, 'htmx:beforeHistoryUpdate', mergeObjects({ history: historyUpdate }, responseInfo)) 4827 if (historyUpdate.type === 'push') { 4828 pushUrlIntoHistory(historyUpdate.path) 4829 triggerEvent(getDocument().body, 'htmx:pushedIntoHistory', { path: historyUpdate.path }) 4830 } else { 4831 replaceUrlInHistory(historyUpdate.path) 4832 triggerEvent(getDocument().body, 'htmx:replacedInHistory', { path: historyUpdate.path }) 4833 } 4834 } 4835 4836 swap(target, serverResponse, swapSpec, { 4837 select: selectOverride || select, 4838 selectOOB, 4839 eventInfo: responseInfo, 4840 anchor: responseInfo.pathInfo.anchor, 4841 contextElement: elt, 4842 afterSwapCallback: function() { 4843 if (hasHeader(xhr, /HX-Trigger-After-Swap:/i)) { 4844 let finalElt = elt 4845 if (!bodyContains(elt)) { 4846 finalElt = getDocument().body 4847 } 4848 handleTriggerHeader(xhr, 'HX-Trigger-After-Swap', finalElt) 4849 } 4850 }, 4851 afterSettleCallback: function() { 4852 if (hasHeader(xhr, /HX-Trigger-After-Settle:/i)) { 4853 let finalElt = elt 4854 if (!bodyContains(elt)) { 4855 finalElt = getDocument().body 4856 } 4857 handleTriggerHeader(xhr, 'HX-Trigger-After-Settle', finalElt) 4858 } 4859 maybeCall(settleResolve) 4860 } 4861 }) 4862 } catch (e) { 4863 triggerErrorEvent(elt, 'htmx:swapError', responseInfo) 4864 maybeCall(settleReject) 4865 throw e 4866 } 4867 } 4868 4869 let shouldTransition = htmx.config.globalViewTransitions 4870 if (swapSpec.hasOwnProperty('transition')) { 4871 shouldTransition = swapSpec.transition 4872 } 4873 4874 if (shouldTransition && 4875 triggerEvent(elt, 'htmx:beforeTransition', responseInfo) && 4876 typeof Promise !== 'undefined' && 4877 // @ts-ignore experimental feature atm 4878 document.startViewTransition) { 4879 const settlePromise = new Promise(function(_resolve, _reject) { 4880 settleResolve = _resolve 4881 settleReject = _reject 4882 }) 4883 // wrap the original doSwap() in a call to startViewTransition() 4884 const innerDoSwap = doSwap 4885 doSwap = function() { 4886 // @ts-ignore experimental feature atm 4887 document.startViewTransition(function() { 4888 innerDoSwap() 4889 return settlePromise 4890 }) 4891 } 4892 } 4893 4894 if (swapSpec.swapDelay > 0) { 4895 getWindow().setTimeout(doSwap, swapSpec.swapDelay) 4896 } else { 4897 doSwap() 4898 } 4899 } 4900 if (isError) { 4901 triggerErrorEvent(elt, 'htmx:responseError', mergeObjects({ error: 'Response Status Error Code ' + xhr.status + ' from ' + responseInfo.pathInfo.requestPath }, responseInfo)) 4902 } 4903 } 4904 4905 //= =================================================================== 4906 // Extensions API 4907 //= =================================================================== 4908 4909 /** @type {Object<string, HtmxExtension>} */ 4910 const extensions = {} 4911 4912 /** 4913 * extensionBase defines the default functions for all extensions. 4914 * @returns {HtmxExtension} 4915 */ 4916 function extensionBase() { 4917 return { 4918 init: function(api) { return null }, 4919 getSelectors: function() { return null }, 4920 onEvent: function(name, evt) { return true }, 4921 transformResponse: function(text, xhr, elt) { return text }, 4922 isInlineSwap: function(swapStyle) { return false }, 4923 handleSwap: function(swapStyle, target, fragment, settleInfo) { return false }, 4924 encodeParameters: function(xhr, parameters, elt) { return null } 4925 } 4926 } 4927 4928 /** 4929 * defineExtension initializes the extension and adds it to the htmx registry 4930 * 4931 * @see https://htmx.org/api/#defineExtension 4932 * 4933 * @param {string} name the extension name 4934 * @param {Partial<HtmxExtension>} extension the extension definition 4935 */ 4936 function defineExtension(name, extension) { 4937 if (extension.init) { 4938 extension.init(internalAPI) 4939 } 4940 extensions[name] = mergeObjects(extensionBase(), extension) 4941 } 4942 4943 /** 4944 * removeExtension removes an extension from the htmx registry 4945 * 4946 * @see https://htmx.org/api/#removeExtension 4947 * 4948 * @param {string} name 4949 */ 4950 function removeExtension(name) { 4951 delete extensions[name] 4952 } 4953 4954 /** 4955 * getExtensions searches up the DOM tree to return all extensions that can be applied to a given element 4956 * 4957 * @param {Element} elt 4958 * @param {HtmxExtension[]=} extensionsToReturn 4959 * @param {string[]=} extensionsToIgnore 4960 * @returns {HtmxExtension[]} 4961 */ 4962 function getExtensions(elt, extensionsToReturn, extensionsToIgnore) { 4963 if (extensionsToReturn == undefined) { 4964 extensionsToReturn = [] 4965 } 4966 if (elt == undefined) { 4967 return extensionsToReturn 4968 } 4969 if (extensionsToIgnore == undefined) { 4970 extensionsToIgnore = [] 4971 } 4972 const extensionsForElement = getAttributeValue(elt, 'hx-ext') 4973 if (extensionsForElement) { 4974 forEach(extensionsForElement.split(','), function(extensionName) { 4975 extensionName = extensionName.replace(/ /g, '') 4976 if (extensionName.slice(0, 7) == 'ignore:') { 4977 extensionsToIgnore.push(extensionName.slice(7)) 4978 return 4979 } 4980 if (extensionsToIgnore.indexOf(extensionName) < 0) { 4981 const extension = extensions[extensionName] 4982 if (extension && extensionsToReturn.indexOf(extension) < 0) { 4983 extensionsToReturn.push(extension) 4984 } 4985 } 4986 }) 4987 } 4988 return getExtensions(asElement(parentElt(elt)), extensionsToReturn, extensionsToIgnore) 4989 } 4990 4991 //= =================================================================== 4992 // Initialization 4993 //= =================================================================== 4994 var isReady = false 4995 getDocument().addEventListener('DOMContentLoaded', function() { 4996 isReady = true 4997 }) 4998 4999 /** 5000 * Execute a function now if DOMContentLoaded has fired, otherwise listen for it. 5001 * 5002 * This function uses isReady because there is no reliable way to ask the browser whether 5003 * the DOMContentLoaded event has already been fired; there's a gap between DOMContentLoaded 5004 * firing and readystate=complete. 5005 */ 5006 function ready(fn) { 5007 // Checking readyState here is a failsafe in case the htmx script tag entered the DOM by 5008 // some means other than the initial page load. 5009 if (isReady || getDocument().readyState === 'complete') { 5010 fn() 5011 } else { 5012 getDocument().addEventListener('DOMContentLoaded', fn) 5013 } 5014 } 5015 5016 function insertIndicatorStyles() { 5017 if (htmx.config.includeIndicatorStyles !== false) { 5018 const nonceAttribute = htmx.config.inlineStyleNonce ? ` nonce="${htmx.config.inlineStyleNonce}"` : '' 5019 getDocument().head.insertAdjacentHTML('beforeend', 5020 '<style' + nonceAttribute + '>\ 5021 .' + htmx.config.indicatorClass + '{opacity:0}\ 5022 .' + htmx.config.requestClass + ' .' + htmx.config.indicatorClass + '{opacity:1; transition: opacity 200ms ease-in;}\ 5023 .' + htmx.config.requestClass + '.' + htmx.config.indicatorClass + '{opacity:1; transition: opacity 200ms ease-in;}\ 5024 </style>') 5025 } 5026 } 5027 5028 function getMetaConfig() { 5029 /** @type HTMLMetaElement */ 5030 const element = getDocument().querySelector('meta[name="htmx-config"]') 5031 if (element) { 5032 return parseJSON(element.content) 5033 } else { 5034 return null 5035 } 5036 } 5037 5038 function mergeMetaConfig() { 5039 const metaConfig = getMetaConfig() 5040 if (metaConfig) { 5041 htmx.config = mergeObjects(htmx.config, metaConfig) 5042 } 5043 } 5044 5045 // initialize the document 5046 ready(function() { 5047 mergeMetaConfig() 5048 insertIndicatorStyles() 5049 let body = getDocument().body 5050 processNode(body) 5051 const restoredElts = getDocument().querySelectorAll( 5052 "[hx-trigger='restored'],[data-hx-trigger='restored']" 5053 ) 5054 body.addEventListener('htmx:abort', function(evt) { 5055 const target = evt.target 5056 const internalData = getInternalData(target) 5057 if (internalData && internalData.xhr) { 5058 internalData.xhr.abort() 5059 } 5060 }) 5061 /** @type {(ev: PopStateEvent) => any} */ 5062 const originalPopstate = window.onpopstate ? window.onpopstate.bind(window) : null 5063 /** @type {(ev: PopStateEvent) => any} */ 5064 window.onpopstate = function(event) { 5065 if (event.state && event.state.htmx) { 5066 restoreHistory() 5067 forEach(restoredElts, function(elt) { 5068 triggerEvent(elt, 'htmx:restored', { 5069 document: getDocument(), 5070 triggerEvent 5071 }) 5072 }) 5073 } else { 5074 if (originalPopstate) { 5075 originalPopstate(event) 5076 } 5077 } 5078 } 5079 getWindow().setTimeout(function() { 5080 triggerEvent(body, 'htmx:load', {}) // give ready handlers a chance to load up before firing this event 5081 body = null // kill reference for gc 5082 }, 0) 5083 }) 5084 5085 return htmx 5086})() 5087 5088/** @typedef {'get'|'head'|'post'|'put'|'delete'|'connect'|'options'|'trace'|'patch'} HttpVerb */ 5089 5090/** 5091 * @typedef {Object} SwapOptions 5092 * @property {string} [select] 5093 * @property {string} [selectOOB] 5094 * @property {*} [eventInfo] 5095 * @property {string} [anchor] 5096 * @property {Element} [contextElement] 5097 * @property {swapCallback} [afterSwapCallback] 5098 * @property {swapCallback} [afterSettleCallback] 5099 */ 5100 5101/** 5102 * @callback swapCallback 5103 */ 5104 5105/** 5106 * @typedef {'innerHTML' | 'outerHTML' | 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend' | 'delete' | 'none' | string} HtmxSwapStyle 5107 */ 5108 5109/** 5110 * @typedef HtmxSwapSpecification 5111 * @property {HtmxSwapStyle} swapStyle 5112 * @property {number} swapDelay 5113 * @property {number} settleDelay 5114 * @property {boolean} [transition] 5115 * @property {boolean} [ignoreTitle] 5116 * @property {string} [head] 5117 * @property {'top' | 'bottom'} [scroll] 5118 * @property {string} [scrollTarget] 5119 * @property {string} [show] 5120 * @property {string} [showTarget] 5121 * @property {boolean} [focusScroll] 5122 */ 5123 5124/** 5125 * @typedef {((this:Node, evt:Event) => boolean) & {source: string}} ConditionalFunction 5126 */ 5127 5128/** 5129 * @typedef {Object} HtmxTriggerSpecification 5130 * @property {string} trigger 5131 * @property {number} [pollInterval] 5132 * @property {ConditionalFunction} [eventFilter] 5133 * @property {boolean} [changed] 5134 * @property {boolean} [once] 5135 * @property {boolean} [consume] 5136 * @property {number} [delay] 5137 * @property {string} [from] 5138 * @property {string} [target] 5139 * @property {number} [throttle] 5140 * @property {string} [queue] 5141 * @property {string} [root] 5142 * @property {string} [threshold] 5143 */ 5144 5145/** 5146 * @typedef {{elt: Element, message: string, validity: ValidityState}} HtmxElementValidationError 5147 */ 5148 5149/** 5150 * @typedef {Record<string, string>} HtmxHeaderSpecification 5151 * @property {'true'} HX-Request 5152 * @property {string|null} HX-Trigger 5153 * @property {string|null} HX-Trigger-Name 5154 * @property {string|null} HX-Target 5155 * @property {string} HX-Current-URL 5156 * @property {string} [HX-Prompt] 5157 * @property {'true'} [HX-Boosted] 5158 * @property {string} [Content-Type] 5159 * @property {'true'} [HX-History-Restore-Request] 5160 */ 5161 5162/** @typedef HtmxAjaxHelperContext 5163 * @property {Element|string} [source] 5164 * @property {Event} [event] 5165 * @property {HtmxAjaxHandler} [handler] 5166 * @property {Element|string} [target] 5167 * @property {HtmxSwapStyle} [swap] 5168 * @property {Object|FormData} [values] 5169 * @property {Record<string,string>} [headers] 5170 * @property {string} [select] 5171 */ 5172 5173/** 5174 * @typedef {Object} HtmxRequestConfig 5175 * @property {boolean} boosted 5176 * @property {boolean} useUrlParams 5177 * @property {FormData} formData 5178 * @property {Object} parameters formData proxy 5179 * @property {FormData} unfilteredFormData 5180 * @property {Object} unfilteredParameters unfilteredFormData proxy 5181 * @property {HtmxHeaderSpecification} headers 5182 * @property {Element} target 5183 * @property {HttpVerb} verb 5184 * @property {HtmxElementValidationError[]} errors 5185 * @property {boolean} withCredentials 5186 * @property {number} timeout 5187 * @property {string} path 5188 * @property {Event} triggeringEvent 5189 */ 5190 5191/** 5192 * @typedef {Object} HtmxResponseInfo 5193 * @property {XMLHttpRequest} xhr 5194 * @property {Element} target 5195 * @property {HtmxRequestConfig} requestConfig 5196 * @property {HtmxAjaxEtc} etc 5197 * @property {boolean} boosted 5198 * @property {string} select 5199 * @property {{requestPath: string, finalRequestPath: string, responsePath: string|null, anchor: string}} pathInfo 5200 * @property {boolean} [failed] 5201 * @property {boolean} [successful] 5202 * @property {boolean} [keepIndicators] 5203 */ 5204 5205/** 5206 * @typedef {Object} HtmxAjaxEtc 5207 * @property {boolean} [returnPromise] 5208 * @property {HtmxAjaxHandler} [handler] 5209 * @property {string} [select] 5210 * @property {Element} [targetOverride] 5211 * @property {HtmxSwapStyle} [swapOverride] 5212 * @property {Record<string,string>} [headers] 5213 * @property {Object|FormData} [values] 5214 * @property {boolean} [credentials] 5215 * @property {number} [timeout] 5216 */ 5217 5218/** 5219 * @typedef {Object} HtmxResponseHandlingConfig 5220 * @property {string} [code] 5221 * @property {boolean} swap 5222 * @property {boolean} [error] 5223 * @property {boolean} [ignoreTitle] 5224 * @property {string} [select] 5225 * @property {string} [target] 5226 * @property {string} [swapOverride] 5227 * @property {string} [event] 5228 */ 5229 5230/** 5231 * @typedef {HtmxResponseInfo & {shouldSwap: boolean, serverResponse: any, isError: boolean, ignoreTitle: boolean, selectOverride:string, swapOverride:string}} HtmxBeforeSwapDetails 5232 */ 5233 5234/** 5235 * @callback HtmxAjaxHandler 5236 * @param {Element} elt 5237 * @param {HtmxResponseInfo} responseInfo 5238 */ 5239 5240/** 5241 * @typedef {(() => void)} HtmxSettleTask 5242 */ 5243 5244/** 5245 * @typedef {Object} HtmxSettleInfo 5246 * @property {HtmxSettleTask[]} tasks 5247 * @property {Element[]} elts 5248 * @property {string} [title] 5249 */ 5250 5251/** 5252 * @see https://github.com/bigskysoftware/htmx-extensions/blob/main/README.md 5253 * @typedef {Object} HtmxExtension 5254 * @property {(api: any) => void} init 5255 * @property {(name: string, event: Event|CustomEvent) => boolean} onEvent 5256 * @property {(text: string, xhr: XMLHttpRequest, elt: Element) => string} transformResponse 5257 * @property {(swapStyle: HtmxSwapStyle) => boolean} isInlineSwap 5258 * @property {(swapStyle: HtmxSwapStyle, target: Node, fragment: Node, settleInfo: HtmxSettleInfo) => boolean|Node[]} handleSwap 5259 * @property {(xhr: XMLHttpRequest, parameters: FormData, elt: Node) => *|string|null} encodeParameters 5260 * @property {() => string[]|null} getSelectors 5261 */