1/* 2Unobtrusive JavaScript 3https://github.com/rails/rails/blob/main/actionview/app/javascript 4Released under the MIT license 5 */ 6(function(global, factory) { 7 typeof exports === "object" && typeof module !== "undefined" ? module.exports = factory() : typeof define === "function" && define.amd ? define(factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, 8 global.Rails = factory()); 9})(this, (function() { 10 "use strict"; 11 const linkClickSelector = "a[data-confirm], a[data-method], a[data-remote]:not([disabled]), a[data-disable-with], a[data-disable]"; 12 const buttonClickSelector = { 13 selector: "button[data-remote]:not([form]), button[data-confirm]:not([form])", 14 exclude: "form button" 15 }; 16 const inputChangeSelector = "select[data-remote], input[data-remote], textarea[data-remote]"; 17 const formSubmitSelector = "form:not([data-turbo=true])"; 18 const formInputClickSelector = "form:not([data-turbo=true]) input[type=submit], form:not([data-turbo=true]) input[type=image], form:not([data-turbo=true]) button[type=submit], form:not([data-turbo=true]) button:not([type]), input[type=submit][form], input[type=image][form], button[type=submit][form], button[form]:not([type])"; 19 const formDisableSelector = "input[data-disable-with]:enabled, button[data-disable-with]:enabled, textarea[data-disable-with]:enabled, input[data-disable]:enabled, button[data-disable]:enabled, textarea[data-disable]:enabled"; 20 const formEnableSelector = "input[data-disable-with]:disabled, button[data-disable-with]:disabled, textarea[data-disable-with]:disabled, input[data-disable]:disabled, button[data-disable]:disabled, textarea[data-disable]:disabled"; 21 const fileInputSelector = "input[name][type=file]:not([disabled])"; 22 const linkDisableSelector = "a[data-disable-with], a[data-disable]"; 23 const buttonDisableSelector = "button[data-remote][data-disable-with], button[data-remote][data-disable]"; 24 let nonce = null; 25 const loadCSPNonce = () => { 26 const metaTag = document.querySelector("meta[name=csp-nonce]"); 27 return nonce = metaTag && metaTag.content; 28 }; 29 const cspNonce = () => nonce || loadCSPNonce(); 30 const m = Element.prototype.matches || Element.prototype.matchesSelector || Element.prototype.mozMatchesSelector || Element.prototype.msMatchesSelector || Element.prototype.oMatchesSelector || Element.prototype.webkitMatchesSelector; 31 const matches = function(element, selector) { 32 if (selector.exclude) { 33 return m.call(element, selector.selector) && !m.call(element, selector.exclude); 34 } else { 35 return m.call(element, selector); 36 } 37 }; 38 const EXPANDO = "_ujsData"; 39 const getData = (element, key) => element[EXPANDO] ? element[EXPANDO][key] : undefined; 40 const setData = function(element, key, value) { 41 if (!element[EXPANDO]) { 42 element[EXPANDO] = {}; 43 } 44 return element[EXPANDO][key] = value; 45 }; 46 const $ = selector => Array.prototype.slice.call(document.querySelectorAll(selector)); 47 const isContentEditable = function(element) { 48 var isEditable = false; 49 do { 50 if (element.isContentEditable) { 51 isEditable = true; 52 break; 53 } 54 element = element.parentElement; 55 } while (element); 56 return isEditable; 57 }; 58 const csrfToken = () => { 59 const meta = document.querySelector("meta[name=csrf-token]"); 60 return meta && meta.content; 61 }; 62 const csrfParam = () => { 63 const meta = document.querySelector("meta[name=csrf-param]"); 64 return meta && meta.content; 65 }; 66 const CSRFProtection = xhr => { 67 const token = csrfToken(); 68 if (token) { 69 return xhr.setRequestHeader("X-CSRF-Token", token); 70 } 71 }; 72 const refreshCSRFTokens = () => { 73 const token = csrfToken(); 74 const param = csrfParam(); 75 if (token && param) { 76 return $('form input[name="' + param + '"]').forEach((input => input.value = token)); 77 } 78 }; 79 const AcceptHeaders = { 80 "*": "*/*", 81 text: "text/plain", 82 html: "text/html", 83 xml: "application/xml, text/xml", 84 json: "application/json, text/javascript", 85 script: "text/javascript, application/javascript, application/ecmascript, application/x-ecmascript" 86 }; 87 const ajax = options => { 88 options = prepareOptions(options); 89 var xhr = createXHR(options, (function() { 90 const response = processResponse(xhr.response != null ? xhr.response : xhr.responseText, xhr.getResponseHeader("Content-Type")); 91 if (Math.floor(xhr.status / 100) === 2) { 92 if (typeof options.success === "function") { 93 options.success(response, xhr.statusText, xhr); 94 } 95 } else { 96 if (typeof options.error === "function") { 97 options.error(response, xhr.statusText, xhr); 98 } 99 } 100 return typeof options.complete === "function" ? options.complete(xhr, xhr.statusText) : undefined; 101 })); 102 if (options.beforeSend && !options.beforeSend(xhr, options)) { 103 return false;
104 } 105 if (xhr.readyState === XMLHttpRequest.OPENED) { 106 return xhr.send(options.data); 107 } 108 }; 109 var prepareOptions = function(options) { 110 options.url = options.url || location.href; 111 options.type = options.type.toUpperCase(); 112 if (options.type === "GET" && options.data) { 113 if (options.url.indexOf("?") < 0) { 114 options.url += "?" + options.data; 115 } else { 116 options.url += "&" + options.data; 117 } 118 } 119 if (!(options.dataType in AcceptHeaders)) { 120 options.dataType = "*"; 121 } 122 options.accept = AcceptHeaders[options.dataType]; 123 if (options.dataType !== "*") { 124 options.accept += ", */*; q=0.01"; 125 } 126 return options; 127 }; 128 var createXHR = function(options, done) { 129 const xhr = new XMLHttpRequest; 130 xhr.open(options.type, options.url, true); 131 xhr.setRequestHeader("Accept", options.accept); 132 if (typeof options.data === "string") { 133 xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); 134 } 135 if (!options.crossDomain) { 136 xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); 137 CSRFProtection(xhr); 138 } 139 xhr.withCredentials = !!options.withCredentials; 140 xhr.onreadystatechange = function() { 141 if (xhr.readyState === XMLHttpRequest.DONE) { 142 return done(xhr); 143 } 144 }; 145 return xhr; 146 }; 147 var processResponse = function(response, type) { 148 if (typeof response === "string" && typeof type === "string") { 149 if (type.match(/\bjson\b/)) { 150 try { 151 response = JSON.parse(response); 152 } catch (error) {} 153 } else if (type.match(/\b(?:java|ecma)script\b/)) { 154 const script = document.createElement("script"); 155 script.setAttribute("nonce", cspNonce()); 156 script.text = response; 157 document.head.appendChild(script).parentNode.removeChild(script); 158 } else if (type.match(/\b(xml|html|svg)\b/)) { 159 const parser = new DOMParser; 160 type = type.replace(/;.+/, ""); 161 try { 162 response = parser.parseFromString(response, type); 163 } catch (error1) {} 164 } 165 } 166 return response; 167 }; 168 const href = element => element.href; 169 const isCrossDomain = function(url) { 170 const originAnchor = document.createElement("a"); 171 originAnchor.href = location.href; 172 const urlAnchor = document.createElement("a"); 173 try { 174 urlAnchor.href = url; 175 return !((!urlAnchor.protocol || urlAnchor.protocol === ":") && !urlAnchor.host || originAnchor.protocol + "//" + originAnchor.host === urlAnchor.protocol + "//" + urlAnchor.host); 176 } catch (e) { 177 return true; 178 } 179 }; 180 let preventDefault; 181 let {CustomEvent: CustomEvent} = window; 182 if (typeof CustomEvent !== "function") { 183 CustomEvent = function(event, params) { 184 const evt = document.createEvent("CustomEvent"); 185 evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail); 186 return evt; 187 }; 188 CustomEvent.prototype = window.Event.prototype; 189 ({preventDefault: preventDefault} = CustomEvent.prototype); 190 CustomEvent.prototype.preventDefault = function() { 191 const result = preventDefault.call(this); 192 if (this.cancelable && !this.defaultPrevented) { 193 Object.defineProperty(this, "defaultPrevented", { 194 get() { 195 return true; 196 } 197 }); 198 } 199 return result; 200 }; 201 } 202 const fire = (obj, name, data) => { 203 const event = new CustomEvent(name, { 204 bubbles: true, 205 cancelable: true, 206 detail: data 207 }); 208 obj.dispatchEvent(event); 209 return !event.defaultPrevented; 210 }; 211 const stopEverything = e => { 212 fire(e.target, "ujs:everythingStopped"); 213 e.preventDefault(); 214 e.stopPropagation(); 215 e.stopImmediatePropagation(); 216 }; 217 const delegate = (element, selector, eventType, handler) => element.addEventListener(eventType, (function(e) { 218 let {target: target} = e; 219 while (!!(target instanceof Element) && !matches(target, selector)) { 220 target = target.parentNode; 221 } 222 if (target instanceof Element && handler.call(target, e) === false) { 223 e.preventDefault(); 224 e.stopPropagation(); 225 } 226 })); 227 const toArray = e => Array.prototype.slice.call(e); 228 const serializeElement = (element, additionalParam) => { 229 let inputs = [ element ]; 230 if (matches(element, "form")) { 231 inputs = toArray(element.elements); 232 } 233 const params = [];
234 inputs.forEach((function(input) { 235 if (!input.name || input.disabled) { 236 return; 237 } 238 if (matches(input, "fieldset[disabled] *")) { 239 return; 240 } 241 if (matches(input, "select")) { 242 toArray(input.options).forEach((function(option) { 243 if (option.selected) { 244 params.push({ 245 name: input.name, 246 value: option.value 247 }); 248 } 249 })); 250 } else if (input.checked || [ "radio", "checkbox", "submit" ].indexOf(input.type) === -1) { 251 params.push({ 252 name: input.name, 253 value: input.value 254 }); 255 } 256 })); 257 if (additionalParam) { 258 params.push(additionalParam); 259 } 260 return params.map((function(param) { 261 if (param.name) { 262 return `${encodeURIComponent(param.name)}=${encodeURIComponent(param.value)}`; 263 } else { 264 return param; 265 } 266 })).join("&"); 267 }; 268 const formElements = (form, selector) => { 269 if (matches(form, "form")) { 270 return toArray(form.elements).filter((el => matches(el, selector))); 271 } else { 272 return toArray(form.querySelectorAll(selector)); 273 } 274 }; 275 const handleConfirmWithRails = rails => function(e) { 276 if (!allowAction(this, rails)) { 277 stopEverything(e); 278 } 279 }; 280 const confirm = (message, element) => window.confirm(message); 281 var allowAction = function(element, rails) {
282 let callback; 283 const message = element.getAttribute("data-confirm"); 284 if (!message) { 285 return true; 286 } 287 let answer = false; 288 if (fire(element, "confirm")) { 289 try { 290 answer = rails.confirm(message, element); 291 } catch (error) {} 292 callback = fire(element, "confirm:complete", [ answer ]); 293 } 294 return answer && callback; 295 }; 296 const handleDisabledElement = function(e) { 297 const element = this; 298 if (element.disabled) { 299 stopEverything(e); 300 } 301 }; 302 const enableElement = e => { 303 let element; 304 if (e instanceof Event) { 305 if (isXhrRedirect(e)) { 306 return; 307 } 308 element = e.target; 309 } else { 310 element = e; 311 } 312 if (isContentEditable(element)) { 313 return; 314 } 315 if (matches(element, linkDisableSelector)) { 316 return enableLinkElement(element); 317 } else if (matches(element, buttonDisableSelector) || matches(element, formEnableSelector)) { 318 return enableFormElement(element); 319 } else if (matches(element, formSubmitSelector)) { 320 return enableFormElements(element); 321 } 322 }; 323 const disableElement = e => { 324 const element = e instanceof Event ? e.target : e; 325 if (isContentEditable(element)) { 326 return; 327 } 328 if (matches(element, linkDisableSelector)) { 329 return disableLinkElement(element); 330 } else if (matches(element, buttonDisableSelector) || matches(element, formDisableSelector)) { 331 return disableFormElement(element); 332 } else if (matches(element, formSubmitSelector)) { 333 return disableFormElements(element); 334 } 335 }; 336 var disableLinkElement = function(element) { 337 if (getData(element, "ujs:disabled")) { 338 return; 339 } 340 const replacement = element.getAttribute("data-disable-with"); 341 if (replacement != null) { 342 setData(element, "ujs:enable-with", element.innerHTML); 343 element.innerHTML = replacement; 344 } 345 element.addEventListener("click", stopEverything); 346 return setData(element, "ujs:disabled", true); 347 }; 348 var enableLinkElement = function(element) { 349 const originalText = getData(element, "ujs:enable-with"); 350 if (originalText != null) { 351 element.innerHTML = originalText; 352 setData(element, "ujs:enable-with", null); 353 } 354 element.removeEventListener("click", stopEverything); 355 return setData(element, "ujs:disabled", null); 356 }; 357 var disableFormElements = form => formElements(form, formDisableSelector).forEach(disableFormElement); 358 var disableFormElement = function(element) { 359 if (getData(element, "ujs:disabled")) { 360 return; 361 } 362 const replacement = element.getAttribute("data-disable-with"); 363 if (replacement != null) { 364 if (matches(element, "button")) { 365 setData(element, "ujs:enable-with", element.innerHTML); 366 element.innerHTML = replacement; 367 } else { 368 setData(element, "ujs:enable-with", element.value); 369 element.value = replacement; 370 } 371 } 372 element.disabled = true; 373 return setData(element, "ujs:disabled", true); 374 }; 375 var enableFormElements = form => formElements(form, formEnableSelector).forEach((element => enableFormElement(element))); 376 var enableFormElement = function(element) { 377 const originalText = getData(element, "ujs:enable-with"); 378 if (originalText != null) { 379 if (matches(element, "button")) { 380 element.innerHTML = originalText; 381 } else { 382 element.value = originalText; 383 } 384 setData(element, "ujs:enable-with", null); 385 } 386 element.disabled = false; 387 return setData(element, "ujs:disabled", null); 388 }; 389 var isXhrRedirect = function(event) { 390 const xhr = event.detail ? event.detail[0] : undefined; 391 return xhr && xhr.getResponseHeader("X-Xhr-Redirect"); 392 }; 393 const handleMethodWithRails = rails => function(e) { 394 const link = this; 395 const method = link.getAttribute("data-method"); 396 if (!method) { 397 return; 398 } 399 if (isContentEditable(this)) { 400 return; 401 } 402 const href = rails.href(link); 403 const csrfToken$1 = csrfToken(); 404 const csrfParam$1 = csrfParam(); 405 const form = document.createElement("form"); 406 let formContent = `<input name='_method' value='${method}' type='hidden' />`; 407 if (csrfParam$1 && csrfToken$1 && !isCrossDomain(href)) { 408 formContent += `<input name='${csrfParam$1}' value='${csrfToken$1}' type='hidden' />`; 409 } 410 formContent += '<input type="submit" />'; 411 form.method = "post"; 412 form.action = href; 413 form.target = link.target; 414 form.innerHTML = formContent; 415 form.style.display = "none"; 416 document.body.appendChild(form); 417 form.querySelector('[type="submit"]').click(); 418 stopEverything(e); 419 }; 420 const isRemote = function(element) { 421 const value = element.getAttribute("data-remote");
422 return value != null && value !== "false"; 423 }; 424 const handleRemoteWithRails = rails => function(e) { 425 let data, method, url; 426 const element = this; 427 if (!isRemote(element)) { 428 return true; 429 } 430 if (!fire(element, "ajax:before")) { 431 fire(element, "ajax:stopped"); 432 return false; 433 } 434 if (isContentEditable(element)) { 435 fire(element, "ajax:stopped"); 436 return false; 437 } 438 const withCredentials = element.getAttribute("data-with-credentials"); 439 const dataType = element.getAttribute("data-type") || "script"; 440 if (matches(element, formSubmitSelector)) { 441 const button = getData(element, "ujs:submit-button"); 442 method = getData(element, "ujs:submit-button-formmethod") || element.getAttribute("method") || "get"; 443 url = getData(element, "ujs:submit-button-formaction") || element.getAttribute("action") || location.href; 444 if (method.toUpperCase() === "GET") { 445 url = url.replace(/\?.*$/, ""); 446 } 447 if (element.enctype === "multipart/form-data") { 448 data = new FormData(element); 449 if (button != null) { 450 data.append(button.name, button.value); 451 } 452 } else { 453 data = serializeElement(element, button); 454 } 455 setData(element, "ujs:submit-button", null); 456 setData(element, "ujs:submit-button-formmethod", null); 457 setData(element, "ujs:submit-button-formaction", null); 458 } else if (matches(element, buttonClickSelector) || matches(element, inputChangeSelector)) { 459 method = element.getAttribute("data-method"); 460 url = element.getAttribute("data-url"); 461 data = serializeElement(element, element.getAttribute("data-params")); 462 } else { 463 method = element.getAttribute("data-method"); 464 url = rails.href(element); 465 data = element.getAttribute("data-params"); 466 } 467 ajax({ 468 type: method || "GET", 469 url: url, 470 data: data, 471 dataType: dataType, 472 beforeSend(xhr, options) { 473 if (fire(element, "ajax:beforeSend", [ xhr, options ])) { 474 return fire(element, "ajax:send", [ xhr ]); 475 } else { 476 fire(element, "ajax:stopped"); 477 return false; 478 } 479 }, 480 success(...args) { 481 return fire(element, "ajax:success", args); 482 }, 483 error(...args) { 484 return fire(element, "ajax:error", args); 485 }, 486 complete(...args) { 487 return fire(element, "ajax:complete", args); 488 }, 489 crossDomain: isCrossDomain(url), 490 withCredentials: withCredentials != null && withCredentials !== "false" 491 }); 492 stopEverything(e); 493 }; 494 const formSubmitButtonClick = function(e) { 495 const button = this; 496 const {form: form} = button; 497 if (!form) { 498 return; 499 } 500 if (button.name) { 501 setData(form, "ujs:submit-button", { 502 name: button.name, 503 value: button.value 504 }); 505 } 506 setData(form, "ujs:formnovalidate-button", button.formNoValidate); 507 setData(form, "ujs:submit-button-formaction", button.getAttribute("formaction")); 508 return setData(form, "ujs:submit-button-formmethod", button.getAttribute("formmethod")); 509 }; 510 const preventInsignificantClick = function(e) { 511 const link = this; 512 const method = (link.getAttribute("data-method") || "GET").toUpperCase(); 513 const data = link.getAttribute("data-params"); 514 const metaClick = e.metaKey || e.ctrlKey; 515 const insignificantMetaClick = metaClick && method === "GET" && !data; 516 const nonPrimaryMouseClick = e.button != null && e.button !== 0; 517 if (nonPrimaryMouseClick || insignificantMetaClick) { 518 e.stopImmediatePropagation(); 519 } 520 }; 521 const Rails = { 522 $: $, 523 ajax: ajax, 524 buttonClickSelector: buttonClickSelector, 525 buttonDisableSelector: buttonDisableSelector, 526 confirm: confirm, 527 cspNonce: cspNonce, 528 csrfToken: csrfToken, 529 csrfParam: csrfParam, 530 CSRFProtection: CSRFProtection, 531 delegate: delegate, 532 disableElement: disableElement, 533 enableElement: enableElement, 534 fileInputSelector: fileInputSelector, 535 fire: fire, 536 formElements: formElements, 537 formEnableSelector: formEnableSelector, 538 formDisableSelector: formDisableSelector, 539 formInputClickSelector: formInputClickSelector, 540 formSubmitButtonClick: formSubmitButtonClick, 541 formSubmitSelector: formSubmitSelector, 542 getData: getData, 543 handleDisabledElement: handleDisabledElement, 544 href: href, 545 inputChangeSelector: inputChangeSelector, 546 isCrossDomain: isCrossDomain,
547 linkClickSelector: linkClickSelector, 548 linkDisableSelector: linkDisableSelector, 549 loadCSPNonce: loadCSPNonce, 550 matches: matches, 551 preventInsignificantClick: preventInsignificantClick, 552 refreshCSRFTokens: refreshCSRFTokens, 553 serializeElement: serializeElement, 554 setData: setData, 555 stopEverything: stopEverything 556 }; 557 const handleConfirm = handleConfirmWithRails(Rails); 558 Rails.handleConfirm = handleConfirm; 559 const handleMethod = handleMethodWithRails(Rails); 560 Rails.handleMethod = handleMethod; 561 const handleRemote = handleRemoteWithRails(Rails); 562 Rails.handleRemote = handleRemote; 563 const start = function() { 564 if (window._rails_loaded) { 565 throw new Error("rails-ujs has already been loaded!"); 566 } 567 window.addEventListener("pageshow", (function() { 568 $(formEnableSelector).forEach((function(el) { 569 if (getData(el, "ujs:disabled")) { 570 enableElement(el); 571 } 572 })); 573 $(linkDisableSelector).forEach((function(el) { 574 if (getData(el, "ujs:disabled")) { 575 enableElement(el); 576 } 577 })); 578 })); 579 delegate(document, linkDisableSelector, "ajax:complete", enableElement); 580 delegate(document, linkDisableSelector, "ajax:stopped", enableElement); 581 delegate(document, buttonDisableSelector, "ajax:complete", enableElement); 582 delegate(document, buttonDisableSelector, "ajax:stopped", enableElement); 583 delegate(document, linkClickSelector, "click", preventInsignificantClick); 584 delegate(document, linkClickSelector, "click", handleDisabledElement); 585 delegate(document, linkClickSelector, "click", handleConfirm); 586 delegate(document, linkClickSelector, "click", disableElement); 587 delegate(document, linkClickSelector, "click", handleRemote); 588 delegate(document, linkClickSelector, "click", handleMethod); 589 delegate(document, buttonClickSelector, "click", preventInsignificantClick); 590 delegate(document, buttonClickSelector, "click", handleDisabledElement); 591 delegate(document, buttonClickSelector, "click", handleConfirm); 592 delegate(document, buttonClickSelector, "click", disableElement); 593 delegate(document, buttonClickSelector, "click", handleRemote); 594 delegate(document, inputChangeSelector, "change", handleDisabledElement); 595 delegate(document, inputChangeSelector, "change", handleConfirm); 596 delegate(document, inputChangeSelector, "change", handleRemote); 597 delegate(document, formSubmitSelector, "submit", handleDisabledElement); 598 delegate(document, formSubmitSelector, "submit", handleConfirm); 599 delegate(document, formSubmitSelector, "submit", handleRemote); 600 delegate(document, formSubmitSelector, "submit", (e => setTimeout((() => disableElement(e)), 13))); 601 delegate(document, formSubmitSelector, "ajax:send", disableElement); 602 delegate(document, formSubmitSelector, "ajax:complete", enableElement); 603 delegate(document, formInputClickSelector, "click", preventInsignificantClick); 604 delegate(document, formInputClickSelector, "click", handleDisabledElement); 605 delegate(document, formInputClickSelector, "click", handleConfirm); 606 delegate(document, formInputClickSelector, "click", formSubmitButtonClick); 607 document.addEventListener("DOMContentLoaded", refreshCSRFTokens); 608 document.addEventListener("DOMContentLoaded", loadCSPNonce); 609 return window._rails_loaded = true; 610 }; 611 Rails.start = start; 612 if (typeof jQuery !== "undefined" && jQuery && jQuery.ajax) { 613 if (jQuery.rails) { 614 throw new Error("If you load both jquery_ujs and rails-ujs, use rails-ujs only."); 615 } 616 jQuery.rails = Rails; 617 jQuery.ajaxPrefilter((function(options, originalOptions, xhr) { 618 if (!options.crossDomain) { 619 return CSRFProtection(xhr); 620 } 621 })); 622 } 623 if (typeof exports !== "object" && typeof module === "undefined") { 624 window.Rails = Rails; 625 if (fire(document, "rails:attachBindings")) { 626 start(); 627 } 628 } 629 return Rails; 630})); 631/** 632 * Taggle - dependency-less tagging library 633 * @author Sean Coker <[email protected]> 634 * @version 1.15.0 (modified) 635 * @license MIT 636 */ 637
638///////////////////// 639// Default options // 640///////////////////// 641 642const BACKSPACE = 8; 643const DELETE = 46; 644const COMMA = 188; 645const TAB = 9; 646const ENTER = 13; 647 648const DEFAULTS = { 649 /** 650 * Class added to the container div when focused 651 * @type {String} 652 */ 653 containerFocusClass: 'active', 654 655 /** 656 * Spaces will be removed from the tags by default 657 * @type {Boolean} 658 */ 659 trimTags: true, 660 661 /** 662 * Limit the number of tags that can be added 663 * @type {Number} 664 */ 665 maxTags: null, 666 667 /** 668 * Placeholder string to be placed in an empty taggle field 669 * @type {String} 670 */ 671 placeholder: 'Enter tags...', 672 673 /** 674 * Keycodes that will add a tag 675 * @type {Array} 676 */ 677 submitKeys: [COMMA, TAB, ENTER], 678 679 /** 680 * Preserve case of tags being added ie 681 * "tag" is different than "Tag" 682 * @type {Boolean} 683 */ 684 preserveCase: false, 685 686 /** 687 * Function hook called when a tag is added 688 * @param {Event} event Event triggered when tag was added 689 * @param {String} tag The tag added 690 */ 691 onTagAdd: () => {}, 692 693 /** 694 * Function hook called when a tag is removed 695 * @param {Event} event Event triggered when tag was removed 696 * @param {String} tag The tag removed 697 */ 698 onTagRemove: () => {} 699}; 700 701////////////////////// 702// Helper functions // 703////////////////////// 704function _clamp(val, min, max) { 705 return Math.min(Math.max(val, min), max); 706} 707 708/** 709 * Taggle ES6 Class - Modern tagging library 710 */ 711class Taggle { 712 /** 713 * Constructor 714 * @param {Mixed} el ID of an element or the actual element 715 * @param {Object} options 716 */ 717 constructor(el, options) { 718 this.settings = Object.assign({}, DEFAULTS, options); 719 this.measurements = { 720 container: { 721 rect: null, 722 style: null, 723 padding: null 724 } 725 }; 726 this.container = el; 727 this.tag = { 728 values: [], 729 elements: [] 730 }; 731 this.inputContainer = options.inputContainer; 732 this.input = document.createElement('input'); 733 this.sizer = document.createElement('div'); 734 this.pasting = false; 735 this.placeholder = null; 736 737 if (this.settings.placeholder) { 738 this.placeholder = document.createElement('span'); 739 } 740 741 this._backspacePressed = false; 742 this._inputPosition = 0; 743 this._setMeasurements(); 744 this._setupTextarea(); 745 this._attachEvents(); 746 } 747 748 /** 749 * Gets all the layout measurements up front 750 */ 751 _setMeasurements() { 752 this.measurements.container.rect = this.container.getBoundingClientRect(); 753 const style = window.getComputedStyle(this.container); 754 this.measurements.container.style = style; 755 756 const lpad = parseInt(style.paddingLeft, 10); 757 const rpad = parseInt(style.paddingRight, 10); 758 const lborder = parseInt(style.borderLeftWidth, 10); 759 const rborder = parseInt(style.borderRightWidth, 10); 760 761 this.measurements.container.padding = lpad + rpad + lborder + rborder; 762 } 763 764 /** 765 * Setup the div container for tags to be entered 766 */ 767 _setupTextarea() { 768 this.input.type = 'text'; 769 // Make sure no left/right padding messes with the input sizing 770 this.input.style.paddingLeft = 0; 771 this.input.style.paddingRight = 0; 772 this.input.className = 'taggle_input'; 773 this.input.tabIndex = 1; 774 this.sizer.className = 'taggle_sizer'; 775 776 [...this.container.children].filter(child => child.tagName === 'TAG-OPTION').forEach(tagOption => { 777 this.tag.values.push(tagOption.value); 778 this.tag.elements.push(tagOption); 779 this._inputPosition = _clamp(this._inputPosition + 1, 0, this.tag.values.length); 780 }); 781 782 783 if (this.placeholder) { 784 this._hidePlaceholder(); 785 this.placeholder.classList.add('taggle_placeholder'); 786 this.container.appendChild(this.placeholder); 787 this.placeholder.textContent = this.settings.placeholder; 788 789 if (!this.tag.values.length) { 790 this._showPlaceholder(); 791 } 792 } 793 794 795 const div = document.createElement('div'); 796 div.appendChild(this.input); 797 div.appendChild(this.sizer); 798 this.inputContainer.appendChild(div); 799 const fontSize = window.getComputedStyle(this.input).fontSize; 800 this.sizer.style.fontSize = fontSize; 801 } 802 803 /** 804 * Attaches neccessary events 805 */ 806 _attachEvents() { 807 if (this._eventsAttached) { 808 return false;
809 } 810 this._eventsAttached = true; 811 812 this._handleContainerClick = () => this.input.focus(); 813 this.container.addEventListener('click', this._handleContainerClick); 814 815 this._handleFocus = this._setFocusStateForContainer.bind(this); 816 this._handleBlur = this._blurEvent.bind(this); 817 this._handleKeydown = this._keydownEvents.bind(this); 818 this._handleKeyup = this._keyupEvents.bind(this); 819 820 this.input.addEventListener('focus', this._handleFocus); 821 this.input.addEventListener('blur', this._handleBlur); 822 this.input.addEventListener('keydown', this._handleKeydown); 823 this.input.addEventListener('keyup', this._handleKeyup); 824 825 return true; 826 } 827 828 _detachEvents() { 829 if (!this._eventsAttached) { 830 return false; 831 } 832 this._eventsAttached = false; 833 834 this.container.removeEventListener('click', this._handleContainerClick); 835 this.input.removeEventListener('focus', this._handleFocus); 836 this.input.removeEventListener('blur', this._handleBlur); 837 this.input.removeEventListener('keydown', this._handleKeydown); 838 this.input.removeEventListener('keyup', this._handleKeyup); 839 840 return true; 841 } 842 843 /** 844 * Returns whether or not the specified tag text can be added 845 * @param {Event} e event causing the potentially added tag 846 * @param {String} text tag value 847 * @return {Boolean} 848 */ 849 _canAdd(e, text) { 850 if (!text) { 851 return false; 852 } 853 const limit = this.settings.maxTags; 854 if (limit !== null && limit <= this.getTagValues().length) { 855 return false; 856 } 857 858 // Check for duplicates 859 return this.tag.values.indexOf(text) === -1; 860 } 861 862 /** 863 * Appends tag with its corresponding input to the list 864 * @param {Event} e 865 * @param {String} text 866 * @param {Number} index 867 */ 868 _add(e, text, index) { 869 let values = text || ''; 870 const delimiter = ','; 871 872 if (typeof text !== 'string') { 873 values = this.input.value; 874 875 if (this.settings.trimTags) { 876 if (values[0] === delimiter) { 877 values = values.replace(delimiter, ''); 878 } 879 values = values.trim(); 880 } 881 } 882 883 values.split(delimiter).map(val => { 884 if (this.settings.trimTags) { 885 val = val.trim(); 886 } 887 return this._formatTag(val); 888 }
888).forEach(val => { 889 if (!this._canAdd(e, val)) { 890 return; 891 } 892 893 const currentTagLength = this.tag.values.length; 894 const tagIndex = _clamp(index || currentTagLength, 0, currentTagLength); 895 const tagOption = this._createTag(val, tagIndex); 896 this.container.append(tagOption); 897 898 val = this.tag.values[tagIndex]; 899 900 this.settings.onTagAdd(e, val); 901 902 this.input.value = ''; 903 this._setMeasurements(); 904 this._setInputWidth(); 905 this._setFocusStateForContainer(); 906 }); 907 } 908 909 /** 910 * Removes last tag if it has already been probed 911 * @param {Event} e 912 */ 913 _checkPrevOrNextTag(e) { 914 const taggles = this.container.querySelectorAll('tag-option'); 915 const prevTagIndex = _clamp(this._inputPosition - 1, 0, taggles.length - 1); 916 const nextTagIndex = _clamp(this._inputPosition, 0, taggles.length - 1); 917 let index = prevTagIndex; 918 919 if (e.keyCode === DELETE) { 920 index = nextTagIndex; 921 } 922 923 const targetTaggle = taggles[index]; 924 const hotClass = 'taggle_hot'; 925 const isDeleteOrBackspace = [BACKSPACE, DELETE].includes(e.keyCode); 926 927 // prevent holding backspace from deleting all tags 928 if (this.input.value === '' && isDeleteOrBackspace && !this._backspacePressed) { 929 if (targetTaggle.classList.contains(hotClass)) { 930 this._backspacePressed = true; 931 this._remove(targetTaggle, e); 932 this._setMeasurements(); 933 this._setInputWidth(); 934 this._setFocusStateForContainer(); 935 } 936 else { 937 targetTaggle.classList.add(hotClass); 938 } 939 } 940 else if (targetTaggle.classList.contains(hotClass)) { 941 targetTaggle.classList.remove(hotClass); 942 } 943 } 944 945 /** 946 * Setter for the hidden input. 947 * @param {Number} width 948 */ 949 _setInputWidth() { 950 const width = this.sizer.getBoundingClientRect().width; 951 const max = this.measurements.container.rect.width - this.measurements.container.padding; 952 const size = parseInt(this.sizer.style.fontSize, 10); 953 954 // 1.5 just seems to be a good multiplier here 955 const newWidth = Math.round(_clamp(width + (size * 1.5), 10, max)); 956 957 this.input.style.width = `${newWidth}px`; 958 } 959 960 /** 961 * Handles focus state of div container. 962 */ 963 _setFocusStateForContainer() { 964 this._setMeasurements(); 965 this._setInputWidth(); 966 967 if (!this.container.classList.contains(this.settings.containerFocusClass)) { 968 this.container.classList.add(this.settings.containerFocusClass); 969 } 970 971 this._hidePlaceholder(); 972 } 973 974 /** 975 * Runs all the events that need to happen on a blur 976 * @param {Event} e 977 */ 978 _blurEvent(e) { 979 if (this.container.classList.contains(this.settings.containerFocusClass)) { 980 this.container.classList.remove(this.settings.containerFocusClass); 981 } 982 983 if (!this.tag.values.length && !this.input.value) { 984 this._showPlaceholder(); 985 } 986 } 987 988 /** 989 * Runs all the events that need to run on keydown 990 * @param {Event} e 991 */ 992 _keydownEvents(e) { 993 const key = e.keyCode; 994 this.pasting = false; 995 996 this._setInputWidth(); 997 998 if (key === 86 && e.metaKey) { 999 this.pasting = true; 1000 } 1001 1002 if (this.settings.submitKeys.includes(key) && this.input.value !== '') { 1003 this._confirmValidTagEvent(e); 1004 return; 1005 } 1006 1007 if (this.tag.values.length) { 1008 this._checkPrevOrNextTag(e); 1009 } 1010 } 1011 1012 /** 1013 * Runs all the events that need to run on keyup 1014 * @param {Event} e 1015 */ 1016 _keyupEvents(e) { 1017 this._backspacePressed = false; 1018 1019 this.sizer.textContent = this.input.value; 1020 1021 // If we break to a new line because the text is too long 1022 // and decide to delete everything, we should resize the input 1023 // so it falls back inline 1024 if (!this.input.value) { 1025 this._setInputWidth(); 1026 } 1027 1028 if (this.pasting && this.input.value !== '') { 1029 this._add(e); 1030 this.pasting = false; 1031 } 1032 } 1033 1034 /** 1035 * Confirms the inputted value to be converted to a tag 1036 * @param {Event} e 1037 */ 1038 _confirmValidTagEvent(e) { 1039 // prevents from jumping out of textarea 1040 e.preventDefault(); 1041 1042 this._add(e, null, this._inputPosition); 1043 } 1044 1045 _createTag(text, index) { 1046 const tagOption = document.createElement('tag-option'); 1047 1048 text = this._formatTag(text); 1049 tagOption.textContent = text; 1050 tagOption.setAttribute('value', text); 1051 1052 this.tag.values.splice(index, 0, text); 1053 this.tag.elements.splice(index, 0, tagOption); 1054 this._inputPosition = _clamp(this._inputPosition + 1, 0, this.tag.values.length); 1055 1056 return tagOption; 1057 } 1058 1059 _showPlaceholder() { 1060 if (this.placeholder) { 1061 this.placeholder.style.opacity = 1; 1062 this.placeholder.setAttribute('aria-hidden', 'false'); 1063 } 1064 } 1065 1066 _hidePlaceholder() { 1067 if (this.placeholder) { 1068 this.placeholder.style.opacity = 0; 1069 this.placeholder.setAttribute('aria-hidden', 'true'); 1070 } 1071 } 1072 1073 /** 1074 * Removes tag from the tags collection 1075 * @param {HTMLElement} tagOption List item to remove 1076 * @param {Event} e 1077 */ 1078 _remove(tagOption, e) { 1079 const index = this.tag.elements.indexOf(tagOption); 1080 if (index === -1) return; 1081 1082 const text = this.tag.values[index]; 1083 1084 tagOption.remove(); 1085 this.tag.elements.splice(index, 1); 1086 this.tag.values.splice(index, 1); 1087 this.settings.onTagRemove(e, text); 1088 1089 if (index < this._inputPosition) { 1090 this._inputPosition = _clamp(this._inputPosition - 1, 0, this.tag.values.length); 1091 } 1092 1093 this._setFocusStateForContainer(); 1094 } 1095 1096 /** 1097 * Format the text for a tag 1098 * @param {String} text Tag text 1099 * @return {String} 1100 */ 1101 _formatTag(text) { 1102 return this.settings.preserveCase ? text : text.toLowerCase(); 1103 } 1104 1105 // @todo 1106 // @deprecated use getTags().values 1107 getTagValues() { 1108 return [...this.tag.values]; 1109 } 1110 1111 getInput() { 1112 return this.input; 1113 } 1114 1115 add(text, index) { 1116 const isArr = Array.isArray(text); 1117 1118 if (isArr) { 1119 text.forEach((tag, i) => { 1120 if (typeof tag === 'string') { 1121 this._add(null, tag, index ? index + i : index); 1122 } 1123 }); 1124 } 1125 else { 1126 this._add(null, text, index); 1127 } 1128 1129 return this; 1130 } 1131 1132 remove(text) { 1133 const index = this.tag.values.indexOf(text); 1134 if (index > -1) { 1135 this._remove(this.tag.elements[index]); 1136 } 1137 return this; 1138 } 1139 1140 removeAll() {
1141 [...this.tag.elements].forEach(element => this._remove(element)); 1142 this._showPlaceholder(); 1143 return this; 1144 } 1145 1146 _setDisabledState(disabled) { 1147 const elements = [ 1148 ...this.container.querySelectorAll('button'), 1149 ...this.container.querySelectorAll('input') 1150 ]; 1151 1152 elements.forEach((el) => { 1153 if (disabled) { 1154 el.setAttribute('disabled', ''); 1155 } else { 1156 el.removeAttribute('disabled'); 1157 } 1158 }); 1159 1160 return this; 1161 } 1162 1163 enable() { 1164 return this._setDisabledState(false); 1165 } 1166 1167 disable() { 1168 return this._setDisabledState(true); 1169 } 1170 1171 destroy() { 1172 this._detachEvents(); 1173 } 1174} 1175 1176/** 1177 * Copyright (c) 2016 Denis Taran 1178 * 1179 * Homepage: https://smartscheduling.com/en/documentation/autocomplete 1180 * Source: https://github.com/denis-taran/autocomplete 1181 * 1182 * MIT License 1183 */ 1184function autocomplete(settings) { 1185 // just an alias to minimize JS file size 1186 var doc = document; 1187 var container = settings.container || doc.createElement('div'); 1188 var preventSubmit = settings.preventSubmit || 0 /* Never */; 1189 container.id = container.id || 'autocomplete-' + uid(); 1190 var containerStyle = container.style; 1191 var debounceWaitMs = settings.debounceWaitMs || 0; 1192 var disableAutoSelect = settings.disableAutoSelect || false; 1193 var customContainerParent = container.parentElement; 1194 var items = []; 1195 var inputValue = ''; 1196 var minLen = 2; 1197 var showOnFocus = settings.showOnFocus; 1198 var selected; 1199 var fetchCounter = 0; 1200 var debounceTimer; 1201 var destroyed = false; 1202 // Fixes #104: autocomplete selection is broken on Firefox for Android 1203 var suppressAutocomplete = false; 1204 if (settings.minLength !== undefined) { 1205 minLen = settings.minLength; 1206 } 1207 if (!settings.input) { 1208 throw new Error('input undefined'); 1209 } 1210 var input = settings.input; 1211 container.className = [container.className, 'autocomplete', settings.className || ''].join(' ').trim(); 1212 container.setAttribute('role', 'listbox'); 1213 input.setAttribute('role', 'combobox'); 1214 input.setAttribute('aria-expanded', 'false'); 1215 input.setAttribute('aria-autocomplete', 'list'); 1216 input.setAttribute('aria-controls', container.id); 1217 input.setAttribute('aria-owns', container.id); 1218 input.setAttribute('aria-activedescendant', ''); 1219 input.setAttribute('aria-haspopup', 'listbox'); 1220 // IOS implementation for fixed positioning has many bugs, so we will use absolute positioning 1221 containerStyle.position = 'absolute'; 1222 /** 1223 * Generate a very complex textual ID that greatly reduces the chance of a collision with another ID or text. 1224 */ 1225 function uid() { 1226 return Date.now().toString(36) + Math.random().toString(36).substring(2); 1227 } 1228 /** 1229 * Detach the container from DOM 1230 */ 1231 function detach() { 1232 var parent = container.parentNode; 1233 if (parent) { 1234 parent.removeChild(container); 1235 } 1236 } 1237 /** 1238 * Clear debouncing timer if assigned 1239 */ 1240 function clearDebounceTimer() { 1241 if (debounceTimer) { 1242 window.clearTimeout(debounceTimer); 1243 } 1244 } 1245 /** 1246 * Attach the container to DOM 1247 */ 1248 function attach() { 1249 if (!container.parentNode) { 1250 (customContainerParent || doc.body).appendChild(container); 1251 } 1252 } 1253 /** 1254 * Check if container for autocomplete is displayed 1255 */ 1256 function containerDisplayed() { 1257 return !!container.parentNode; 1258 } 1259 /** 1260 * Clear autocomplete state and hide container 1261 */ 1262 function clear() { 1263 // prevent the update call if there are pending AJAX requests 1264 fetchCounter++; 1265 items = []; 1266 inputValue = ''; 1267 selected = undefined; 1268 input.setAttribute('aria-activedescendant', ''); 1269 input.setAttribute('aria-expanded', 'false'); 1270 detach(); 1271 } 1272 /** 1273 * Update autocomplete position 1274 */ 1275 function updatePosition() { 1276 if (!containerDisplayed()) { 1277 return; 1278 } 1279 input.setAttribute('aria-expanded', 'true'); 1280 containerStyle.height = 'auto'; 1281 containerStyle.width = input.offsetWidth + 'px'; 1282 var maxHeight = 0; 1283 var inputRect; 1284 function calc() { 1285 var docEl = doc.documentElement; 1286 var clientTop = docEl.clientTop || doc.body.clientTop || 0; 1287 var clientLeft = docEl.clientLeft || doc.body.clientLeft || 0; 1288 var scrollTop = window.pageYOffset || docEl.scrollTop; 1289 var scrollLeft = window.pageXOffset || docEl.scrollLeft; 1290 inputRect = input.getBoundingClientRect(); 1291 var top = inputRect.top + input.offsetHeight + scrollTop - clientTop; 1292 var left = inputRect.left + scrollLeft - clientLeft; 1293 containerStyle.top = top + 'px'; 1294 containerStyle.left = left + 'px'; 1295 maxHeight = window.innerHeight - (inputRect.top + input.offsetHeight); 1296 if (maxHeight < 0) { 1297 maxHeight = 0; 1298 } 1299 containerStyle.top = top + 'px'; 1300 containerStyle.bottom = ''; 1301 containerStyle.left = left + 'px'; 1302 containerStyle.maxHeight = maxHeight + 'px'; 1303 } 1304 // the calc method must be called twice, otherwise the calculation may be wrong on resize event (chrome browser) 1305 calc(); 1306 calc(); 1307 if (settings.customize && inputRect) { 1308 settings.customize(input, inputRect, container, maxHeight); 1309 } 1310 } 1311 /** 1312 * Redraw the autocomplete div element with suggestions 1313 */
1314 function update() { 1315 container.textContent = ''; 1316 input.setAttribute('aria-activedescendant', ''); 1317 // function for rendering autocomplete suggestions 1318 var render = function (item, _, __) { 1319 var itemElement = doc.createElement('div'); 1320 itemElement.textContent = item.label || ''; 1321 return itemElement; 1322 }; 1323 if (settings.render) { 1324 render = settings.render; 1325 } 1326 // function to render autocomplete groups 1327 var renderGroup = function (groupName, _) { 1328 var groupDiv = doc.createElement('div'); 1329 groupDiv.textContent = groupName; 1330 return groupDiv; 1331 }; 1332 if (settings.renderGroup) { 1333 renderGroup = settings.renderGroup; 1334 } 1335 var fragment = doc.createDocumentFragment(); 1336 var prevGroup = uid(); 1337 items.forEach(function (item, index) { 1338 if (item.group && item.group !== prevGroup) { 1339 prevGroup = item.group; 1340 var groupDiv = renderGroup(item.group, inputValue); 1341 if (groupDiv) { 1342 groupDiv.className += ' group'; 1343 fragment.appendChild(groupDiv); 1344 } 1345 } 1346 var div = render(item, inputValue, index); 1347 if (div) { 1348 div.id = container.id + "_" + index; 1349 div.setAttribute('role', 'option'); 1350 div.addEventListener('click', function (ev) { 1351 suppressAutocomplete = true; 1352 try { 1353 settings.onSelect(item, input); 1354 } 1355 finally { 1356 suppressAutocomplete = false; 1357 } 1358 clear(); 1359 ev.preventDefault(); 1360 ev.stopPropagation(); 1361 }); 1362 if (item === selected) { 1363 div.className += ' selected'; 1364 div.setAttribute('aria-selected', 'true'); 1365 input.setAttribute('aria-activedescendant', div.id); 1366 } 1367 fragment.appendChild(div); 1368 } 1369 }); 1370 container.appendChild(fragment); 1371 if (items.length < 1) { 1372 if (settings.emptyMsg) { 1373 var empty = doc.createElement('div'); 1374 empty.id = container.id + "_" + uid(); 1375 empty.className = 'empty'; 1376 empty.textContent = settings.emptyMsg; 1377 container.appendChild(empty); 1378 input.setAttribute('aria-activedescendant', empty.id); 1379 } 1380 else { 1381 clear(); 1382 return; 1383 } 1384 } 1385 attach(); 1386 updatePosition(); 1387 updateScroll(); 1388 } 1389 function updateIfDisplayed() { 1390 if (containerDisplayed()) { 1391 update(); 1392 } 1393 } 1394 function resizeEventHandler() { 1395 updateIfDisplayed(); 1396 } 1397 function scrollEventHandler(e) { 1398 if (e.target !== container) { 1399 updateIfDisplayed(); 1400 } 1401 else { 1402 e.preventDefault(); 1403 } 1404 } 1405 function inputEventHandler() { 1406 if (!suppressAutocomplete) { 1407 fetch(0 /* Keyboard */); 1408 } 1409 } 1410 /** 1411 * Automatically move scroll bar if selected item is not visible 1412 */ 1413 function updateScroll() { 1414 var elements = container.getElementsByClassName('selected'); 1415 if (elements.length > 0) { 1416 var element = elements[0]; 1417 // make group visible 1418 var previous = element.previousElementSibling; 1419 if (previous && previous.className.indexOf('group') !== -1 && !previous.previousElementSibling) { 1420 element = previous; 1421 } 1422 if (element.offsetTop < container.scrollTop) { 1423 container.scrollTop = element.offsetTop; 1424 } 1425 else { 1426 var selectBottom = element.offsetTop + element.offsetHeight; 1427 var containerBottom = container.scrollTop + container.offsetHeight; 1428 if (selectBottom > containerBottom) { 1429 container.scrollTop += selectBottom - containerBottom; 1430 } 1431 } 1432 } 1433 } 1434 function selectPreviousSuggestion() { 1435 var index = items.indexOf(selected); 1436 selected = index === -1 1437 ? undefined 1438 : items[(index + items.length - 1) % items.length]; 1439 updateSelectedSuggestion(index); 1440 } 1441 function selectNextSuggestion() { 1442 var index = items.indexOf(selected); 1443 selected = items.length < 1 1444 ? undefined 1445 : index === -1 1446 ? items[0] 1447 : items[(index + 1) % items.length]; 1448 updateSelectedSuggestion(index); 1449 } 1450 function updateSelectedSuggestion(index) { 1451 if (items.length > 0) { 1452 unselectSuggestion(index); 1453 selectSuggestion(items.indexOf(selected)); 1454 updateScroll(); 1455 } 1456 } 1457 function selectSuggestion(index) { 1458 var element = doc.getElementById(container.id + "_" + index); 1459 if (element) { 1460 element.classList.add('selected'); 1461 element.setAttribute('aria-selected', 'true'); 1462 input.setAttribute('aria-activedescendant', element.i
1462d); 1463 } 1464 } 1465 function unselectSuggestion(index) { 1466 var element = doc.getElementById(container.id + "_" + index); 1467 if (element) { 1468 element.classList.remove('selected'); 1469 element.removeAttribute('aria-selected'); 1470 input.removeAttribute('aria-activedescendant'); 1471 } 1472 } 1473 function handleArrowAndEscapeKeys(ev, key) { 1474 var containerIsDisplayed = containerDisplayed(); 1475 if (key === 'Escape') { 1476 clear(); 1477 } 1478 else { 1479 if (!containerIsDisplayed || items.length < 1) { 1480 return; 1481 } 1482 key === 'ArrowUp' 1483 ? selectPreviousSuggestion() 1484 : selectNextSuggestion(); 1485 } 1486 ev.preventDefault(); 1487 if (containerIsDisplayed) { 1488 ev.stopPropagation(); 1489 } 1490 } 1491 function handleEnterKey(ev) { 1492 if (selected) { 1493 if (preventSubmit === 2 /* OnSelect */) { 1494 ev.preventDefault(); 1495 } 1496 suppressAutocomplete = true; 1497 try { 1498 settings.onSelect(selected, input); 1499 } 1500 finally { 1501 suppressAutocomplete = false; 1502 } 1503 clear(); 1504 } 1505 if (preventSubmit === 1 /* Always */) { 1506 ev.preventDefault(); 1507 } 1508 } 1509 function keydownEventHandler(ev) { 1510 var key = ev.key; 1511 switch (key) { 1512 case 'ArrowUp': 1513 case 'ArrowDown': 1514 case 'Escape': 1515 handleArrowAndEscapeKeys(ev, key); 1516 break; 1517 case 'Enter': 1518 handleEnterKey(ev); 1519 break; 1520 } 1521 } 1522 function focusEventHandler() { 1523 if (showOnFocus) { 1524 fetch(1 /* Focus */); 1525 } 1526 } 1527 function fetch(trigger) { 1528 if (input.value.length >= minLen || trigger === 1 /* Focus */) { 1529 clearDebounceTimer(); 1530 debounceTimer = window.setTimeout(function () { return startFetch(input.value, trigger, input.selectionStart || 0); }, trigger === 0 /* Keyboard */ || trigger === 2 /* Mouse */ ? debounceWaitMs : 0); 1531 } 1532 else { 1533 clear(); 1534 } 1535 } 1536 function startFetch(inputText, trigger, cursorPos) { 1537 if (destroyed) 1538 return; 1539 var savedFetchCounter = ++fetchCounter; 1540 settings.fetch(inputText, function (elements) { 1541 if (fetchCounter === savedFetchCounter && elements) { 1542 items = elements; 1543 inputValue = inputText; 1544 selected = (items.length < 1 || disableAutoSelect) ? undefined : items[0]; 1545 update(); 1546 } 1547 }, trigger, cursorPos); 1548 } 1549 function keyupEventHandler(e) { 1550 if (settings.keyup) { 1551 settings.keyup({ 1552 event: e, 1553 fetch: function () { return fetch(0 /* Keyboard */); } 1554 }); 1555 return; 1556 } 1557 if (!containerDisplayed() && e.key === 'ArrowDown') { 1558 fetch(0 /* Keyboard */); 1559 } 1560 } 1561 function clickEventHandler(e) { 1562 settings.click && settings.click({ 1563 event: e, 1564 fetch: function () { return fetch(2 /* Mouse */); } 1565 }); 1566 } 1567 function blurEventHandler() { 1568 // when an item is selected by mouse click, the blur event will be initiated before the click event and remove DOM elements, 1569 // so that the click event will never be triggered. In order to avoid this issue, DOM removal should be delayed. 1570 setTimeout(function () { 1571 if (doc.activeElement !== input) { 1572 clear(); 1573 } 1574 }, 200); 1575 } 1576 function manualFetch() { 1577 startFetch(input.value, 3 /* Manual */, input.selectionStart || 0); 1578 } 1579 /** 1580 * Fixes #26: on long clicks focus will be lost and onSelect method will not be called 1581 */ 1582 container.addEventListener('mousedown', function (evt) { 1583 evt.stopPropagation(); 1584 evt.preventDefault(); 1585 }); 1586 /** 1587 * Fixes #30: autocomplete closes when scrollbar is clicked in IE 1588 * See: https://stackoverflow.com/a/9210267/13172349 1589 */ 1590 container.addEventListener('focus', function () { return input.focus(); }); 1591 // If the custom autocomplete container is already appended to the DOM during widget initialization, detach it. 1592 detach(); 1593 /** 1594 * This function will remove DOM elements and clear event handlers 1595 */ 1596 function destroy() { 1597 input.removeEventListener('focus', focusEventHandler); 1598 input.removeEventListener('keyup', keyupEventHandler); 1599 input.removeEventListener('click', clickEventHandler); 1600 input.removeEventListener('keydown', keydownEventHandler); 1601 input.removeEventListener('input', inputEventHandler); 1602 input.removeEventListener('blur', blurEventHandler); 1603 window.removeEventListener('resize', resizeEventHandler); 1604 doc.removeEventListener('scroll', scrollEventHandler, true); 1605 input.removeAttribute('role'); 1606 input.removeAttribute('aria-expanded'); 1607 input.removeAttribute('aria-autocomplete'); 1608 input.removeAttribute('aria-controls'); 1609 input.removeAttribute('aria-activedescendant'); 1610 input.removeAttribute('aria-owns'); 1611 input.removeAttribute('aria-haspopup'); 1612 clearDebounceTimer(); 1613 clear(); 1614 destroyed = true; 1615 } 1616 // setup event handlers 1617 input.addEventListener('keyup', keyupEventHandler); 1618 input.addEventListener('click', clickEventHandler); 1619 input.addEventListener('keydown', keydownEventHandler); 1620 input.addEventListener('input', inputEventHandler); 1621 input.addEventListener('blur', blurEventHandler); 1622 input.addEventListener('focus', focusEventHandler); 1623 window.addEventListener('resize', resizeEventHandler); 1624 doc.addEventListener('scroll', scrollEventHandler, true); 1625 return { 1626 destroy: destroy, 1627 fetch: manualFetch 1628 }; 1629} 1630 1631const tagOptionStyleSheet = new CSSStyleSheet(); 1632tagOptionStyleSheet.replaceSync(` 1633 :host { 1634 background: var(--tag-option-bg, #588a00); 1635 padding: 3px 10px 3px 10px !important; 1636 margin-right: 4px !important; 1637 margin-bottom: 2px !important; 1638 display: inline-flex; 1639 align-items: center; 1640 float: none; 1641 font-size: 14px; 1642 line-height: 1; 1643 min-height: 32px; 1644 color: var(--tag-option-color, #fff); 1645 text-transform: none; 1646 border-radius: 3px; 1647 position: relative; 1648 cursor: pointer; 1649 } 1650 button { 1651 z-index: 1; 1652 border: none; 1653 background: none; 1654 font-size: 20px; 1655 display: inline-block; 1656 color: var(--tag-option-button-color, rgba(255, 255, 255, 0.6)); 1657 right: 10px; 1658 height: 100%; 1659 cursor: pointer; 1660 } 1661`); 1662 1663const inputTagStyleSheet = new CSSStyleSheet(); 1664inputTagStyleSheet.replaceSync(` 1665 :host { display: block; } 1666 :host *{ 1667 position: relative; 1668 box-sizing: border-box; 1669 margin: 0; 1670 padding: 0; 1671 } 1672 #container { 1673 background: var(--container-bg, rgba(255, 255, 255, 0.8)); 1674 padding: 6px 6px 3px; 1675 max-height: none; 1676 display: flex; 1677 margin: 0; 1678 flex-wrap: wrap; 1679 align-items: flex-start; 1680 min-height: 48px; 1681 line-height: 48px; 1682 width: 100%; 1683 border: 1px solid var(--container-border, #d0d0d0); 1684 border-left-width: var(--container-border-left-width, 1px); 1685 border-left-color: var(--container-border-left-color, #d0d0d0); 1686 outline: 1px solid transparent; 1687 box-shadow: var(--container-shadow, #ccc) 0 1px 4px 0 inset; 1688 border-radius: 2px; 1689 cursor: text; 1690 color: #333; 1691 list-style: none; 1692 padding-right: 32px; 1693 } 1694 input { 1695 display: block; 1696 height: 38px; 1697 float: none; 1698 margin: 0; 1699 padding-left: 10px !important; 1700 padding-right: 30px !important; 1701 width: auto !important; 1702 min-width: 70px; 1703 font-size: 14px; 1704 width: 100%; 1705 line-height: 2; 1706 padding: 0 0 0 10px; 1707 border: 1px dashed var(--input-border, #d0d0d0); 1708 outline: 1px solid transparent; 1709 background: var(--input-bg, #fff); 1710 box-shadow: none; 1711 border-radius: 2px; 1712 cursor: text; 1713 color: var(--input-color, #333); 1714 } 1715 button { 1716 width: 38px; 1717 text-align: center; 1718 line-height: 36px; 1719 border: 1px solid var(--button-border, #e0e0e0); 1720 font-size: 20px; 1721 color: var(--button-color, #666); 1722 position: absolute !important; 1723 z-index: 10; 1724 right: 0px; 1725 top: 0; 1726 font-weight: 400; 1727 cursor: pointer; 1728 background: none; 1729 } 1730 .taggle_sizer{ 1731 padding: 0; 1732 margin: 0; 1733 position: absolute; 1734 top: -500px; 1735 z-index: -1; 1736 visibility: hidden; 1737 } 1738 .ui-autocomplete{ 1739 position: static !important; 1740 width: 100% !important; 1741 margin-top: 2px; 1742 } 1743 .ui-menu{ 1744 margin: 0; 1745 padding: 6px; 1746 box-shadow: var(--menu-shadow, #ccc) 0 1px 6px; 1747 z-index: 2; 1748 display: flex; 1749 flex-wrap: wrap; 1750 background: var(--menu-bg, #fff); 1751 list-style: none; 1752 font-size: 14px; 1753 min-width: 200px; 1754 } 1755 .ui-menu .ui-menu-item{ 1756 display: inline-block; 1757 margin: 0 0 2px; 1758 line-height: 30px; 1759 border: none; 1760 padding: 0 10px; 1761 text-indent: 0; 1762 border-radius: 2px; 1763 width: auto; 1764 cursor: pointer; 1765 color: var(--menu-color, #555); 1766 } 1767 .ui-menu .ui-menu-item::before{ display: none; } 1768 .ui-menu .ui-menu-item:hover{ background: var(--menu-hover, #e0e0e0); } 1769 .ui-state-active{ 1770 padding: 0; 1771 border: none; 1772 background: none; 1773 color: inherit; 1774 } 1775`); 1776 1777 1778class TagOption extends HTMLElement { 1779 constructor() { 1780 super(); 1781 this._shadowRoot = this.attachShadow({ mode: "open" }); 1782 this._shadowRoot.adoptedStyleSheets = [tagOptionStyleSheet]; 1783 } 1784 1785 connectedCallback() { 1786 this._shadowRoot.replaceChildren(); 1787 this._shadowRoot.appendChild(document.createElement("slot")); 1788 1789 this.buttonTarget = document.createElement("button"); 1790 this.buttonTarget.type = "button"; 1791 this.buttonTarget.textContent = "×"; 1792 this.buttonTarget.onclick = event => { 1793 this.parentNode._taggle._remove(this, event); 1794 }; 1795 this._shadowRoot.appendChild(this.buttonTarget); 1796 } 1797 1798 get value() { 1799 return this.getAttribute("value") || this.innerText 1800 } 1801 1802 get label() { 1803 return this.innerText 1804 } 1805} 1806customElements.define("tag-option", TagOption); 1807 1808 1809class InputTag extends HTMLElement { 1810 static get formAssociated() { 1811 return true; 1812 } 1813 1814 static get observedAttributes() { 1815 return ['name', 'multiple', 'required', 'list']; 1816 } 1817 1818 constructor() { 1819 super(); 1820 this._internals = this.attachInternals(); 1821 this._shadowRoot = this.attachShadow({ mode: "open" }); 1822 this._shadowRoot.adoptedStyleSheets = [inputTagStyleSheet]; 1823 1824 this.observer = new MutationObserver(mutations => { 1825 let needsTagOptionsUpdate = false;
1826 let needsAutocompleteUpdate = false; 1827 1828 for (const mutation of mutations) { 1829 if (mutation.type === 'childList') { 1830 const addedRemovedNodes = [...mutation.addedNodes, ...mutation.removedNodes]; 1831 if (addedRemovedNodes.some(node => node.tagName === 'TAG-OPTION')) { 1832 needsTagOptionsUpdate = true; 1833 } 1834 if (addedRemovedNodes.some(node => node.tagName === 'DATALIST')) { 1835 needsAutocompleteUpdate = true; 1836 } 1837 } else if (mutation.type === 'attributes') { 1838 // Handle attribute changes on tag-option elements 1839 if (mutation.target !== this && mutation.target.tagName === 'TAG-OPTION') { 1840 needsTagOptionsUpdate = true; 1841 } 1842 } 1843 } 1844 1845 if (needsTagOptionsUpdate || needsAutocompleteUpdate) { 1846 this.unobserve(); 1847 if (needsTagOptionsUpdate) { 1848 this.processTagOptions(); 1849 } 1850 if (needsAutocompleteUpdate && this.initialized) { 1851 this.setupAutocomplete(); 1852 } 1853 this.observe(); 1854 } 1855 }); 1856 } 1857 1858 unobserve() { 1859 this.observer.disconnect(); 1860 } 1861 1862 observe() { 1863 this.observer.observe(this, { 1864 childList: true, 1865 attributes: true, 1866 subtree: true, 1867 attributeFilter: ["value"], 1868 }); 1869 } 1870 1871 processTagOptions() { 1872 if(!this._taggle || !this._taggle.tag) return 1873 let tagOptions = Array.from(this.children).filter(e => e.tagName === 'TAG-OPTION'); 1874 let values = tagOptions.map(e => e.value).filter(value => value !== null && value !== undefined); 1875 1876 // Enforce maxTags constraint for single mode 1877 if (!this.multiple && values.length > 1) { 1878 // Remove excess tag-options from DOM (keep only the first one) 1879 tagOptions.slice(1).forEach(el => el.remove()); 1880 tagOptions = tagOptions.slice(0, 1); 1881 values = values.slice(0, 1); 1882 } 1883 1884 this._taggle.tag.elements = tagOptions; 1885 this._taggle.tag.values = values; 1886 this._inputPosition = this._taggle.tag.values.length; 1887 1888 // Update the taggle display elements to match the current values 1889 const taggleElements = this._taggle.tag.elements; 1890 taggleElements.forEach((element, index) => { 1891 if (element && element.setAttribute) { 1892 element.setAttribute('data-value', values[index]); 1893 } 1894 }); 1895 1896 // Update internal value to match 1897 this.updateValue(); 1898 1899 // Ensure input visibility is updated when tags change via DOM 1900 this.updateInputVisibility(); 1901 } 1902 1903 get form() { 1904 return this._internals.form; 1905 } 1906 1907 _setFormValue(values) { 1908 this._internals.value = values; 1909 1910 const formData = new FormData(); 1911 values.forEach(value => formData.append(this.name, value)); 1912 // Always append empty string when no values so server knows to clear the field 1913 // (like Rails multiple checkboxes which prepend an empty hidden field) 1914 if (values.length === 0) { 1915 formData.append(this.name, ""); 1916 } 1917 this._internals.setFormValue(formData); 1918 } 1919 1920 get name() { 1921 return this.getAttribute("name"); 1922 } 1923 1924 get multiple() { 1925 return this.hasAttribute('multiple'); 1926 } 1927 1928 get value() { 1929 const internalValue = this._internals.value; 1930 if (this.multiple) { 1931 return internalValue; // Return array for multiple mode 1932 } else { 1933 return internalValue.length > 0 ? internalValue[0] : ''; // Return string for single mode 1934 } 1935 } 1936 1937 set value(input) { 1938 // Convert input to array format for internal storage 1939 let values; 1940 if (Array.isArray(input)) { 1941 values = input; 1942 } else if (typeof input === 'string') { 1943 values = input === '' ? [] : [input]; 1944 } else { 1945 values = []; 1946 } 1947 1948 const oldValues = this._internals.value; 1949 this._setFormValue(values); 1950 1951 // Update taggle to match the new values 1952 if (this._taggle && this.initialized) { 1953 this.suppressEvents = true; // Prevent infinite loops 1954 this._taggle.removeAll(); 1955 if (values.length > 0) { 1956 this._taggle.add(values); 1957 } 1958 this.suppressEvents = false; 1959 } 1960 1961 if(this.initialized && !this.suppressEvents && JSON.stringify(oldValues) !== JSON.stringify(values)) { 1962 this.dispatchEvent(new CustomEvent("change", { 1963 bubbles: true, 1964 composed: true, 1965 })); 1966 } 1967 } 1968 1969 reset() { 1970 this._taggle.removeAll(); 1971 this._taggleInputTarget.value = ''; 1972 this._updateButtonContent(); 1973 } 1974 1975 get options() { 1976 const datalistId = this.getAttribute("list"); 1977 if(datalistId) { 1978 const datalist = document.getElementById(datalistId); 1979 if(datalist) { 1980 return [...datalist.options].map(option => option.value).filter(value => value !== null && value !== undefined) 1981 } 1982 } 1983 1984 // Fall back to nested datalist 1985 const nestedDatalist = this.querySelector('datalist'); 1986 if(nestedDatalist) { 1987 return [...nestedDatalist.options].map(option => option.hasAttribute('value') ? option.value : option.textContent).filter(value => value !== null && value !== undefined) 1988 } 1989 1990 return [] 1991 } 1992 1993 _getOptionsWithLabels() { 1994 const datalistId = this.getAttribute("list"); 1995 if(datalistId) { 1996 const datalist = document.getElementById(datalistId); 1997 if(datalist) { 1998 return [...datalist.options].map(option => ({ 1999 value: option.value, 2000 label: option.textContent || option.value 2001 })).filter(item => item.value !== null && item.value !== undefined) 2002 } 2003 } 2004 2005 // Fall back to nested datalist 2006 const nestedDatalist = this.querySelector('datalist'); 2007 if(nestedDatalist) { 2008 return [...nestedDatalist.options].map(option => ({ 2009 value: option.hasAttribute('value') ? option.value : option.textContent, 2010 label: option.textContent || option.value 2011 })).filter(item => item.value !== null && item.value !== undefined) 2012 } 2013 2014 return [] 2015 } 2016 2017 async connectedCallback() { 2018 this.setAttribute('tabindex', '0'); 2019 this.addEventListener("focus", e => this.focus(e)); 2020 2021 // Wait for child tag-option elements to be fully connected 2022 await new Promise(resolve => setTimeout(resolve, 0)); 2023 2024 this._shadowRoot.replaceChildren(); 2025 2026 this._wrapperTarget = document.createElement("div"); 2027 this._wrapperTarget.style.position = "relative"; 2028 2029 this.containerTarget = document.createElement("div"); 2030 this.containerTarget.id = "container"; 2031 this.containerTarget.appendChild(document.createElement("slot")); 2032 2033 this.inputTarget = document.createElement("input"); 2034 this.inputTarget.id = "inputTarget"; 2035 this.inputTarget.type = "hidden"; 2036 this.inputTarget.name = this.name || ""; 2037 2038 this._wrapperTarget.appendChild(this.containerTarget); 2039 this._wrapperTarget.appendChild(this.inputTarget); 2040 this._shadowRoot.appendChild(this._wrapperTarget); 2041 2042 this.form?.addEventListener("reset", this.reset.bind(this)); 2043 2044 this.required = this.hasAttribute("required"); 2045 2046 const maxTags = this.multiple ? undefined : 1; 2047 const placeholder = this.inputTarget.getAttribute("placeholder"); 2048 2049 this.inputTarget.value = ""; 2050 this.inputTarget.id = ""; 2051 2052 this._taggle = new Taggle(this, { 2053 inputContainer: this.containerTarget, 2054 preserveCase: true, 2055 hiddenInputName: this.name, 2056 maxTags: maxTags, 2057 placeholder: placeholder, 2058 onTagAdd: (event, tag) => this.onTagAdd(event, tag), 2059 onTagRemove: (event, tag) => this.onTagRemove(event, tag), 2060 }); 2061 this._taggleInputTarget = this._taggle.getInput(); 2062 this._taggleInputTarget.id = this.id; 2063 this._taggleInputTarget.autocomplete = "off"; 2064 this._taggleInputTarget.setAttribute("data-turbo-permanent", true); 2065 this._taggleInputTarget.addEventListener("keyup", e => this.keyup(e)); 2066 2067 // Set initial value after taggle is initialized 2068 this.value = this._taggle.getTagValues(); 2069 2070 this.checkRequired(); 2071 2072 this.buttonTarget = document.createElement("button"); 2073 this.buttonTarget.type = "button"; 2074 this.buttonTarget.className = "add"; 2075 this.buttonTarget.textContent = "+"; 2076 this.buttonTarget.addEventListener("mousedown", e => e.preventDefault()); 2077 this.buttonTarget.addEventListener("click", e => this._add(e)); 2078 this._taggleInputTarget.insertAdjacentElement("afterend", this.buttonTarget); 2079 2080 this._taggleInputTarget.addEventListener("input", () => this._updateButtonContent()); 2081 2082 this.autocompleteContainerTarget = document.createElement("ul"); 2083 this._wrapperTarget.appendChild(this.autocompleteContainerTarget); 2084 2085 this.setupAutocomplete(); 2086 2087 this.observe(); // Start observing after taggle is set up 2088 this.initialized = true; 2089 2090 // Update visibility based on current state 2091 this.updateInputVisibility(); 2092 this._updateButtonContent(); 2093 } 2094 2095 setupAutocomplete() { 2096 this._autocompleteResult = autocomplete({ 2097 input: this._taggleInputTarget, 2098 container: this.autocompleteContainerTarget, 2099 className: "ui-menu ui-autocomplete", 2100 fetch: (text, update) => { 2101 const currentTags = this._taggle.getTagValues(); 2102 const suggestions = this._getOptionsWithLabels().filter(option => 2103 option.label.toLowerCase().includes(text.toLowerCase()) && 2104 !currentTags.includes(option.value) 2105 ); 2106 update(suggestions); 2107 }, 2108 render: item => { 2109 const li = document.createElement("li"); 2110 li.className = "ui-menu-item"; 2111 li.dataset.value = item.value; 2112 li.textContent = item.label; 2113 return li 2114 }, 2115 onSelect: item => { 2116 // Prevent adding multiple tags in single mode 2117 if (!this.multiple && this._taggle.getTagValues().length > 0) { 2118 this._taggleInputTarget.value = ''; 2119 this._updateButtonContent(); 2120 return 2121 } 2122 2123 // Create a tag-option element with proper value/label separation 2124 const tagOption = document.createElement('tag-option'); 2125 tagOption.setAttribute('value', item.value); 2126 tagOption.textContent = item.label; 2127 this.appendChild(tagOption); 2128 2129 // Clear input 2130 this._taggleInputTarget.value = ''; 2131 this._updateButtonContent(); 2132 }, 2133 minLength: 0, 2134 customize: (input, inputRect, container, maxHeight) => { 2135 // Position autocomplete below the input-tag container, accounting for dynamic height 2136 this._updateAutocompletePosition(container); 2137 2138 // Store reference to update positioning when container height changes 2139 this._autocompleteContainer = container; 2140 } 2141 }); 2142 } 2143 2144 disconnectedCallback() { 2145 this.form?.removeEventListener("reset", this.reset.bind(this)); 2146 this.unobserve(); 2147 } 2148 2149 attributeChangedCallback(name, oldValue, newValue) { 2150 if (oldValue === newValue) return; 2151 2152 // Only handle changes after the component is connected and initialized 2153 if (!this._taggle) return; 2154 2155 switch (name) { 2156 case 'name': 2157 this.handleNameChange(newValue); 2158 break; 2159 case 'multiple': 2160 this.handleMultipleChange(newValue !== null); 2161 break; 2162 case 'required': 2163 this.handleRequiredChange(newValue !== null); 2164 break; 2165 case 'list': 2166 this.handleListChange(newValue); 2167 break; 2168 } 2169 } 2170 2171 checkRequired() { 2172 const flag = this.required && this._taggle.getTagValues().length == 0; 2173 this._taggleInputTarget.required = flag; 2174 2175 // Update ElementInternals validity to match internal input 2176 if (flag) { 2177 this._internals.setValidity({ valueMissing: true }, 'Please fill out this field.', this._taggleInputTarget); 2178 } else { 2179 this._internals.setValidity({}); 2180 } 2181 } 2182 2183 // monkeypatch support for android comma 2184 keyup(event) {
2185 const key = event.which || event.keyCode; 2186 const normalKeyboard = key != 229; 2187 if(normalKeyboard) return 2188 const value = this._taggleInputTarget.value; 2189 2190 // backspace 2191 if(value.length == 0) { 2192 const values = this._taggle.tag.values; 2193 this._taggle.remove(values[values.length - 1]); 2194 return 2195 } 2196 2197 // comma 2198 if(/,$/.test(value)) { 2199 const tag = value.replace(',', ''); 2200 this._taggle.add(tag); 2201 this._taggleInputTarget.value = ''; 2202 return 2203 } 2204 } 2205 2206 _add(event) { 2207 event.preventDefault(); 2208 const value = this._taggleInputTarget.value; 2209 if (value === '') { 2210 if (this._isAutocompleteOpen()) { 2211 this._closeAutocomplete(); 2212 } else { 2213 this._taggleInputTarget.focus(); 2214 this._autocompleteResult.fetch(); 2215 } 2216 return 2217 } 2218 this._taggle.add(value); 2219 this._taggleInputTarget.value = ''; 2220 this._updateButtonContent(); 2221 } 2222 2223 _isAutocompleteOpen() { 2224 return this._taggleInputTarget.getAttribute("aria-expanded") === "true" 2225 } 2226 2227 _closeAutocomplete() { 2228 this._taggleInputTarget.setAttribute("aria-expanded", "false"); 2229 this.autocompleteContainerTarget.remove(); 2230 } 2231 2232 _updateButtonContent() { 2233 const isEmpty = !this._taggleInputTarget.value; 2234 const currentTags = this._taggle.getTagValues(); 2235 const hasAvailableOption = this.options.some(value => !currentTags.includes(value)); 2236 this.buttonTarget.textContent = isEmpty && hasAvailableOption ? "▾" : "+"; 2237 } 2238 2239 onTagAdd(event, tag) { 2240 if (!this.suppressEvents) { 2241 const isNew = !this.options.includes(tag); 2242 this.dispatchEvent(new CustomEvent("update", { 2243 detail: { tag, isNew }, 2244 bubbles: true, 2245 composed: true, 2246 })); 2247 } 2248 this.syncValue(); 2249 this.checkRequired(); 2250 this.updateInputVisibility(); 2251 // Defer button update: taggle clears input.value after calling this callback 2252 setTimeout(() => this._updateButtonContent(), 0); 2253 2254 // Update autocomplete position if it's currently open 2255 if (this._autocompleteContainer) { 2256 // Use setTimeout to allow DOM to update first 2257 setTimeout(() => this._updateAutocompletePosition(this._autocompleteContainer), 0); 2258 } 2259 } 2260 2261 onTagRemove(event, tag) { 2262 if (!this.suppressEvents) { 2263 this.dispatchEvent(new CustomEvent("update", { 2264 detail: { tag }, 2265 bubbles: true, 2266 composed: true, 2267 })); 2268 } 2269 this.syncValue(); 2270 this.checkRequired(); 2271 this.updateInputVisibility(); 2272 this._updateButtonContent(); 2273 2274 // Update autocomplete position if it's currently open 2275 if (this._autocompleteContainer) { 2276 // Use setTimeout to allow DOM to update first 2277 setTimeout(() => this._updateAutocompletePosition(this._autocompleteContainer), 0); 2278 } 2279 } 2280 2281 syncValue() { 2282 // Directly update internals without triggering the setter 2283 const values = this._taggle.getTagValues(); 2284 const oldValues = this._internals.value; 2285 this._setFormValue(values); 2286 2287 if(this.initialized && !this.suppressEvents && JSON.stringify(oldValues) !== JSON.stringify(values)) { 2288 this.dispatchEvent(new CustomEvent("change", { 2289 bubbles: true, 2290 composed: true, 2291 })); 2292 } 2293 } 2294 2295 // Public API methods 2296 add(tags) { 2297 if (!this._taggle) return 2298 this._taggle.add(tags); 2299 } 2300 2301 remove(tag) { 2302 if (!this._taggle) return 2303 this._taggle.remove(tag); 2304 } 2305 2306 removeAll() { 2307 if (!this._taggle) return 2308 this._taggle.removeAll(); 2309 } 2310 2311 has(tag) { 2312 if (!this._taggle) return false 2313 return this._taggle.getTagValues().includes(tag) 2314 } 2315 2316 get tags() { 2317 if (!this._taggle) return [] 2318 return this._taggle.getTagValues() 2319 } 2320 2321 // Private getter for testing autocomplete suggestions 2322 get _autocompleteSuggestions() { 2323 if (!this.autocompleteContainerTarget) return [] 2324 const items = this.autocompleteContainerTarget.querySelectorAll('.ui-menu-item'); 2325 return Array.from(items).map(item => item.textContent.trim()) 2326 } 2327 2328 // Update autocomplete position based on current container height 2329 _updateAutocompletePosition(container) { 2330 if (!container) return 2331 2332 const inputTagRect = this.containerTarget.getBoundingClientRect(); 2333 2334 container.style.setProperty('position', 'absolute', 'important'); 2335 container.style.setProperty('top', `${inputTagRect.height}px`, 'important'); 2336 container.style.setProperty('left', '0', 'important'); 2337 container.style.setProperty('right', '0', 'important'); 2338 container.style.setProperty('width', '100%', 'important'); 2339 container.style.setProperty('z-index', '1000', 'important'); 2340 } 2341 2342 updateInputVisibility() { 2343 if (!this._taggleInputTarget || !this.buttonTarget) return; 2344 2345 const hasTags = this._taggle && this._taggle.getTagValues().length > 0; 2346 2347 if (this.multiple) { 2348 // Multiple mode: always show input and button 2349 this._taggleInputTarget.style.display = ''; 2350 this.buttonTarget.style.display = ''; 2351 } else { 2352 // Single mode: hide input and button when tag exists 2353 if (hasTags) { 2354 this._taggleInputTarget.style.display = 'none'; 2355 this.buttonTarget.style.display = 'none'; 2356 } else { 2357 this._taggleInputTarget.style.display = ''; 2358 this.buttonTarget.style.display = ''; 2359 } 2360 } 2361 } 2362 2363 addAt(tag, index) { 2364 if (!this._taggle) return 2365 this._taggle.add(tag, index); 2366 } 2367 2368 disable() { 2369 if (this._taggle) { 2370 this._taggle.disable(); 2371 } 2372 } 2373 2374 enable() { 2375 if (this._taggle) { 2376 this._taggle.enable(); 2377 } 2378 } 2379 2380 focus() { 2381 if (this._taggleInputTarget) {
2382 this._taggleInputTarget.focus(); 2383 } 2384 } 2385 2386 checkValidity() { 2387 if (this._taggle) { 2388 this.checkRequired(); 2389 } 2390 return this._internals.checkValidity() 2391 } 2392 2393 reportValidity() { 2394 if (this._taggle) { 2395 this.checkRequired(); 2396 } 2397 return this._internals.reportValidity() 2398 } 2399 2400 handleNameChange(newName) { 2401 // Update the hidden input name to match 2402 const hiddenInput = this._shadowRoot.querySelector('input[type="hidden"]'); 2403 if (hiddenInput) { 2404 hiddenInput.name = newName || ''; 2405 } 2406 2407 // Update the form value with the new name 2408 if (this._internals.value) { 2409 this.value = this._internals.value; // This will recreate FormData with new name 2410 } 2411 } 2412 2413 handleMultipleChange(isMultiple) { 2414 if (!this._taggle) return; 2415 2416 // Get current tags 2417 const currentTags = this._taggle.getTagValues(); 2418 2419 if (!isMultiple && currentTags.length > 1) { 2420 // Single mode: remove excess tag-option elements from DOM 2421 const tagOptions = Array.from(this.children); 2422 // Keep only the first tag-option element, remove the rest 2423 tagOptions.forEach((tagOption, i) => { 2424 if (i > 0 && tagOption) { 2425 this.removeChild(tagOption); 2426 } 2427 }); 2428 } 2429 2430 // Reinitialize taggle with new multiple setting 2431 this.reinitializeTaggle(); 2432 2433 // Restore tags, respecting the new multiple constraint 2434 if (isMultiple) { 2435 // Multiple mode: restore all remaining tags 2436 if (currentTags.length > 0) { 2437 this._taggle.add(currentTags); 2438 } 2439 } else { 2440 // Single mode: keep only the first tag 2441 if (currentTags.length > 0) { 2442 this._taggle.add(currentTags[0]); 2443 } 2444 } 2445 2446 this.updateValue(); 2447 this.updateInputVisibility(); 2448 } 2449 2450 handleRequiredChange(isRequired) { 2451 if (!this._taggle) return; 2452 2453 // Update the internal required state 2454 this.required = isRequired; 2455 2456 // Update validation 2457 this.checkRequired(); 2458 } 2459 2460 handleListChange(newListId) { 2461 if (!this._taggle) return; 2462 2463 // Re-setup autocomplete with new datalist 2464 this.setupAutocomplete(); 2465 } 2466 2467 reinitializeTaggle() { 2468 // Clean up existing taggle if it exists 2469 if (this._taggle && this._taggle.destroy) { 2470 this._taggle.destroy(); 2471 } 2472 2473 // Get current configuration 2474 const maxTags = this.hasAttribute("multiple") ? undefined : 1; 2475 const placeholder = this.getAttribute("placeholder") || ""; 2476 2477 // Create new taggle instance using original configuration pattern 2478 this._taggle = new Taggle(this, { 2479 inputContainer: this.containerTarget, 2480 preserveCase: true, 2481 hiddenInputName: this.name, 2482 maxTags: maxTags, 2483 placeholder: placeholder, 2484 onTagAdd: (event, tag) => this.onTagAdd(event, tag), 2485 onTagRemove: (event, tag) => this.onTagRemove(event, tag), 2486 }); 2487 2488 // Re-get references since taggle was recreated 2489 this._taggleInputTarget = this._taggle.getInput(); 2490 this._taggleInputTarget.id = this.id || ""; 2491 this._taggleInputTarget.autocomplete = "off"; 2492 this._taggleInputTarget.setAttribute("data-turbo-permanent", true); 2493 this._taggleInputTarget.addEventListener("keyup", e => this.keyup(e)); 2494 this._taggleInputTarget.addEventListener("input", () => this._updateButtonContent()); 2495 2496 // Re-setup autocomplete 2497 this.setupAutocomplete(); 2498 this._updateButtonContent(); 2499 2500 // Re-process existing tag options 2501 this.processTagOptions(); 2502 } 2503 2504 updateValue() { 2505 if (!this._taggle) return; 2506 2507 // Update the internal value to match taggle state 2508 const values = this._taggle.getTagValues(); 2509 const oldValues = this._internals.value; 2510 this._setFormValue(values); 2511 2512 // Check validity after updating 2513 this.checkRequired(); 2514 2515 if(this.initialized && !this.suppressEvents && JSON.stringify(oldValues) !== JSON.stringify(values)) { 2516 this.dispatchEvent(new CustomEvent("change", { 2517 bubbles: true, 2518 composed: true, 2519 })); 2520 } 2521 } 2522} 2523customElements.define("input-tag", InputTag); 2524// concatenate successive sites main menus together. this allows for extension in other engines. pretty lame. 2525document.addEventListener("DOMContentLoaded", () => { 2526 let first = null 2527 Array.from(document.querySelectorAll("#top >
2527 .main")).forEach((element, index) => { 2528 if(index === 0) { 2529 first = element 2530 } else { 2531 first.insertAdjacentHTML("beforeend", element.innerHTML) 2532 element.remove() 2533 } 2534 }) 2535}) 2536; 2537document.addEventListener("DOMContentLoaded", () => { 2538 Array.from(document.querySelectorAll(".wysiwyg")).forEach(e => { 2539 if(!window.CKEDITOR) return 2540 CKEDITOR.timestamp = new Date().valueOf() 2541 e.ckeditor = CKEDITOR.replace(e.id, { 2542 width: e.getBoundingClientRect().width, 2543 height: e.getBoundingClientRect().height, 2544 customConfig: '/assets/ckeditor/config-d6288973a5d4491b97a4deeefd9eaa8bec60f0d05460200fa2352296e5fdbf63.js', 2545 }) 2546 }) 2547}) 2548; 2549document.addEventListener("DOMContentLoaded", () => { 2550 const table = document.querySelector("table.list") 2551 const link = document.querySelector("a.reorder") 2552 if(table && link) new TableTree(table, link) 2553}) 2554 2555/** 2556 * Configuration options: 2557 * 2558 * onDragClass 2559 * This class is added for the duration of the drag and then removed when the row is dropped. It is more 2560 * flexible than using onDragStyle since it can be inherited by the row cells and other content. The default 2561 * is class is tDnD_whileDrag. So to use the default, simply customise this CSS class in your 2562 * stylesheet. 2563 * onDrop 2564 * Pass a function that will be called when the row is dropped. The function takes 2 parameters: the table 2565 * and the row that was dropped. You can work out the new order of the rows by using 2566 * table.rows. 2567 * onDragStart 2568 * Pass a function that will be called when the user starts dragging. The function takes 2 parameters: the 2569 * table and the row which the user has started to drag. 2570 * onAllowDrop 2571 * Pass a function that will be called as a row is over another row. If the function returns true, allow 2572 * dropping on that row, otherwise not. The function takes 2 parameters: the dragged row and the row under 2573 * the cursor. It returns a boolean: true allows the drop, false doesn't allow it. 2574 * scrollAmount 2575 * This is the number of pixels to scroll if the user moves the mouse
2575cursor to the top or bottom of the 2576 * window. The page should automatically scroll up or down as appropriate. 2577 */ 2578 2579class TableDnD { 2580 constructor() { 2581 this.currentTable = null 2582 this.dragObject = null 2583 this.mouseOffset = null 2584 this.oldY = 0 2585 } 2586 2587 add(table, options) { 2588 table.tableDnDConfig = { 2589 // Add in the default class for whileDragging 2590 onDragClass: "tDnD_whileDrag", 2591 onDrop: null, 2592 onDrag: null, // ADDED 2593 onDragStart: null, 2594 scrollAmount: 5, 2595 ...(options || {}), 2596 } 2597 this.makeDraggable(table) 2598 } 2599 2600 makeDraggable(table) { 2601 table.querySelectorAll("tr").forEach(row => { 2602 row.addEventListener("mousedown", event => this.mousedown(table, row, event)) 2603 row.style.cursor = "move" 2604 }) 2605 // Now we need to capture the mouse up and mouse move event 2606 // We can use bind so that we don't interfere with other event handlers 2607 document.addEventListener('mousemove', event => this.mousemove(event)) 2608 document.addEventListener('mouseup', event => this.mouseup(event)) 2609 } 2610 2611 mousedown(table, row, event) { 2612 if(event.target.tagName == "TD") { 2613 event.preventDefault() 2614 this.dragObject = row 2615 this.currentTable = table 2616 this.mouseOffset = this.getMouseOffset(row, event) 2617 let config = table.tableDnDConfig 2618 if(config.onDragStart) config.onDragStart(table, row) 2619 } 2620 } 2621 2622 /** Given a target element and a mouse event, get the mouse offset from that element. 2623 To do this we need the element's position and the mouse position */ 2624 getMouseOffset(target, event) { 2625 var docPos = this.getPosition(target) 2626 return { 2627 x: event.pageX - docPos.x, 2628 y: event.pageY - docPos.y, 2629 } 2630 } 2631 2632 getPosition(element) { 2633 const rect = element.getBoundingClientRect() 2634 return { 2635 x: rect.left + window.pageXOffset, 2636 y: rect.top + window.pageYOffset, 2637 } 2638 } 2639 2640 mousemove(event) { 2641 if (this.dragObject == null) { 2642 return 2643 } 2644 2645 var config = this.currentTable.tableDnDConfig 2646 var y = event.pageY - this.mouseOffset.y 2647 var yOffset = window.pageYOffset 2648 if (event.pageY - yOffset < config.scrollAmount) { 2649 window.scrollBy(0, -config.scrollAmount) 2650 } else if(window.innerHeight - (event.pageY-yOffset) < config.scrollAmount) { 2651 window.scrollBy(0, config.scrollAmount) 2652 } 2653 2654 if (y != this.oldY) { 2655 // work out if we're going up or down... 2656 var movingDown = y > this.oldY 2657 this.oldY = y 2658 this.dragObject.classList.add(config.onDragClass) 2659 // If we're over a row then move the dragged row to there so that the user sees the 2660 // effect dynamically 2661 var currentRow = this.findDropTargetRow(this.dragObject, y, movingDown) 2662 if(currentRow) { 2663 if (movingDown && this.dragObject != currentRow) { 2664 this.dragObject.parentNode.insertBefore(this.dragObject, currentRow.nextSibling) 2665 } else if(!movingDown && this.dragObject != currentRow) { 2666 this.dragObject.parentNode.insertBefore(this.dragObject, currentRow) 2667 } 2668 if(config.onDrag) { 2669 config.onDrag(this.currentTable, this.dragObject) 2670 } 2671 } 2672 } 2673 return false 2674 } 2675 2676 /** We're only worried about the y position really, because we can only move rows up and down */ 2677 findDropTargetRow(draggedRow, y, movingDown) { 2678 const rows = Array.from(this.currentTable.querySelectorAll("tr")) 2679 for (var i=0; i<rows.length; i++) { 2680 var row = rows[i] 2681 var rowY = this.getPosition(row).y 2682 var rowHeight = parseInt(row.offsetHeight)/2 2683 if (row.offsetHeight == 0) { 2684 rowY = this.getPosition(row.firstChild).y 2685 rowHeight = parseInt(row.firstChild.offsetHeight)/2 2686 } 2687 // Because we always have to insert before, we need to offset the height a bit 2688 if ((y > rowY - rowHeight) && (y < (rowY + rowHeight))) { 2689 // that's the row we're over 2690 // If it's the same as the current row, ignore it 2691 if(row == draggedRow) return 2692 var config = this.currentTable.tableDnDConfig 2693 if (config.onAllowDrop) { 2694 if(!config.onAllowDrop(draggedRow, row, movingDown)) return 2695 } 2696 return row 2697 } 2698 } 2699 } 2700 2701 mouseup(e) { 2702 if(this.currentTable && this.dragObject) { 2703 var droppedRow = this.dragObject 2704 var config = this.currentTable.tableDnDConfig 2705 // If we have a dragObject, then we need to release it, 2706 // The row will already have been moved to the right place so we just reset stuff
2707 droppedRow.classList.remove(config.onDragClass) 2708 this.dragObject = null 2709 if(config.onDrop) config.onDrop(this.currentTable, droppedRow) 2710 this.currentTable = null; // let go of the table too 2711 } 2712 } 2713 2714 teardown(table) { 2715 table.querySelectorAll("tr").forEach(row => { 2716 row.removeEventListener("mousedown", event => this.mousedown(table, row, event)) 2717 row.style.cursor = "auto" 2718 }) 2719 this.dragObject = null 2720 this.currentTable = null 2721 this.mouseOffset = null 2722 } 2723} 2724 2725class Base { 2726 find_node(element) { 2727 for (var i = 0; i < this.children.length; i++) { 2728 var child = this.children[i] 2729 if (this.children[i].element == element) { 2730 return this.children[i] 2731 } else { 2732 var result = this.children[i].find_node(element) 2733 if (result) return result 2734 } 2735 } 2736 } 2737 2738 ttnode(node) { 2739 var subject = node.push ? node[0] : node 2740 return (this.current_table || this.table_tree.current_table).find_node(subject) 2741 } 2742} 2743 2744class TableTree extends Base { 2745 constructor(table, link) { 2746 super() 2747 this.table = table 2748 this.type = link.id.replace('reorder_', '') 2749 this.collection_url = link.href 2750 this.tableDnD = new TableDnD() 2751 2752 link.addEventListener("click", event => { 2753 event.preventDefault() 2754 link.parentElement.classList.toggle('active') 2755 this.toggle() 2756 }) 2757 2758 this.tableDnDOptions = { 2759 onDragClass: 'drag', 2760 onDragStart: (table, row) => { 2761 this.startOffset = this.tableDnD.mouseOffset.x 2762 row.addEventListener("mousemove", event => this.mousemove(event)) 2763 this.ttnode(row)?.dragStart() 2764 }, 2765 onDrag: (table, row) => { 2766 this.current_table.dirty = true 2767 this.ttnode(row)?.update_children() 2768 }, 2769 onDrop: (table, row) => { 2770 row.removeEventListener("mousemove", event => this.mousemove(event)) 2771 this.ttnode(row)?.drop() 2772 this.current_table.rebuild() 2773 this.current_table.update_remote(row) 2774 }, 2775 onAllowDrop: (draggedRow, row, movingDown) => { 2776 const node = this.ttnode(row) 2777 const next = movingDown ? node.next_row_sibling() : node 2778 if(next && next.parent && this.ttnode(draggedRow)) { 2779 if(next.parent.level >= this.ttnode(draggedRow).level) return false 2780 } 2781 return node ? true : false 2782 } 2783 } 2784 } 2785 2786 toggle() { 2787 this.current_table ? this.teardown() : this.setup() 2788 } 2789 2790 setup() { 2791 const tbody = this.table.querySelector('tbody') 2792 this.tableDnD.add(tbody, this.tableDnDOptions) 2793 this.current_table = new Table(this, this.table, this.type, this.collection_url) 2794 this.current_table.setSortable() 2795 } 2796 2797 teardown() { 2798 // this.current_table.update_remote() 2799 this.tableDnD.teardown(this.table) 2800 this.current_table.setUnsortable() 2801 this.current_table = null 2802 } 2803 2804 level(element) { 2805 var match = element.className.match(/level_([\d]+)/) 2806 return match ? parseInt(match[1]) : 0 2807 } 2808 2809 mousemove(event) { 2810 if (!this.current_table.is_tree) return 2811 2812 const element = event.target.closest("tr") 2813 const offset = this.tableDnD.getMouseOffset(element, event).x - this.startOffset 2814 if(offset > 25) { 2815 this.current_table.dirty = true 2816 this.ttnode(element).increment_level(event) 2817 } else if(offset < -25) { 2818 this.current_table.dirty = true 2819 this.ttnode(element).decrement_level(event) 2820 } 2821 } 2822} 2823 2824class Table extends Base { 2825 constructor(table_tree, table, type, collection_url) { 2826 super() 2827 this.table_tree = table_tree 2828 this.is_tree = table.classList.contains('tree') 2829 this.table = table 2830 this.type = type 2831 this.level = -1 2832 this.collection_url = collection_url 2833 this.rebuild() 2834 } 2835 2836 rebuild() { 2837 this.trs = Array.from(this.table.querySelectorAll('tr')) 2838 this.children = this.trs.map(tr => { 2839 if(this.table_tree.level(tr) === 0) { 2840 return new TableNode(this.table_tree, this, tr, this.table_tree.level(tr)) 2841 } 2842 }).filter(e => e) 2843 } 2844 2845 setSortable() { 2846 this.trs.forEach(tr => { 2847 // tr is in thead 2848 let cells = tr.querySelectorAll('th')
2849 cells.forEach((th, index) => { 2850 if(index === 0) { 2851 th.setAttribute('colspan', cells.length) 2852 } else { 2853 th.hidden = true 2854 } 2855 }) 2856 2857 // tbody 2858 cells = tr.querySelectorAll('td') 2859 cells.forEach((td, index) => { 2860 if (index === 0) { 2861 td.setAttribute('colspan', cells.length) 2862 td.querySelectorAll('a').forEach((a, index) => { 2863 a.hidden = true 2864 if (index === 0) { 2865 td.appendChild(document.createTextNode(a.innerText)) 2866 } 2867 }) 2868 } else { 2869 td.hidden = true 2870 } 2871 }) 2872 }) 2873 } 2874 2875 setUnsortable() { 2876 this.trs.forEach(tr => { 2877 // tr is in thead 2878 let cells = tr.querySelectorAll('th') 2879 cells.forEach((th, index) => { 2880 if(index === 0) { 2881 this.setAttribute('colspan', 1) 2882 } else { 2883 th.hidden = false 2884 } 2885 }) 2886 2887 // tbody 2888 cells = tr.querySelectorAll('td') 2889 cells.forEach((td, index) => { 2890 if (index === 0) { 2891 td.querySelectorAll('a').forEach((a, index) => { 2892 a.hidden = false 2893 if (index === 0) { 2894 td.removeChild(td.lastChild) 2895 } 2896 }) 2897 td.querySelector('img.spinner')?.remove() 2898 td.setAttribute('colspan', 1) 2899 } else { 2900 td.hidden = false 2901 } 2902 }) 2903 }) 2904 } 2905 2906 update_remote(row) { 2907 if(!this.dirty) return 2908 this.dirty = false 2909 this.show_spinner(row) 2910 2911 fetch(this.collection_url, { 2912 method: "PUT", 2913 body: new URLSearchParams({ 2914 ...this.serialize(row), 2915 authenticity_token: window._auth_token, 2916 }), 2917 }) 2918 .then(() => this.hide_spinner(row)) 2919 .catch(() => this.hide_spinner(row)) 2920 } 2921 2922 serialize(row) { 2923 row = this.ttnode(row) 2924 let data = {} 2925 data[`${this.type}[${row.id()}][parent_id]`] = row.parent_id() 2926 data[`${this.type}[${row.id()}][left_id]`] = row.left_id() 2927 return data 2928 } 2929 2930 show_spinner(row) { 2931 let img = document.createElement('img') 2932 img.src = 'data:image/gif;
2932base64,R0lGODlhEAAQAPQAAP///wAAAPDw8IqKiuDg4EZGRnp6egAAAFhYWCQkJKysrL6+vhQUFJycnAQEBDY2NmhoaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh/hpDcmVhdGVkIHdpdGggYWpheGxvYWQuaW5mbwAh+QQJCgAAACwAAAAAEAAQAAAFdyAgAgIJIeWoAkRCCMdBkKtIHIngyMKsErPBYbADpkSCwhDmQCBethRB6Vj4kFCkQPG4IlWDgrNRIwnO4UKBXDufzQvDMaoSDBgFb886MiQadgNABAokfCwzBA8LCg0Egl8jAggGAA1kBIA1BAYzlyILczULC2UhACH5BAkKAAAALAAAAAAQABAAAAV2ICACAmlAZTmOREEIyUEQjLKKxPHADhEvqxlgcGgkGI1DYSVAIAWMx+lwSKkICJ0QsHi9RgKBwnVTiRQQgwF4I4UFDQQEwi6/3YSGWRRmjhEETAJfIgMFCnAKM0KDV4EEEAQLiF18TAYNXDaSe3x6mjidN1s3IQAh+QQJCgAAACwAAAAAEAAQAAAFeCAgAgLZDGU5jgRECEUiCI+yioSDwDJyLKsXoHFQxBSHAoAAFBhqtMJg8DgQBgfrEsJAEAg4YhZIEiwgKtHiMBgtpg3wbUZXGO7kOb1MUKRFMysCChAoggJCIg0GC2aNe4gqQldfL4l/Ag1AXySJgn5LcoE3QXI3IQAh+QQJCgAAACwAAAAAEAAQAAAFdiAgAgLZNGU5joQhCEjxIssqEo8bC9BRjy9Ag7GILQ4QEoE0gBAEBcOpcBA0DoxSK/e8LRIHn+i1cK0IyKdg0VAoljYIg+GgnRrwVS/8IAkICyosBIQpBAMoKy9dImxPhS+GKkFrkX+TigtLlIyKXUF+NjagNiEAIfkECQoAAAAsAAAAABAAEAAABWwgIAICaRhlOY4EIgjH8R7LKhKHGwsMvb4AAy3WODBIBBKCsYA9TjuhDNDKEVSERezQEL0WrhXucRUQGuik7bFlngzqVW9LMl9XWvLdjFaJtDFqZ1cEZUB0dUgvL3dgP4WJZn4jkomWNpSTIyEAIfkECQoAAAAsAAAAABAAEAAABX4gIAICuSxlOY6CIgiD8RrEKgqGOwxwUrMlAoSwIzAGpJpgoSDAGifDY5kopBYDlEpAQBwevxfBtRIUGi8xwWkDNBCIwmC9Vq0aiQQDQuK+VgQPDXV9hCJjBwcFYU5pLwwHXQcMKSmNLQcIAExlbH8JBwttaX0ABAcNbWVbKyEAIfkECQoAAAAsAAAAABAAEAAABXkgIAICSRBlOY7CIghN8zbEKsKoIjdFzZaEgUBHKChMJtRwcWpAWoWnifm6ESAMhO8lQK0EEAV3rFopIBCEcGwDKAqPh4HUrY4ICHH1dSoTFgcHUiZjBhAJB2AHDykpKAwHAwdzf19KkASIPl9cDgcnDkdtNwiMJCshACH5BAkKAAAALAAAAAAQABAAAAV3ICACAkkQZTmOAiosiyAoxCq+KPxCNVsSMRgBsiClWrLTSWFoIQZHl6pleBh6suxKMIhlvzbAwkBWfFWrBQTxNLq2RG2yhSUkDs2b63AYDAoJXAcFRwADeAkJDX0AQCsEfAQMDAIPBz0rCgcxky0JRWE1AmwpKyEAIfkECQoAAAAsAAAAABAAEAAABXkgIAICKZzkqJ4nQZxLqZKv4NqNLKK2/Q4Ek4lFXChsg5ypJjs1II3gEDUSRInEGYAw6B6zM4JhrDAtEosVkLUtHA7RHaHAGJQEjsODcEg0FBAFVgkQJQ1pAwcDDw8KcFtSInwJAowCCA6RIwqZAgkPNgVpWndjdyohACH5BAkKAAAALAAAAAAQABAAAAV5ICACAimc5KieLEuUKvm2xAKLqDCfC2GaO9eL0LABWTiBYmA06W6kHgvCqEJiAIJiu3gcvgUsscHUERm+kaCxyxa+zRPk0SgJEgfIvbAdIAQLCAYlCj4DBw0IBQsMCjIqBAcPAooCBg9pKgsJLwUFOhCZKyQDA3YqIQAh+QQJCgAAACwAAAAAEAAQAAAFdSAgAgIpnOSonmxbqiThCrJKEHFbo8JxDDOZYFFb+A41E4H4OhkOipXwBElYITDAckFEOBgMQ3arkMkUBdxIUGZpEb7kaQBRlASPg0FQQHAbEEMGDSVEAA1QBhAED1E0NgwFAooCDWljaQIQCE5qMHcNhCkjIQAh+QQJCgAAACwAAAAAEAAQAAAFeSAgAgIpnOSoLgxxvqgKLEcCC65KEAByKK8cSpA4DAiHQ/DkKhGKh4ZCtCyZGo6F6iYYPAqFgYy02xkSaLEMV34tELyRYNEsCQyHlvWkGCzsPgMCEAY7Cg04Uk48LAsDhRA8MVQPEF0GAgqYYwSRlycNcWskCkApIyEAOwAAAAAAAAAAAA==' 2933 img.className = 'spinner' 2934 row.querySelector("td").appendChild(img) 2935 } 2936 2937 hide_spinner(row) {
2938 let cell = row.querySelector('td') 2939 cell.removeChild(cell.lastChild) 2940 } 2941} 2942 2943class TableNode extends Base { 2944 constructor(table_tree, parent, element, level) { 2945 super() 2946 this.table_tree = table_tree 2947 this.parent = parent 2948 this.element = element 2949 this.level = level 2950 2951 this.children = this.find_children().map(child => { 2952 var level = this.table_tree.level(child) 2953 if(level == this.level + 1) { 2954 return new TableNode(this.table_tree, this, child, level) 2955 } 2956 }).filter(e => e) 2957 } 2958 2959 find_children() { 2960 var stop = false 2961 return this.row_siblings().slice(this.row_index() + 1).filter(child => { 2962 var level = this.table_tree.level(child) 2963 if(this.level == level) stop = true // how to break from an iterator? 2964 return !stop && this.level + 1 == level 2965 }) 2966 } 2967 2968 depth() { 2969 if (this.children.length > 0) { 2970 return Math.max.apply(Math, this.children.map(child => child.depth())) 2971 } else { 2972 return this.level 2973 } 2974 } 2975 2976 siblings() { 2977 return this.parent.children 2978 } 2979 2980 id() { 2981 return this.element ? this.to_int(this.element.id) : 'null' 2982 } 2983 2984 parent_id() { 2985 return this.parent.element ? this.to_int(this.parent.element.id) : 'null' 2986 } 2987 2988 left_id() { 2989 let left = this.left() 2990 return left ? this.to_int(left.element.id) : 'null' 2991 } 2992 2993 left() { 2994 let siblings = this.siblings() 2995 let ix = siblings.indexOf(this) - 1 2996 if(ix >= 0) return siblings[ix] 2997 } 2998 2999 to_int(str) { 3000 if(str) return str.replace(/[\D]+/, '') 3001 } 3002 3003 next_row_sibling() { 3004 return this.row_siblings()[this.row_index() + 1] 3005 } 3006 3007 row_siblings() { 3008 this._row_siblings ||= Array.from(this.element.parentElement.children) 3009 return this._row_siblings 3010 } 3011 3012 row_index() { 3013 return this.row_siblings().indexOf(this.element) 3014 } 3015 3016 dragStart() { 3017 this.element.classList.add('drag') 3018 this.children.forEach(child => child.dragStart()) 3019 } 3020 3021 drop() { 3022 this.element.classList.remove('drag') 3023 this.children.forEach(child => child.drop()) 3024 this.adjust_level() 3025 } 3026 3027 increment_level(event) { 3028 let prev = this.element.previousElementSibling 3029 if(prev) prev = this.ttnode(prev) 3030 if(!prev || prev.level < this.level || this.depth() >= 5) return 3031 this.update_level(event, this.level + 1) 3032 } 3033 3034 decrement_level(event) { 3035 if(this.level == 0) return 3036 this.update_level(event, this.level - 1) 3037 } 3038 3039 update_level(event, level) { 3040 if (event) this.table_tree.startOffset = this.table_tree.tableDnD.getMouseOffset(this.element, event).x 3041 3042 this.element.classList.remove('level_' + this.level) 3043 this.element.classList.add('level_' + level) 3044 3045 this.level = level 3046 this.children.forEach(child => child.update_level(event, level + 1)) 3047 } 3048 3049 adjust_level() { 3050 var prev = this.element.previousElementSibling 3051 if(!prev) { 3052 this.update_level(null, 0) 3053 } else if(this.ttnode(prev).level + 1 < this.level) { 3054 this.update_level(null, this.ttnode(prev).level + 1) 3055 } 3056 } 3057 3058 update_children() { 3059 this.children.forEach(child => child.element.parentNode.removeChild(child.element)) 3060 var next = this.element.nextSibling 3061 this.children.forEach(child => this.element.parentNode.insertBefore(child.element, next)) 3062 this.children.forEach(child => child.update_children()) 3063 } 3064} 3065; 3066 3067 3068 3069 3070 3071 3072 3073
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.