added floating ui for search dropdowns

This commit is contained in:
Chris
2025-12-08 16:24:22 -08:00
parent 9bef5c54c4
commit ecd753f4f2
3 changed files with 81 additions and 4 deletions

View File

@@ -1,4 +1,5 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
import { computePosition, autoUpdate, flip, shift, offset, size } from "@floating-ui/dom"
import { debounce } from "../../utils" import { debounce } from "../../utils"
/** /**
@@ -26,9 +27,9 @@ export default class extends Controller {
// Disable browser autocomplete // Disable browser autocomplete
this.input.setAttribute('autocomplete', 'off') this.input.setAttribute('autocomplete', 'off')
// Create dropdown // Create dropdown and append to the appropriate container
this.dropdown = this.createDropdown() this.dropdown = this.createDropdown()
this.element.appendChild(this.dropdown) this.getDropdownContainer().appendChild(this.dropdown)
// Bind search handler with debounce // Bind search handler with debounce
this.searchHandler = debounce(this.performSearch.bind(this), this.getDebounceDelay()) this.searchHandler = debounce(this.performSearch.bind(this), this.getDebounceDelay())
@@ -37,6 +38,8 @@ export default class extends Controller {
// Handle click outside to close dropdown // Handle click outside to close dropdown
this.clickOutsideHandler = this.handleClickOutside.bind(this) this.clickOutsideHandler = this.handleClickOutside.bind(this)
document.addEventListener('click', this.clickOutsideHandler) document.addEventListener('click', this.clickOutsideHandler)
this.cleanupAutoUpdate = null
} }
disconnect() { disconnect() {
@@ -44,14 +47,52 @@ export default class extends Controller {
this.input.removeEventListener('input', this.searchHandler) this.input.removeEventListener('input', this.searchHandler)
} }
document.removeEventListener('click', this.clickOutsideHandler) document.removeEventListener('click', this.clickOutsideHandler)
if (this.cleanupAutoUpdate) {
this.cleanupAutoUpdate()
}
if (this.dropdown && this.dropdown.parentNode) {
this.dropdown.parentNode.removeChild(this.dropdown)
}
} }
createDropdown() { createDropdown() {
const dropdown = document.createElement('ul') 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 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() { getInputElement() {
return this.element.querySelector('input') return this.element.querySelector('input')
} }
@@ -121,11 +162,26 @@ export default class extends Controller {
showDropdown() { showDropdown() {
this.dropdown.classList.remove('hidden') 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() { hideDropdown() {
this.dropdown.classList.add('hidden') this.dropdown.classList.add('hidden')
this.dropdown.innerHTML = '' this.dropdown.innerHTML = ''
// Clean up auto-update listener
if (this.cleanupAutoUpdate) {
this.cleanupAutoUpdate()
this.cleanupAutoUpdate = null
}
} }
showLoading() { showLoading() {
@@ -157,7 +213,7 @@ export default class extends Controller {
} }
handleClickOutside(event) { handleClickOutside(event) {
if (!this.element.contains(event.target)) { if (!this.element.contains(event.target) && !this.dropdown.contains(event.target)) {
this.hideDropdown() this.hideDropdown()
} }
} }

View File

@@ -13,6 +13,7 @@
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.1", "@codemirror/view": "^6.38.1",
"@floating-ui/dom": "^1.7.4",
"@gorails/ninja-keys": "^1.2.1", "@gorails/ninja-keys": "^1.2.1",
"@hotwired/stimulus": "^3.2.2", "@hotwired/stimulus": "^3.2.2",
"@hotwired/turbo-rails": "^8.0.20", "@hotwired/turbo-rails": "^8.0.20",

View File

@@ -217,6 +217,26 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz#168ab1c7e1c318b922637fad8f339d48b01e1244" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz#168ab1c7e1c318b922637fad8f339d48b01e1244"
integrity sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA== 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": "@gorails/ninja-keys@^1.2.1":
version "1.2.1" version "1.2.1"
resolved "https://registry.npmjs.org/@gorails/ninja-keys/-/ninja-keys-1.2.1.tgz" resolved "https://registry.npmjs.org/@gorails/ninja-keys/-/ninja-keys-1.2.1.tgz"