PageSourceSearch

https://railsblocks.com/assets/controllers/searchable_dropdown_con…17c21bc57e91713fec486e0a52765213712d6d3c.js

js railsblocks.com collected 2026-09-25 20:24:05 UTC 15,101 bytes, 477 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2
3export default class extends Controller {
4  static targets = ["input", "item", "results", "noResults"];
5  static values = {
6    placeholder: { type: String, default: "Search..." },
7    noResultsText: { type: String, default: "No results found" },
8  };
9
10  connect() {
11    this.originalItems = [...this.itemTargets];
12    this.selectedIndex = -1;
13    this.keyboardNavigation = false; // Track if user is navigating with keyboard
14
15    this.setupAccessibility();
16    this.bindEvents();
17    this.bindHoverEvents();
18  }
19
20  disconnect() {
21    this.unbindEvents();
22    this.unbindHoverEvents();
23  }
24
25  setupAccessibility() {
26    // Set up ARIA attributes for better accessibility
27    if (this.hasInputTarget) {
28      this.inputTarget.setAttribute("role", "combobox");
29      this.inputTarget.setAttribute("aria-expanded", "false");
30      this.inputTarget.setAttribute("aria-autocomplete", "list");
31      this.inputTarget.setAttribute("aria-haspopup", "listbox");
32
33      // Generate unique IDs if not present
34      if (!this.inputTarget.id) {
35        this.inputTarget.id = `searchable-dropdown-input-${Math.random().toString(36).substring(7)}`;
36      }
37    }
38
39    if (this.hasResultsTarget) {
40      this.resultsTarget.setAttribute("role", "listbox");
41      if (this.hasInputTarget) {
42        this.resultsTarget.setAttribute("aria-labelledby", this.inputTarget.id);
43      }
44    }
45
46    // Set up items with proper roles and IDs
47    this.itemTargets.forEach((item, index) => {
48      item.setAttribute("role", "option");
49      item.setAttribute("aria-selected", "false");
50      if (!item.id) {
51        item.id = `searchable-dropdown-option-${index}-${Math.random().toString(36).substring(7)}`;
52      }
53    });
54  }
55
56  bindEvents() {
57    // Bind regular event handlers (arrow functions don't need binding)
58    this.handleKeydown = this.handleKeydown.bind(this);
59    this.handleInputFocus = this.handleInputFocus.bind(this);
60
61    if (this.hasInputTarget) {
62      this.inputTarget.addEventListener("keydown", this.handleKeydown);
63      this.inputTarget.addEventListener("focus", this.handleInputFocus);
64    }
65  }
66
67  bindHoverEvents() {
68    this.itemTargets.forEach((item) => {
69      // Use capture phase to intercept events before menu controller
70      item.addEventListener("mouseenter", this.handleItemMouseEnter, true);
71      item.addEventListener("mouseleave", this.handleItemMouseLeave, true);
72      // Reset keyboard navigation when mouse moves
73      item.addEventListener("mousemove", this.handleMouseMove, true);
74    });
75
76    // Add mouse leave to the results container to clear selection when leaving all items
77    if (this.hasResultsTarget) {
78      this.resultsTarget.addEventListener("mouseleave", this.handleResultsMouseLeave, true);
79    }
80  }
81
82  unbindEvents() {
83    if (this.hasInputTarget) {
84      this.inputTarget.removeEventListener("keydown", this.handleKeydown);
85      this.inputTarget.removeEventListener("focus", this.handleInputFocus);
86    }
87  }
88
89  unbindHoverEvents() {
90    this.itemTargets.forEach((item) => {
91      item.removeEventListener("mouseenter", this.handleItemMouseEnter, true);
92      item.removeEventListener("mouseleave", this.handleItemMouseLeave, true);
93      item.removeEventListener("mousemove", this.handleMouseMove, true);
94    });
95
96    // Remove results container event listener
97    if (this.hasResultsTarget) {
98      this.resultsTarget.removeEventListener("mouseleave", this.handleResultsMouseLeave, true);
99    }
100  }
101
102  handleInputFocus() {
103    if (this.hasInputTarget) {
104      this.inputTarget.setAttribute("aria-expanded", "true");
105    }
106  }
107
108  handleItemMouseEnter = (event) => {
109    // Prevent the menu controller from handling this event
110    event.preventDefault();
111    event.stopPropagation();
112
113    // Don't interfere with keyboard navigation
114    if (this.keyboardNavigation) {
115      return;
116    }
117
118    const visibleItems = this.getVisibleItems();
119    const hoveredItem = event.currentTarget;
120
121    // Only update selection if the item is visible
122    if (!hoveredItem.classList.contains("hidden")) {
123      this.selectedIndex = visibleItems.indexOf(hoveredItem);
124      this.updateSelection(visibleItems);
125    }
126  };
127
128  handleItemMouseLeave = (event) => {
129    // Prevent the menu controller from handling this event
130    event.preventDefault();
131    event.stopPropagation();
132
133    // Keep the input focused when leaving an item
134    this.ensureInputFocus();
135  };
136
137  handleResultsMouseLeave = (event) => {
138    // During keyboard navigation, scrolling items under a stationary cursor can trigger mouseleave
139    // and we don't want that to clear the selection / "snap" the experience.
140    if (this.keyboardNavigation) {
141      return;
142    }
143
144    // Clear selection when mouse leaves the entire results area
145    this.clearSelection();
146    this.ensureInputFocus();
147  };
148
149  handleMouseMove = (event) => {
150    // Ignore mousemove events where the mouse didn't actually move.
151    // This happens when content scrolls under a stationary cursor (e.g., during keyboard navigation).
152    if (event.movementX === 0 && event.movementY === 0) return;
153
154    // Reset keyboard navigation flag when mouse moves
155    this.keyboardNavigation = false;
156  };
157
158  handleKeydown(event) {
159    const visibleItems = this.getVisibleItems();
160
161    switch (event.key) {
162      case "ArrowDown":
163        event.preventDefault();
164        this.navigateDown(visibleItems);
165        break;
166      case "ArrowUp":
167        event.preventDefault();
168        this.navigateUp(visibleItems);
169        break;
170      case "Enter":
171        event.preventDefault();
172        this.selectCurrentItem(visibleItems);
173        break;
174      case "Escape":
175        event.preventDefault();
176        this.closeDropdown();
177        break;
178      case "Tab":
179        this.clearSelection();
180        break;
181      case "Home":
182        event.preventDefault();
183        this.navigateToFirst(visibleItems);
184        break;
185      case "End":
186        event.preventDefault();
187        this.navigateToLast(visibleItems);
188        break;
189    }
190  }
191
192  search(event) {
193    // Reset keyboard navigation when user starts typing
194    this.keyboardNavigation = false;
195
196    const query = event.target.value.toLowerCase().trim();
197    let hasVisibleItems = false;
198
199    this.originalItems.forEach((item) => {
200      const searchText = item.dataset.searchableText || item.textContent.toLowerCase();
201      const matches = searchText.includes(query);
202
203      if (matches) {
204        item.classList.remove("hidden");
205        hasVisibleItems = true;
206      } else {
207        item.classList.add("hidden");
208      }
209    });
210
211    // Reset selection when searching
212    this.clearSelection();
213
214    // Show/hide no results message
215    this.toggleNoResults(!hasVisibleItems);
216
217    // Update ARIA live region for screen readers
218    this.announceResults(hasVisibleItems, query);
219
220    // Ensure input stays focused during search
221    this.ensureInputFocus();
222  }
223
224  getVisibleItems() {
225    return this.itemTargets.filter((item) => !item.classList.contains("hidden"));
226  }
227
228  navigateDown(visibleItems) {
229    if (visibleItems.length === 0) return;
230
231    this.keyboardNavigation = true;
232    this.selectedIndex = Math.min(this.selectedIndex + 1, visibleItems.length - 1);
233    this.updateSelection(visibleItems);
234  }
235
236  navigateUp(visibleItems) {
237    if (visibleItems.length === 0) return;
238
239    this.keyboardNavigation = true;
240    this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
241    this.updateSelection(visibleItems);
242  }
243
244  navigateToFirst(visibleItems) {
245    if (visibleItems.length === 0) return;
246
247    this.keyboardNavigation = true;
248    this.selectedIndex = 0;
249    this.updateSelection(visibleItems);
250  }
251
252  navigateToLast(visibleItems) {
253    if (visibleItems.length === 0) return;
254
255    this.keyboardNavigation = true;
256    this.selectedIndex = visibleItems.length - 1;
257    this.updateSelection(visibleItems);
258  }
259
260  updateSelection(visibleItems) {
261    // Clear all selections
262    this.itemTargets.forEach((item) => {
263      item.setAttribute("aria-selected", "false");
264      item.classList.remove("bg-neutral-100", "dark:bg-neutral-700/50");
265    });
266
267    // Update input aria-activedescendant
268    if (this.hasInputTarget) {
269      if (this.selectedIndex >= 0 && visibleItems[this.selectedIndex]) {
270        const selectedItem = visibleItems[this.selectedIndex];
271        this.inputTarget.setAttribute("aria-activedescendant", selectedItem.id);
272        selectedItem.setAttribute("aria-selected", "true");
273        selectedItem.classList.add("bg-neutral-100", "dark:bg-neutral-700/50");
274
275        // Only scroll item into view for keyboard navigation, not mouse hover
276        if (this.keyboardNavigation) {
277          this.scrollItemIntoView(selectedItem);
278        }
279
280        // Keep focus on input for continuous typing
281        this.inputTarget.focus();
282      } else {
283        this.inputTarget.removeAttribute("aria-activedescendant");
284        // Keep focus on input
285        this.inputTarget.focus();
286      }
287    }
288  }
289
290  scrollItemIntoView(item) {
291    if (this.hasResultsTarget) {
292      const container = this.resultsTarget;
293      const containerRect = container.getBoundingClientRect();
294      const itemRect = item.getBoundingClientRect();
295
296      // Calculate the item's position relative to the scrollable content
297      const itemRelativeTop = itemRect.top - containerRect.top;
298      const itemRelativeBottom = itemRelativeTop + itemRect.height;
299      const containerScrollTop = container.scrollTop;
300      const containerHeight = containerRect.height;
301
302      // Define padding: scroll when the item is within this distance from the edge
303      // This ensures we can see approximately one more item beyond the current selection
304      const scrollPadding = itemRect.height + 6;
305
306      // Check if we need to scroll down
307      if (itemRelativeBottom > containerHeight - scrollPadding) {
308        container.scrollTop = containerScrollTop + (itemRelativeBottom - containerHeight + scrollPadding);
309      }
310      // Check if we need to scroll up
311      else if (itemRelativeTop < scrollPadding) {
312        container.scrollTop = containerScrollTop + (itemRelativeTop - scrollPadding);
313      }
314    }
315  }
316
317  selectCurrentItem(visibleItems) {
318    if (this.selectedIndex >= 0 && visibleItems[this.selectedIndex]) {
319      const selectedItem = visibleItems[this.selectedIndex];
320      this.selectItem(selectedItem);
321    }
322  }
323
324  selectItem(item) {
325    // Dispatch custom event with selected item data
326    const selectEvent = new CustomEvent("searchable-dropdown:select", {
327      detail: {
328        item: item,
329        value: item.dataset.value || item.textContent.trim(),
330        text: item.textContent.trim(),
331      },
332      bubbles: true,
333    });
334
335    this.element.dispatchEvent(selectEvent);
336
337    // Optional: Update input with selected value
338    if (this.hasInputTarget) {
339      // You might want to clear the input or set it to the selected value
340      // this.inputTarget.value = item.textContent.trim()
341    }
342
343    // Close dropdown (this will be handled by the dropdown-popover controller)
344    this.closeDropdown();
345  }
346
347  closeDropdown() {
348    // Update aria-expanded before closing
349    if (this.hasInputTarget) {
350      this.inputTarget.setAttribute("aria-expanded", "false");
351    }
352
353    // Get the dropdown-popover controller and call its close method.
354    // The searchable controller can live on a menu that is portaled outside the
355    // dropdown wrapper, so find the owner by cached menu element when needed.
356    const dropdownController = this.#dropdownController();
357
358    if (dropdownController) {
359      dropdownController.close();
360    }
361
362    this.clearSelection();
363  }
364
365  clearSelection() {
366    this.selectedIndex = -1;
367    this.keyboardNavigation = false;
368    this.itemTargets.forEach((item) => {
369      item.setAttribute("aria-selected", "false");
370      item.classList.remove("bg-neutral-100", "dark:bg-neutral-700/50");
371    });
372
373    if (this.hasInputTarget) {
374      this.inputTarget.removeAttribute("aria-activedescendant");
375    }
376  }
377
378  toggleNoResults(show) {
379    if (this.hasNoResultsTarget) {
380      if (show) {
381        this.noResultsTarget.classList.remove("hidden");
382      } else {
383        this.noResultsTarget.classList.add("hidden");
384      }
385    }
386  }
387
388  announceResults(hasResults, query) {
389    // Create or update ARIA live region for screen reader announcements
390    let liveRegion = this.element.querySelector("[aria-live]");
391    if (!liveRegion) {
392      liveRegion = document.createElement("div");
393      liveRegion.setAttribute("aria-live", "polite");
394      liveRegion.setAttribute("aria-atomic", "true");
395      liveRegion.className = "sr-only";
396      this.element.appendChild(liveRegion);
397    }
398
399    if (query.length > 0) {
400      const visibleCount = this.getVisibleItems().length;
401      if (hasResults) {
402        liveRegion.textContent = `${visibleCount} ${visibleCount === 1 ? "result" : "results"} available`;
403      } else {
404        liveRegion.textContent = "No results found";
405      }
406    } else {
407      liveRegion.textContent = "";
408    }
409  }
410
411  // Action method for clicking on items
412  itemClick(event) {
413    event.preventDefault();
414    event.stopPropagation();
415    this.selectItem(event.currentTarget);
416  }
417
418  // Reset search when dropdown opens
419  reset() {
420    this.keyboardNavigation = false;
421
422    if (this.hasInputTarget) {
423      this.inputTarget.value = "";
424    }
425
426    // Show all items
427    this.originalItems.forEach((item) => {
428      item.classList.remove("hidden");
429    });
430
431    // Hide no results
432    this.toggleNoResults(false);
433
434    // Clear selection
435    this.clearSelection();
436
437    // Focus input and ensure it stays focused
438    this.ensureInputFocus();
439  }
440
441  // Ensure input maintains focus for continuous typing
442  ensureInputFocus() {
443    if (this.hasInputTarget) {
444      // Use setTimeout to ensure focus happens after any other focus changes
445      setTimeout(() => {
446        this.inputTarget.focus();
447      }, 0);
448    }
449  }
450
451  // Handle when new items are added (Stimulus target callbacks)
452  itemTargetConnected(element) {
453    // Bind hover events to new items with capture phase
454    element.addEventListener("mouseenter", this.handleItemMouseEnter, true);
455    element.addEventListener("mouseleave", this.handleItemMouseLeave, true);
456    element.addEventListener("mousemove", this.handleMouseMove, true);
457  }
458
459  itemTargetDisconnected(element) {
460    // Clean up hover events from removed items
461    element.removeEventListener("mouseenter", this.handleItemMouseEnter, true);
462    element.removeEventListener("mouseleave", this.handleItemMouseLeave, true);
463    element.removeEventListener("mousemove", this.handleMouseMove, true);
464  }
465
466  #dropdownController() {
467    const closestDropdown = this.element.closest('[data-controller~="dropdown-popover"]');
468    if (closestDropdown) {
469      const controller = this.application.getControllerForElementAndIdentifier(closestDropdown, "dropdown-popover");
470      if (controller) return controller;
471    }
472
473    return this.application.controllers.find(
474      (controller) => controller.identifier === "dropdown-popover" && controller.menuElement === this.element,
475    );
476  }
477};

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.