import type { Alpine, ElementWithXAttributes } from "alpinejs";
export default function (Alpine: Alpine) {
Alpine.directive('combobox', (el, directive) => {
if (directive.value === 'input') comboboxInput(el, Alpine)
else if (directive.value === 'label') comboboxLabel(el, Alpine)
else if (directive.value === 'list') comboboxList(el, Alpine)
else if (directive.value === 'item') comboboxListItem(el, Alpine)
else if (directive.value === 'values') comboboxValues(el, Alpine)
else if (directive.value === 'clear') comboboxClearButton(el, Alpine)
else if (directive.value === 'toggle') comboboxToggleButton(el, Alpine)
else comboboxRoot(el, Alpine)
Alpine.magic('combobox', el => {
const $data = Alpine.$data(el)
const comboboxRoot = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
return ['combobox-label', 'combobox-list', 'combobox-input']
this.$watch('isOpen', value => {
if (value && this.selectedEl) {
this.selectedEl.scrollIntoView();
if (!value) this.activeEl = undefined
listEl: undefined as HTMLElement | undefined,
inputEl: undefined as HTMLElement | undefined,
labelEl: undefined as HTMLElement | undefined,
selectedEl: undefined as HTMLElement | undefined,
activeEl: undefined as HTMLElement | undefined,
allOptions: [] as Array<{ label: string, value: string }>,
return this.selectedEl ? this.selectedEl.textContent : ''
return this.isDirty && !this.allOptions.some(o => o.label.toLowerCase().startsWith(this.inputValue.toLowerCase()))
select(el: HTMLElement) {
this.inputEl.value = el.textContent;
this.$focus.focus(this.inputEl);
this.selectedEl = undefined;
const el = this.$focus.within(this.listEl).getFirst()
const el = this.$focus.within(this.listEl).getLast()
const el = this.$focus.within(this.listEl).getNext()
this.$focus.focus(this.inputEl)
const el = this.$focus.within(this.listEl).getPrevious()
this.$focus.focus(this.inputEl)
this.activeEl = this.selectedEl;
this.$focus.focus(this.selectedEl)
// If typed value matches an option value, set it as selectedEl
const found = [...this.listEl.getElementsByTagName('LI')]
.find((li) => li.textContent === this.inputValue)
this.selectedEl = found ? found : this.selectedEl
this.inputEl.value = this.selectedValue
if (!el.contains(this.$event.relatedTarget)) {
const comboboxValues = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
this.allOptions = [...el.children].map((o) => ({ label: o.textContent, value: o.value }))
const comboboxInput = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
return this.$id('combobox-input')
':aria-activedescendant'() {
return this.activeEl ? this.activeEl.id : undefined
return this.$id('combobox-list')
return this.$id('combobox-list')
this.activeEl = undefined
this.inputValue = el.value;
'@keydown.prevent.up'() {
this.selectedEl ? this.focusSelected() : this.focusLast()
'@keydown.prevent.down'() {
this.selectedEl ? this.focusSelected() : this.focusFirst()
const comboboxList = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
return this.$id('combobox-list')
return this.$id('combobox-label')
'@keydown.prevent.up'() {
'@keydown.prevent.down'() {
'@keydown.prevent.home'() {
'@keydown.prevent.end'() {
'@keydown.prevent.shift.tab'() {
const comboboxClearButton = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
return !!this.selectedValue
this.$focus.focus(this.inputEl);
const comboboxToggleButton = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
this.isOpen = !this.isOpen;
this.$focus.focus(this.inputEl)
const comboboxListItem = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
return this.$id('combobox-item')
return el === this.selectedEl;
return el === this.activeEl;
return el === this.activeEl ? 0 : -1;
this.label = el.textContent
return this.isDirty ? this.$data.label.toLowerCase().startsWith(this.inputValue.toLowerCase()) : true
'@keydown.prevent.enter'() {
'@keydown.prevent.esc'() {
this.$focus.focus(this.inputEl)
const comboboxLabel = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
return this.$id('combobox-label')
return this.$id('combobox-input')