1import { Controller } from '@hotwired/stimulus' 2 3/* global MutationObserver, URL, clearTimeout, setTimeout */ 4 5// Connects to data-controller='search--everything-combobox' 6// 7// Wraps the hotwire_combobox that powers the search query items field. The 8// combobox keeps a single comma-joined hidden field; this controller mirrors 9// that value into query_items[] fields (what the server expects), keeps 10// window.searchBarCategories in sync so autocomplete results stay relevant, 11// submits the form on enter, and keeps the dropdown from overlaying the form. 12export default class extends Controller { 13 static targets = ['combobox', 'nonjsfields', 'queryItems'] 14 15 connect () { 16 // Remove the plain query field shown to users without JS 17 this.nonjsfieldsTargets.forEach(el => el.remove()) 18 19 this.fieldElement = this.element.querySelector('.hw-combobox') 20 this.hiddenField = this.element.querySelector('input[data-hw-combobox-target="hiddenField"]') 21 22 // Holds the query_items[] fields that actually get submitted. Reuse the 23 // existing container if there is one - a Turbo-cached snapshot already 24 // carries it, so creating a new one would orphan its stale inputs. 25 if (this.hasQueryItemsTarget) { 26 this.queryItems = this.queryItemsTarget 27 } else { 28 this.queryItems = document.createElement('div') 29 this.queryItems.hidden = true 30 this.queryItems.setAttribute('data-search--everything-combobox-target', 'queryItems') 31 this.element.appendChild(this.queryItems) 32 } 33 34 // Reveal the combobox now that JS is handling it 35 if (this.hasComboboxTarget) this.comboboxTarget.classList.remove('tw:hidden') 36 37 this.syncQueryItems() 38 39 this.element.addEventListener('hw-combobox:selection', this.afterChange) 40 this.element.addEventListener('hw-combobox:removal', this.afterChange) 41 42 this.inputElement = this.element.querySelector('.hw-combobox__input') 43 // Capture phase so we can act before the combobox handles enter itself 44 this.inputElement?.addEventListener('keydown', this.onEnterKey, true) 45 46 // Track whether a user gesture is in progress, so we can tell a deliberate 47 // open from the async dropdown reopen the combobox does after a selection 48 this.element.addEventListener('click', this.markUserEvent, true) 49 this.element.addEventListener('keydown', this.markUserEvent, true) 50 51 // One observer for two jobs: 52 // - keep chips visible (the combobox hides them before Turbo caches the 53 // page, but the search form never reconnects to restore them) 54 // - close the dropdown when it reopens itself after a selection, so it 55 // doesn't overlay the rest of the form 56 this.showChips() 57 this.observer = new MutationObserver(this.handleMutations) 58 this.observer.observe(this.element, { 59 attributeFilter: ['hidden', 'data-hw-combobox-expanded-value'], 60 subtree: true 61 }) 62 } 63 64 disconnect () { 65 this.element.removeEventListener('hw-combobox:selection', this.afterChange) 66 this.element.removeEventListener('hw-combobox:removal', this.afterChange) 67 this.inputElement?.removeEventListener('keydown', this.onEnterKey, true) 68 this.element.removeEventListener('click', this.markUserEvent, true) 69 this.element.removeEventListener('keydown', this.markUserEvent, true) 70 this.observer?.disconnect() 71 clearTimeout(this.userEventTimer) 72 } 73 74 handleMutations = (records) => { 75 this.showChips() 76 records.forEach(record => { 77 if (record.attributeName === 'data-hw-combobox-expanded-value') { 78 this.suppressAutoReopen(record.target) 79 } 80 }) 81 } 82 83 showChips = () => { 84 this.element.querySelectorAll('[data-hw-combobox-chip][hidden]').forEach(chip => { chip.hidden = false }) 85 } 86 87 markUserEvent = () => { 88 this.inUserEvent = true 89 clearTimeout(this.userEventTimer) 90 this.userEventTimer = setTimeout(() => { this.inUserEvent = false }, 0) 91 } 92 93 // The async multiselect reopens the dropdown after a selection; if it opened 94 // without a user gesture, close it so it doesn't cover the rest of the form. 95 suppressAutoReopen (combobox) { 96 if (!this.inUserEvent && combobox.getAttribute('data-hw-combobox-expanded-value') === 'true') { 97 combobox.setAttribute('data-hw-combobox-expanded-value', 'false') 98 } 99 } 100 101 // The selection event fires before the combobox writes the new value to its 102 // hidden field, so defer a tick to read the up-to-date value. 103 afterChange = () => { 104 Promise.resolve().then(() => { 105 this.syncQueryItems() 106 window.kindControllerUpdateAfterComboboxChange?.() 107 // Keep focus in the field after a mouse selection so enter still submits. The defer 108 // is load-bearing here too: focusing during the event instead leaves the gem to move 109 // focus after us, and the next field the reader fills loses what they type 110 if (!this.focusMovedOn) this.inputElement?.focus() 111 }) 112 } 113 114 // A task late, the reader may have clicked into another field -- taking focus back there 115 // eats what they type. Nothing focused (body) is the clicked option having been removed, 116 // which is still ours to take 117 get focusMovedOn () { 118 const active = document.activeElement 119 120 return Boolean(active) && active !== document.body && !this.element.contains(active) 121 } 122 123 syncQueryItems () { 124 const values = (this.hiddenField?.value || '').split(',').filter(value => value.length) 125 126 this.queryItems.replaceChildren(...values.map(value => { 127 const input = document.createElement('input') 128 input.type = 'hidden' 129 input.name = 'query_items[]' 130 input.value = value 131 return input 132 })) 133 134 this.setCategories(values) 135 } 136 137 // Don't autocomplete manufacturers if a manufacturer is already selected, etc. 138 setCategories (values) { 139 const queriedCategories = values 140 .filter(value => /^(v|m)_/.test(value)) 141 .map(value => value.split('_')[0]) 142
143 let categories = '' 144 if (queriedCategories.length > 0) { 145 categories = 'colors' 146 147 if (!queriedCategories.includes('v')) categories += ',cycle_type' 148 if (!queriedCategories.includes('m')) categories += ',frame_mnfg,cmp_mnfg' 149 if (!queriedCategories.includes('p')) categories += ',propulsion' 150 } 151 152 if (categories === this.appliedCategories) return 153 this.appliedCategories = categories 154 155 window.searchBarCategories = categories 156 this.updateAsyncSrc(categories) 157 } 158 159 // Push the categories filter onto the combobox's async autocomplete URL 160 updateAsyncSrc (categories) { 161 if (!this.fieldElement) return 162 163 const attribute = 'data-hw-combobox-async-src-value' 164 const url = new URL(this.fieldElement.getAttribute(attribute), window.location.origin) 165 166 if (categories) { 167 url.searchParams.set('categories', categories) 168 } else { 169 url.searchParams.delete('categories') 170 } 171 172 this.fieldElement.setAttribute(attribute, url.pathname + url.search) 173 } 174 175 onEnterKey = (event) => { 176 if (event.key !== 'Enter') return 177 178 // Enter on an empty input submits the search 179 if (this.inputElement.value.trim() === '') { 180 event.preventDefault() 181 this.element.closest('form')?.requestSubmit() 182 return 183 } 184 185 // With a typed query, prefer a matching autocomplete option over free text. 186 // If nothing matches, fall through and let the combobox add it as free text. 187 const option = this.matchingOption() 188 if (option) { 189 event.preventDefault() 190 event.stopImmediatePropagation() 191 option.click() 192 } 193 } 194 195 // The active option if the user navigated to one, otherwise the first match 196 matchingOption () { 197 const query = this.inputElement.value.trim().toLowerCase() 198 const options = Array.from(this.element.querySelectorAll('.hw-combobox__option')) 199 .filter(option => !option.hidden && option.offsetParent !== null) 200 // The listbox can still show stale results from a previous query, so 201 // only consider options whose text actually contains what was typed 202 .filter(option => (option.getAttribute('data-autocompletable-as') || '').toLowerCase().includes(query)) 203 204 return options.find(option => option.getAttribute('aria-selected') === 'true') || options[0] 205 } 206};
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.