PageSourceSearch

https://bikeindex.org/assets/controllers/search/everything_combobo…5286e9f620418d5defea136c0743cd39b3b5ce46.js

js bikeindex.org collected 2026-10-01 10:24:50 UTC 8,184 bytes, 206 lines download raw bytes

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.