frutjam
Version:
A utility-first CSS UI Library for Tailwind CSS
102 lines (86 loc) • 3.22 kB
JavaScript
let _comboboxIdCounter = 0
export function createCombobox(el) {
const input = el.querySelector('input')
let activeIndex = -1
const uid = ++_comboboxIdCounter
const listbox = el.querySelector('[role="listbox"]') ?? el.querySelector('.combobox-dropdown') ?? (() => {
const found = el.querySelector('.combobox-option, [role="option"]')
return found ? found.parentElement : null
})()
if (listbox && !listbox.id) listbox.id = `fj-combobox-listbox-${uid}`
if (listbox && !listbox.getAttribute('role')) listbox.setAttribute('role', 'listbox')
if (input && listbox) {
input.setAttribute('aria-controls', listbox.id)
if (!input.getAttribute('role')) input.setAttribute('role', 'combobox')
if (!input.getAttribute('aria-autocomplete')) input.setAttribute('aria-autocomplete', 'list')
}
const allOptions = () => Array.from(el.querySelectorAll('[role="option"], .combobox-option'))
const visibleOptions = () => allOptions().filter((o) => !o.hidden)
function open() {
el.classList.add('combobox-open')
input?.setAttribute('aria-expanded', 'true')
}
function close() {
el.classList.remove('combobox-open')
input?.setAttribute('aria-expanded', 'false')
highlight(-1)
}
function toggle() {
el.classList.contains('combobox-open') ? close() : open()
}
function filter(query) {
allOptions().forEach((opt) => {
opt.hidden = !opt.textContent.toLowerCase().includes(query.toLowerCase())
})
highlight(-1)
}
function highlight(index) {
const opts = visibleOptions()
opts.forEach((o, i) => {
o.classList.remove('combobox-option-active')
if (!o.id) o.id = `fj-combobox-option-${uid}-${i}`
})
if (index >= 0 && opts[index]) {
opts[index].classList.add('combobox-option-active')
opts[index].scrollIntoView({ block: 'nearest' })
input?.setAttribute('aria-activedescendant', opts[index].id)
} else {
input?.removeAttribute('aria-activedescendant')
}
activeIndex = index
}
function select(opt) {
if (input) input.value = opt.textContent.trim()
el.dispatchEvent(new CustomEvent('fj:select', { detail: { value: opt.dataset.value ?? opt.textContent.trim() } }))
close()
}
input?.addEventListener('input', (e) => {
open()
filter(e.target.value)
})
input?.addEventListener('keydown', (e) => {
const opts = visibleOptions()
if (e.key === 'ArrowDown') {
e.preventDefault()
if (!el.classList.contains('combobox-open')) { open(); return }
highlight(Math.min(activeIndex + 1, opts.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
highlight(Math.max(activeIndex - 1, 0))
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault()
select(opts[activeIndex])
} else if (e.key === 'Escape') {
close()
}
})
el.addEventListener('click', (e) => {
const opt = e.target.closest('[role="option"], .combobox-option')
if (opt) { select(opt); return }
if (e.target === input) open()
})
document.addEventListener('click', (e) => {
if (!el.contains(e.target)) close()
}, { capture: true })
return { open, close, toggle, filter }
}