diff --git a/app/javascript/controllers/components/async_search_dropdown_controller.js b/app/javascript/controllers/components/async_search_dropdown_controller.js index 82d1abfe..68389ea9 100644 --- a/app/javascript/controllers/components/async_search_dropdown_controller.js +++ b/app/javascript/controllers/components/async_search_dropdown_controller.js @@ -1,4 +1,5 @@ import { Controller } from "@hotwired/stimulus" +import { computePosition, autoUpdate, flip, shift, offset, size } from "@floating-ui/dom" import { debounce } from "../../utils" /** @@ -26,9 +27,9 @@ export default class extends Controller { // Disable browser autocomplete this.input.setAttribute('autocomplete', 'off') - // Create dropdown + // Create dropdown and append to the appropriate container this.dropdown = this.createDropdown() - this.element.appendChild(this.dropdown) + this.getDropdownContainer().appendChild(this.dropdown) // Bind search handler with debounce this.searchHandler = debounce(this.performSearch.bind(this), this.getDebounceDelay()) @@ -37,6 +38,8 @@ export default class extends Controller { // Handle click outside to close dropdown this.clickOutsideHandler = this.handleClickOutside.bind(this) document.addEventListener('click', this.clickOutsideHandler) + + this.cleanupAutoUpdate = null } disconnect() { @@ -44,14 +47,52 @@ export default class extends Controller { this.input.removeEventListener('input', this.searchHandler) } document.removeEventListener('click', this.clickOutsideHandler) + if (this.cleanupAutoUpdate) { + this.cleanupAutoUpdate() + } + if (this.dropdown && this.dropdown.parentNode) { + this.dropdown.parentNode.removeChild(this.dropdown) + } } createDropdown() { const dropdown = document.createElement('ul') - dropdown.className = 'hidden absolute z-10 w-full mt-1 menu bg-neutral block rounded-box shadow-lg max-h-[300px] overflow-y-auto' + dropdown.className = 'hidden z-50 menu bg-neutral rounded-box shadow-lg max-h-[300px] overflow-y-auto' + dropdown.style.position = 'absolute' + dropdown.style.top = '0' + dropdown.style.left = '0' return dropdown } + getDropdownContainer() { + // Check if we're inside a modal and append there to maintain stacking context + const modal = this.element.closest('.modal, [role="dialog"], dialog') + return modal || document.body + } + + updatePosition() { + computePosition(this.input, this.dropdown, { + placement: 'bottom-start', + middleware: [ + offset(4), + flip({ fallbackPlacements: ['top-start'] }), + shift({ padding: 8 }), + size({ + apply({ rects, elements }) { + Object.assign(elements.floating.style, { + minWidth: `${rects.reference.width}px` + }) + } + }) + ] + }).then(({ x, y }) => { + Object.assign(this.dropdown.style, { + left: `${x}px`, + top: `${y}px` + }) + }) + } + getInputElement() { return this.element.querySelector('input') } @@ -121,11 +162,26 @@ export default class extends Controller { showDropdown() { this.dropdown.classList.remove('hidden') + this.updatePosition() + + // Set up auto-update to keep position in sync during scroll/resize + if (this.cleanupAutoUpdate) { + this.cleanupAutoUpdate() + } + this.cleanupAutoUpdate = autoUpdate(this.input, this.dropdown, () => { + this.updatePosition() + }) } hideDropdown() { this.dropdown.classList.add('hidden') this.dropdown.innerHTML = '' + + // Clean up auto-update listener + if (this.cleanupAutoUpdate) { + this.cleanupAutoUpdate() + this.cleanupAutoUpdate = null + } } showLoading() { @@ -157,7 +213,7 @@ export default class extends Controller { } handleClickOutside(event) { - if (!this.element.contains(event.target)) { + if (!this.element.contains(event.target) && !this.dropdown.contains(event.target)) { this.hideDropdown() } } diff --git a/package.json b/package.json index 779aed7a..2c38b23d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@codemirror/state": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.38.1", + "@floating-ui/dom": "^1.7.4", "@gorails/ninja-keys": "^1.2.1", "@hotwired/stimulus": "^3.2.2", "@hotwired/turbo-rails": "^8.0.20", diff --git a/yarn.lock b/yarn.lock index 8df07aee..dad22462 100644 --- a/yarn.lock +++ b/yarn.lock @@ -217,6 +217,26 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz#168ab1c7e1c318b922637fad8f339d48b01e1244" integrity sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA== +"@floating-ui/core@^1.7.3": + version "1.7.3" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.3.tgz#462d722f001e23e46d86fd2bd0d21b7693ccb8b7" + integrity sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w== + dependencies: + "@floating-ui/utils" "^0.2.10" + +"@floating-ui/dom@^1.7.4": + version "1.7.4" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.4.tgz#ee667549998745c9c3e3e84683b909c31d6c9a77" + integrity sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA== + dependencies: + "@floating-ui/core" "^1.7.3" + "@floating-ui/utils" "^0.2.10" + +"@floating-ui/utils@^0.2.10": + version "0.2.10" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c" + integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ== + "@gorails/ninja-keys@^1.2.1": version "1.2.1" resolved "https://registry.npmjs.org/@gorails/ninja-keys/-/ninja-keys-1.2.1.tgz"