1/*! 2HotwireCombobox 0.4.1 3*/ 4import { Controller } from '@hotwired/stimulus'; 5 6const Combobox = {}; 7 8Combobox.Actors = Base => class extends Base { 9 _initializeActors() { 10 this._actingListbox = this.listboxTarget; 11 this._actingCombobox = this.comboboxTarget; 12 } 13 14 _forAllComboboxes(callback) { 15 this._allComboboxes.forEach(callback); 16 } 17 18 get _actingListbox() { 19 return this.actingListbox 20 } 21 22 set _actingListbox(listbox) { 23 this.actingListbox = listbox; 24 } 25 26 get _actingCombobox() { 27 return this.actingCombobox 28 } 29 30 set _actingCombobox(combobox) { 31 this.actingCombobox = combobox; 32 } 33 34 get _allComboboxes() { 35 return [ this.comboboxTarget, this.dialogComboboxTarget ] 36 } 37}; 38 39Combobox.Announcements = Base => class extends Base { 40 _announceToScreenReader(display, action) { 41 this.announcerTarget.innerText = `${display} ${action}`; 42 } 43}; 44 45Combobox.AsyncLoading = Base => class extends Base { 46 get _isAsync() { 47 return this.hasAsyncSrcValue 48 } 49 50 get _isSync() { 51 return !this._isAsync 52 } 53}; 54 55function Concerns(Base, ...mixins) { 56 return mixins.reduce((accumulator, current) => current(accumulator), Base) 57} 58 59function visible(target) { 60 return !(target.hidden || target.closest("[hidden]")) 61} 62 63function wrapAroundAccess(array, index) { 64 const first = 0; 65 const last = array.length - 1; 66 67 if (index < first) return array[last] 68 if (index > last) return array[first] 69 return array[index] 70} 71 72function applyFilter(query, { matching }) { 73 return (target) => { 74 if (query) { 75 const value = target.getAttribute(matching) || ""; 76 const match = value.toLowerCase().includes(query.toLowerCase()); 77 78 target.hidden = !match; 79 } else { 80 target.hidden = false; 81 } 82 } 83} 84 85function cancel(event) { 86 event.stopPropagation(); 87 event.preventDefault(); 88} 89 90function startsWith(string, substring) { 91 return string.toLowerCase().startsWith(substring.toLowerCase()) 92} 93 94function debounce(fn, delay) { 95 let timeoutId = null; 96 97 return (...args) => { 98 const callback = () => fn.apply(this, args); 99 clearTimeout(timeoutId); 100 timeoutId = setTimeout(callback, delay); 101 } 102} 103 104function isDeleteEvent(event) { 105 return event.inputType === "deleteContentBackward" || event.inputType === "deleteWordBackward" 106} 107 108function sleep(ms) { 109 return new Promise(resolve => setTimeout(resolve, ms)) 110} 111 112function unselectedPortion(element) { 113 if (element.selectionStart === element.selectionEnd) { 114 return element.value 115 } else { 116 return element.value.substring(0, element.selectionStart) 117 } 118} 119 120function dispatch(eventName, { target, cancelable, detail } = {}) { 121 const event = new CustomEvent(eventName, { 122 cancelable, 123 bubbles: true, 124 composed: true, 125 detail 126 }); 127 128 if (target && target.isConnected) { 129 target.dispatchEvent(event); 130 } else { 131 document.documentElement.dispatchEvent(event); 132 } 133 134 return event 135} 136 137function nextRepaint() { 138 if (document.visibilityState === "hidden") { 139 return nextEventLoopTick() 140 } else { 141 return nextAnimationFrame() 142 } 143} 144 145function nextAnimationFrame() { 146 return new Promise((resolve) => requestAnimationFrame(() => resolve())) 147} 148 149function nextEventLoopTick() { 150 return new Promise((resolve) => setTimeout(() => resolve(), 0)) 151} 152 153function randomUUID() { 154 const uuidPattern = "10000000-1000-4000-8000-100000000000"; 155 156 return uuidPattern.replace(/[018]/g, (match) => { 157 const randomByte = crypto.getRandomValues(new Uint8Array(1))[0]; 158 return (match ^ (randomByte & 15) >> (match / 4)).toString(16) 159 }) 160} 161 162Combobox.Autocomplete = Base => class extends Base { 163 _connectListAutocomplete() { 164 if (!this._autocompletesList) { 165 this._visuallyHideListbox(); 166 } 167 } 168 169 _hardAutocomplete(option) { 170 const typedValue = this._typedQuery; 171 const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue); 172 173 this._fullQuery = autocompletedValue; 174 175 if (this._isAutocompletableWith(typedValue, autocompletedValue)) { 176 this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length); 177 } else { 178 this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length); 179 } 180 } 181 182 _softAutocomplete(option) { 183 const typedValue = this._typedQuery; 184 const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue); 185 186 if (this._isAutocompletableWith(typedValue, autocompletedValue)) { 187 this._fullQuery = autocompletedValue; 188 this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length); 189 } 190 } 191 192 _isAutocompletableWith(typedValue, autocompletedValue) { 193 return this._autocompletesInline && startsWith(autocompletedValue, typedValue) 194 } 195 196 // +visuallyHideListbox+ hides the listbox from the user, 197 // but makes it still searchable by JS. 198 _visuallyHideListbox() { 199 this.listboxTarget.style.display = "none"; 200 } 201 202 get _isExactAutocompleteMatch() { 203 return this._immediatelyAutocompletableValue === this._fullQuery 204 } 205 206 // All `_isExactAutocompleteMatch` matches are `_isPartialAutocompleteMatch` matches 207 // but not all `_isPartialAutocompleteMatch` matches are `_isExactAutocompleteMatch` matches. 208 get _isPartialAutocompleteMatch() { 209 return !!this._immediatelyAutocompletableValue && 210 startsWith(this._immediatelyAutocompletableValue, this._fullQuery) 211 } 212 213 get _autocompletesList() { 214 return this.autocompleteValue === "both" || this.autocompleteValue === "list" 215 } 216 217 get _autocompletesInline() { 218 return this.autocompleteValue === "both" || this.autocompleteValue === "inline" 219 } 220 221 get _immediatelyAutocompletableValue() { 222 return this._ensurableOption?.getAttribute(this.autocompletableAttributeValue) 223 } 224}; 225 226const MAX_CALLBACK_ATTEMPTS = 3; 227 228Combobox.Callbacks = Base => class extends Base { 229 _initializeCallbacks() { 230 this.callbackQueue = []; 231 this.callbackExecutionAttempts = {}; 232 } 233 234 _enqueueCallback() { 235 const callbackId = randomUUID(); 236 this.callbackQueue.push(callbackId); 237 return callbackId 238 } 239 240 _isNextCallback(callbackId) { 241 return this._nextCallback === callbackId 242 } 243 244 _callbackAttemptsExceeded(callbackId) { 245 return this._callbackAttempts(callbackId) > MAX_CALLBACK_ATTEMPTS 246 } 247 248 _callbackAttempts(callbackId) { 249 return this.callbackExecutionAttempts[callbackId] || 0 250 } 251 252 _recordCallbackAttempt(callbackId) { 253 this.callbackExecutionAttempts[callbackId] = this._callbackAttempts(callbackId) + 1; 254 } 255 256 _dequeueCallback(callbackId) { 257 this.callbackQueue = this.callbackQueue.filter(id => id !== callbackId); 258 this._forgetCallbackExecutionAttempts(callbackId); 259 } 260 261 _forgetCallbackExecutionAttempts(callbackId) { 262 delete this.callbackExecutionAttempts[callbackId]; 263 } 264 265 get _nextCallback() { 266 return this.callbackQueue[0] 267 } 268}; 269 270Combobox.Dialog = Base => class extends Base { 271 rerouteListboxStreamToDialog({ detail: { newStream } }) { 272 if (newStream.target == this.listboxTarget.id && this._dialogIsOpen) { 273 newStream.setAttribute("target", this.dialogListboxTarget.id); 274 } 275 } 276 277 _connectDialog() {
278 if (window.visualViewport) { 279 window.visualViewport.addEventListener("resize", this._resizeDialog); 280 } 281 } 282 283 _disconnectDialog() { 284 if (window.visualViewport) { 285 window.visualViewport.removeEventListener("resize", this._resizeDialog); 286 } 287 } 288 289 _moveArtifactsToDialog() { 290 this.dialogComboboxTarget.value = this._fullQuery; 291 292 this._actingCombobox = this.dialogComboboxTarget; 293 this._actingListbox = this.dialogListboxTarget; 294 295 this.dialogListboxTarget.append(...this.listboxTarget.children); 296 } 297 298 _moveArtifactsInline() { 299 this.comboboxTarget.value = this._fullQuery; 300 301 this._actingCombobox = this.comboboxTarget; 302 this._actingListbox = this.listboxTarget; 303 304 this.listboxTarget.append(...this.dialogListboxTarget.children); 305 } 306 307 _resizeDialog = () => { 308 if (window.visualViewport) { 309 this.dialogTarget.style.setProperty("--hw-visual-viewport-height", `${window.visualViewport.height}px`); 310 } 311 } 312 313 // After closing a dialog, focus returns to the last focused element. 314 // +preventFocusingComboboxAfterClosingDialog+ focuses a placeholder element before opening 315 // the dialog, so that the combobox is not focused again after closing, which would reopen. 316 _preventFocusingComboboxAfterClosingDialog() { 317 this.dialogFocusTrapTarget.focus(); 318 } 319 320 get _isSmallViewport() { 321 return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches 322 } 323 324 get _dialogIsOpen() { 325 return this.dialogTarget.open 326 } 327}; 328 329Combobox.Events = Base => class extends Base { 330 _dispatchPreselectionEvent({ isNewAndAllowed, previousValue }) { 331 if (previousValue === this._incomingFieldValueString) return 332 333 dispatch("hw-combobox:preselection", { 334 target: this.element, 335 detail: { ...this._eventableDetails, isNewAndAllowed, previousValue } 336 }); 337 } 338 339 _dispatchSelectionEvent() { 340 dispatch("hw-combobox:selection", { 341 target: this.element, 342 detail: this._eventableDetails 343 }); 344 } 345 346 _dispatchRemovalEvent({ removedDisplay, removedValue }) { 347 dispatch("hw-combobox:removal", { 348 target: this.element, 349 detail: { ...this._eventableDetails, removedDisplay, removedValue } 350 }); 351 } 352 353 get _eventableDetails() { 354 return { 355 value: this._incomingFieldValueString, 356 display: this._fullQuery, 357 query: this._typedQuery, 358 fieldName: this._fieldName, 359 isValid: this._valueIsValid 360 } 361 } 362}; 363 364class FetchResponse { 365 constructor(response) { 366 this.response = response; 367 } 368 get statusCode() { 369 return this.response.status; 370 } 371 get redirected() { 372 return this.response.redirected; 373 } 374 get ok() { 375 return this.response.ok; 376 } 377 get unauthenticated() { 378 return this.statusCode === 401; 379 } 380 get unprocessableEntity() { 381 return this.statusCode === 422; 382 } 383 get authenticationURL() { 384 return this.response.headers.get("WWW-Authenticate"); 385 } 386 get contentType() { 387 const contentType = this.response.headers.get("Content-Type") || ""; 388 return contentType.replace(/;.*$/, ""); 389 } 390 get headers() { 391 return this.response.headers; 392 } 393 get html() { 394 if (this.contentType.match(/^(application|text)\/(html|xhtml\+xml)$/)) { 395 return this.text; 396 } 397 return Promise.reject(new Error(`Expected an HTML response but got "${this.contentType}" instead`)); 398 } 399 get json() { 400 if (this.contentType.match(/^application\/.*json$/)) { 401 return this.responseJson || (this.responseJson = this.response.json()); 402 } 403 return Promise.reject(new Error(`Expected a JSON response but got "${this.contentType}" instead`)); 404 } 405 get text() { 406 return this.responseText || (this.responseText = this.response.text()); 407 } 408 get isTurboStream() { 409 return this.contentType.match(/^text\/vnd\.turbo-stream\.html/); 410 } 411 async renderTurboStream() { 412 if (this.isTurboStream) { 413 if (window.Turbo) { 414 await window.Turbo.renderStreamMessage(await this.text); 415 } else { 416 console.warn("You must set `window.Turbo = Turbo` to automatically process Turbo Stream events with request.js"); 417 } 418 } else { 419 return Promise.reject(new Error(`Expected a Turbo Stream response but got "${this.contentType}" instead`)); 420 } 421 } 422} 423 424class RequestInterceptor { 425 static register(interceptor) { 426 this.interceptor = interceptor; 427 } 428 static get() { 429 return this.interceptor; 430 } 431 static reset() { 432 this.interceptor = undefined; 433 } 434} 435 436function getCookie(name) { 437 const cookies = document.cookie ? document.cookie.split("; ") : []; 438 const prefix = `${encodeURIComponent(name)}=`; 439 const cookie = cookies.find((cookie => cookie.startsWith(prefix))); 440 if (cookie) { 441 const value = cookie.split("=").slice(1).join("="); 442 if (value) { 443 return decodeURIComponent(value); 444 } 445 } 446} 447 448function compact(object) { 449 const result = {}; 450 for (const key in object) { 451 const value = object[key]; 452 if (value !== undefined) { 453 result[key] = value; 454 } 455 } 456 return result; 457} 458 459function metaContent(name) { 460 const element = document.head.querySelector(`meta[name="${name}"]`); 461 return element && element.content; 462} 463 464function stringEntriesFromFormData(formData) { 465 return [ ...formData ].reduce(((entries, [name, value]) => entries.concat(typeof value === "string" ? [ [ name, value ] ] : [])), []); 466} 467 468function mergeEntries(searchParams, entries) {
vendor: 5,334 bytes, lines 469-640
469 for (const [name, value] of entries) { 470 if (value instanceof window.File) continue; 471 if (searchParams.has(name) && !name.includes("[]")) { 472 searchParams.delete(name); 473 searchParams.set(name, value); 474 } else { 475 searchParams.append(name, value); 476 } 477 } 478} 479 480class FetchRequest { 481 constructor(method, url, options = {}) { 482 this.method = method; 483 this.options = options; 484 this.originalUrl = url.toString(); 485 } 486 async perform() { 487 try { 488 const requestInterceptor = RequestInterceptor.get(); 489 if (requestInterceptor) { 490 await requestInterceptor(this); 491 } 492 } catch (error) { 493 console.error(error); 494 } 495 const response = new FetchResponse(await window.fetch(this.url, this.fetchOptions)); 496 if (response.unauthenticated && response.authenticationURL) { 497 return Promise.reject(window.location.href = response.authenticationURL); 498 } 499 const responseStatusIsTurboStreamable = response.ok || response.unprocessableEntity; 500 if (responseStatusIsTurboStreamable && response.isTurboStream) { 501 await response.renderTurboStream(); 502 } 503 return response; 504 } 505 addHeader(key, value) { 506 const headers = this.additionalHeaders; 507 headers[key] = value; 508 this.options.headers = headers; 509 } 510 sameHostname() { 511 if (!this.originalUrl.startsWith("http:")) { 512 return true; 513 } 514 try { 515 return new URL(this.originalUrl).hostname === window.location.hostname; 516 } catch (_) { 517 return true; 518 } 519 } 520 get fetchOptions() { 521 return { 522 method: this.method.toUpperCase(), 523 headers: this.headers, 524 body: this.formattedBody, 525 signal: this.signal, 526 credentials: this.credentials, 527 redirect: this.redirect 528 }; 529 } 530 get headers() { 531 const baseHeaders = { 532 "X-Requested-With": "XMLHttpRequest", 533 "Content-Type": this.contentType, 534 Accept: this.accept 535 }; 536 if (this.sameHostname()) { 537 baseHeaders["X-CSRF-Token"] = this.csrfToken; 538 } 539 return compact(Object.assign(baseHeaders, this.additionalHeaders)); 540 } 541 get csrfToken() { 542 return getCookie(metaContent("csrf-param")) || metaContent("csrf-token"); 543 } 544 get contentType() { 545 if (this.options.contentType) { 546 return this.options.contentType; 547 } else if (this.body == null || this.body instanceof window.FormData) { 548 return undefined; 549 } else if (this.body instanceof window.File) { 550 return this.body.type; 551 } 552 return "application/json"; 553 } 554 get accept() { 555 switch (this.responseKind) { 556 case "html": 557 return "text/html, application/xhtml+xml"; 558 559 case "turbo-stream": 560 return "text/vnd.turbo-stream.html, text/html, application/xhtml+xml"; 561 562 case "json": 563 return "application/json, application/vnd.api+json"; 564 565 default: 566 return "*/*"; 567 } 568 } 569 get body() { 570 return this.options.body; 571 } 572 get query() { 573 const originalQuery = (this.originalUrl.split("?")[1] || "").split("#")[0]; 574 const params = new URLSearchParams(originalQuery); 575 let requestQuery = this.options.query; 576 if (requestQuery instanceof window.FormData) { 577 requestQuery = stringEntriesFromFormData(requestQuery); 578 } else if (requestQuery instanceof window.URLSearchParams) { 579 requestQuery = requestQuery.entries(); 580 } else { 581 requestQuery = Object.entries(requestQuery || {}); 582 } 583 mergeEntries(params, requestQuery); 584 const query = params.toString(); 585 return query.length > 0 ? `?${query}` : ""; 586 } 587 get url() { 588 return this.originalUrl.split("?")[0].split("#")[0] + this.query; 589 } 590 get responseKind() { 591 return this.options.responseKind || "html"; 592 } 593 get signal() { 594 return this.options.signal; 595 } 596 get redirect() { 597 return this.options.redirect || "follow"; 598 } 599 get credentials() { 600 return this.options.credentials || "same-origin"; 601 } 602 get additionalHeaders() { 603 return this.options.headers || {}; 604 } 605 get formattedBody() { 606 const bodyIsAString = Object.prototype.toString.call(this.body) === "[object String]"; 607 const contentTypeIsJson = this.headers["Content-Type"] === "application/json"; 608 if (contentTypeIsJson && !bodyIsAString) { 609 return JSON.stringify(this.body); 610 } 611 return this.body; 612 } 613} 614 615async function get(url, options) { 616 const request = new FetchRequest("get", url, options); 617 return request.perform(); 618} 619 620async function post(url, options) { 621 const request = new FetchRequest("post", url, options); 622 return request.perform(); 623} 624 625// Copyright (c) 2021 Marcelo Lauxen 626 627// Permission is hereby granted, free of charge, to any person obtaining 628// a copy of this software and associated documentation files (the 629// "Software"), to deal in the Software without restriction, including 630// without limitation the rights to use, copy, modify, merge, publish, 631// distribute, sublicense, and/or sell copies of the Software, and to 632// permit persons to whom the Software is furnished to do so, subject to 633// the following conditions: 634 635// The above copyright notice and this permission notice shall be 636// included in all copies or substantial portions of the Software. 637 638// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 639// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 640// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
641// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 642// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 643// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 644// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 645 646Combobox.Filtering = Base => class extends Base { 647 prepareToFilter({ key }) { 648 const intendsToFilter = key.match(/^[a-zA-Z0-9]$|^ArrowDown$/); 649 650 if (this._isClosed && intendsToFilter) { 651 this.open(); // `.open()` sets the appropriate state so the combobox knows itâs open. 652 this._expand(); // `.open()` will call `._expand()` via stimulus callbacks, but weâre calling it inline so it happens immediately. 653 } 654 } 655 656 filterAndSelect({ inputType }) { 657 this._filter(inputType); 658 659 if (this._isSync) { 660 this._selectOnQuery(inputType); 661 } 662 } 663 664 clear(event) { 665 this._clearQuery(); 666 this.chipDismisserTargets.forEach(el => el.click()); 667 if (event && !event.defaultPrevented) event.target.focus(); 668 } 669 670 _initializeFiltering() { 671 this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this), this.debounceIntervalValue); 672 } 673 674 _filter(inputType) { 675 if (this._isAsync) { 676 this._debouncedFilterAsync(inputType); 677 } else { 678 this._filterSync(); 679 } 680 681 this._markQueried(); 682 } 683 684 _debouncedFilterAsync(inputType) { 685 this._filterAsync(inputType); 686 } 687 688 async _filterAsync(inputType) { 689 const query = { 690 q: this._fullQuery, 691 input_type: inputType, 692 for_id: this.element.dataset.asyncId, 693 callback_id: this._enqueueCallback() 694 }; 695 696 await get(this.asyncSrcValue, { responseKind: "turbo-stream", query }); 697 } 698 699 _filterSync() { 700 this._allFilterableOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue })); 701 } 702 703 _clearQuery() { 704 this._fullQuery = ""; 705 this.filterAndSelect({ inputType: "deleteContentBackward" }); 706 } 707 708 _markQueried() { 709 this._forAllComboboxes(el => el.toggleAttribute("data-queried", this._isQueried)); 710 } 711 712 get _isQueried() { 713 return this._fullQuery.length > 0 714 } 715 716 get _fullQuery() { 717 return this._actingCombobox.value 718 } 719 720 set _fullQuery(value) { 721 this._actingCombobox.value = value; 722 } 723 724 get _typedQuery() { 725 return unselectedPortion(this._actingCombobox) 726 } 727}; 728 729Combobox.FormField = Base => class extends Base { 730 get _fieldValue() { 731 if (this._isMultiselect) { 732 const currentValue = this.hiddenFieldTarget.value; 733 const arrayFromValue = currentValue ? currentValue.split(",") : []; 734 735 return new Set(arrayFromValue) 736 } else { 737 return this.hiddenFieldTarget.value 738 } 739 } 740 741 get _fieldValueString() { 742 if (this._isMultiselect) { 743 return this._fieldValueArray.join(",") 744 } else { 745 return this.hiddenFieldTarget.value 746 } 747 } 748 749 get _incomingFieldValueString() { 750 if (this._isMultiselect) { 751 const array = this._fieldValueArray; 752 753 if (this.hiddenFieldTarget.dataset.valueForMultiselect) { 754 array.push(this.hiddenFieldTarget.dataset.valueForMultiselect); 755 } 756 757 return array.join(",") 758 } else { 759 return this.hiddenFieldTarget.value 760 } 761 } 762 763 get _fieldValueArray() { 764 if (this._isMultiselect) { 765 return Array.from(this._fieldValue) 766 } else { 767 return [ this.hiddenFieldTarget.value ] 768 } 769 } 770 771 set _fieldValue(value) { 772 if (this._isMultiselect) { 773 this.hiddenFieldTarget.dataset.valueForMultiselect = value?.replace(/,/g, ""); 774 this.hiddenFieldTarget.dataset.displayForMultiselect = this._fullQuery; 775 } else { 776 this.hiddenFieldTarget.value = value; 777 } 778 } 779 780 get _hasEmptyFieldValue() { 781 if (this._isMultiselect) { 782 return this.hiddenFieldTarget.dataset.valueForMultiselect == "" || this.hiddenFieldTarget.dataset.valueForMultiselect == "undefined" 783 } else { 784 return this.hiddenFieldTarget.value === "" 785 } 786 } 787 788 get _hasFieldValue() { 789 return !this._hasEmptyFieldValue 790 } 791 792 get _fieldName() { 793 return this.hiddenFieldTarget.name 794 } 795 796 set _fieldName(value) { 797 this.hiddenFieldTarget.name = value; 798 } 799}; 800 801Combobox.Multiselect = Base => class extends Base { 802 navigateChip(event) { 803 this._chipKeyHandlers[event.key]?.call(this, event); 804 } 805 806 removeChip({ currentTarget, params }) { 807 let display; 808 const option = this._optionElementWithValue(params.value); 809 810 if (option) { 811 display = option.getAttribute(this.autocompletableAttributeValue); 812 this._markNotSelected(option); 813 this._markNotMultiselected(option); 814 } else { 815 display = params.value; // for new options 816 } 817 818 this._removeFromFieldValue(params.value); 819 this._filter("hw:multiselectSync"); 820 821 currentTarget.closest("[data-hw-combobox-chip]").remove(); 822 823 if (!this._isSmallViewport) { 824 this.open(); 825 } 826 827 this._announceToScreenReader(display, "removed"); 828 this._dispatchRemovalEvent({ removedDisplay: display, removedValue: params.value }); 829 } 830 831 hideChipsForCache() { 832 this.element.querySelectorAll("[data-hw-combobox-chip]").forEach(chip => chip.hidden = true); 833 } 834 835 _chipKeyHandlers = { 836 Backspace: (event) => { 837 this.removeChip(event); 838 cancel(event); 839 }, 840 Enter: (event) => { 841 this.removeChip(event); 842 cancel(event); 843 }, 844 Space: (event) => { 845 this.removeChip(event); 846 cancel(event); 847 }, 848 Escape: (event) => { 849 this.open(); 850 cancel(event); 851 } 852 } 853 854 _connectMultiselect() { 855 if (!this._isMultiPreselected) { 856 this._preselectMultiple(); 857 this._markMultiPreselected(); 858 } 859 } 860
861 async _createChip(shouldReopen) { 862 if (!this._isMultiselect) return 863 864 this._beforeClearingMultiselectQuery(async (display, value) => { 865 this._fullQuery = ""; 866 867 this._filter("hw:multiselectSync"); 868 this._requestChips(value); 869 this._addToFieldValue(value); 870 871 if (shouldReopen) { 872 await nextRepaint(); 873 this.open(); 874 } 875 876 this._announceToScreenReader(display, "multi-selected. Press Shift + Tab, then Enter to remove."); 877 }); 878 } 879 880 async _requestChips(values) { 881 await post(this.selectionChipSrcValue, { 882 responseKind: "turbo-stream", 883 query: { 884 for_id: this.element.dataset.asyncId, 885 combobox_values: values 886 } 887 }); 888 } 889 890 _beforeClearingMultiselectQuery(callback) { 891 const display = this.hiddenFieldTarget.dataset.displayForMultiselect; 892 const value = this.hiddenFieldTarget.dataset.valueForMultiselect; 893 894 if (value && !this._fieldValue.has(value)) { 895 callback(display, value); 896 } 897 898 this.hiddenFieldTarget.dataset.displayForMultiselect = ""; 899 this.hiddenFieldTarget.dataset.valueForMultiselect = ""; 900 } 901 902 _resetMultiselectionMarks() { 903 if (!this._isMultiselect) return 904 905 this._fieldValueArray.forEach(value => { 906 const option = this._optionElementWithValue(value); 907 908 if (option) { 909 option.setAttribute("data-multiselected", ""); 910 option.hidden = true; 911 } 912 }); 913 } 914 915 _markNotMultiselected(option) { 916 if (!this._isMultiselect) return 917 918 option.removeAttribute("data-multiselected"); 919 option.hidden = false; 920 } 921 922 _addToFieldValue(value) { 923 const newValue = this._fieldValue; 924 925 newValue.add(String(value)); 926 this.hiddenFieldTarget.value = Array.from(newValue).join(","); 927 928 if (this._isSync) this._resetMultiselectionMarks(); 929 } 930 931 _removeFromFieldValue(value) { 932 const newValue = this._fieldValue; 933 934 newValue.delete(String(value)); 935 this.hiddenFieldTarget.value = Array.from(newValue).join(","); 936 937 if (this._isSync) this._resetMultiselectionMarks(); 938 } 939 940 _focusLastChipDismisser() { 941 this.chipDismisserTargets[this.chipDismisserTargets.length - 1]?.focus(); 942 } 943 944 _markMultiPreselected() { 945 this.element.dataset.multiPreselected = ""; 946 } 947 948 get _isMultiselect() { 949 return this.hasSelectionChipSrcValue 950 } 951 952 get _isSingleSelect() { 953 return !this._isMultiselect 954 } 955 956 get _isMultiPreselected() { 957 return this.element.hasAttribute("data-multi-preselected") 958 } 959}; 960 961Combobox.Navigation = Base => class extends Base { 962 navigate(event) { 963 if (this._autocompletesList) { 964 this._navigationKeyHandlers[event.key]?.call(this, event); 965 } 966 } 967 968 _navigationKeyHandlers = { 969 ArrowUp: (event) => { 970 this._selectIndex(this._selectedOptionIndex - 1); 971 cancel(event); 972 }, 973 ArrowDown: (event) => { 974 this._selectIndex(this._selectedOptionIndex + 1); 975 976 if (this._selectedOptionIndex === 0) { 977 this._actingListbox.scrollTop = 0; 978 } 979 980 cancel(event); 981 }, 982 Home: (event) => { 983 this._selectIndex(0); 984 cancel(event); 985 }, 986 End: (event) => { 987 this._selectIndex(this._visibleOptionElements.length - 1); 988 cancel(event); 989 }, 990 Enter: (event) => { 991 this.close("hw:keyHandler:enter"); 992 cancel(event); 993 }, 994 Escape: (event) => { 995 this._isOpen ? this.close("hw:keyHandler:escape") : this._clearQuery(); 996 cancel(event); 997 }, 998 Backspace: (event) => { 999 if (this._isMultiselect && !this._fullQuery) { 1000 this._focusLastChipDismisser(); 1001 cancel(event); 1002 } 1003 } 1004 } 1005}; 1006 1007Combobox.NewOptions = Base => class extends Base { 1008 _shouldTreatAsNewOptionForFiltering(queryIsBeingRefined) { 1009 if (queryIsBeingRefined) { 1010 return this._isNewOptionWithNoPotentialMatches 1011 } else { 1012 return this._isNewOptionWithPotentialMatches 1013 } 1014 } 1015 1016 // If the user is going to keep refining the query, we can't be sure whether 1017 // the option will end up being new or not unless there are no potential matches. 1018 // +_isNewOptionWithNoPotentialMatches+ allows us to make our best guess 1019 // while the state of the combobox is still in flux. 1020 // 1021 // It's okay for the combobox to say it's not new even if it will be eventually, 1022 // as only the final state matters for submission purposes. This method exists 1023 // as a best effort to keep the state accurate as often as we can. 1024 // 1025 // Note that the first visible option is automatically selected as you type. 1026 // So if there's a partial match, it's not a new option at this point. 1027 // 1028 // The final state is locked-in upon closing the combobox via `_isNewOptionWithPotentialMatches`. 1029 get _isNewOptionWithNoPotentialMatches() { 1030 return this._isNewOptionWithPotentialMatches && !this._isPartialAutocompleteMatch 1031 } 1032 1033 // If the query is finalized, we don't care that there are potential matches 1034 // because new options can be substrings of existing options. 1035 // 1036 // We can't use `_isNewOptionWithNoPotentialMatches` because that would 1037 // rule out new options that are partial matches. 1038 get _isNewOptionWithPotentialMatches() { 1039 return this._isQueried && this._allowNew && !this._isExactAutocompleteMatch 1040 } 1041}; 1042 1043Combobox.Options = Base => class extends Base { 1044 _resetOptionsSilently() { 1045 this._resetOptions(this._deselect.bind(this)); 1046 } 1047 1048 _resetOptionsAndNotify() { 1049 this._resetOptions(this._deselectAndNotify.bind(this)); 1050 } 1051 1052 _resetOptions(deselectionStrategy) { 1053 this._fieldName = this.originalNameValue; 1054 deselectionStrategy(); 1055 } 1056 1057 _optionElementWithValue(value) { 1058 return this._actingListbox.querySelector(`[${this.filterableAttributeValue}][data-value='${value}']`) 1059 } 1060 1061 _displayForOptionElement(element) { 1062 return element.getAttribute(this.autocompletableAttributeValue) 1063 } 1064 1065 get _allowNew() { 1066 return !!this.nameWhenNewValue 1067 } 1068 1069 get _allOptions() { 1070 return Array.from(this._allFilterableOptionElements) 1071 } 1072 1073 get _allFilterableOptionElements() { 1074 return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]:not([data-multiselected])`) 1075 } 1076 1077 get _visibleOptionElements() { 1078 return [ ...this._allFilterableOptionElements ].filter(visible) 1079 } 1080 1081 get _selectedOptionElement() { 1082 return this._actingListbox.querySelector("[role=option][aria-selected=true]:not([data-multiselected])") 1083 } 1084 1085 get _multiselectedOptionElements() { 1086 return this._actingListbox.querySelectorAll("[role=option][data-multiselected]") 1087 } 1088 1089 get _selectedOptionIndex() { 1090 return [ ...this._visibleOptionElements ].indexOf(this._selectedOptionElement) 1091 } 1092 1093 get _isUnjustifiablyBlank() { 1094 const valueIsMissing = this._hasEmptyFieldValue; 1095 const noBlankOptionSelected = !this._selectedOptionElement; 1096 1097 return valueIsMissing && noBlankOptionSelected 1098 } 1099}; 1100 1101Combobox.Selection = Base => class extends Base { 1102 selectOnClick({ currentTarget, inputType }) { 1103 this._forceSelectionAndFilter(currentTarget, inputType); 1104 this.close("hw:optionRoleClick"); 1105 } 1106 1107 _connectSelection() { 1108 if (this.hasPrefilledDisplayValue) { 1109 this._fullQuery = this.prefilledDisplayValue; 1110 this._markQueried(); 1111 } 1112 } 1113 1114 _selectOnQuery(inputType) { 1115 if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent({ inputType: inputType }))) { 1116 this._selectNew(); 1117 } else if (isDeleteEvent({ inputType: inputType })) { 1118 this._deselect(); 1119 } else if (inputType === "hw:lockInSelection" && this._ensurableOption) { 1120 this._select(this._ensurableOption, this._softAutocomplete.bind(this)); 1121 } else if (this._isOpen && this._visibleOptionElements[0]) { 1122 this._select(this._visibleOptionElements[0], this._softAutocomplete.bind(this)); 1123 } else if (this._isOpen) { 1124 this._resetOptionsAndNotify(); 1125 this._markInvalid(); 1126 } else ; 1127 } 1128 1129 _select(option, autocompleteStrategy) { 1130 const previousValue = this._fieldValueString; 1131 1132 this._resetOptionsSilently(); 1133 1134 autocompleteStrategy(option); 1135 1136 this._fieldValue = option.dataset.value; 1137 this._markSelected(option); 1138 this._markValid();
1139 this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue }); 1140 1141 option.scrollIntoView({ block: "nearest" }); 1142 } 1143 1144 _selectNew() { 1145 const previousValue = this._fieldValueString; 1146 1147 this._resetOptionsSilently(); 1148 this._fieldValue = this._fullQuery; 1149 this._fieldName = this.nameWhenNewValue; 1150 this._markValid(); 1151 this._dispatchPreselectionEvent({ isNewAndAllowed: true, previousValue: previousValue }); 1152 } 1153 1154 _deselect() { 1155 const previousValue = this._fieldValueString; 1156 1157 if (this._selectedOptionElement) { 1158 this._markNotSelected(this._selectedOptionElement); 1159 } 1160 1161 this._fieldValue = ""; 1162 this._setActiveDescendant(""); 1163 1164 return previousValue 1165 } 1166 1167 _deselectAndNotify() { 1168 const previousValue = this._deselect(); 1169 this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue }); 1170 } 1171 1172 _selectIndex(index) { 1173 const option = wrapAroundAccess(this._visibleOptionElements, index); 1174 this._forceSelectionWithoutFiltering(option); 1175 } 1176 1177 _preselectSingle() { 1178 if (this._isSingleSelect && this._hasValueButNoSelection) { 1179 const option = this._optionElementWithValue(this._fieldValue); 1180 if (option) this._markSelected(option); 1181 } 1182 } 1183 1184 _preselectMultiple() { 1185 if (this._isMultiselect && this._hasValueButNoSelection) { 1186 this._requestChips(this._fieldValueString); 1187 this._resetMultiselectionMarks(); 1188 } 1189 } 1190 1191 _forceSelectionAndFilter(option, inputType) { 1192 this._forceSelectionWithoutFiltering(option); 1193 this._filter(inputType); 1194 } 1195 1196 _forceSelectionWithoutFiltering(option) { 1197 this._select(option, this._hardAutocomplete.bind(this)); 1198 } 1199 1200 _lockInSelection() { 1201 if (this._shouldLockInSelection) { 1202 this._forceSelectionAndFilter(this._ensurableOption, "hw:lockInSelection"); 1203 } 1204 } 1205 1206 _markSelected(option) { 1207 if (this.hasSelectedClass) option.classList.add(this.selectedClass); 1208 option.setAttribute("aria-selected", true); 1209 this._setActiveDescendant(option.id); 1210 } 1211 1212 _markNotSelected(option) { 1213 if (this.hasSelectedClass) option.classList.remove(this.selectedClass); 1214 option.removeAttribute("aria-selected"); 1215 this._removeActiveDescendant(); 1216 } 1217 1218 _setActiveDescendant(id) { 1219 this._forAllComboboxes(el => el.setAttribute("aria-activedescendant", id)); 1220 } 1221 1222 _removeActiveDescendant() { 1223 this._setActiveDescendant(""); 1224 } 1225 1226 get _hasValueButNoSelection() { 1227 return this._hasFieldValue && !this._hasSelection 1228 } 1229 1230 get _hasSelection() { 1231 if (this._isSingleSelect) { 1232 this._selectedOptionElement; 1233 } else { 1234 this._multiselectedOptionElements.length > 0; 1235 } 1236 } 1237 1238 get _shouldLockInSelection() { 1239 return this._isQueried && !!this._ensurableOption && !this._isNewOptionWithPotentialMatches 1240 } 1241 1242 get _ensurableOption() { 1243 return this._selectedOptionElement || this._visibleOptionElements[0] 1244 } 1245}; 1246 1247function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } 1248 1249// Older browsers don't support event options, feature detect it. 1250 1251// Adopted and modified solution from Bohdan Didukh (2017) 1252// https://stackoverflow.com/questions/41594997/ios-10-safari-prevent-scrolling-behind-a-fixed-overlay-and-maintain-scroll-posi 1253 1254var hasPassiveEvents = false; 1255if (typeof window !== 'undefined') { 1256 var passiveTestOptions = { 1257 get passive() { 1258 hasPassiveEvents = true; 1259 return undefined; 1260 } 1261 }; 1262 window.addEventListener('testPassive', null, passiveTestOptions); 1263 window.removeEventListener('testPassive', null, passiveTestOptions); 1264} 1265 1266var isIosDevice = typeof window !== 'undefined' && window.navigator && window.navigator.platform && (/iP(ad|hone|od)/.test(window.navigator.platform) || window.navigator.platform === 'MacIntel' && window.navigator.maxTouchPoints > 1); 1267 1268 1269var locks = []; 1270var documentListenerAdded = false; 1271var initialClientY = -1; 1272var previousBodyOverflowSetting = void 0; 1273var previousBodyPosition = void 0; 1274var previousBodyPaddingRight = void 0; 1275 1276// returns true if `el` should be allowed to receive touchmove events. 1277var allowTouchMove = function allowTouchMove(el) { 1278 return locks.some(function (lock) { 1279 if (lock.options.allowTouchMove && lock.options.allowTouchMove(el)) { 1280 return true; 1281 } 1282 1283 return false;
1284 }); 1285}; 1286 1287var preventDefault = function preventDefault(rawEvent) { 1288 var e = rawEvent || window.event; 1289 1290 // For the case whereby consumers adds a touchmove event listener to document. 1291 // Recall that we do document.addEventListener('touchmove', preventDefault, { passive: false }) 1292 // in disableBodyScroll - so if we provide this opportunity to allowTouchMove, then 1293 // the touchmove event on document will break. 1294 if (allowTouchMove(e.target)) { 1295 return true; 1296 } 1297 1298 // Do not prevent if the event has more than one touch (usually meaning this is a multi touch gesture like pinch to zoom). 1299 if (e.touches.length > 1) return true; 1300 1301 if (e.preventDefault) e.preventDefault(); 1302 1303 return false; 1304}; 1305 1306var setOverflowHidden = function setOverflowHidden(options) { 1307 // If previousBodyPaddingRight is already set, don't set it again. 1308 if (previousBodyPaddingRight === undefined) { 1309 var _reserveScrollBarGap = !!options && options.reserveScrollBarGap === true; 1310 var scrollBarGap = window.innerWidth - document.documentElement.clientWidth; 1311 1312 if (_reserveScrollBarGap && scrollBarGap > 0) { 1313 var computedBodyPaddingRight = parseInt(window.getComputedStyle(document.body).getPropertyValue('padding-right'), 10); 1314 previousBodyPaddingRight = document.body.style.paddingRight; 1315 document.body.style.paddingRight = computedBodyPaddingRight + scrollBarGap + 'px'; 1316 } 1317 } 1318 1319 // If previousBodyOverflowSetting is already set, don't set it again. 1320 if (previousBodyOverflowSetting === undefined) { 1321 previousBodyOverflowSetting = document.body.style.overflow; 1322 document.body.style.overflow = 'hidden'; 1323 } 1324}; 1325 1326var restoreOverflowSetting = function restoreOverflowSetting() { 1327 if (previousBodyPaddingRight !== undefined) { 1328 document.body.style.paddingRight = previousBodyPaddingRight; 1329 1330 // Restore previousBodyPaddingRight to undefined so setOverflowHidden knows it 1331 // can be set again. 1332 previousBodyPaddingRight = undefined; 1333 } 1334 1335 if (previousBodyOverflowSetting !== undefined) { 1336 document.body.style.overflow = previousBodyOverflowSetting; 1337 1338 // Restore previousBodyOverflowSetting to undefined 1339 // so setOverflowHidden knows it can be set again. 1340 previousBodyOverflowSetting = undefined; 1341 } 1342}; 1343 1344var setPositionFixed = function setPositionFixed() { 1345 return window.requestAnimationFrame(function () { 1346 // If previousBodyPosition is already set, don't set it again. 1347 if (previousBodyPosition === undefined) { 1348 previousBodyPosition = { 1349 position: document.body.style.position, 1350 top: document.body.style.top, 1351 left: document.body.style.left 1352 }; 1353 1354 // Update the dom inside an animation frame 1355 var _window = window, 1356 scrollY = _window.scrollY, 1357 scrollX = _window.scrollX, 1358 innerHeight = _window.innerHeight; 1359 1360 document.body.style.position = 'fixed'; 1361 document.body.style.top = -scrollY + 'px'; 1362 document.body.style.left = -scrollX + 'px'; 1363 1364 setTimeout(function () { 1365 return window.requestAnimationFrame(function () { 1366 // Attempt to check if the bottom bar appeared due to the position change 1367 var bottomBarHeight = innerHeight - window.innerHeight; 1368 if (bottomBarHeight && scrollY >= innerHeight) { 1369 // Move the content further up so that the bottom bar doesn't hide it 1370 document.body.style.top = -(scrollY + bottomBarHeight); 1371 } 1372 }); 1373 }, 300); 1374 } 1375 }); 1376}; 1377 1378var restorePositionSetting = function restorePositionSetting() { 1379 if (previousBodyPosition !== undefined) { 1380 // Convert the position from "px" to Int 1381 var y = -parseInt(document.body.style.top, 10); 1382 var x = -parseInt(document.body.style.left, 10); 1383 1384 // Restore styles 1385 document.body.style.position = previousBodyPosition.position; 1386 document.body.style.top = previousBodyPosition.top; 1387 document.body.style.left = previousBodyPosition.left; 1388 1389 // Restore scroll 1390 window.scrollTo(x, y); 1391 1392 previousBodyPosition = undefined; 1393 } 1394}; 1395 1396// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#Problems_and_solutions 1397var isTargetElementTotallyScrolled = function isTargetElementTotallyScrolled(targetElement) { 1398 return targetElement ? targetElement.scrollHeight - targetElement.scrollTop <= targetElement.clientHeight : false;
1399}; 1400 1401var handleScroll = function handleScroll(event, targetElement) { 1402 var clientY = event.targetTouches[0].clientY - initialClientY; 1403 1404 if (allowTouchMove(event.target)) { 1405 return false; 1406 } 1407 1408 if (targetElement && targetElement.scrollTop === 0 && clientY > 0) { 1409 // element is at the top of its scroll. 1410 return preventDefault(event); 1411 } 1412 1413 if (isTargetElementTotallyScrolled(targetElement) && clientY < 0) { 1414 // element is at the bottom of its scroll. 1415 return preventDefault(event); 1416 } 1417 1418 event.stopPropagation(); 1419 return true; 1420}; 1421 1422var disableBodyScroll = function disableBodyScroll(targetElement, options) { 1423 // targetElement must be provided 1424 if (!targetElement) { 1425 // eslint-disable-next-line no-console 1426 console.error('disableBodyScroll unsuccessful - targetElement must be provided when calling disableBodyScroll on IOS devices.'); 1427 return; 1428 } 1429 1430 // disableBodyScroll must not have been called on this targetElement before 1431 if (locks.some(function (lock) { 1432 return lock.targetElement === targetElement; 1433 })) { 1434 return; 1435 } 1436 1437 var lock = { 1438 targetElement: targetElement, 1439 options: options || {} 1440 }; 1441 1442 locks = [].concat(_toConsumableArray(locks), [lock]); 1443 1444 if (isIosDevice) { 1445 setPositionFixed(); 1446 } else { 1447 setOverflowHidden(options); 1448 } 1449 1450 if (isIosDevice) { 1451 targetElement.ontouchstart = function (event) { 1452 if (event.targetTouches.length === 1) { 1453 // detect single touch. 1454 initialClientY = event.targetTouches[0].clientY; 1455 } 1456 }; 1457 targetElement.ontouchmove = function (event) { 1458 if (event.targetTouches.length === 1) { 1459 // detect single touch. 1460 handleScroll(event, targetElement); 1461 } 1462 }; 1463 1464 if (!documentListenerAdded) { 1465 document.addEventListener('touchmove', preventDefault, hasPassiveEvents ? { passive: false } : undefined); 1466 documentListenerAdded = true; 1467 } 1468 } 1469}; 1470 1471var enableBodyScroll = function enableBodyScroll(targetElement) { 1472 if (!targetElement) { 1473 // eslint-disable-next-line no-console 1474 console.error('enableBodyScroll unsuccessful - targetElement must be provided when calling enableBodyScroll on IOS devices.'); 1475 return; 1476 } 1477 1478 locks = locks.filter(function (lock) { 1479 return lock.targetElement !== targetElement; 1480 }); 1481 1482 if (isIosDevice) { 1483 targetElement.ontouchstart = null; 1484 targetElement.ontouchmove = null; 1485 1486 if (documentListenerAdded && locks.length === 0) { 1487 document.removeEventListener('touchmove', preventDefault, hasPassiveEvents ? { passive: false } : undefined); 1488 documentListenerAdded = false; 1489 } 1490 } 1491 1492 if (isIosDevice) { 1493 restorePositionSetting(); 1494 } else { 1495 restoreOverflowSetting(); 1496 } 1497}; 1498 1499// MIT License 1500 1501// Copyright (c) 2018 Will Po 1502 1503// Permission is hereby granted, free of charge, to any person obtaining a copy 1504// of this software and associated documentation files (the "Software"), to deal 1505// in the Software without restriction, including without limitation the rights 1506// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 1507// copies of the Software, and to permit persons to whom the Software is 1508// furnished to do so, subject to the following conditions: 1509 1510// The above copyright notice and this permission notice shall be included in all 1511// copies or substantial portions of the Software. 1512 1513// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 1514// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 1515// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 1516// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 1517// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1518// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 1519// SOFTWARE. 1520 1521Combobox.Toggle = Base => class extends Base { 1522 open() { 1523 this.expandedValue = true; 1524 } 1525 1526 close(inputType) { 1527 if (this._isOpen) { 1528 const shouldReopen = this._isMultiselect && 1529 this._isSync && 1530 !this._isSmallViewport && 1531 inputType != "hw:clickOutside" && 1532 inputType != "hw:focusOutside" && 1533 inputType != "hw:asyncCloser"; 1534 1535 this._lockInSelection(); 1536 this._clearInvalidQuery(); 1537 1538 this.expandedValue = false; 1539 1540 if (inputType != "hw:keyHandler:escape") { 1541 this._dispatchSelectionEvent(); 1542 this._createChip(shouldReopen); 1543 } 1544 1545 if (this._isSingleSelect && this._selectedOptionElement) { 1546 this._announceToScreenReader(this._displayForOptionElement(this._selectedOptionElement), "selected"); 1547 } 1548 } 1549 } 1550 1551 toggle() { 1552 if (this.expandedValue) { 1553 this.close("hw:toggle"); 1554 } else { 1555 this.open(); 1556 } 1557 } 1558 1559 closeOnClickOutside(event) { 1560 const target = event.target; 1561 1562 if (this._isClosed) return 1563 if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return 1564 if (this._withinElementBounds(event)) return 1565 1566 this.close("hw:clickOutside"); 1567 } 1568 1569 closeOnFocusOutside({ target }) { 1570 if (this._isClosed) return 1571 if (this.element.contains(target)) return 1572 1573 this.close("hw:focusOutside"); 1574 } 1575 1576 clearOrToggleOnHandleClick() { 1577 if (this.comboboxTarget.disabled) return 1578 1579 if (this._isQueried) { 1580 this._clearQuery(); 1581 this.open(); 1582 } else { 1583 this.toggle(); 1584 } 1585 } 1586 1587 // Some browser extensions like 1Password overlay elements on top of the combobox. 1588 // Hovering over these elements emits a click event for some reason. 1589 // These events don't contain any telling information, so we use `_withinElementBounds` 1590 // as an alternative to check whether the click is legitimate. 1591 _withinElementBounds(event) { 1592 const { left, right, top, bottom } = this.mainWrapperTarget.getBoundingClientRect(); 1593 const { clientX, clientY } = event; 1594 1595 return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom 1596 } 1597 1598 _isDialogDismisser(target) { 1599 return target.closest("dialog") && target.role != "combobox" 1600 } 1601 1602 _expand() { 1603 if (this._isSync) { 1604 this._preselectSingle(); 1605 } 1606 1607 if (this._autocompletesList && this._isSmallViewport) { 1608 this._openInDialog(); 1609 } else { 1610 this._openInline(); 1611 } 1612 1613 this._actingCombobox.setAttribute("aria-expanded", true); // needs to happen after setting acting combobox 1614 } 1615 1616 // +._collapse()+ differs from `.close()` in that it might be called by stimulus on connect because 1617 // it interprets a change in `expandedValue`Â â whereas `.close()` is only called
1617internally by us. 1618 _collapse() { 1619 this._actingCombobox.setAttribute("aria-expanded", false); // needs to happen before resetting acting combobox 1620 1621 if (this._dialogIsOpen) { 1622 this._closeInDialog(); 1623 } else { 1624 this._closeInline(); 1625 } 1626 } 1627 1628 _openInDialog() { 1629 this._moveArtifactsToDialog(); 1630 this._preventFocusingComboboxAfterClosingDialog(); 1631 this._preventBodyScroll(); 1632 this.dialogTarget.showModal(); 1633 this._resizeDialog(); 1634 } 1635 1636 _openInline() { 1637 this.listboxTarget.hidden = false; 1638 } 1639 1640 _closeInDialog() { 1641 this._moveArtifactsInline(); 1642 this.dialogTarget.close(); 1643 this._restoreBodyScroll(); 1644 this._actingCombobox.scrollIntoView({ block: "center" }); 1645 } 1646 1647 _closeInline() { 1648 this.listboxTarget.hidden = true; 1649 } 1650 1651 _preventBodyScroll() { 1652 disableBodyScroll(this.dialogListboxTarget); 1653 } 1654 1655 _restoreBodyScroll() { 1656 enableBodyScroll(this.dialogListboxTarget); 1657 } 1658 1659 _clearInvalidQuery() { 1660 if (this._isUnjustifiablyBlank) { 1661 this._deselect(); 1662 this._clearQuery(); 1663 } 1664 } 1665 1666 get _isOpen() { 1667 return this.expandedValue 1668 } 1669 1670 get _isClosed() { 1671 return !this._isOpen 1672 } 1673}; 1674 1675Combobox.Validity = Base => class extends Base { 1676 _markValid() { 1677 if (this._valueIsInvalid) return 1678 1679 this._forAllComboboxes(combobox => { 1680 if (this.hasInvalidClass) { 1681 combobox.classList.remove(this.invalidClass); 1682 } 1683 1684 combobox.removeAttribute("aria-invalid"); 1685 combobox.removeAttribute("aria-errormessage"); 1686 }); 1687 } 1688 1689 _markInvalid() { 1690 if (this._valueIsValid) return 1691 1692 this._forAllComboboxes(combobox => { 1693 if (this.hasInvalidClass) { 1694 combobox.classList.add(this.invalidClass); 1695 } 1696 1697 combobox.setAttribute("aria-invalid", true); 1698 combobox.setAttribute("aria-errormessage", `Please select a valid option for ${combobox.name}`); 1699 }); 1700 } 1701 1702 get _valueIsValid() { 1703 return !this._valueIsInvalid 1704 } 1705 1706 // +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required 1707 // because the `required` attribute is only forwarded to the `comboboxTarget` element 1708 get _valueIsInvalid() {
1709 const isRequiredAndEmpty = this.comboboxTarget.required && this._hasEmptyFieldValue; 1710 return isRequiredAndEmpty 1711 } 1712}; 1713 1714window.HOTWIRE_COMBOBOX_STREAM_DELAY = 0; // ms, for testing purposes 1715 1716const concerns = [ 1717 Controller, 1718 Combobox.Actors, 1719 Combobox.Announcements, 1720 Combobox.AsyncLoading, 1721 Combobox.Autocomplete, 1722 Combobox.Callbacks, 1723 Combobox.Dialog, 1724 Combobox.Events, 1725 Combobox.Filtering, 1726 Combobox.FormField, 1727 Combobox.Multiselect, 1728 Combobox.Navigation, 1729 Combobox.NewOptions, 1730 Combobox.Options, 1731 Combobox.Selection, 1732 Combobox.Toggle, 1733 Combobox.Validity 1734]; 1735 1736class HwComboboxController extends Concerns(...concerns) { 1737 static classes = [ "invalid", "selected" ] 1738 static targets = [ 1739 "announcer", 1740 "combobox", 1741 "chipDismisser", 1742 "closer", 1743 "dialog", "dialogCombobox", "dialogFocusTrap", "dialogListbox", 1744 "endOfOptionsStream", 1745 "handle", 1746 "hiddenField", 1747 "listbox", 1748 "mainWrapper" 1749 ] 1750 1751 static values = { 1752 asyncSrc: String, 1753 autocompletableAttribute: String, 1754 autocomplete: String, 1755 debounceInterval: Number, 1756 expanded: Boolean, 1757 filterableAttribute: String, 1758 nameWhenNew: String, 1759 originalName: String, 1760 prefilledDisplay: String, 1761 selectionChipSrc: String, 1762 smallViewportMaxWidth: String 1763 } 1764 1765 initialize() { 1766 this._initializeActors(); 1767 this._initializeFiltering(); 1768 this._initializeCallbacks(); 1769 } 1770 1771 connect() { 1772 this.idempotentConnect(); 1773 } 1774 1775 idempotentConnect() { 1776 this._connectSelection(); 1777 this._connectMultiselect(); 1778 this._connectListAutocomplete(); 1779 this._connectDialog(); 1780 } 1781 1782 disconnect() { 1783 this._disconnectDialog(); 1784 } 1785 1786 expandedValueChanged() { 1787 if (this.expandedValue) { 1788 this._expand(); 1789 } else { 1790 this._collapse(); 1791 } 1792 } 1793 1794 async endOfOptionsStreamTargetConnected(element) { 1795 if (element.dataset.callbackId) { 1796 this._runCallback(element); 1797 } else { 1798 this._preselectSingle(); 1799 } 1800 } 1801 1802 async _runCallback(element) { 1803 const callbackId = element.dataset.callbackId; 1804 1805 if (this._callbackAttemptsExceeded(callbackId)) { 1806 return this._dequeueCallback(callbackId) 1807 } else { 1808 this._recordCallbackAttempt(callbackId); 1809 } 1810 1811 if (this._isNextCallback(callbackId)) { 1812 const inputType = element.dataset.inputType; 1813 const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY; 1814 1815 if (delay) await sleep(delay); 1816 this._dequeueCallback(callbackId); 1817 this._resetMultiselectionMarks(); 1818 1819 if (inputType === "hw:multiselectSync") { 1820 this.open(); 1821 } else if (inputType !== "hw:lockInSelection") { 1822 this._selectOnQuery(inputType); 1823 } 1824 } else { 1825 await nextRepaint(); 1826 this._runCallback(element); 1827 } 1828 } 1829 1830 closerTargetConnected() { 1831 this.close("hw:asyncCloser"); 1832 } 1833 1834 // Use +_printStack+ for debugging purposes 1835 _printStack() { 1836 const err = new Error(); 1837 console.log(err.stack || err.stacktrace); 1838 } 1839} 1840 1841export { HwComboboxController as default };
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.